blob: 88e59d831634c62b1308514c5e51afdf517bd604 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Per-shard request dispatch.
//!
//! Client-request queue plumbing, the transport / replica /
//! metadata-submit handler factories, the owner-forwarding helpers that
//! run consensus on shard 0, and the login/register, logout, and
//! non-replicated request handlers.
mod authz;
use crate::auth::{
complete_login_register, send_login_failure_reply, surface_login_failure,
verify_login_credentials, verify_pat_credentials,
};
use crate::bootstrap::{ServerNgShard, ServerNgShardHandle};
use crate::cluster_meta::ClusterRoster;
use crate::consumer_group::{
maybe_rewrite_consumer_group_request, maybe_rewrite_consumer_offset_request,
};
use crate::dispatch::authz::{
authorize_default_read, authorize_partition_op, authorize_partition_read, authorize_uid,
send_non_replicated_deny, send_partition_deny_reply,
};
use crate::login_register::LoginRegisterError;
use crate::pat::maybe_rewrite_pat_request;
use crate::responses::{
NonReplicatedResponse, build_consumer_offset_body, build_deny_reply, build_empty_reply,
build_get_me_response, build_get_personal_access_tokens_response,
build_non_replicated_response, build_polled_messages_body, build_raw_pat_reply,
connected_client_to_response, current_metadata_commit, resolve_partition_namespace,
resolve_partition_request_namespace,
};
use crate::session_manager::SessionManager;
use crate::snapshot;
use crate::users::maybe_rewrite_user_password_request;
use crate::wire::{request_body, usize_to_u32};
use bytes::Bytes;
use configs::server_ng::NgSystemConfig;
use consensus::{
Consensus, EvictionContext, MetadataHandle, PartitionsHandle, build_eviction_message,
build_incompatible_protocol_eviction_message, build_result_rejection_reply,
};
use iggy_binary_protocol::codes::{
GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE,
GET_ME_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE, GET_SNAPSHOT_FILE_CODE, LOGIN_USER_CODE,
LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE, POLL_MESSAGES_CODE, SYNC_CONSUMER_GROUP_CODE,
};
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
use iggy_binary_protocol::requests::consumer_offsets::{
GetConsumerOffsetRequest, StoreConsumerOffset2Request,
};
use iggy_binary_protocol::requests::messages::PollMessagesRequest;
use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
use iggy_binary_protocol::requests::system::get_snapshot::GetSnapshotRequest;
use iggy_binary_protocol::requests::users::{LoginRegisterRequest, LoginRegisterWithPatRequest};
use iggy_binary_protocol::responses::clients::client_response::ConsumerGroupInfoResponse;
use iggy_binary_protocol::responses::clients::get_client::ClientDetailsResponse;
use iggy_binary_protocol::responses::clients::get_clients::GetClientsResponse;
use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
use iggy_binary_protocol::responses::system::get_snapshot::GetSnapshotResponse;
use iggy_binary_protocol::{
AckLevel, ClientVersionInfo, Command2, EvictionReason, GenericHeader, HEADER_SIZE,
KIND_CONSUMER_GROUP, Operation, ProtocolVersion, RequestHeader, WireDecode, WireEncode,
WireIdentifier, is_protocol_compatible,
};
use iggy_common::{IggyError, PollingStrategy, SnapshotCompression, SystemSnapshotType};
use message_bus::client_listener::RequestHandler;
use message_bus::framing::MAX_MESSAGE_SIZE;
use message_bus::replica::listener::MessageHandler;
use message_bus::{AUTO_COMMIT_CLIENT_ID, IggyMessageBus, MessageBus};
use metadata::impls::metadata::{
MetadataSubmitError, StreamsFrontend, build_truncate_partition_client_message,
build_truncate_partition_client_message_with_identifiers,
};
use metadata::permissioner::Permissioner;
use partitions::{AutoCommitApplied, PollPlan, PollingArgs, PollingConsumer};
use secrecy::ExposeSecret;
use server_common::Message;
use server_common::sharding::IggyNamespace;
use shard::shards_table::ShardsTable;
use shard::{
ConnectedClientInfo, ListClientsHandler, PartitionRead, PartitionReadHandler,
PartitionReadReply,
};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
use std::sync::Arc;
use tracing::{debug, warn};
pub(crate) type ClientRequestQueues = Rc<RefCell<HashMap<u128, VecDeque<Message<GenericHeader>>>>>;
pub(crate) type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
pub(crate) fn make_client_request_handler(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
) -> RequestHandler {
let shard = Rc::clone(shard);
let sessions = Rc::clone(sessions);
let queues: ClientRequestQueues = Rc::new(RefCell::new(HashMap::new()));
let active: ActiveClientRequests = Rc::new(RefCell::new(HashSet::new()));
let sessions_for_disconnect = Rc::clone(&sessions);
let shard_for_disconnect = Rc::clone(&shard);
shard
.bus
.set_client_connection_lost_fn(Rc::new(move |client_id| {
if let Some((vsr_client_id, session)) = sessions_for_disconnect
.borrow_mut()
.remove_connection(client_id)
{
submit_disconnect_logout(Rc::clone(&shard_for_disconnect), vsr_client_id, session);
}
}));
Rc::new(move |client_id, message| {
enqueue_client_request(
Rc::clone(&shard),
Rc::clone(&sessions),
Arc::clone(&system_config),
Rc::clone(&queues),
Rc::clone(&active),
client_id,
message,
);
})
}
/// Build the per-shard [`ListClientsHandler`]: on a `ListClients`
/// broadcast, serialize this shard's locally-homed connected clients from
/// its `SessionManager` and push them back over the reply sender. The
/// aggregation across all shards happens in
/// [`shard::IggyShard::list_all_clients`].
pub(crate) fn make_list_clients_handler(
sessions: &Rc<RefCell<SessionManager>>,
) -> ListClientsHandler {
let sessions = Rc::clone(sessions);
Rc::new(move |reply| {
let clients: Vec<ConnectedClientInfo> = sessions.borrow().iter_clients().collect();
// Best-effort: the gather side bounds itself by count + timeout, so
// a dropped reply (receiver gone) just means this shard is omitted.
let _ = reply.try_send(clients);
})
}
/// Build the per-shard [`PartitionReadHandler`]: on a `PartitionRead` frame
/// (this shard owns the namespace), run the poll / consumer-offset lookup
/// against the local partitions plane and push the result back over the
/// carried reply sender. The requesting shard bounds the wait with a
/// timeout, so a dropped reply degrades to a client-visible read failure.
pub(crate) fn make_partition_read_handler(
shard_handle: &ServerNgShardHandle,
) -> PartitionReadHandler {
let shard_handle = Rc::clone(shard_handle);
// Runs synchronously on the shard pump (see `process_lifecycle` ->
// `on_partition_read`). `build_poll_snapshot` takes the partition borrow via
// `with_partition` (closure-scoped, debug `BorrowGuard`) and returns an owned
// `PollPlan`; only owned data crosses into `spawn_poll_io`. A fully-resident
// poll replies here without spawning. See the `poll_plan` module docs.
Rc::new(move |namespace, read, reply| {
let Some(shard) = upgrade_shard_handle(&shard_handle) else {
return;
};
let partitions = shard.plane.partitions();
match read {
PartitionRead::Poll { consumer, args } => {
match partitions.build_poll_snapshot(&namespace, consumer, &args) {
None => {
let _ = reply.try_send(PartitionReadReply::NotFound);
}
Some(plan) if plan.needs_off_pump_io() => {
spawn_poll_io(Rc::clone(&shard), namespace, plan, reply);
}
Some(plan) => {
let (fragments, current_offset, auto_commit) = plan.execute_resident();
if let Some(applied) = auto_commit {
submit_auto_commit(&shard, namespace, &applied);
}
let _ = reply.try_send(PartitionReadReply::Poll {
fragments,
current_offset,
});
}
}
}
PartitionRead::ConsumerOffset { consumer } => {
let result = match partitions.consumer_offset_read(&namespace, consumer) {
Some((stored, current_offset)) => PartitionReadReply::ConsumerOffset {
stored,
current_offset,
},
None => PartitionReadReply::NotFound,
};
let _ = reply.try_send(result);
}
PartitionRead::GroupOffsetState { group_id } => {
let result = match partitions.group_offset_state(&namespace, group_id) {
Some((last_polled, committed)) => PartitionReadReply::GroupOffsetState {
last_polled,
committed,
},
None => PartitionReadReply::NotFound,
};
let _ = reply.try_send(result);
}
PartitionRead::ClearGroupLastPolled { group_id } => {
let result = match partitions.clear_group_last_polled(&namespace, group_id) {
Some(()) => PartitionReadReply::Ack,
None => PartitionReadReply::NotFound,
};
let _ = reply.try_send(result);
}
PartitionRead::ResolveSegmentDeleteOffset { count } => {
let result = partitions
.segment_delete_resolution(&namespace, count)
.map_or_else(
|| PartitionReadReply::NotFound,
|(up_to_offset, lagging)| PartitionReadReply::SegmentDeleteOffset {
up_to_offset,
lagging,
},
);
let _ = reply.try_send(result);
}
}
})
}
/// Spawn the off-pump leg of a partition poll: disk read + auto-commit apply on
/// the OWNED plan (disk descriptors, resident-tail `Frozen` clones, `Arc` offset
/// map), then replicate the auto-committed offset and send the reply. Holds no
/// partition reference across the IO, so it is sound concurrently with the
/// pump's `&mut` writes; the auto-commit submit re-borrows synchronously after.
fn spawn_poll_io(
shard: Rc<ServerNgShard>,
namespace: IggyNamespace,
plan: PollPlan,
reply: shard::Sender<PartitionReadReply>,
) {
compio::runtime::spawn(async move {
let poll_started = std::time::Instant::now();
let (fragments, current_offset, auto_commit) = plan.execute().await;
let elapsed = poll_started.elapsed();
if elapsed > std::time::Duration::from_secs(1) {
warn!(
namespace_raw = namespace.inner(),
elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
"slow partition poll; gather side may have timed out"
);
}
// Fire-and-forget: the poll reply is not gated on the offset commit.
if let Some(applied) = auto_commit {
submit_auto_commit(&shard, namespace, &applied);
}
let _ = reply.try_send(PartitionReadReply::Poll {
fragments,
current_offset,
});
})
.detach();
}
/// Replicate a poll's auto-committed offset through the partition consensus so
/// it survives failover, mirroring the explicit `StoreConsumerOffset` path: the
/// same op code, submitted onto the owning shard's own pipeline. Best-effort and
/// fire-and-forget -- the poll reply never waits on it, and a full inbox drops
/// the op at WARN rather than backpressuring the reply.
///
/// The partition plane admits writes on the primary only (it asserts so), and a
/// poll is served on whichever node owns the namespace locally, which may be a
/// backup. So gate on primary status here and drop at WARN otherwise; auto-commit
/// is server-managed best-effort (at-least-once delivery), so a follower-served
/// poll simply does not advance the durable offset.
///
/// Coalescing: an offset the partition's committed high-water already covers is
/// dropped without a consensus op (the steady state for a re-poll of committed
/// data, hence no log). The gate reads committed state only, so an offset that
/// merely sits in flight keeps resubmitting until its covering op commits -- a
/// dropped op self-heals on the next poll instead of being suppressed forever.
fn submit_auto_commit(
shard: &Rc<ServerNgShard>,
namespace: IggyNamespace,
applied: &AutoCommitApplied,
) {
enum AutoCommitGate {
Submit,
Covered,
NotPrimary,
}
let gate = shard
.plane
.partitions()
.with_partition(&namespace, |partition| {
let consensus = partition.consensus();
if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_syncing()) {
AutoCommitGate::NotPrimary
} else if partition.is_auto_commit_offset_covered(
applied.kind,
applied.consumer_id,
applied.offset,
) {
AutoCommitGate::Covered
} else {
AutoCommitGate::Submit
}
});
match gate {
Some(AutoCommitGate::Submit) => {}
Some(AutoCommitGate::Covered) => return,
Some(AutoCommitGate::NotPrimary) | None => {
warn!(
namespace_raw = namespace.inner(),
"auto-commit offset not replicated: partition not primary on this node (best-effort)"
);
return;
}
}
let message = match build_auto_commit_request(namespace, applied) {
Ok(message) => message,
Err(error) => {
warn!(
namespace_raw = namespace.inner(),
error = %error,
"failed to build auto-commit store-offset request"
);
return;
}
};
// Routes by namespace to this same (owning, primary) shard's inbox; the pump
// admits it next turn exactly like a client store. `dispatch` never blocks.
shard.dispatch(message.into_generic());
}
/// Build the synthetic `StoreConsumerOffset2` request for an auto-commit, keyed
/// to the resolved numeric consumer/group id and stamped with the reserved
/// [`AUTO_COMMIT_CLIENT_ID`] so the commit path skips the (unwaited) reply. The
/// wire stream/topic ids are cosmetic here -- admission and apply key off the
/// header namespace and the consumer id -- but are set from the namespace for a
/// well-formed body. `ack` is `Quorum` so the offset actually replicates.
fn build_auto_commit_request(
namespace: IggyNamespace,
applied: &AutoCommitApplied,
) -> Result<Message<RequestHeader>, IggyError> {
let request = StoreConsumerOffset2Request {
consumer: WireConsumer {
kind: applied.kind.as_code(),
id: WireIdentifier::Numeric(applied.consumer_id),
},
stream_id: WireIdentifier::Numeric(usize_to_u32(namespace.stream_id())?),
topic_id: WireIdentifier::Numeric(usize_to_u32(namespace.topic_id())?),
partition_id: Some(usize_to_u32(namespace.partition_id())?),
offset: applied.offset,
ack: AckLevel::Quorum,
};
let body = request.to_bytes();
let header_size = std::mem::size_of::<RequestHeader>();
let total_size = header_size + body.len();
let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
let mut message = Message::<RequestHeader>::new(total_size);
message.as_mut_slice()[header_size..].copy_from_slice(&body);
Ok(message.transmute_header(|_, header: &mut RequestHeader| {
*header = RequestHeader {
command: Command2::Request,
operation: Operation::StoreConsumerOffset2,
size,
client: AUTO_COMMIT_CLIENT_ID,
// The partition plane is sessionless (no `ClientTable` dedup); a
// nonzero session + request just satisfy the wire header
// validation.
session: 1,
request: 1,
namespace: namespace.inner(),
..Default::default()
};
}))
}
pub(crate) fn make_deferred_replica_message_handler(
shard_handle: &ServerNgShardHandle,
) -> MessageHandler {
let shard_handle = Rc::clone(shard_handle);
Rc::new(move |_replica_id, message| {
if let Some(shard) = upgrade_shard_handle(&shard_handle) {
shard.dispatch(message);
}
})
}
pub(crate) fn make_deferred_client_request_handler(
bus: &Rc<IggyMessageBus>,
shard_handle: &ServerNgShardHandle,
sessions: &Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
) -> RequestHandler {
let shard_handle = Rc::clone(shard_handle);
let sessions = Rc::clone(sessions);
let queues: ClientRequestQueues = Rc::new(RefCell::new(HashMap::new()));
let active: ActiveClientRequests = Rc::new(RefCell::new(HashSet::new()));
let sessions_for_disconnect = Rc::clone(&sessions);
let shard_handle_for_disconnect = Rc::clone(&shard_handle);
bus.set_client_connection_lost_fn(Rc::new(move |client_id| {
if let Some((vsr_client_id, session)) = sessions_for_disconnect
.borrow_mut()
.remove_connection(client_id)
&& let Some(shard) = upgrade_shard_handle(&shard_handle_for_disconnect)
{
submit_disconnect_logout(shard, vsr_client_id, session);
}
}));
Rc::new(move |client_id, message| {
let shard_handle = Rc::clone(&shard_handle);
let sessions = Rc::clone(&sessions);
let system_config = Arc::clone(&system_config);
let queues = Rc::clone(&queues);
let active = Rc::clone(&active);
queues
.borrow_mut()
.entry(client_id)
.or_default()
.push_back(message);
if !active.borrow_mut().insert(client_id) {
return;
}
compio::runtime::spawn(async move {
let Some(shard) = upgrade_shard_handle(&shard_handle) else {
active.borrow_mut().remove(&client_id);
return;
};
drain_client_requests(shard, sessions, system_config, queues, active, client_id).await;
})
.detach();
})
}
/// Handler shard 0 runs for an inbound [`shard::MetadataSubmit`]: a peer
/// shard has verified credentials and owns the session locally, and asks
/// shard 0 (the metadata consensus owner) to run only the consensus
/// proposal. Spawns a task so the awaiting peer is woken once the op
/// commits; replies `None` on transient submit failure so the peer never
/// blocks forever.
pub(crate) fn make_metadata_submit_handler(
shard_handle: &ServerNgShardHandle,
) -> shard::MetadataSubmitHandler {
let shard_handle = Rc::clone(shard_handle);
Rc::new(move |submit| {
let shard_handle = Rc::clone(&shard_handle);
compio::runtime::spawn(async move {
let Some(shard) = upgrade_shard_handle(&shard_handle) else {
return;
};
match submit {
shard::MetadataSubmit::Register {
vsr_client_id,
user_id,
reply,
} => {
let session = shard
.plane
.metadata()
.submit_register_in_process(vsr_client_id, user_id)
.await
.ok();
let _ = reply.try_send(session);
}
shard::MetadataSubmit::Logout {
vsr_client_id,
session,
request,
reply,
} => {
let commit = shard
.plane
.metadata()
.submit_logout_in_process(vsr_client_id, session, request)
.await
.ok();
let _ = reply.try_send(commit);
}
shard::MetadataSubmit::ClientRequest { request, reply } => {
let committed = match request.try_into_typed::<RequestHeader>() {
Ok(typed) => shard
.plane
.metadata()
.submit_request_in_process(typed)
.await
.ok(),
Err(error) => {
warn!(?error, "ClientRequest submit: undecodable request header");
None
}
};
let _ = reply.try_send(committed);
}
shard::MetadataSubmit::CompleteRevocation {
stream_id,
topic_id,
group_id,
source_client_id,
partition_id,
reply,
} => {
let commit = shard
.plane
.metadata()
.submit_complete_revocation_in_process(
stream_id,
topic_id,
group_id,
source_client_id,
partition_id,
)
.await
.ok();
let _ = reply.try_send(commit);
}
}
})
.detach();
})
}
fn enqueue_client_request(
shard: Rc<ServerNgShard>,
sessions: Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
queues: ClientRequestQueues,
active: ActiveClientRequests,
client_id: u128,
message: Message<GenericHeader>,
) {
queues
.borrow_mut()
.entry(client_id)
.or_default()
.push_back(message);
if !active.borrow_mut().insert(client_id) {
return;
}
compio::runtime::spawn(async move {
drain_client_requests(shard, sessions, system_config, queues, active, client_id).await;
})
.detach();
}
#[allow(clippy::future_not_send)]
async fn drain_client_requests(
shard: Rc<ServerNgShard>,
sessions: Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
queues: ClientRequestQueues,
active: ActiveClientRequests,
client_id: u128,
) {
loop {
let Some(message) = pop_next_client_request(&queues, &active, client_id) else {
return;
};
handle_client_request(&shard, &sessions, &system_config, client_id, message).await;
}
}
fn pop_next_client_request(
queues: &ClientRequestQueues,
active: &ActiveClientRequests,
client_id: u128,
) -> Option<Message<GenericHeader>> {
let mut queues = queues.borrow_mut();
let Some(queue) = queues.get_mut(&client_id) else {
active.borrow_mut().remove(&client_id);
return None;
};
let message = queue.pop_front();
if queue.is_empty() {
queues.remove(&client_id);
}
if message.is_none() {
active.borrow_mut().remove(&client_id);
}
message
}
#[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn handle_client_request(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: &Arc<NgSystemConfig>,
transport_client_id: u128,
message: Message<iggy_binary_protocol::GenericHeader>,
) {
let request = match message.try_into_typed::<RequestHeader>() {
Ok(request) => request,
Err(error) => {
warn!(
transport_client_id,
error = %error,
"dropping client request with invalid header"
);
return;
}
};
ensure_transport_connection(shard, sessions, transport_client_id);
// Any request is liveness proof, not just PING: an idle-but-active client
// (e.g. an admin issuing reads between long sleeps) must not be evicted by
// the heartbeat verifier. A genuinely dead connection sends nothing, so the
// intended stale-client eviction still fires. No-ops for an unbound client.
sessions.borrow_mut().record_heartbeat(transport_client_id);
let header = *request.header();
if header.operation == Operation::NonReplicated {
// Auth bypass guard: only `PING` and `GET_CLUSTER_METADATA` are
// legitimately pre-auth (liveness probe + connection bootstrap
// metadata). Every other non-replicated code (`GET_STREAM*`,
// `GET_TOPIC*`, `GET_STATS`, `POLL_MESSAGES`) reads live state and
// MUST go through Register first, which binds the acting user the
// per-op authz gates resolve.
let nr_code = u32::from_le_bytes(request.header().reserved[..4].try_into().unwrap());
// Legacy (pre-register) login codes. server-ng authenticates only via
// the Register handshake (LOGIN_REGISTER / LOGIN_REGISTER_WITH_PAT,
// Operation::Register); the vsr SDK funnels both logins there and never
// emits these. Reject them uniformly with a typed MalformedLogin (the
// SDK maps it to InvalidFormat) before the session gate, so a legacy or
// foreign client fails fast instead of getting the misleading
// NoSession eviction the pre-auth guard would send unbound, or the
// silent empty-ok Reply the bound non-replicated path would send.
if matches!(
nr_code,
LOGIN_USER_CODE | LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE
) {
warn!(
transport_client_id,
code = nr_code,
"rejecting legacy login code; server-ng requires the register handshake"
);
send_login_eviction(
shard,
transport_client_id,
header.client,
EvictionReason::MalformedLogin,
)
.await;
return;
}
let allowed_pre_auth = matches!(nr_code, PING_CODE | GET_CLUSTER_METADATA_CODE);
if !allowed_pre_auth && sessions.borrow().get_session(transport_client_id).is_none() {
warn!(
transport_client_id,
code = nr_code,
"rejecting pre-auth non-replicated read with Eviction(NoSession)"
);
send_unauthenticated_eviction(shard, transport_client_id).await;
return;
}
handle_non_replicated_request(shard, sessions, system_config, transport_client_id, request)
.await;
return;
}
if header.operation == Operation::Register && header.session == 0 && header.request == 0 {
handle_login_register_request(shard, sessions, transport_client_id, request).await;
return;
}
if header.operation == Operation::Logout {
handle_logout_request(shard, sessions, transport_client_id, request).await;
return;
}
let bound = sessions.borrow().get_session(transport_client_id);
if bound.is_none() {
// Replicated request on an unbound transport. Without this short-
// circuit, the rewrite below overwrites `header.client` with
// `transport_client_id` and dispatches; the request_preflight then
// rejects with `NoSession`/`SessionMismatch` and the failure either
// disappears silently or emits an Eviction the SDK previously
// could not decode. Either way the SDK blocked until socket
// timeout. Emit an empty Reply so the SDK fails fast: the typed
// decoder downstream rejects the empty body with `InvalidCommand`
// instead of hanging.
let commit = current_metadata_commit(shard);
let reply = build_empty_reply(&header, transport_client_id, 0, commit);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"failed to surface unbound-session reply"
);
} else {
warn!(
transport_client_id,
operation = ?header.operation,
"dropping replicated request from unbound transport; replied empty"
);
}
return;
}
// DeleteSegments is neither a partition nor a metadata consensus op: the
// owning shard resolves the requested count to a concrete offset, then a
// `TruncatePartition` is replicated through metadata (Option A). Each
// replica's reconciler trims to the committed watermark. Handle it here,
// ahead of the partition/metadata routing below.
if header.operation == Operation::DeleteSegments {
handle_delete_segments_request(shard, transport_client_id, bound, &request).await;
return;
}
if header.operation.is_partition() {
// `bound` is Some here (unbound transports returned above).
let (vsr_client_id, bound_session) = bound.unwrap_or((0, 0));
// `get_session` discards the acting user id the partition gate needs;
// resolve it from the same bound connection. A bound transport always
// has one, but the gate fails closed on `None` rather than trust that.
let acting_user_id = sessions.borrow().get_user_id(transport_client_id);
dispatch_partition_request(
shard,
request,
vsr_client_id,
bound_session,
transport_client_id,
acting_user_id,
)
.await;
return;
}
let request = request.transmute_header(|header, new_header: &mut RequestHeader| {
*new_header = header;
// `bound` is always Some here (unbound transports early-return above);
// this sets the consensus client id + session for the replicated op.
if let Some((bound_client_id, bound_session)) = bound {
new_header.client = bound_client_id;
new_header.session = bound_session;
}
});
let (request, raw_pat_token) =
match maybe_rewrite_pat_request(sessions, transport_client_id, request) {
Ok(rewritten) => rewritten,
Err(error) => {
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"dropping request with invalid PAT replication context"
);
return;
}
};
// Hash raw passwords and, for ChangePassword, verify the current password
// on the primary before replication; see `crate::users`. Replicas store the
// hash directly. A wrong current password is not denied here: it rides
// consensus and applies as a committed InvalidCredentials no-op, so the only
// Err returned is a malformed body.
let request = match maybe_rewrite_user_password_request(shard, request) {
Ok(rewritten) => rewritten,
Err(error) => {
// Malformed body: deny fast with InvalidCommand. A silent drop
// would wedge every later request on the connection until the
// socket read timeout.
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"denying user password request"
);
let commit = current_metadata_commit(shard);
let reply = build_deny_reply(&header, transport_client_id, 0, commit, error.as_code());
if let Err(send_error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %send_error,
"failed to send password-deny reply"
);
}
return;
}
};
// Enrich consumer-group Join/Leave with the client's VSR id (+ topic
// partition count for Join) before replication; see `crate::consumer_group`.
let request = match maybe_rewrite_consumer_group_request(shard, request).await {
Ok(rewritten) => rewritten,
Err(error) => {
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"dropping consumer-group request with invalid payload"
);
return;
}
};
let request_header = *request.header();
// Replicated request: run consensus on the metadata owner (shard 0) and
// bring the committed reply back here. This shard owns the connection,
// so it writes the reply to the socket via the transport client id --
// shard 0 can't route by the consensus client id (no home-shard bits).
match submit_client_request_on_owner(shard, request).await {
Some(reply) => {
// The raw PAT token never enters consensus (it is non-deterministic
// and secret), so the committed reply body is empty. Substitute the
// raw-token response here, on the minting client's home shard, using
// the confirmed commit position from the committed reply.
let reply = match build_raw_pat_reply(&request_header, reply, raw_pat_token) {
Ok(reply) => reply,
Err(error) => {
warn!(
transport_client_id,
error = %error,
"failed to build raw PAT reply"
);
return;
}
};
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"failed to deliver committed reply to client"
);
}
}
None => {
// Transient submit failure (not primary / not caught up / dedup
// absorbed). Stay silent; the SDK read-timeout replays.
warn!(
transport_client_id,
operation = ?header.operation,
"replicated request not committed (transient); client will replay"
);
}
}
}
/// Per-user PATs, resolved from this shard's session (like `get_me`) and read
/// out of the Users STM. Built here rather than in `build_non_replicated_response`
/// which has no session context.
#[allow(clippy::future_not_send)]
async fn handle_get_personal_access_tokens(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RequestHeader>,
) {
let response = build_get_personal_access_tokens_response(shard, sessions, transport_client_id);
send_non_replicated_bytes(
shard,
request,
transport_client_id,
response.to_bytes(),
"get_personal_access_tokens",
)
.await;
}
/// The requesting connection's own identity, sourced from this shard's
/// `SessionManager` (not `IggyMetadata`), so built here rather than in
/// `build_non_replicated_response`.
#[allow(clippy::future_not_send)]
async fn handle_get_me(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RequestHeader>,
) {
let response = build_get_me_response(shard, sessions, transport_client_id);
send_non_replicated_bytes(
shard,
request,
transport_client_id,
response.to_bytes(),
"get_me",
)
.await;
}
/// Route a partition data-plane op (`SendMessages` / consumer-offset writes)
/// through the shard mesh by namespace: the op belongs to the partition's
/// own consensus group, not the metadata group. The owning shard's
/// partitions plane runs at-least-once consensus and replies directly via
/// `send_to_client`. `header.client` therefore stays the TRANSPORT id
/// (home-shard routing bits), not the VSR session id -- partition ops are
/// sessionless ("session lifecycle is metadata-only").
///
/// Callers must have authenticated the transport already: `vsr_client_id` /
/// `bound_session` come from its bound VSR session. Every failure before
/// dispatch replies empty so the client fails fast instead of wedging on a
/// silent drop.
///
/// `vsr_client_id` keys the consumer-group offset fence (the member id),
/// not the transport id stamped into the partition-op header.
#[allow(clippy::future_not_send)]
pub(crate) async fn dispatch_partition_request(
shard: &Rc<ServerNgShard>,
request: Message<RequestHeader>,
vsr_client_id: u128,
bound_session: u64,
transport_client_id: u128,
acting_user_id: Option<u32>,
) {
let header = *request.header();
let namespace = match resolve_partition_request_namespace(
shard,
header.operation,
request_body(&request),
vsr_client_id,
) {
Ok(namespace) => namespace,
Err(error) => {
// A partition op against a stream/topic that no longer
// resolves (e.g. a consumer's trailing auto-commit racing a
// `delete_stream`). A silent drop wedges the SDK connection
// forever; reply empty so the client fails fast instead.
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"partition request with unresolved namespace; replying empty"
);
send_empty_partition_reply(shard, transport_client_id, &header).await;
return;
}
};
// Dispatch-time RBAC. The partition plane is not replicated through the
// metadata STM, so the in-apply gate cannot cover it; authorize here, on
// the connection's own shard, before burning the routable wait or touching
// the plane. The namespace resolved above, so its stream/topic are the
// committed slab ids the permissioner keys on directly. A denial replies
// the op's frame with an empty body and a nonzero `status` the SDK peeks.
//
// Consistency: this reads THIS shard's local committed permissioner. On a
// peer shard that is a replicated read-mirror, so a permission revocation
// takes effect on the partition plane only once this shard applies the
// revoking commit -- an apply-lag window bounded by replication lag.
// Control-plane ops are exact (gated in-apply, in the same committed order
// on every replica); this local-read relaxation on the data plane is the
// accepted trade for keeping partition ops off the metadata consensus.
let scope = IggyNamespace::from_raw(namespace);
if let Some(status) = authorize_partition_op(
shard,
header.operation,
acting_user_id,
scope.stream_id(),
scope.topic_id(),
) {
warn!(
transport_client_id,
status,
operation = ?header.operation,
"partition request denied by authorization; replying with status"
);
send_partition_deny_reply(shard, transport_client_id, &header, status).await;
return;
}
// Convergence wait: a CreateTopic commit returns to the client
// before the per-shard reconcilers seed routing rows and
// materialise the partition (next wake/periodic tick). The SDK
// does not replay sends, so an immediately-following partition op
// would be dropped as unroutable. Absorb that window here with a
// bounded wait; steady-state sends (row present, partition probed
// once) skip it entirely.
if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
warn!(
transport_client_id,
namespace,
operation = ?header.operation,
"partition request not routable within budget; replying empty"
);
send_empty_partition_reply(shard, transport_client_id, &header).await;
return;
}
// A group consumer-offset op carries the group NAME on the wire; the
// partition plane keys the offset by the group's monotonic id (the same
// key the poll path auto-commits under and the read path resolves), so
// rewrite the consumer id before replication -- the apply layer has no
// metadata access to resolve it.
let request = match maybe_rewrite_consumer_offset_request(shard, request) {
Ok(rewritten) => rewritten,
Err(error) => {
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
"failed to rewrite consumer-offset request; replying empty"
);
send_empty_partition_reply(shard, transport_client_id, &header).await;
return;
}
};
let request = request.transmute_header(|header, new_header: &mut RequestHeader| {
*new_header = header;
new_header.namespace = namespace;
new_header.client = transport_client_id;
// Header validation requires `session > 0 && request > 0` for
// non-register ops. The partition plane itself is sessionless
// (at-least-once, no `ClientTable` dedup), so the bound VSR
// session merely satisfies validation, and a zero request id
// (the SDK does not number data-plane ops) is normalized.
new_header.session = bound_session;
new_header.request = new_header.request.max(1);
});
shard.dispatch(request.into_generic());
}
#[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn handle_non_replicated_request(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: &Arc<NgSystemConfig>,
transport_client_id: u128,
request: Message<RequestHeader>,
) {
const CODE_RANGE: std::ops::Range<usize> = 0..4;
let code = u32::from_le_bytes(request.header().reserved[CODE_RANGE].try_into().unwrap());
// Acting user for the read gates below, resolved once. `None` only on the
// pre-auth path (PING / GET_CLUSTER_METADATA), which serves ungated codes;
// the gated arms fail closed on it.
let user_id = sessions.borrow().get_user_id(transport_client_id);
match code {
PING_CODE => {
// A ping is the client's liveness proof; reset its staleness clock
// so the heartbeat verifier doesn't evict an active connection.
sessions.borrow_mut().record_heartbeat(transport_client_id);
let commit = current_metadata_commit(shard);
let reply = build_empty_reply(
request.header(),
request.header().client,
request.header().session,
commit,
);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"failed to send non-replicated ping reply"
);
}
}
GET_ME_CODE => {
handle_get_me(shard, sessions, transport_client_id, &request).await;
}
GET_PERSONAL_ACCESS_TOKENS_CODE => {
handle_get_personal_access_tokens(shard, sessions, transport_client_id, &request).await;
}
GET_CLIENTS_CODE => {
if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_clients) {
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
return;
}
// Shared-nothing: each shard knows only its own connections, so
// gather across all shards (scatter-gather over the mesh).
let infos = shard.list_all_clients().await;
let response = GetClientsResponse {
clients: infos
.iter()
.map(|info| connected_client_to_response(shard, info))
.collect(),
};
send_non_replicated_bytes(
shard,
&request,
transport_client_id,
response.to_bytes(),
"get_clients",
)
.await;
}
GET_CLIENT_CODE => {
if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_client) {
send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
.await;
return;
}
// No reverse map from the wire u32 id to a u128 transport id /
// home shard (the u32 is just the seq tail), so gather all and
// filter -- same fan-out as `get_clients`.
let target = GetClientRequest::decode_from(request_body(&request))
.ok()
.map(|req| req.client_id);
let infos = shard.list_all_clients().await;
#[allow(clippy::cast_possible_truncation)]
let found = target.and_then(|id| infos.iter().find(|info| info.client_id as u32 == id));
// The SDK decodes an empty body as `None` (client not found).
let bytes = found.map_or_else(Bytes::new, |info| {
let consumer_groups = info.vsr_client_id.map_or_else(Vec::new, |vsr_client_id| {
shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_memberships(vsr_client_id)
.into_iter()
.map(
|(stream_id, topic_id, group_id)| ConsumerGroupInfoResponse {
stream_id,
topic_id,
group_id,
},
)
.collect()
});
ClientDetailsResponse {
client: connected_client_to_response(shard, info),
consumer_groups,
}
.to_bytes()
});
send_non_replicated_bytes(shard, &request, transport_client_id, bytes, "get_client")
.await;
}
GET_SNAPSHOT_FILE_CODE => {
handle_get_snapshot(shard, system_config, transport_client_id, &request, user_id).await;
}
POLL_MESSAGES_CODE => {
handle_poll_messages(shard, transport_client_id, &request, user_id).await;
}
GET_CONSUMER_OFFSET_CODE => {
handle_get_consumer_offset(shard, transport_client_id, &request, user_id).await;
}
SYNC_CONSUMER_GROUP_CODE => {
// Self-scoped: serves the caller's own assignment keyed by the
// header client id, so it carries no permissioner rule.
handle_sync_consumer_group(shard, transport_client_id, &request).await;
}
_ => {
let roster = sessions.borrow().cluster_roster();
handle_default_non_replicated(
shard,
transport_client_id,
code,
&request,
user_id,
&roster,
)
.await;
}
}
}
#[allow(clippy::future_not_send)]
async fn handle_default_non_replicated(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
code: u32,
request: &Message<RequestHeader>,
user_id: Option<u32>,
roster: &ClusterRoster,
) {
// Gate by command code before the shared builder runs. The builder stays
// authz-free (it is byte-shared with the HTTP read path, which gates
// separately); a denial replies status!=0 with an empty body.
if let Err(error) = authorize_default_read(shard, code, request_body(request), user_id) {
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
return;
}
match build_non_replicated_response(shard, code, request_body(request), user_id, roster) {
Ok(response) => {
let commit = current_metadata_commit(shard);
let reply = response.into_reply(
request.header(),
request.header().client,
request.header().session,
commit,
);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
code,
error = %error,
"failed to send non-replicated VSR reply"
);
}
}
Err(error) => {
// Surface the builder's typed error (unsupported op, undecodable
// body, or a not-found parity read) on the same deny channel the
// authz gate uses; a silent drop would wedge the client until its
// read timeout.
warn!(
transport_client_id,
code,
error = %error,
"denying non-replicated VSR request"
);
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
}
}
}
/// Serve `GET_SNAPSHOT_FILE`: gate on the snapshot rule (`read_servers ||
/// manage_servers`, the legacy gate - the archive dumps host diagnostics, so
/// plain authentication must not suffice), then await the off-thread
/// collection (see `snapshot::collect`) and reply with the raw ZIP bytes.
#[allow(clippy::future_not_send)]
async fn handle_get_snapshot(
shard: &Rc<ServerNgShard>,
system_config: &Arc<NgSystemConfig>,
transport_client_id: u128,
request: &Message<RequestHeader>,
user_id: Option<u32>,
) {
if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_snapshot) {
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
return;
}
let result = match decode_get_snapshot(request_body(request)) {
Ok((compression, snapshot_types)) => {
snapshot::collect(Arc::clone(system_config), compression, snapshot_types).await
}
Err(error) => Err(error),
};
match result {
Ok(archive) => {
// The reply frames as `[256-byte header][archive]`. The client's
// `message_bus::read_message` rejects any frame past `MAX_MESSAGE_SIZE`
// (64 MiB) by tearing the connection down untyped, and a frame past
// `u32::MAX` would panic `build_reply_with_body`. The archive is the
// only unbounded non-replicated body, so refuse an oversized one with a
// typed error the SDK decodes. The HTTP path streams via `Body` (not
// this framing), so it stays uncapped.
let frame_size = HEADER_SIZE + archive.len();
if frame_size > MAX_MESSAGE_SIZE {
warn!(
transport_client_id,
frame_size,
max = MAX_MESSAGE_SIZE,
"snapshot archive exceeds the client frame limit; refusing to send"
);
send_non_replicated_deny(
shard,
request,
transport_client_id,
IggyError::SnapshotFileCompletionFailed.as_code(),
)
.await;
return;
}
send_non_replicated_bytes(
shard,
request,
transport_client_id,
GetSnapshotResponse { data: archive }.to_bytes(),
"get_snapshot",
)
.await;
}
Err(error) => {
warn!(transport_client_id, error = %error, "denying snapshot request");
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
}
}
}
fn decode_get_snapshot(
body: &[u8],
) -> Result<(SnapshotCompression, Vec<SystemSnapshotType>), IggyError> {
let request = GetSnapshotRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
let compression = SnapshotCompression::from_code(request.compression)?;
let snapshot_types = request
.snapshot_types
.iter()
.map(|&code| SystemSnapshotType::from_code(code))
.collect::<Result<Vec<_>, _>>()?;
Ok((compression, snapshot_types))
}
/// Send a non-replicated reply body to a client, stamping the current
/// metadata commit. Shared by the `get_me` / `get_clients` / `get_client`
/// arms.
#[allow(clippy::future_not_send)]
async fn send_non_replicated_bytes(
shard: &Rc<ServerNgShard>,
request: &Message<RequestHeader>,
transport_client_id: u128,
bytes: Bytes,
label: &'static str,
) {
let commit = current_metadata_commit(shard);
let reply = NonReplicatedResponse::Bytes(bytes).into_reply(
request.header(),
request.header().client,
request.header().session,
commit,
);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(transport_client_id, label, error = %error, "failed to send non-replicated reply");
}
}
/// Reject a pre-auth request with a typed `Eviction(NoSession)` frame.
///
/// The SDK's reply decoder maps eviction reasons to typed errors
/// (`NoSession` -> `Unauthenticated`), so clients fail fast with the same
/// error the legacy server returns instead of a body-decode failure. The
/// eviction context is best-effort off the metadata consensus (peer shards
/// have none; zeroes are cosmetic -- the SDK only reads the reason).
#[allow(clippy::future_not_send)]
async fn send_unauthenticated_eviction(shard: &Rc<ServerNgShard>, transport_client_id: u128) {
let ctx = shard.plane.metadata().consensus.as_ref().map_or(
consensus::EvictionContext {
cluster: 0,
view: 0,
replica: 0,
},
consensus::EvictionContext::from_consensus,
);
let eviction = consensus::build_eviction_message(
ctx,
transport_client_id,
iggy_binary_protocol::EvictionReason::NoSession,
);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, eviction.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"failed to send unauthenticated eviction"
);
}
}
/// Per-shard heartbeat verifier: evict connections that have not pinged within
/// `1.2 x interval`. Mirrors the legacy `verify_heartbeats` periodic task.
/// Eviction reuses the disconnect path (drops the client from its consumer
/// groups + rebalances via the replicated `Logout`) and sends a session-
/// terminal `Eviction(StaleClient)` so the client fails fast and can reconnect.
#[allow(clippy::future_not_send)]
pub(crate) async fn run_heartbeat_verifier(
shard: Rc<ServerNgShard>,
sessions: Rc<RefCell<SessionManager>>,
interval: std::time::Duration,
stop_rx: shard::Receiver<()>,
) {
// Legacy `MAX_THRESHOLD`: a client is stale once it misses ~1.2 intervals.
let max_age = interval.mul_f64(1.2);
loop {
if stop_rx.try_recv().is_ok() {
break;
}
let stale = sessions
.borrow()
.collect_stale(max_age, std::time::Instant::now());
for transport_client_id in stale {
// The heartbeat verifier exists to release a dead client's
// consumer-group membership (so the group rebalances off it). A
// connection that holds no membership has nothing for the eviction
// to clean up; reaping it would only drop a still-usable session
// (e.g. an idle admin connection that polls between long gaps),
// which the legacy server tolerates. The real transport-disconnect
// path still reaps it on socket close. So only evict a stale
// connection that is actually a group member.
let is_group_member = sessions
.borrow()
.bound_client_id(transport_client_id)
.is_some_and(|vsr_client_id| {
!shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_memberships(vsr_client_id)
.is_empty()
});
if is_group_member {
evict_stale_client(&shard, &sessions, transport_client_id).await;
}
}
compio::time::sleep(interval).await;
}
}
/// Evict one stale connection: drop its session (releasing consumer-group
/// membership through a replicated `Logout`) and notify the client with a
/// session-terminal `Eviction(StaleClient)`.
#[allow(clippy::future_not_send)]
async fn evict_stale_client(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) {
let bound = sessions.borrow_mut().remove_connection(transport_client_id);
if let Some((vsr_client_id, session)) = bound {
submit_disconnect_logout(Rc::clone(shard), vsr_client_id, session);
}
let ctx = shard.plane.metadata().consensus.as_ref().map_or(
consensus::EvictionContext {
cluster: 0,
view: 0,
replica: 0,
},
consensus::EvictionContext::from_consensus,
);
let eviction = consensus::build_eviction_message(
ctx,
transport_client_id,
iggy_binary_protocol::EvictionReason::StaleClient,
);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, eviction.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"failed to send stale-client eviction"
);
} else {
warn!(
transport_client_id,
"evicted stale client (missed heartbeat)"
);
}
}
/// Serve `poll_messages`: resolve the partition namespace, run the read on
/// the owning shard ([`shard::IggyShard::partition_read`]), and re-encode
/// the stored batches into the legacy wire `PolledMessages` body.
///
/// Failures reply with an empty body so the SDK fails fast on decode
/// instead of hanging until its read timeout.
#[allow(clippy::future_not_send)]
async fn handle_poll_messages(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
request: &Message<RequestHeader>,
user_id: Option<u32>,
) {
let Ok(wire) = PollMessagesRequest::decode_from(request_body(request)) else {
// Undecodable poll: keep the fail-fast empty-poll shape.
send_non_replicated_bytes(
shard,
request,
transport_client_id,
empty_polled_messages_body(0),
"poll_messages",
)
.await;
return;
};
// Gate on (stream, topic) before touching the partition plane. A resolution
// miss falls through to the resolve path below (empty-poll / not-found); a
// denial replies status!=0 with an empty body, distinct from the empty-poll
// "0 messages" shape.
if let Some(status) = authorize_partition_read(
shard,
&wire.stream_id,
&wire.topic_id,
user_id,
|permissioner, uid, stream_id, topic_id| {
permissioner.poll_messages(uid, stream_id, topic_id)
},
) {
send_non_replicated_deny(shard, request, transport_client_id, status).await;
return;
}
let body = match resolve_poll_request(shard, &wire, request.header().client) {
Ok((namespace, partition_id, consumer, args)) => {
match shard
.partition_read(namespace, PartitionRead::Poll { consumer, args })
.await
{
Some(PartitionReadReply::Poll {
fragments,
current_offset,
}) => build_polled_messages_body(
partition_id,
current_offset,
fragments,
shard.plane.partitions().config().encryptor.as_deref(),
)
.unwrap_or_else(|error| {
warn!(
transport_client_id,
error = %error,
"failed to re-encode polled batches; replying empty poll"
);
empty_polled_messages_body(partition_id)
}),
other => {
warn!(
transport_client_id,
namespace = namespace.inner(),
reply_was_none = other.is_none(),
"partition read failed; replying empty poll"
);
empty_polled_messages_body(partition_id)
}
}
}
Err(error) => {
// A zero-byte body would panic the SDK's `PolledMessages`
// decoder; reply the 16-byte empty-poll shape instead. A generation
// fence (the client's cached assignment is stale after a rebalance)
// carries the re-sync sentinel so the SDK re-syncs and retries
// rather than treating the empty poll as end-of-partition.
warn!(
transport_client_id,
error = %error,
"poll_messages request rejected; replying empty poll"
);
let partition_id = if matches!(error, IggyError::ConsumerGroupPartitionNotOwned(..)) {
iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL
} else {
0
};
empty_polled_messages_body(partition_id)
}
};
send_non_replicated_bytes(shard, request, transport_client_id, body, "poll_messages").await;
}
/// Serve `get_consumer_offset`. An empty body decodes as `None` on the SDK
/// side (no offset stored / partition unknown).
#[allow(clippy::future_not_send)]
async fn handle_get_consumer_offset(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
request: &Message<RequestHeader>,
user_id: Option<u32>,
) {
let Ok(wire) = GetConsumerOffsetRequest::decode_from(request_body(request)) else {
// Undecodable: an empty body decodes as None (no offset) on the SDK.
send_non_replicated_bytes(
shard,
request,
transport_client_id,
Bytes::new(),
"get_consumer_offset",
)
.await;
return;
};
if let Some(status) = authorize_partition_read(
shard,
&wire.stream_id,
&wire.topic_id,
user_id,
|permissioner, uid, stream_id, topic_id| {
permissioner.get_consumer_offset(uid, stream_id, topic_id)
},
) {
send_non_replicated_deny(shard, request, transport_client_id, status).await;
return;
}
let body = match resolve_consumer_offset_request(shard, &wire) {
Ok((namespace, partition_id, consumer)) => {
match shard
.partition_read(namespace, PartitionRead::ConsumerOffset { consumer })
.await
{
Some(PartitionReadReply::ConsumerOffset {
stored: Some(stored_offset),
current_offset,
}) => build_consumer_offset_body(partition_id, current_offset, stored_offset),
_ => Bytes::new(),
}
}
Err(error) => {
warn!(
transport_client_id,
error = %error,
"get_consumer_offset request rejected; replying empty"
);
Bytes::new()
}
};
send_non_replicated_bytes(
shard,
request,
transport_client_id,
body,
"get_consumer_offset",
)
.await;
}
/// Serve `SyncConsumerGroup`: return the requesting member's current partition
/// assignment + group generation so the client can select partitions locally.
/// The member is keyed by the connection's bound VSR client id
/// (`header().client`). An empty body decodes as "no assignment" on the SDK.
#[allow(clippy::future_not_send)]
async fn handle_sync_consumer_group(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
request: &Message<RequestHeader>,
) {
let body = match SyncConsumerGroupRequest::decode_from(request_body(request)) {
Ok(wire) => shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_member_assignment(
&wire.stream_id,
&wire.topic_id,
&wire.group_id,
request.header().client,
)
.map_or_else(Bytes::new, |(generation, partitions)| {
SyncConsumerGroupResponse {
generation,
partitions,
}
.to_bytes()
}),
Err(error) => {
warn!(
transport_client_id,
error = %error,
"sync_consumer_group request rejected; replying empty"
);
Bytes::new()
}
};
send_non_replicated_bytes(
shard,
request,
transport_client_id,
body,
"sync_consumer_group",
)
.await;
}
/// Ack a partition op that cannot be routed (unresolved or never-
/// materialised namespace) with an empty Reply. The SDK connection
/// processes replies in lockstep, so a silent drop wedges every
/// subsequent request on that connection.
#[allow(clippy::future_not_send)]
async fn send_empty_partition_reply(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
request_header: &RequestHeader,
) {
let commit = current_metadata_commit(shard);
let reply = build_empty_reply(request_header, transport_client_id, 0, commit);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
operation = ?request_header.operation,
"failed to surface empty partition reply"
);
}
}
/// Wait (bounded) until `namespace` is routable: this shard's routing row
/// exists and the owning shard answers a probe read (partition
/// materialised). Fast path: row already present -> no probe, no wait.
///
/// Covers the post-`CreateTopic` convergence window where the metadata
/// commit has returned to the client but the per-shard reconcilers have
/// not yet seeded routing rows / materialised partitions.
#[allow(clippy::future_not_send)]
async fn wait_for_partition_routable(shard: &Rc<ServerNgShard>, namespace: IggyNamespace) -> bool {
const ATTEMPT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
const BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
if shard.shards_table().shard_for(namespace).is_some() {
return true;
}
let deadline = std::time::Instant::now() + BUDGET;
while shard.shards_table().shard_for(namespace).is_none() {
if std::time::Instant::now() >= deadline {
return false;
}
compio::time::sleep(ATTEMPT_DELAY).await;
}
// The local row is seeded by THIS shard's reconciler; the owner
// materialises the partition on its own pass. Probe with a cheap read
// until the owner answers, so the write below normally clears the
// owner's "partition not initialized" guard. Not a hard guarantee: the
// partition can de-materialise between this probe and the dispatch, but
// the park/tombstone path re-checks and the client retries.
while std::time::Instant::now() < deadline {
match shard
.partition_read(
namespace,
PartitionRead::ConsumerOffset {
consumer: PollingConsumer::Consumer(0, 0),
},
)
.await
{
Some(PartitionReadReply::NotFound) | None => {
compio::time::sleep(ATTEMPT_DELAY).await;
}
Some(_) => return true,
}
}
false
}
/// The 16-byte `PolledMessages` body with zero messages
/// (`[partition_id:4][current_offset:8][count:4]`). The SDK decoder
/// requires at least this header, so failure paths must never reply a
/// zero-byte body.
fn empty_polled_messages_body(partition_id: u32) -> Bytes {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&partition_id.to_le_bytes());
body.extend_from_slice(&0u64.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
Bytes::from(body)
}
pub(crate) type DecodedPollRequest = (IggyNamespace, u32, PollingConsumer, PollingArgs);
/// Resolve a decoded poll request into its owning-shard read: namespace,
/// partition, polling consumer, and args. Shared by the TCP dispatch (client
/// id = the connection's bound VSR client) and the HTTP route (client id 0,
/// which fences group polls closed).
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn resolve_poll_request(
shard: &Rc<ServerNgShard>,
wire: &PollMessagesRequest,
client_id: u128,
) -> Result<DecodedPollRequest, IggyError> {
let strategy = polling_strategy_from_wire(&wire.strategy)?;
let args = PollingArgs::new(strategy, wire.count, wire.auto_commit);
// Consumer-group poll: the client selects which of its assigned partitions
// to read and sends it explicitly. The coordinator FENCES ownership (a stale
// client whose partition was reassigned is rejected with
// `ConsumerGroupPartitionNotOwned`, prompting a re-sync) and resolves the
// group's monotonic id -- the offset key the store rewrite and read path
// both use, so `next()` reads back the offset it just committed.
if wire.consumer.kind == KIND_CONSUMER_GROUP {
let partition_id = wire.partition_id.ok_or(IggyError::InvalidIdentifier)?;
let group_id = shard
.plane
.metadata()
.mux_stm
.streams()
.consumer_group_fence(
&wire.stream_id,
&wire.topic_id,
&wire.consumer.id,
client_id,
partition_id,
// Poll fence: reject a pending-revoked partition so the source
// re-syncs and skips it (it still commits it via the offset fence).
true,
)
.ok_or(IggyError::ConsumerGroupPartitionNotOwned(
client_id as u32,
partition_id,
))?;
let namespace = resolve_partition_namespace(
shard,
&wire.stream_id,
&wire.topic_id,
Some(partition_id),
)?;
#[allow(clippy::cast_possible_truncation)]
let consumer = PollingConsumer::ConsumerGroup(group_id as usize, partition_id as usize);
return Ok((namespace, partition_id, consumer, args));
}
// Plain-consumer poll: an omitted partition selects partition 0, matching
// the legacy resolver (`resolve_consumer_with_partition_id` uses
// `unwrap_or(0)` for `ConsumerKind::Consumer`).
let partition_id = wire.partition_id.unwrap_or(0);
let namespace =
resolve_partition_namespace(shard, &wire.stream_id, &wire.topic_id, Some(partition_id))?;
let consumer = polling_consumer_from_wire(&wire.consumer, partition_id)?;
Ok((namespace, partition_id, consumer, args))
}
/// Resolve a decoded consumer-offset read into its owning-shard read:
/// namespace, partition, and polling consumer. Shared by the TCP dispatch and
/// the HTTP route; needs no client id because offset reads are not fenced
/// (any client may read a group's offset, member or not).
pub(crate) fn resolve_consumer_offset_request(
shard: &Rc<ServerNgShard>,
wire: &GetConsumerOffsetRequest,
) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError> {
// Omitted partition reads partition 0, matching the legacy resolver for
// both consumer kinds (`unwrap_or(0)`).
let partition_id = wire.partition_id.unwrap_or(0);
let namespace =
resolve_partition_namespace(shard, &wire.stream_id, &wire.topic_id, Some(partition_id))?;
// A group offset is keyed by the group's monotonic id (any client may read
// it, member or not), the same key the write path is rewritten to. An
// unresolved group (e.g. deleted) has no offset, so the read reports None.
let consumer = if wire.consumer.kind == KIND_CONSUMER_GROUP {
let group_id = shard
.plane
.metadata()
.mux_stm
.streams()
.resolve_consumer_group_id(&wire.stream_id, &wire.topic_id, &wire.consumer.id)
.ok_or(IggyError::InvalidIdentifier)?;
#[allow(clippy::cast_possible_truncation)]
PollingConsumer::ConsumerGroup(group_id as usize, partition_id as usize)
} else {
polling_consumer_from_wire(&wire.consumer, partition_id)?
};
Ok((namespace, partition_id, consumer))
}
fn polling_consumer_from_wire(
consumer: &WireConsumer,
partition_id: u32,
) -> Result<PollingConsumer, IggyError> {
// Mirrors the legacy server's `PollingConsumer::resolve_consumer_id`:
// numeric ids pass through, named consumers hash to a stable u32 so
// reads derive the same offset-table key the write path stores under.
let consumer_id = match &consumer.id {
iggy_binary_protocol::WireIdentifier::Numeric(id) => *id,
iggy_binary_protocol::WireIdentifier::String(name) => {
iggy_common::calculate_32(name.as_str().as_bytes())
}
} as usize;
match consumer.kind {
1 => Ok(PollingConsumer::Consumer(
consumer_id,
partition_id as usize,
)),
KIND_CONSUMER_GROUP => Ok(PollingConsumer::ConsumerGroup(
consumer_id,
partition_id as usize,
)),
_ => Err(IggyError::InvalidCommand),
}
}
fn polling_strategy_from_wire(
strategy: &WirePollingStrategy,
) -> Result<PollingStrategy, IggyError> {
let mut mapped = match strategy.kind {
1 => PollingStrategy::offset(0),
2 => PollingStrategy::timestamp(iggy_common::IggyTimestamp::from(strategy.value)),
3 => PollingStrategy::first(),
4 => PollingStrategy::last(),
5 => PollingStrategy::next(),
_ => return Err(IggyError::InvalidCommand),
};
mapped.set_value(strategy.value);
Ok(mapped)
}
/// Run the consensus `Register` proposal on the metadata owner (shard 0)
/// and return the committed session.
///
/// Credential verification and session binding stay on the calling (home)
/// shard -- only this consensus step must execute where the metadata
/// consensus group lives. On shard 0 it calls in-process directly; on a
/// peer it forwards a [`shard::MetadataSubmit`] to shard 0 and awaits the
/// committed op. A dropped reply (shard-0 inbox full / shutdown) maps to a
/// transient `Canceled`, which the caller wraps so the SDK replays.
#[allow(clippy::future_not_send)]
pub(crate) async fn submit_register_on_owner(
shard: &Rc<ServerNgShard>,
vsr_client_id: u128,
user_id: u32,
) -> Result<u64, MetadataSubmitError> {
if shard.id == 0 {
return shard
.plane
.metadata()
.submit_register_in_process(vsr_client_id, user_id)
.await;
}
let (reply, rx) = shard::channel::<Option<u64>>(1);
shard.forward_metadata_submit(shard::MetadataSubmit::Register {
vsr_client_id,
user_id,
reply,
});
match rx.recv().await {
Ok(Some(session)) => Ok(session),
_ => Err(MetadataSubmitError::Canceled),
}
}
/// Logout counterpart of [`submit_register_on_owner`].
#[allow(clippy::future_not_send)]
pub(crate) async fn submit_logout_on_owner(
shard: &Rc<ServerNgShard>,
vsr_client_id: u128,
session: u64,
request: u64,
) -> Result<u64, MetadataSubmitError> {
if shard.id == 0 {
return shard
.plane
.metadata()
.submit_logout_in_process(vsr_client_id, session, request)
.await;
}
let (reply, rx) = shard::channel::<Option<u64>>(1);
shard.forward_metadata_submit(shard::MetadataSubmit::Logout {
vsr_client_id,
session,
request,
reply,
});
match rx.recv().await {
Ok(Some(commit)) => Ok(commit),
_ => Err(MetadataSubmitError::Canceled),
}
}
/// Handle a client `DeleteSegments`: resolve the requested count to an offset
/// on the owning shard, replicate a `TruncatePartition` through metadata so
/// every replica trims to the same watermark, then ack the client. The local
/// deletion happens later, when each replica's reconciler observes the commit.
///
/// The consensus reply is forwarded verbatim: nothing-to-delete commits a
/// no-op `TruncatePartition(0)` and acks, while a not-primary rejection
/// reaches the client as `TransientNotCommitted` so the SDK replays instead
/// of mistaking a dropped delete for success. Only a malformed / unresolvable
/// request is acked empty without a commit.
#[allow(clippy::future_not_send)]
async fn handle_delete_segments_request(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
bound: Option<(u128, u64)>,
request: &Message<RequestHeader>,
) {
let header = *request.header();
let body = request_body(request);
// An unbound transport cannot be attributed a VSR request sequence; the
// outer handler already short-circuits these, so this is defensive.
let Some((vsr_client_id, session)) = bound else {
return;
};
// The client numbers DeleteSegments in the same monotonic request sequence
// as every other metadata op. So resolve the requested count to a concrete
// offset on the owning shard, then replicate a `TruncatePartition(offset)`
// AS the client's own request through the standard owner path: the commit
// records (client, session, request) in the `ClientTable` on every replica,
// keeping the sequence contiguous. Skipping the commit (or attributing it
// to an internal id) leaves a hole that fails the next metadata op's
// `request == committed + 1` preflight -> RequestGap -> silent drop -> the
// SDK blocks until timeout. A no-op delete still commits `up_to_offset = 0`
// (monotonic apply) for the same reason.
let truncate = match resolve_delete_segments_truncate(
shard,
&header,
vsr_client_id,
session,
body,
)
.await
{
Ok(truncate) => Some(truncate),
// The owning partition has not converged on the committed log yet, so
// the delete cannot be resolved to a watermark. Reply with the
// result-framed transient rejection (under the TruncatePartition
// operation, which the SDK decodes) so the client replays the same
// request once the partition catches up. Nothing was submitted, hence
// the re-issuable-anywhere flavor.
Err(IggyError::TransientNotAccepted) => {
let template = build_truncate_partition_client_message(
&header,
vsr_client_id,
session,
0,
0,
0,
0,
);
let reply = build_result_rejection_reply(
template.header(),
current_metadata_commit(shard),
IggyError::TransientNotAccepted.as_code(),
);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"delete_segments: failed to send transient rejection"
);
}
return;
}
Err(_) => None,
};
let reply = if let Some(truncate) = truncate {
// Forward the consensus reply verbatim, exactly like the generic
// metadata path: a committed success acks the delete, and a
// result-framed `TransientNotCommitted` rejection makes the SDK
// replay the request. Acking unconditionally here would swallow a
// not-primary rejection and drop the delete on the floor while the
// client believes it succeeded.
let Some(reply) = submit_client_request_on_owner(shard, truncate).await else {
// Transient submit failure (not primary / view change). Stay
// silent; the SDK read-timeout replays the same request id,
// which re-resolves and commits. Acking here would advance the
// client past an unrecorded request and gap the next metadata
// op.
warn!(
transport_client_id,
"delete_segments: transient submit; client will replay"
);
return;
};
reply
} else {
// Undecodable body (never produced by the SDK): ack empty so the
// lockstep stream stays framed; the typed decoder surfaces the
// failure client-side. Unresolvable-but-well-formed targets commit a
// typed rejection instead (see the resolve), so only a wire-corrupt
// request can gap the sequence here.
let commit = current_metadata_commit(shard);
build_empty_reply(&header, transport_client_id, session, commit).into_generic()
};
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"delete_segments: failed to send reply"
);
}
}
/// Resolve a client `DeleteSegments` to the `TruncatePartition` that commits the
/// trim. Shared by the TCP dispatch and the HTTP listener so both resolve the
/// requested segment count to a concrete watermark identically.
///
/// `template` supplies the wire `cluster` / `view` / `release` and the client's
/// `request` number; `client_id` / `session` are the bound VSR identity the
/// truncate commits under. A resolvable namespace with nothing sealed to delete
/// still yields a `TruncatePartition(up_to_offset = 0)` so the metadata request
/// sequence stays contiguous. `Err` on a malformed body or an unresolved
/// namespace: the TCP caller drops it to a silent replay, the HTTP caller renders
/// the error.
#[allow(clippy::future_not_send)]
#[allow(clippy::cast_possible_truncation)]
pub(crate) async fn resolve_delete_segments_truncate(
shard: &Rc<ServerNgShard>,
template: &RequestHeader,
client_id: u128,
session: u64,
body: &[u8],
) -> Result<Message<RequestHeader>, IggyError> {
let parsed = DeleteSegmentsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
let namespace_raw = match resolve_partition_request_namespace(
shard,
Operation::DeleteSegments,
body,
client_id,
) {
Ok(namespace_raw) => namespace_raw,
// Unresolvable stream/topic: still commit the truncate, against the
// client's raw identifiers -- the apply rejects it as a committed
// result. That keeps the request sequence contiguous (an empty ack
// without a commit gaps `request == committed + 1` and silently
// drops the NEXT metadata op) while the client gets the typed error.
Err(error) => {
debug!(
client_id,
%error,
"delete_segments: unresolved target; committing typed rejection"
);
return Ok(build_truncate_partition_client_message_with_identifiers(
template,
client_id,
session,
parsed.stream_id,
parsed.topic_id,
parsed.partition_id,
0,
));
}
};
let namespace = IggyNamespace::from_raw(namespace_raw);
let up_to_offset = match shard
.partition_read(
namespace,
PartitionRead::ResolveSegmentDeleteOffset {
count: parsed.segments_count,
},
)
.await
{
Some(PartitionReadReply::SegmentDeleteOffset {
up_to_offset: Some(offset),
..
}) => offset,
// Nothing sealed to delete on a replica that has not converged on the
// replicated log (a backup behind the commit frontier may be missing
// whole sealed segments). Answering now would commit a no-op truncate
// and silently drop the delete, so surface a transient and let the
// client replay once the partition catches up. A converged primary
// whose resident tail is merely unflushed settles as a no-op below.
Some(PartitionReadReply::SegmentDeleteOffset {
up_to_offset: None,
lagging: true,
}) => {
debug!(
client_id,
namespace_raw, "delete_segments: partition not converged; transient"
);
return Err(IggyError::TransientNotAccepted);
}
other => {
debug!(
client_id,
namespace_raw,
reply = ?other,
"delete_segments: nothing to delete; committing no-op truncate"
);
0
}
};
Ok(build_truncate_partition_client_message(
template,
client_id,
session,
namespace.stream_id() as u32,
namespace.topic_id() as u32,
namespace.partition_id() as u32,
up_to_offset,
))
}
/// Disconnect cleanup: the local `SessionManager` connection is already
/// dropped by the caller; this submits a session-matched `Logout` so the
/// committed apply releases the `ClientTable` slot on every replica (shard 0
/// included, since shard 0 is itself a replica).
///
/// Deliberately does NOT drop the local `ClientTable` slot first:
/// `submit_logout_*` short-circuits when the slot is already gone, so a
/// pre-emptive local removal would suppress the `Logout` and leave peer
/// replicas with an orphaned session until they evict it themselves -- the
/// exact divergence this avoids. `submit_logout_on_owner` runs in-process on
/// shard 0 and forwards for peer-homed connections; its session guard drops a
/// stale logout for a reused client id.
#[allow(clippy::future_not_send)]
fn submit_disconnect_logout(shard: Rc<ServerNgShard>, vsr_client_id: u128, session: u64) {
// Synthetic request id: header validation rejects `request == 0` for
// non-register ops, and a disconnect has no client-issued request id.
// The logout apply keys on (client, session) only, so any non-zero id
// is valid here.
const DISCONNECT_LOGOUT_REQUEST_ID: u64 = u64::MAX;
compio::runtime::spawn(async move {
if let Err(error) =
submit_logout_on_owner(&shard, vsr_client_id, session, DISCONNECT_LOGOUT_REQUEST_ID)
.await
{
warn!(
vsr_client_id,
?error,
"disconnect logout submit failed; peer slots may linger until eviction"
);
}
})
.detach();
}
/// Submit a replicated client request to the metadata owner (shard 0) and
/// return the committed reply.
///
/// The metadata consensus group lives on shard 0, but the connection lives
/// on the home shard (this shard). Run consensus where it belongs and bring
/// the committed reply back here so the caller can write it to the
/// originating socket -- shard 0 cannot route the reply by the consensus
/// `client` id (it's the VSR id, not the transport/home-shard-encoding id).
/// `None` = transient submit failure (SDK read-timeout replays).
#[allow(clippy::future_not_send)]
pub(crate) async fn submit_client_request_on_owner(
shard: &Rc<ServerNgShard>,
request: Message<RequestHeader>,
) -> Option<Message<GenericHeader>> {
if shard.id == 0 {
return shard
.plane
.metadata()
.submit_request_in_process(request)
.await
.ok();
}
let (reply, rx) = shard::channel::<Option<Message<GenericHeader>>>(1);
shard.forward_metadata_submit(shard::MetadataSubmit::ClientRequest {
request: request.into_generic(),
reply,
});
rx.recv().await.ok().flatten()
}
#[allow(clippy::future_not_send)]
async fn handle_logout_request(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: Message<RequestHeader>,
) {
let Some((vsr_client_id, session)) = sessions.borrow().get_session(transport_client_id) else {
warn!(
transport_client_id,
"dropping logout for unbound VSR session"
);
return;
};
let request_id = request.header().request;
let commit = match submit_logout_on_owner(shard, vsr_client_id, session, request_id).await {
Ok(commit) => commit,
Err(error) => {
warn!(transport_client_id, error = %error, "logout/unregister failed");
return;
}
};
sessions.borrow_mut().remove_connection(transport_client_id);
let reply = build_empty_reply(request.header(), vsr_client_id, session, commit);
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, reply.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
"failed to send logout reply"
);
}
}
fn ensure_transport_connection(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) {
let Some(meta) = shard.bus.client_meta(transport_client_id) else {
return;
};
sessions
.borrow_mut()
.ensure_connection(transport_client_id, meta.peer_addr, meta.transport);
}
#[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn handle_login_register_request(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: Message<RequestHeader>,
) {
let body = request_body(&request);
let vsr_client_id = request.header().client;
// Both login-register shapes share the ClientVersionInfo prefix, so the
// protocol gate decodes it once and runs before any credential work; the
// body shapes below parse from past the prefix. Only VSR clients reach
// this gate -- legacy SDKs use LOGIN_USER_CODE, a separate path. A
// pre-versioning VSR client sends the old prefix-less body, which fails
// ClientVersionInfo::decode (-> MalformedLogin) or the version gate
// (-> IncompatibleProtocol) right here, not dropped earlier.
let Ok((version_info, prefix_len)) = ClientVersionInfo::decode(body) else {
warn!(
transport_client_id,
"rejecting login: body has no decodable version prefix"
);
send_login_eviction(
shard,
transport_client_id,
vsr_client_id,
EvictionReason::MalformedLogin,
)
.await;
return;
};
if !is_protocol_compatible(version_info.protocol_version) {
warn!(
transport_client_id,
client_protocol_version = %ProtocolVersion(version_info.protocol_version),
sdk_name = %version_info.sdk_name,
sdk_version = %version_info.sdk_version,
"rejecting login: incompatible protocol version"
);
send_login_eviction(
shard,
transport_client_id,
vsr_client_id,
EvictionReason::IncompatibleProtocol,
)
.await;
return;
}
let body_tail = &body[prefix_len..];
if let Ok((wire_request, _)) =
LoginRegisterRequest::decode_after_prefix(version_info.clone(), body_tail)
{
match verify_login_credentials(
shard,
wire_request.username.as_str(),
wire_request.password.expose_secret(),
) {
Ok(user_id) => {
if let Err(error) = complete_login_register(
shard,
sessions,
transport_client_id,
vsr_client_id,
request.header(),
user_id,
&wire_request.version_info,
)
.await
{
warn!(transport_client_id, error = %error, "login/register failed");
surface_login_failure(shard, transport_client_id, request.header(), &error)
.await;
}
return;
}
Err(LoginRegisterError::InvalidCredentials) => {
// Fall through to PAT attempt so a credential payload that
// collides with a valid PAT payload shape still gets a
// chance; if PAT also rejects, the final fall-through emits
// the empty-reply failure path below.
}
Err(error) => {
warn!(transport_client_id, error = %error, "login/register failed");
surface_login_failure(shard, transport_client_id, request.header(), &error).await;
return;
}
}
}
if let Ok((wire_request, _)) =
LoginRegisterWithPatRequest::decode_after_prefix(version_info, body_tail)
{
match verify_pat_credentials(shard, wire_request.token.expose_secret()) {
Ok(user_id) => {
if let Err(error) = complete_login_register(
shard,
sessions,
transport_client_id,
vsr_client_id,
request.header(),
user_id,
&wire_request.version_info,
)
.await
{
warn!(
transport_client_id,
error = %error,
"login/register with PAT failed"
);
surface_login_failure(shard, transport_client_id, request.header(), &error)
.await;
}
return;
}
Err(error) => {
warn!(
transport_client_id,
error = %error,
"login/register with PAT failed"
);
surface_login_failure(shard, transport_client_id, request.header(), &error).await;
return;
}
}
}
warn!(
transport_client_id,
"dropping register request with unsupported payload shape"
);
send_login_failure_reply(shard, transport_client_id, request.header()).await;
}
/// Best-effort login-rejection eviction. Terminal one-way frame; a gone
/// connection has nothing to recover, so the send error is logged and
/// dropped. Consensus context (cluster/view/replica) is stamped on the
/// metadata shard and zeroed elsewhere -- the SDK only reads the reason,
/// plus the protocol window on `IncompatibleProtocol`.
#[allow(clippy::future_not_send)]
async fn send_login_eviction(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
vsr_client_id: u128,
reason: EvictionReason,
) {
let ctx = shard.plane.metadata().consensus.as_ref().map_or(
EvictionContext {
cluster: 0,
view: 0,
replica: 0,
},
EvictionContext::from_consensus,
);
let eviction = match reason {
EvictionReason::IncompatibleProtocol => {
build_incompatible_protocol_eviction_message(ctx, vsr_client_id)
}
_ => build_eviction_message(ctx, vsr_client_id, reason),
};
if let Err(error) = shard
.bus
.send_to_client(transport_client_id, eviction.into_generic().into_frozen())
.await
{
warn!(
transport_client_id,
error = %error,
reason = ?reason,
"failed to send login eviction"
);
}
}
pub(crate) fn upgrade_shard_handle(
shard_handle: &ServerNgShardHandle,
) -> Option<Rc<ServerNgShard>> {
shard_handle
.borrow()
.as_ref()
.and_then(std::rc::Weak::upgrade)
}