spetz commented on code in PR #4387:
URL: https://github.com/apache/iggy/pull/4387#discussion_r4174478262


##########
core/partitions/src/state_transfer.rs:
##########
@@ -2312,18 +2406,24 @@ where
                         .then(|| self.consensus().last_prepare_checksum())
                 })
         };
-        let checkpoint_prepare = self
-            .log
-            .journal()
-            .inner
-            .repair_entry(commit_op)
-            .map_or_else(Vec::new, |prepare| prepare.as_slice().to_vec());
-        if self.persistence.is_some()
+        let checkpoint_prepare = if require_prepare {
+            self.log
+                .journal()
+                .inner
+                .repair_entry(commit_op)
+                .map_or_else(Vec::new, |prepare| prepare.as_slice().to_vec())
+        } else {
+            Vec::new()
+        };
+        if require_prepare
+            && self.persistence.is_some()
             && (prepare_checksum.is_none() || (commit_op > 0 && 
checkpoint_prepare.is_empty()))
         {
             return Err(PartitionTransferUnavailable::MissingPrepareChecksum { 
op: commit_op });
         }
         Ok(ConsumerOffsetsWire {
+            required_metadata_frontier: self.required_metadata_frontier,
+            dedup_capacity: self.dedup().capacity(),

Review Comment:
   The transfer offer sends `self.dedup().capacity()` even when no prepare 
committed yet, so the receiver of a commit_op 0 transfer installs the sender's 
local `dedup_clients_max` as committed capacity through `install_watermarks`. 
If the first committed prepare nominates a different `retry_capacity`, the 
receiver's commit walk fails `commit_capacity` at 
`core/partitions/src/iggy_partition.rs:6453`, sets `FatalCommit`, and the node 
shuts down. This is a separate path from the ignored configuration change at 
`core/consensus/src/client_table.rs:765`, because here a node-local value fixes 
the capacity before any prepare does, and the mismatch is fatal.



##########
foreign/go/client/tcp/tcp_session_management.go:
##########
@@ -29,27 +29,31 @@ import (
 )
 
 func (c *IggyTcpClient) LoginUser(ctx context.Context, username string, 
password string) (*iggcon.IdentityInfo, error) {
-       body, err := vsr.SerializeLoginRegister(username, password, 
iggcon.Version)
+       secret := vsr.NewBindSecret()

Review Comment:
   Go, Java and C# register a fresh client id and secret on every transport 
loss (`foreign/go/client/tcp/tcp_core.go:1024`, 
`foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs:761`,
 Java `beginRegister`), while Rust and Node resume with `BindSession`. With 
eviction gone, each reconnect from these three SDKs leaves the old entry 
holding a slot until the 30 s session timeout, which adds SDK-driven churn on 
top of the slot pressure described at `core/consensus/src/client_table.rs:1289`.



##########
core/partitions/src/iggy_partition.rs:
##########
@@ -4380,80 +4528,103 @@ where
             // earned, not the typed 404 the existence check would raise now
             // that the offset is gone.
             //
-            // A replay racing its own in-flight original is absorbed here: the
-            // slice only knows committed ops, so it cannot yet see the copy
-            // still in the pipeline. Keyed on the exact `(client, request)`
-            // for the transports that keep several writes in flight per
-            // client (HTTP handlers on one session, the pipelining SDKs):
-            // matching any request from the client would serialize them to
-            // one in-flight write per group. A lockstep TCP connection never
-            // has a second request here to begin with.
+            // One unresolved mutation per session/group protects the original
+            // receipt until the caller resolves it. An exact pending retry is
+            // still uncertain; a different request waits for that original
+            // instead of displacing its receipt.
             //
-            // Those same transports can deliver a client's ids out of order:
+            // HTTP handlers and pipelining SDKs can deliver a client's ids 
out of order:
             // a write refused transiently here is replayed after its
             // successors committed. The slice therefore keeps a committed-id
             // window under the watermark (`consensus::COMMITTED_WINDOW_BITS`)
             // and admits an unmarked id inside it instead of absorbing it.
             if !is_auto_commit_client(client_id) {
-                if 
consensus.pipeline_has_message_from_client_request(client_id, request) {
+                if let Some((pending_session, pending_request, 
pending_operation)) =

Review Comment:
   The preflight replaced the exact-replay check 
`pipeline_has_message_from_client_request` with `pending_request`, so on the 
binary transports a pipelined second request from the same client in the group 
gets `TransientNotAccepted`. Pipelining clients such as the Java async SDK 
handle that code as a routing failure, retry on a timer and run leader rechecks 
for a lane that is only busy. The thread at 
`core/server/src/http/submit.rs:370` covers the HTTP side effect of the double 
resolve. This one hits any pipelining binary client.



##########
core/sdk/src/clients/client.rs:
##########
@@ -755,7 +756,8 @@ impl IggyClient {
     /// client.connect().await?;
     ///
     /// let producer = client
-    ///     .producer("stream_name", "topic_name")? // returns 
IggyProducerBuilder from IggyClient
+    ///     .producer("stream_name", "topic_name")?
+    ///     .topic_durability(iggy::prelude::Durability::Persisted) // returns 
IggyProducerBuilder from IggyClient

Review Comment:
   The `returns IggyProducerBuilder from IggyClient` comment moved onto the 
`.topic_durability(...)` line, so it now labels the wrong call.



##########
core/metadata/src/impls/recovery.rs:
##########
@@ -642,19 +676,22 @@ where
         // WAL replay must recompute authorization denials identically to the
         // primary/backup commit paths, so it goes through the same gate.
         let reply = mux_stm.gated_update(entry)?;
-        // Re-cache the reply exactly like the commit paths: same prepare
-        // header + deterministic apply output = the original bytes. Skipped
-        // when the session is absent (server-originated ops, or the client
-        // was evicted).
-        if client_table.get_epoch(header.client).is_some() {
+        // Every client commit must restore its original receipt before 
admission.
+        if header.client != consensus::client_table::RESERVED_CLIENT_ID
+            && header.operation != Operation::CompleteConsumerGroupRevocation
+        {
             let cached = build_reply_message_with(header, 
reply.reply_body_len(), |dst| {
                 reply.write_reply_body(dst);
             });
-            // Skips (never panics) on a stale-request replay: capacity
-            // eviction is replica-local and unlogged, so a WAL can 
legitimately
-            // replay a lower request id onto a preserved watermark. Recovery
-            // must boot from such a WAL, not refuse it.
-            let _ = client_table.commit_reply(header.client, header.user_id, 
cached);
+            if client_table.commit_reply(header.client, header.user_id, cached)
+                != consensus::client_table::CommitReply::Cached

Review Comment:
   Recovery now refuses to boot on any replay outcome other than `Cached`, but 
`checkpoint_if_needed` at `core/metadata/src/impls/metadata.rs:3547` can 
snapshot a client table that a state transfer installed ahead of `commit_min`. 
`install_state_transfer` sets the table at `table_frontier` while the state 
machine sits at `snapshot_seq`, so replaying ops in `(snap_op, table_frontier]` 
returns `SkippedRegression` for a client that committed twice there, or fails 
`commit_register` for a session that ended there, and the node cannot restart.



##########
core/consensus/src/client_table.rs:
##########
@@ -1277,62 +1134,136 @@ impl ClientTable {
             return SliceRequestStatus::New;
         };
         let entry = self.slots[slot_idx].as_ref().expect("index/slot 
mismatch");
-        if entry.user_id != user_id || request > entry.watermark {
+        if entry.user_id != user_id {
             return SliceRequestStatus::New;
         }
-        let below = entry.watermark - request;
-        if below >= COMMITTED_WINDOW_BITS {
-            return SliceRequestStatus::AgedOut;
+        entry.check_slice_request(request)
+    }
+
+    #[must_use]
+    /// # Panics
+    /// Only if the private index refers to an unoccupied slot.
+    pub fn check_partition_request(
+        &self,
+        client_id: u128,
+        user_id: u32,
+        session: u64,
+        request: u64,
+        operation: Operation,
+    ) -> RequestStatus {
+        let Some(&slot) = self.index.get(&client_id) else {
+            return RequestStatus::New;
+        };
+        let entry = self.slots[slot].as_ref().expect("index/slot mismatch");
+        if entry.user_id != user_id || entry.epoch != session {
+            return RequestStatus::Fenced {
+                current: entry.epoch,
+                received: session,
+            };
         }
-        if entry.committed_window & (1 << below) == 0 {
-            SliceRequestStatus::New
-        } else {
-            SliceRequestStatus::Committed
+        match entry.check_slice_request(request) {
+            SliceRequestStatus::New => RequestStatus::New,
+            SliceRequestStatus::Committed => match entry.find_cached(request) {
+                Some(reply) if reply.header().operation != operation => {
+                    RequestStatus::OperationMismatch { request }
+                }
+                Some(reply) => RequestStatus::Duplicate(reply.clone()),
+                None => RequestStatus::AlreadyApplied {
+                    request,
+                    watermark: entry.watermark,
+                },
+            },
+            SliceRequestStatus::AgedOut => RequestStatus::AlreadyApplied {
+                request,
+                watermark: entry.watermark,
+            },
         }
     }
 
-    /// Record a committed request without a reply to cache.
+    /// Retain a partition receipt and return a shared handle to its first 
committed result.
     ///
-    /// The entry point for a plane that runs
-    /// [`ClientTableMode::PartitionSlice`]: there is no register to create the
-    /// entry, so the first committed request creates it, and there is no 
result
-    /// section worth retaining. [`Self::check_slice_request`] proves retained
-    /// duplicates and the caller synthesizes their reply.
-    ///
-    /// Idempotent and order-insensitive for one user: the watermark only rises
-    /// and the window only gains bits, so replaying an already-folded op is a
-    /// no-op and a state-transfer install followed by a re-walk of the same
-    /// commits converges. A commit above the watermark shifts the window up by
-    /// the gap (ids that age out read as [`SliceRequestStatus::AgedOut`]);
-    /// one below it sets its bit. A commit by a DIFFERENT user under the same
-    /// client id replaces the entry outright: nothing observes a logout here, 
so this is
-    /// what stops the next holder of a reused id from having its first
-    /// writes absorbed by the previous holder's watermark.
+    /// # Errors
+    /// Refuses invalid or inconsistent receipts and exhausted protection 
capacity.
     ///
     /// # Panics
-    /// If called on a table whose mode caches replies -- that plane must go
-    /// through [`Self::commit_reply`] so the ring stays populated.
-    pub fn commit_request(&mut self, client_id: u128, user_id: u32, request: 
u64, commit_op: u64) {
-        debug_assert!(
-            !self.mode.cache_replies(),
-            "commit_request: a reply-caching table must use commit_reply"
-        );
-        // Zero is the reserved client id, refused at every ingress (wire
-        // validation, the HTTP minter, the auto-commit guard at the call
-        // sites). Kept as a return rather than an assert so that an artifact
-        // slipping past the decoder degrades to no dedup for that entry 
instead
+    /// Only if the private index refers to an unoccupied slot.
+    pub fn commit_partition_reply(
+        &mut self,
+        user_id: u32,
+        session: u64,
+        reply: Message<ReplyHeader>,
+    ) -> Result<CachedReply, ClientTableWireError> {
+        let header = *reply.header();
+        if header.client == 0
+            || session == 0
+            || header.commit == 0
+            || !matches!(
+                header.operation,
+                iggy_binary_protocol::Operation::SendMessages
+                    | iggy_binary_protocol::Operation::StoreConsumerOffset
+                    | iggy_binary_protocol::Operation::DeleteConsumerOffset
+            )
+        {
+            return Err(ClientTableWireError::InvalidReply);
+        }
+        if let Some(&slot) = self.index.get(&header.client)
+            && self.slots[slot]
+                .as_ref()
+                .is_some_and(|entry| entry.epoch != session)
+        {
+            return Err(ClientTableWireError::InvalidWatermark {
+                client_id: header.client,
+            });
+        }
+        self.commit_request(header.client, user_id, header.request, 
header.commit)?;
+        let slot = self.index[&header.client];
+        let entry = self.slots[slot].as_mut().expect("index/slot mismatch");
+        entry.epoch = session;
+        if let Some(cached) = entry.find_cached(header.request) {
+            return Ok(cached.clone());
+        }
+        let cached = CachedReply::from(reply);
+        entry.push_latest(cached.clone());
+        Ok(cached)
+    }
+
+    /// Fold an AUTO offset request into the partition watermark without a 
receipt.

Review Comment:
   The doc of `commit_request` says that it folds AUTO offset requests without 
a receipt, but the commit walk skips dedup for auto-commit ops at 
`core/partitions/src/iggy_partition.rs:6621`, and the only production caller is 
`commit_partition_reply`, although the method stays `pub`.



##########
core/sdk/src/session.rs:
##########
@@ -98,66 +86,50 @@ impl ConsensusSession {
         self.session.is_some()
     }
 
-    /// Bind the session after register commits through consensus.
-    /// The `session` value is the commit op number from the server's reply.
-    ///
-    /// # Panics
-    /// Panics if already bound (drop and create a new session instead).
-    pub fn bind(&mut self, session: u64) {
-        assert!(
-            self.session.is_none(),
-            "session already bound (session={})",
-            self.session.unwrap()
-        );
-        assert!(session > 0, "session must be > 0");
+    /// Registration proof retained across reconnects and session bindings.
+    pub fn bind_secret(&self) -> BindSecret {
+        self.bind_secret.clone()
+    }
+
+    /// Accept the original registration, including its replay after reconnect.
+    pub fn bind(&mut self, session: u64) -> Result<(), IggyError> {
+        if session == 0 {
+            return Err(IggyError::InvalidSession(session));
+        }
+        if let Some(bound) = self.session
+            && bound != session
+        {
+            return Err(IggyError::SessionMismatch(bound, session));
+        }
         self.session = Some(session);
+        Ok(())
     }
 
     /// Returns the request ID for the register operation (always 0).
     ///
-    /// Must be called exactly once, before [`bind`](Self::bind).
-    ///
-    /// # Panics
-    /// Panics if called more than once or after the session is bound.
-    pub fn register_request_id(&mut self) -> u64 {
-        assert!(
-            !self.register_consumed,
-            "register_request_id already called"
-        );
-        assert!(!self.is_bound(), "register_request_id called after bind");
-        self.register_consumed = true;
+    pub const fn register_request_id(&self) -> u64 {

Review Comment:
   `register_request_id` and `begin_register` are now both `const fn` that 
return 0, and only `core/sdk/src/vsr.rs:89` calls one of them outside tests, so 
the second method is redundant public API.



##########
core/consensus/src/client_table.rs:
##########
@@ -1277,62 +1134,136 @@ impl ClientTable {
             return SliceRequestStatus::New;
         };
         let entry = self.slots[slot_idx].as_ref().expect("index/slot 
mismatch");
-        if entry.user_id != user_id || request > entry.watermark {
+        if entry.user_id != user_id {

Review Comment:
   `ClientTable::check_slice_request` is public but has no production caller 
since the partition preflight moved to `check_partition_request` at 
`core/partitions/src/iggy_partition.rs:4564`, so it and its wrapper-only tests 
are dead code.



##########
core/server/src/session_manager.rs:
##########
@@ -471,10 +380,10 @@ impl SessionManager {
     pub fn get_user_id(&self, connection_id: u128) -> Option<u32> {
         let conn = self.connections.get(&connection_id)?;
         match conn.state {
-            ConnectionState::Authenticated { user_id } | 
ConnectionState::Bound { user_id, .. } => {
+            ConnectionState::Bound { user_id, .. } if 
self.get_session(connection_id).is_some() => {

Review Comment:
   `get_user_id` matches `Bound` and then calls `get_session`, which looks up 
the same connection again only to check that the attachment is valid on the 
`conn` already in hand.



##########
core/sdk/src/poll_routing.rs:
##########
@@ -372,14 +374,33 @@ impl<T: PollTransport> PollRouter<T> {
                 || session.session != route.consumer_session.session
                 || session.metadata_watermark < 
route.consumer_session.metadata_watermark
         }) {
-            connection
+            let response = connection

Review Comment:
   The poll path repeats the `BindSession` request encoding, reply decode and 
session check of `resume_vsr_session` at 
`core/common/src/traits/binary_transport.rs:84`, so the two copies of the 
handshake can drift.



##########
core/consensus/src/client_table.rs:
##########
@@ -1277,62 +1134,136 @@ impl ClientTable {
             return SliceRequestStatus::New;
         };
         let entry = self.slots[slot_idx].as_ref().expect("index/slot 
mismatch");
-        if entry.user_id != user_id || request > entry.watermark {
+        if entry.user_id != user_id {
             return SliceRequestStatus::New;
         }
-        let below = entry.watermark - request;
-        if below >= COMMITTED_WINDOW_BITS {
-            return SliceRequestStatus::AgedOut;
+        entry.check_slice_request(request)
+    }
+
+    #[must_use]
+    /// # Panics
+    /// Only if the private index refers to an unoccupied slot.
+    pub fn check_partition_request(
+        &self,
+        client_id: u128,
+        user_id: u32,
+        session: u64,
+        request: u64,
+        operation: Operation,
+    ) -> RequestStatus {
+        let Some(&slot) = self.index.get(&client_id) else {
+            return RequestStatus::New;
+        };
+        let entry = self.slots[slot].as_ref().expect("index/slot mismatch");
+        if entry.user_id != user_id || entry.epoch != session {
+            return RequestStatus::Fenced {
+                current: entry.epoch,
+                received: session,
+            };
         }
-        if entry.committed_window & (1 << below) == 0 {
-            SliceRequestStatus::New
-        } else {
-            SliceRequestStatus::Committed
+        match entry.check_slice_request(request) {
+            SliceRequestStatus::New => RequestStatus::New,
+            SliceRequestStatus::Committed => match entry.find_cached(request) {
+                Some(reply) if reply.header().operation != operation => {
+                    RequestStatus::OperationMismatch { request }
+                }
+                Some(reply) => RequestStatus::Duplicate(reply.clone()),
+                None => RequestStatus::AlreadyApplied {
+                    request,
+                    watermark: entry.watermark,
+                },
+            },
+            SliceRequestStatus::AgedOut => RequestStatus::AlreadyApplied {
+                request,
+                watermark: entry.watermark,
+            },
         }
     }
 
-    /// Record a committed request without a reply to cache.
+    /// Retain a partition receipt and return a shared handle to its first 
committed result.
     ///
-    /// The entry point for a plane that runs
-    /// [`ClientTableMode::PartitionSlice`]: there is no register to create the
-    /// entry, so the first committed request creates it, and there is no 
result
-    /// section worth retaining. [`Self::check_slice_request`] proves retained
-    /// duplicates and the caller synthesizes their reply.
-    ///
-    /// Idempotent and order-insensitive for one user: the watermark only rises
-    /// and the window only gains bits, so replaying an already-folded op is a
-    /// no-op and a state-transfer install followed by a re-walk of the same
-    /// commits converges. A commit above the watermark shifts the window up by
-    /// the gap (ids that age out read as [`SliceRequestStatus::AgedOut`]);
-    /// one below it sets its bit. A commit by a DIFFERENT user under the same
-    /// client id replaces the entry outright: nothing observes a logout here, 
so this is
-    /// what stops the next holder of a reused id from having its first
-    /// writes absorbed by the previous holder's watermark.
+    /// # Errors
+    /// Refuses invalid or inconsistent receipts and exhausted protection 
capacity.
     ///
     /// # Panics
-    /// If called on a table whose mode caches replies -- that plane must go
-    /// through [`Self::commit_reply`] so the ring stays populated.
-    pub fn commit_request(&mut self, client_id: u128, user_id: u32, request: 
u64, commit_op: u64) {
-        debug_assert!(
-            !self.mode.cache_replies(),
-            "commit_request: a reply-caching table must use commit_reply"
-        );
-        // Zero is the reserved client id, refused at every ingress (wire
-        // validation, the HTTP minter, the auto-commit guard at the call
-        // sites). Kept as a return rather than an assert so that an artifact
-        // slipping past the decoder degrades to no dedup for that entry 
instead
+    /// Only if the private index refers to an unoccupied slot.
+    pub fn commit_partition_reply(
+        &mut self,
+        user_id: u32,
+        session: u64,
+        reply: Message<ReplyHeader>,
+    ) -> Result<CachedReply, ClientTableWireError> {
+        let header = *reply.header();
+        if header.client == 0
+            || session == 0
+            || header.commit == 0
+            || !matches!(

Review Comment:
   The allowed partition receipt operations (`SendMessages | 
StoreConsumerOffset | DeleteConsumerOffset`) are matched in three places, here, 
in `install_watermarks` at `core/consensus/src/client_table.rs:1367` and at 
`core/shard/src/poll.rs:63`, so write, install and poll validation can drift 
apart.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to