This is an automated email from the ASF dual-hosted git repository.
numinnex pushed a commit to branch clients_table_v2
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/clients_table_v2 by this push:
new 05124f1f7 addres review comments
05124f1f7 is described below
commit 05124f1f7d967eef878d504511d62842745f5e8d
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Tue Jul 28 09:30:42 2026 +0200
addres review comments
---
core/binary_protocol/src/consensus/header.rs | 18 ++-
core/consensus/src/client_table.rs | 4 +-
core/consensus/src/metadata_helpers.rs | 105 ++++++++++--
.../tests/cluster/client_table_restart.rs | 27 +++-
core/metadata/src/impls/metadata.rs | 122 +++++++++-----
core/metadata/src/impls/recovery.rs | 94 ++++++++++-
core/sdk/src/vsr.rs | 18 ++-
core/server-ng/src/bootstrap.rs | 15 +-
core/server-ng/src/dispatch.rs | 152 ++++-------------
core/server-ng/src/http/error.rs | 53 +++++-
core/server-ng/src/http/session.rs | 16 +-
core/server-ng/src/http/state.rs | 113 +++++++++++--
core/server-ng/src/session_manager.rs | 162 ++-----------------
core/shard/src/coordinator.rs | 179 ++++++++++++++++++---
core/shard/src/lib.rs | 17 +-
15 files changed, 708 insertions(+), 387 deletions(-)
diff --git a/core/binary_protocol/src/consensus/header.rs
b/core/binary_protocol/src/consensus/header.rs
index 0114f8eb7..b819037a7 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -163,11 +163,19 @@ pub struct RequestHeader {
pub operation: Operation,
pub operation_padding: [u8; 7],
pub namespace: u64,
- /// Session fence epoch, handed to the client by its `Register` reply and
- /// echoed on every subsequent request. Bumped by the server on each
- /// committed re-register of the same `client`, so a request stamped with
an
- /// older value is a zombie from before a rebind and gets fenced. Unrelated
- /// to op numbers: nothing in it derives from the consensus log.
+ /// Session fence epoch: the commit op of the latest committed `Register`
+ /// for this `client`. Handed to the client by that register's reply and
+ /// echoed on every subsequent request.
+ ///
+ /// Every bind commits a `Register`, so each rebind of the same `client`
+ /// carries a strictly higher value, and a request stamped with an older
one
+ /// is a zombie from before that rebind and gets fenced. Being a log
+ /// position rather than a counter is what makes it non-regressing: it
+ /// cannot restart low after the server drops an entry and the client
+ /// registers again.
+ ///
+ /// Zero on `Register` itself (the client has no epoch to echo yet) and on
+ /// sessionless ops; header validation enforces both.
pub session: u64,
/// Acting user id, stamped by the metadata primary at admission for every
/// gated client op so the in-apply RBAC gate resolves the same identity on
diff --git a/core/consensus/src/client_table.rs
b/core/consensus/src/client_table.rs
index 0e3471f68..0fcc3fc30 100644
--- a/core/consensus/src/client_table.rs
+++ b/core/consensus/src/client_table.rs
@@ -237,7 +237,9 @@ impl ClientTable {
}
/// Check a request against the table. Epoch fence first, then the
- /// watermark. For Register, use [`Self::check_register`].
+ /// watermark. Register does not come through here: every bind proposes
+ /// unconditionally so its fence actually moves, see
+ /// [`Self::commit_register`].
///
/// `request_checksum` is the request's integrity stamp; zero (unstamped)
/// disables the reuse check.
diff --git a/core/consensus/src/metadata_helpers.rs
b/core/consensus/src/metadata_helpers.rs
index 7bb40edf3..065de7799 100644
--- a/core/consensus/src/metadata_helpers.rs
+++ b/core/consensus/src/metadata_helpers.rs
@@ -73,6 +73,21 @@ pub enum PreflightOutcome {
/// `session` is the wire `session` field, which carries the entry's fence
/// epoch; `request_checksum` is the request's integrity stamp (zero =
/// unstamped, disables the reuse check).
+///
+/// ## What the catch-up gate below does and does not establish
+///
+/// It says this replica has APPLIED everything it holds and its log suffix has
+/// re-earned quorum, which is what makes the eviction decisions below safe
+/// against a stale in-memory table on a freshly promoted primary.
+///
+/// It says nothing about how the table was BUILT. Every clause of
+/// `is_caught_up_primary` is about the log; recovery, meanwhile, replays from
+/// this node's snapshot floor, and checkpointing is node-local, so two
replicas
+/// reconstruct their tables from different starting points. Epochs survive
that
+/// (they are op-derived), watermarks do not -- see
+/// `metadata::impls::recovery`. So a caught-up primary is authoritative for
its
+/// own table, not for agreement with its peers' tables. Closing that needs the
+/// table in the snapshot, not a stronger gate here.
pub fn request_preflight<B, P>(
consensus: &VsrConsensus<B, P>,
client_table: &RefCell<ClientTable>,
@@ -126,11 +141,13 @@ where
RequestStatus::Duplicate(cached_reply) => {
PreflightOutcome::Replay(cached_reply.into_wire_bytes())
}
- // Session evicted under capacity pressure. SAFETY: catch-up gate makes
- // this replica authoritative for session truth.
+ // Session evicted under capacity pressure. The catch-up gate makes
this
+ // replica authoritative for its own committed session state, which is
+ // what an eviction frame reports.
RequestStatus::NoSession =>
PreflightOutcome::Evict(EvictionReason::NoSession),
- // Zombie holdover from before a re-register: terminal for that
- // holder. SAFETY: catch-up gate makes this replica authoritative.
+ // Zombie holdover from before a re-register: terminal for that holder.
+ // Sound on any caught-up replica because the fence is op-derived, so
+ // every replica that applied this register holds the same value.
RequestStatus::Fenced { current, received } => {
tracing::debug!(
client_id,
@@ -234,7 +251,12 @@ where
/// `true` -> dispatch. `false` -> absorbed (`AlreadyRegistered` replays cache;
/// in-flight register silently dropped).
#[allow(clippy::future_not_send)]
-pub fn register_preflight<B, P>(consensus: &VsrConsensus<B, P>, client_id:
u128) -> bool
+pub fn register_preflight<B, P>(
+ consensus: &VsrConsensus<B, P>,
+ client_table: &RefCell<ClientTable>,
+ client_id: u128,
+ user_id: u32,
+) -> bool
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
@@ -250,9 +272,9 @@ where
}
// Catch-up gate: new primary may have inherited Register(client, op=N)
- // committed in WAL but not yet applied. Without gate, check_register
- // returns New -> a second register commits -> the epoch bumps past the
- // one the first register's reply handed the client, fencing a live
+ // committed in WAL but not yet applied. Without the gate this dispatches a
+ // second register, which commits at a later op -> the entry's fence moves
+ // past the one the first register's reply handed the client, fencing a
live
// client for no reason. SDK retry recovers post-catch-up.
if !is_caught_up_primary(consensus) {
tracing::debug!(
@@ -267,6 +289,30 @@ where
return false;
}
+ // OWNERSHIP GATE. `commit_register`'s rebind branch overwrites the entry's
+ // `user_id`, and `resolve_acting_user_id` resolves authority for every
+ // replicated op from that field, so admitting a register for an entry
+ // another user owns would hand the caller that user's authority. Refuse by
+ // dropping it: this runs on the wire ingress and on the promotion of a
+ // queued register, neither of which has a caller to return a typed error
+ // to. The in-process submit checks the same condition first and does
+ // return one (`ClientIdOwnedByAnotherUser`), so a client that reaches here
+ // and is dropped retries into that terminal answer.
+ //
+ // Correct to decide here because the catch-up gate above already
+ // established this replica as authoritative for session truth.
+ if let Some(owner) = client_table.borrow().get_user_id(client_id)
+ && owner != user_id
+ {
+ tracing::warn!(
+ client_id,
+ presented_user = user_id,
+ entry_owner = owner,
+ "register_preflight: dropping register for an entry owned by
another user"
+ );
+ return false;
+ }
+
// Past the gates, every Register dispatches -- including one whose client
// already holds an entry. A bind is a fencing event: `commit_register`'s
// rebind branch bumps the entry's epoch, which is what fences the previous
@@ -502,7 +548,7 @@ mod tests {
client_table.borrow_mut().commit_reply(client_id, app_reply);
assert!(
- register_preflight(&consensus, client_id),
+ register_preflight(&consensus, &client_table, client_id,
ACTING_USER_ID),
"rebind must dispatch so commit_register bumps the fence epoch"
);
let sends = consensus.message_bus().client_sends.borrow();
@@ -714,6 +760,41 @@ mod tests {
);
}
+ // The promotion path and the wire ingress both land here, and neither has
+ // a caller to return a typed error to, so an entry owned by another user
is
+ // refused by dropping the register. Without this a register queued while
+ // the primary was catching up would commit at promotion and
+ // `commit_register` would overwrite the entry's `user_id`, handing the
+ // presenter that user's authority.
+ #[test]
+ fn register_preflight_drops_a_register_for_another_users_entry() {
+ const OWNER: u32 = ACTING_USER_ID;
+ const IMPOSTOR: u32 = ACTING_USER_ID + 1;
+
+ let consensus = VsrConsensus::new(1, 0, 3, 0, ClientSpyBus::new(),
LocalPipeline::new());
+ consensus.init();
+ let client_table = fresh_client_table();
+ let client_id: u128 = 0xBEEF;
+ let initial_reply = synthesize_register_reply(&consensus, client_id,
17);
+ client_table
+ .borrow_mut()
+ .commit_register(client_id, OWNER, initial_reply);
+
+ assert!(
+ !register_preflight(&consensus, &client_table, client_id,
IMPOSTOR),
+ "a register for another user's entry must not dispatch"
+ );
+ assert!(
+ register_preflight(&consensus, &client_table, client_id, OWNER),
+ "the owner's own rebind must still dispatch"
+ );
+ assert_eq!(
+ client_table.borrow().get_user_id(client_id),
+ Some(OWNER),
+ "the refused register must not have touched the entry"
+ );
+ }
+
// Watermark jump: request numbers above the watermark dispatch even
// when non-contiguous (there is no RequestGap).
#[test]
@@ -754,9 +835,10 @@ mod tests {
consensus.advance_commit_max(5);
assert_ne!(consensus.commit_min(), consensus.commit_max());
+ let client_table = fresh_client_table();
let client_id: u128 = 0xC0DE;
- let result = register_preflight(&consensus, client_id);
+ let result = register_preflight(&consensus, &client_table, client_id,
ACTING_USER_ID);
assert!(!result, "register dispatch must short-circuit");
let sends = consensus.message_bus().client_sends.borrow();
@@ -794,9 +876,10 @@ mod tests {
let consensus = VsrConsensus::new(1, 0, 3, 0, ClientSpyBus::new(),
LocalPipeline::new());
consensus.init();
+ let client_table = fresh_client_table();
let client_id: u128 = 0xC0DE;
- let result = register_preflight(&consensus, client_id);
+ let result = register_preflight(&consensus, &client_table, client_id,
ACTING_USER_ID);
assert!(result, "New client proceeds through consensus");
let sends = consensus.message_bus().client_sends.borrow();
diff --git a/core/integration/tests/cluster/client_table_restart.rs
b/core/integration/tests/cluster/client_table_restart.rs
index 661aba302..750e83ce2 100644
--- a/core/integration/tests/cluster/client_table_restart.rs
+++ b/core/integration/tests/cluster/client_table_restart.rs
@@ -39,10 +39,12 @@
//! the pre-restart epoch are fenced zombies. There is deliberately no
//! credential-free rebind; `given_live_session_when_unauthenticated_peer_*`
//! pins that.
-//! 3. Sessions survive transport disconnect: `submit_disconnect_logout`
-//! tears down only consumer-group members (the group must rebalance off
-//! a dead consumer); everything else keeps its slot for resume until an
-//! explicit `Logout` or capacity eviction.
+//! 3. A crash leaves no `Logout` behind. Every transport disconnect releases
+//! its session (`submit_disconnect_logout`), so what makes the entry
+//! survivable is that the process died with the connection still open --
+//! hence these tests restart before closing the socket. Holding the slot
+//! open past a graceful disconnect would make resume work there too, but
+//! it needs a timer of its own; see that function's rustdoc.
//!
//! The Rust SDK cannot drive this yet: it resets its `ConsensusSession` on
//! every disconnect and re-registers under a fresh identity (the
@@ -120,9 +122,20 @@ async fn
given_committed_request_when_node_restarts_should_dedup_same_id_retry(
let (mut stream, session) = register(addr).await;
let create_stream = create_stream_payload("iggy137-dedup");
let committed = commit_request(&mut stream, session, 1,
&create_stream).await;
- drop(stream);
+ // Restart BEFORE dropping the socket, because the ordering is the
scenario.
+ // A crash takes the process down with the connection still open, so no
+ // `Logout` is committed and the session is still in the WAL for replay to
+ // rebuild. Closing first would model a graceful goodbye instead, and a
+ // graceful disconnect ends the session by design
+ // (`submit_disconnect_logout` releases the slot).
+ //
+ // Deterministic, not a race: the harness stops the node with SIGTERM, and
+ // the per-connection cleanup in the bus installer skips
`remove_client_meta`
+ // once the bus token is triggered -- so a shutdown fires no
+ // connection-lost callback for any still-open socket.
harness.restart_server().await.unwrap();
+ drop(stream);
// The reply for request 1 was already delivered, but the client cannot
// know that in the crash window; retrying the same id must converge on
@@ -147,9 +160,9 @@ async fn
given_bound_session_when_node_restarts_should_accept_next_request_id(
)
.await;
- drop(stream);
-
+ // Crash ordering, see the sibling test.
harness.restart_server().await.unwrap();
+ drop(stream);
// Continuation, not retry: the session advances to the next id. A node
// that forgot the watermark sees request 2 on an unknown session and
diff --git a/core/metadata/src/impls/metadata.rs
b/core/metadata/src/impls/metadata.rs
index 403f0b7df..e40c27a0d 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -50,7 +50,7 @@ use iggy_common::variadic;
use journal::{Journal, JournalHandle};
use message_bus::MessageBus;
use server_common::Message;
-use server_common::iobuf::Frozen;
+use server_common::iobuf::{Frozen, Owned};
use std::cell::{Cell, RefCell};
use std::mem::size_of;
use std::path::Path;
@@ -689,13 +689,14 @@ where
let request = message.header().request;
let request_checksum = message.header().request_checksum;
let operation = message.header().operation;
+ let user_id = message.header().user_id;
// Preflight first: dedup, eviction sends, cached-reply replay all
// must run regardless of pipeline pressure. Wire-path ingress has no
// home-shard transport context, so resends fall back to the
// consensus-plane (best-effort by VSR id).
let dispatch = if operation == Operation::Register {
- register_preflight(consensus, client_id)
+ register_preflight(consensus, &self.client_table, client_id,
user_id)
} else {
let outcome = request_preflight(
consensus,
@@ -1088,6 +1089,12 @@ where
.as_ref()
.expect("submit_register_in_process: consensus only exists on
shard 0");
+ // Wrong node: waiting or queueing cannot fix that, the client must
+ // re-route to the primary.
+ if !(consensus.is_primary() && consensus.is_normal() &&
!consensus.is_syncing()) {
+ return Err(MetadataSubmitError::NotPrimary);
+ }
+
// OWNERSHIP GATE: the login frame's `client` field is caller-supplied,
// and `resolve_acting_user_id` resolves authority for every replicated
// op from this entry, so rebinding someone else's entry would run the
@@ -1095,7 +1102,17 @@ where
// its `user_id`). Refuse unless the authenticated user owns it.
// Terminal (see `ClientIdOwnedByAnotherUser`). An owned entry falls
// through: the rebind must commit so the epoch actually moves.
- {
+ //
+ // Only a CAUGHT-UP primary may issue it, like both sibling readers of
+ // this table (`request_preflight` and `register_preflight`, which gate
+ // the same way): the refusal is terminal, so a lagging or diverged
+ // replica answering it would deny a legitimate login off state it has
+ // not finished applying, and the client would never learn to redirect.
+ // Not caught up therefore SKIPS the check rather than refusing -- the
+ // register goes on to park in the request queue below, and
+ // `register_preflight` re-applies this gate when the commit path
+ // promotes it, by which point the table is authoritative.
+ if is_caught_up_primary(consensus) {
let table = self.client_table.borrow();
if let Some(owner) = table.get_user_id(client_id)
&& owner != user_id
@@ -1111,12 +1128,6 @@ where
}
}
- // Wrong node: waiting or queueing cannot fix that, the client must
- // re-route to the primary.
- if !(consensus.is_primary() && consensus.is_normal() &&
!consensus.is_syncing()) {
- return Err(MetadataSubmitError::NotPrimary);
- }
-
// Mirror wire-path register_preflight: a racing second prepare would
// commit a second register and bump the epoch past the first reply's.
// Surface pre-synthesis. Scans both the prepare queue and the request
@@ -1140,14 +1151,20 @@ where
"build_register_request_message produced a header that fails
validate()"
);
- // Catch-up gate (Register only: admitting one while a committed op
- // is still unapplied risks a double-register epoch bump) or
- // prepare queue full: absorb into the request queue instead of
- // bouncing with a transient error. The queued
- // entry carries this caller's reply subscriber; the commit path
- // promotes it (`drain_request_queue_into_prepares`, which re-runs
- // `register_preflight`) as soon as the in-flight batch drains, and
- // the await below resolves exactly like the direct dispatch would.
+ // Fence floor, snapshotted BEFORE dispatch. This register's op is
+ // assigned above the journal tail, so it is strictly greater than
+ // `commit_max` is now -- which is what lets the cancel path below tell
+ // OUR fence from an older entry's that happened to survive.
+ let epoch_floor = consensus.commit_max();
+
+ // Not caught up (admitting a register while a committed op is still
+ // unapplied risks a double-register fence bump) or prepare queue full:
+ // absorb into the request queue instead of bouncing with a transient
+ // error. The queued entry carries this caller's reply subscriber; the
+ // commit path promotes it (`drain_request_queue_into_prepares`, which
+ // re-runs `register_preflight` and so applies the ownership gate) as
+ // soon as the in-flight batch drains, and the await below resolves
+ // exactly like the direct dispatch would.
if !is_caught_up_primary(consensus) ||
consensus.pipeline().borrow().is_full() {
let (entry, receiver) =
consensus::RequestEntry::with_subscriber(request);
if consensus
@@ -1159,14 +1176,17 @@ where
// Both queues full: honest terminal backpressure.
return Err(MetadataSubmitError::PipelineFull);
}
- // Commit and cancel share one recovery: read the table. On
- // commit, `commit_register` moved the entry's fence (the reply
header
- // carries neither epoch nor watermark). On a view-change cancel
- // or promotion-time rejection the re-read is correct-by-VSR: an
- // inherited Register applied via `commit_journal` between cancel
- // and read stamps the same op-derived epoch on every replica.
- let _ = receiver.await;
- return self.bound_session(client_id);
+ return match receiver.await {
+ // The reply's `commit` IS the fence `commit_register` just
+ // stored (`build_reply_message` stamps it from the prepare's
+ // op), so take it from there rather than re-reading the table.
+ Ok(reply) => {
+ self.bound_session(client_id, Some(reply.header().commit),
epoch_floor)
+ }
+ // Entry dropped before commit: view-change reset, or a
+ // promotion-time preflight rejection.
+ Err(Canceled) => self.bound_session(client_id, None,
epoch_floor),
+ };
}
// `prepare_request` only fails on `!is_client_allowed`; Register is
// allowed, so unreachable. Panic loudly on regression instead of
@@ -1175,18 +1195,39 @@ where
.prepare_request(request)
.expect("Operation::Register is client-allowed; prepare projection
cannot fail");
- // Same commit/cancel collapse as the queued path above.
- let _ = self.dispatch_prepare_and_await(consensus, prepare).await;
- self.bound_session(client_id)
+ match self.dispatch_prepare_and_await(consensus, prepare).await {
+ Ok(reply) => self.bound_session(client_id,
Some(reply.header().commit), epoch_floor),
+ Err(Canceled) => self.bound_session(client_id, None, epoch_floor),
+ }
}
- /// Read a client's post-commit bind state (fence epoch + watermark) in one
- /// borrow. `Canceled` when the entry is absent: the register was canceled
- /// before commit, or the entry was evicted in between.
- fn bound_session(&self, client_id: u128) -> Result<BoundSession,
MetadataSubmitError> {
+ /// Assemble the bind result in one table borrow.
+ ///
+ /// `committed_epoch` is `Some` when this call's own Register committed, in
+ /// which case the fence comes from the reply that carries it. `None` is
the
+ /// view-change cancel path, where the fence has to be read back -- and is
+ /// only ours if it sits above `epoch_floor`. An entry at or below the
floor
+ /// predates this register, so returning its epoch would hand the caller a
+ /// fence that never moved, and nothing downstream would notice: a stale
+ /// epoch satisfies `check_request`'s equality test, so there is no
`Fenced`
+ /// and no `EpochAhead` to surface it. `Canceled` instead, and the retry
+ /// gets a real bind.
+ ///
+ /// `Canceled` also covers an absent entry (evicted between commit and
+ /// read).
+ fn bound_session(
+ &self,
+ client_id: u128,
+ committed_epoch: Option<u64>,
+ epoch_floor: u64,
+ ) -> Result<BoundSession, MetadataSubmitError> {
let table = self.client_table.borrow();
- table
- .get_epoch(client_id)
+ let epoch = committed_epoch.or_else(|| {
+ table
+ .get_epoch(client_id)
+ .filter(|&epoch| epoch > epoch_floor)
+ });
+ epoch
.zip(table.get_watermark(client_id))
.map(|(epoch, watermark)| BoundSession { epoch, watermark })
.ok_or(MetadataSubmitError::Canceled)
@@ -1217,13 +1258,11 @@ where
) {
return Some(Ok(refusal));
}
+ let owned =
+ Owned::<{ server_common::MESSAGE_ALIGN
}>::copy_from_slice(reply.as_slice());
Some(
- server_common::Message::<GenericHeader>::try_from(
- server_common::iobuf::Owned::<{
server_common::MESSAGE_ALIGN }>::copy_from_slice(
- reply.as_slice(),
- ),
- )
- .map_err(|_| MetadataSubmitError::Canceled),
+ Message::<GenericHeader>::try_from(owned)
+ .map_err(|_| MetadataSubmitError::Canceled),
)
}
PreflightOutcome::Evict(reason) => {
@@ -2095,12 +2134,13 @@ where
let request = req.message.header().request;
let request_checksum = req.message.header().request_checksum;
let operation = req.message.header().operation;
+ let user_id = req.message.header().user_id;
// If preflight or projection rejects below, dropping `req` (and
// the sender taken from it) wakes an in-process awaiter with
// `Canceled`; its submit path re-checks the client table.
let reply_sender = req.take_reply_sender();
let dispatch = if operation == Operation::Register {
- register_preflight(consensus, client_id)
+ register_preflight(consensus, &self.client_table, client_id,
user_id)
} else {
let outcome = request_preflight(
consensus,
diff --git a/core/metadata/src/impls/recovery.rs
b/core/metadata/src/impls/recovery.rs
index 37fd7355d..00ea8b800 100644
--- a/core/metadata/src/impls/recovery.rs
+++ b/core/metadata/src/impls/recovery.rs
@@ -213,8 +213,29 @@ where
// Register/Logout mutate the client table and skip the state
// machine, mirroring the commit paths (`on_ack` / `commit_journal`).
- // Replaying them from a fresh table in apply order re-mints the same
- // epochs every replica derived live.
+ //
+ // Epochs come back identical on every replica: they are the register's
+ // own commit op, so replay reads them out of the log rather than
+ // deriving them from replay order.
+ //
+ // Watermarks do NOT, and this is a known gap rather than an invariant.
+ // Replay starts at THIS node's snapshot floor, and checkpointing is
+ // node-local (`checkpoint_if_needed` fires on local journal
occupancy),
+ // so replicas cross the floor at different ops. If a client's earlier
+ // register fell below this node's floor while a later one survived,
+ // replay takes `commit_register`'s fresh-entry branch and the entry
+ // returns with `watermark = 0`, where a peer that replayed both took
+ // the rebind branch and kept it. The fence still passes, so nothing is
+ // evicted, and the same request id a peer answers `Duplicate` gets
+ // answered `New` here and re-executed -- exactly-once degrading to
+ // at-least-once, silently.
+ //
+ // Unobservable today (no shipping client re-presents a recovered
+ // `client_id`), and closed by putting the table in the snapshot so
+ // replay no longer has to reconstruct it. Until then a caught-up
+ // primary is authoritative for its OWN table only, which is why
+ // `request_preflight`'s catch-up gate cannot bridge this (see its
+ // rustdoc).
if header.operation == Operation::Register {
let reply = build_reply_message(header, &bytes::Bytes::new());
client_table.commit_register(header.client, header.user_id, reply);
@@ -493,6 +514,75 @@ mod tests {
);
}
+ // The watermark half of table recovery does NOT survive a checkpoint, and
+ // this pins that as a fact rather than a comment. Shape: a client
registers,
+ // commits request 1, a checkpoint lands past that register, then the
client
+ // rebinds. Replay starts above the floor, so it never sees the first
+ // register: `commit_register` takes its fresh-entry branch and the entry
+ // comes back with watermark 0, while a peer whose floor sat lower replayed
+ // both registers, took the rebind branch, and kept watermark 1.
+ //
+ // Consequence, once a client re-presents a recovered id: the same request
+ // that peer answers `Duplicate` is `New` here and gets re-executed. The
+ // fence is unaffected -- epochs are op-derived, so this node still returns
+ // the second register's op.
+ //
+ // Red/green for the table-in-snapshot work: when the table ships in the
+ // checkpoint, the watermark assertion below flips to `Some(1)`.
+ #[compio::test]
+ async fn
recover_loses_the_watermark_when_a_checkpoint_hides_the_first_register() {
+ const CLIENT: u128 = 0x1337;
+ const USER: u32 = 7;
+ const FLOOR: u64 = 2;
+
+ let dir = tempdir().unwrap();
+ let metadata_dir = dir.path().join("metadata");
+ std::fs::create_dir_all(&metadata_dir).unwrap();
+
+ // Ops 1..=2 are below the floor and are never replayed: the client's
+ // first Register and the request that advanced its watermark.
+ IggySnapshot::new(FLOOR)
+ .persist(&metadata_dir.join("snapshot.bin"))
+ .unwrap();
+
+ {
+ let journal =
PrepareJournal::open(&metadata_dir.join("journal.wal"), 0)
+ .await
+ .unwrap();
+ for entry in [
+ make_client_prepare(1, Operation::Register, CLIENT, USER, 0),
+ make_client_prepare(2, Operation::CreateStream, CLIENT, USER,
1),
+ // The rebind, above the floor, so replay does see this one.
+ make_client_prepare(3, Operation::Register, CLIENT, USER, 0),
+ ] {
+ journal.append(entry).await.unwrap();
+ }
+ journal.storage_ref().fsync().await.unwrap();
+ }
+
+ let recovered = recover::<TestStm>(
+ dir.path(),
+ true,
+ journal::prepare_journal::DEFAULT_SLOT_COUNT,
+ |_| {},
+ )
+ .await
+ .unwrap();
+
+ let table = &recovered.client_table;
+ assert_eq!(
+ table.get_epoch(CLIENT),
+ Some(3),
+ "the fence is op-derived, so it survives the checkpoint intact"
+ );
+ assert_eq!(
+ table.get_watermark(CLIENT),
+ Some(0),
+ "KNOWN GAP: the pre-floor watermark is lost, so request 1 reads as
New \
+ here while a lower-floor peer answers it as a duplicate"
+ );
+ }
+
// The IGGY-137 restart contract: a rebooted node must remember where
// each client left off. Replay re-mints the epoch, restores the
// watermark, and re-caches the reply so a retry of the last committed
diff --git a/core/sdk/src/vsr.rs b/core/sdk/src/vsr.rs
index 671fbd5e9..90a10a552 100644
--- a/core/sdk/src/vsr.rs
+++ b/core/sdk/src/vsr.rs
@@ -47,10 +47,20 @@ const NON_REPLICATED_CODE_RANGE: std::ops::Range<usize> =
0..4;
// the retry from the current ConsensusSession. If disconnect created a fresh
VSR
// client/session, the retried request gets a new (client_id, request_id)
tuple, so
// server-side deduplication cannot match a mutation that may already have
committed
-// before the transport failure. The server now rebinds a transport that
presents
-// its old (client, session) identity (`try_resume_session`), so the fix here
is
-// to keep the ConsensusSession across reconnects and retry replicated writes
-// under the same (client_id, request_id) instead of re-registering fresh.
+// before the transport failure.
+//
+// The fix is to keep the ConsensusSession's client_id and request counter
across
+// reconnects and retry replicated writes under the same (client_id,
request_id)
+// instead of re-registering fresh. Resume happens through the LOGIN path: the
+// reconnecting client re-authenticates presenting its previous client_id, the
+// server verifies the authenticated user owns that entry, and the rebind
commits
+// a Register that adopts the entry with its watermark and reply ring intact.
Note
+// the epoch changes -- the rebind moves the fence to the new register's op --
so
+// the session field must be taken from the new login reply, not carried over.
+//
+// There is deliberately no credential-free rebind: presenting (client,
session)
+// on an unauthenticated transport is refused, since that pair is a dedup key
and
+// never a bearer token.
pub(crate) fn encode_contiguous_request(
session: &mut ConsensusSession,
code: u32,
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index 9886321f8..e018c4e8b 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -1218,8 +1218,19 @@ async fn shard_main(
.expect("shard 0 always has a coordinator attached by the
builder");
// Reseed the client-id minter above every recovered entry before any
// listener accepts. The counter is per process; the table it must not
- // collide with was rebuilt from the previous boot's WAL.
-
coord.seed_client_sequence(shard.plane.metadata().client_table.borrow().client_ids());
+ // collide with was rebuilt from the previous boot's WAL. Keyed by view
+ // so a later promotion refolds the table (the minting path calls the
+ // same method, see `HttpInner::register_session_once`).
+ let boot_view = shard
+ .plane
+ .metadata()
+ .consensus
+ .as_ref()
+ .map_or(0, consensus::VsrConsensus::view);
+ coord.seed_client_sequence(
+ boot_view,
+ shard.plane.metadata().client_table.borrow().client_ids(),
+ );
let on_client_request =
make_client_request_handler(&shard, &sessions,
Arc::clone(&config.system));
let (accepted_replica, dialed_replica) =
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 8ab85204d..8c4b4e241 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -112,17 +112,6 @@ use tracing::{debug, warn};
pub(crate) type ClientRequestQueues = Rc<RefCell<HashMap<u128,
VecDeque<Message<GenericHeader>>>>>;
pub(crate) type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
-/// How long a disconnected session's client-table slot is held open for the
-/// client to reconnect and resume onto it before the sweeper reclaims it.
-///
-/// Resume runs through the login path, so the window only has to cover a
-/// reconnect plus a re-login -- seconds. It is generous by an order of
-/// magnitude so a client riding out a view change still lands inside it, and
-/// still bounded so cumulative connects cannot push the client table to its
-/// capacity-eviction point (every eviction silently erases a watermark).
-/// Swept by `run_heartbeat_verifier`, whose tick is far shorter than this.
-const SESSION_RECLAIM_GRACE: std::time::Duration =
std::time::Duration::from_mins(1);
-
pub(crate) fn make_client_request_handler<B, MJ, S>(
shard: &Rc<ShellShard<B, MJ, S>>,
sessions: &Rc<RefCell<SessionManager>>,
@@ -147,12 +136,7 @@ where
.borrow_mut()
.remove_connection(client_id)
{
- submit_disconnect_logout(
- Rc::clone(&shard_for_disconnect),
- &sessions_for_disconnect,
- vsr_client_id,
- session,
- );
+ submit_disconnect_logout(Rc::clone(&shard_for_disconnect),
vsr_client_id, session);
}
}));
Rc::new(move |client_id, message| {
@@ -480,7 +464,7 @@ where
.remove_connection(client_id)
&& let Some(shard) =
upgrade_shard_handle(&shard_handle_for_disconnect)
{
- submit_disconnect_logout(shard, &sessions_for_disconnect,
vsr_client_id, session);
+ submit_disconnect_logout(shard, vsr_client_id, session);
}
}));
Rc::new(move |client_id, message| {
@@ -540,7 +524,6 @@ where
.metadata()
.submit_register_in_process(vsr_client_id, user_id)
.await
- .ok()
.map(|bound| (bound.epoch, bound.watermark));
let _ = reply.try_send(bound);
}
@@ -1584,34 +1567,6 @@ pub(crate) async fn run_heartbeat_verifier<B, MJ, S>(
}
}
- // Reclaim sessions whose resume window elapsed. A disconnect defers
- // rather than logs out (see `submit_disconnect_logout`) so a
- // reconnecting client can resume onto its entry through the login
- // path; without this sweep those slots would accumulate per
- // cumulative connect and push the client table into capacity
- // eviction, which silently erases dedup watermarks.
- let reclaims = sessions
- .borrow_mut()
- .take_expired_reclaims(std::time::Instant::now());
- for (vsr_client_id, session) in reclaims {
- debug!(
- vsr_client_id,
- "session resume window elapsed; reclaiming client-table slot"
- );
- // Re-arm before submitting. The submit is spawned and can fail
- // transiently (view change, full pipeline), and
`take_expired_reclaims`
- // has already dropped the entry, so without this a failed submit
- // leaks the slot until capacity eviction. The spawned task clears
- // the entry once the `Logout` commits.
- sessions.borrow_mut().defer_reclaim(
- vsr_client_id,
- session,
- SESSION_RECLAIM_GRACE,
- std::time::Instant::now(),
- );
- submit_session_logout(Rc::clone(&shard), &sessions, vsr_client_id,
session);
- }
-
shard.bus.sleep(interval).await;
}
}
@@ -1632,7 +1587,7 @@ async fn evict_stale_client<B, MJ, S>(
{
let bound = sessions.borrow_mut().remove_connection(transport_client_id);
if let Some((vsr_client_id, session)) = bound {
- submit_disconnect_logout(Rc::clone(shard), sessions, vsr_client_id,
session);
+ submit_disconnect_logout(Rc::clone(shard), vsr_client_id, session);
}
let ctx = shard.plane.metadata().consensus.as_ref().map_or(
consensus::EvictionContext {
@@ -2170,15 +2125,18 @@ where
.submit_register_in_process(vsr_client_id, user_id)
.await;
}
- let (reply, rx) = shard::channel::<Option<(u64, u64)>>(1);
+ let (reply, rx) = shard::channel::<Result<(u64, u64),
MetadataSubmitError>>(1);
shard.forward_metadata_submit(shard::MetadataSubmit::Register {
vsr_client_id,
user_id,
reply,
});
match rx.recv().await {
- Ok(Some((epoch, watermark))) => Ok(BoundSession { epoch, watermark }),
- _ => Err(MetadataSubmitError::Canceled),
+ Ok(Ok((epoch, watermark))) => Ok(BoundSession { epoch, watermark }),
+ // The owner's error, preserved: `Canceled` is only for a dropped
+ // channel, where nothing came back to classify.
+ Ok(Err(error)) => Err(error),
+ Err(_) => Err(MetadataSubmitError::Canceled),
}
}
@@ -2452,62 +2410,26 @@ where
))
}
-/// Disconnect policy: reclaim now, or hold the slot open for a resume window.
+/// Release the client-table slot for a disconnected transport, cluster-wide.
///
-/// The local `SessionManager` connection is already dropped by the caller.
-/// A consumer-group member is logged out immediately -- the group must
-/// rebalance off a dead consumer without waiting. Anything else has its
-/// reclaim DEFERRED by [`SESSION_RECLAIM_GRACE`], because session resume runs
-/// through the login path: a reconnecting client re-authenticates under its
-/// previous `client_id` and the committed rebind adopts the existing entry
-/// while keeping its watermark and reply ring intact, which only works while
-/// that entry still exists.
+/// 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.
///
-/// The deferral is swept by `run_heartbeat_verifier`, which calls
-/// [`submit_session_logout`] directly once the window elapses. Holding slots
-/// indefinitely instead would move the client table's eviction point from
-/// concurrent connections to CUMULATIVE connects, and every capacity eviction
-/// silently erases a dedup watermark.
-#[allow(clippy::future_not_send)]
-fn submit_disconnect_logout<B, MJ, S>(
- shard: Rc<ShellShard<B, MJ, S>>,
- sessions: &Rc<RefCell<SessionManager>>,
- vsr_client_id: u128,
- session: u64,
-) where
- B: ShellBus,
- MJ: JournalHandle + 'static,
- MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header =
PrepareHeader>,
- S: 'static,
-{
- let is_group_member = !shard
- .plane
- .metadata()
- .mux_stm
- .streams()
- .consumer_group_memberships(vsr_client_id)
- .is_empty();
- if is_group_member {
- submit_session_logout(shard, sessions, vsr_client_id, session);
- return;
- }
- debug!(
- vsr_client_id,
- grace = ?SESSION_RECLAIM_GRACE,
- "transport disconnected; holding session for resume, then reclaiming"
- );
- sessions.borrow_mut().defer_reclaim(
- vsr_client_id,
- session,
- SESSION_RECLAIM_GRACE,
- std::time::Instant::now(),
- );
-}
-
-/// Submit a session-matched `Logout` unconditionally, releasing the
-/// `ClientTable` slot on every replica (shard 0 included, since shard 0 is
-/// itself a replica).
+/// 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 is not an option, since that only runs when `heartbeat.enabled`
+/// is set and `collect_stale` keys off the heartbeat interval, so ungating it
+/// would mass-evict consumer-group members on a deployment that does not ping.
/// 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
@@ -2516,9 +2438,8 @@ fn submit_disconnect_logout<B, MJ, S>(
/// 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_session_logout<B, MJ, S>(
+fn submit_disconnect_logout<B, MJ, S>(
shard: Rc<ShellShard<B, MJ, S>>,
- sessions: &Rc<RefCell<SessionManager>>,
vsr_client_id: u128,
session: u64,
) where
@@ -2534,23 +2455,16 @@ fn submit_session_logout<B, MJ, S>(
const DISCONNECT_LOGOUT_REQUEST_ID: u64 = u64::MAX;
let bus = shard.bus.clone();
- let sessions = Rc::clone(sessions);
bus.spawn(async move {
- match submit_logout_on_owner(&shard, vsr_client_id, session,
DISCONNECT_LOGOUT_REQUEST_ID)
- .await
+ if let Err(error) =
+ submit_logout_on_owner(&shard, vsr_client_id, session,
DISCONNECT_LOGOUT_REQUEST_ID)
+ .await
{
- // Slot released cluster-wide; drop the bookkeeping that would
- // otherwise re-submit on the next sweep.
- Ok(_) => sessions.borrow_mut().complete_reclaim(vsr_client_id),
- // Leave the re-armed reclaim in place so the sweeper retries. A
- // repeat is harmless: `submit_logout_*` short-circuits once the
- // slot is gone, and its session guard drops a stale logout for a
- // reused client id.
- Err(error) => warn!(
+ warn!(
vsr_client_id,
?error,
- "session logout submit failed; retrying after the reclaim
grace"
- ),
+ "disconnect logout submit failed; peer slots may linger until
eviction"
+ );
}
});
}
diff --git a/core/server-ng/src/http/error.rs b/core/server-ng/src/http/error.rs
index 511a1dd22..0d294908a 100644
--- a/core/server-ng/src/http/error.rs
+++ b/core/server-ng/src/http/error.rs
@@ -160,6 +160,21 @@ pub(in crate::http) enum AuthError {
/// way.
SessionNotAccepted,
SessionUnavailable,
+ /// The `client_id` this gateway minted already has a committed session
+ /// owned by a DIFFERENT user, so the Register was refused terminally.
+ ///
+ /// Distinct from [`Self::SessionUnavailable`] because the status code is
+ /// the whole point: 503 is about the most auto-retried status there is and
+ /// no foreign SDK special-cases it, so rendering a permanent,
deterministic
+ /// refusal as 503 hands the caller's HTTP stack a retry loop it can never
+ /// escape. 409 says the id is taken and stops it.
+ SessionIdOwnedByAnotherUser,
+ /// The minted `client_id` already had a committed session for this SAME
+ /// user, so the Register rebound onto it instead of creating one. Internal
+ /// to the mint retry in `register_session` and never rendered: the caller
+ /// mints a different id. Present as a variant so the retry cannot confuse
+ /// it with a terminal cross-user refusal.
+ SessionIdTaken,
}
impl From<IggyError> for AuthError {
@@ -186,7 +201,18 @@ impl IntoResponse for AuthError {
// Transient server condition -> 503, retryable by the CLIENT only
// (a forwarder must not re-issue an unknown-outcome Register under
// this node's session budget on the caller's behalf).
- Self::SessionUnavailable => service_unavailable(),
+ // `SessionIdTaken` only escapes the mint retry when every attempt
+ // collided, which means the minter is wrong rather than unlucky --
+ // same unknown-outcome answer as a canceled Register.
+ Self::SessionUnavailable | Self::SessionIdTaken =>
service_unavailable(),
+ // Terminal: retrying cannot change the answer, and admitting it
+ // would run this caller's replicated ops under the entry owner's
+ // authority.
+ Self::SessionIdOwnedByAnotherUser => (
+ StatusCode::CONFLICT,
+ Json(ErrorResponse::from_error(&IggyError::InvalidClientId)),
+ )
+ .into_response(),
}
}
}
@@ -634,4 +660,29 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(!response.headers().contains_key(RETRY_AFTER));
}
+
+ // The ownership refusal is permanent and deterministic. Rendering it as
+ // 503 would hand the caller's HTTP stack a retry loop it can never escape
+ // (no foreign SDK special-cases 503), so the status is load-bearing.
+ #[test]
+ fn owned_client_id_renders_as_terminal_conflict() {
+ let response = AuthError::SessionIdOwnedByAnotherUser.into_response();
+ assert_eq!(response.status(), StatusCode::CONFLICT);
+ assert!(
+ response.headers().get(RETRY_AFTER).is_none(),
+ "a terminal refusal must not advertise a retry"
+ );
+ }
+
+ // Its siblings stay retryable, so the split is visible in one place.
+ #[test]
+ fn unknown_outcome_registers_stay_retryable() {
+ for error in [AuthError::SessionUnavailable,
AuthError::SessionNotAccepted] {
+ let status = error.into_response().status();
+ assert!(
+ status.is_server_error(),
+ "an unknown commit outcome must stay retryable, got {status}"
+ );
+ }
+ }
}
diff --git a/core/server-ng/src/http/session.rs
b/core/server-ng/src/http/session.rs
index acc6527e6..8b1540f66 100644
--- a/core/server-ng/src/http/session.rs
+++ b/core/server-ng/src/http/session.rs
@@ -40,13 +40,19 @@ use tokio::sync::Mutex;
/// which evicts the oldest-committed client when full. Capping HTTP at half
/// that bound keeps this plane from crowding the others out and keeps the
/// combined steady state under the shared bound, so a live idle HTTP session
-/// is not routinely evicted consensus-side. The other half stays honest
-/// because a dropped connection releases its slot once the reclaim grace
-/// expires, rather than holding it for the process lifetime. The residual
eviction race (both planes
-/// busy) degrades gracefully: an evicted session's next control write is
-/// classified as an eviction and re-registers (see
[`HttpInner::forget_session`]).
+/// is not routinely evicted consensus-side. The non-HTTP half tracks live
+/// connections because every transport disconnect logs its session out
+/// (`submit_disconnect_logout`), so occupancy there is concurrent, not
+/// cumulative. The residual eviction race (both planes busy) degrades
+/// gracefully: an evicted session's next control write is classified as an
+/// eviction and re-registers (see [`HttpInner::forget_session`]).
pub(in crate::http) const MAX_HTTP_SESSIONS: usize = CLIENTS_TABLE_MAX / 2;
+/// Watermark a brand-new client-table entry carries: no application request
+/// has committed under it yet. A fresh mint that comes back with anything else
+/// bound to an entry that already existed (see `HttpInner::register_session`).
+pub(in crate::http) const FRESH_ENTRY_WATERMARK: u64 = 0;
+
/// First per-session request id the write path hands out. VSR request numbers
/// are 1-based and strictly increasing within a session.
pub(in crate::http) const FIRST_REQUEST_ID: u64 = 1;
diff --git a/core/server-ng/src/http/state.rs b/core/server-ng/src/http/state.rs
index 81519ff1e..933d6c6d7 100644
--- a/core/server-ng/src/http/state.rs
+++ b/core/server-ng/src/http/state.rs
@@ -43,8 +43,8 @@ use crate::http::error::{AuthError, ReadError,
primary_redirect_location};
use crate::http::forward::ForwardState;
use crate::http::jwt::JwtManager;
use crate::http::session::{
- BarrierEntry, FIRST_REQUEST_ID, HttpSession, MAX_HTTP_SESSIONS,
RegistrationBarrier,
- forget_if_same, live_entry, sweep_expired,
+ BarrierEntry, FIRST_REQUEST_ID, FRESH_ENTRY_WATERMARK, HttpSession,
MAX_HTTP_SESSIONS,
+ RegistrationBarrier, forget_if_same, live_entry, sweep_expired,
};
/// Response header carrying the current VSR view number. Stamped by
@@ -194,18 +194,82 @@ impl HttpInner {
live_entry(&self.sessions.borrow(), key, now_secs)
}
- /// Mint a shard-0 client id and run the VSR `Register` for a fresh
session.
- /// Holds no table borrow; the caller inserts the result under `key`.
+ /// Mint a shard-0 client id and run the VSR `Register` for a fresh
session,
+ /// retrying on a fresh id if the minted one turns out to be taken.
+ ///
+ /// The minter is a per-process counter reseeded from the client table at
+ /// boot, so a fresh mint normally lands on a free id. Two situations break
+ /// that, and neither is predictable from here: a promoted primary mints
+ /// from a counter with no relationship to the ids its predecessor
+ /// committed, and in a cluster every node counts independently. Landing on
+ /// an occupied entry is therefore reactive to detect and cheap to fix --
+ /// mint again. Bounded, because a run of collisions means the counter is
+ /// wrong rather than unlucky, and looping would hide that.
+ ///
+ /// The two collision signals are asymmetric. A different owner is refused
+ /// terminally by the register ownership gate. The SAME user is not refused
+ /// at all -- it rebinds, silently inheriting a watermark written by
another
+ /// of that user's sessions, which would make this session's first writes
+ /// read as duplicates and answer them from the other session's cache. A
+ /// non-zero watermark on what should be a brand-new session is exactly
that
+ /// tell.
async fn register_session(
&self,
key: String,
user_id: u32,
expiry: u64,
+ ) -> Result<Rc<HttpSession>, AuthError> {
+ /// Enough to ride out a promotion-era counter overlap; beyond this the
+ /// minter is misconfigured and the 503 is the honest answer.
+ const MINT_ATTEMPTS: u8 = 3;
+
+ for attempt in 1..=MINT_ATTEMPTS {
+ match self
+ .register_session_once(key.clone(), user_id, expiry)
+ .await
+ {
+ Ok(session) => return Ok(session),
+ Err(AuthError::SessionIdOwnedByAnotherUser |
AuthError::SessionIdTaken)
+ if attempt < MINT_ATTEMPTS =>
+ {
+ warn!(
+ attempt,
+ "server-ng HTTP: minted client id was already
registered; re-minting"
+ );
+ }
+ Err(error) => return Err(error),
+ }
+ }
+ Err(AuthError::SessionUnavailable)
+ }
+
+ /// One mint-and-Register attempt. `SessionIdTaken` means the id was live
+ /// under this same user, so the caller should mint a different one.
+ async fn register_session_once(
+ &self,
+ key: String,
+ user_id: u32,
+ expiry: u64,
) -> Result<Rc<HttpSession>, AuthError> {
let coordinator = self
.shard
.coordinator()
.ok_or(AuthError::SessionUnavailable)?;
+ // Refold the client table into the minter if this is the first mint of
+ // the current view. Cheap and skipped within a view, and it is what
+ // stops a PROMOTED primary from minting against ids its predecessor
+ // committed from an unrelated counter -- the table is replicated, the
+ // counter is per process. Boot does the same call (`bootstrap`); this
+ // one covers every later view.
+ {
+ let metadata = self.shard.plane.metadata();
+ if let Some(consensus) = metadata.consensus.as_ref() {
+ coordinator.seed_client_sequence(
+ consensus.view(),
+ metadata.client_table.borrow().client_ids(),
+ );
+ }
+ }
// Reuse the TCP accept path's minter: it draws from the same shard-0
// `client_seq`, so an HTTP session id can never collide with a TCP
// virtual client's and the shard-0 tag (top 16 bits == 0) is
preserved.
@@ -250,26 +314,43 @@ impl HttpInner {
MetadataSubmitError::NotPrimary
| MetadataSubmitError::NotCaughtUp
| MetadataSubmitError::PipelineFull =>
AuthError::SessionNotAccepted,
- _ => AuthError::SessionUnavailable,
+ // Terminal, and the only variant here that is: retrying
+ // anywhere cannot make the id free. Kept off the 503 path
+ // so the caller's HTTP stack does not auto-retry forever.
+ MetadataSubmitError::ClientIdOwnedByAnotherUser => {
+ AuthError::SessionIdOwnedByAnotherUser
+ }
+ // `InProgress` / `Canceled` mean a prepare may still
+ // commit cluster-wide, so the outcome is unknown rather
+ // than terminal. A future variant lands here too: 503 is
+ // the safe default, since it never asserts a refusal the
+ // server did not make.
+ MetadataSubmitError::InProgress |
MetadataSubmitError::Canceled | _ => {
+ AuthError::SessionUnavailable
+ }
}
})?;
- // Number from above the entry's watermark, not from 1. The gateway is
- // the one client whose request counter lives in the process that
- // restarts: after a reboot the id minter can re-mint this client id
- // for the same user, binding to the recovered entry -- numbering from
- // FIRST_REQUEST_ID would then read as duplicates and answer this
- // session's writes with the previous boot's cached replies. A fresh
- // entry has watermark 0, so this is FIRST_REQUEST_ID there.
- let next_request = bound.watermark + 1;
- debug_assert!(next_request >= FIRST_REQUEST_ID);
+ // A fresh mint must land on a fresh entry, so a watermark it did not
+ // write means the id was already registered to this same user (see
+ // `register_session`). Rebinding onto it would inherit that session's
+ // dedup history; hand the id back instead and let the caller re-mint.
+ if bound.watermark != FRESH_ENTRY_WATERMARK {
+ warn!(
+ client_id,
+ user_id,
+ watermark = bound.watermark,
+ "server-ng HTTP: minted client id already had a committed
session for this user"
+ );
+ return Err(AuthError::SessionIdTaken);
+ }
Ok(Rc::new(HttpSession {
key,
client_id,
session: bound.epoch,
user_id,
expiry,
- gate: Mutex::new(next_request),
- data_request: Cell::new(next_request),
+ gate: Mutex::new(FIRST_REQUEST_ID),
+ data_request: Cell::new(FIRST_REQUEST_ID),
registry_token: Cell::new(None),
in_flight_writes: Cell::new(0),
}))
diff --git a/core/server-ng/src/session_manager.rs
b/core/server-ng/src/session_manager.rs
index 4d6c6b43a..9033aa98b 100644
--- a/core/server-ng/src/session_manager.rs
+++ b/core/server-ng/src/session_manager.rs
@@ -99,9 +99,6 @@ pub struct SessionManager {
/// Reverse index: `client_id` → `connection_id` for fast lookup when
/// a consensus reply arrives and needs routing to the right connection.
client_to_connection: HashMap<u128, u128>,
- /// Disconnected sessions awaiting reclaim, `client_id` -> deadline.
- /// See [`Self::defer_reclaim`].
- pending_reclaims: HashMap<u128, PendingReclaim>,
/// This shard's copy of the configured cluster roster, served by the
/// pre-auth `GetClusterMetadata` read. Lives here because it is the
/// per-shard context already threaded to the non-replicated read path;
@@ -109,24 +106,12 @@ pub struct SessionManager {
cluster_roster: Rc<ClusterRoster>,
}
-/// A disconnected session's slot, held open just long enough for the client
-/// to come back and resume onto it.
-#[derive(Debug, Clone, Copy)]
-struct PendingReclaim {
- /// Fence epoch the disconnected connection was bound to; the reclaiming
- /// `Logout` is session-matched against it so it cannot wipe a newer
- /// registration under the same id.
- session: u64,
- deadline: Instant,
-}
-
impl SessionManager {
#[must_use]
pub fn new() -> Self {
Self {
connections: HashMap::new(),
client_to_connection: HashMap::new(),
- pending_reclaims: HashMap::new(),
cluster_roster: Rc::new(ClusterRoster::disabled()),
}
}
@@ -181,66 +166,6 @@ impl SessionManager {
.collect()
}
- /// Hold a disconnected session's client-table slot open for `grace`, then
- /// let the sweeper reclaim it.
- ///
- /// Session resume runs through the login path: a reconnecting client
- /// re-authenticates under its previous `client_id` and the register fast
- /// path binds it back to the existing entry, watermark and reply ring
- /// intact. That only works while the entry still exists, so a disconnect
- /// cannot reclaim it immediately -- but leaving it forever moves the
- /// client table's eviction point from concurrent connections to CUMULATIVE
- /// connects, and every capacity eviction silently erases a watermark
- /// (turning that client's next retry back into a re-execution). The grace
- /// window is the compromise: resume within it, reclaim after.
- ///
- /// Consumer-group members skip this entirely and are logged out at once --
- /// the group must rebalance off a dead consumer without waiting.
- pub fn defer_reclaim(&mut self, client_id: u128, session: u64, grace:
Duration, now: Instant) {
- self.pending_reclaims.insert(
- client_id,
- PendingReclaim {
- session,
- deadline: now + grace,
- },
- );
- }
-
- /// Drop a pending reclaim because the client came back. Called from
- /// [`Self::bind_session`], so a resumed session is never reclaimed out
- /// from under its new connection.
- fn cancel_reclaim(&mut self, client_id: u128) {
- self.pending_reclaims.remove(&client_id);
- }
-
- /// Drop a pending reclaim because its `Logout` committed and the slot is
- /// released. Separate from [`Self::cancel_reclaim`] only in intent: the
- /// reclaim submit is fire-and-forget and can fail transiently, so the
- /// sweeper re-arms the deadline before submitting and the entry is cleared
- /// here on success. Without that pairing a failed submit would drop the
- /// bookkeeping and leak the slot until capacity eviction, which silently
- /// erases the client's dedup watermark.
- pub fn complete_reclaim(&mut self, client_id: u128) {
- self.pending_reclaims.remove(&client_id);
- }
-
- /// Take every reclaim whose grace has elapsed. The caller submits a
- /// session-matched `Logout` for each, which releases the client-table slot
- /// on every replica.
- #[must_use]
- pub fn take_expired_reclaims(&mut self, now: Instant) -> Vec<(u128, u64)> {
- let mut reclaimed = Vec::new();
- self.pending_reclaims.retain(|&client_id, pending| {
- if now >= pending.deadline {
- reclaimed.push((client_id, pending.session));
- false
- } else {
- true
- }
- });
- reclaimed
- }
-
/// The consensus client id a connection is bound to, if any. The heartbeat
/// verifier reads it to look up consumer-group membership before deciding
/// whether an eviction would actually release anything.
@@ -342,10 +267,6 @@ impl SessionManager {
old_conn.state = ConnectionState::Connected;
}
- // The client came back and re-authenticated onto this session, so a
- // reclaim deferred by its previous disconnect must not fire.
- self.cancel_reclaim(client_id);
-
// Now mutate the target connection.
self.connections.get_mut(&connection_id).unwrap().state =
ConnectionState::Bound {
user_id,
@@ -627,83 +548,30 @@ mod tests {
assert!(mgr.get_session(c1).is_none());
assert_eq!(mgr.get_session(c2), Some((200, 20)));
}
- // A disconnected non-group session holds its client-table slot only for
- // the grace window: resume within it, reclaim after. Without the reclaim
- // the slot leaks per cumulative connect and pushes the client table into
- // capacity eviction, which silently erases dedup watermarks.
+ // Every disconnect releases its consensus session, group member or not.
+ // Holding the slot open for a resume window instead leaked it: the sweep
+ // that would have collected it rides the heartbeat verifier, which only
+ // runs when `heartbeat.enabled` is set, and that ships false -- so the
+ // slot survived for the process lifetime and pushed the client table
+ // toward capacity eviction, which silently erases dedup watermarks.
#[test]
- fn deferred_reclaim_expires_after_its_grace_window() {
+ fn disconnect_releases_the_bound_session_for_logout() {
let mut mgr = SessionManager::new();
- let now = Instant::now();
- let grace = Duration::from_mins(1);
-
- mgr.defer_reclaim(100, 7, grace, now);
- assert!(
- mgr.take_expired_reclaims(now + Duration::from_secs(59))
- .is_empty(),
- "inside the window the slot is held for resume"
- );
- assert_eq!(
- mgr.take_expired_reclaims(now + Duration::from_secs(61)),
- vec![(100, 7)],
- "past the window the slot is reclaimed with its bound epoch"
- );
- assert!(
- mgr.take_expired_reclaims(now + Duration::from_mins(10))
- .is_empty(),
- "a reclaim is taken exactly once"
- );
- }
-
- // A client that comes back and re-authenticates onto its session must not
- // have it reclaimed out from under the new connection.
- #[test]
- fn rebinding_cancels_a_pending_reclaim() {
- let mut mgr = SessionManager::new();
- let now = Instant::now();
- mgr.defer_reclaim(100, 7, Duration::from_mins(1), now);
-
let conn = 1;
mgr.ensure_connection(conn, addr(5100), ClientTransportKind::Tcp);
mgr.login(conn, 3).unwrap();
mgr.bind_session(conn, 100, 7).unwrap();
- assert!(
- mgr.take_expired_reclaims(now + Duration::from_mins(10))
- .is_empty(),
- "the resumed session must survive its old disconnect's reclaim"
- );
- assert_eq!(mgr.get_session(conn), Some((100, 7)));
- }
-
- // The reclaim submit is spawned and can fail transiently, so the sweeper
- // re-arms before submitting and clears only on a committed `Logout`. This
- // pins both halves: a failed submit comes back, a committed one does not.
- #[test]
- fn re_armed_reclaim_retries_until_completed() {
- let mut mgr = SessionManager::new();
- let now = Instant::now();
- let grace = Duration::from_mins(1);
-
- mgr.defer_reclaim(100, 7, grace, now);
- let swept = mgr.take_expired_reclaims(now + grace);
- assert_eq!(swept, vec![(100, 7)]);
-
- // Submit failed: the sweeper's re-arm is what makes it retryable.
- mgr.defer_reclaim(100, 7, grace, now + grace);
assert_eq!(
- mgr.take_expired_reclaims(now + grace + grace),
- vec![(100, 7)],
- "a failed submit must be retried, not leaked"
+ mgr.remove_connection(conn),
+ Some((100, 7)),
+ "the disconnect must hand back (client_id, epoch) so the caller
can log it out"
);
-
- // Submit committed on the retry.
- mgr.defer_reclaim(100, 7, grace, now + grace + grace);
- mgr.complete_reclaim(100);
- assert!(
- mgr.take_expired_reclaims(now + Duration::from_mins(10))
- .is_empty(),
- "a completed reclaim must not be re-submitted"
+ assert!(mgr.get_session(conn).is_none());
+ assert_eq!(
+ mgr.remove_connection(conn),
+ None,
+ "a second disconnect has nothing left to release"
);
}
}
diff --git a/core/shard/src/coordinator.rs b/core/shard/src/coordinator.rs
index 21e2ff821..f85daba69 100644
--- a/core/shard/src/coordinator.rs
+++ b/core/shard/src/coordinator.rs
@@ -62,6 +62,12 @@ use tracing::warn;
/// bits carry the shard, the bottom 112 the mint sequence.
const CLIENT_ID_SHARD_SHIFT: u32 = 112;
+/// Sequence half of a minted client id. The tag above it is the inter-shard
+/// reply routing key (`message_bus::client_id_owning_shard`), so a sequence
+/// that bled past this mask would silently re-route a connection's replies to
+/// another shard for the process lifetime.
+const CLIENT_SEQUENCE_MASK: u128 = (1 << CLIENT_ID_SHARD_SHIFT) - 1;
+
/// Coordinator owned by shard 0 only.
///
/// Wrapped in `Rc` by the bootstrap and shared with the replica listener,
@@ -83,6 +89,10 @@ pub struct ShardZeroCoordinator {
replica_rr: Cell<u16>,
client_rr: Cell<u16>,
client_seq: Cell<u128>,
+ /// View the mint counter was last seeded for. `None` until the first seed.
+ /// Tracked so [`Self::seed_client_sequence`] can be called on the minting
+ /// path and fold the table exactly once per view instead of per mint.
+ seeded_view: Cell<Option<u32>>,
}
impl ShardZeroCoordinator {
@@ -115,6 +125,7 @@ impl ShardZeroCoordinator {
replica_rr: Cell::new(0),
client_rr: Cell::new(0),
client_seq: Cell::new(1),
+ seeded_view: Cell::new(None),
})
}
@@ -144,32 +155,67 @@ impl ShardZeroCoordinator {
/// Mint a client id encoding `target_shard` in the top 16 bits and a
/// monotonic per-coordinator counter in the bottom 112 bits.
+ ///
+ /// The sequence is masked into its half of the id. Counting there cannot
+ /// reach 2^112, but the counter is also SEEDED from recovered client-table
+ /// ids ([`Self::seed_client_sequence`]), and those include values a client
+ /// supplied on the wire -- so without the mask a chosen id near the top of
+ /// the range would, after a restart, push the next mint's carry into the
+ /// shard tag and misroute that connection's replies. Zero is skipped
+ /// because the wire header rejects `client == 0`.
fn mint_client_id(&self, target_shard: u16) -> u128 {
- let seq = self.client_seq.get();
- self.client_seq.set(seq.wrapping_add(1));
+ let mut seq = self.client_seq.get() & CLIENT_SEQUENCE_MASK;
+ if seq == 0 {
+ seq = 1;
+ }
+ self.client_seq
+ .set(seq.wrapping_add(1) & CLIENT_SEQUENCE_MASK);
(u128::from(target_shard) << CLIENT_ID_SHARD_SHIFT) | seq
}
- /// Reseed the mint counter above every sequence in `recovered_ids`.
+ /// Reseed the mint counter above every sequence in `recovered_ids`, once
+ /// per `view`.
///
- /// The counter is per process and starts at 1, while a restarted node
- /// recovers client-table entries keyed by the previous boot's ids. Left
- /// alone, the first logins after a restart re-mint ids that are already
- /// taken: a different user's login is then refused outright (the register
- /// ownership gate), and the same user's inherits a watermark it never
- /// wrote. Seeding past the recovered high-water mark makes the collision
- /// unreachable instead of handled.
+ /// The counter is per process and starts at 1, while the client table it
+ /// must not collide with is REPLICATED -- so it holds ids minted by other
+ /// nodes' counters, and by this node's previous boot. Left alone, a mint
+ /// lands on an id that is already taken: a different user's login is then
+ /// refused outright (the register ownership gate), and the same user's
+ /// rebinds and inherits a watermark it never wrote. Seeding past the
+ /// table's high-water mark makes that unreachable instead of handled.
///
- /// Boot-time only, on shard 0, before any listener accepts. Never lowers
- /// the counter.
- pub fn seed_client_sequence(&self, recovered_ids: impl Iterator<Item =
u128>) {
- const SEQUENCE_MASK: u128 = (1 << CLIENT_ID_SHARD_SHIFT) - 1;
-
- let highest = recovered_ids.map(|id| id & SEQUENCE_MASK).max();
+ /// Two moments need it, which is why `view` is the key rather than "call
+ /// this at boot": startup, where the table was rebuilt from the previous
+ /// boot's WAL, and PROMOTION, where a node starts minting against ids its
+ /// predecessor committed from an unrelated counter. A view change is the
+ /// observable edge for the second, and re-seeding for a view already
+ /// covered is skipped, so this is cheap enough to call from the minting
+ /// path.
+ ///
+ /// Never lowers the counter, so a redundant call cannot hand back an id
+ /// this process already minted.
+ ///
+ /// Only ids whose tag names a shard of this node are folded in. The table
+ /// also holds ids a client chose for itself (the TCP login path takes
+ /// `client` straight off the wire), and those carry arbitrary tags;
folding
+ /// them would let one login steer this node's counter. A chosen id with a
+ /// *plausible* tag can still move it, which is why the mint masks -- the
+ /// worst case is then a counter that wraps low and collides with live
+ /// entries, which the register ownership gate refuses rather than
+ /// mis-serves.
+ pub fn seed_client_sequence(&self, view: u32, recovered_ids: impl
Iterator<Item = u128>) {
+ if self.seeded_view.get() == Some(view) {
+ return;
+ }
+ self.seeded_view.set(Some(view));
+ let highest = recovered_ids
+ .filter(|id| (id >> CLIENT_ID_SHARD_SHIFT) <
u128::from(self.total_shards))
+ .map(|id| id & CLIENT_SEQUENCE_MASK)
+ .max();
let Some(highest) = highest else {
return;
};
- let next = highest.saturating_add(1);
+ let next = highest.saturating_add(1) & CLIENT_SEQUENCE_MASK;
if next > self.client_seq.get() {
self.client_seq.set(next);
}
@@ -535,7 +581,8 @@ mod tests {
(3u128 << CLIENT_ID_SHARD_SHIFT) | 0x2a,
9,
];
- coord.seed_client_sequence(recovered.into_iter());
+ // total_shards is 4, so tags 1 and 3 are this node's and fold in.
+ coord.seed_client_sequence(0, recovered.into_iter());
let id = coord.mint_client_id(2);
assert_eq!(
@@ -562,8 +609,8 @@ mod tests {
for _ in 0..5 {
let _ = coord.mint_client_id(0);
}
- coord.seed_client_sequence(std::iter::empty());
- coord.seed_client_sequence([1u128, 2].into_iter());
+ coord.seed_client_sequence(0, std::iter::empty());
+ coord.seed_client_sequence(1, [1u128, 2].into_iter());
let id = coord.mint_client_id(0);
assert_eq!(
@@ -573,6 +620,98 @@ mod tests {
);
}
+ // The sequence must never carry into the shard tag: that tag is the
+ // inter-shard reply routing key, so a carry would send a live connection's
+ // replies to a different shard for the process lifetime. Reachable only
+ // because the counter is seeded from recovered ids, and the TCP login path
+ // takes `client` straight off the wire -- so a client can choose one near
+ // the ceiling and a restart folds it in.
+ #[test]
+ fn mint_never_carries_the_sequence_into_the_shard_tag() {
+ let senders = build_senders(4);
+ let coord = ShardZeroCoordinator::new(
+ senders,
+ 4,
+ CoordinatorConfig::default(),
+ crate::metrics::ShardMetrics::for_shard(),
+ )
+ .expect("coord ctor ok");
+
+ // A chosen id with a plausible tag, sequence at the ceiling.
+ coord.seed_client_sequence(0, std::iter::once(CLIENT_SEQUENCE_MASK));
+ for _ in 0..4 {
+ let id = coord.mint_shard_zero_client_id();
+ assert_eq!(
+ id >> CLIENT_ID_SHARD_SHIFT,
+ 0,
+ "shard-0 mint must keep tag 0, got id {id:#x}"
+ );
+ assert_ne!(id, 0, "the wire header rejects client == 0");
+ }
+ }
+
+ // Ids a client chose for itself carry arbitrary tags. Folding those into
+ // this node's counter would let one login steer it.
+ #[test]
+ fn seed_ignores_ids_tagged_outside_this_node() {
+ let senders = build_senders(2);
+ let coord = ShardZeroCoordinator::new(
+ senders,
+ 2,
+ CoordinatorConfig::default(),
+ crate::metrics::ShardMetrics::for_shard(),
+ )
+ .expect("coord ctor ok");
+
+ // Tag 9 is not a shard of a 2-shard node: caller-supplied, ignored.
+ coord.seed_client_sequence(
+ 0,
+ std::iter::once((9u128 << CLIENT_ID_SHARD_SHIFT) | 0x0f_ff_ff),
+ );
+ assert_eq!(
+ coord.mint_shard_zero_client_id() & CLIENT_SEQUENCE_MASK,
+ 1,
+ "an id tagged outside this node must not move the counter"
+ );
+ }
+
+ // Promotion is the second moment the counter can be wrong: the new primary
+ // mints against ids its predecessor committed from an unrelated counter.
+ // A view change is the observable edge, and re-seeding within one view is
+ // skipped so this is cheap enough to sit on the minting path.
+ #[test]
+ fn seed_refolds_the_table_once_per_view() {
+ let senders = build_senders(2);
+ let coord = ShardZeroCoordinator::new(
+ senders,
+ 2,
+ CoordinatorConfig::default(),
+ crate::metrics::ShardMetrics::for_shard(),
+ )
+ .expect("coord ctor ok");
+
+ coord.seed_client_sequence(4, std::iter::once(0x10));
+ assert_eq!(
+ coord.mint_shard_zero_client_id() & CLIENT_SEQUENCE_MASK,
+ 0x11
+ );
+
+ // Same view: the table is not refolded, so a later entry is ignored.
+ coord.seed_client_sequence(4, std::iter::once(0x80));
+ assert_eq!(
+ coord.mint_shard_zero_client_id() & CLIENT_SEQUENCE_MASK,
+ 0x12
+ );
+
+ // Promotion: new view, so the predecessor's high-water mark is folded.
+ coord.seed_client_sequence(5, std::iter::once(0x80));
+ assert_eq!(
+ coord.mint_shard_zero_client_id() & CLIENT_SEQUENCE_MASK,
+ 0x81,
+ "a promoted primary must mint above what its predecessor committed"
+ );
+ }
+
#[test]
fn shard_zero_local_and_delegated_ids_never_collide() {
let senders = build_senders(4);
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index b042d556c..74e48c91e 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -50,6 +50,7 @@ use message_bus::fd_transfer::DupedFd;
use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
use message_bus::replica::listener::MessageHandler;
use metadata::IggyMetadata;
+use metadata::MetadataSubmitError;
use metadata::impls::metadata::StreamsFrontend;
use metadata::stm::StateMachine;
use partitions::{IggyPartition, IggyPartitions, PollFragments, PollingArgs,
PollingConsumer};
@@ -161,16 +162,20 @@ pub fn channel<T: Send + 'static>(capacity: usize) ->
(Sender<T>, Receiver<T>) {
/// connection homes on a peer shard, that shard verifies credentials and
/// owns the session locally, but the consensus proposal (`Register` /
/// `Logout`) must execute on shard 0. The peer hands just that step here
-/// and awaits the committed op number over `reply` (`None` = transient
-/// submit failure; all `MetadataSubmitError` variants are transient by
-/// contract, so the caller retries rather than distinguishing them).
+/// and awaits the outcome over `reply`. `Register` carries the submit error
+/// verbatim because one variant
+/// (`MetadataSubmitError::ClientIdOwnedByAnotherUser`) is terminal and must
+/// not be retried; the remaining variants are transient by contract.
pub enum MetadataSubmit {
Register {
vsr_client_id: u128,
user_id: u32,
- /// `(epoch, watermark)` of the committed bind; `None` on a transient
- /// submit failure.
- reply: Sender<Option<(u64, u64)>>,
+ /// `Ok((epoch, watermark))` for a committed bind, or the submit error
+ /// verbatim. The error must survive the hop: the ownership refusal is
+ /// TERMINAL, and flattening it into "no reply" makes the login look
+ /// transient, which costs the client a retry storm of full password
+ /// verifications.
+ reply: Sender<Result<(u64, u64), MetadataSubmitError>>,
},
Logout {
vsr_client_id: u128,