You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by hg...@apache.org on 2026/09/01 19:46:00 UTC
(iggy) branch master updated: refactor(server): split the dispatch spine into plane-named leaves (#4026)
This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new f45f9dda9 refactor(server): split the dispatch spine into plane-named leaves (#4026)
f45f9dda9 is described below
commit f45f9dda9bcfb4b74723650fafd8933cc7ed19c9
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Tue Sep 1 21:45:55 2026 +0200
refactor(server): split the dispatch spine into plane-named leaves (#4026)
---
core/server/src/auth.rs | 457 --
core/server/src/bootstrap.rs | 16 +-
core/server/src/dispatch.rs | 5275 --------------------
core/server/src/dispatch/authz.rs | 14 +-
.../{login_register.rs => dispatch/login_error.rs} | 25 +-
core/server/src/dispatch/mod.rs | 1413 ++++++
core/server/src/dispatch/partition.rs | 1497 ++++++
core/server/src/dispatch/reads.rs | 491 ++
core/server/src/dispatch/session_ops.rs | 2096 ++++++++
core/server/src/dispatch/submit.rs | 194 +
core/server/src/dispatch/test_support.rs | 279 ++
core/server/src/http/extractor.rs | 2 +-
core/server/src/http/handlers.rs | 8 +-
core/server/src/http/reply.rs | 2 +-
core/server/src/http/state.rs | 2 +-
core/server/src/http/submit.rs | 7 +-
core/server/src/lib.rs | 2 -
core/server/src/partition_reconciler.rs | 2 +-
core/server/tests/module_graph.rs | 45 +-
19 files changed, 6043 insertions(+), 5784 deletions(-)
diff --git a/core/server/src/auth.rs b/core/server/src/auth.rs
deleted file mode 100644
index 1851281c6..000000000
--- a/core/server/src/auth.rs
+++ /dev/null
@@ -1,457 +0,0 @@
-// 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.
-
-//! Local credential verification and login/register completion.
-//!
-//! Verifies password + PAT credentials locally, then runs the consensus
-//! `Register` proposal on the metadata owner; terminal failures are
-//! surfaced as typed `Eviction` frames, transient ones as result-framed
-//! replay hints.
-
-use crate::dispatch::{send_login_eviction, submit_register_on_owner};
-use crate::login_register::LoginRegisterError;
-use crate::responses::{build_login_register_reply, current_metadata_commit};
-use crate::session_manager::{ClientSdkInfo, SessionManager};
-use crate::shell::{ShellBus, ShellShard};
-use consensus::{MetadataHandle, build_result_rejection_reply};
-use iggy_binary_protocol::PrepareHeader;
-use iggy_binary_protocol::{ClientVersionInfo, EvictionReason, RoutedRequestHeader};
-use iggy_common::defaults::{
- MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
-};
-use iggy_common::{IggyError, IggyTimestamp, PersonalAccessToken, UserStatus};
-use journal::superblock::SuperblockStore;
-use journal::{Journal, JournalHandle};
-use metadata::MetadataSubmitError;
-use metadata::impls::metadata::StreamsFrontend;
-use server_common::Message;
-use server_common::crypto;
-use std::cell::RefCell;
-use std::rc::Rc;
-use std::sync::LazyLock;
-use tracing::warn;
-
-/// A well-formed Argon2 hash to verify against on the unknown-user login
-/// branch, so a missing username costs the same single `verify_password` a real
-/// user's wrong-password branch costs. Closes the username-existence timing
-/// oracle without changing the returned error. On the unknown-username branch
-/// the verify result is discarded, so even a request presenting the exact dummy
-/// plaintext cannot authenticate; the literal only needs to be a fixed input
-/// hashed by the same Argon2 hasher real users use, so the dummy verify runs an
-/// identical Argon2 KDF.
-static DUMMY_PASSWORD_HASH: LazyLock<String> =
- LazyLock::new(|| crypto::hash_password("http-login-timing-guard"));
-
-/// Pay the one-time Argon2 cost of [`DUMMY_PASSWORD_HASH`] at boot instead of
-/// inside the first unknown-username login request.
-pub fn warm_dummy_password_hash() {
- LazyLock::force(&DUMMY_PASSWORD_HASH);
-}
-
-pub fn verify_login_credentials<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- username: &str,
- password: &str,
-) -> Result<u32, LoginRegisterError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- // Same bounds the legacy server enforces before any lookup or hashing;
- // also keeps arbitrary-length input out of the password hash. Collapsed
- // to InvalidCredentials on purpose (legacy: InvalidUsername /
- // InvalidPassword): don't leak which field failed.
- if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&username.len())
- || !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&password.len())
- {
- return Err(LoginRegisterError::InvalidCredentials);
- }
- shard.plane.metadata().mux_stm.users().read(|users| {
- let user = users
- .index
- .get(username)
- .copied()
- .and_then(|user_id| users.items.get(user_id as usize));
- let Some(user) = user else {
- // Constant-cost path: verify against a dummy hash so a missing
- // username is indistinguishable by response timing from a wrong
- // password (both return InvalidCredentials).
- let _ = crypto::verify_password(password, DUMMY_PASSWORD_HASH.as_str());
- return Err(LoginRegisterError::InvalidCredentials);
- };
- // Verify before the status check and collapse inactive to
- // InvalidCredentials: an inactive account must answer exactly like a
- // wrong password (same error, same Argon2 cost), or login could probe
- // which accounts exist but are disabled.
- if !crypto::verify_password(password, user.password_hash.as_ref())
- || user.status != UserStatus::Active
- {
- return Err(LoginRegisterError::InvalidCredentials);
- }
- Ok(user.id)
- })
-}
-
-pub fn verify_pat_credentials<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- token: &str,
-) -> Result<u32, LoginRegisterError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- verify_pat_credentials_with_expiry(shard, token).map(|(user_id, _)| user_id)
-}
-
-/// Like [`verify_pat_credentials`] but also surfaces the token's expiry (unix
-/// seconds, `u64::MAX` when the PAT never expires). The HTTP extractor keys a
-/// per-token VSR session table on this expiry for lazy eviction; the wire and
-/// login paths only need the user id and go through [`verify_pat_credentials`].
-pub fn verify_pat_credentials_with_expiry<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- token: &str,
-) -> Result<(u32, u64), LoginRegisterError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let token_hash = PersonalAccessToken::hash_token(token);
- // PAT expiry gates the login accept/reject, and that outcome folds into
- // the reply, so read the environment-injected bus clock (seed-derived
- // under the simulator), not the wall clock, or a replayed login diverges.
- // The two sibling wall-clock reads in `dispatch` stay direct because both
- // are off the reply path (diagnostic-only). The bus seam exists on every
- // shard, so this holds even when login lands on an entry shard that does
- // not own the metadata consensus.
- let now = IggyTimestamp::from(shard.bus.realtime_micros());
- shard.plane.metadata().mux_stm.users().read(|users| {
- let Some((user_id, token_name)) =
- users.personal_access_token_index.get(token_hash.as_str())
- else {
- return Err(LoginRegisterError::InvalidToken);
- };
- let Some(pat) = users
- .personal_access_tokens
- .get(user_id)
- .and_then(|tokens| tokens.get(token_name))
- else {
- return Err(LoginRegisterError::InvalidToken);
- };
- if pat.is_expired(now) {
- return Err(LoginRegisterError::InvalidToken);
- }
- let Some(user) = users.items.get(*user_id as usize) else {
- return Err(LoginRegisterError::InvalidToken);
- };
- if user.status != UserStatus::Active {
- return Err(LoginRegisterError::UserInactive);
- }
- // `expiry_at == None` is a never-expiring PAT; map it to `u64::MAX` so
- // the HTTP session table never expiry-evicts its entry.
- let expiry = pat
- .expiry_at
- .map_or(u64::MAX, |expiry_at| expiry_at.to_secs());
- Ok((user.id, expiry))
- })
-}
-
-#[allow(clippy::future_not_send)]
-pub async fn complete_login_register<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- transport_client_id: u128,
- vsr_client_id: u128,
- request_header: &RoutedRequestHeader,
- user_id: u32,
- client_version: &ClientVersionInfo,
-) -> Result<(), LoginRegisterError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let sdk_info = ClientSdkInfo {
- sdk_name: client_version.sdk_name.as_str().to_owned(),
- sdk_version: client_version.sdk_version.as_str().to_owned(),
- protocol_version: client_version.protocol_version,
- };
- let existing_session = {
- let sessions = sessions.borrow();
- sessions
- .get_session(transport_client_id)
- .map(|(_, session)| session)
- };
- if let Some(session) = existing_session {
- // Re-login on a bound connection: refresh the recorded SDK info
- // (a reconnecting client may have been upgraded) and replay.
- sessions
- .borrow_mut()
- .record_sdk_info(transport_client_id, sdk_info);
- // A lagging backup's commit_max can sit below the epoch this session
- // already bound; never advertise a commit behind the session itself.
- let commit = current_metadata_commit(shard).max(session);
- let reply =
- build_login_register_reply(request_header, vsr_client_id, session, commit, user_id);
- let _ = shard
- .bus
- .send_to_client(transport_client_id, reply.into_generic().into_frozen())
- .await;
- return Ok(());
- }
-
- // Submit Register and await the commit. The SessionManager is left
- // untouched until the op commits cluster-wide (post-quorum): there is no
- // optimistic Authenticated transition, so a transient submit failure
- // needs no rollback -- the connection stays Connected and the SDK
- // read-timeout replays.
- let session = match submit_register_on_owner(shard, vsr_client_id, user_id).await {
- // The wire reply carries only the fence epoch; the SDK numbers its
- // own requests, so the bind watermark is not surfaced (see the
- // BoundSession doc for who does consume it).
- Ok(bound) => bound.epoch,
- Err(error) => {
- return Err(LoginRegisterError::Transient(error));
- }
- };
-
- // Post-commit: Connected -> Authenticated -> Bound in a single borrow with
- // no await in between, so the intermediate Authenticated state is never
- // observable to a concurrent request on this connection.
- {
- let mut sessions = sessions.borrow_mut();
- sessions
- .login(transport_client_id, user_id)
- .map_err(LoginRegisterError::Session)?;
- sessions.record_sdk_info(transport_client_id, sdk_info);
- if let Err(error) = sessions.bind_session(transport_client_id, vsr_client_id, session) {
- // No local rollback: `submit_register_in_process` above has
- // already committed cluster-wide. A local-only
- // `remove_client_session` here would diverge peers (they retain
- // the slot until they evict the client themselves). The
- // transport-disconnect callback owns local cleanup once the
- // socket closes.
- return Err(LoginRegisterError::Session(error));
- }
- }
-
- // `session` IS the register's commit op, and on a backup that forwarded
- // the proposal the local applied commit still lags it. Reporting the
- // lower number would make one frame contradict itself.
- let commit = current_metadata_commit(shard).max(session);
- let reply = build_login_register_reply(request_header, vsr_client_id, session, commit, user_id);
- let send_result = shard
- .bus
- .send_to_client(transport_client_id, reply.into_generic().into_frozen())
- .await;
- if let Err(error) = send_result {
- warn!(
- transport_client_id,
- error = %error,
- "failed to send login/register reply"
- );
- }
-
- Ok(())
-}
-
-/// Decide whether a failed login/register gets a terminal eviction or a
-/// transient replay hint.
-///
-/// A transient consensus failure ([`LoginRegisterError::is_terminal`] is
-/// `false`) means the cluster could not commit *right now* (a freshly booted
-/// primary still catching up, or a cross-shard submit canceled). Those get a
-/// result-framed replay hint instead of silence, so the SDK replays at once
-/// rather than waiting out its read-timeout; replying empty would surface as
-/// a hard `InvalidFormat` decode failure and break the replay.
-///
-/// Terminal auth errors (`InvalidCredentials` / `InvalidToken` /
-/// `UserInactive` / `Session`) fast-fail with a typed `Eviction` frame so the
-/// SDK surfaces the real reason (every frame transport decodes
-/// `Command::Eviction`) instead of a decode error or a timeout.
-#[allow(clippy::future_not_send)]
-pub async fn surface_login_failure<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- request_header: &RoutedRequestHeader,
- error: &LoginRegisterError,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- if error.is_terminal() {
- send_login_eviction(
- shard,
- transport_client_id,
- request_header.client,
- eviction_reason_for(error),
- )
- .await;
- } else {
- // Which code the hint carries is what tells the client whether the
- // replay may move to another node: see `transient_login_code`.
- send_login_transient_reply(
- shard,
- transport_client_id,
- request_header,
- transient_login_code(error),
- )
- .await;
- }
-}
-
-/// Result-framed transient Reply on a non-terminal failed Register. The SDK
-/// decodes the nonzero result code and replays the same login on the same
-/// connection. Only call for transient errors -- see
-/// [`surface_login_failure`].
-#[allow(clippy::future_not_send)]
-async fn send_login_transient_reply<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- request_header: &RoutedRequestHeader,
- code: IggyError,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let commit = current_metadata_commit(shard);
- let reply = build_result_rejection_reply(request_header, commit, code.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,
- "failed to send login transient reply"
- );
- }
-}
-
-/// Wire code for a transient (non-terminal) login/register failure.
-///
-/// `TransientNotAccepted` asserts nothing was committed: the register never
-/// entered a pipeline (not primary / not caught up / pipeline full) or never
-/// left this node (primary unreachable). The client may re-issue it anywhere,
-/// including under a fresh identity after failing over to another node.
-///
-/// A forward timeout, an in-progress proposal, or a canceled proposal has an
-/// UNKNOWN outcome, so none can ride that assertion. `TransientNotCommitted`
-/// pins the replay to this connection and its client id, where a register that
-/// did commit rebinds its own client-table entry. Re-issuing under a freshly
-/// minted id would instead orphan that entry until capacity eviction reclaims
-/// it.
-const fn transient_login_code(error: &LoginRegisterError) -> IggyError {
- match error {
- LoginRegisterError::Transient(
- MetadataSubmitError::ForwardTimedOut
- | MetadataSubmitError::InProgress
- | MetadataSubmitError::Canceled,
- ) => IggyError::TransientNotCommitted,
- _ => IggyError::TransientNotAccepted,
- }
-}
-
-/// Wire reason for a terminal login/register failure. Session-level
-/// rejections (including the non-retryable submit refusal, where the
-/// presented client id belongs to another user) collapse to
-/// `SessionError`; the SDK maps it to `Unauthenticated`.
-const fn eviction_reason_for(error: &LoginRegisterError) -> EvictionReason {
- match error {
- LoginRegisterError::InvalidCredentials => EvictionReason::InvalidCredentials,
- LoginRegisterError::InvalidToken => EvictionReason::InvalidToken,
- LoginRegisterError::UserInactive => EvictionReason::UserInactive,
- _ => EvictionReason::SessionError,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use iggy_common::{IggyError, eviction_reason_to_error};
-
- /// The reasons this module emits must map back to the credential
- /// errors the SDK is expected to surface (the shared
- /// `eviction_reason_to_error` grading both ends).
- #[test]
- fn terminal_login_errors_map_to_typed_sdk_errors() {
- let cases = [
- (
- eviction_reason_for(&LoginRegisterError::InvalidCredentials),
- IggyError::InvalidCredentials,
- ),
- (
- eviction_reason_for(&LoginRegisterError::InvalidToken),
- IggyError::InvalidPersonalAccessToken,
- ),
- (
- eviction_reason_for(&LoginRegisterError::UserInactive),
- IggyError::Unauthenticated,
- ),
- ];
- for (reason, expected) in cases {
- let error = eviction_reason_to_error(reason, 0, 0);
- assert_eq!(
- error.as_code(),
- expected.as_code(),
- "reason {reason:?} must surface as {expected:?}"
- );
- }
- }
-
- #[test]
- fn unknown_register_outcomes_pin_the_client_identity() {
- for error in [
- MetadataSubmitError::ForwardTimedOut,
- MetadataSubmitError::InProgress,
- MetadataSubmitError::Canceled,
- ] {
- assert_eq!(
- transient_login_code(&LoginRegisterError::Transient(error)),
- IggyError::TransientNotCommitted,
- );
- }
- for error in [
- MetadataSubmitError::NotPrimary,
- MetadataSubmitError::NotCaughtUp,
- MetadataSubmitError::PipelineFull,
- MetadataSubmitError::PrimaryUnreachable,
- ] {
- assert_eq!(
- transient_login_code(&LoginRegisterError::Transient(error)),
- IggyError::TransientNotAccepted,
- );
- }
- }
-}
diff --git a/core/server/src/bootstrap.rs b/core/server/src/bootstrap.rs
index 186325ff4..5903aebaf 100644
--- a/core/server/src/bootstrap.rs
+++ b/core/server/src/bootstrap.rs
@@ -15,13 +15,14 @@
// specific language governing permissions and limitations
// under the License.
-use crate::auth::warm_dummy_password_hash;
use crate::cluster_meta::{ClusterRoster, resolved_roster_nodes, self_advertised_address};
use crate::config_writer::write_current_config;
+use crate::dispatch::partition::make_partition_read_handler;
+use crate::dispatch::session_ops::warm_dummy_password_hash;
+use crate::dispatch::submit::make_metadata_submit_handler;
use crate::dispatch::{
make_client_request_handler, make_deferred_client_request_handler,
- make_deferred_replica_message_handler, make_list_clients_handler, make_metadata_submit_handler,
- make_partition_read_handler,
+ make_deferred_replica_message_handler, make_list_clients_handler,
};
use crate::http;
use crate::partition_helpers::{
@@ -1252,8 +1253,13 @@ async fn shard_main(
let hb_sessions = Rc::clone(&sessions);
let hb_interval = config.heartbeat.interval.get_duration();
let hb_handle = compio::runtime::spawn(async move {
- crate::dispatch::run_heartbeat_verifier(hb_shard, hb_sessions, hb_interval, hb_stop_rx)
- .await;
+ crate::dispatch::session_ops::run_heartbeat_verifier(
+ hb_shard,
+ hb_sessions,
+ hb_interval,
+ hb_stop_rx,
+ )
+ .await;
});
bus.track_background(hb_handle);
Some(hb_stop_tx)
diff --git a/core/server/src/dispatch.rs b/core/server/src/dispatch.rs
deleted file mode 100644
index 3bf5af28a..000000000
--- a/core/server/src/dispatch.rs
+++ /dev/null
@@ -1,5275 +0,0 @@
-// 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, surface_login_failure, verify_login_credentials,
- verify_pat_credentials,
-};
-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_deny_reply, send_non_replicated_deny, send_unbound_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_reply, build_raw_pat_reply,
- connected_client_to_response, current_metadata_commit, resolve_partition_namespace,
- resolve_partition_request_namespace,
-};
-use crate::segment_cleaner::UNENFORCEABLE_TOPIC_SIZE_WARN;
-use crate::session_manager::SessionManager;
-use crate::shell::{ShellBus, ShellShard, ShellShardHandle};
-use crate::snapshot;
-use crate::users::maybe_rewrite_user_password_request;
-use crate::wire::{request_body, usize_to_u32, verify_request_checksum};
-use bytes::Bytes;
-use configs::server::ServerSystemConfig;
-use consensus::{
- Consensus, DISCONNECT_LOGOUT_REQUEST_ID, EvictionContext, MetadataHandle, PartitionsHandle,
- build_eviction_message, build_incompatible_protocol_eviction_message,
- build_result_rejection_reply,
-};
-use iggy_binary_protocol::PrepareHeader;
-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, GET_STATS_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, StoreConsumerOffsetRequest,
-};
-use iggy_binary_protocol::requests::messages::PollMessagesRequest;
-use iggy_binary_protocol::requests::partitions::{
- CreatePartitionsRequest, DeletePartitionsRequest,
-};
-use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
-use iggy_binary_protocol::requests::streams::{CreateStreamRequest, UpdateStreamRequest};
-use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
-use iggy_binary_protocol::requests::system::get_snapshot::GetSnapshotRequest;
-use iggy_binary_protocol::requests::topics::{CreateTopicRequest, UpdateTopicRequest};
-use iggy_binary_protocol::requests::users::{
- CreateUserRequest, LoginRegisterRequest, LoginRegisterWithPatRequest, UpdateUserRequest,
-};
-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, Command, ConsensusHeader, EvictionReason, ForwardLogoutHeader,
- ForwardLogoutOutcome, ForwardLogoutResultHeader, ForwardRegisterHeader, ForwardRegisterOutcome,
- ForwardRegisterResultHeader, GenericHeader, HEADER_SIZE, KIND_CONSUMER_GROUP,
- MAX_PARTITIONS_PER_REQUEST, Operation, ProtocolVersion, RequestHeader, RoutedRequestHeader,
- WireDecode, WireEncode, WireIdentifier, WireOptions, is_protocol_compatible,
-};
-use iggy_common::{
- IggyByteSize, IggyError, MaxTopicSize, PollingStrategy, SnapshotCompression,
- SystemSnapshotType, TopicCreateOptions, UPDATABLE_STREAM_OPTION_KEYS,
- UPDATABLE_TOPIC_OPTION_KEYS, UPDATABLE_USER_OPTION_KEYS, validate_preallocated_topic_bytes,
- validate_topic_segment_size,
-};
-use journal::superblock::SuperblockStore;
-use journal::{Journal, JournalHandle};
-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, BusMessage};
-use metadata::impls::metadata::{
- BoundSession, MetadataSubmitError, StreamsFrontend, build_truncate_partition_client_message,
- build_truncate_partition_client_message_with_identifiers,
-};
-use metadata::permissioner::Permissioner;
-use metadata::stm::stream::Streams;
-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::net::IpAddr;
-use std::rc::Rc;
-use std::sync::Arc;
-use std::time::Duration;
-use tracing::{debug, warn};
-
-pub type ClientRequestQueues = Rc<RefCell<HashMap<u128, VecDeque<Message<GenericHeader>>>>>;
-pub type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
-
-pub fn make_client_request_handler<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- system_config: Arc<ServerSystemConfig>,
- max_tokens_per_user: u32,
-) -> RequestHandler
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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),
- max_tokens_per_user,
- 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 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 fn make_partition_read_handler<B, MJ, S, SB>(
- shard_handle: &ShellShardHandle<B, MJ, S, SB>,
-) -> PartitionReadHandler
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let shard_handle = Rc::clone(shard_handle);
- // Runs synchronously on the shard pump (see `process_lifecycle` ->
- // `on_partition_read`). `build_poll_snapshot` takes a pump-only `&mut`
- // partition borrow (synchronous, so no sibling task can realloc under it) 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<B, MJ, S, SB>(
- shard: Rc<ShellShard<B, MJ, S, SB>>,
- namespace: IggyNamespace,
- plan: PollPlan,
- reply: shard::Sender<PartitionReadReply>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let bus = shard.bus.clone();
- bus.spawn(async move {
- // Diagnostic-only wall clock: `elapsed` gates the slow-poll `warn!`
- // below and is never folded into a reply or the deterministic schedule,
- // so it stays sound under the simulator's virtual clock (there it just
- // measures near-zero real time and never fires). Do not derive any
- // replicated or reply value from it, or replay determinism breaks.
- 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,
- });
- });
-}
-
-/// 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- namespace: IggyNamespace,
- applied: &AutoCommitApplied,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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_transferring()) {
- 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 `StoreConsumerOffset` 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<RoutedRequestHeader>, IggyError> {
- let request = StoreConsumerOffsetRequest {
- 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::<RoutedRequestHeader>();
- let total_size = header_size + body.len();
- let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
- let mut message = Message::<RoutedRequestHeader>::new(total_size);
- message.as_mut_slice()[header_size..].copy_from_slice(&body);
- Ok(
- message.transmute_header(|_, header: &mut RoutedRequestHeader| {
- *header = RoutedRequestHeader {
- command: Command::Request,
- operation: Operation::StoreConsumerOffset,
- 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,
- group: namespace.inner(),
- ..Default::default()
- };
- }),
- )
-}
-
-pub fn make_deferred_replica_message_handler<B, MJ, S, SB>(
- shard_handle: &ShellShardHandle<B, MJ, S, SB>,
-) -> MessageHandler
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 fn make_deferred_client_request_handler<B, MJ, S, SB>(
- bus: &B,
- shard_handle: &ShellShardHandle<B, MJ, S, SB>,
- sessions: &Rc<RefCell<SessionManager>>,
- system_config: Arc<ServerSystemConfig>,
- max_tokens_per_user: u32,
-) -> RequestHandler
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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);
- let bus_for_spawn = (*bus).clone();
- 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;
- }
- bus_for_spawn.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,
- max_tokens_per_user,
- queues,
- active,
- client_id,
- )
- .await;
- });
- })
-}
-
-/// 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. Submit failures are returned verbatim so the peer can preserve
-/// unknown-outcome retry semantics.
-pub fn make_metadata_submit_handler<B, MJ, S, SB>(
- shard_handle: &ShellShardHandle<B, MJ, S, SB>,
-) -> shard::MetadataSubmitHandler
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let shard_handle = Rc::clone(shard_handle);
- Rc::new(move |submit| {
- let Some(shard) = upgrade_shard_handle(&shard_handle) else {
- return;
- };
- let bus = shard.bus.clone();
- bus.spawn(async move {
- match submit {
- shard::MetadataSubmit::Register {
- vsr_client_id,
- user_id,
- reply,
- } => {
- let bound =
- submit_register_local_or_forward(&shard, vsr_client_id, user_id).await;
- let _ = reply.try_send(bound);
- }
- shard::MetadataSubmit::ForwardedRegister {
- vsr_client_id,
- user_id,
- nonce,
- origin_replica,
- } => {
- answer_forwarded_register(
- &shard,
- vsr_client_id,
- user_id,
- nonce,
- origin_replica,
- )
- .await;
- }
- shard::MetadataSubmit::ForwardedLogout {
- vsr_client_id,
- session,
- request,
- nonce,
- origin_replica,
- } => {
- answer_forwarded_logout(
- &shard,
- vsr_client_id,
- session,
- request,
- nonce,
- origin_replica,
- )
- .await;
- }
- shard::MetadataSubmit::Logout {
- vsr_client_id,
- session,
- request,
- reply,
- } => {
- let outcome =
- submit_logout_local_or_forward(&shard, vsr_client_id, session, request)
- .await;
- let _ = reply.try_send(outcome);
- }
- shard::MetadataSubmit::ClientRequest { request, reply } => {
- let committed = match request.try_into_typed::<RoutedRequestHeader>() {
- 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);
- }
- }
- });
- })
-}
-
-// Session resume is performed BY THE LOGIN PATH, not by a separate
-// credential-free rebind.
-//
-// A reconnecting client re-authenticates on the new connection and presents
-// its previous `client_id` in the login frame; `submit_register_in_process`
-// finds the existing table entry, verifies the authenticated user owns it,
-// and returns its epoch, so `bind_session` binds the new transport to the
-// old entry with its watermark and reply ring intact. That IS the resume.
-//
-// An earlier revision instead rebound an *unbound* transport straight from
-// the table whenever a replicated frame carried a matching
-// `(client, session)`, treating that pair as a bearer token. That was wrong
-// in four ways, and the combination was a pre-auth session takeover:
-//
-// - it called `SessionManager::login` itself, so no credential was ever
-// presented, and the connection was logged in as the entry's cached
-// `user_id`; authority for replicated ops then resolves from the table
-// (`resolve_acting_user_id`) and for partition ops from the session
-// manager, so BOTH planes ran as the original registrant;
-// - the pair carries far less entropy than "client-generated random
-// u128" implies: HTTP mints `client_id` from the shard-0 sequential
-// counter (`mint_shard_zero_client_id`, seeded at 1 per process) and no
-// live path ever bumps an epoch past 1, so the token was `client=N,
-// session=1` for small N;
-// - `ClientEntry` carries no transport or plane tag, so a raw TCP peer
-// could bind an HTTP-originated session;
-// - `bind_session` demotes the evicted holder to `Connected`, the one
-// state `login` accepts, so the loser's next replicated frame
-// re-resumed and stole the session back, unbounded and with no eviction
-// frame either way.
-//
-// Routing resume through login also restores the checks that path owns:
-// password / PAT verification, `UserStatus::Active`, PAT expiry, the
-// protocol-version gate, and SDK-info recording.
-//
-// An unbound transport sending a replicated frame therefore gets the typed
-// `Eviction(NoSession)` fail-fast below and must log in.
-
-#[allow(clippy::too_many_arguments)]
-fn enqueue_client_request<B, MJ, S, SB>(
- shard: Rc<ShellShard<B, MJ, S, SB>>,
- sessions: Rc<RefCell<SessionManager>>,
- system_config: Arc<ServerSystemConfig>,
- max_tokens_per_user: u32,
- queues: ClientRequestQueues,
- active: ActiveClientRequests,
- client_id: u128,
- message: Message<GenericHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- queues
- .borrow_mut()
- .entry(client_id)
- .or_default()
- .push_back(message);
- if !active.borrow_mut().insert(client_id) {
- return;
- }
-
- let bus = shard.bus.clone();
- bus.spawn(async move {
- drain_client_requests(
- shard,
- sessions,
- system_config,
- max_tokens_per_user,
- queues,
- active,
- client_id,
- )
- .await;
- });
-}
-
-#[allow(clippy::future_not_send)]
-async fn drain_client_requests<B, MJ, S, SB>(
- shard: Rc<ShellShard<B, MJ, S, SB>>,
- sessions: Rc<RefCell<SessionManager>>,
- system_config: Arc<ServerSystemConfig>,
- max_tokens_per_user: u32,
- queues: ClientRequestQueues,
- active: ActiveClientRequests,
- client_id: u128,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- loop {
- let Some(message) = pop_next_client_request(&queues, &active, client_id) else {
- return;
- };
- handle_client_request(
- &shard,
- &sessions,
- &system_config,
- max_tokens_per_user,
- 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
-}
-
-/// Per-request partitions-count cap, shared by create-topic, create-partitions
-/// and delete-partitions admission. Runs pre-consensus like
-/// [`validate_topic_bounds`]: an oversized count must not burn a replicated
-/// log entry (create-partitions admission would also allocate that many
-/// consensus-group ids before replicating).
-///
-/// Zero passes here because a zero-partition TOPIC is legal (legacy
-/// `create_topic` admits `0..=MAX`); the add/remove requests reject it in
-/// [`validate_partitions_change_count`].
-pub const fn validate_partitions_count(partitions_count: u32) -> Result<(), IggyError> {
- if partitions_count > MAX_PARTITIONS_PER_REQUEST {
- return Err(IggyError::TooManyPartitions);
- }
- Ok(())
-}
-
-/// [`validate_partitions_count`] plus the zero rejection that create-partitions
-/// and delete-partitions carry: adding or removing zero partitions is a no-op
-/// that would still burn a replicated log entry, bump `Streams::revision` and
-/// force every shard through a rebalance pass. Legacy rejects it with
-/// `TooManyPartitions` in both handlers (`1..=MAX` on create, `== 0` on
-/// delete), so the code matches rather than inventing a new one.
-pub const fn validate_partitions_change_count(partitions_count: u32) -> Result<(), IggyError> {
- if partitions_count == 0 {
- return Err(IggyError::TooManyPartitions);
- }
- validate_partitions_count(partitions_count)
-}
-
-/// Static create-topic bounds shared by the TCP and HTTP ingresses. Runs
-/// pre-consensus: a rejected request must not burn a replicated log entry,
-/// and `prepare_request` errors evict the session instead of denying typed.
-/// `ServerDefault` is exempt from the size floor (it resolves against server
-/// config at admission, matching legacy); `Unlimited` passes numerically.
-/// `segment_size_bytes` is the topic's RESOLVED segment size (explicit
-/// option, else this node's default), so a per-topic segment above the
-/// global default still floors the topic cap.
-pub fn validate_topic_bounds(
- partitions_count: u32,
- max_topic_size: MaxTopicSize,
- segment_size_bytes: u64,
-) -> Result<(), IggyError> {
- validate_partitions_count(partitions_count)?;
- validate_topic_size_floor(max_topic_size, segment_size_bytes)
-}
-
-/// A topic cap below one segment can never be enforced: the first segment
-/// already exceeds it. Split out of [`validate_topic_bounds`] because update
-/// admission checks the cap without a partitions count to check.
-pub fn validate_topic_size_floor(
- max_topic_size: MaxTopicSize,
- segment_size_bytes: u64,
-) -> Result<(), IggyError> {
- if !matches!(max_topic_size, MaxTopicSize::ServerDefault)
- && max_topic_size.as_bytes_u64() < segment_size_bytes
- {
- return Err(IggyError::InvalidTopicSize(
- max_topic_size,
- IggyByteSize::from(segment_size_bytes),
- ));
- }
- Ok(())
-}
-
-/// Announce an accepted `max_topic_size` the server cannot enforce as written.
-///
-/// [`validate_topic_size_floor`] admits any cap of one segment or more, but
-/// retention runs PER PARTITION and floors each partition's share at one SEALED
-/// segment, which reaches up to one maximum bus frame past `segment_size`. A cap
-/// between the two is stored and echoed back verbatim while the server actually
-/// keeps `(segment_size + max_message_size) * partitions_count`, so the only
-/// moment an operator can be told is the one where they set it.
-///
-/// Warns rather than rejects: which caps are accepted is client-visible wire
-/// behavior, and tightening it would break topics that already exist.
-pub fn warn_unenforceable_topic_size(
- max_topic_size: MaxTopicSize,
- segment_size_bytes: u64,
- max_message_size_bytes: usize,
- partitions_count: u32,
-) {
- let MaxTopicSize::Custom(configured) = max_topic_size else {
- return;
- };
- let max_message_size_bytes = u64::try_from(max_message_size_bytes).unwrap_or(u64::MAX);
- let per_partition_floor = segment_size_bytes.saturating_add(max_message_size_bytes);
- let topic_floor = per_partition_floor.saturating_mul(u64::from(partitions_count));
- if configured.as_bytes_u64() >= topic_floor {
- return;
- }
- warn!(
- max_topic_size = configured.as_bytes_u64(),
- partitions_count,
- segment_size = segment_size_bytes,
- enforced_per_partition = per_partition_floor,
- "{UNENFORCEABLE_TOPIC_SIZE_WARN}"
- );
-}
-
-/// Announce the same unenforceable cap when partitions are ADDED to a topic.
-///
-/// The cap is topic-wide but enforcement is per partition, so every added
-/// partition shrinks the share: a cap that cleared the floor when the topic was
-/// created can stop clearing it here. The request carries only the delta, so
-/// the stored cap, segment size and current partition count come from metadata.
-pub fn warn_unenforceable_topic_size_on_partition_add(
- streams: &Streams,
- stream_id: &WireIdentifier,
- topic_id: &WireIdentifier,
- max_message_size_bytes: usize,
- added_partitions_count: u32,
-) {
- let Some(((stream_slab, topic_slab), _)) = streams.partition_count_context(stream_id, topic_id)
- else {
- return;
- };
- let Some((_, max_topic_size, partitions_count, segment_size)) =
- streams.topic_retention_config(stream_slab, topic_slab)
- else {
- return;
- };
- warn_unenforceable_topic_size(
- max_topic_size,
- segment_size.map_or(iggy_common::DEFAULT_SEGMENT_SIZE, |segment_size| {
- segment_size.as_bytes_u64()
- }),
- max_message_size_bytes,
- u32::try_from(partitions_count)
- .unwrap_or(u32::MAX)
- .saturating_add(added_partitions_count),
- );
-}
-
-/// Reject option keys outside the resource's catalog, pre-consensus. Unknown
-/// keys are rejected rather than skipped: a silently ignored knob would hand
-/// the client server defaults without it ever learning. Streams and users
-/// have no catalog keys yet, so `known` is empty for both until one lands.
-pub fn validate_option_keys(options: &WireOptions, known: &[&str]) -> Result<(), IggyError> {
- for entry in options {
- // Wire validation already enforced UTF-8 string keys.
- let key = String::from_utf8_lossy(entry.key);
- if !known.contains(&key.as_ref()) {
- return Err(IggyError::UnsupportedOptionKey(key.into_owned()));
- }
- }
- Ok(())
-}
-
-/// Reject a request before it reaches consensus: warn, then send the typed
-/// deny reply. A silent drop would wedge every later request on the
-/// connection until the socket read timeout. `context` labels the rejection
-/// site in both log lines.
-#[allow(clippy::future_not_send)]
-async fn send_pre_consensus_deny<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- header: &RoutedRequestHeader,
- transport_client_id: u128,
- error: &IggyError,
- context: &'static str,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- warn!(
- transport_client_id,
- error = %error,
- operation = ?header.operation,
- context,
- "denying request pre-consensus"
- );
- 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,
- context,
- "failed to send pre-consensus deny reply"
- );
- }
-}
-
-#[allow(clippy::future_not_send, clippy::too_many_lines)]
-async fn handle_client_request<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- system_config: &Arc<ServerSystemConfig>,
- max_tokens_per_user: u32,
- transport_client_id: u128,
- message: Message<iggy_binary_protocol::GenericHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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;
- }
- };
- // Promote to the server-internal routed shape at the boundary: the
- // client wire carries no group (it is derived -- plane from `operation`,
- // partition target from the payload), so it starts unset here and the
- // resolution sites below stamp it before anything routes on it.
- let request = request.into_routed();
-
- // The last point that still sees the body the CLIENT sent; every rewrite below
- // substitutes server-chosen bytes and carries the stamp through unchanged.
- if let Err(error) = verify_request_checksum(&request) {
- warn!(
- transport_client_id,
- operation = ?request.header().operation,
- request = request.header().request,
- "dropping client request whose body does not match its own checksum"
- );
- send_deny_reply(
- shard,
- transport_client_id,
- request.header(),
- error.as_code(),
- )
- .await;
- 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: `PING`, the liveness probe, is the only pre-auth
- // code, on every roster shape. `GET_CLUSTER_METADATA` describes the
- // private replica network and is not something an unauthenticated
- // caller gets to read; a client that dialed a backup no longer needs
- // it to find the leader, because the backup authenticates the login
- // locally and forwards only the consensus proposal
- // (`submit_register_local_or_forward`). Every other non-replicated
- // code 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. The server 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 generic
- // Unauthenticated deny 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 requires the register handshake"
- );
- send_login_eviction(
- shard,
- transport_client_id,
- header.client,
- EvictionReason::MalformedLogin,
- )
- .await;
- return;
- }
- let allowed_pre_auth = nr_code == PING_CODE;
- if !allowed_pre_auth && sessions.borrow().get_session(transport_client_id).is_none() {
- // Foreign SDKs still probe `GET_CLUSTER_METADATA` before login
- // until they are fixed, so that rejection is routine traffic and
- // logs at debug rather than warn.
- if nr_code == GET_CLUSTER_METADATA_CODE {
- debug!(
- transport_client_id,
- "denying pre-auth cluster-metadata read with Unauthenticated"
- );
- } else {
- warn!(
- transport_client_id,
- code = nr_code,
- "denying pre-auth non-replicated read with Unauthenticated"
- );
- }
- // A plain deny Reply, not an Eviction: there is no session to
- // evict, and an Eviction is session-terminal by wire contract,
- // so SDKs would tear down the very connection their login is
- // about to use. The status channel carries the error the same
- // way the request-checksum denial above does.
- send_unbound_deny_reply(
- shard,
- transport_client_id,
- request.header(),
- IggyError::Unauthenticated.as_code(),
- )
- .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`/`Fenced` and the failure disappears
- // silently, wedging the SDK until the socket timeout. A typed
- // `Eviction(NoSession)` is right here, unlike the pre-auth read
- // guard above: a replicated request implies the client believes it
- // has a session, and that session is gone, so it must register
- // again. An empty status-0 Reply is not safe here, because
- // SendMessages is the one replicated operation without a result
- // section, and its decoder would read the empty body as a
- // successful send.
- warn!(
- transport_client_id,
- operation = ?header.operation,
- "rejecting replicated request from unbound transport with Eviction(NoSession)"
- );
- send_unauthenticated_eviction(shard, transport_client_id).await;
- 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 RoutedRequestHeader| {
- *new_header = header;
- // Metadata-plane ops route by operation: stamp the sentinel group.
- new_header.group = server_common::sharding::METADATA_GROUP;
- // `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,
- max_tokens_per_user,
- |user_id| {
- shard
- .plane
- .metadata()
- .mux_stm
- .users()
- .read(|users| users.pat_count_of(user_id))
- },
- request,
- ) {
- Ok(rewritten) => rewritten,
- Err(error) => {
- // Token cap reached, malformed body, or a lost session binding.
- send_pre_consensus_deny(
- shard,
- &header,
- transport_client_id,
- &error,
- "personal-access-token",
- )
- .await;
- 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.
- send_pre_consensus_deny(shard, &header, transport_client_id, &error, "user-password")
- .await;
- return;
- }
- };
- // Static bounds run pre-consensus so a rejected request burns no
- // replicated log entry; HTTP covers the same bounds via
- // `command.validate()`. A body that fails to decode denies typed too
- // (`InvalidCommand`), instead of riding consensus just to fail there.
- let bounds = match header.operation {
- Operation::CreateTopic => CreateTopicRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|create_topic| {
- // `parse` doubles as the catalog gate: an unknown key or a
- // malformed value denies typed here, pre-consensus.
- let options = TopicCreateOptions::parse(&create_topic.options)?;
- if let Some(segment_size) = options.segment_size {
- validate_topic_segment_size(
- segment_size.as_bytes_u64(),
- iggy_common::MAX_TOPIC_SEGMENT_SIZE,
- )?;
- }
- let segment_size = options.segment_size.map_or_else(
- || iggy_common::DEFAULT_SEGMENT_SIZE,
- |segment_size| segment_size.as_bytes_u64(),
- );
- if options
- .preallocate_segments
- .unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS)
- {
- validate_preallocated_topic_bytes(segment_size, create_topic.partitions_count)?;
- }
- let max_topic_size = options
- .max_topic_size
- .unwrap_or(MaxTopicSize::ServerDefault);
- validate_topic_bounds(create_topic.partitions_count, max_topic_size, segment_size)?;
- warn_unenforceable_topic_size(
- max_topic_size,
- segment_size,
- shard.bus_max_message_size(),
- create_topic.partitions_count,
- );
- Ok(())
- }),
- Operation::CreatePartitions => CreatePartitionsRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|create_partitions| {
- validate_partitions_change_count(create_partitions.partitions_count)?;
- let metadata = shard.plane.metadata();
- warn_unenforceable_topic_size_on_partition_add(
- metadata.mux_stm.streams(),
- &create_partitions.stream_id,
- &create_partitions.topic_id,
- shard.bus_max_message_size(),
- create_partitions.partitions_count,
- );
- Ok(())
- }),
- Operation::DeletePartitions => DeletePartitionsRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|delete_partitions| {
- validate_partitions_change_count(delete_partitions.partitions_count)
- }),
- // Only the updatable subset: the create-time knobs are pushed to
- // partitions when the topic is built and nothing re-pushes them, so
- // accepting one here would store a value no partition ever sees.
- Operation::UpdateTopic => UpdateTopicRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|update_topic| {
- validate_option_keys(&update_topic.options, UPDATABLE_TOPIC_OPTION_KEYS)?;
- let options = TopicCreateOptions::parse(&update_topic.options)?;
- let Some(max_topic_size) = options.max_topic_size else {
- return Ok(());
- };
- // An update can lower the cap below one segment just as a
- // create can, and the stored map would then report a size the
- // topic can never enforce. The floor is this topic's own
- // segment size, since that key is create-only.
- let metadata = shard.plane.metadata();
- let streams = metadata.mux_stm.streams();
- let segment_size = streams
- .topic_segment_size(&update_topic.stream_id, &update_topic.topic_id)
- .map_or_else(
- || iggy_common::DEFAULT_SEGMENT_SIZE,
- |segment_size| segment_size.as_bytes_u64(),
- );
- validate_topic_size_floor(max_topic_size, segment_size)?;
- let partitions_count = streams
- .topic_partitions_count(&update_topic.stream_id, &update_topic.topic_id)
- .unwrap_or(0);
- warn_unenforceable_topic_size(
- max_topic_size,
- segment_size,
- shard.bus_max_message_size(),
- u32::try_from(partitions_count).unwrap_or(u32::MAX),
- );
- Ok(())
- }),
- Operation::UpdateStream => UpdateStreamRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|update_stream| {
- validate_option_keys(&update_stream.options, UPDATABLE_STREAM_OPTION_KEYS)
- }),
- Operation::UpdateUser => UpdateUserRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|update_user| {
- validate_option_keys(&update_user.options, UPDATABLE_USER_OPTION_KEYS)
- }),
- Operation::CreateStream => CreateStreamRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|create_stream| validate_option_keys(&create_stream.options, &[])),
- Operation::CreateUser => CreateUserRequest::decode_from(request_body(&request))
- .map_err(|_| IggyError::InvalidCommand)
- .and_then(|create_user| validate_option_keys(&create_user.options, &[])),
- _ => Ok(()),
- };
- if let Err(error) = bounds {
- send_pre_consensus_deny(shard, &header, transport_client_id, &error, "static-bounds").await;
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- transport_client_id: u128,
- request: &Message<RoutedRequestHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- transport_client_id: u128,
- request: &Message<RoutedRequestHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 with a nonzero status -- unresolvable namespace,
-/// authorization denial, exhausted routable wait -- so the client fails fast
-/// instead of wedging on a silent drop or reading a status-0 frame as a
-/// committed write.
-///
-/// `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 async fn dispatch_partition_request<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- request: Message<RoutedRequestHeader>,
- vsr_client_id: u128,
- bound_session: u64,
- transport_client_id: u128,
- acting_user_id: Option<u32>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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`,
- // or an explicit partition id that skipped the client-side
- // resolve). The op never reached the partition plane, so a status-0
- // reply would read as a committed ack for work that never happened.
- // A silent drop is no better: the SDK connection processes replies
- // in lockstep and would wedge forever.
- warn!(
- transport_client_id,
- error = %error,
- operation = ?header.operation,
- "partition request with unresolved namespace; replying denied"
- );
- send_deny_reply(
- shard,
- transport_client_id,
- &header,
- IggyError::ResourceNotFound(String::new()).as_code(),
- )
- .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_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). An op arriving inside that window is not lost
- // if it skips this wait -- `router::route_typed` falls back to the hash
- // assignment, and the owning shard parks it -- so this is an admission
- // courtesy that keeps the steady state off that park buffer, not a
- // correctness gate. See `wait_for_partition_routable`, which spells out why
- // there is no owner-readiness probe here any more.
- if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
- // The op never reached the partition plane, so it is safe to re-issue
- // anywhere -- the same contract the plane itself answers for a
- // non-primary routing artifact. A status-0 empty reply here would
- // fabricate a success ack for a write that hit no partition at all.
- warn!(
- transport_client_id,
- namespace,
- operation = ?header.operation,
- "partition request not routable within budget; replying transient"
- );
- send_deny_reply(
- shard,
- transport_client_id,
- &header,
- IggyError::TransientNotAccepted.as_code(),
- )
- .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 RoutedRequestHeader| {
- *new_header = header;
- new_header.group = 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. Current SDKs do number
- // partition ops, but older and internal callers may still send
- // zero, so a zero id is normalized to the compatibility value 1.
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- system_config: &Arc<ServerSystemConfig>,
- transport_client_id: u128,
- request: Message<RoutedRequestHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 and peer address for the read gates below, resolved in one
- // connection lookup. `user_id` is `None` only on the pre-auth path
- // (PING), which serves ungated codes; the gated arms fail closed on it.
- let (user_id, client_address) = sessions.borrow().read_context(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();
- let client_ip = client_address.map(|address| address.ip());
- if client_ip.is_none() {
- debug!(
- transport_client_id,
- code,
- "no peer address recorded; advertised-address resolution degrades to the catch-all"
- );
- }
- handle_default_non_replicated(
- shard,
- transport_client_id,
- code,
- &request,
- user_id,
- &roster,
- client_ip,
- )
- .await;
- }
- }
-}
-
-#[allow(clippy::future_not_send, clippy::too_many_arguments)]
-async fn handle_default_non_replicated<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- code: u32,
- request: &Message<RoutedRequestHeader>,
- user_id: Option<u32>,
- roster: &ClusterRoster,
- client_ip: Option<IpAddr>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- // 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;
- }
- // Stats is the one default read with an async input: the cross-shard
- // connected-client gather. Run it here so the shared builder stays sync.
- let clients_count = if code == GET_STATS_CODE {
- u32::try_from(shard.list_all_clients().await.len()).unwrap_or(u32::MAX)
- } else {
- 0
- };
- match build_non_replicated_response(
- shard,
- code,
- request_body(request),
- user_id,
- roster,
- client_ip,
- clients_count,
- ) {
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- system_config: &Arc<ServerSystemConfig>,
- transport_client_id: u128,
- request: &Message<RoutedRequestHeader>,
- user_id: Option<u32>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- request: &Message<RoutedRequestHeader>,
- transport_client_id: u128,
- bytes: Bytes,
- label: &'static str,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let commit = current_metadata_commit(shard);
- let reply = NonReplicatedResponse::Bytes(bytes).into_reply(
- request.header(),
- request.header().client,
- request.header().session,
- commit,
- );
- send_reply_frame(
- shard,
- transport_client_id,
- reply.into_generic().into_frozen(),
- label,
- )
- .await;
-}
-
-/// Hand a built reply frame to the bus for `transport_client_id`.
-#[allow(clippy::future_not_send)]
-async fn send_reply_frame<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- frame: impl Into<BusMessage>,
- label: &'static str,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- if let Err(error) = shard.bus.send_to_client(transport_client_id, frame).await {
- warn!(transport_client_id, label, error = %error, "failed to send non-replicated reply");
- }
-}
-
-/// Reject a replicated request from an unbound transport with a typed
-/// `Eviction(NoSession)` frame: the session the client believes it has is
-/// gone, so it must register again. Pre-auth non-replicated reads get a
-/// deny Reply instead (no session exists, so nothing is evicted).
-///
-/// 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 async fn run_heartbeat_verifier<B, MJ, S, SB>(
- shard: Rc<ShellShard<B, MJ, S, SB>>,
- sessions: Rc<RefCell<SessionManager>>,
- interval: std::time::Duration,
- stop_rx: shard::Receiver<()>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- // Legacy `MAX_THRESHOLD`: a client is stale once it misses 1.2 intervals.
- // Integer 6/5 rather than `mul_f64`, which panics on an absurd interval.
- let max_age = interval.saturating_mul(6) / 5;
- loop {
- // `Ok(_)`: stop signalled -> exit. `Err(_)`: interval elapsed -> pass.
- // Waiting on the stop channel rather than sleeping past it keeps this
- // task inside the shutdown drain budget, which is shorter than the
- // heartbeat interval.
- let stop_signal = compio::time::timeout(interval, stop_rx.recv()).await;
- if stop_signal.is_ok() {
- break;
- }
- // Production-only wall clock: the heartbeat verifier is spawned solely
- // by `build_shard_for_thread`, never by the simulator's
- // `wire_shell_handlers`, so neither the interval wait above nor this
- // read is on a deterministic path. Driving this task under the
- // deterministic executor means routing both through the injected clock.
- 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;
- }
- }
- }
-}
-
-/// 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- transport_client_id: u128,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- request: &Message<RoutedRequestHeader>,
- user_id: Option<u32>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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,
- }) => match build_polled_messages_reply(
- request.header(),
- current_metadata_commit(shard),
- partition_id,
- current_offset,
- fragments,
- shard.plane.partitions().config().encryptor.as_deref(),
- ) {
- Ok(reply) => {
- send_reply_frame(shard, transport_client_id, reply, "poll_messages").await;
- return;
- }
- Err(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 stream, topic, or partition id that does not resolve is a
- // client addressing error and must surface as a typed rejection,
- // not an empty poll a consumer would read as end-of-partition.
- if matches!(
- error,
- IggyError::PartitionNotFound(..)
- | IggyError::StreamIdNotFound(_)
- | IggyError::TopicIdNotFound(..)
- ) {
- warn!(
- transport_client_id,
- error = %error,
- "poll_messages rejected: target not found"
- );
- send_non_replicated_deny(shard, request, transport_client_id, error.as_code())
- .await;
- return;
- }
- // 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).
-// TODO(hubcio): plain local partition_read with no primary gate, so a
-// follower answers from its own (possibly lagging) offset state. Needs the
-// same is-caught-up-primary gate the auto-commit path has, or an explicit
-// read-from-follower contract.
-#[allow(clippy::future_not_send)]
-async fn handle_get_consumer_offset<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- request: &Message<RoutedRequestHeader>,
- user_id: Option<u32>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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(),
- }
- }
- // A partition id that does not exist in a resolvable topic is a client
- // addressing error, the same one the poll path denies typed. An empty
- // body decodes as `None` -- indistinguishable from "this consumer has
- // no stored offset yet" -- so the caller cannot tell a typo from a
- // fresh consumer.
- Err(error @ IggyError::PartitionNotFound(..)) => {
- warn!(
- transport_client_id,
- error = %error,
- "get_consumer_offset rejected: partition not found"
- );
- send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
- return;
- }
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- request: &Message<RoutedRequestHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 consumer-offset op whose body could not be rewritten for the
-/// partition plane 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- request_header: &RoutedRequestHeader,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 this shard holds a routing row for `namespace`. Fast
-/// path: row already present -> 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. This is an admission courtesy, not a correctness gate: the row
-/// is a cache of the deterministic hash assignment and may exist before the
-/// owner has materialised anything, so its presence proves only where the
-/// partition belongs. What makes an early arrival safe is the owning shard
-/// itself - `park_if_unmaterialised` holds the frame until its partition lands,
-/// and `serves_committed_incarnation` refuses to serve a mismatched
-/// incarnation. Waiting here simply keeps the steady state off that park
-/// buffer, whose overflow is the one path that still sheds a request without
-/// replying (`frame_drops_total{variant=partition,reason=park_overflow}`).
-///
-/// Deliberately no owner-readiness probe. One used to run here, on the theory
-/// that the table could not be trusted; it could not close the window either,
-/// because the fast path above skipped it in exactly the case it was meant to
-/// cover - a row seeded from the hash by a shard that owns nothing. Readiness
-/// belongs to the owner, which is where it is now enforced.
-#[allow(clippy::future_not_send)]
-async fn wait_for_partition_routable<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- namespace: IggyNamespace,
-) -> bool
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- const ATTEMPT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
- // 3s budget at 50ms per attempt. Counting attempts, not reading a
- // wall-clock deadline, keeps the wait virtual under the simulator: the
- // bus sleep advances virtual time, whereas `Instant::now` would not.
- const MAX_ATTEMPTS: u32 = 60;
-
- let mut attempts = 0u32;
- while shard.shards_table().shard_for(namespace).is_none() {
- if attempts >= MAX_ATTEMPTS {
- return false;
- }
- attempts += 1;
- shard.bus.sleep(ATTEMPT_DELAY).await;
- }
- true
-}
-
-/// 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 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 fn resolve_poll_request<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- wire: &PollMessagesRequest,
- client_id: u128,
-) -> Result<DecodedPollRequest, IggyError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 fn resolve_consumer_offset_request<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- wire: &GetConsumerOffsetRequest,
-) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- // 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)
-}
-
-/// Answer a backup's forwarded `Register` from the node it named primary.
-///
-/// Proposes in process, never through [`submit_register_local_or_forward`]:
-/// that is what bounds a forward at one hop. A node that has since lost
-/// primaryship answers `NotPrimary`, and the origin's client replays against
-/// whichever node it names next.
-#[allow(clippy::future_not_send)]
-async fn answer_forwarded_register<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- vsr_client_id: u128,
- user_id: u32,
- nonce: u128,
- origin_replica: u8,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let Some((cluster, view, replica)) = shard
- .plane
- .metadata()
- .consensus
- .as_ref()
- .map(|consensus| (consensus.cluster(), consensus.view(), consensus.replica()))
- else {
- warn!("ForwardedRegister submit reached a shard without metadata consensus");
- return;
- };
- let bound = shard
- .plane
- .metadata()
- .submit_register_in_process(vsr_client_id, user_id)
- .await;
- // `view` predates the await above, which parks with no deadline, so the
- // sealed value can be stale by send time. The origin routes the result by
- // `(nonce, client)` alone; this field must never become a freshness fence.
- let result =
- build_forward_register_result_message(cluster, view, replica, vsr_client_id, nonce, &bound);
- if let Err(error) = shard
- .bus
- .send_to_replica(origin_replica, result.into_generic().into_frozen())
- .await
- {
- warn!(
- origin_replica,
- error = %error,
- "failed to answer a forwarded register"
- );
- }
-}
-
-/// How long a login waits for the primary's verdict on a forwarded register.
-///
-/// Expiry does NOT prove the peer or the frame was lost. The primary answers
-/// only once the proposal resolves, and its own submit parks with no deadline:
-/// a primary that is not caught up, or whose pipeline is full, absorbs the
-/// register into its request queue and answers when that drains. So a slow but
-/// healthy primary commits the register after this node has stopped waiting,
-/// which is why expiry surfaces as `TransientNotCommitted` rather than the
-/// not-accepted flavor.
-///
-/// The budget stays well under the SDK's response-read timeout on purpose: the
-/// client only replays a login while it is still reading, so a longer wait
-/// here turns a transient into a torn-down socket.
-const FORWARD_SUBMIT_TIMEOUT: Duration = Duration::from_secs(5);
-
-/// Run the `Register` proposal for a login this node has already
-/// authenticated, wherever the metadata primary currently is. Shard 0 only.
-///
-/// A client may dial any node in the cluster. Credentials verify against the
-/// replicated users table, which every node holds, so the whole login except
-/// the consensus proposal already works on a backup. Only the verified
-/// identity crosses the replica interconnect -- never the client's frame and
-/// never its credentials -- and the session bind, the reply, and the
-/// connection all stay on the node the client dialed.
-///
-/// The hop does not move any credential decision:
-/// - `verify_login_credentials` reads the backup's applied replicated user
-/// state.
-/// - `verify_pat_credentials` reads the same state, so a PAT minted on the
-/// primary that has not replicated here yet is refused until it does.
-/// Fail-closed on purpose, the same parity the HTTP forward keeps: it too
-/// answers 401 until replication catches up rather than relaying an
-/// unverified bearer.
-/// - `ClientIdOwnedByAnotherUser` stays a decision of the caught-up primary
-/// and round-trips as a terminal refusal.
-///
-/// Verification is point-in-time on the backup. A password change, PAT
-/// revocation, or user deactivation committed on the primary but not yet
-/// applied on the backup can therefore admit a login during the backup's apply
-/// lag. The forward cannot complete while the backup is partitioned from the
-/// primary, which bounds this to a connected replica's replication lag. This
-/// is the same stale-read window as the existing HTTP forward.
-///
-/// The session binds here before this node applies the commit locally. That
-/// is the window a primary-side login already has against every other node's
-/// apply lag, not a new one.
-#[allow(clippy::future_not_send)]
-async fn submit_register_local_or_forward<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- vsr_client_id: u128,
- user_id: u32,
-) -> Result<BoundSession, MetadataSubmitError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let Some(consensus) = shard.plane.metadata().consensus.as_ref() else {
- return Err(MetadataSubmitError::NotPrimary);
- };
- let (cluster, view, self_replica) =
- (consensus.cluster(), consensus.view(), consensus.replica());
- let target = consensus.primary_index(view);
- // Forward only as a healthy backup. Everything else answers locally: the
- // in-process submit proposes when this node is the serving primary and
- // re-derives `NotPrimary` otherwise -- mid view change there is nobody to
- // forward to (the node the view names has not finished taking over, the
- // SDK replays once it settles), and the view's own primary under state
- // transfer has nowhere to forward to and nothing to commit yet.
- if target == self_replica || !consensus.is_normal() {
- return shard
- .plane
- .metadata()
- .submit_register_in_process(vsr_client_id, user_id)
- .await;
- }
-
- let nonce = shard.next_forward_nonce(self_replica);
- let (reply, outcome) = shard::channel::<ForwardRegisterResultHeader>(1);
- shard.park_register_forward(nonce, vsr_client_id, reply);
- let forward =
- build_forward_register_message(cluster, view, self_replica, vsr_client_id, nonce, user_id);
- if let Err(error) = shard
- .bus
- .send_to_replica(target, forward.into_generic().into_frozen())
- .await
- {
- shard.cancel_register_forward(nonce, vsr_client_id);
- warn!(
- target,
- error = %error,
- "failed to forward register to the metadata primary"
- );
- return Err(MetadataSubmitError::PrimaryUnreachable);
- }
-
- match shard::bus_timeout(&shard.bus, FORWARD_SUBMIT_TIMEOUT, outcome.recv()).await {
- Some(Ok(result)) => forward_register_result(&result),
- // Shard-0 teardown dropped the sender without answering.
- Some(Err(_)) => Err(MetadataSubmitError::Canceled),
- None => {
- shard.cancel_register_forward(nonce, vsr_client_id);
- warn!(target, "forwarded register timed out");
- Err(MetadataSubmitError::ForwardTimedOut)
- }
- }
-}
-
-/// The primary's verdict, back in the vocabulary the login path speaks.
-const fn forward_register_result(
- result: &ForwardRegisterResultHeader,
-) -> Result<BoundSession, MetadataSubmitError> {
- match result.outcome {
- ForwardRegisterOutcome::Ok => Ok(BoundSession {
- epoch: result.epoch,
- watermark: result.watermark,
- }),
- ForwardRegisterOutcome::NotPrimary => Err(MetadataSubmitError::NotPrimary),
- ForwardRegisterOutcome::NotCaughtUp => Err(MetadataSubmitError::NotCaughtUp),
- ForwardRegisterOutcome::PipelineFull => Err(MetadataSubmitError::PipelineFull),
- ForwardRegisterOutcome::InProgress => Err(MetadataSubmitError::InProgress),
- ForwardRegisterOutcome::Canceled => Err(MetadataSubmitError::Canceled),
- ForwardRegisterOutcome::ClientIdOwnedByAnotherUser => {
- Err(MetadataSubmitError::ClientIdOwnedByAnotherUser)
- }
- }
-}
-
-/// Inverse of [`forward_register_result`], for the answering primary.
-const fn forward_register_outcome(
- bound: &Result<BoundSession, MetadataSubmitError>,
-) -> (BoundSession, ForwardRegisterOutcome) {
- let zero = BoundSession {
- epoch: 0,
- watermark: 0,
- };
- match bound {
- Ok(bound) => (*bound, ForwardRegisterOutcome::Ok),
- Err(MetadataSubmitError::NotPrimary) => (zero, ForwardRegisterOutcome::NotPrimary),
- Err(MetadataSubmitError::NotCaughtUp) => (zero, ForwardRegisterOutcome::NotCaughtUp),
- Err(MetadataSubmitError::PipelineFull) => (zero, ForwardRegisterOutcome::PipelineFull),
- Err(MetadataSubmitError::InProgress) => (zero, ForwardRegisterOutcome::InProgress),
- Err(MetadataSubmitError::ClientIdOwnedByAnotherUser) => {
- (zero, ForwardRegisterOutcome::ClientIdOwnedByAnotherUser)
- }
- // `MetadataSubmitError` is `#[non_exhaustive]`. Every variant but the
- // ownership refusal is transient by contract, and `Canceled` is the
- // transient answer that claims nothing beyond "retry".
- Err(_) => (zero, ForwardRegisterOutcome::Canceled),
- }
-}
-
-#[allow(clippy::cast_possible_truncation)]
-fn build_forward_register_message(
- cluster: u128,
- view: u32,
- replica: u8,
- client: u128,
- nonce: u128,
- user_id: u32,
-) -> Message<ForwardRegisterHeader> {
- Message::<ForwardRegisterHeader>::new(HEADER_SIZE).transmute_header(
- |_, header: &mut ForwardRegisterHeader| {
- header.command = Command::ForwardRegister;
- header.cluster = cluster;
- header.view = view;
- header.replica = replica;
- header.client = client;
- header.nonce = nonce;
- header.user_id = user_id;
- header.size = HEADER_SIZE as u32;
- header.seal();
- },
- )
-}
-
-#[allow(clippy::cast_possible_truncation)]
-fn build_forward_register_result_message(
- cluster: u128,
- view: u32,
- replica: u8,
- client: u128,
- nonce: u128,
- bound: &Result<BoundSession, MetadataSubmitError>,
-) -> Message<ForwardRegisterResultHeader> {
- let (session, outcome) = forward_register_outcome(bound);
- Message::<ForwardRegisterResultHeader>::new(HEADER_SIZE).transmute_header(
- |_, header: &mut ForwardRegisterResultHeader| {
- header.command = Command::ForwardRegisterResult;
- header.cluster = cluster;
- header.view = view;
- header.replica = replica;
- header.client = client;
- header.nonce = nonce;
- header.epoch = session.epoch;
- header.watermark = session.watermark;
- header.outcome = outcome;
- header.size = HEADER_SIZE as u32;
- header.seal();
- },
- )
-}
-
-/// Answer a backup's forwarded Logout from the node it named primary.
-#[allow(clippy::future_not_send)]
-async fn answer_forwarded_logout<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- vsr_client_id: u128,
- session: u64,
- request: u64,
- nonce: u128,
- origin_replica: u8,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let Some((cluster, view, replica)) = shard
- .plane
- .metadata()
- .consensus
- .as_ref()
- .map(|consensus| (consensus.cluster(), consensus.view(), consensus.replica()))
- else {
- warn!("ForwardedLogout submit reached a shard without metadata consensus");
- return;
- };
- let outcome = shard
- .plane
- .metadata()
- .submit_logout_in_process(vsr_client_id, session, request)
- .await;
- let result =
- build_forward_logout_result_message(cluster, view, replica, vsr_client_id, nonce, &outcome);
- if let Err(error) = shard
- .bus
- .send_to_replica(origin_replica, result.into_generic().into_frozen())
- .await
- {
- warn!(
- origin_replica,
- error = %error,
- "failed to answer a forwarded logout"
- );
- }
-}
-
-/// Commit a Logout locally when this node is primary, otherwise forward it
-/// once to the primary named by the current normal view.
-#[allow(clippy::future_not_send)]
-async fn submit_logout_local_or_forward<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- vsr_client_id: u128,
- session: u64,
- request: u64,
-) -> Result<u64, MetadataSubmitError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let Some(consensus) = shard.plane.metadata().consensus.as_ref() else {
- return Err(MetadataSubmitError::NotPrimary);
- };
- let (cluster, view, self_replica) =
- (consensus.cluster(), consensus.view(), consensus.replica());
- let target = consensus.primary_index(view);
- if target == self_replica || !consensus.is_normal() {
- return shard
- .plane
- .metadata()
- .submit_logout_in_process(vsr_client_id, session, request)
- .await;
- }
-
- let nonce = shard.next_forward_nonce(self_replica);
- let (reply, outcome) = shard::channel::<ForwardLogoutResultHeader>(1);
- shard.park_logout_forward(nonce, vsr_client_id, reply);
- let forward = build_forward_logout_message(
- cluster,
- view,
- self_replica,
- vsr_client_id,
- nonce,
- session,
- request,
- );
- if let Err(error) = shard
- .bus
- .send_to_replica(target, forward.into_generic().into_frozen())
- .await
- {
- shard.cancel_logout_forward(nonce, vsr_client_id);
- warn!(
- target,
- error = %error,
- "failed to forward logout to the metadata primary"
- );
- return Err(MetadataSubmitError::PrimaryUnreachable);
- }
-
- match shard::bus_timeout(&shard.bus, FORWARD_SUBMIT_TIMEOUT, outcome.recv()).await {
- Some(Ok(result)) => forward_logout_result(&result),
- Some(Err(_)) => Err(MetadataSubmitError::Canceled),
- None => {
- shard.cancel_logout_forward(nonce, vsr_client_id);
- warn!(target, "forwarded logout timed out");
- Err(MetadataSubmitError::ForwardTimedOut)
- }
- }
-}
-
-const fn forward_logout_result(
- result: &ForwardLogoutResultHeader,
-) -> Result<u64, MetadataSubmitError> {
- match result.outcome {
- ForwardLogoutOutcome::Ok => Ok(result.commit),
- ForwardLogoutOutcome::NotPrimary => Err(MetadataSubmitError::NotPrimary),
- ForwardLogoutOutcome::PipelineFull => Err(MetadataSubmitError::PipelineFull),
- ForwardLogoutOutcome::InProgress => Err(MetadataSubmitError::InProgress),
- ForwardLogoutOutcome::Canceled => Err(MetadataSubmitError::Canceled),
- }
-}
-
-const fn forward_logout_outcome(
- outcome: &Result<u64, MetadataSubmitError>,
-) -> (u64, ForwardLogoutOutcome) {
- match outcome {
- Ok(commit) => (*commit, ForwardLogoutOutcome::Ok),
- Err(MetadataSubmitError::NotPrimary) => (0, ForwardLogoutOutcome::NotPrimary),
- Err(MetadataSubmitError::PipelineFull) => (0, ForwardLogoutOutcome::PipelineFull),
- Err(MetadataSubmitError::InProgress) => (0, ForwardLogoutOutcome::InProgress),
- Err(_) => (0, ForwardLogoutOutcome::Canceled),
- }
-}
-
-#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
-fn build_forward_logout_message(
- cluster: u128,
- view: u32,
- replica: u8,
- client: u128,
- nonce: u128,
- session: u64,
- request: u64,
-) -> Message<ForwardLogoutHeader> {
- Message::<ForwardLogoutHeader>::new(HEADER_SIZE).transmute_header(
- |_, header: &mut ForwardLogoutHeader| {
- header.command = Command::ForwardLogout;
- header.cluster = cluster;
- header.view = view;
- header.replica = replica;
- header.client = client;
- header.nonce = nonce;
- header.session = session;
- header.request = request;
- header.size = HEADER_SIZE as u32;
- header.seal();
- },
- )
-}
-
-#[allow(clippy::cast_possible_truncation)]
-fn build_forward_logout_result_message(
- cluster: u128,
- view: u32,
- replica: u8,
- client: u128,
- nonce: u128,
- result: &Result<u64, MetadataSubmitError>,
-) -> Message<ForwardLogoutResultHeader> {
- let (commit, outcome) = forward_logout_outcome(result);
- Message::<ForwardLogoutResultHeader>::new(HEADER_SIZE).transmute_header(
- |_, header: &mut ForwardLogoutResultHeader| {
- header.command = Command::ForwardLogoutResult;
- header.cluster = cluster;
- header.view = view;
- header.replica = replica;
- header.client = client;
- header.nonce = nonce;
- header.commit = commit;
- header.outcome = outcome;
- header.size = HEADER_SIZE as u32;
- header.seal();
- },
- )
-}
-
-/// 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 goes straight to
-/// [`submit_register_local_or_forward`]; 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 async fn submit_register_on_owner<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- vsr_client_id: u128,
- user_id: u32,
-) -> Result<BoundSession, MetadataSubmitError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- if shard.id == 0 {
- return submit_register_local_or_forward(shard, vsr_client_id, user_id).await;
- }
- let (reply, rx) = shard::channel::<Result<BoundSession, MetadataSubmitError>>(1);
- shard.forward_metadata_submit(shard::MetadataSubmit::Register {
- vsr_client_id,
- user_id,
- reply,
- });
- // The owner's outcome, verbatim in both directions. `Canceled` is only for a
- // dropped channel, where nothing came back to classify.
- rx.recv()
- .await
- .unwrap_or(Err(MetadataSubmitError::Canceled))
-}
-
-/// Logout counterpart of [`submit_register_on_owner`].
-#[allow(clippy::future_not_send)]
-pub async fn submit_logout_on_owner<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- vsr_client_id: u128,
- session: u64,
- request: u64,
-) -> Result<u64, MetadataSubmitError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- if shard.id == 0 {
- return submit_logout_local_or_forward(shard, vsr_client_id, session, request).await;
- }
- let (reply, rx) = shard::channel::<Result<u64, MetadataSubmitError>>(1);
- shard.forward_metadata_submit(shard::MetadataSubmit::Logout {
- vsr_client_id,
- session,
- request,
- reply,
- });
- rx.recv()
- .await
- .unwrap_or(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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- bound: Option<(u128, u64)>,
- request: &Message<RoutedRequestHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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,
- // advancing the watermark. Skipping the commit (or attributing it to an
- // internal id) leaves this request id unrecorded, so the SDK's own retry
- // of it would re-execute instead of deduping. 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 async fn resolve_delete_segments_truncate<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- template: &RoutedRequestHeader,
- client_id: u128,
- session: u64,
- body: &[u8],
-) -> Result<Message<RoutedRequestHeader>, IggyError>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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, so the failure is recorded against the client's request id
- // and its retry dedups, while the client gets the typed error an
- // empty ack would swallow.
- 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,
- ))
-}
-
-/// Release the client-table slot for a disconnected transport, cluster-wide.
-///
-/// The local `SessionManager` connection is already dropped by the caller;
-/// this is what drops the replicated entry, so a peer replica does not keep an
-/// orphaned session until it evicts one under capacity pressure.
-///
-/// Unconditional, and deliberately so. Holding the slot open for a grace
-/// window would let a reconnecting client resume onto its entry with its
-/// watermark and reply ring intact, but nothing in tree re-presents a
-/// `client_id` after a disconnect (the Rust SDK mints a fresh one on
-/// re-login), so the window buys nothing today and the slot it holds is not
-/// free: the client table's eviction point moves from concurrent connections
-/// to CUMULATIVE connects, and every capacity eviction silently erases a
-/// dedup watermark.
-///
-/// A resume window becomes worth having once SDK-side identity stability
-/// lands, at which point it needs a timer of its own -- riding the heartbeat
-/// verifier would tie the grace period to heartbeat configuration, since
-/// `collect_stale` keys off `heartbeat.interval` and the verifier does not run
-/// at all when `heartbeat.enabled` is false.
-/// 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<B, MJ, S, SB>(
- shard: Rc<ShellShard<B, MJ, S, SB>>,
- vsr_client_id: u128,
- session: u64,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- // The sentinel request id is what the apply path reads to keep, rather
- // than drop, the session's dedup fence: the client may be reconnecting
- // under the same key, and its retry must still be answered.
- let bus = shard.bus.clone();
- bus.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"
- );
- }
- });
-}
-
-/// 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 async fn submit_client_request_on_owner<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- request: Message<RoutedRequestHeader>,
-) -> Option<Message<GenericHeader>>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- transport_client_id: u128,
- request: Message<RoutedRequestHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- let Some((vsr_client_id, session)) = sessions.borrow().get_session(transport_client_id) else {
- // Logout on an unbound transport: the desired state already holds,
- // so answer ok. A silent drop would wedge the lockstep SDK on this
- // connection until its socket read timeout, and the SDK routinely
- // sends a logout before each re-login.
- warn!(
- transport_client_id,
- "logout for unbound VSR session; answering ok"
- );
- 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,
- "failed to send unbound logout reply"
- );
- }
- 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) => {
- // Deny as transient instead of dropping the frame: the submit
- // usually fails because this replica is not the metadata owner
- // right now, and the SDK replays a transient rejection.
- warn!(transport_client_id, error = %error, "logout/unregister failed; denying transient");
- let commit = current_metadata_commit(shard);
- let reply = build_deny_reply(
- request.header(),
- vsr_client_id,
- session,
- commit,
- transient_logout_code(&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 logout deny reply"
- );
- }
- 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"
- );
- }
-}
-
-/// Preserve the client identity when a Logout may already have entered the
-/// primary's pipeline. Moving an unknown-outcome replay to another connection
-/// could race a later Register and obscure whether the old epoch was removed.
-const fn transient_logout_code(error: &MetadataSubmitError) -> IggyError {
- match error {
- MetadataSubmitError::ForwardTimedOut
- | MetadataSubmitError::InProgress
- | MetadataSubmitError::Canceled => IggyError::TransientNotCommitted,
- _ => IggyError::TransientNotAccepted,
- }
-}
-
-fn ensure_transport_connection<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- transport_client_id: u128,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- sessions: &Rc<RefCell<SessionManager>>,
- transport_client_id: u128,
- request: Message<RoutedRequestHeader>,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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..];
- let mut credentials_rejected = false;
- 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. A password-shaped body rarely parses as a PAT
- // body, so remember the rejection: the final fall-through
- // must surface InvalidCredentials, not MalformedLogin.
- credentials_rejected = true;
- }
- 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;
- }
- }
- }
-
- if credentials_rejected {
- warn!(
- transport_client_id,
- "rejecting register request: invalid credentials"
- );
- send_login_eviction(
- shard,
- transport_client_id,
- request.header().client,
- EvictionReason::InvalidCredentials,
- )
- .await;
- return;
- }
-
- warn!(
- transport_client_id,
- "rejecting register request with unsupported payload shape"
- );
- send_login_eviction(
- shard,
- transport_client_id,
- request.header().client,
- EvictionReason::MalformedLogin,
- )
- .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)]
-pub async fn send_login_eviction<B, MJ, S, SB>(
- shard: &Rc<ShellShard<B, MJ, S, SB>>,
- transport_client_id: u128,
- vsr_client_id: u128,
- reason: EvictionReason,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- 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 fn upgrade_shard_handle<B, MJ, S, SB>(
- shard_handle: &ShellShardHandle<B, MJ, S, SB>,
-) -> Option<Rc<ShellShard<B, MJ, S, SB>>>
-where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- S: 'static,
- SB: SuperblockStore + 'static,
-{
- shard_handle
- .borrow()
- .as_ref()
- .and_then(std::rc::Weak::upgrade)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use consensus::{LocalPipeline, Plane as _, PlaneKind, VsrConsensus};
- use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
- use iggy_binary_protocol::requests::messages::SendMessagesHeader;
- use iggy_binary_protocol::requests::streams::CreateStreamRequest;
- use iggy_binary_protocol::requests::topics::CreateTopicWithAssignmentsRequest;
- use iggy_binary_protocol::{PrepareOkHeader, ReplyHeader, WireName, WirePartitioning};
- use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
- use iggy_common::variadic;
- use journal::prepare_journal::PrepareJournal;
- use message_bus::BusMessage;
- use message_bus::client_listener::RequestHandler;
- use message_bus::fd_transfer::DupedFd;
- use message_bus::installer::ConnectionInstaller;
- use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
- use message_bus::replica::listener::MessageHandler;
- use message_bus::{
- ClientConnectionLostFn, ClientForwardFn, ConnectionLostFn, JoinHandle, MessageBus,
- ReplicaForwardFn, ReplicaHandshakeDoneFn, SendError,
- };
- use metadata::impls::metadata::IggySnapshot;
- use metadata::stm::StateMachine as _;
- use metadata::stm::stream::Streams;
- use metadata::stm::user::Users;
- use metadata::{IggyMetadata, MuxStateMachine};
- use partitions::{IggyPartitions, PartitionPathLayout, PartitionsConfig};
- use server_common::iobuf::Frozen;
- use server_common::sharding::ShardId;
- use server_common::{MESSAGE_ALIGN, Message, MessageBag};
- use shard::metrics::ShardMetrics;
- use shard::shards_table::PapayaShardsTable;
- use shard::{
- IggyShard, LifecycleFrame, PartitionConsensusConfig, ReconcileOp, ReplicaTopology,
- ShardFrame, ShardIdentity, shard_channel,
- };
- use std::cell::{Cell, RefCell};
- use std::future::Future;
- use std::mem::size_of;
- use std::rc::Rc;
- use std::sync::atomic::AtomicBool;
-
- type TestMux = MuxStateMachine<variadic!(Users, Streams)>;
- type TestShard = IggyShard<SpyBus, PrepareJournal, IggySnapshot, TestMux, PapayaShardsTable>;
- /// `(target client id, reply frame bytes)` per `send_to_client` call.
- type RecordedReplies = Rc<RefCell<Vec<(u128, Vec<u8>)>>>;
- /// `(target replica id, frame bytes)` per `send_to_replica` call.
- type RecordedReplicaSends = Rc<RefCell<Vec<(u8, Vec<u8>)>>>;
-
- /// Records every client-bound reply and replica-bound frame (target +
- /// bytes) instead of writing to a socket; everything else is a no-op. The
- /// two `ShellBus` halves are stubbed.
- #[derive(Debug, Clone, Default)]
- struct SpyBus {
- client_replies: RecordedReplies,
- replica_sends: RecordedReplicaSends,
- /// Resolve [`MessageBus::sleep`] immediately instead of arming a real
- /// timer. The register forward is the only path here that races a
- /// timer, and its budget is five seconds -- too long to wait for in a
- /// unit test, and too long to shorten in production for one.
- instant_timers: Rc<Cell<bool>>,
- }
-
- impl SpyBus {
- /// Decode the single frame this bus sent to a replica.
- fn sole_replica_send<H: iggy_binary_protocol::ConsensusHeader>(&self) -> (u8, H) {
- let sends = self.replica_sends.borrow();
- assert_eq!(sends.len(), 1, "expected exactly one replica-bound frame");
- let (target, frame) = &sends[0];
- let mut aligned = server_common::iobuf::Owned::<MESSAGE_ALIGN>::zeroed(frame.len());
- aligned.as_mut_slice().copy_from_slice(frame);
- let header =
- *bytemuck::checked::try_from_bytes::<H>(&aligned.as_slice()[..size_of::<H>()])
- .expect("replica frame decodes into the expected header");
- (*target, header)
- }
- }
-
- #[allow(clippy::future_not_send)]
- impl MessageBus for SpyBus {
- fn track_background(&self, _handle: JoinHandle<()>) {}
- async fn send_to_client(
- &self,
- client_id: u128,
- data: impl Into<BusMessage>,
- ) -> Result<(), SendError> {
- self.client_replies
- .borrow_mut()
- .push((client_id, data.into().into_contiguous().as_slice().to_vec()));
- Ok(())
- }
- async fn send_to_replica(
- &self,
- replica: u8,
- data: Frozen<MESSAGE_ALIGN>,
- ) -> Result<(), SendError> {
- self.replica_sends
- .borrow_mut()
- .push((replica, data.as_slice().to_vec()));
- Ok(())
- }
- async fn sleep(&self, duration: std::time::Duration) {
- if !self.instant_timers.get() {
- compio::time::sleep(duration).await;
- }
- }
- fn set_connection_lost_fn(&self, _f: ConnectionLostFn) {}
- fn set_replica_forward_fn(&self, _f: ReplicaForwardFn) {}
- fn set_client_forward_fn(&self, _f: ClientForwardFn) {}
- }
-
- impl ConnectionInstaller for SpyBus {
- fn install_replica_inbound_fd(
- &self,
- _fd: DupedFd,
- _on_message: MessageHandler,
- _on_done: ReplicaHandshakeDoneFn,
- ) {
- }
- fn install_replica_outbound_fd(
- &self,
- _fd: DupedFd,
- _replica_id: u8,
- _on_message: MessageHandler,
- _on_done: ReplicaHandshakeDoneFn,
- ) {
- }
- fn release_replica_handshake_slot(&self, _slot: u64) {}
- fn clear_replica_dial_pending(&self, _replica_id: u8) {}
- fn install_client_fd(
- &self,
- _fd: DupedFd,
- _meta: ClientConnMeta,
- _on_request: RequestHandler,
- ) {
- }
- fn install_client_ws_fd(
- &self,
- _fd: DupedFd,
- _meta: ClientConnMeta,
- _on_request: RequestHandler,
- ) {
- }
- fn client_meta(&self, _client_id: u128) -> Option<Rc<ClientConnMeta>> {
- None
- }
- fn set_client_connection_lost_fn(&self, _f: ClientConnectionLostFn) {}
- }
-
- /// Consensus incarnations standing for two successive boots of one node, as
- /// far apart as the random draw at bootstrap makes them.
- const FIRST_BOOT: u128 = 0x5EED_0001;
- const SECOND_BOOT: u128 = 0x9E37_79B9_7F4A_7C15;
-
- /// Shard 0 carrying a metadata consensus group of `replica_count`
- /// replicas in which this node is `replica`. No journal: every test using
- /// it either never proposes, or is a backup that cannot.
- ///
- /// `incarnation` stands for one boot of this node: the shard seeds its
- /// forward-nonce counter from it, so passing a different value models a
- /// restart.
- fn test_shard(bus: &SpyBus, replica: u8, replica_count: u8, incarnation: u128) -> TestShard {
- let consensus = VsrConsensus::new(
- 1,
- replica,
- replica_count,
- server_common::sharding::METADATA_GROUP,
- bus.clone(),
- LocalPipeline::new(),
- );
- consensus.set_incarnation(incarnation);
- consensus.init();
- let metadata: IggyMetadata<_, PrepareJournal, IggySnapshot, TestMux> =
- IggyMetadata::new(Some(consensus), None, None, None, TestMux::default(), None);
- let partitions = IggyPartitions::new(
- ShardId::new(0),
- PartitionsConfig {
- messages_required_to_save: 1,
- size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- validate_checksum: true,
- segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
- preallocate_segments: false,
- encryptor: None,
- path_layout: PartitionPathLayout::default(),
- },
- );
- TestShard::without_inbox(
- ShardIdentity::new(0, "dispatch-test".to_string()),
- bus.clone(),
- metadata,
- partitions,
- PapayaShardsTable::new(),
- PartitionConsensusConfig::new(
- 1,
- ReplicaTopology::new(replica, replica_count),
- bus.clone(),
- ),
- )
- }
-
- /// Minimal committed `Register` reply for `ClientTable::commit_register`
- /// (reads only `client` and `commit`).
- fn register_reply(client: u128, session: u64) -> Message<ReplyHeader> {
- let header_size = size_of::<ReplyHeader>();
- let mut reply = Message::<ReplyHeader>::new(header_size);
- let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(
- &mut reply.as_mut_slice()[..header_size],
- )
- .expect("zeroed bytes are a valid ReplyHeader");
- *header = ReplyHeader {
- client,
- request: 0,
- commit: session,
- command: Command::Reply,
- operation: Operation::Register,
- ..Default::default()
- };
- reply
- }
-
- fn request_message(
- operation: Operation,
- client: u128,
- session: u64,
- request: u64,
- body: &[u8],
- ) -> Message<RoutedRequestHeader> {
- let header_size = size_of::<RoutedRequestHeader>();
- let total = header_size + body.len();
- let mut message = Message::<RoutedRequestHeader>::new(total);
- {
- let slice = message.as_mut_slice();
- slice[header_size..total].copy_from_slice(body);
- let header =
- bytemuck::checked::from_bytes_mut::<RoutedRequestHeader>(&mut slice[..header_size]);
- *header = RoutedRequestHeader {
- command: Command::Request,
- operation,
- size: u32::try_from(total).expect("test request fits u32"),
- client,
- session,
- request,
- user_id: 0,
- group: server_common::sharding::METADATA_GROUP,
- ..Default::default()
- };
- }
- message
- }
-
- /// Raw prepare for the sibling op, standing in for the crate-private
- /// `prepare_request` projection. `user_id` 0 skips the in-apply RBAC
- /// gate (server-originated convention), so the op applies cleanly.
- fn prepare_message(
- operation: Operation,
- client: u128,
- request: u64,
- body: &[u8],
- ) -> Message<PrepareHeader> {
- let header_size = size_of::<PrepareHeader>();
- let total = header_size + body.len();
- let mut message = Message::<PrepareHeader>::new(total);
- {
- let slice = message.as_mut_slice();
- slice[header_size..total].copy_from_slice(body);
- let header =
- bytemuck::checked::from_bytes_mut::<PrepareHeader>(&mut slice[..header_size]);
- *header = PrepareHeader {
- command: Command::Prepare,
- operation,
- size: u32::try_from(total).expect("test prepare fits u32"),
- op: 1,
- view: 0,
- client,
- request,
- user_id: 0,
- group: server_common::sharding::METADATA_GROUP,
- ..Default::default()
- };
- }
- // A real identity, not a placeholder: `on_replicate` recomputes it before the
- // prepare reaches the WAL, so an arbitrary value reads as transit corruption.
- consensus::seal_prepare_checksum(message)
- }
-
- /// Regression test for the production failure chain "CLI stream
- /// create succeeded, logout failed: Disconnected".
- ///
- /// Why the logout of a CLI invocation used to fail during ITS OWN
- /// successful `stream create`: the catch-up gate was GLOBAL. The suite
- /// runs many CLI invocations against one shared single-node server;
- /// each one is three replicated ops (Register, work, Logout). When
- /// THIS client's logout frame arrived, some SIBLING client's op was
- /// regularly sitting between quorum-ack (`commit_max` advanced inside
- /// `on_ack`) and apply (`commit_min` still behind, driver parked at
- /// the journal read). `submit_logout_in_process` then rejected
- /// `NotCaughtUp`, and `handle_logout_request` swallowed the error: no
- /// reply frame, session left bound. A one-shot CLI saw only a dead
- /// connection — "Problem with server logout / Disconnected" — and
- /// exited non-zero although its create committed; the harness retry
- /// then tripped "already exists".
- ///
- /// This test rebuilds that interleaving deterministically (client B =
- /// the sibling parked mid-commit; client A = the CLI logging out) and
- /// pins the contract that fixed it (non-register ops carry no
- /// catch-up gate, see `submit_logout_in_process`):
- ///
- /// a client-initiated logout must always produce a reply frame and
- /// unbind the transport session, even while a sibling's commit is
- /// in flight — the logout simply pipelines behind it.
- #[compio::test]
- async fn logout_rejected_by_closed_gate_must_still_reply_to_client() {
- const CLIENT_A: u128 = 1;
- const CLIENT_B: u128 = 2;
- const SESSION: u64 = 1;
- const ACTING_USER: u32 = 7;
- const TRANSPORT_A: u128 = 77;
-
- let dir = tempfile::tempdir().unwrap();
- let journal = PrepareJournal::open(&dir.path().join("journal.wal"), 0)
- .await
- .unwrap();
- let bus = SpyBus::default();
- let consensus = VsrConsensus::new(
- 1,
- 0,
- 1,
- server_common::sharding::METADATA_GROUP,
- bus.clone(),
- LocalPipeline::new(),
- );
- consensus.init();
- let metadata: IggyMetadata<_, PrepareJournal, IggySnapshot, TestMux> = IggyMetadata::new(
- Some(consensus),
- Some(journal),
- None,
- None,
- TestMux::default(),
- None,
- );
- let partitions = IggyPartitions::new(
- ShardId::new(0),
- PartitionsConfig {
- messages_required_to_save: 1,
- size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- validate_checksum: true,
- segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
- preallocate_segments: false,
- encryptor: None,
- path_layout: PartitionPathLayout::default(),
- },
- );
- let shard = Rc::new(TestShard::without_inbox(
- ShardIdentity::new(0, "logout-window-test".to_string()),
- bus.clone(),
- metadata,
- partitions,
- PapayaShardsTable::new(),
- PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
- ));
- let md = shard.plane.metadata();
- let consensus = md.consensus.as_ref().unwrap();
-
- // A and B hold committed sessions (as after their CLI logins).
- for client in [CLIENT_A, CLIENT_B] {
- md.client_table.borrow_mut().commit_register(
- client,
- ACTING_USER,
- register_reply(client, SESSION),
- );
- }
- // A's transport connection, authenticated + bound — the state a
- // CLI connection is in right after its create-stream reply.
- let sessions = Rc::new(RefCell::new(SessionManager::new()));
- sessions.borrow_mut().ensure_connection(
- TRANSPORT_A,
- "127.0.0.1:34567".parse().unwrap(),
- ClientTransportKind::Tcp,
- );
- sessions
- .borrow_mut()
- .login(TRANSPORT_A, ACTING_USER)
- .unwrap();
- sessions
- .borrow_mut()
- .bind_session(TRANSPORT_A, CLIENT_A, SESSION)
- .unwrap();
-
- // Sibling B's op: prepared, journaled, self-acked through the real
- // replicate path. (The public submit API cannot be used to open
- // the window: `dispatch_prepare_and_await` pumps its own loopback
- // inline, committing before it returns. Production's window is a
- // sibling submit task parked INSIDE `on_ack`'s awaits — modeled
- // below by driving `on_ack` by hand.)
- let create_body = CreateStreamRequest {
- name: iggy_binary_protocol::primitives::identifier::WireName::new("s1").unwrap(),
- options: WireOptions::empty(),
- }
- .to_bytes();
- let prepare = prepare_message(Operation::CreateStream, CLIENT_B, 1, &create_body);
- consensus.pipeline_message(PlaneKind::Metadata, &prepare);
- md.on_replicate(prepare).await;
- let mut loopback = Vec::new();
- consensus.drain_loopback_into(&mut loopback);
- let ack = loopback
- .pop()
- .expect("one self-ack per replicated prepare")
- .try_into_typed::<PrepareOkHeader>()
- .expect("loopback holds self PrepareOks");
-
- // Open the window: first poll of `on_ack` advances commit_max at
- // quorum, then parks at the journal read — commit_min unchanged.
- // Every production NotCaughtUp logout was submitted exactly here.
- let waker = std::task::Waker::noop();
- let mut cx = std::task::Context::from_waker(waker);
- let mut driver = Box::pin(md.on_ack(ack));
- assert!(
- driver.as_mut().poll(&mut cx).is_pending(),
- "driver must park mid-commit at the journal read"
- );
- assert_eq!(consensus.commit_max(), 1);
- assert_eq!(consensus.commit_min(), 0);
-
- // A's logout lands in the window, through the real dispatch path.
- let logout = request_message(Operation::Logout, CLIENT_A, SESSION, 2, &[]);
- handle_logout_request(&shard, &sessions, TRANSPORT_A, logout).await;
-
- // DESIRED CONTRACT (red on current code): the client must never be
- // left in silence — that silence is what a one-shot CLI reports as
- // "Problem with server logout / Disconnected".
- assert!(
- bus.client_replies
- .borrow()
- .iter()
- .any(|(client, _)| *client == TRANSPORT_A),
- "logout must produce a reply frame to the client even while the \
- catch-up gate is closed (silence = CLI 'Disconnected', exit 1)"
- );
- assert_eq!(
- sessions.borrow().get_session(TRANSPORT_A),
- None,
- "transport session must be unbound by a client-initiated logout; \
- the VSR slot may lapse to the eviction sweep"
- );
- }
-
- /// A partition write whose routable wait exhausts (namespace committed,
- /// but no reconciler ever seeds this shard's routing row -- the state a
- /// teardown/rematerialise churn leaves behind) must answer a nonzero
- /// retriable status. A status-0 empty reply is a fabricated success: the
- /// SDK grades the send as acknowledged while zero bytes reached any
- /// partition.
- #[compio::test]
- async fn unroutable_partition_send_must_reply_transient_error_not_success() {
- const VSR_CLIENT: u128 = 1;
- const SESSION: u64 = 1;
- const TRANSPORT: u128 = 91;
- const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
-
- let bus = SpyBus::default();
- let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
- let partitions = IggyPartitions::new(
- ShardId::new(0),
- PartitionsConfig {
- messages_required_to_save: 1,
- size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- validate_checksum: true,
- segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
- preallocate_segments: false,
- encryptor: None,
- path_layout: PartitionPathLayout::default(),
- },
- );
- let shard = Rc::new(TestShard::without_inbox(
- ShardIdentity::new(0, "unroutable-send-test".to_string()),
- bus.clone(),
- metadata,
- partitions,
- PapayaShardsTable::new(),
- PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
- ));
- let md = shard.plane.metadata();
-
- // Committed stream 0 / topic 0 / partition 0, applied straight into
- // the STM: the namespace resolves and root authorizes, but no
- // reconciler runs, so the shards table never gains a routing row and
- // the routable wait exhausts its budget.
- md.mux_stm.users().ensure_root_user("iggy", "hash");
- let create_stream = CreateStreamRequest {
- name: WireName::new("stream").unwrap(),
- options: WireOptions::empty(),
- };
- md.mux_stm
- .update(prepare_message(
- Operation::CreateStream,
- VSR_CLIENT,
- 1,
- &create_stream.to_bytes(),
- ))
- .unwrap();
- let create_topic = CreateTopicWithAssignmentsRequest {
- request: CreateTopicRequest {
- stream_id: WireIdentifier::numeric(0),
- partitions_count: 1,
- name: WireName::new("topic").unwrap(),
- options: WireOptions::empty(),
- },
- derived_options: WireOptions::empty(),
- partitions: vec![CreatedPartitionAssignment {
- partition_id: 0,
- consensus_group_id: 1,
- }],
- created_view: 0,
- };
- md.mux_stm
- .update(prepare_message(
- Operation::CreateTopicWithAssignments,
- VSR_CLIENT,
- 2,
- &create_topic.to_bytes(),
- ))
- .unwrap();
- assert!(
- md.mux_stm
- .streams()
- .namespace_from_partition(
- &WireIdentifier::numeric(0),
- &WireIdentifier::numeric(0),
- 0
- )
- .is_some(),
- "seeded namespace must resolve, or the unresolved-namespace path \
- would reply instead of the exhausted routable wait"
- );
-
- let send_header = SendMessagesHeader {
- stream_id: WireIdentifier::numeric(0),
- topic_id: WireIdentifier::numeric(0),
- partitioning: WirePartitioning::PartitionId(0),
- messages_count: 1,
- };
- let send_metadata = send_header.to_bytes();
- let mut send_body = Vec::with_capacity(4 + send_metadata.len());
- send_body.extend_from_slice(&u32::try_from(send_metadata.len()).unwrap().to_le_bytes());
- send_body.extend_from_slice(&send_metadata);
- let request = request_message(Operation::SendMessages, VSR_CLIENT, SESSION, 1, &send_body);
-
- dispatch_partition_request(
- &shard,
- request,
- VSR_CLIENT,
- SESSION,
- TRANSPORT,
- Some(DEFAULT_ROOT_USER_ID),
- )
- .await;
-
- let replies = bus.client_replies.borrow();
- assert_eq!(replies.len(), 1, "one reply frame for the failed send");
- let (client, frame) = &replies[0];
- assert_eq!(*client, TRANSPORT, "reply must target the transport id");
- let status =
- u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
- assert_eq!(
- status,
- IggyError::TransientNotAccepted.as_code(),
- "an unroutable partition write must surface the retriable \
- transient status; status 0 with an empty body grades as a \
- successfully acknowledged send"
- );
- }
-
- /// A send that reaches the owning shard while its namespace is
- /// tombstoned (the teardown fence a delete/recreate churn sets before
- /// the disk delete) must answer the retriable transient status. The
- /// partition plane's own tombstone guard drops the frame without any
- /// reply; the transports decode replies in lockstep, so that silence
- /// wedges the connection until the SDK's response read-timeout.
- #[compio::test]
- async fn tombstoned_partition_send_must_reply_transient_error_not_silence() {
- const TRANSPORT: u128 = 91;
- const SESSION: u64 = 1;
- const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
-
- let bus = SpyBus::default();
- let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
- let partitions = IggyPartitions::new(
- ShardId::new(0),
- PartitionsConfig {
- messages_required_to_save: 1,
- size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- validate_checksum: true,
- segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
- preallocate_segments: false,
- encryptor: None,
- path_layout: PartitionPathLayout::default(),
- },
- );
- let shard = Rc::new(TestShard::without_inbox(
- ShardIdentity::new(0, "tombstoned-send-test".to_string()),
- bus.clone(),
- metadata,
- partitions,
- PapayaShardsTable::new(),
- PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
- ));
-
- let namespace = IggyNamespace::new(0, 0, 0);
- shard.plane.partitions().tombstone(namespace);
-
- let request = request_message(Operation::SendMessages, TRANSPORT, SESSION, 1, &[])
- .transmute_header(|header, new_header: &mut RoutedRequestHeader| {
- *new_header = header;
- new_header.group = namespace.inner();
- });
- shard.on_message(MessageBag::Request(request)).await;
-
- let replies = bus.client_replies.borrow();
- assert_eq!(
- replies.len(),
- 1,
- "a send into a tombstoned namespace must produce a reply frame; \
- silence wedges the connection's lockstep decode"
- );
- let (client, frame) = &replies[0];
- assert_eq!(*client, TRANSPORT, "reply must target the request's client");
- let status =
- u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
- assert_eq!(
- status,
- IggyError::TransientNotAccepted.as_code(),
- "a tombstoned-namespace send must surface the retriable transient \
- status so the SDK replays it after the partition rematerialises"
- );
- }
-
- /// A test shard wired to its own lanes (the held sender feeds them),
- /// for the reply-lane pump tests below.
- fn reply_lane_test_shard(name: &str) -> (SpyBus, shard::TaggedSender, Rc<TestShard>) {
- let bus = SpyBus::default();
- let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
- let partitions = IggyPartitions::new(
- ShardId::new(0),
- PartitionsConfig {
- messages_required_to_save: 1,
- size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- validate_checksum: true,
- segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
- preallocate_segments: false,
- encryptor: None,
- path_layout: PartitionPathLayout::default(),
- },
- );
- let (sender, inbox_rx, reply_inbox_rx) = shard_channel(0, 16, 16);
- let lane_sender = sender.clone();
- let shard = TestShard::new(
- ShardIdentity::new(0, name.to_string()),
- bus.clone(),
- Rc::new(|_, _| {}),
- Rc::new(|_, _| {}),
- Rc::new(|_| {}),
- Rc::new(|_| {}),
- Rc::new(|_, _, _| {}),
- metadata,
- partitions,
- vec![sender],
- inbox_rx,
- reply_inbox_rx,
- PapayaShardsTable::new(),
- PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
- None,
- ShardMetrics::for_shard(),
- )
- .expect("single-sender ring is canonically ordered");
- (bus, lane_sender, Rc::new(shard))
- }
-
- fn reply_lane_forward(client_id: u128) -> ShardFrame {
- ShardFrame::lifecycle(LifecycleFrame::ForwardClientSend {
- client_id,
- msg: server_common::iobuf::Frozen::from(
- server_common::iobuf::Owned::<MESSAGE_ALIGN>::zeroed(64),
- )
- .into(),
- })
- }
-
- /// A frame on the reply lane must reach the client through the RUNNING
- /// pump's reply arm: the lane split moved `ForwardClientSend` off the
- /// main inbox, so a pump that forgot to service the new lane would
- /// strand every cross-shard reply while the send sites happily report
- /// success.
- #[compio::test]
- async fn pump_live_arm_delivers_reply_lane_forwards() {
- const TRANSPORT: u128 = 92;
- let (bus, lane_sender, shard) = reply_lane_test_shard("reply-lane-live-arm-test");
-
- let (stop_tx, stop_rx) = shard::channel::<()>(1);
- let pump_shard = Rc::clone(&shard);
- let pump = compio::runtime::spawn(async move {
- pump_shard
- .run_message_pump(stop_rx, Arc::new(AtomicBool::new(false)))
- .await;
- });
-
- lane_sender
- .reply_sender()
- .try_send(reply_lane_forward(TRANSPORT))
- .expect("reply lane has capacity");
-
- // The pump is idle on the main lane, so its bottom reply arm must
- // serve the frame without any main-lane traffic or shutdown drain.
- let mut delivered = false;
- for _ in 0..500 {
- if !bus.client_replies.borrow().is_empty() {
- delivered = true;
- break;
- }
- compio::time::sleep(std::time::Duration::from_millis(1)).await;
- }
- stop_tx.try_send(()).expect("stop channel has capacity");
- let _ = pump.await;
-
- assert!(
- delivered,
- "the live reply arm must deliver a forward while the pump runs"
- );
- let replies = bus.client_replies.borrow();
- assert_eq!(replies[0].0, TRANSPORT, "forward must reach its client");
- }
-
- /// The shutdown path must ALSO deliver reply-lane frames: a forward
- /// already accepted by the lane when the stop signal wins the biased
- /// select would otherwise be silently destroyed at teardown.
- #[compio::test]
- async fn pump_shutdown_drain_delivers_reply_lane_forwards() {
- const TRANSPORT: u128 = 93;
- let (bus, lane_sender, shard) = reply_lane_test_shard("reply-lane-drain-test");
-
- lane_sender
- .reply_sender()
- .try_send(reply_lane_forward(TRANSPORT))
- .expect("reply lane has capacity");
-
- // Pre-armed stop: the pump exits through the biased stop arm and the
- // post-loop drain must still deliver the reply-lane frame.
- let (stop_tx, stop_rx) = shard::channel::<()>(1);
- stop_tx.try_send(()).expect("stop channel has capacity");
- shard
- .run_message_pump(stop_rx, Arc::new(AtomicBool::new(false)))
- .await;
-
- let replies = bus.client_replies.borrow();
- assert_eq!(
- replies.len(),
- 1,
- "the pump's reply-lane drain must deliver the forwarded reply"
- );
- assert_eq!(
- replies[0].0, TRANSPORT,
- "the forward must reach the client it was addressed to"
- );
- }
-
- /// A send parked for a namespace that is torn down before materialising
- /// (create -> delete before the reconciler's `InsertOwned`) is discarded
- /// on `ConfirmRemove`. The discard must stage the same retriable
- /// transient deny toward the client -- through the shard's own pump as a
- /// `ForwardClientSend` -- instead of dropping the request without any
- /// reply.
- #[compio::test]
- async fn discarded_parked_partition_send_must_reply_transient_error_not_silence() {
- const TRANSPORT: u128 = 91;
- const SESSION: u64 = 1;
- const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
-
- let bus = SpyBus::default();
- let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
- let partitions = IggyPartitions::new(
- ShardId::new(0),
- PartitionsConfig {
- messages_required_to_save: 1,
- size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
- enforce_fsync: false,
- validate_checksum: true,
- segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
- preallocate_segments: false,
- encryptor: None,
- path_layout: PartitionPathLayout::default(),
- },
- );
- // Real sender ring so the staged deny is observable: the test holds
- // the receiving ends of this shard's own lanes. The deny is a client
- // Reply forward, so it lands on the REPLY lane.
- let (sender, _pump_rx, reply_rx) = shard_channel(0, 16, 16);
- let (_inbox_tx, inbox_rx, reply_inbox_rx) = shard_channel(0, 1, 1);
- let shard = TestShard::new(
- ShardIdentity::new(0, "discarded-parked-send-test".to_string()),
- bus.clone(),
- Rc::new(|_, _| {}),
- Rc::new(|_, _| {}),
- Rc::new(|_| {}),
- Rc::new(|_| {}),
- Rc::new(|_, _, _| {}),
- metadata,
- partitions,
- vec![sender],
- inbox_rx,
- reply_inbox_rx,
- PapayaShardsTable::new(),
- PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
- None,
- ShardMetrics::for_shard(),
- )
- .expect("single-sender ring is canonically ordered");
-
- let namespace = IggyNamespace::new(0, 0, 0);
- let request = request_message(Operation::SendMessages, TRANSPORT, SESSION, 1, &[])
- .transmute_header(|header, new_header: &mut RoutedRequestHeader| {
- *new_header = header;
- new_header.group = namespace.inner();
- });
- // Namespace neither materialised nor tombstoned: the frame parks.
- shard.on_message(MessageBag::Request(request)).await;
-
- shard.enqueue_reconcile_op(ReconcileOp::ConfirmRemove { namespace });
- shard.apply_reconcile_ops();
-
- let mut denies = Vec::new();
- while let Ok(frame) = reply_rx.try_recv() {
- if let ShardFrame::Lifecycle(LifecycleFrame::ForwardClientSend { client_id, msg }) =
- frame
- {
- denies.push((client_id, msg.into_contiguous().as_slice().to_vec()));
- }
- }
- assert_eq!(
- denies.len(),
- 1,
- "discarding a parked client request must stage exactly one deny \
- reply; silence wedges the connection's lockstep decode"
- );
- let (client, frame) = &denies[0];
- assert_eq!(*client, TRANSPORT, "deny must target the request's client");
- let status =
- u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
- assert_eq!(
- status,
- IggyError::TransientNotAccepted.as_code(),
- "a discarded parked send must surface the retriable transient \
- status so the SDK replays it instead of timing out"
- );
- }
-
- /// A backup's login: it forwards the register it authenticated to the
- /// view's primary and completes on the primary's verdict, with the whole
- /// round trip going through the real shard ingest arm.
- #[compio::test]
- async fn backup_forwards_register_and_completes_on_the_primary_verdict() {
- const CLIENT: u128 = 0xCAFE;
- const USER: u32 = 7;
- const EPOCH: u64 = 41;
- const WATERMARK: u64 = 9;
-
- let bus = SpyBus::default();
- // Replica 1 of 3, view 0: `primary_index(0)` is replica 0.
- let shard = Rc::new(test_shard(&bus, 1, 3, FIRST_BOOT));
- let login = {
- let shard = Rc::clone(&shard);
- compio::runtime::spawn(async move {
- submit_register_local_or_forward(&shard, CLIENT, USER).await
- })
- };
- await_forward(&bus).await;
- let (target, forward) = bus.sole_replica_send::<ForwardRegisterHeader>();
- assert_eq!(target, 0, "forward must address the view's primary");
- assert_eq!(forward.command, Command::ForwardRegister);
- assert_eq!(forward.client, CLIENT);
- assert_eq!(
- forward.user_id, USER,
- "the forwarded identity is the payload"
- );
- assert_eq!(forward.replica, 1, "the origin names itself for the answer");
- assert_ne!(forward.nonce, 0);
- assert_eq!(forward.verify_frame(), Ok(()), "the frame must be sealed");
- assert_eq!(forward.validate(), Ok(()));
-
- shard
- .on_message(forward_register_result(
- &forward,
- ForwardRegisterOutcome::Ok,
- EPOCH,
- WATERMARK,
- ))
- .await;
- assert_eq!(
- login.await.expect("the login task ran to completion"),
- Ok(BoundSession {
- epoch: EPOCH,
- watermark: WATERMARK,
- })
- );
- }
-
- #[compio::test]
- async fn backup_forwards_logout_and_completes_on_the_primary_verdict() {
- const CLIENT: u128 = 0xCAFE;
- const SESSION: u64 = 41;
- const REQUEST: u64 = 9;
- const COMMIT: u64 = 42;
-
- let bus = SpyBus::default();
- let shard = Rc::new(test_shard(&bus, 1, 3, FIRST_BOOT));
- let logout = {
- let shard = Rc::clone(&shard);
- compio::runtime::spawn(async move {
- submit_logout_local_or_forward(&shard, CLIENT, SESSION, REQUEST).await
- })
- };
- await_forward(&bus).await;
- let (target, forward) = bus.sole_replica_send::<ForwardLogoutHeader>();
- assert_eq!(target, 0, "forward must address the view's primary");
- assert_eq!(forward.command, Command::ForwardLogout);
- assert_eq!(forward.client, CLIENT);
- assert_eq!(forward.session, SESSION);
- assert_eq!(forward.request, REQUEST);
- assert_eq!(forward.replica, 1);
- assert_ne!(forward.nonce, 0);
- assert_eq!(forward.verify_frame(), Ok(()));
- assert_eq!(forward.validate(), Ok(()));
-
- shard
- .on_message(forward_logout_result_message(&forward, &Ok(COMMIT)))
- .await;
- assert_eq!(
- logout.await.expect("the logout task ran to completion"),
- Ok(COMMIT)
- );
- }
-
- #[compio::test]
- async fn unanswered_logout_forward_times_out_and_clears_the_waiter() {
- let bus = SpyBus::default();
- bus.instant_timers.set(true);
- let shard = Rc::new(test_shard(&bus, 1, 3, FIRST_BOOT));
-
- let outcome = submit_logout_local_or_forward(&shard, 0xCAFE, 41, 9).await;
- assert_eq!(outcome, Err(MetadataSubmitError::ForwardTimedOut));
-
- let (_, forward) = bus.sole_replica_send::<ForwardLogoutHeader>();
- shard
- .on_message(forward_logout_result_message(&forward, &Ok(42)))
- .await;
- }
-
- #[test]
- fn unknown_logout_outcomes_pin_the_session() {
- for error in [
- MetadataSubmitError::ForwardTimedOut,
- MetadataSubmitError::InProgress,
- MetadataSubmitError::Canceled,
- ] {
- assert_eq!(
- transient_logout_code(&error),
- IggyError::TransientNotCommitted
- );
- }
- for error in [
- MetadataSubmitError::NotPrimary,
- MetadataSubmitError::PipelineFull,
- MetadataSubmitError::PrimaryUnreachable,
- ] {
- assert_eq!(
- transient_logout_code(&error),
- IggyError::TransientNotAccepted
- );
- }
- }
-
- /// The ownership refusal is the one terminal verdict, and it has to stay
- /// terminal across the hop or the SDK replays a login that cannot succeed.
- #[compio::test]
- async fn forwarded_register_keeps_the_ownership_refusal_terminal() {
- let bus = SpyBus::default();
- let shard = Rc::new(test_shard(&bus, 1, 3, FIRST_BOOT));
- let login = {
- let shard = Rc::clone(&shard);
- compio::runtime::spawn(async move {
- submit_register_local_or_forward(&shard, 0xCAFE, 7).await
- })
- };
- await_forward(&bus).await;
- let (_, forward) = bus.sole_replica_send::<ForwardRegisterHeader>();
- shard
- .on_message(forward_register_result(
- &forward,
- ForwardRegisterOutcome::ClientIdOwnedByAnotherUser,
- 0,
- 0,
- ))
- .await;
- let error = login
- .await
- .expect("the login task ran to completion")
- .expect_err("the refusal must surface");
- assert_eq!(error, MetadataSubmitError::ClientIdOwnedByAnotherUser);
- assert!(!error.is_transient(), "the refusal must stay terminal");
- }
-
- /// A primary that never answers must not strand the login or leak its
- /// parked entry; the client gets a transient failure and replays.
- #[compio::test]
- async fn unanswered_forward_times_out_and_clears_the_parked_login() {
- let bus = SpyBus::default();
- bus.instant_timers.set(true);
- let shard = Rc::new(test_shard(&bus, 1, 3, FIRST_BOOT));
-
- let outcome = submit_register_local_or_forward(&shard, 0xCAFE, 7).await;
- assert_eq!(outcome, Err(MetadataSubmitError::ForwardTimedOut));
- assert!(
- outcome.unwrap_err().is_transient(),
- "a lost answer is replayable"
- );
-
- // The parked entry is gone: the answer that arrives late finds nothing
- // and is dropped rather than completing a login nobody is waiting on.
- let (_, forward) = bus.sole_replica_send::<ForwardRegisterHeader>();
- shard
- .on_message(forward_register_result(
- &forward,
- ForwardRegisterOutcome::Ok,
- 41,
- 0,
- ))
- .await;
- }
-
- /// The reply frame is where an unknown outcome has to be told apart from a
- /// refusal: a forward that timed out may still commit, so the client must
- /// replay under the same client id instead of failing over under a fresh
- /// one. A verdict that refused the register carries no such doubt.
- #[compio::test]
- async fn transient_login_reply_marks_a_timed_out_forward_not_committed() {
- const TRANSPORT: u128 = 91;
- const VSR_CLIENT: u128 = 0xCAFE;
- const RESULT_OFFSET: usize = size_of::<ReplyHeader>() + 8;
-
- let bus = SpyBus::default();
- let shard = Rc::new(test_shard(&bus, 1, 3, FIRST_BOOT));
- let request = request_message(Operation::Register, VSR_CLIENT, 0, 0, &[]);
-
- for (submit_error, expected) in [
- (
- MetadataSubmitError::ForwardTimedOut,
- IggyError::TransientNotCommitted,
- ),
- (
- MetadataSubmitError::NotPrimary,
- IggyError::TransientNotAccepted,
- ),
- ] {
- let error = LoginRegisterError::Transient(submit_error);
- surface_login_failure(&shard, TRANSPORT, request.header(), &error).await;
-
- let replies = bus.client_replies.borrow();
- assert_eq!(replies.len(), 1, "a transient login must answer a frame");
- let (client, frame) = &replies[0];
- assert_eq!(*client, TRANSPORT, "reply must target the transport id");
- let result =
- u32::from_le_bytes(frame[RESULT_OFFSET..RESULT_OFFSET + 4].try_into().unwrap());
- assert_eq!(result, expected.as_code(), "{error} must reply {expected}");
- drop(replies);
- bus.client_replies.borrow_mut().clear();
- }
- }
-
- /// A restart must not re-mint the nonce sequence of the boot before it. The
- /// nonce is never persisted, and an answer to a pre-restart forward can
- /// still be in flight: routed by a repeated nonce it would confirm a login
- /// the cluster never committed, with another client's epoch.
- #[compio::test]
- async fn a_restart_moves_the_forward_nonce_sequence() {
- assert_ne!(
- first_forward_nonce(FIRST_BOOT).await,
- first_forward_nonce(SECOND_BOOT).await,
- "each boot must start its nonce sequence somewhere the other did not"
- );
- }
-
- /// Seeding the counter from the incarnation means it can start one step
- /// short of wrapping, and a zero nonce is a frame every replica rejects.
- #[compio::test]
- async fn wrapping_forward_nonce_counter_skips_zero() {
- let nonce = first_forward_nonce(u128::from(u64::MAX)).await;
- assert_ne!(
- nonce & u128::from(u64::MAX),
- 0,
- "a counter that wrapped must not contribute a zero nonce half"
- );
- }
-
- /// An answer echoing a client the nonce was never parked for must neither
- /// complete that login nor evict it, since a repeated nonce is exactly what
- /// a late cross-boot answer carries.
- #[compio::test]
- async fn forward_result_for_another_client_leaves_the_login_parked() {
- const CLIENT: u128 = 0xCAFE;
- const EPOCH: u64 = 41;
- const WATERMARK: u64 = 9;
- const FOREIGN_EPOCH: u64 = 77;
-
- let bus = SpyBus::default();
- let shard = Rc::new(test_shard(&bus, 1, 3, FIRST_BOOT));
- let login = {
- let shard = Rc::clone(&shard);
- compio::runtime::spawn(async move {
- submit_register_local_or_forward(&shard, CLIENT, 7).await
- })
- };
- await_forward(&bus).await;
- let (_, forward) = bus.sole_replica_send::<ForwardRegisterHeader>();
-
- let mut foreign = forward;
- foreign.client = CLIENT + 1;
- shard
- .on_message(forward_register_result(
- &foreign,
- ForwardRegisterOutcome::Ok,
- FOREIGN_EPOCH,
- 0,
- ))
- .await;
- shard
- .on_message(forward_register_result(
- &forward,
- ForwardRegisterOutcome::Ok,
- EPOCH,
- WATERMARK,
- ))
- .await;
- assert_eq!(
- login.await.expect("the login task ran to completion"),
- Ok(BoundSession {
- epoch: EPOCH,
- watermark: WATERMARK,
- }),
- "the login must bind the epoch addressed to it, and must still be \
- parked to receive it"
- );
- }
-
- /// A node that is primary itself never forwards -- that is what bounds a
- /// forward at one hop.
- #[compio::test]
- async fn primary_proposes_locally_instead_of_forwarding() {
- let bus = SpyBus::default();
- // Replica 0 of 3, view 0: this node IS the primary.
- let shard = Rc::new(test_shard(&bus, 0, 3, FIRST_BOOT));
-
- // No journal on the test shard, so the proposal cannot commit; what
- // matters is that nothing left over the interconnect.
- let _ = compio::time::timeout(
- Duration::from_millis(50),
- submit_register_local_or_forward(&shard, 0xCAFE, 7),
- )
- .await;
- assert!(
- bus.replica_sends.borrow().is_empty(),
- "a primary must propose in process"
- );
- }
-
- /// The nonce a shard booted at `incarnation` stamps on its first forward.
- /// Nobody answers, so the login abandons on the instant timer; the frame it
- /// left on the bus is what the caller is after.
- async fn first_forward_nonce(incarnation: u128) -> u128 {
- let bus = SpyBus::default();
- bus.instant_timers.set(true);
- let shard = Rc::new(test_shard(&bus, 1, 3, incarnation));
- let outcome = submit_register_local_or_forward(&shard, 0xCAFE, 7).await;
- assert_eq!(outcome, Err(MetadataSubmitError::ForwardTimedOut));
- bus.sole_replica_send::<ForwardRegisterHeader>().1.nonce
- }
-
- /// Let a spawned login run until it has parked on the primary's answer.
- async fn await_forward(bus: &SpyBus) {
- for _ in 0..1000 {
- if !bus.replica_sends.borrow().is_empty() {
- return;
- }
- compio::time::sleep(Duration::from_millis(1)).await;
- }
- panic!("the login never forwarded a register");
- }
-
- /// A sealed `ForwardRegisterResult` addressed to `forward`'s nonce.
- fn forward_register_result(
- forward: &ForwardRegisterHeader,
- outcome: ForwardRegisterOutcome,
- epoch: u64,
- watermark: u64,
- ) -> MessageBag {
- let bound = match outcome {
- ForwardRegisterOutcome::Ok => Ok(BoundSession { epoch, watermark }),
- ForwardRegisterOutcome::ClientIdOwnedByAnotherUser => {
- Err(MetadataSubmitError::ClientIdOwnedByAnotherUser)
- }
- _ => Err(MetadataSubmitError::NotPrimary),
- };
- MessageBag::ForwardRegisterResult(build_forward_register_result_message(
- forward.cluster,
- forward.view,
- 0,
- forward.client,
- forward.nonce,
- &bound,
- ))
- }
-
- fn forward_logout_result_message(
- forward: &ForwardLogoutHeader,
- outcome: &Result<u64, MetadataSubmitError>,
- ) -> MessageBag {
- MessageBag::ForwardLogoutResult(build_forward_logout_result_message(
- forward.cluster,
- forward.view,
- 0,
- forward.client,
- forward.nonce,
- outcome,
- ))
- }
-
- /// The `GET_CLUSTER_METADATA` auth gate holds on every roster shape: it
- /// describes the private replica network, and a client that dialed a
- /// backup reaches the cluster by logging in there (the backup forwards
- /// the register), not by reading the topology first.
- ///
- /// The denial must be a plain Reply on the status channel, not an
- /// Eviction: no session exists yet, and a session-terminal frame makes
- /// SDKs drop the connection their login is about to use.
- #[compio::test]
- async fn pre_auth_cluster_metadata_denied_on_every_roster() {
- use configs::cluster::{ClusterNodeConfig, TransportPorts};
- use iggy_binary_protocol::codes::GET_CLUSTER_METADATA_CODE;
- use iggy_binary_protocol::{GenericHeader, ReplyHeader};
-
- const TRANSPORT: u128 = 91;
- const COMMAND_OFFSET: usize = std::mem::offset_of!(GenericHeader, command);
- const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
- const OP_OFFSET: usize = std::mem::offset_of!(ReplyHeader, op);
- const COMMIT_OFFSET: usize = std::mem::offset_of!(ReplyHeader, commit);
-
- fn metadata_read() -> Message<GenericHeader> {
- let header_size = size_of::<RequestHeader>();
- let mut message = Message::<RequestHeader>::new(header_size);
- {
- let header = bytemuck::checked::from_bytes_mut::<RequestHeader>(
- &mut message.as_mut_slice()[..header_size],
- );
- *header = RequestHeader {
- command: Command::Request,
- operation: Operation::NonReplicated,
- size: u32::try_from(header_size).expect("header fits u32"),
- client: TRANSPORT,
- ..Default::default()
- };
- header.reserved[..4].copy_from_slice(&GET_CLUSTER_METADATA_CODE.to_le_bytes());
- }
- message.into_generic()
- }
-
- fn roster_node(name: &str) -> ClusterNodeConfig {
- ClusterNodeConfig {
- name: name.to_owned(),
- ip: "127.0.0.1".to_owned(),
- advertised_address: None,
- advertised_addresses: Vec::new(),
- replica_id: 0,
- ports: TransportPorts::default(),
- }
- }
-
- let bus = SpyBus::default();
- let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
- let sessions = Rc::new(RefCell::new(SessionManager::new()));
- let system_config = Arc::new(ServerSystemConfig::default());
-
- let multi_node = Rc::new(ClusterRoster {
- enabled: true,
- name: "test-cluster".to_owned(),
- nodes: ["node-0", "node-1"]
- .map(|name| {
- configs::cluster::ResolvedClusterNode::try_from(roster_node(name))
- .expect("valid roster node")
- })
- .to_vec(),
- self_advertised: "127.0.0.1".to_owned(),
- self_ports: TransportPorts::default(),
- metadata_view: Arc::new(std::sync::atomic::AtomicU64::new(
- crate::cluster_meta::METADATA_VIEW_UNKNOWN,
- )),
- });
- // Default roster is disabled / single node; the installed one is a
- // real cluster. Neither serves an unbound caller.
- for roster in [None, Some(multi_node)] {
- if let Some(roster) = roster {
- sessions.borrow_mut().set_cluster_roster(roster);
- }
- handle_client_request(
- &shard,
- &sessions,
- &system_config,
- 1,
- TRANSPORT,
- metadata_read(),
- )
- .await;
- let replies = bus.client_replies.borrow();
- assert_eq!(replies.len(), 1, "gated read must still produce a frame");
- let (client, frame) = &replies[0];
- assert_eq!(*client, TRANSPORT);
- assert_eq!(
- frame[COMMAND_OFFSET],
- Command::Reply as u8,
- "an unbound cluster-metadata read must be denied with a Reply, not evicted"
- );
- let status =
- u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
- assert_eq!(
- status,
- IggyError::Unauthenticated.as_code(),
- "deny reply status must be Unauthenticated"
- );
- let op = u64::from_le_bytes(frame[OP_OFFSET..OP_OFFSET + 8].try_into().unwrap());
- assert_eq!(op, 0, "pre-auth deny carries no session, so op must be 0");
- let commit =
- u64::from_le_bytes(frame[COMMIT_OFFSET..COMMIT_OFFSET + 8].try_into().unwrap());
- assert_eq!(commit, 0, "pre-auth deny must not disclose commit activity");
- drop(replies);
- bus.client_replies.borrow_mut().clear();
- }
- }
-
- #[test]
- fn create_topic_bounds_deny_pre_consensus() {
- let segment_size = iggy_common::DEFAULT_SEGMENT_SIZE;
- assert!(segment_size > 0, "default segment size must be nonzero");
-
- assert!(
- validate_topic_bounds(
- MAX_PARTITIONS_PER_REQUEST,
- MaxTopicSize::ServerDefault,
- segment_size
- )
- .is_ok(),
- "the partition cap itself is admissible"
- );
- assert!(
- matches!(
- validate_topic_bounds(
- MAX_PARTITIONS_PER_REQUEST + 1,
- MaxTopicSize::ServerDefault,
- segment_size
- ),
- Err(IggyError::TooManyPartitions)
- ),
- "one past the partition cap must deny"
- );
- // ServerDefault is numerically 0 yet exempt from the segment-size
- // floor: it resolves against server config, matching legacy.
- assert!(validate_topic_bounds(1, MaxTopicSize::ServerDefault, segment_size).is_ok());
- assert!(validate_topic_bounds(1, MaxTopicSize::Unlimited, segment_size).is_ok());
- let below_floor = MaxTopicSize::Custom((segment_size - 1).into());
- assert!(
- matches!(
- validate_topic_bounds(1, below_floor, segment_size),
- Err(IggyError::InvalidTopicSize(size, floor))
- if size == below_floor && floor == IggyByteSize::from(segment_size)
- ),
- "custom size below the segment size must deny with the bounds"
- );
- let at_floor = MaxTopicSize::Custom(IggyByteSize::from(segment_size));
- assert!(
- validate_topic_bounds(1, at_floor, segment_size).is_ok(),
- "a topic exactly one segment large is admissible"
- );
- }
-
- #[test]
- fn partitions_count_cap_denies_pre_consensus() {
- assert!(
- validate_partitions_count(MAX_PARTITIONS_PER_REQUEST).is_ok(),
- "the cap itself is admissible"
- );
- assert!(
- matches!(
- validate_partitions_count(MAX_PARTITIONS_PER_REQUEST + 1),
- Err(IggyError::TooManyPartitions)
- ),
- "one past the cap must deny"
- );
- // Zero passes the shared cap because a zero-partition TOPIC is legal
- // (legacy `create_topic` admits `0..=MAX`).
- assert!(validate_partitions_count(0).is_ok());
- }
-
- #[test]
- fn zero_partitions_change_denies_pre_consensus() {
- // Adding or removing zero partitions is a no-op that would still burn
- // a replicated log entry and force a rebalance. Legacy rejects it with
- // `TooManyPartitions` in both handlers, so the code matches.
- assert!(
- matches!(
- validate_partitions_change_count(0),
- Err(IggyError::TooManyPartitions)
- ),
- "adding or removing zero partitions must deny"
- );
- assert!(validate_partitions_change_count(1).is_ok());
- assert!(validate_partitions_change_count(MAX_PARTITIONS_PER_REQUEST).is_ok());
- assert!(
- matches!(
- validate_partitions_change_count(MAX_PARTITIONS_PER_REQUEST + 1),
- Err(IggyError::TooManyPartitions)
- ),
- "the cap still applies"
- );
- }
-}
diff --git a/core/server/src/dispatch/authz.rs b/core/server/src/dispatch/authz.rs
index ee113e0d1..436aa2222 100644
--- a/core/server/src/dispatch/authz.rs
+++ b/core/server/src/dispatch/authz.rs
@@ -60,7 +60,7 @@ use crate::shell::{ShellBus, ShellShard};
/// namespace already resolved, so the entity exists; a `None` user id (which
/// the bound-session gate should preclude) fails closed with `Unauthenticated`
/// rather than allow an unattributed write.
-pub(super) fn authorize_partition_op<B, MJ, S, SB>(
+pub(in crate::dispatch) fn authorize_partition_op<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
operation: Operation,
user_id: Option<u32>,
@@ -139,7 +139,7 @@ where
/// better, the connection decodes replies in lockstep and would wedge on every
/// later request.
#[allow(clippy::future_not_send)]
-pub(super) async fn send_deny_reply<B, MJ, S, SB>(
+pub(in crate::dispatch) async fn send_deny_reply<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
@@ -172,7 +172,7 @@ pub(super) async fn send_deny_reply<B, MJ, S, SB>(
/// commit frontier. The status is the only field a pre-authenticated caller
/// needs, while the live commit would expose cluster write activity.
#[allow(clippy::future_not_send)]
-pub(super) async fn send_unbound_deny_reply<B, MJ, S, SB>(
+pub(in crate::dispatch) async fn send_unbound_deny_reply<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
@@ -202,7 +202,7 @@ pub(super) async fn send_unbound_deny_reply<B, MJ, S, SB>(
/// Run an unscoped non-replicated-read rule for the acting user. A `None` user
/// id (only the pre-auth path, which serves ungated codes) fails closed.
-pub(super) fn authorize_uid<B, MJ, S, SB>(
+pub(in crate::dispatch) fn authorize_uid<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
user_id: Option<u32>,
rule: impl FnOnce(&Permissioner, u32) -> Result<(), IggyError>,
@@ -227,7 +227,7 @@ where
/// (stream, topic). `None` proceeds (allowed, or a resolution miss the caller's
/// own not-found path handles); `Some(status)` denies. A `None` user id fails
/// closed.
-pub(super) fn authorize_partition_read<B, MJ, S, SB>(
+pub(in crate::dispatch) fn authorize_partition_read<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
@@ -263,7 +263,7 @@ where
/// topic]) against committed state first. The PAT list is self-scoped, so
/// authentication is its whole rule, and `GET_CLUSTER_METADATA` -- which
/// describes the private replica network -- is gated the same way.
-pub(super) fn authorize_default_read<B, MJ, S, SB>(
+pub(in crate::dispatch) fn authorize_default_read<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
code: u32,
body: &[u8],
@@ -337,7 +337,7 @@ where
/// surfaces the typed error, so a poll denial never reaches the empty-poll
/// "0 messages" body path.
#[allow(clippy::future_not_send)]
-pub(super) async fn send_non_replicated_deny<B, MJ, S, SB>(
+pub(in crate::dispatch) async fn send_non_replicated_deny<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: &Message<RoutedRequestHeader>,
transport_client_id: u128,
diff --git a/core/server/src/login_register.rs b/core/server/src/dispatch/login_error.rs
similarity index 75%
rename from core/server/src/login_register.rs
rename to core/server/src/dispatch/login_error.rs
index bcc7d8c75..c5e984258 100644
--- a/core/server/src/login_register.rs
+++ b/core/server/src/dispatch/login_error.rs
@@ -15,12 +15,12 @@
// specific language governing permissions and limitations
// under the License.
-//! Login/register failure taxonomy.
+//! Login/register failure taxonomy, shared by both spines.
//!
-//! The login/register flow itself lives in `dispatch` + `auth`; this module
-//! owns the error type those handlers return and the terminal-vs-transient
-//! split that decides a fast-fail reply versus a silent close (recoverable
-//! failures stay silent so the SDK replays).
+//! Its own leaf because the TCP spine raises these ([`super::session_ops`])
+//! while the HTTP spine maps them to wire errors (`crate::http::reply`).
+//! Parked in either spine it would make one import the other's session module
+//! for a type neither owns.
use crate::session_manager::SessionError;
use metadata::MetadataSubmitError;
@@ -71,18 +71,3 @@ impl std::fmt::Display for LoginRegisterError {
}
impl std::error::Error for LoginRegisterError {}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn terminal_vs_transient() {
- assert!(LoginRegisterError::InvalidCredentials.is_terminal());
- assert!(LoginRegisterError::InvalidToken.is_terminal());
- assert!(LoginRegisterError::UserInactive.is_terminal());
- assert!(LoginRegisterError::Session(SessionError::ConnectionNotFound(0)).is_terminal());
- // Transient is the only recoverable variant: never terminal.
- assert!(!LoginRegisterError::Transient(MetadataSubmitError::PipelineFull).is_terminal());
- }
-}
diff --git a/core/server/src/dispatch/mod.rs b/core/server/src/dispatch/mod.rs
new file mode 100644
index 000000000..0385fdeba
--- /dev/null
+++ b/core/server/src/dispatch/mod.rs
@@ -0,0 +1,1413 @@
+// 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: queue plumbing and the request funnel.
+//!
+//! The tree: [`session_ops`] (login/register/logout and their replica
+//! forwards), [`partition`] (the partition data plane, both mesh ends),
+//! [`reads`] (the non-replicated read router), [`submit`] (the shard-0
+//! metadata-submit RPC), `authz` (the wire-path authorization gates).
+//!
+//! Deliberate asymmetry (the two authz gates): replicated metadata ops are
+//! authorized in-apply by the STM, in committed order on every replica;
+//! partition and non-replicated ops never enter the metadata log, so `authz`
+//! gates them pre-dispatch against this shard's applied permissioner. The
+//! HTTP spine keeps its own equivalent gates (see `crate::http`) because its
+//! error contract (404-before-403) is pinned client-visible behavior.
+
+mod authz;
+pub mod login_error;
+pub mod partition;
+mod reads;
+pub mod session_ops;
+pub mod submit;
+#[cfg(test)]
+mod test_support;
+
+use crate::consumer_group::maybe_rewrite_consumer_group_request;
+use crate::dispatch::authz::{send_deny_reply, send_unbound_deny_reply};
+use crate::dispatch::partition::{dispatch_partition_request, handle_delete_segments_request};
+use crate::dispatch::reads::handle_non_replicated_request;
+use crate::dispatch::session_ops::{
+ handle_login_register_request, handle_logout_request, send_login_eviction,
+ send_unauthenticated_eviction, submit_disconnect_logout,
+};
+use crate::dispatch::submit::submit_client_request_on_owner;
+use crate::pat::maybe_rewrite_pat_request;
+use crate::responses::{
+ NonReplicatedResponse, build_deny_reply, build_raw_pat_reply, current_metadata_commit,
+};
+use crate::segment_cleaner::UNENFORCEABLE_TOPIC_SIZE_WARN;
+use crate::session_manager::SessionManager;
+use crate::shell::{ShellBus, ShellShard, ShellShardHandle};
+use crate::users::maybe_rewrite_user_password_request;
+use crate::wire::{request_body, verify_request_checksum};
+use bytes::Bytes;
+use configs::server::ServerSystemConfig;
+use consensus::MetadataHandle;
+use iggy_binary_protocol::PrepareHeader;
+use iggy_binary_protocol::codes::{
+ GET_CLUSTER_METADATA_CODE, LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE,
+};
+use iggy_binary_protocol::requests::partitions::{
+ CreatePartitionsRequest, DeletePartitionsRequest,
+};
+use iggy_binary_protocol::requests::streams::{CreateStreamRequest, UpdateStreamRequest};
+use iggy_binary_protocol::requests::topics::{CreateTopicRequest, UpdateTopicRequest};
+use iggy_binary_protocol::requests::users::{CreateUserRequest, UpdateUserRequest};
+use iggy_binary_protocol::{
+ EvictionReason, GenericHeader, MAX_PARTITIONS_PER_REQUEST, Operation, RequestHeader,
+ RoutedRequestHeader, WireDecode, WireIdentifier, WireOptions,
+};
+use iggy_common::{
+ IggyByteSize, IggyError, MaxTopicSize, TopicCreateOptions, UPDATABLE_STREAM_OPTION_KEYS,
+ UPDATABLE_TOPIC_OPTION_KEYS, UPDATABLE_USER_OPTION_KEYS, validate_preallocated_topic_bytes,
+ validate_topic_segment_size,
+};
+use journal::superblock::SuperblockStore;
+use journal::{Journal, JournalHandle};
+use message_bus::BusMessage;
+use message_bus::client_listener::RequestHandler;
+use message_bus::replica::listener::MessageHandler;
+use metadata::impls::metadata::StreamsFrontend;
+use metadata::stm::stream::Streams;
+use server_common::Message;
+use shard::{ConnectedClientInfo, ListClientsHandler};
+use std::cell::RefCell;
+use std::collections::{HashMap, HashSet, VecDeque};
+use std::rc::Rc;
+use std::sync::Arc;
+use tracing::{debug, warn};
+
+type ClientRequestQueues = Rc<RefCell<HashMap<u128, VecDeque<Message<GenericHeader>>>>>;
+type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
+
+pub fn make_client_request_handler<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ system_config: Arc<ServerSystemConfig>,
+ max_tokens_per_user: u32,
+) -> RequestHandler
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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),
+ max_tokens_per_user,
+ 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 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);
+ })
+}
+
+pub fn make_deferred_replica_message_handler<B, MJ, S, SB>(
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
+) -> MessageHandler
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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 fn make_deferred_client_request_handler<B, MJ, S, SB>(
+ bus: &B,
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ system_config: Arc<ServerSystemConfig>,
+ max_tokens_per_user: u32,
+) -> RequestHandler
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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);
+ let bus_for_spawn = (*bus).clone();
+ 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;
+ }
+ bus_for_spawn.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,
+ max_tokens_per_user,
+ queues,
+ active,
+ client_id,
+ )
+ .await;
+ });
+ })
+}
+
+// Session resume is performed BY THE LOGIN PATH, not by a separate
+// credential-free rebind.
+//
+// A reconnecting client re-authenticates on the new connection and presents
+// its previous `client_id` in the login frame; `submit_register_in_process`
+// finds the existing table entry, verifies the authenticated user owns it,
+// and returns its epoch, so `bind_session` binds the new transport to the
+// old entry with its watermark and reply ring intact. That IS the resume.
+//
+// An earlier revision instead rebound an *unbound* transport straight from
+// the table whenever a replicated frame carried a matching
+// `(client, session)`, treating that pair as a bearer token. That was wrong
+// in four ways, and the combination was a pre-auth session takeover:
+//
+// - it called `SessionManager::login` itself, so no credential was ever
+// presented, and the connection was logged in as the entry's cached
+// `user_id`; authority for replicated ops then resolves from the table
+// (`resolve_acting_user_id`) and for partition ops from the session
+// manager, so BOTH planes ran as the original registrant;
+// - the pair carries far less entropy than "client-generated random
+// u128" implies: HTTP mints `client_id` from the shard-0 sequential
+// counter (`mint_shard_zero_client_id`, seeded at 1 per process) and no
+// live path ever bumps an epoch past 1, so the token was `client=N,
+// session=1` for small N;
+// - `ClientEntry` carries no transport or plane tag, so a raw TCP peer
+// could bind an HTTP-originated session;
+// - `bind_session` demotes the evicted holder to `Connected`, the one
+// state `login` accepts, so the loser's next replicated frame
+// re-resumed and stole the session back, unbounded and with no eviction
+// frame either way.
+//
+// Routing resume through login also restores the checks that path owns:
+// password / PAT verification, `UserStatus::Active`, PAT expiry, the
+// protocol-version gate, and SDK-info recording.
+//
+// An unbound transport sending a replicated frame therefore gets the typed
+// `Eviction(NoSession)` fail-fast below and must log in.
+
+#[allow(clippy::too_many_arguments)]
+fn enqueue_client_request<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: Rc<RefCell<SessionManager>>,
+ system_config: Arc<ServerSystemConfig>,
+ max_tokens_per_user: u32,
+ queues: ClientRequestQueues,
+ active: ActiveClientRequests,
+ client_id: u128,
+ message: Message<GenericHeader>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ queues
+ .borrow_mut()
+ .entry(client_id)
+ .or_default()
+ .push_back(message);
+ if !active.borrow_mut().insert(client_id) {
+ return;
+ }
+
+ let bus = shard.bus.clone();
+ bus.spawn(async move {
+ drain_client_requests(
+ shard,
+ sessions,
+ system_config,
+ max_tokens_per_user,
+ queues,
+ active,
+ client_id,
+ )
+ .await;
+ });
+}
+
+#[allow(clippy::future_not_send)]
+async fn drain_client_requests<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: Rc<RefCell<SessionManager>>,
+ system_config: Arc<ServerSystemConfig>,
+ max_tokens_per_user: u32,
+ queues: ClientRequestQueues,
+ active: ActiveClientRequests,
+ client_id: u128,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ loop {
+ let Some(message) = pop_next_client_request(&queues, &active, client_id) else {
+ return;
+ };
+ handle_client_request(
+ &shard,
+ &sessions,
+ &system_config,
+ max_tokens_per_user,
+ 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
+}
+
+/// Per-request partitions-count cap, shared by create-topic, create-partitions
+/// and delete-partitions admission. Runs pre-consensus like
+/// [`validate_topic_bounds`]: an oversized count must not burn a replicated
+/// log entry (create-partitions admission would also allocate that many
+/// consensus-group ids before replicating).
+///
+/// Zero passes here because a zero-partition TOPIC is legal (legacy
+/// `create_topic` admits `0..=MAX`); the add/remove requests reject it in
+/// [`validate_partitions_change_count`].
+const fn validate_partitions_count(partitions_count: u32) -> Result<(), IggyError> {
+ if partitions_count > MAX_PARTITIONS_PER_REQUEST {
+ return Err(IggyError::TooManyPartitions);
+ }
+ Ok(())
+}
+
+/// [`validate_partitions_count`] plus the zero rejection that create-partitions
+/// and delete-partitions carry: adding or removing zero partitions is a no-op
+/// that would still burn a replicated log entry, bump `Streams::revision` and
+/// force every shard through a rebalance pass. Legacy rejects it with
+/// `TooManyPartitions` in both handlers (`1..=MAX` on create, `== 0` on
+/// delete), so the code matches rather than inventing a new one.
+const fn validate_partitions_change_count(partitions_count: u32) -> Result<(), IggyError> {
+ if partitions_count == 0 {
+ return Err(IggyError::TooManyPartitions);
+ }
+ validate_partitions_count(partitions_count)
+}
+
+/// Static create-topic bounds shared by the TCP and HTTP ingresses. Runs
+/// pre-consensus: a rejected request must not burn a replicated log entry,
+/// and `prepare_request` errors evict the session instead of denying typed.
+/// `ServerDefault` is exempt from the size floor (it resolves against server
+/// config at admission, matching legacy); `Unlimited` passes numerically.
+/// `segment_size_bytes` is the topic's RESOLVED segment size (explicit
+/// option, else this node's default), so a per-topic segment above the
+/// global default still floors the topic cap.
+pub fn validate_topic_bounds(
+ partitions_count: u32,
+ max_topic_size: MaxTopicSize,
+ segment_size_bytes: u64,
+) -> Result<(), IggyError> {
+ validate_partitions_count(partitions_count)?;
+ validate_topic_size_floor(max_topic_size, segment_size_bytes)
+}
+
+/// A topic cap below one segment can never be enforced: the first segment
+/// already exceeds it. Split out of [`validate_topic_bounds`] because update
+/// admission checks the cap without a partitions count to check.
+pub fn validate_topic_size_floor(
+ max_topic_size: MaxTopicSize,
+ segment_size_bytes: u64,
+) -> Result<(), IggyError> {
+ if !matches!(max_topic_size, MaxTopicSize::ServerDefault)
+ && max_topic_size.as_bytes_u64() < segment_size_bytes
+ {
+ return Err(IggyError::InvalidTopicSize(
+ max_topic_size,
+ IggyByteSize::from(segment_size_bytes),
+ ));
+ }
+ Ok(())
+}
+
+/// Announce an accepted `max_topic_size` the server cannot enforce as written.
+///
+/// [`validate_topic_size_floor`] admits any cap of one segment or more, but
+/// retention runs PER PARTITION and floors each partition's share at one SEALED
+/// segment, which reaches up to one maximum bus frame past `segment_size`. A cap
+/// between the two is stored and echoed back verbatim while the server actually
+/// keeps `(segment_size + max_message_size) * partitions_count`, so the only
+/// moment an operator can be told is the one where they set it.
+///
+/// Warns rather than rejects: which caps are accepted is client-visible wire
+/// behavior, and tightening it would break topics that already exist.
+pub fn warn_unenforceable_topic_size(
+ max_topic_size: MaxTopicSize,
+ segment_size_bytes: u64,
+ max_message_size_bytes: usize,
+ partitions_count: u32,
+) {
+ let MaxTopicSize::Custom(configured) = max_topic_size else {
+ return;
+ };
+ let max_message_size_bytes = u64::try_from(max_message_size_bytes).unwrap_or(u64::MAX);
+ let per_partition_floor = segment_size_bytes.saturating_add(max_message_size_bytes);
+ let topic_floor = per_partition_floor.saturating_mul(u64::from(partitions_count));
+ if configured.as_bytes_u64() >= topic_floor {
+ return;
+ }
+ warn!(
+ max_topic_size = configured.as_bytes_u64(),
+ partitions_count,
+ segment_size = segment_size_bytes,
+ enforced_per_partition = per_partition_floor,
+ "{UNENFORCEABLE_TOPIC_SIZE_WARN}"
+ );
+}
+
+/// Announce the same unenforceable cap when partitions are ADDED to a topic.
+///
+/// The cap is topic-wide but enforcement is per partition, so every added
+/// partition shrinks the share: a cap that cleared the floor when the topic was
+/// created can stop clearing it here. The request carries only the delta, so
+/// the stored cap, segment size and current partition count come from metadata.
+pub fn warn_unenforceable_topic_size_on_partition_add(
+ streams: &Streams,
+ stream_id: &WireIdentifier,
+ topic_id: &WireIdentifier,
+ max_message_size_bytes: usize,
+ added_partitions_count: u32,
+) {
+ let Some(((stream_slab, topic_slab), _)) = streams.partition_count_context(stream_id, topic_id)
+ else {
+ return;
+ };
+ let Some((_, max_topic_size, partitions_count, segment_size)) =
+ streams.topic_retention_config(stream_slab, topic_slab)
+ else {
+ return;
+ };
+ warn_unenforceable_topic_size(
+ max_topic_size,
+ segment_size.map_or(iggy_common::DEFAULT_SEGMENT_SIZE, |segment_size| {
+ segment_size.as_bytes_u64()
+ }),
+ max_message_size_bytes,
+ u32::try_from(partitions_count)
+ .unwrap_or(u32::MAX)
+ .saturating_add(added_partitions_count),
+ );
+}
+
+/// Reject option keys outside the resource's catalog, pre-consensus. Unknown
+/// keys are rejected rather than skipped: a silently ignored knob would hand
+/// the client server defaults without it ever learning. Streams and users
+/// have no catalog keys yet, so `known` is empty for both until one lands.
+pub fn validate_option_keys(options: &WireOptions, known: &[&str]) -> Result<(), IggyError> {
+ for entry in options {
+ // Wire validation already enforced UTF-8 string keys.
+ let key = String::from_utf8_lossy(entry.key);
+ if !known.contains(&key.as_ref()) {
+ return Err(IggyError::UnsupportedOptionKey(key.into_owned()));
+ }
+ }
+ Ok(())
+}
+
+/// Reject a request before it reaches consensus: warn, then send the typed
+/// deny reply. A silent drop would wedge every later request on the
+/// connection until the socket read timeout. `context` labels the rejection
+/// site in both log lines.
+#[allow(clippy::future_not_send)]
+async fn send_pre_consensus_deny<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ header: &RoutedRequestHeader,
+ transport_client_id: u128,
+ error: &IggyError,
+ context: &'static str,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ warn!(
+ transport_client_id,
+ error = %error,
+ operation = ?header.operation,
+ context,
+ "denying request pre-consensus"
+ );
+ 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,
+ context,
+ "failed to send pre-consensus deny reply"
+ );
+ }
+}
+
+#[allow(clippy::future_not_send, clippy::too_many_lines)]
+async fn handle_client_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ system_config: &Arc<ServerSystemConfig>,
+ max_tokens_per_user: u32,
+ transport_client_id: u128,
+ message: Message<iggy_binary_protocol::GenericHeader>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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;
+ }
+ };
+ // Promote to the server-internal routed shape at the boundary: the
+ // client wire carries no group (it is derived -- plane from `operation`,
+ // partition target from the payload), so it starts unset here and the
+ // resolution sites below stamp it before anything routes on it.
+ let request = request.into_routed();
+
+ // The last point that still sees the body the CLIENT sent; every rewrite below
+ // substitutes server-chosen bytes and carries the stamp through unchanged.
+ if let Err(error) = verify_request_checksum(&request) {
+ warn!(
+ transport_client_id,
+ operation = ?request.header().operation,
+ request = request.header().request,
+ "dropping client request whose body does not match its own checksum"
+ );
+ send_deny_reply(
+ shard,
+ transport_client_id,
+ request.header(),
+ error.as_code(),
+ )
+ .await;
+ 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: `PING`, the liveness probe, is the only pre-auth
+ // code, on every roster shape. `GET_CLUSTER_METADATA` describes the
+ // private replica network and is not something an unauthenticated
+ // caller gets to read; a client that dialed a backup no longer needs
+ // it to find the leader, because the backup authenticates the login
+ // locally and forwards only the consensus proposal
+ // (`submit_register_local_or_forward`). Every other non-replicated
+ // code 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. The server 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 generic
+ // Unauthenticated deny 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 requires the register handshake"
+ );
+ send_login_eviction(
+ shard,
+ transport_client_id,
+ header.client,
+ EvictionReason::MalformedLogin,
+ )
+ .await;
+ return;
+ }
+ let allowed_pre_auth = nr_code == PING_CODE;
+ if !allowed_pre_auth && sessions.borrow().get_session(transport_client_id).is_none() {
+ // Foreign SDKs still probe `GET_CLUSTER_METADATA` before login
+ // until they are fixed, so that rejection is routine traffic and
+ // logs at debug rather than warn.
+ if nr_code == GET_CLUSTER_METADATA_CODE {
+ debug!(
+ transport_client_id,
+ "denying pre-auth cluster-metadata read with Unauthenticated"
+ );
+ } else {
+ warn!(
+ transport_client_id,
+ code = nr_code,
+ "denying pre-auth non-replicated read with Unauthenticated"
+ );
+ }
+ // A plain deny Reply, not an Eviction: there is no session to
+ // evict, and an Eviction is session-terminal by wire contract,
+ // so SDKs would tear down the very connection their login is
+ // about to use. The status channel carries the error the same
+ // way the request-checksum denial above does.
+ send_unbound_deny_reply(
+ shard,
+ transport_client_id,
+ request.header(),
+ IggyError::Unauthenticated.as_code(),
+ )
+ .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`/`Fenced` and the failure disappears
+ // silently, wedging the SDK until the socket timeout. A typed
+ // `Eviction(NoSession)` is right here, unlike the pre-auth read
+ // guard above: a replicated request implies the client believes it
+ // has a session, and that session is gone, so it must register
+ // again. An empty status-0 Reply is not safe here, because
+ // SendMessages is the one replicated operation without a result
+ // section, and its decoder would read the empty body as a
+ // successful send.
+ warn!(
+ transport_client_id,
+ operation = ?header.operation,
+ "rejecting replicated request from unbound transport with Eviction(NoSession)"
+ );
+ send_unauthenticated_eviction(shard, transport_client_id).await;
+ 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 RoutedRequestHeader| {
+ *new_header = header;
+ // Metadata-plane ops route by operation: stamp the sentinel group.
+ new_header.group = server_common::sharding::METADATA_GROUP;
+ // `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,
+ max_tokens_per_user,
+ |user_id| {
+ shard
+ .plane
+ .metadata()
+ .mux_stm
+ .users()
+ .read(|users| users.pat_count_of(user_id))
+ },
+ request,
+ ) {
+ Ok(rewritten) => rewritten,
+ Err(error) => {
+ // Token cap reached, malformed body, or a lost session binding.
+ send_pre_consensus_deny(
+ shard,
+ &header,
+ transport_client_id,
+ &error,
+ "personal-access-token",
+ )
+ .await;
+ 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.
+ send_pre_consensus_deny(shard, &header, transport_client_id, &error, "user-password")
+ .await;
+ return;
+ }
+ };
+ // Static bounds run pre-consensus so a rejected request burns no
+ // replicated log entry; HTTP covers the same bounds via
+ // `command.validate()`. A body that fails to decode denies typed too
+ // (`InvalidCommand`), instead of riding consensus just to fail there.
+ let bounds = match header.operation {
+ Operation::CreateTopic => CreateTopicRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|create_topic| {
+ // `parse` doubles as the catalog gate: an unknown key or a
+ // malformed value denies typed here, pre-consensus.
+ let options = TopicCreateOptions::parse(&create_topic.options)?;
+ if let Some(segment_size) = options.segment_size {
+ validate_topic_segment_size(
+ segment_size.as_bytes_u64(),
+ iggy_common::MAX_TOPIC_SEGMENT_SIZE,
+ )?;
+ }
+ let segment_size = options.segment_size.map_or_else(
+ || iggy_common::DEFAULT_SEGMENT_SIZE,
+ |segment_size| segment_size.as_bytes_u64(),
+ );
+ if options
+ .preallocate_segments
+ .unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS)
+ {
+ validate_preallocated_topic_bytes(segment_size, create_topic.partitions_count)?;
+ }
+ let max_topic_size = options
+ .max_topic_size
+ .unwrap_or(MaxTopicSize::ServerDefault);
+ validate_topic_bounds(create_topic.partitions_count, max_topic_size, segment_size)?;
+ warn_unenforceable_topic_size(
+ max_topic_size,
+ segment_size,
+ shard.bus_max_message_size(),
+ create_topic.partitions_count,
+ );
+ Ok(())
+ }),
+ Operation::CreatePartitions => CreatePartitionsRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|create_partitions| {
+ validate_partitions_change_count(create_partitions.partitions_count)?;
+ let metadata = shard.plane.metadata();
+ warn_unenforceable_topic_size_on_partition_add(
+ metadata.mux_stm.streams(),
+ &create_partitions.stream_id,
+ &create_partitions.topic_id,
+ shard.bus_max_message_size(),
+ create_partitions.partitions_count,
+ );
+ Ok(())
+ }),
+ Operation::DeletePartitions => DeletePartitionsRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|delete_partitions| {
+ validate_partitions_change_count(delete_partitions.partitions_count)
+ }),
+ // Only the updatable subset: the create-time knobs are pushed to
+ // partitions when the topic is built and nothing re-pushes them, so
+ // accepting one here would store a value no partition ever sees.
+ Operation::UpdateTopic => UpdateTopicRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|update_topic| {
+ validate_option_keys(&update_topic.options, UPDATABLE_TOPIC_OPTION_KEYS)?;
+ let options = TopicCreateOptions::parse(&update_topic.options)?;
+ let Some(max_topic_size) = options.max_topic_size else {
+ return Ok(());
+ };
+ // An update can lower the cap below one segment just as a
+ // create can, and the stored map would then report a size the
+ // topic can never enforce. The floor is this topic's own
+ // segment size, since that key is create-only.
+ let metadata = shard.plane.metadata();
+ let streams = metadata.mux_stm.streams();
+ let segment_size = streams
+ .topic_segment_size(&update_topic.stream_id, &update_topic.topic_id)
+ .map_or_else(
+ || iggy_common::DEFAULT_SEGMENT_SIZE,
+ |segment_size| segment_size.as_bytes_u64(),
+ );
+ validate_topic_size_floor(max_topic_size, segment_size)?;
+ let partitions_count = streams
+ .topic_partitions_count(&update_topic.stream_id, &update_topic.topic_id)
+ .unwrap_or(0);
+ warn_unenforceable_topic_size(
+ max_topic_size,
+ segment_size,
+ shard.bus_max_message_size(),
+ u32::try_from(partitions_count).unwrap_or(u32::MAX),
+ );
+ Ok(())
+ }),
+ Operation::UpdateStream => UpdateStreamRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|update_stream| {
+ validate_option_keys(&update_stream.options, UPDATABLE_STREAM_OPTION_KEYS)
+ }),
+ Operation::UpdateUser => UpdateUserRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|update_user| {
+ validate_option_keys(&update_user.options, UPDATABLE_USER_OPTION_KEYS)
+ }),
+ Operation::CreateStream => CreateStreamRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|create_stream| validate_option_keys(&create_stream.options, &[])),
+ Operation::CreateUser => CreateUserRequest::decode_from(request_body(&request))
+ .map_err(|_| IggyError::InvalidCommand)
+ .and_then(|create_user| validate_option_keys(&create_user.options, &[])),
+ _ => Ok(()),
+ };
+ if let Err(error) = bounds {
+ send_pre_consensus_deny(shard, &header, transport_client_id, &error, "static-bounds").await;
+ 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"
+ );
+ }
+ }
+}
+
+/// 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ request: &Message<RoutedRequestHeader>,
+ transport_client_id: u128,
+ bytes: Bytes,
+ label: &'static str,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let commit = current_metadata_commit(shard);
+ let reply = NonReplicatedResponse::Bytes(bytes).into_reply(
+ request.header(),
+ request.header().client,
+ request.header().session,
+ commit,
+ );
+ send_reply_frame(
+ shard,
+ transport_client_id,
+ reply.into_generic().into_frozen(),
+ label,
+ )
+ .await;
+}
+
+/// Hand a built reply frame to the bus for `transport_client_id`.
+#[allow(clippy::future_not_send)]
+async fn send_reply_frame<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ frame: impl Into<BusMessage>,
+ label: &'static str,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ if let Err(error) = shard.bus.send_to_client(transport_client_id, frame).await {
+ warn!(transport_client_id, label, error = %error, "failed to send non-replicated reply");
+ }
+}
+
+fn ensure_transport_connection<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ transport_client_id: u128,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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);
+}
+
+pub(in crate::dispatch) fn upgrade_shard_handle<B, MJ, S, SB>(
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
+) -> Option<Rc<ShellShard<B, MJ, S, SB>>>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ shard_handle
+ .borrow()
+ .as_ref()
+ .and_then(std::rc::Weak::upgrade)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::cluster_meta::ClusterRoster;
+ use crate::dispatch::test_support::{FIRST_BOOT, SpyBus, TestMux, TestShard, test_shard};
+ use iggy_binary_protocol::Command;
+ use metadata::IggyMetadata;
+ use partitions::{IggyPartitions, PartitionPathLayout, PartitionsConfig};
+ use server_common::MESSAGE_ALIGN;
+ use server_common::sharding::ShardId;
+ use shard::metrics::ShardMetrics;
+ use shard::shards_table::PapayaShardsTable;
+ use shard::{
+ LifecycleFrame, PartitionConsensusConfig, ReplicaTopology, ShardFrame, ShardIdentity,
+ shard_channel,
+ };
+ use std::mem::size_of;
+ use std::sync::atomic::AtomicBool;
+
+ /// A test shard wired to its own lanes (the held sender feeds them),
+ /// for the reply-lane pump tests below.
+ fn reply_lane_test_shard(name: &str) -> (SpyBus, shard::TaggedSender, Rc<TestShard>) {
+ let bus = SpyBus::default();
+ let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
+ let partitions = IggyPartitions::new(
+ ShardId::new(0),
+ PartitionsConfig {
+ messages_required_to_save: 1,
+ size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
+ enforce_fsync: false,
+ validate_checksum: true,
+ segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
+ preallocate_segments: false,
+ encryptor: None,
+ path_layout: PartitionPathLayout::default(),
+ },
+ );
+ let (sender, inbox_rx, reply_inbox_rx) = shard_channel(0, 16, 16);
+ let lane_sender = sender.clone();
+ let shard = TestShard::new(
+ ShardIdentity::new(0, name.to_string()),
+ bus.clone(),
+ Rc::new(|_, _| {}),
+ Rc::new(|_, _| {}),
+ Rc::new(|_| {}),
+ Rc::new(|_| {}),
+ Rc::new(|_, _, _| {}),
+ metadata,
+ partitions,
+ vec![sender],
+ inbox_rx,
+ reply_inbox_rx,
+ PapayaShardsTable::new(),
+ PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
+ None,
+ ShardMetrics::for_shard(),
+ )
+ .expect("single-sender ring is canonically ordered");
+ (bus, lane_sender, Rc::new(shard))
+ }
+
+ fn reply_lane_forward(client_id: u128) -> ShardFrame {
+ ShardFrame::lifecycle(LifecycleFrame::ForwardClientSend {
+ client_id,
+ msg: server_common::iobuf::Frozen::from(
+ server_common::iobuf::Owned::<MESSAGE_ALIGN>::zeroed(64),
+ )
+ .into(),
+ })
+ }
+
+ /// A frame on the reply lane must reach the client through the RUNNING
+ /// pump's reply arm: the lane split moved `ForwardClientSend` off the
+ /// main inbox, so a pump that forgot to service the new lane would
+ /// strand every cross-shard reply while the send sites happily report
+ /// success.
+ #[compio::test]
+ async fn pump_live_arm_delivers_reply_lane_forwards() {
+ const TRANSPORT: u128 = 92;
+ let (bus, lane_sender, shard) = reply_lane_test_shard("reply-lane-live-arm-test");
+
+ let (stop_tx, stop_rx) = shard::channel::<()>(1);
+ let pump_shard = Rc::clone(&shard);
+ let pump = compio::runtime::spawn(async move {
+ pump_shard
+ .run_message_pump(stop_rx, Arc::new(AtomicBool::new(false)))
+ .await;
+ });
+
+ lane_sender
+ .reply_sender()
+ .try_send(reply_lane_forward(TRANSPORT))
+ .expect("reply lane has capacity");
+
+ // The pump is idle on the main lane, so its bottom reply arm must
+ // serve the frame without any main-lane traffic or shutdown drain.
+ let mut delivered = false;
+ for _ in 0..500 {
+ if !bus.client_replies.borrow().is_empty() {
+ delivered = true;
+ break;
+ }
+ compio::time::sleep(std::time::Duration::from_millis(1)).await;
+ }
+ stop_tx.try_send(()).expect("stop channel has capacity");
+ let _ = pump.await;
+
+ assert!(
+ delivered,
+ "the live reply arm must deliver a forward while the pump runs"
+ );
+ let replies = bus.client_replies.borrow();
+ assert_eq!(replies[0].0, TRANSPORT, "forward must reach its client");
+ }
+
+ /// The shutdown path must ALSO deliver reply-lane frames: a forward
+ /// already accepted by the lane when the stop signal wins the biased
+ /// select would otherwise be silently destroyed at teardown.
+ #[compio::test]
+ async fn pump_shutdown_drain_delivers_reply_lane_forwards() {
+ const TRANSPORT: u128 = 93;
+ let (bus, lane_sender, shard) = reply_lane_test_shard("reply-lane-drain-test");
+
+ lane_sender
+ .reply_sender()
+ .try_send(reply_lane_forward(TRANSPORT))
+ .expect("reply lane has capacity");
+
+ // Pre-armed stop: the pump exits through the biased stop arm and the
+ // post-loop drain must still deliver the reply-lane frame.
+ let (stop_tx, stop_rx) = shard::channel::<()>(1);
+ stop_tx.try_send(()).expect("stop channel has capacity");
+ shard
+ .run_message_pump(stop_rx, Arc::new(AtomicBool::new(false)))
+ .await;
+
+ let replies = bus.client_replies.borrow();
+ assert_eq!(
+ replies.len(),
+ 1,
+ "the pump's reply-lane drain must deliver the forwarded reply"
+ );
+ assert_eq!(
+ replies[0].0, TRANSPORT,
+ "the forward must reach the client it was addressed to"
+ );
+ }
+
+ /// The `GET_CLUSTER_METADATA` auth gate holds on every roster shape: it
+ /// describes the private replica network, and a client that dialed a
+ /// backup reaches the cluster by logging in there (the backup forwards
+ /// the register), not by reading the topology first.
+ ///
+ /// The denial must be a plain Reply on the status channel, not an
+ /// Eviction: no session exists yet, and a session-terminal frame makes
+ /// SDKs drop the connection their login is about to use.
+ #[compio::test]
+ async fn pre_auth_cluster_metadata_denied_on_every_roster() {
+ use configs::cluster::{ClusterNodeConfig, TransportPorts};
+ use iggy_binary_protocol::codes::GET_CLUSTER_METADATA_CODE;
+ use iggy_binary_protocol::{GenericHeader, ReplyHeader};
+
+ const TRANSPORT: u128 = 91;
+ const COMMAND_OFFSET: usize = std::mem::offset_of!(GenericHeader, command);
+ const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
+ const OP_OFFSET: usize = std::mem::offset_of!(ReplyHeader, op);
+ const COMMIT_OFFSET: usize = std::mem::offset_of!(ReplyHeader, commit);
+
+ fn metadata_read() -> Message<GenericHeader> {
+ let header_size = size_of::<RequestHeader>();
+ let mut message = Message::<RequestHeader>::new(header_size);
+ {
+ let header = bytemuck::checked::from_bytes_mut::<RequestHeader>(
+ &mut message.as_mut_slice()[..header_size],
+ );
+ *header = RequestHeader {
+ command: Command::Request,
+ operation: Operation::NonReplicated,
+ size: u32::try_from(header_size).expect("header fits u32"),
+ client: TRANSPORT,
+ ..Default::default()
+ };
+ header.reserved[..4].copy_from_slice(&GET_CLUSTER_METADATA_CODE.to_le_bytes());
+ }
+ message.into_generic()
+ }
+
+ fn roster_node(name: &str) -> ClusterNodeConfig {
+ ClusterNodeConfig {
+ name: name.to_owned(),
+ ip: "127.0.0.1".to_owned(),
+ advertised_address: None,
+ advertised_addresses: Vec::new(),
+ replica_id: 0,
+ ports: TransportPorts::default(),
+ }
+ }
+
+ let bus = SpyBus::default();
+ let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
+ let sessions = Rc::new(RefCell::new(SessionManager::new()));
+ let system_config = Arc::new(ServerSystemConfig::default());
+
+ let multi_node = Rc::new(ClusterRoster {
+ enabled: true,
+ name: "test-cluster".to_owned(),
+ nodes: ["node-0", "node-1"]
+ .map(|name| {
+ configs::cluster::ResolvedClusterNode::try_from(roster_node(name))
+ .expect("valid roster node")
+ })
+ .to_vec(),
+ self_advertised: "127.0.0.1".to_owned(),
+ self_ports: TransportPorts::default(),
+ metadata_view: Arc::new(std::sync::atomic::AtomicU64::new(
+ crate::cluster_meta::METADATA_VIEW_UNKNOWN,
+ )),
+ });
+ // Default roster is disabled / single node; the installed one is a
+ // real cluster. Neither serves an unbound caller.
+ for roster in [None, Some(multi_node)] {
+ if let Some(roster) = roster {
+ sessions.borrow_mut().set_cluster_roster(roster);
+ }
+ handle_client_request(
+ &shard,
+ &sessions,
+ &system_config,
+ 1,
+ TRANSPORT,
+ metadata_read(),
+ )
+ .await;
+ let replies = bus.client_replies.borrow();
+ assert_eq!(replies.len(), 1, "gated read must still produce a frame");
+ let (client, frame) = &replies[0];
+ assert_eq!(*client, TRANSPORT);
+ assert_eq!(
+ frame[COMMAND_OFFSET],
+ Command::Reply as u8,
+ "an unbound cluster-metadata read must be denied with a Reply, not evicted"
+ );
+ let status =
+ u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
+ assert_eq!(
+ status,
+ IggyError::Unauthenticated.as_code(),
+ "deny reply status must be Unauthenticated"
+ );
+ let op = u64::from_le_bytes(frame[OP_OFFSET..OP_OFFSET + 8].try_into().unwrap());
+ assert_eq!(op, 0, "pre-auth deny carries no session, so op must be 0");
+ let commit =
+ u64::from_le_bytes(frame[COMMIT_OFFSET..COMMIT_OFFSET + 8].try_into().unwrap());
+ assert_eq!(commit, 0, "pre-auth deny must not disclose commit activity");
+ drop(replies);
+ bus.client_replies.borrow_mut().clear();
+ }
+ }
+
+ #[test]
+ fn create_topic_bounds_deny_pre_consensus() {
+ let segment_size = iggy_common::DEFAULT_SEGMENT_SIZE;
+ assert!(segment_size > 0, "default segment size must be nonzero");
+
+ assert!(
+ validate_topic_bounds(
+ MAX_PARTITIONS_PER_REQUEST,
+ MaxTopicSize::ServerDefault,
+ segment_size
+ )
+ .is_ok(),
+ "the partition cap itself is admissible"
+ );
+ assert!(
+ matches!(
+ validate_topic_bounds(
+ MAX_PARTITIONS_PER_REQUEST + 1,
+ MaxTopicSize::ServerDefault,
+ segment_size
+ ),
+ Err(IggyError::TooManyPartitions)
+ ),
+ "one past the partition cap must deny"
+ );
+ // ServerDefault is numerically 0 yet exempt from the segment-size
+ // floor: it resolves against server config, matching legacy.
+ assert!(validate_topic_bounds(1, MaxTopicSize::ServerDefault, segment_size).is_ok());
+ assert!(validate_topic_bounds(1, MaxTopicSize::Unlimited, segment_size).is_ok());
+ let below_floor = MaxTopicSize::Custom((segment_size - 1).into());
+ assert!(
+ matches!(
+ validate_topic_bounds(1, below_floor, segment_size),
+ Err(IggyError::InvalidTopicSize(size, floor))
+ if size == below_floor && floor == IggyByteSize::from(segment_size)
+ ),
+ "custom size below the segment size must deny with the bounds"
+ );
+ let at_floor = MaxTopicSize::Custom(IggyByteSize::from(segment_size));
+ assert!(
+ validate_topic_bounds(1, at_floor, segment_size).is_ok(),
+ "a topic exactly one segment large is admissible"
+ );
+ }
+
+ #[test]
+ fn partitions_count_cap_denies_pre_consensus() {
+ assert!(
+ validate_partitions_count(MAX_PARTITIONS_PER_REQUEST).is_ok(),
+ "the cap itself is admissible"
+ );
+ assert!(
+ matches!(
+ validate_partitions_count(MAX_PARTITIONS_PER_REQUEST + 1),
+ Err(IggyError::TooManyPartitions)
+ ),
+ "one past the cap must deny"
+ );
+ // Zero passes the shared cap because a zero-partition TOPIC is legal
+ // (legacy `create_topic` admits `0..=MAX`).
+ assert!(validate_partitions_count(0).is_ok());
+ }
+
+ #[test]
+ fn zero_partitions_change_denies_pre_consensus() {
+ // Adding or removing zero partitions is a no-op that would still burn
+ // a replicated log entry and force a rebalance. Legacy rejects it with
+ // `TooManyPartitions` in both handlers, so the code matches.
+ assert!(
+ matches!(
+ validate_partitions_change_count(0),
+ Err(IggyError::TooManyPartitions)
+ ),
+ "adding or removing zero partitions must deny"
+ );
+ assert!(validate_partitions_change_count(1).is_ok());
+ assert!(validate_partitions_change_count(MAX_PARTITIONS_PER_REQUEST).is_ok());
+ assert!(
+ matches!(
+ validate_partitions_change_count(MAX_PARTITIONS_PER_REQUEST + 1),
+ Err(IggyError::TooManyPartitions)
+ ),
+ "the cap still applies"
+ );
+ }
+}
diff --git a/core/server/src/dispatch/partition.rs b/core/server/src/dispatch/partition.rs
new file mode 100644
index 000000000..4da515f8e
--- /dev/null
+++ b/core/server/src/dispatch/partition.rs
@@ -0,0 +1,1497 @@
+// 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.
+
+//! The partition data plane, both sides of the mesh on one page.
+//!
+//! Server side: [`make_partition_read_handler`] answers `PartitionRead`
+//! frames on the owning shard (poll snapshots, consumer offsets, segment
+//! deletes). Client side: the funnel routes partition writes through
+//! [`dispatch_partition_request`], and the non-replicated read arms call
+//! [`handle_poll_messages`] / [`handle_get_consumer_offset`], which read via
+//! the shard mesh.
+//!
+//! Deliberate asymmetry (the plane-split reply trio): the partitions engine
+//! replies to committed writes itself, straight from the owning shard, while
+//! everything the host builds here -- read bodies, denies, empty-poll shapes
+//! -- goes out on the connection's home shard. The reply path is therefore
+//! split by plane, not unified, and the deny helpers in `authz` are the
+//! third leg (typed status replies for requests that never reach a plane).
+
+use crate::consumer_group::maybe_rewrite_consumer_offset_request;
+use crate::dispatch::authz::{
+ authorize_partition_op, authorize_partition_read, send_deny_reply, send_non_replicated_deny,
+};
+use crate::dispatch::submit::submit_client_request_on_owner;
+use crate::dispatch::{send_non_replicated_bytes, send_reply_frame, upgrade_shard_handle};
+use crate::responses::{
+ build_consumer_offset_body, build_empty_reply, build_polled_messages_reply,
+ current_metadata_commit, resolve_partition_namespace, resolve_partition_request_namespace,
+};
+use crate::shell::{ShellBus, ShellShard, ShellShardHandle};
+use crate::wire::{request_body, usize_to_u32};
+use bytes::Bytes;
+use consensus::{Consensus, MetadataHandle, PartitionsHandle, build_result_rejection_reply};
+use iggy_binary_protocol::PrepareHeader;
+use iggy_binary_protocol::primitives::consumer::WireConsumer;
+use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
+use iggy_binary_protocol::requests::consumer_offsets::{
+ GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
+};
+use iggy_binary_protocol::requests::messages::PollMessagesRequest;
+use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
+use iggy_binary_protocol::{
+ AckLevel, Command, KIND_CONSUMER_GROUP, Operation, RoutedRequestHeader, WireDecode, WireEncode,
+ WireIdentifier,
+};
+use iggy_common::{IggyError, PollingStrategy};
+use journal::superblock::SuperblockStore;
+use journal::{Journal, JournalHandle};
+use message_bus::AUTO_COMMIT_CLIENT_ID;
+use metadata::impls::metadata::{
+ StreamsFrontend, build_truncate_partition_client_message,
+ build_truncate_partition_client_message_with_identifiers,
+};
+use partitions::{AutoCommitApplied, PollPlan, PollingArgs, PollingConsumer};
+use server_common::Message;
+use server_common::sharding::IggyNamespace;
+use shard::shards_table::ShardsTable;
+use shard::{PartitionRead, PartitionReadHandler, PartitionReadReply};
+use std::rc::Rc;
+use tracing::{debug, warn};
+
+/// 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 fn make_partition_read_handler<B, MJ, S, SB>(
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
+) -> PartitionReadHandler
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let shard_handle = Rc::clone(shard_handle);
+ // Runs synchronously on the shard pump (see `process_lifecycle` ->
+ // `on_partition_read`). `build_poll_snapshot` takes a pump-only `&mut`
+ // partition borrow (synchronous, so no sibling task can realloc under it) 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<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
+ namespace: IggyNamespace,
+ plan: PollPlan,
+ reply: shard::Sender<PartitionReadReply>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let bus = shard.bus.clone();
+ bus.spawn(async move {
+ // Diagnostic-only wall clock: `elapsed` gates the slow-poll `warn!`
+ // below and is never folded into a reply or the deterministic schedule,
+ // so it stays sound under the simulator's virtual clock (there it just
+ // measures near-zero real time and never fires). Do not derive any
+ // replicated or reply value from it, or replay determinism breaks.
+ 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,
+ });
+ });
+}
+
+/// 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ namespace: IggyNamespace,
+ applied: &AutoCommitApplied,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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_transferring()) {
+ 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 `StoreConsumerOffset` 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<RoutedRequestHeader>, IggyError> {
+ let request = StoreConsumerOffsetRequest {
+ 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::<RoutedRequestHeader>();
+ let total_size = header_size + body.len();
+ let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
+ let mut message = Message::<RoutedRequestHeader>::new(total_size);
+ message.as_mut_slice()[header_size..].copy_from_slice(&body);
+ Ok(
+ message.transmute_header(|_, header: &mut RoutedRequestHeader| {
+ *header = RoutedRequestHeader {
+ command: Command::Request,
+ operation: Operation::StoreConsumerOffset,
+ 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,
+ group: namespace.inner(),
+ ..Default::default()
+ };
+ }),
+ )
+}
+
+/// 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 with a nonzero status -- unresolvable namespace,
+/// authorization denial, exhausted routable wait -- so the client fails fast
+/// instead of wedging on a silent drop or reading a status-0 frame as a
+/// committed write.
+///
+/// `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 async fn dispatch_partition_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ request: Message<RoutedRequestHeader>,
+ vsr_client_id: u128,
+ bound_session: u64,
+ transport_client_id: u128,
+ acting_user_id: Option<u32>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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`,
+ // or an explicit partition id that skipped the client-side
+ // resolve). The op never reached the partition plane, so a status-0
+ // reply would read as a committed ack for work that never happened.
+ // A silent drop is no better: the SDK connection processes replies
+ // in lockstep and would wedge forever.
+ warn!(
+ transport_client_id,
+ error = %error,
+ operation = ?header.operation,
+ "partition request with unresolved namespace; replying denied"
+ );
+ send_deny_reply(
+ shard,
+ transport_client_id,
+ &header,
+ IggyError::ResourceNotFound(String::new()).as_code(),
+ )
+ .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_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). An op arriving inside that window is not lost
+ // if it skips this wait -- `router::route_typed` falls back to the hash
+ // assignment, and the owning shard parks it -- so this is an admission
+ // courtesy that keeps the steady state off that park buffer, not a
+ // correctness gate. See `wait_for_partition_routable`, which spells out why
+ // there is no owner-readiness probe here any more.
+ if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
+ // The op never reached the partition plane, so it is safe to re-issue
+ // anywhere -- the same contract the plane itself answers for a
+ // non-primary routing artifact. A status-0 empty reply here would
+ // fabricate a success ack for a write that hit no partition at all.
+ warn!(
+ transport_client_id,
+ namespace,
+ operation = ?header.operation,
+ "partition request not routable within budget; replying transient"
+ );
+ send_deny_reply(
+ shard,
+ transport_client_id,
+ &header,
+ IggyError::TransientNotAccepted.as_code(),
+ )
+ .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 RoutedRequestHeader| {
+ *new_header = header;
+ new_header.group = 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. Current SDKs do number
+ // partition ops, but older and internal callers may still send
+ // zero, so a zero id is normalized to the compatibility value 1.
+ new_header.session = bound_session;
+ new_header.request = new_header.request.max(1);
+ });
+ shard.dispatch(request.into_generic());
+}
+
+/// 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)]
+pub(in crate::dispatch) async fn handle_poll_messages<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ request: &Message<RoutedRequestHeader>,
+ user_id: Option<u32>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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,
+ }) => match build_polled_messages_reply(
+ request.header(),
+ current_metadata_commit(shard),
+ partition_id,
+ current_offset,
+ fragments,
+ shard.plane.partitions().config().encryptor.as_deref(),
+ ) {
+ Ok(reply) => {
+ send_reply_frame(shard, transport_client_id, reply, "poll_messages").await;
+ return;
+ }
+ Err(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 stream, topic, or partition id that does not resolve is a
+ // client addressing error and must surface as a typed rejection,
+ // not an empty poll a consumer would read as end-of-partition.
+ if matches!(
+ error,
+ IggyError::PartitionNotFound(..)
+ | IggyError::StreamIdNotFound(_)
+ | IggyError::TopicIdNotFound(..)
+ ) {
+ warn!(
+ transport_client_id,
+ error = %error,
+ "poll_messages rejected: target not found"
+ );
+ send_non_replicated_deny(shard, request, transport_client_id, error.as_code())
+ .await;
+ return;
+ }
+ // 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).
+// TODO(hubcio): plain local partition_read with no primary gate, so a
+// follower answers from its own (possibly lagging) offset state. Needs the
+// same is-caught-up-primary gate the auto-commit path has, or an explicit
+// read-from-follower contract.
+#[allow(clippy::future_not_send)]
+pub(in crate::dispatch) async fn handle_get_consumer_offset<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ request: &Message<RoutedRequestHeader>,
+ user_id: Option<u32>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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(),
+ }
+ }
+ // A partition id that does not exist in a resolvable topic is a client
+ // addressing error, the same one the poll path denies typed. An empty
+ // body decodes as `None` -- indistinguishable from "this consumer has
+ // no stored offset yet" -- so the caller cannot tell a typo from a
+ // fresh consumer.
+ Err(error @ IggyError::PartitionNotFound(..)) => {
+ warn!(
+ transport_client_id,
+ error = %error,
+ "get_consumer_offset rejected: partition not found"
+ );
+ send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
+ return;
+ }
+ 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;
+}
+
+/// Ack a consumer-offset op whose body could not be rewritten for the
+/// partition plane 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ request_header: &RoutedRequestHeader,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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 this shard holds a routing row for `namespace`. Fast
+/// path: row already present -> 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. This is an admission courtesy, not a correctness gate: the row
+/// is a cache of the deterministic hash assignment and may exist before the
+/// owner has materialised anything, so its presence proves only where the
+/// partition belongs. What makes an early arrival safe is the owning shard
+/// itself - `park_if_unmaterialised` holds the frame until its partition lands,
+/// and `serves_committed_incarnation` refuses to serve a mismatched
+/// incarnation. Waiting here simply keeps the steady state off that park
+/// buffer, whose overflow is the one path that still sheds a request without
+/// replying (`frame_drops_total{variant=partition,reason=park_overflow}`).
+///
+/// Deliberately no owner-readiness probe. One used to run here, on the theory
+/// that the table could not be trusted; it could not close the window either,
+/// because the fast path above skipped it in exactly the case it was meant to
+/// cover - a row seeded from the hash by a shard that owns nothing. Readiness
+/// belongs to the owner, which is where it is now enforced.
+#[allow(clippy::future_not_send)]
+async fn wait_for_partition_routable<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ namespace: IggyNamespace,
+) -> bool
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ const ATTEMPT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
+ // 3s budget at 50ms per attempt. Counting attempts, not reading a
+ // wall-clock deadline, keeps the wait virtual under the simulator: the
+ // bus sleep advances virtual time, whereas `Instant::now` would not.
+ const MAX_ATTEMPTS: u32 = 60;
+
+ let mut attempts = 0u32;
+ while shard.shards_table().shard_for(namespace).is_none() {
+ if attempts >= MAX_ATTEMPTS {
+ return false;
+ }
+ attempts += 1;
+ shard.bus.sleep(ATTEMPT_DELAY).await;
+ }
+ true
+}
+
+/// 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)
+}
+
+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 fn resolve_poll_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ wire: &PollMessagesRequest,
+ client_id: u128,
+) -> Result<DecodedPollRequest, IggyError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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 fn resolve_consumer_offset_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ wire: &GetConsumerOffsetRequest,
+) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ // 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)
+}
+
+/// 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)]
+pub(in crate::dispatch) async fn handle_delete_segments_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ bound: Option<(u128, u64)>,
+ request: &Message<RoutedRequestHeader>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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,
+ // advancing the watermark. Skipping the commit (or attributing it to an
+ // internal id) leaves this request id unrecorded, so the SDK's own retry
+ // of it would re-execute instead of deduping. 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 async fn resolve_delete_segments_truncate<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ template: &RoutedRequestHeader,
+ client_id: u128,
+ session: u64,
+ body: &[u8],
+) -> Result<Message<RoutedRequestHeader>, IggyError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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, so the failure is recorded against the client's request id
+ // and its retry dedups, while the client gets the typed error an
+ // empty ack would swallow.
+ 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,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dispatch::test_support::{
+ SpyBus, TestMux, TestShard, prepare_message, request_message,
+ };
+ use iggy_binary_protocol::ReplyHeader;
+ use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
+ use iggy_binary_protocol::requests::messages::SendMessagesHeader;
+ use iggy_binary_protocol::requests::streams::CreateStreamRequest;
+ use iggy_binary_protocol::requests::topics::{
+ CreateTopicRequest, CreateTopicWithAssignmentsRequest,
+ };
+ use iggy_binary_protocol::{WireName, WireOptions, WirePartitioning};
+ use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
+ use metadata::IggyMetadata;
+ use metadata::stm::StateMachine as _;
+ use partitions::{IggyPartitions, PartitionPathLayout, PartitionsConfig};
+ use server_common::MessageBag;
+ use server_common::sharding::ShardId;
+ use shard::metrics::ShardMetrics;
+ use shard::shards_table::PapayaShardsTable;
+ use shard::{
+ LifecycleFrame, PartitionConsensusConfig, ReconcileOp, ReplicaTopology, ShardFrame,
+ ShardIdentity, shard_channel,
+ };
+
+ /// A partition write whose routable wait exhausts (namespace committed,
+ /// but no reconciler ever seeds this shard's routing row -- the state a
+ /// teardown/rematerialise churn leaves behind) must answer a nonzero
+ /// retriable status. A status-0 empty reply is a fabricated success: the
+ /// SDK grades the send as acknowledged while zero bytes reached any
+ /// partition.
+ #[compio::test]
+ async fn unroutable_partition_send_must_reply_transient_error_not_success() {
+ const VSR_CLIENT: u128 = 1;
+ const SESSION: u64 = 1;
+ const TRANSPORT: u128 = 91;
+ const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
+
+ let bus = SpyBus::default();
+ let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
+ let partitions = IggyPartitions::new(
+ ShardId::new(0),
+ PartitionsConfig {
+ messages_required_to_save: 1,
+ size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
+ enforce_fsync: false,
+ validate_checksum: true,
+ segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
+ preallocate_segments: false,
+ encryptor: None,
+ path_layout: PartitionPathLayout::default(),
+ },
+ );
+ let shard = Rc::new(TestShard::without_inbox(
+ ShardIdentity::new(0, "unroutable-send-test".to_string()),
+ bus.clone(),
+ metadata,
+ partitions,
+ PapayaShardsTable::new(),
+ PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
+ ));
+ let md = shard.plane.metadata();
+
+ // Committed stream 0 / topic 0 / partition 0, applied straight into
+ // the STM: the namespace resolves and root authorizes, but no
+ // reconciler runs, so the shards table never gains a routing row and
+ // the routable wait exhausts its budget.
+ md.mux_stm.users().ensure_root_user("iggy", "hash");
+ let create_stream = CreateStreamRequest {
+ name: WireName::new("stream").unwrap(),
+ options: WireOptions::empty(),
+ };
+ md.mux_stm
+ .update(prepare_message(
+ Operation::CreateStream,
+ VSR_CLIENT,
+ 1,
+ &create_stream.to_bytes(),
+ ))
+ .unwrap();
+ let create_topic = CreateTopicWithAssignmentsRequest {
+ request: CreateTopicRequest {
+ stream_id: WireIdentifier::numeric(0),
+ partitions_count: 1,
+ name: WireName::new("topic").unwrap(),
+ options: WireOptions::empty(),
+ },
+ derived_options: WireOptions::empty(),
+ partitions: vec![CreatedPartitionAssignment {
+ partition_id: 0,
+ consensus_group_id: 1,
+ }],
+ created_view: 0,
+ };
+ md.mux_stm
+ .update(prepare_message(
+ Operation::CreateTopicWithAssignments,
+ VSR_CLIENT,
+ 2,
+ &create_topic.to_bytes(),
+ ))
+ .unwrap();
+ assert!(
+ md.mux_stm
+ .streams()
+ .namespace_from_partition(
+ &WireIdentifier::numeric(0),
+ &WireIdentifier::numeric(0),
+ 0
+ )
+ .is_some(),
+ "seeded namespace must resolve, or the unresolved-namespace path \
+ would reply instead of the exhausted routable wait"
+ );
+
+ let send_header = SendMessagesHeader {
+ stream_id: WireIdentifier::numeric(0),
+ topic_id: WireIdentifier::numeric(0),
+ partitioning: WirePartitioning::PartitionId(0),
+ messages_count: 1,
+ };
+ let send_metadata = send_header.to_bytes();
+ let mut send_body = Vec::with_capacity(4 + send_metadata.len());
+ send_body.extend_from_slice(&u32::try_from(send_metadata.len()).unwrap().to_le_bytes());
+ send_body.extend_from_slice(&send_metadata);
+ let request = request_message(Operation::SendMessages, VSR_CLIENT, SESSION, 1, &send_body);
+
+ dispatch_partition_request(
+ &shard,
+ request,
+ VSR_CLIENT,
+ SESSION,
+ TRANSPORT,
+ Some(DEFAULT_ROOT_USER_ID),
+ )
+ .await;
+
+ let replies = bus.client_replies.borrow();
+ assert_eq!(replies.len(), 1, "one reply frame for the failed send");
+ let (client, frame) = &replies[0];
+ assert_eq!(*client, TRANSPORT, "reply must target the transport id");
+ let status =
+ u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
+ assert_eq!(
+ status,
+ IggyError::TransientNotAccepted.as_code(),
+ "an unroutable partition write must surface the retriable \
+ transient status; status 0 with an empty body grades as a \
+ successfully acknowledged send"
+ );
+ }
+
+ /// A send that reaches the owning shard while its namespace is
+ /// tombstoned (the teardown fence a delete/recreate churn sets before
+ /// the disk delete) must answer the retriable transient status. The
+ /// partition plane's own tombstone guard drops the frame without any
+ /// reply; the transports decode replies in lockstep, so that silence
+ /// wedges the connection until the SDK's response read-timeout.
+ #[compio::test]
+ async fn tombstoned_partition_send_must_reply_transient_error_not_silence() {
+ const TRANSPORT: u128 = 91;
+ const SESSION: u64 = 1;
+ const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
+
+ let bus = SpyBus::default();
+ let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
+ let partitions = IggyPartitions::new(
+ ShardId::new(0),
+ PartitionsConfig {
+ messages_required_to_save: 1,
+ size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
+ enforce_fsync: false,
+ validate_checksum: true,
+ segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
+ preallocate_segments: false,
+ encryptor: None,
+ path_layout: PartitionPathLayout::default(),
+ },
+ );
+ let shard = Rc::new(TestShard::without_inbox(
+ ShardIdentity::new(0, "tombstoned-send-test".to_string()),
+ bus.clone(),
+ metadata,
+ partitions,
+ PapayaShardsTable::new(),
+ PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
+ ));
+
+ let namespace = IggyNamespace::new(0, 0, 0);
+ shard.plane.partitions().tombstone(namespace);
+
+ let request = request_message(Operation::SendMessages, TRANSPORT, SESSION, 1, &[])
+ .transmute_header(|header, new_header: &mut RoutedRequestHeader| {
+ *new_header = header;
+ new_header.group = namespace.inner();
+ });
+ shard.on_message(MessageBag::Request(request)).await;
+
+ let replies = bus.client_replies.borrow();
+ assert_eq!(
+ replies.len(),
+ 1,
+ "a send into a tombstoned namespace must produce a reply frame; \
+ silence wedges the connection's lockstep decode"
+ );
+ let (client, frame) = &replies[0];
+ assert_eq!(*client, TRANSPORT, "reply must target the request's client");
+ let status =
+ u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
+ assert_eq!(
+ status,
+ IggyError::TransientNotAccepted.as_code(),
+ "a tombstoned-namespace send must surface the retriable transient \
+ status so the SDK replays it after the partition rematerialises"
+ );
+ }
+
+ /// A send parked for a namespace that is torn down before materialising
+ /// (create -> delete before the reconciler's `InsertOwned`) is discarded
+ /// on `ConfirmRemove`. The discard must stage the same retriable
+ /// transient deny toward the client -- through the shard's own pump as a
+ /// `ForwardClientSend` -- instead of dropping the request without any
+ /// reply.
+ #[compio::test]
+ async fn discarded_parked_partition_send_must_reply_transient_error_not_silence() {
+ const TRANSPORT: u128 = 91;
+ const SESSION: u64 = 1;
+ const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
+
+ let bus = SpyBus::default();
+ let metadata = IggyMetadata::new(None, None, None, None, TestMux::default(), None);
+ let partitions = IggyPartitions::new(
+ ShardId::new(0),
+ PartitionsConfig {
+ messages_required_to_save: 1,
+ size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
+ enforce_fsync: false,
+ validate_checksum: true,
+ segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
+ preallocate_segments: false,
+ encryptor: None,
+ path_layout: PartitionPathLayout::default(),
+ },
+ );
+ // Real sender ring so the staged deny is observable: the test holds
+ // the receiving ends of this shard's own lanes. The deny is a client
+ // Reply forward, so it lands on the REPLY lane.
+ let (sender, _pump_rx, reply_rx) = shard_channel(0, 16, 16);
+ let (_inbox_tx, inbox_rx, reply_inbox_rx) = shard_channel(0, 1, 1);
+ let shard = TestShard::new(
+ ShardIdentity::new(0, "discarded-parked-send-test".to_string()),
+ bus.clone(),
+ Rc::new(|_, _| {}),
+ Rc::new(|_, _| {}),
+ Rc::new(|_| {}),
+ Rc::new(|_| {}),
+ Rc::new(|_, _, _| {}),
+ metadata,
+ partitions,
+ vec![sender],
+ inbox_rx,
+ reply_inbox_rx,
+ PapayaShardsTable::new(),
+ PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), bus.clone()),
+ None,
+ ShardMetrics::for_shard(),
+ )
+ .expect("single-sender ring is canonically ordered");
+
+ let namespace = IggyNamespace::new(0, 0, 0);
+ let request = request_message(Operation::SendMessages, TRANSPORT, SESSION, 1, &[])
+ .transmute_header(|header, new_header: &mut RoutedRequestHeader| {
+ *new_header = header;
+ new_header.group = namespace.inner();
+ });
+ // Namespace neither materialised nor tombstoned: the frame parks.
+ shard.on_message(MessageBag::Request(request)).await;
+
+ shard.enqueue_reconcile_op(ReconcileOp::ConfirmRemove { namespace });
+ shard.apply_reconcile_ops();
+
+ let mut denies = Vec::new();
+ while let Ok(frame) = reply_rx.try_recv() {
+ if let ShardFrame::Lifecycle(LifecycleFrame::ForwardClientSend { client_id, msg }) =
+ frame
+ {
+ denies.push((client_id, msg.into_contiguous().as_slice().to_vec()));
+ }
+ }
+ assert_eq!(
+ denies.len(),
+ 1,
+ "discarding a parked client request must stage exactly one deny \
+ reply; silence wedges the connection's lockstep decode"
+ );
+ let (client, frame) = &denies[0];
+ assert_eq!(*client, TRANSPORT, "deny must target the request's client");
+ let status =
+ u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 4].try_into().unwrap());
+ assert_eq!(
+ status,
+ IggyError::TransientNotAccepted.as_code(),
+ "a discarded parked send must surface the retriable transient \
+ status so the SDK replays it instead of timing out"
+ );
+ }
+}
diff --git a/core/server/src/dispatch/reads.rs b/core/server/src/dispatch/reads.rs
new file mode 100644
index 000000000..2c8b43c0d
--- /dev/null
+++ b/core/server/src/dispatch/reads.rs
@@ -0,0 +1,491 @@
+// 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.
+
+//! Non-replicated read router and its per-code arms.
+//!
+//! Every `Operation::NonReplicated` request lands in
+//! [`handle_non_replicated_request`] after the funnel's pre-auth gate; the
+//! poll and consumer-offset arms live in `partition` (they read through the
+//! shard mesh), everything else is served here from local shard state. The
+//! catch-all arm delegates to the shared `responses` builder, which is
+//! byte-shared with the HTTP read path -- authorization happens HERE (and in
+//! the HTTP layer), never in the builder.
+
+use crate::cluster_meta::ClusterRoster;
+use crate::dispatch::authz::{authorize_default_read, authorize_uid, send_non_replicated_deny};
+use crate::dispatch::partition::{handle_get_consumer_offset, handle_poll_messages};
+use crate::dispatch::send_non_replicated_bytes;
+use crate::responses::{
+ build_empty_reply, build_get_me_response, build_get_personal_access_tokens_response,
+ build_non_replicated_response, connected_client_to_response, current_metadata_commit,
+};
+use crate::session_manager::SessionManager;
+use crate::shell::{ShellBus, ShellShard};
+use crate::snapshot;
+use crate::wire::request_body;
+use bytes::Bytes;
+use configs::server::ServerSystemConfig;
+use consensus::MetadataHandle;
+use iggy_binary_protocol::PrepareHeader;
+use iggy_binary_protocol::codes::{
+ GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE,
+ GET_PERSONAL_ACCESS_TOKENS_CODE, GET_SNAPSHOT_FILE_CODE, GET_STATS_CODE, PING_CODE,
+ POLL_MESSAGES_CODE, SYNC_CONSUMER_GROUP_CODE,
+};
+use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
+use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
+use iggy_binary_protocol::requests::system::get_snapshot::GetSnapshotRequest;
+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::{HEADER_SIZE, RoutedRequestHeader, WireDecode, WireEncode};
+use iggy_common::{IggyError, SnapshotCompression, SystemSnapshotType};
+use journal::superblock::SuperblockStore;
+use journal::{Journal, JournalHandle};
+use message_bus::framing::MAX_MESSAGE_SIZE;
+use metadata::impls::metadata::StreamsFrontend;
+use metadata::permissioner::Permissioner;
+use server_common::Message;
+use std::cell::RefCell;
+use std::net::IpAddr;
+use std::rc::Rc;
+use std::sync::Arc;
+use tracing::{debug, warn};
+
+/// 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ transport_client_id: u128,
+ request: &Message<RoutedRequestHeader>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ transport_client_id: u128,
+ request: &Message<RoutedRequestHeader>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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;
+}
+
+#[allow(clippy::future_not_send, clippy::too_many_lines)]
+pub(in crate::dispatch) async fn handle_non_replicated_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ system_config: &Arc<ServerSystemConfig>,
+ transport_client_id: u128,
+ request: Message<RoutedRequestHeader>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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 and peer address for the read gates below, resolved in one
+ // connection lookup. `user_id` is `None` only on the pre-auth path
+ // (PING), which serves ungated codes; the gated arms fail closed on it.
+ let (user_id, client_address) = sessions.borrow().read_context(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();
+ let client_ip = client_address.map(|address| address.ip());
+ if client_ip.is_none() {
+ debug!(
+ transport_client_id,
+ code,
+ "no peer address recorded; advertised-address resolution degrades to the catch-all"
+ );
+ }
+ handle_default_non_replicated(
+ shard,
+ transport_client_id,
+ code,
+ &request,
+ user_id,
+ &roster,
+ client_ip,
+ )
+ .await;
+ }
+ }
+}
+
+#[allow(clippy::future_not_send, clippy::too_many_arguments)]
+async fn handle_default_non_replicated<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ code: u32,
+ request: &Message<RoutedRequestHeader>,
+ user_id: Option<u32>,
+ roster: &ClusterRoster,
+ client_ip: Option<IpAddr>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ // 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;
+ }
+ // Stats is the one default read with an async input: the cross-shard
+ // connected-client gather. Run it here so the shared builder stays sync.
+ let clients_count = if code == GET_STATS_CODE {
+ u32::try_from(shard.list_all_clients().await.len()).unwrap_or(u32::MAX)
+ } else {
+ 0
+ };
+ match build_non_replicated_response(
+ shard,
+ code,
+ request_body(request),
+ user_id,
+ roster,
+ client_ip,
+ clients_count,
+ ) {
+ 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ system_config: &Arc<ServerSystemConfig>,
+ transport_client_id: u128,
+ request: &Message<RoutedRequestHeader>,
+ user_id: Option<u32>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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))
+}
+
+/// 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ request: &Message<RoutedRequestHeader>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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;
+}
diff --git a/core/server/src/dispatch/session_ops.rs b/core/server/src/dispatch/session_ops.rs
new file mode 100644
index 000000000..d43bcc17b
--- /dev/null
+++ b/core/server/src/dispatch/session_ops.rs
@@ -0,0 +1,2096 @@
+// 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.
+
+//! Session lifecycle: login/register, logout, evictions, and their
+//! replication plumbing.
+//!
+//! Credentials (password + PAT) verify locally against replicated state on
+//! whichever node the client dialed; only the consensus `Register` / `Logout`
+//! proposal runs on the metadata owner, forwarded at most one hop to the
+//! current primary (`submit_*_local_or_forward`, shard 0 only) with both ends
+//! of that replica protocol on this page. Terminal failures surface as typed
+//! `Eviction` frames, transient ones as result-framed replay hints.
+//!
+//! Deliberate asymmetry (the two session stores): the per-shard
+//! `SessionManager` owns transport sessions (connection -> user binding,
+//! heartbeats, SDK info) and is never replicated; the consensus `ClientTable`
+//! owns replicated VSR sessions and their dedup watermarks. Login binds the
+//! two together, so logout and eviction must release BOTH -- every teardown
+//! path below pairs `remove_connection` with a replicated `Logout`.
+
+use crate::dispatch::login_error::LoginRegisterError;
+use crate::responses::{
+ build_deny_reply, build_empty_reply, build_login_register_reply, current_metadata_commit,
+};
+use crate::session_manager::{ClientSdkInfo, SessionManager};
+use crate::shell::{ShellBus, ShellShard};
+use crate::wire::request_body;
+use consensus::{
+ Consensus, DISCONNECT_LOGOUT_REQUEST_ID, EvictionContext, MetadataHandle,
+ build_eviction_message, build_incompatible_protocol_eviction_message,
+ build_result_rejection_reply,
+};
+use iggy_binary_protocol::PrepareHeader;
+use iggy_binary_protocol::requests::users::{LoginRegisterRequest, LoginRegisterWithPatRequest};
+use iggy_binary_protocol::{
+ ClientVersionInfo, Command, ConsensusHeader, EvictionReason, ForwardLogoutHeader,
+ ForwardLogoutOutcome, ForwardLogoutResultHeader, ForwardRegisterHeader, ForwardRegisterOutcome,
+ ForwardRegisterResultHeader, HEADER_SIZE, ProtocolVersion, RoutedRequestHeader, WireDecode,
+ is_protocol_compatible,
+};
+use iggy_common::defaults::{
+ MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
+};
+use iggy_common::{IggyError, IggyTimestamp, PersonalAccessToken, UserStatus};
+use journal::superblock::SuperblockStore;
+use journal::{Journal, JournalHandle};
+use metadata::MetadataSubmitError;
+use metadata::impls::metadata::{BoundSession, StreamsFrontend};
+use secrecy::ExposeSecret;
+use server_common::Message;
+use server_common::crypto;
+use std::cell::RefCell;
+use std::rc::Rc;
+use std::sync::LazyLock;
+use std::time::Duration;
+use tracing::warn;
+
+/// A well-formed Argon2 hash to verify against on the unknown-user login
+/// branch, so a missing username costs the same single `verify_password` a real
+/// user's wrong-password branch costs. Closes the username-existence timing
+/// oracle without changing the returned error. On the unknown-username branch
+/// the verify result is discarded, so even a request presenting the exact dummy
+/// plaintext cannot authenticate; the literal only needs to be a fixed input
+/// hashed by the same Argon2 hasher real users use, so the dummy verify runs an
+/// identical Argon2 KDF.
+static DUMMY_PASSWORD_HASH: LazyLock<String> =
+ LazyLock::new(|| crypto::hash_password("http-login-timing-guard"));
+
+/// Pay the one-time Argon2 cost of [`DUMMY_PASSWORD_HASH`] at boot instead of
+/// inside the first unknown-username login request.
+pub fn warm_dummy_password_hash() {
+ LazyLock::force(&DUMMY_PASSWORD_HASH);
+}
+
+pub fn verify_login_credentials<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ username: &str,
+ password: &str,
+) -> Result<u32, LoginRegisterError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ // Same bounds the legacy server enforces before any lookup or hashing;
+ // also keeps arbitrary-length input out of the password hash. Collapsed
+ // to InvalidCredentials on purpose (legacy: InvalidUsername /
+ // InvalidPassword): don't leak which field failed.
+ if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&username.len())
+ || !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&password.len())
+ {
+ return Err(LoginRegisterError::InvalidCredentials);
+ }
+ shard.plane.metadata().mux_stm.users().read(|users| {
+ let user = users
+ .index
+ .get(username)
+ .copied()
+ .and_then(|user_id| users.items.get(user_id as usize));
+ let Some(user) = user else {
+ // Constant-cost path: verify against a dummy hash so a missing
+ // username is indistinguishable by response timing from a wrong
+ // password (both return InvalidCredentials).
+ let _ = crypto::verify_password(password, DUMMY_PASSWORD_HASH.as_str());
+ return Err(LoginRegisterError::InvalidCredentials);
+ };
+ // Verify before the status check and collapse inactive to
+ // InvalidCredentials: an inactive account must answer exactly like a
+ // wrong password (same error, same Argon2 cost), or login could probe
+ // which accounts exist but are disabled.
+ if !crypto::verify_password(password, user.password_hash.as_ref())
+ || user.status != UserStatus::Active
+ {
+ return Err(LoginRegisterError::InvalidCredentials);
+ }
+ Ok(user.id)
+ })
+}
+
+pub fn verify_pat_credentials<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ token: &str,
+) -> Result<u32, LoginRegisterError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ verify_pat_credentials_with_expiry(shard, token).map(|(user_id, _)| user_id)
+}
+
+/// Like [`verify_pat_credentials`] but also surfaces the token's expiry (unix
+/// seconds, `u64::MAX` when the PAT never expires). The HTTP extractor keys a
+/// per-token VSR session table on this expiry for lazy eviction; the wire and
+/// login paths only need the user id and go through [`verify_pat_credentials`].
+pub fn verify_pat_credentials_with_expiry<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ token: &str,
+) -> Result<(u32, u64), LoginRegisterError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let token_hash = PersonalAccessToken::hash_token(token);
+ // PAT expiry gates the login accept/reject, and that outcome folds into
+ // the reply, so read the environment-injected bus clock (seed-derived
+ // under the simulator), not the wall clock, or a replayed login diverges.
+ // The two sibling wall-clock reads in `dispatch` stay direct because both
+ // are off the reply path (diagnostic-only). The bus seam exists on every
+ // shard, so this holds even when login lands on an entry shard that does
+ // not own the metadata consensus.
+ let now = IggyTimestamp::from(shard.bus.realtime_micros());
+ shard.plane.metadata().mux_stm.users().read(|users| {
+ let Some((user_id, token_name)) =
+ users.personal_access_token_index.get(token_hash.as_str())
+ else {
+ return Err(LoginRegisterError::InvalidToken);
+ };
+ let Some(pat) = users
+ .personal_access_tokens
+ .get(user_id)
+ .and_then(|tokens| tokens.get(token_name))
+ else {
+ return Err(LoginRegisterError::InvalidToken);
+ };
+ if pat.is_expired(now) {
+ return Err(LoginRegisterError::InvalidToken);
+ }
+ let Some(user) = users.items.get(*user_id as usize) else {
+ return Err(LoginRegisterError::InvalidToken);
+ };
+ if user.status != UserStatus::Active {
+ return Err(LoginRegisterError::UserInactive);
+ }
+ // `expiry_at == None` is a never-expiring PAT; map it to `u64::MAX` so
+ // the HTTP session table never expiry-evicts its entry.
+ let expiry = pat
+ .expiry_at
+ .map_or(u64::MAX, |expiry_at| expiry_at.to_secs());
+ Ok((user.id, expiry))
+ })
+}
+
+#[allow(clippy::future_not_send)]
+async fn complete_login_register<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ transport_client_id: u128,
+ vsr_client_id: u128,
+ request_header: &RoutedRequestHeader,
+ user_id: u32,
+ client_version: &ClientVersionInfo,
+) -> Result<(), LoginRegisterError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let sdk_info = ClientSdkInfo {
+ sdk_name: client_version.sdk_name.as_str().to_owned(),
+ sdk_version: client_version.sdk_version.as_str().to_owned(),
+ protocol_version: client_version.protocol_version,
+ };
+ let existing_session = {
+ let sessions = sessions.borrow();
+ sessions
+ .get_session(transport_client_id)
+ .map(|(_, session)| session)
+ };
+ if let Some(session) = existing_session {
+ // Re-login on a bound connection: refresh the recorded SDK info
+ // (a reconnecting client may have been upgraded) and replay.
+ sessions
+ .borrow_mut()
+ .record_sdk_info(transport_client_id, sdk_info);
+ // A lagging backup's commit_max can sit below the epoch this session
+ // already bound; never advertise a commit behind the session itself.
+ let commit = current_metadata_commit(shard).max(session);
+ let reply =
+ build_login_register_reply(request_header, vsr_client_id, session, commit, user_id);
+ let _ = shard
+ .bus
+ .send_to_client(transport_client_id, reply.into_generic().into_frozen())
+ .await;
+ return Ok(());
+ }
+
+ // Submit Register and await the commit. The SessionManager is left
+ // untouched until the op commits cluster-wide (post-quorum): there is no
+ // optimistic Authenticated transition, so a transient submit failure
+ // needs no rollback -- the connection stays Connected and the SDK
+ // read-timeout replays.
+ let session = match submit_register_on_owner(shard, vsr_client_id, user_id).await {
+ // The wire reply carries only the fence epoch; the SDK numbers its
+ // own requests, so the bind watermark is not surfaced (see the
+ // BoundSession doc for who does consume it).
+ Ok(bound) => bound.epoch,
+ Err(error) => {
+ return Err(LoginRegisterError::Transient(error));
+ }
+ };
+
+ // Post-commit: Connected -> Authenticated -> Bound in a single borrow with
+ // no await in between, so the intermediate Authenticated state is never
+ // observable to a concurrent request on this connection.
+ {
+ let mut sessions = sessions.borrow_mut();
+ sessions
+ .login(transport_client_id, user_id)
+ .map_err(LoginRegisterError::Session)?;
+ sessions.record_sdk_info(transport_client_id, sdk_info);
+ if let Err(error) = sessions.bind_session(transport_client_id, vsr_client_id, session) {
+ // No local rollback: `submit_register_in_process` above has
+ // already committed cluster-wide. A local-only
+ // `remove_client_session` here would diverge peers (they retain
+ // the slot until they evict the client themselves). The
+ // transport-disconnect callback owns local cleanup once the
+ // socket closes.
+ return Err(LoginRegisterError::Session(error));
+ }
+ }
+
+ // `session` IS the register's commit op, and on a backup that forwarded
+ // the proposal the local applied commit still lags it. Reporting the
+ // lower number would make one frame contradict itself.
+ let commit = current_metadata_commit(shard).max(session);
+ let reply = build_login_register_reply(request_header, vsr_client_id, session, commit, user_id);
+ let send_result = shard
+ .bus
+ .send_to_client(transport_client_id, reply.into_generic().into_frozen())
+ .await;
+ if let Err(error) = send_result {
+ warn!(
+ transport_client_id,
+ error = %error,
+ "failed to send login/register reply"
+ );
+ }
+
+ Ok(())
+}
+
+/// Decide whether a failed login/register gets a terminal eviction or a
+/// transient replay hint.
+///
+/// A transient consensus failure ([`LoginRegisterError::is_terminal`] is
+/// `false`) means the cluster could not commit *right now* (a freshly booted
+/// primary still catching up, or a cross-shard submit canceled). Those get a
+/// result-framed replay hint instead of silence, so the SDK replays at once
+/// rather than waiting out its read-timeout; replying empty would surface as
+/// a hard `InvalidFormat` decode failure and break the replay.
+///
+/// Terminal auth errors (`InvalidCredentials` / `InvalidToken` /
+/// `UserInactive` / `Session`) fast-fail with a typed `Eviction` frame so the
+/// SDK surfaces the real reason (every frame transport decodes
+/// `Command::Eviction`) instead of a decode error or a timeout.
+#[allow(clippy::future_not_send)]
+async fn surface_login_failure<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ request_header: &RoutedRequestHeader,
+ error: &LoginRegisterError,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ if error.is_terminal() {
+ send_login_eviction(
+ shard,
+ transport_client_id,
+ request_header.client,
+ eviction_reason_for(error),
+ )
+ .await;
+ } else {
+ // Which code the hint carries is what tells the client whether the
+ // replay may move to another node: see `transient_login_code`.
+ send_login_transient_reply(
+ shard,
+ transport_client_id,
+ request_header,
+ transient_login_code(error),
+ )
+ .await;
+ }
+}
+
+/// Result-framed transient Reply on a non-terminal failed Register. The SDK
+/// decodes the nonzero result code and replays the same login on the same
+/// connection. Only call for transient errors -- see
+/// [`surface_login_failure`].
+#[allow(clippy::future_not_send)]
+async fn send_login_transient_reply<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+ request_header: &RoutedRequestHeader,
+ code: IggyError,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let commit = current_metadata_commit(shard);
+ let reply = build_result_rejection_reply(request_header, commit, code.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,
+ "failed to send login transient reply"
+ );
+ }
+}
+
+/// Wire code for a transient (non-terminal) login/register failure.
+///
+/// `TransientNotAccepted` asserts nothing was committed: the register never
+/// entered a pipeline (not primary / not caught up / pipeline full) or never
+/// left this node (primary unreachable). The client may re-issue it anywhere,
+/// including under a fresh identity after failing over to another node.
+///
+/// A forward timeout, an in-progress proposal, or a canceled proposal has an
+/// UNKNOWN outcome, so none can ride that assertion. `TransientNotCommitted`
+/// pins the replay to this connection and its client id, where a register that
+/// did commit rebinds its own client-table entry. Re-issuing under a freshly
+/// minted id would instead orphan that entry until capacity eviction reclaims
+/// it.
+const fn transient_login_code(error: &LoginRegisterError) -> IggyError {
+ match error {
+ LoginRegisterError::Transient(
+ MetadataSubmitError::ForwardTimedOut
+ | MetadataSubmitError::InProgress
+ | MetadataSubmitError::Canceled,
+ ) => IggyError::TransientNotCommitted,
+ _ => IggyError::TransientNotAccepted,
+ }
+}
+
+/// Wire reason for a terminal login/register failure. Session-level
+/// rejections (including the non-retryable submit refusal, where the
+/// presented client id belongs to another user) collapse to
+/// `SessionError`; the SDK maps it to `Unauthenticated`.
+const fn eviction_reason_for(error: &LoginRegisterError) -> EvictionReason {
+ match error {
+ LoginRegisterError::InvalidCredentials => EvictionReason::InvalidCredentials,
+ LoginRegisterError::InvalidToken => EvictionReason::InvalidToken,
+ LoginRegisterError::UserInactive => EvictionReason::UserInactive,
+ _ => EvictionReason::SessionError,
+ }
+}
+
+/// Reject a replicated request from an unbound transport with a typed
+/// `Eviction(NoSession)` frame: the session the client believes it has is
+/// gone, so it must register again. Pre-auth non-replicated reads get a
+/// deny Reply instead (no session exists, so nothing is evicted).
+///
+/// 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)]
+pub(in crate::dispatch) async fn send_unauthenticated_eviction<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ transport_client_id: u128,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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 async fn run_heartbeat_verifier<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: Rc<RefCell<SessionManager>>,
+ interval: std::time::Duration,
+ stop_rx: shard::Receiver<()>,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ // Legacy `MAX_THRESHOLD`: a client is stale once it misses 1.2 intervals.
+ // Integer 6/5 rather than `mul_f64`, which panics on an absurd interval.
+ let max_age = interval.saturating_mul(6) / 5;
+ loop {
+ // `Ok(_)`: stop signalled -> exit. `Err(_)`: interval elapsed -> pass.
+ // Waiting on the stop channel rather than sleeping past it keeps this
+ // task inside the shutdown drain budget, which is shorter than the
+ // heartbeat interval.
+ let stop_signal = compio::time::timeout(interval, stop_rx.recv()).await;
+ if stop_signal.is_ok() {
+ break;
+ }
+ // Production-only wall clock: the heartbeat verifier is spawned solely
+ // by `build_shard_for_thread`, never by the simulator's
+ // `wire_shell_handlers`, so neither the interval wait above nor this
+ // read is on a deterministic path. Driving this task under the
+ // deterministic executor means routing both through the injected clock.
+ 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;
+ }
+ }
+ }
+}
+
+/// 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<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ transport_client_id: u128,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ 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)"
+ );
+ }
+}
+
+/// Answer a backup's forwarded `Register` from the node it named primary.
+///
+/// Proposes in process, never through [`submit_register_local_or_forward`]:
+/// that is what bounds a forward at one hop. A node that has since lost
+/// primaryship answers `NotPrimary`, and the origin's client replays against
+/// whichever node it names next.
+#[allow(clippy::future_not_send)]
+pub(in crate::dispatch) async fn answer_forwarded_register<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ vsr_client_id: u128,
+ user_id: u32,
+ nonce: u128,
+ origin_replica: u8,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let Some((cluster, view, replica)) = shard
+ .plane
+ .metadata()
+ .consensus
+ .as_ref()
+ .map(|consensus| (consensus.cluster(), consensus.view(), consensus.replica()))
+ else {
+ warn!("ForwardedRegister submit reached a shard without metadata consensus");
+ return;
+ };
+ let bound = shard
+ .plane
+ .metadata()
+ .submit_register_in_process(vsr_client_id, user_id)
+ .await;
+ // `view` predates the await above, which parks with no deadline, so the
+ // sealed value can be stale by send time. The origin routes the result by
+ // `(nonce, client)` alone; this field must never become a freshness fence.
+ let result =
+ build_forward_register_result_message(cluster, view, replica, vsr_client_id, nonce, &bound);
+ if let Err(error) = shard
+ .bus
+ .send_to_replica(origin_replica, result.into_generic().into_frozen())
+ .await
+ {
+ warn!(
+ origin_replica,
+ error = %error,
+ "failed to answer a forwarded register"
+ );
+ }
+}
+
+/// How long a login waits for the primary's verdict on a forwarded register.
+///
+/// Expiry does NOT prove the peer or the frame was lost. The primary answers
+/// only once the proposal resolves, and its own submit parks with no deadline:
+/// a primary that is not caught up, or whose pipeline is full, absorbs the
+/// register into its request queue and answers when that drains. So a slow but
+/// healthy primary commits the register after this node has stopped waiting,
+/// which is why expiry surfaces as `TransientNotCommitted` rather than the
+/// not-accepted flavor.
+///
+/// The budget stays well under the SDK's response-read timeout on purpose: the
+/// client only replays a login while it is still reading, so a longer wait
+/// here turns a transient into a torn-down socket.
... 2117 lines suppressed ...