| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| use crate::le_cursor::{LeCursor, Truncated, split_verified_trailer}; |
| use iggy_binary_protocol::consensus::ConsensusError; |
| use iggy_binary_protocol::{GenericHeader, Operation, ReplyHeader}; |
| use iggy_common::IggyError; |
| use serde::{Deserialize, Serialize}; |
| use server_common::{ |
| MESSAGE_ALIGN, Message, |
| iobuf::{Frozen, Owned}, |
| }; |
| use std::collections::{HashMap, VecDeque}; |
| use std::fmt; |
| use std::mem::size_of; |
| use std::sync::{Arc, Weak}; |
| |
| /// Refcounted wrapper around a committed reply. |
| /// |
| /// Bytes are deterministic across replicas: `build_reply_message` reads |
| /// only from the prepare header, so a backup-promoted primary replays |
| /// the exact bytes the original primary produced. |
| /// |
| /// Immutable by construction: [`Frozen`] has no mutable accessor. |
| #[derive(Debug, Clone)] |
| pub struct CachedReply { |
| bytes: Frozen<MESSAGE_ALIGN>, |
| } |
| |
| impl CachedReply { |
| /// # Panics |
| /// Only if immutable bytes cease to match the validated reply stored here. |
| #[must_use] |
| pub fn into_message(self) -> Message<ReplyHeader> { |
| Message::try_from(Owned::<MESSAGE_ALIGN>::copy_from_slice( |
| self.bytes.as_slice(), |
| )) |
| .expect("immutable cached reply was validated before storage") |
| } |
| |
| /// Reply header view. |
| /// |
| /// # Panics |
| /// Unreachable: prefix validated by [`Message::try_from`] at construction; |
| /// `Frozen` has no mutable accessor. |
| #[must_use] |
| pub fn header(&self) -> &ReplyHeader { |
| bytemuck::checked::try_from_bytes(&self.bytes.as_slice()[..size_of::<ReplyHeader>()]) |
| .expect("cached reply bytes contain a valid ReplyHeader (validated at storage time)") |
| } |
| |
| /// Consume into wire-shareable [`Frozen`] buffer. |
| /// |
| /// `MessageBus::send_to_client` takes `Frozen<MESSAGE_ALIGN>` directly. |
| /// To retain the cached entry, `.clone()` (Arc bump) first. |
| #[must_use] |
| pub fn into_wire_bytes(self) -> Frozen<MESSAGE_ALIGN> { |
| self.bytes |
| } |
| } |
| |
| impl From<Message<ReplyHeader>> for CachedReply { |
| fn from(message: Message<ReplyHeader>) -> Self { |
| Self { |
| bytes: message.into_generic().into_frozen(), |
| } |
| } |
| } |
| |
| impl CachedReply { |
| /// Raw reply bytes for checkpoint serialization, validated on decode. |
| fn as_bytes(&self) -> &[u8] { |
| self.bytes.as_slice() |
| } |
| |
| /// Wire size of this reply, the unit [`REPLY_RING_RETENTION_BYTES`] budgets. |
| fn byte_len(&self) -> usize { |
| self.bytes.len() |
| } |
| } |
| |
| /// Reserved request number for [`Operation::Register`]. |
| /// Real requests start at 1 (header validation enforces `request > 0`). |
| pub const REGISTER_REQUEST_ID: u64 = 0; |
| |
| /// Server-originated operations have no session or cached receipt. Wire |
| /// validation rejects this id, so no external client can claim it. |
| pub const RESERVED_CLIENT_ID: u128 = 0; |
| |
| #[must_use] |
| pub const fn is_partition_receipt_operation(operation: Operation) -> bool { |
| matches!( |
| operation, |
| Operation::SendMessages | Operation::StoreConsumerOffset | Operation::DeleteConsumerOffset |
| ) |
| } |
| |
| pub use iggy_binary_protocol::requests::users::login_register::BIND_SECRET_BYTES; |
| const BIND_VERIFIER_CONTEXT: &str = "apache.iggy session bind verifier v1"; |
| |
| #[must_use] |
| pub fn bind_verifier( |
| client_id: u128, |
| user_id: u32, |
| secret: &[u8; BIND_SECRET_BYTES], |
| ) -> [u8; BIND_SECRET_BYTES] { |
| let mut hasher = blake3::Hasher::new_derive_key(BIND_VERIFIER_CONTEXT); |
| hasher.update(&client_id.to_le_bytes()); |
| hasher.update(&user_id.to_le_bytes()); |
| hasher.update(secret); |
| hasher.finalize().into() |
| } |
| |
| /// Server-owned request number for lease expiry, which has no client-issued ID. |
| pub const EXPIRED_SESSION_REQUEST_ID: u64 = u64::MAX; |
| |
| /// Exclusive ceiling on a checkpointed slot index. |
| /// |
| /// Bounds the table [`ClientTable::from_snapshot`] allocates from an index it read off |
| /// disk. Mirrors the config's `MAX_METADATA_CLIENTS_TABLE_MAX`, the largest capacity an |
| /// operator can configure, so no valid checkpoint can carry an index at or above it. |
| pub const CLIENTS_TABLE_SLOT_MAX: usize = 1 << 16; |
| |
| /// Minimum reply-cache depth. The latest unresolved result is always retained. |
| /// |
| /// Metadata checkpoints retain the latest reply, and transfer preserves this |
| /// floor. Older cached replies may be lost across recovery; their request IDs |
| /// remain protected and cannot execute again. One unresolved request per group |
| /// makes the latest reply sufficient for the durable retry contract. |
| pub const REPLY_RING_CAPACITY: usize = 5; |
| |
| /// Byte budget for the replies retained past [`REPLY_RING_CAPACITY`]. |
| /// |
| /// Deep retention exists for the slow retrier: a request whose reply aged out |
| /// can only be answered "already applied, reply gone", which tells the caller |
| /// its operation succeeded but hands back no result. Budgeting in bytes rather |
| /// than in replies puts the depth where it is cheapest: a session sending |
| /// metadata operations keeps a long history, one pulling large batches keeps |
| /// none past the floor. |
| /// |
| /// # Depth |
| /// |
| /// A reply is never shorter than its 256-byte [`ReplyHeader`], so this budget |
| /// is also the only thing bounding the ring's length: 8 KiB / 256 B = 32 |
| /// replies at the deepest, against a floor of [`REPLY_RING_CAPACITY`]. |
| /// |
| /// The 32 is a chosen bound, not a measured one. The SDK holds one request in |
| /// flight per session, so what has to fit is the number of newer requests the |
| /// same session commits between a reply going unacknowledged and its retry |
| /// landing, and nothing in the tree measures that today. |
| /// |
| /// # Cost |
| /// |
| /// Not a wash on the common case. The common metadata reply is header-only, so |
| /// per-slot retention goes from 5 x 256 B = 1.25 KiB to 8 KiB, a 6.4x rise: a |
| /// saturated table at the default `clients_table_max` of 8192 goes from 10 MiB |
| /// to 64 MiB, and from 41k live [`Frozen`] buffers to 262k. At the |
| /// [`CLIENTS_TABLE_SLOT_MAX`] ceiling it is 512 MiB. Replies carrying a payload |
| /// exhaust the budget sooner and cost proportionally less; above roughly |
| /// 1.6 KiB apiece the [`REPLY_RING_CAPACITY`] floor dominates and this budget |
| /// adds nothing at all. |
| pub const REPLY_RING_RETENTION_BYTES: usize = 8 * 1024; |
| |
| /// Registered identity, immutable epoch and principal, and committed retry protection. |
| #[derive(Debug)] |
| struct ClientEntry { |
| bind_verifier: [u8; 32], |
| ended_op: Option<u64>, |
| /// Original Register commit. Matching login retries retain this epoch. |
| epoch: u64, |
| attachment: Option<Arc<()>>, |
| /// Principal that owns the registration and its retry protection. |
| user_id: u32, |
| /// Highest committed request number. `REGISTER_REQUEST_ID` (0) until the |
| /// first app op commits. Survives re-register: a resumed session keeps |
| /// its dedup history. |
| watermark: u64, |
| /// Partition-slice only: bit `i` set means request `watermark - i` has |
| /// committed, bit 0 being the watermark itself. A request in the retained |
| /// window with its bit clear has no commit recorded on this replica and |
| /// executes; below the window the outcome is unknown and refused. Zero |
| /// on the metadata plane, whose reply ring plays this role. |
| committed_window: u128, |
| /// Latest reply is protected while live; older cached results are bounded. |
| ring: VecDeque<CachedReply>, |
| /// Reply identity retained for checkpoint validation without header casts. |
| client_id: u128, |
| latest_commit: u64, |
| } |
| |
| /// A local attachment to one authenticated metadata session. |
| /// |
| /// It cannot keep the session alive. Logout and table replacement invalidate |
| /// every attachment, including those held by other shard threads. Matching |
| /// registration retries preserve existing attachments. |
| #[derive(Debug, Clone)] |
| pub struct SessionAttachment { |
| session: Weak<()>, |
| } |
| |
| impl SessionAttachment { |
| #[must_use] |
| pub fn is_valid(&self) -> bool { |
| self.session.strong_count() != 0 |
| } |
| } |
| |
| /// Serializable form of one occupied slot. |
| /// |
| /// Folded into the metadata checkpoint (`MetadataSnapshot`) so fence epochs and |
| /// dedup watermarks survive a restart that drained the WAL prefix they committed |
| /// in. Carries `client_id` explicitly because the index is rebuilt from it on |
| /// decode. |
| /// |
| /// The latest result is sufficient because a session permits one unresolved |
| /// request per group. Older request numbers remain fenced without cached bytes. |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct ClientEntrySnapshot { |
| pub bind_verifier: [u8; 32], |
| pub ended_op: Option<u64>, |
| pub client_id: u128, |
| pub epoch: u64, |
| pub user_id: u32, |
| pub watermark: u64, |
| /// Wire bytes of the latest committed reply, validated as [`Message<ReplyHeader>`] |
| /// on restore. Never empty: registration seeds the ring. |
| /// |
| /// Serialized as a msgpack `bin` blob, not the integer array a plain `Vec<u8>` |
| /// produces, which spends 2 bytes on every byte >= 0x80 and runs a checkpoint's |
| /// reply payload up to roughly double on disk. |
| #[serde(with = "reply_bytes")] |
| pub reply: Vec<u8>, |
| } |
| |
| /// Committed capacity and occupied slots. Local configuration cannot shrink |
| /// recovered protection; sparse slots avoid serializing unused capacity. |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct ClientTableSnapshot { |
| pub capacity: usize, |
| pub slots: Vec<(u32, ClientEntrySnapshot)>, |
| } |
| |
| /// Serializes reply bytes as a msgpack `bin` blob. See [`ClientEntrySnapshot::reply`]. |
| /// |
| /// `bin` is the only accepted encoding; the `visit_seq` arm that also took the older |
| /// integer-array form is gone. `SNAPSHOT_FORMAT_VERSION` decides readability in one |
| /// place, and a second decoder quietly accepting a retired layout makes that stamp a |
| /// lie. |
| mod reply_bytes { |
| use serde::de::{Error, Visitor}; |
| use serde::{Deserializer, Serializer}; |
| use std::fmt; |
| |
| pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> { |
| serializer.serialize_bytes(bytes) |
| } |
| |
| pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> { |
| deserializer.deserialize_byte_buf(BytesVisitor) |
| } |
| |
| struct BytesVisitor; |
| |
| impl Visitor<'_> for BytesVisitor { |
| type Value = Vec<u8>; |
| |
| fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| f.write_str("reply bytes") |
| } |
| |
| fn visit_bytes<E: Error>(self, bytes: &[u8]) -> Result<Self::Value, E> { |
| Ok(bytes.to_vec()) |
| } |
| |
| fn visit_byte_buf<E: Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> { |
| Ok(bytes) |
| } |
| } |
| } |
| |
| /// A [`ClientTableSnapshot`] could not be decoded into a [`ClientTable`], so a |
| /// corrupt or torn checkpoint refuses boot with a typed error rather than |
| /// panicking mid-decode. |
| #[derive(Debug)] |
| pub enum ClientTableDecodeError { |
| InvalidEntry { |
| slot: usize, |
| }, |
| /// A slot's serialized reply bytes are not a valid reply message. |
| InvalidReply { |
| /// Slot whose reply bytes failed to decode. |
| slot: usize, |
| /// The underlying wire-decode failure. |
| source: ConsensusError, |
| }, |
| /// Two occupied slots carry the same `client_id`. Rebuilding the index would |
| /// collapse them onto one slot and leave the other occupied but unindexed, so |
| /// the decode is rejected. |
| DuplicateClientId { |
| /// Slot repeating an already-seen `client_id`. |
| slot: usize, |
| /// Slot that first declared it. |
| first_slot: usize, |
| /// The duplicated client id. |
| client_id: u128, |
| }, |
| /// Two entries claim the same slot index. The second would overwrite the first, |
| /// leaving that client indexed onto another's state, so the decode is rejected. |
| DuplicateSlot { |
| /// The repeated slot index. |
| slot: usize, |
| }, |
| /// A slot index is past what any configured capacity can produce, so honouring |
| /// it would size the table from a corrupt length. |
| SlotOutOfRange { |
| /// The out-of-range slot index. |
| slot: usize, |
| /// Exclusive ceiling on a slot index. |
| max: usize, |
| }, |
| } |
| |
| impl fmt::Display for ClientTableDecodeError { |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| match self { |
| Self::InvalidEntry { slot } => write!( |
| f, |
| "client-table checkpoint slot {slot} has inconsistent protection" |
| ), |
| Self::InvalidReply { slot, source } => write!( |
| f, |
| "client-table checkpoint slot {slot} holds invalid reply bytes: {source}" |
| ), |
| Self::DuplicateClientId { |
| slot, |
| first_slot, |
| client_id, |
| } => write!( |
| f, |
| "client-table checkpoint slot {slot} repeats client_id {client_id} already in \ |
| slot {first_slot}" |
| ), |
| Self::DuplicateSlot { slot } => write!( |
| f, |
| "client-table checkpoint holds two entries for slot {slot}" |
| ), |
| Self::SlotOutOfRange { slot, max } => write!( |
| f, |
| "client-table checkpoint slot index {slot} is past the {max}-slot ceiling" |
| ), |
| } |
| } |
| } |
| |
| impl std::error::Error for ClientTableDecodeError { |
| fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { |
| match self { |
| Self::InvalidReply { source, .. } => Some(source), |
| Self::DuplicateClientId { .. } |
| | Self::DuplicateSlot { .. } |
| | Self::InvalidEntry { .. } |
| | Self::SlotOutOfRange { .. } => None, |
| } |
| } |
| } |
| |
| /// Result of checking a request against the client table. |
| /// |
| /// In-progress dedup is the caller's job, preflights consult |
| /// `pipeline.has_message_from_client(client_id)`. `ClientTable` only sees |
| /// committed state. |
| #[derive(Debug)] |
| pub enum RequestStatus { |
| /// Above the watermark; proceed with consensus. Jumps are allowed: the |
| /// watermark records the highest committed request, not a contiguous |
| /// sequence, so `watermark + k` for any `k >= 1` is new. |
| New, |
| /// At or below the watermark with the original reply still cached; |
| /// re-send it. |
| Duplicate(CachedReply), |
| /// At or below the watermark, original reply no longer cached. Applied |
| /// once already; must not re-execute, nothing to replay. |
| AlreadyApplied { request: u64, watermark: u64 }, |
| /// The retained receipt belongs to a different operation. |
| OperationMismatch { request: u64 }, |
| /// No entry for this client; must register first. |
| NoSession, |
| /// Stamped epoch is older than the entry's: a zombie holdover from |
| /// before a re-register. Terminal for that holder. |
| Fenced { current: u64, received: u64 }, |
| /// Stamped epoch is newer than any this table minted: client bug |
| /// (epochs are only handed out by register replies). |
| EpochAhead { current: u64, received: u64 }, |
| } |
| |
| /// Committed-window verdict for a partition dedup slice. |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| enum SliceRequestStatus { |
| /// No retained commit mark for this request. |
| New, |
| /// A retained commit mark protects this request from execution. |
| Committed, |
| /// The request is outside the retained window and its outcome is unknown. |
| AgedOut, |
| } |
| |
| /// Result of retaining a committed metadata receipt. |
| /// |
| /// Real client operations fail closed on a non-`Cached` outcome; |
| /// server-originated operations have no registered entry or receipt. |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| pub enum CommitReply { |
| /// Reply cached and the watermark advanced (or refreshed in place). |
| Cached, |
| /// No registered entry, including for server-originated operations. |
| NoEntry, |
| /// A replayed op is older than the restored protection frontier. |
| SkippedRegression { stored: u64, received: u64 }, |
| } |
| |
| /// Which of the table's mechanisms an instance runs. |
| /// |
| /// Metadata registers sessions and keeps a reply ring in preallocated slots. |
| /// Partition slices grow lazily and retain one session-qualified receipt per client. |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| pub enum ClientTableMode { |
| /// Metadata plane: replies cached, epoch fenced, slots preallocated. |
| Metadata, |
| /// One partition consensus group's receipt protection. |
| PartitionSlice, |
| } |
| |
| impl ClientTableMode { |
| /// Cache metadata replies in a ring. Partition receipts use |
| /// [`ClientTable::commit_partition_reply`] independently of this setting. |
| #[must_use] |
| pub const fn cache_replies(self) -> bool { |
| matches!(self, Self::Metadata) |
| } |
| |
| /// Enforce the metadata registration fence in `check_request`. |
| /// Partition admission checks its receipt's epoch independently. |
| #[must_use] |
| pub const fn fence_epoch(self) -> bool { |
| matches!(self, Self::Metadata) |
| } |
| |
| /// Allocate every slot up front. Off: slots grow to the cap on demand. |
| /// Slot assignment is identical either way -- both hand out the lowest free |
| /// index -- so slot identities and wire encoding are unchanged. |
| #[must_use] |
| pub const fn preallocate_slots(self) -> bool { |
| matches!(self, Self::Metadata) |
| } |
| } |
| |
| /// One partition slice entry in its wire and install form. |
| /// |
| /// Named fields rather than a tuple because `watermark` and `latest_commit` |
| /// are both `u64` and a positional swap would decode cleanly into the wrong |
| /// dedup decision. |
| #[derive(Debug, Clone, PartialEq, Eq)] |
| pub struct DedupWatermark { |
| pub client: u128, |
| /// Acting user the watermark belongs to. A different user committing under |
| /// the same client ID cannot inherit a live session's protection. |
| pub user_id: u32, |
| /// Highest committed request number. |
| pub watermark: u64, |
| /// Commit op of the protected result. |
| pub latest_commit: u64, |
| /// Bit `i` set: request `watermark - i` committed. See |
| /// [`COMMITTED_WINDOW_BITS`]. |
| pub committed_window: u128, |
| pub session: u64, |
| pub reply: Vec<u8>, |
| } |
| |
| /// Width of the per-entry committed-request window below the watermark. |
| /// |
| /// A client that pipelines writes can see one of them refused transiently and |
| /// replay it after later ids have committed, so "at or below the watermark" |
| /// alone would absorb that replay as a duplicate and lose the write. The window |
| /// records which ids under the watermark this replica saw commit; an unmarked |
| /// one inside it executes, while one that has aged out below it reads as |
| /// [`IggyError::RequestTooOld`] because its outcome is unknown. |
| /// |
| /// The width is in the CLIENT's request-id space, not in this group's writes: |
| /// `ConsensusSession` mints from one counter across every partition, stream and |
| /// metadata op, so a slice only ever sees the subset of those ids routed to it. |
| /// Coverage in a client's own writes to one group is this width divided by the |
| /// number of groups it interleaves, so 128 ids is around 16 writes per group |
| /// across 8 partitions, and a replay held back longer than that can be refused |
| /// with an unknown outcome. A wider bitmap divides by the same fanout: closing |
| /// the gap needs per-group request numbering, which waits on the clients-table follow-up. |
| pub const COMMITTED_WINDOW_BITS: u64 = 128; |
| |
| /// VSR client table: per-session fence epoch + request-watermark dedup. |
| /// |
| /// Fixed-size slot array (source of truth) + `HashMap` index (O(1) lookup). |
| /// |
| /// Metadata registration stores an immutable principal, epoch and bind |
| /// verifier. Matching login retries resolve that same registration. Explicit |
| /// logout or lease expiry ends it; ordered retirement in every partition group |
| /// precedes removal. Capacity pressure never evicts a live registration. |
| /// |
| /// Partition slices retain session-qualified receipts, committed windows |
| /// and exact results. The first committed prepare fixes the group's capacity. |
| /// Pending queues reserve slots before admission. Apply, checkpoint recovery and |
| /// state transfer preserve that committed capacity and all live protection. |
| /// |
| /// Metadata checkpoints retain the latest result and session tombstones. |
| /// Partition receipt checkpoints publish protection before WAL reclamation. |
| /// Missing or malformed referenced protection is refused, never replaced by an |
| /// empty table beside recovered data. |
| /// |
| /// ## Serialization (wire) |
| /// |
| /// [`Self::encode`] / [`Self::decode`] carry the table across state transfer |
| /// (a rejoin behind the peers' retained floor replaces its table with the |
| /// primary's copy). Deterministic: slot-order walk over apply-derived state, |
| /// so every caught-up replica encodes identical bytes. Distinct from the |
| /// checkpoint form above: that one is local-recovery durability, this one is |
| /// the transfer wire format. |
| #[derive(Debug)] |
| pub struct ClientTable { |
| /// `None` = free slot. Deterministic iteration for serialization. |
| /// |
| /// Under [`ClientTableMode::preallocate_slots`] this is sized to |
| /// `clients_max` at construction; otherwise it grows to that cap on demand. |
| /// Every `Some` has exactly one `index` entry, so `index.len()` is the |
| /// occupied count. |
| slots: Vec<Option<ClientEntry>>, |
| /// `client_id` -> slot index. Rebuilt on decode. |
| index: HashMap<u128, usize>, |
| /// Slot ceiling. Tracked explicitly because `slots.len()` is the allocated |
| /// length, which only equals the cap when slots are preallocated. |
| clients_max: usize, |
| capacity_committed: bool, |
| mode: ClientTableMode, |
| } |
| |
| impl ClientTable { |
| /// `max_clients` caps slots; index pre-sized to avoid rehash storms. |
| #[must_use] |
| pub fn new(max_clients: usize) -> Self { |
| Self::with_mode(max_clients, ClientTableMode::Metadata) |
| } |
| |
| /// `max_clients` caps slots; `mode` selects which mechanisms run. |
| #[must_use] |
| pub fn with_mode(max_clients: usize, mode: ClientTableMode) -> Self { |
| let (slots, index) = if mode.preallocate_slots() { |
| let mut slots = Vec::with_capacity(max_clients); |
| slots.resize_with(max_clients, || None); |
| (slots, HashMap::with_capacity(max_clients)) |
| } else { |
| (Vec::new(), HashMap::new()) |
| }; |
| Self { |
| slots, |
| index, |
| clients_max: max_clients, |
| capacity_committed: false, |
| mode, |
| } |
| } |
| |
| /// Resize the table to `max_clients` slots. Boot-only: reallocating a |
| /// populated table would silently drop live sessions, so this must run |
| /// before any client registers (the server bootstrap applies the configured |
| /// `[metadata] clients_table_max` here). |
| /// |
| /// # Panics |
| /// If the table already holds a client. |
| pub fn set_capacity(&mut self, max_clients: usize) { |
| if self.capacity_committed { |
| return; |
| } |
| assert!( |
| self.index.is_empty(), |
| "set_capacity must run before any client registers" |
| ); |
| *self = Self::with_mode(max_clients, self.mode); |
| } |
| |
| #[must_use] |
| pub const fn capacity_committed(&self) -> bool { |
| self.capacity_committed |
| } |
| |
| #[must_use] |
| pub fn contains(&self, client_id: u128) -> bool { |
| self.index.contains_key(&client_id) |
| } |
| |
| /// Verify a binding credential, resolving a zero epoch to its original registration. |
| /// |
| /// # Errors |
| /// Returns `TransientNotAccepted` while an uncertain registration is not visible, |
| /// `Unauthenticated` for an absent known session, bad proof or ended session, |
| /// and `InvalidSession` for a different nonzero epoch. |
| pub fn bind_session( |
| &mut self, |
| client_id: u128, |
| session: u64, |
| secret: &[u8; BIND_SECRET_BYTES], |
| ) -> Result<(u32, u64, SessionAttachment), IggyError> { |
| let missing = if session == 0 { |
| IggyError::TransientNotAccepted |
| } else { |
| IggyError::Unauthenticated |
| }; |
| let user_id = self.get_user_id(client_id).ok_or(missing)?; |
| let epoch = self |
| .registered_session( |
| client_id, |
| user_id, |
| bind_verifier(client_id, user_id, secret), |
| )? |
| .ok_or(IggyError::Unauthenticated)?; |
| if session != 0 && session != epoch { |
| return Err(IggyError::InvalidSession(session)); |
| } |
| let attachment = self |
| .attach_session(client_id, epoch, user_id) |
| .ok_or(IggyError::Unauthenticated)?; |
| Ok((user_id, epoch, attachment)) |
| } |
| |
| /// Resolve an idempotent registration for the same principal and verifier. |
| /// |
| /// # Errors |
| /// Returns `Unauthenticated` when a registered identity has different credentials. |
| /// |
| /// # Panics |
| /// Only if the private index refers to an unoccupied slot. |
| pub fn registered_session( |
| &self, |
| client_id: u128, |
| user_id: u32, |
| verifier: [u8; BIND_SECRET_BYTES], |
| ) -> Result<Option<u64>, IggyError> { |
| let Some(&slot) = self.index.get(&client_id) else { |
| return Ok(None); |
| }; |
| let entry = self.slots[slot].as_ref().expect("index/slot mismatch"); |
| if entry.user_id != user_id |
| || entry.ended_op.is_some() |
| || blake3::Hash::from_bytes(entry.bind_verifier) != blake3::Hash::from_bytes(verifier) |
| { |
| return Err(IggyError::Unauthenticated); |
| } |
| Ok(Some(entry.epoch)) |
| } |
| |
| pub fn end_session( |
| &mut self, |
| client_id: u128, |
| user_id: u32, |
| session: u64, |
| ended_op: u64, |
| ) -> bool { |
| if let Some(&slot) = self.index.get(&client_id) |
| && let Some(entry) = &mut self.slots[slot] |
| && entry.user_id == user_id |
| && entry.epoch == session |
| && entry.ended_op.is_none() |
| { |
| entry.ended_op = Some(ended_op); |
| entry.attachment = None; |
| return true; |
| } |
| false |
| } |
| |
| /// Revoke a principal's bindings while retaining receipts until ordered retirement. |
| pub fn end_user_sessions( |
| &mut self, |
| user_id: u32, |
| ended_op: u64, |
| ) -> Vec<iggy_binary_protocol::requests::system::SessionIdentity> { |
| let mut ended = Vec::new(); |
| for entry in self.slots.iter_mut().flatten() { |
| if entry.user_id == user_id && entry.ended_op.is_none() { |
| entry.ended_op = Some(ended_op); |
| entry.attachment = None; |
| ended.push(iggy_binary_protocol::requests::system::SessionIdentity { |
| client_id: entry.client_id, |
| session: entry.epoch, |
| metadata_watermark: ended_op, |
| }); |
| } |
| } |
| ended |
| } |
| |
| /// Retain the exact Logout result before ending its session. |
| /// |
| /// # Errors |
| /// Returns an error if a new Logout receipt cannot be retained. |
| /// |
| /// # Panics |
| /// Only if the private index refers to an unoccupied slot. |
| pub fn commit_logout( |
| &mut self, |
| client_id: u128, |
| user_id: u32, |
| session: u64, |
| reply: Message<ReplyHeader>, |
| ) -> Result<bool, ClientTableWireError> { |
| let Some(&slot) = self.index.get(&client_id) else { |
| return Ok(false); |
| }; |
| let entry = self.slots[slot].as_ref().expect("index/slot mismatch"); |
| if entry.epoch != session |
| || entry.user_id != user_id |
| || entry.ended_op.is_some() |
| || (reply.header().request <= entry.watermark |
| && reply.header().request != EXPIRED_SESSION_REQUEST_ID) |
| { |
| return Ok(false); |
| } |
| let ended_op = reply.header().commit; |
| if self.commit_reply(client_id, user_id, reply) != CommitReply::Cached { |
| return Err(ClientTableWireError::InvalidWatermark { client_id }); |
| } |
| Ok(self.end_session(client_id, user_id, session, ended_op)) |
| } |
| |
| pub fn ended_sessions( |
| &self, |
| ) -> impl Iterator<Item = iggy_binary_protocol::requests::system::SessionIdentity> + '_ { |
| self.slots.iter().flatten().filter_map(|entry| { |
| entry.ended_op.map( |
| |ended_op| iggy_binary_protocol::requests::system::SessionIdentity { |
| client_id: entry.client_id, |
| session: entry.epoch, |
| metadata_watermark: ended_op, |
| }, |
| ) |
| }) |
| } |
| |
| pub fn forget_session(&mut self, client_id: u128, session: u64) -> bool { |
| let Some(&slot) = self.index.get(&client_id) else { |
| return false; |
| }; |
| if self.slots[slot] |
| .as_ref() |
| .is_none_or(|entry| entry.epoch != session) |
| { |
| return false; |
| } |
| self.index.remove(&client_id); |
| self.slots[slot] = None; |
| true |
| } |
| |
| pub fn finalize_session( |
| &mut self, |
| identity: iggy_binary_protocol::requests::system::SessionIdentity, |
| ) -> bool { |
| if !self.ended_sessions().any(|ended| ended == identity) { |
| return false; |
| } |
| self.forget_session(identity.client_id, identity.session) |
| } |
| |
| /// Apply the limit carried by the ordered log, independent of local configuration. |
| /// |
| /// # Errors |
| /// Refuses an invalid limit, a changed committed limit, or one below live protection. |
| pub fn commit_capacity(&mut self, capacity: usize) -> Result<(), ClientTableWireError> { |
| if capacity == 0 |
| || capacity > CLIENTS_TABLE_SLOT_MAX |
| || self.count() > capacity |
| || (self.capacity_committed && self.clients_max != capacity) |
| { |
| return Err(ClientTableWireError::InvalidCapacity { capacity }); |
| } |
| self.clients_max = capacity; |
| if self.mode.preallocate_slots() { |
| self.slots.resize_with(capacity, || None); |
| } |
| self.capacity_committed = true; |
| Ok(()) |
| } |
| |
| /// Preserve committed capacity, live sessions, tombstones, and the latest result. |
| #[must_use] |
| pub fn to_snapshot(&self) -> ClientTableSnapshot { |
| let slots = self |
| .slots |
| .iter() |
| .enumerate() |
| .filter_map(|(slot_idx, slot)| { |
| let entry = slot.as_ref()?; |
| let slot_idx = u32::try_from(slot_idx).ok()?; |
| Some(( |
| slot_idx, |
| ClientEntrySnapshot { |
| bind_verifier: entry.bind_verifier, |
| ended_op: entry.ended_op, |
| client_id: entry.client_id, |
| epoch: entry.epoch, |
| user_id: entry.user_id, |
| watermark: entry.watermark, |
| reply: entry.latest().as_bytes().to_vec(), |
| }, |
| )) |
| }) |
| .collect(); |
| ClientTableSnapshot { |
| capacity: self.clients_max, |
| slots, |
| } |
| } |
| |
| /// Restore the committed limit without consulting local configuration. |
| /// |
| /// # Errors |
| /// Refuses invalid slots, duplicate identities, and malformed receipts. |
| pub fn from_snapshot(snapshot: ClientTableSnapshot) -> Result<Self, ClientTableDecodeError> { |
| // Bound the capacity on a slot index read off disk before allocating from it, |
| // as the superblock and WAL do with their length fields. |
| let capacity = snapshot.capacity; |
| if capacity == 0 || capacity > CLIENTS_TABLE_SLOT_MAX { |
| return Err(ClientTableDecodeError::SlotOutOfRange { |
| slot: capacity, |
| max: CLIENTS_TABLE_SLOT_MAX, |
| }); |
| } |
| for (slot_idx, _) in &snapshot.slots { |
| let slot = *slot_idx as usize; |
| if slot >= capacity { |
| return Err(ClientTableDecodeError::SlotOutOfRange { |
| slot, |
| max: capacity, |
| }); |
| } |
| } |
| |
| let mut index = HashMap::with_capacity(snapshot.slots.len()); |
| let mut slots = Vec::with_capacity(capacity); |
| slots.resize_with(capacity, || None); |
| for (slot_idx, entry) in snapshot.slots { |
| let slot_idx = slot_idx as usize; |
| let reply = Message::<ReplyHeader>::try_from(Owned::<MESSAGE_ALIGN>::copy_from_slice( |
| &entry.reply, |
| )) |
| .map_err(|source| ClientTableDecodeError::InvalidReply { |
| slot: slot_idx, |
| source, |
| })?; |
| // Reject rather than collapse the index onto one slot, leaving the |
| // other occupied but unindexed. Slot `client_id`s are unique in a |
| // table this crate produced, so a duplicate means a corrupt or |
| // foreign checkpoint. |
| if let Some(first_slot) = index.insert(entry.client_id, slot_idx) { |
| return Err(ClientTableDecodeError::DuplicateClientId { |
| slot: slot_idx, |
| first_slot, |
| client_id: entry.client_id, |
| }); |
| } |
| let latest_commit = reply.header().commit; |
| let mut ring = VecDeque::with_capacity(REPLY_RING_CAPACITY); |
| ring.push_back(CachedReply::from(reply)); |
| // Two entries claiming one slot would silently drop the first, leaving it |
| // indexed but pointing at another client's state. |
| if slots[slot_idx].is_some() { |
| return Err(ClientTableDecodeError::DuplicateSlot { slot: slot_idx }); |
| } |
| let restored = ClientEntry { |
| bind_verifier: entry.bind_verifier, |
| ended_op: entry.ended_op, |
| epoch: entry.epoch, |
| attachment: None, |
| user_id: entry.user_id, |
| watermark: entry.watermark, |
| committed_window: 0, |
| ring, |
| client_id: entry.client_id, |
| latest_commit, |
| }; |
| if !restored.valid_metadata_protection() { |
| return Err(ClientTableDecodeError::InvalidEntry { slot: slot_idx }); |
| } |
| slots[slot_idx] = Some(restored); |
| } |
| let clients_max = slots.len(); |
| let table = Self { |
| slots, |
| index, |
| clients_max, |
| capacity_committed: true, |
| mode: ClientTableMode::Metadata, |
| }; |
| Ok(table) |
| } |
| |
| /// Check a request against the table. Epoch fence first, then the |
| /// watermark. Register does not come through here: every bind proposes |
| /// unconditionally so its fence actually moves, see |
| /// [`Self::commit_register`]. |
| /// |
| /// The first committed result wins even when a retry changes its body. |
| /// Reusing an ID for another operation cannot replay that result. |
| /// |
| /// # Panics |
| /// If index points to empty slot (invariant violation). |
| #[must_use] |
| pub fn check_request( |
| &self, |
| client_id: u128, |
| epoch: u64, |
| request: u64, |
| operation: Operation, |
| ) -> RequestStatus { |
| assert!( |
| client_id != RESERVED_CLIENT_ID, |
| "client_id 0 is reserved for internal use" |
| ); |
| // Header validation guarantees both > 0 at wire layer. |
| debug_assert!( |
| epoch > 0 || !self.mode.fence_epoch(), |
| "check_request: epoch must be > 0 when fencing" |
| ); |
| debug_assert!(request > 0, "check_request: request must be > 0"); |
| |
| // Epoch check before request: a fenced zombie must be rejected even |
| // if its request number would read as a clean duplicate. |
| let Some(&slot_idx) = self.index.get(&client_id) else { |
| return RequestStatus::NoSession; |
| }; |
| let entry = self.slots[slot_idx].as_ref().expect("index/slot mismatch"); |
| |
| if entry.ended_op.is_some() { |
| if epoch == entry.epoch |
| && let Some(cached) = entry.find_cached(request) |
| && cached.header().operation == iggy_binary_protocol::Operation::Logout |
| { |
| return if cached.header().operation.request_operation() |
| == operation.request_operation() |
| { |
| RequestStatus::Duplicate(cached.clone()) |
| } else { |
| RequestStatus::OperationMismatch { request } |
| }; |
| } |
| return RequestStatus::NoSession; |
| } |
| |
| // A plane with no register mints no epoch, so there is nothing to |
| // fence against and the presented value is ignored. |
| if self.mode.fence_epoch() { |
| if epoch < entry.epoch { |
| return RequestStatus::Fenced { |
| current: entry.epoch, |
| received: epoch, |
| }; |
| } |
| if epoch > entry.epoch { |
| return RequestStatus::EpochAhead { |
| current: entry.epoch, |
| received: epoch, |
| }; |
| } |
| } |
| |
| if request > entry.watermark { |
| return RequestStatus::New; |
| } |
| |
| match entry.find_cached(request) { |
| Some(cached) |
| if cached.header().operation.request_operation() |
| != operation.request_operation() => |
| { |
| RequestStatus::OperationMismatch { request } |
| } |
| Some(cached) => RequestStatus::Duplicate(cached.clone()), |
| None => RequestStatus::AlreadyApplied { |
| request, |
| watermark: entry.watermark, |
| }, |
| } |
| } |
| |
| /// Register once. Matching login retries preserve the original epoch and history. |
| /// |
| /// # Errors |
| /// Refuses conflicting ownership, ended identities, and exhausted capacity. |
| pub fn commit_register( |
| &mut self, |
| client_id: u128, |
| user_id: u32, |
| bind_verifier: [u8; BIND_SECRET_BYTES], |
| reply: Message<ReplyHeader>, |
| ) -> Result<(), ClientTableWireError> { |
| if client_id == 0 |
| || client_id != reply.header().client |
| || reply.header().commit == 0 |
| || reply.header().request != REGISTER_REQUEST_ID |
| || reply.header().operation != iggy_binary_protocol::Operation::Register |
| { |
| return Err(ClientTableWireError::InvalidReply); |
| } |
| if let Some(&slot) = self.index.get(&client_id) { |
| let entry = self.slots[slot] |
| .as_ref() |
| .ok_or(ClientTableWireError::InvalidWatermark { client_id })?; |
| return if entry.user_id == user_id |
| && entry.bind_verifier == bind_verifier |
| && entry.ended_op.is_none() |
| { |
| Ok(()) |
| } else { |
| Err(ClientTableWireError::InvalidWatermark { client_id }) |
| }; |
| } |
| if self.index.len() >= self.clients_max { |
| return Err(ClientTableWireError::TooManyEntries { |
| count: u32::try_from(self.count() + 1).unwrap_or(u32::MAX), |
| max: self.clients_max, |
| }); |
| } |
| let slot = self |
| .first_free_slot() |
| .ok_or(ClientTableWireError::InvalidCapacity { |
| capacity: self.clients_max, |
| })?; |
| let epoch = reply.header().commit; |
| let mut ring = VecDeque::with_capacity(REPLY_RING_CAPACITY); |
| ring.push_back(CachedReply::from(reply)); |
| self.slots[slot] = Some(ClientEntry { |
| bind_verifier, |
| ended_op: None, |
| epoch, |
| attachment: None, |
| user_id, |
| client_id, |
| latest_commit: epoch, |
| watermark: REGISTER_REQUEST_ID, |
| committed_window: 0, |
| ring, |
| }); |
| self.index.insert(client_id, slot); |
| Ok(()) |
| } |
| |
| /// Retain a committed metadata result before advancing the applied frontier. |
| /// Missing ownership or regressing protection is a commit failure. |
| /// Server-originated operations return [`CommitReply::NoEntry`]. |
| /// # Panics |
| /// If the client id differs from the reply or the private index is inconsistent. |
| pub fn commit_reply( |
| &mut self, |
| client_id: u128, |
| user_id: u32, |
| reply: Message<ReplyHeader>, |
| ) -> CommitReply { |
| let new_header = reply.header(); |
| let new_client = new_header.client; |
| let new_request = new_header.request; |
| let new_commit = new_header.commit; |
| assert_eq!( |
| client_id, new_client, |
| "commit_reply: client_id mismatch (arg={client_id}, header={new_client})", |
| ); |
| // Ahead of the register guard below, which the default `request` 0 of |
| // a server-originated header would trip. |
| if client_id == RESERVED_CLIENT_ID { |
| return CommitReply::NoEntry; |
| } |
| debug_assert!( |
| new_request > REGISTER_REQUEST_ID, |
| "commit_reply: register replies go through commit_register" |
| ); |
| |
| let Some(&slot_idx) = self.index.get(&client_id) else { |
| return CommitReply::NoEntry; |
| }; |
| |
| let entry = self.slots[slot_idx].as_mut().expect("index/slot mismatch"); |
| if entry.user_id != user_id { |
| return CommitReply::NoEntry; |
| } |
| if new_commit < entry.latest_commit { |
| return CommitReply::SkippedRegression { |
| stored: entry.latest_commit, |
| received: new_commit, |
| }; |
| } |
| if new_request < entry.watermark { |
| return CommitReply::SkippedRegression { |
| stored: entry.watermark, |
| received: new_request, |
| }; |
| } |
| |
| // Freeze once; later dedup-hit clones Arc-bump. |
| let cached = CachedReply::from(reply); |
| if new_request == entry.watermark { |
| // Same request re-committed (WAL replay shape): replace in |
| // place, never push a stale twin - two cached replies for one |
| // request number would make lookups ambiguous. |
| if let Some(stored) = entry |
| .ring |
| .iter_mut() |
| .find(|stored| stored.header().request == new_request) |
| { |
| *stored = cached; |
| entry.latest_commit = entry.latest().header().commit; |
| } else { |
| entry.push_latest(cached); |
| } |
| } else { |
| entry.push_latest(cached); |
| entry.watermark = new_request; |
| } |
| CommitReply::Cached |
| } |
| |
| #[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, |
| }; |
| } |
| 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, |
| }, |
| } |
| } |
| |
| /// Retain a partition receipt and return a shared handle to its first committed result. |
| /// |
| /// # Errors |
| /// Refuses invalid or inconsistent receipts and exhausted protection capacity. |
| /// |
| /// # Panics |
| /// 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 |
| || !is_partition_receipt_operation(header.operation) |
| { |
| 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); |
| // A lower request does not acknowledge the watermark's result. |
| if let Some(pinned) = entry.ring.back() |
| && header.request < entry.watermark |
| { |
| entry.latest_commit = pinned.header().commit; |
| return Ok(cached); |
| } |
| entry.ring.clear(); |
| entry.latest_commit = cached.header().commit; |
| entry.ring.push_back(cached.clone()); |
| Ok(cached) |
| } |
| |
| /// Fold a partition receipt's identity into its committed request window. |
| /// |
| /// Replay is idempotent and order-insensitive within the retained window. |
| /// The first committed request fixes its principal. Entries are never |
| /// replaced or evicted to admit another client. Explicit writes use |
| /// [`Self::commit_partition_reply`] to retain their original result. |
| /// |
| /// # Panics |
| /// If called on a table whose mode caches replies -- that plane must go |
| /// through [`Self::commit_reply`] so the ring stays populated. |
| /// # Errors |
| /// Returns an error for zero identities, a changed principal, or exhausted |
| /// committed protection capacity. |
| pub(crate) fn commit_request( |
| &mut self, |
| client_id: u128, |
| user_id: u32, |
| request: u64, |
| commit_op: u64, |
| ) -> Result<(), ClientTableWireError> { |
| debug_assert!( |
| !self.mode.cache_replies(), |
| "commit_request: a reply-caching table must use commit_reply" |
| ); |
| if client_id == 0 { |
| return Err(ClientTableWireError::InvalidWatermark { client_id }); |
| } |
| |
| if let Some(&slot_idx) = self.index.get(&client_id) { |
| let entry = self.slots[slot_idx].as_mut().expect("index/slot mismatch"); |
| if entry.user_id != user_id { |
| return Err(ClientTableWireError::InvalidWatermark { client_id }); |
| } else if request > entry.watermark { |
| let gap = request - entry.watermark; |
| entry.committed_window = if gap >= COMMITTED_WINDOW_BITS { |
| 1 |
| } else { |
| (entry.committed_window << gap) | 1 |
| }; |
| entry.watermark = request; |
| entry.latest_commit = commit_op; |
| } else { |
| let below = entry.watermark - request; |
| if below < COMMITTED_WINDOW_BITS && entry.committed_window & (1 << below) == 0 { |
| entry.committed_window |= 1 << below; |
| // Commits walk in op order, so a newly folded reordered id |
| // is the newest commit unless an install re-walk replays an |
| // older one. |
| entry.latest_commit = entry.latest_commit.max(commit_op); |
| } |
| } |
| return Ok(()); |
| } |
| |
| if self.index.len() >= self.clients_max { |
| return Err(ClientTableWireError::TooManyEntries { |
| count: u32::try_from(self.index.len() + 1).unwrap_or(u32::MAX), |
| max: self.clients_max, |
| }); |
| } |
| let Some(slot_idx) = self.first_free_slot() else { |
| return Err(ClientTableWireError::InvalidCapacity { |
| capacity: self.clients_max, |
| }); |
| }; |
| self.index.insert(client_id, slot_idx); |
| self.slots[slot_idx] = Some(ClientEntry::watermark_only( |
| client_id, user_id, request, 1, commit_op, |
| )); |
| Ok(()) |
| } |
| |
| /// Replace partition protection atomically. Invalid protection leaves the |
| /// existing table intact; a smaller local limit cannot discard live receipts. |
| /// |
| /// # Errors |
| /// Refuses duplicate identities, invalid capacity, inconsistent windows, or receipts. |
| #[allow(clippy::suspicious_operation_groupings)] |
| pub fn install_watermarks( |
| &mut self, |
| capacity: usize, |
| entries: impl IntoIterator<Item = DedupWatermark>, |
| ) -> Result<(), ClientTableWireError> { |
| debug_assert!( |
| !self.mode.cache_replies(), |
| "install_watermarks: a reply-caching table installs via decode" |
| ); |
| let mut replacement = Self::with_mode(capacity, self.mode); |
| replacement.commit_capacity(capacity)?; |
| for entry in entries { |
| let slot = replacement.slots.len(); |
| if slot >= capacity { |
| return Err(ClientTableWireError::TooManyEntries { |
| count: u32::try_from(slot + 1).unwrap_or(u32::MAX), |
| max: capacity, |
| }); |
| } |
| if entry.client == 0 || entry.session == 0 || entry.committed_window & 1 == 0 { |
| return Err(ClientTableWireError::InvalidWatermark { |
| client_id: entry.client, |
| }); |
| } |
| if replacement.index.insert(entry.client, slot).is_some() { |
| return Err(ClientTableWireError::DuplicateClientId { |
| slot, |
| client_id: entry.client, |
| }); |
| } |
| let mut restored = ClientEntry::watermark_only( |
| entry.client, |
| entry.user_id, |
| entry.watermark, |
| entry.committed_window, |
| entry.latest_commit, |
| ); |
| restored.epoch = entry.session; |
| if entry.reply.is_empty() { |
| return Err(ClientTableWireError::EmptyRing); |
| } |
| { |
| let reply = Message::<ReplyHeader>::try_from( |
| Owned::<MESSAGE_ALIGN>::copy_from_slice(&entry.reply), |
| ) |
| .map_err(|_| ClientTableWireError::InvalidReply)?; |
| let header = reply.header(); |
| if header.client != entry.client |
| || header.request > entry.watermark |
| || entry.watermark - header.request >= COMMITTED_WINDOW_BITS |
| || entry.committed_window & (1_u128 << (entry.watermark - header.request)) == 0 |
| || header.commit != entry.latest_commit |
| || header.commit == 0 |
| || header.size as usize != entry.reply.len() |
| || !is_partition_receipt_operation(header.operation) |
| { |
| return Err(ClientTableWireError::InvalidReply); |
| } |
| restored.ring.push_back(CachedReply::from(reply)); |
| } |
| replacement.slots.push(Some(restored)); |
| } |
| *self = replacement; |
| Ok(()) |
| } |
| |
| /// Every entry ascending by client: the deterministic form a wire encoding |
| /// needs. |
| #[must_use] |
| pub fn watermarks_sorted(&self) -> Vec<DedupWatermark> { |
| let mut entries: Vec<DedupWatermark> = self |
| .slots |
| .iter() |
| .flatten() |
| .map(|entry| DedupWatermark { |
| client: entry.client_id, |
| user_id: entry.user_id, |
| watermark: entry.watermark, |
| latest_commit: entry.latest_commit, |
| committed_window: entry.committed_window, |
| session: entry.epoch, |
| reply: entry |
| .ring |
| .back() |
| .map_or_else(Vec::new, |reply| reply.as_bytes().to_vec()), |
| }) |
| .collect(); |
| entries.sort_unstable_by_key(|entry| entry.client); |
| entries |
| } |
| |
| /// Resolve ownership for an exact epoch, including a retained Logout replay. |
| #[must_use] |
| pub fn user_id_for_session(&self, client_id: u128, session: u64) -> Option<u32> { |
| let &slot = self.index.get(&client_id)?; |
| let entry = self.slots[slot].as_ref()?; |
| (entry.epoch == session).then_some(entry.user_id) |
| } |
| |
| /// Lowest free slot, growing the array when slots are allocated lazily. |
| /// Assignment is identical to the preallocated case: both hand out the |
| /// lowest free index, so slot identities and wire encoding do not |
| /// depend on the mode. |
| /// |
| /// The hole scan runs only when a hole exists (`index.len()` is the |
| /// occupied count): a lazily grown table below its cap has none, and |
| /// scanning it before every push would make the fill quadratic on the |
| /// commit path. |
| fn first_free_slot(&mut self) -> Option<usize> { |
| if self.index.len() < self.slots.len() |
| && let Some(index) = self.slots.iter().position(Option::is_none) |
| { |
| return Some(index); |
| } |
| (self.slots.len() < self.clients_max).then(|| { |
| self.slots.push(None); |
| self.slots.len() - 1 |
| }) |
| } |
| |
| /// Latest cached reply for a client. |
| /// |
| /// Borrow avoids Arc bump for header-only inspection. Wire-senders |
| /// `.clone()` (Arc bump) then `.into_wire_bytes()`. |
| #[must_use] |
| pub fn get_reply(&self, client_id: u128) -> Option<&CachedReply> { |
| let &slot_idx = self.index.get(&client_id)?; |
| self.slots[slot_idx].as_ref().map(ClientEntry::latest) |
| } |
| |
| /// Fence epoch for a registered client. This is the u64 the register |
| /// reply hands the client and the wire `session` field carries back. |
| #[must_use] |
| pub fn get_epoch(&self, client_id: u128) -> Option<u64> { |
| let &slot_idx = self.index.get(&client_id)?; |
| self.slots[slot_idx] |
| .as_ref() |
| .filter(|entry| entry.ended_op.is_none()) |
| .map(|entry| entry.epoch) |
| } |
| |
| /// Attach only after the caller has authenticated `user_id` and waited |
| /// for the local metadata frontier to cover the requested session. |
| /// The registered user owns the session, matching authenticated login |
| /// resume. Client ids and epochs are identifiers, not authentication secrets. |
| pub fn attach_session( |
| &mut self, |
| client_id: u128, |
| session: u64, |
| user_id: u32, |
| ) -> Option<SessionAttachment> { |
| let &slot_idx = self.index.get(&client_id)?; |
| let entry = self.slots[slot_idx].as_mut()?; |
| if entry.epoch != session |
| || entry.user_id != user_id |
| || session == 0 |
| || entry.ended_op.is_some() |
| { |
| return None; |
| } |
| let attachment = entry.attachment.get_or_insert_with(|| Arc::new(())); |
| Some(SessionAttachment { |
| session: Arc::downgrade(attachment), |
| }) |
| } |
| |
| /// Every registered client id, in slot order. |
| pub fn client_ids(&self) -> impl Iterator<Item = u128> + '_ { |
| self.slots |
| .iter() |
| .filter_map(|slot| slot.as_ref().map(|entry| entry.client_id)) |
| } |
| |
| /// Committed-request watermark for a registered client. |
| /// |
| /// NOT yet surfaced to clients: `LoginRegisterResponse` carries only |
| /// `{user_id, session, server_protocol_version, server_version}` and |
| /// `ReplyHeader.context` is hardcoded `0`, so there is no channel for it. |
| /// Until one exists, a client that restarts and resumes numbering from |
| /// below this value has those requests answered as duplicates |
| /// ([`RequestStatus::Duplicate`] / [`RequestStatus::AlreadyApplied`]) |
| /// rather than executed. Returning it on (re)bind is the missing half of |
| /// SDK-side resume; used by tests and recovery assertions today. |
| #[must_use] |
| pub fn get_watermark(&self, client_id: u128) -> Option<u64> { |
| let &slot_idx = self.index.get(&client_id)?; |
| self.slots[slot_idx].as_ref().map(|entry| entry.watermark) |
| } |
| |
| /// Acting user id captured when the client registered. |
| #[must_use] |
| pub fn get_user_id(&self, client_id: u128) -> Option<u32> { |
| let &slot_idx = self.index.get(&client_id)?; |
| self.slots[slot_idx].as_ref().map(|entry| entry.user_id) |
| } |
| |
| /// Active committed entries. |
| #[must_use] |
| pub fn count(&self) -> usize { |
| self.index.len() |
| } |
| } |
| |
| /// Failure decoding the state-transfer WIRE encoding of a client table |
| /// ([`ClientTable::encode`] / [`ClientTable::decode`]). |
| /// |
| /// Distinct from [`ClientTableDecodeError`], which covers the msgpack |
| /// CHECKPOINT encoding read off local disk. The two formats validate the same |
| /// invariants against differently-trusted inputs: a checkpoint is this node's |
| /// own bytes, while these arrive from a peer. |
| #[derive(Debug)] |
| pub enum ClientTableWireError { |
| /// Byte stream ended mid-field. |
| Truncated, |
| /// Leading magic is not [`CLIENT_TABLE_MAGIC`]. |
| BadMagic, |
| /// Trailing hash does not match the content. |
| ChecksumMismatch { |
| expected: u64, |
| actual: u64, |
| }, |
| /// Encoded entry count exceeds [`CLIENTS_TABLE_SLOT_MAX`], the allocation |
| /// ceiling no valid table can reach. |
| TooManyEntries { |
| count: u32, |
| max: usize, |
| }, |
| /// A cached reply's bytes do not parse as a valid reply message. |
| InvalidReply, |
| InvalidWatermark { |
| client_id: u128, |
| }, |
| InvalidCapacity { |
| capacity: usize, |
| }, |
| /// An entry carries an empty reply ring (violates the never-empty |
| /// invariant registration establishes). |
| EmptyRing, |
| /// Two entries claim the same `client_id`. Indexing them would leave one |
| /// slot occupied but unindexed, which desynchronizes the capacity check in |
| /// [`ClientTable::commit_register`] from the actual occupancy. |
| DuplicateClientId { |
| slot: usize, |
| client_id: u128, |
| }, |
| /// A reply ring longer than [`REPLY_RING_CAPACITY`], which is every reply |
| /// `transferable_replies` ever writes. `encode` writes the length as a |
| /// `u8`, and a peer that sends more replies than this crate transfers is |
| /// reporting state this one cannot have produced. |
| RingTooLong { |
| slot: usize, |
| len: u8, |
| max: usize, |
| }, |
| } |
| |
| impl std::fmt::Display for ClientTableWireError { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| match self { |
| Self::Truncated => write!(f, "encoded client table truncated"), |
| Self::BadMagic => write!(f, "encoded client table has wrong magic"), |
| Self::ChecksumMismatch { expected, actual } => write!( |
| f, |
| "client table checksum mismatch: expected {expected:#018x}, actual {actual:#018x}" |
| ), |
| Self::TooManyEntries { count, max } => { |
| write!(f, "encoded client table holds {count} entries, max {max}") |
| } |
| Self::InvalidReply => write!(f, "encoded client table holds an invalid cached reply"), |
| Self::InvalidWatermark { client_id } => { |
| write!(f, "invalid committed watermark for client {client_id}") |
| } |
| Self::InvalidCapacity { capacity } => write!( |
| f, |
| "invalid or conflicting committed retry capacity {capacity}" |
| ), |
| Self::EmptyRing => write!(f, "encoded client table entry has an empty reply ring"), |
| Self::DuplicateClientId { slot, client_id } => write!( |
| f, |
| "encoded client table repeats client {client_id} at entry {slot}" |
| ), |
| Self::RingTooLong { slot, len, max } => write!( |
| f, |
| "encoded client table entry {slot} has a {len}-reply ring, max {max}" |
| ), |
| } |
| } |
| } |
| |
| impl std::error::Error for ClientTableWireError {} |
| |
| impl From<Truncated> for ClientTableWireError { |
| fn from(_: Truncated) -> Self { |
| Self::Truncated |
| } |
| } |
| |
| /// Format tag for [`ClientTable::encode`]; bump on layout change. |
| /// |
| /// That includes any `ReplyHeader` layout move -- cached replies are embedded |
| /// as raw wire bytes, so an artifact written under an older header layout must |
| /// be refused, not silently misread. `ICT2`: `status` sits at offset 216 (the |
| /// pre-`ICT2` layout carried a `namespace` word before it). |
| pub const CLIENT_TABLE_MAGIC: [u8; 4] = *b"ICT5"; |
| |
| /// Per-entry fixed fields in the wire encoding: `client(u128) epoch(u64) |
| /// user_id(u32) watermark(u64) bind_verifier([u8; 32]) ended_op(u64) ring_len(u8)`. |
| const ENCODED_ENTRY_FIXED_LEN: usize = size_of::<u128>() |
| + size_of::<u64>() |
| + size_of::<u32>() |
| + size_of::<u64>() |
| + size_of::<u8>() |
| + BIND_SECRET_BYTES |
| + size_of::<u64>(); |
| |
| impl ClientTable { |
| /// Encode committed capacity and session protection for state transfer. |
| #[must_use] |
| #[allow(clippy::cast_possible_truncation)] |
| pub fn encode(&self) -> Vec<u8> { |
| // Size exactly rather than guess: each cached reply is a full wire |
| // message, so at the default client cap a guessed reservation is off by |
| // orders of magnitude and costs several reallocs of a multi-MB buffer |
| // on the serving primary's pump, once per offer build. |
| let entries = self.slots.iter().flatten(); |
| let reserved = CLIENT_TABLE_MAGIC.len() |
| + 2 * size_of::<u32>() |
| + entries |
| .map(|entry| { |
| ENCODED_ENTRY_FIXED_LEN |
| + entry |
| .transferable_replies() |
| .map(|reply| size_of::<u32>() + reply.bytes.len()) |
| .sum::<usize>() |
| }) |
| .sum::<usize>() |
| + size_of::<u64>(); |
| let mut out = Vec::with_capacity(reserved); |
| out.extend_from_slice(&CLIENT_TABLE_MAGIC); |
| out.extend_from_slice(&(self.clients_max as u32).to_le_bytes()); |
| out.extend_from_slice(&(self.index.len() as u32).to_le_bytes()); |
| for (slot_idx, slot) in self.slots.iter().enumerate() { |
| let Some(entry) = slot else { continue }; |
| debug_assert_eq!(self.index.get(&entry.client_id), Some(&slot_idx)); |
| out.extend_from_slice(&entry.client_id.to_le_bytes()); |
| out.extend_from_slice(&entry.epoch.to_le_bytes()); |
| out.extend_from_slice(&entry.user_id.to_le_bytes()); |
| out.extend_from_slice(&entry.watermark.to_le_bytes()); |
| out.extend_from_slice(&entry.bind_verifier); |
| out.extend_from_slice(&entry.ended_op.unwrap_or(0).to_le_bytes()); |
| out.push(entry.transferable_replies().count() as u8); |
| for reply in entry.transferable_replies() { |
| let bytes = reply.bytes.as_slice(); |
| out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); |
| out.extend_from_slice(bytes); |
| } |
| } |
| debug_assert_eq!(out.len() + size_of::<u64>(), reserved, "encode reservation"); |
| let trailer = crate::state_manifest::state_artifact_checksum(&out); |
| out.extend_from_slice(&trailer.to_le_bytes()); |
| out |
| } |
| |
| /// Decode the current format, retaining its committed capacity. |
| /// |
| /// # Errors |
| /// Refuses corrupt, truncated, unsupported, or internally inconsistent protection. |
| pub fn decode(bytes: &[u8]) -> Result<Self, ClientTableWireError> { |
| let content = split_verified_trailer(bytes).map_err(|mismatch| match mismatch { |
| Some((expected, actual)) => ClientTableWireError::ChecksumMismatch { expected, actual }, |
| None => ClientTableWireError::Truncated, |
| })?; |
| |
| let mut reader = LeCursor::new(content); |
| let magic = reader.take(CLIENT_TABLE_MAGIC.len())?; |
| if magic != CLIENT_TABLE_MAGIC { |
| return Err(ClientTableWireError::BadMagic); |
| } |
| let capacity = reader.u32()? as usize; |
| let count = reader.u32()?; |
| // Bound the allocation on the same slot ceiling `from_snapshot` uses; |
| // `count` is a peer-supplied u32 and this is the only check between it |
| // and `Self::new`'s eager Vec resize. |
| if count as usize > capacity || capacity > CLIENTS_TABLE_SLOT_MAX { |
| return Err(ClientTableWireError::TooManyEntries { |
| count, |
| max: capacity.min(CLIENTS_TABLE_SLOT_MAX), |
| }); |
| } |
| |
| let mut table = Self::new(capacity); |
| table.commit_capacity(capacity)?; |
| for slot_idx in 0..count as usize { |
| let client_id = reader.u128()?; |
| let epoch = reader.u64()?; |
| let user_id = reader.u32()?; |
| let watermark = reader.u64()?; |
| let bind_verifier = reader |
| .take(BIND_SECRET_BYTES)? |
| .try_into() |
| .map_err(|_| ClientTableWireError::Truncated)?; |
| let ended_op = match reader.u64()? { |
| 0 => None, |
| op => Some(op), |
| }; |
| let ring_len = reader.u8()?; |
| if ring_len == 0 { |
| return Err(ClientTableWireError::EmptyRing); |
| } |
| // The artifact checksum only proves the bytes survived transit; it |
| // says nothing about the peer that computed them. Bounding at what |
| // `transferable_replies` emits keeps an installed ring inside the |
| // unconditional floor, so it satisfies `trim_ring`'s byte budget on |
| // arrival and needs no trimming of its own. |
| if usize::from(ring_len) > REPLY_RING_CAPACITY { |
| return Err(ClientTableWireError::RingTooLong { |
| slot: slot_idx, |
| len: ring_len, |
| max: REPLY_RING_CAPACITY, |
| }); |
| } |
| let mut ring = VecDeque::with_capacity(REPLY_RING_CAPACITY); |
| for _ in 0..ring_len { |
| let reply_len = reader.u32()? as usize; |
| let reply_bytes = reader.take(reply_len)?; |
| let owned = Owned::<MESSAGE_ALIGN>::copy_from_slice(reply_bytes); |
| let message = Message::<GenericHeader>::try_from(owned) |
| .map_err(|_| ClientTableWireError::InvalidReply)? |
| .try_into_typed::<ReplyHeader>() |
| .map_err(|_| ClientTableWireError::InvalidReply)?; |
| ring.push_back(CachedReply::from(message)); |
| } |
| let latest_commit = ring |
| .back() |
| .ok_or(ClientTableWireError::EmptyRing)? |
| .header() |
| .commit; |
| let restored = ClientEntry { |
| bind_verifier, |
| ended_op, |
| epoch, |
| attachment: None, |
| user_id, |
| watermark, |
| committed_window: 0, |
| ring, |
| client_id, |
| latest_commit, |
| }; |
| if !restored.valid_metadata_protection() { |
| return Err(ClientTableWireError::InvalidWatermark { client_id }); |
| } |
| table.slots[slot_idx] = Some(restored); |
| // Reject rather than overwrite, as the checkpoint decoder does. An |
| // overwrite would leave an occupied slot outside the index, breaking |
| // capacity accounting and session lookup. |
| if let Some(first_slot) = table.index.insert(client_id, slot_idx) { |
| return Err(ClientTableWireError::DuplicateClientId { |
| slot: first_slot, |
| client_id, |
| }); |
| } |
| } |
| if !reader.remaining().is_empty() { |
| return Err(ClientTableWireError::Truncated); |
| } |
| Ok(table) |
| } |
| |
| /// Protection slots fixed by the first committed prepare's capacity. |
| /// Recovery and transfer preserve that limit over local configuration. |
| #[must_use] |
| pub const fn capacity(&self) -> usize { |
| self.clients_max |
| } |
| } |
| |
| impl ClientEntry { |
| /// Partition identity initialized before its session and receipt are installed. |
| /// Bit 0 is always set because the watermark itself is committed. |
| const fn watermark_only( |
| client_id: u128, |
| user_id: u32, |
| watermark: u64, |
| committed_window: u128, |
| commit_op: u64, |
| ) -> Self { |
| Self { |
| bind_verifier: [0; 32], |
| ended_op: None, |
| epoch: 0, |
| attachment: None, |
| user_id, |
| watermark, |
| committed_window: committed_window | 1, |
| ring: VecDeque::new(), |
| client_id, |
| latest_commit: commit_op, |
| } |
| } |
| |
| const fn check_slice_request(&self, request: u64) -> SliceRequestStatus { |
| if request > self.watermark { |
| return SliceRequestStatus::New; |
| } |
| let below = self.watermark - request; |
| if below >= COMMITTED_WINDOW_BITS { |
| return SliceRequestStatus::AgedOut; |
| } |
| if self.committed_window & (1 << below) == 0 { |
| SliceRequestStatus::New |
| } else { |
| SliceRequestStatus::Committed |
| } |
| } |
| |
| fn valid_metadata_protection(&self) -> bool { |
| if self.client_id == 0 || self.epoch == 0 || self.latest_commit < self.epoch { |
| return false; |
| } |
| let Some(latest) = self.ring.back() else { |
| return false; |
| }; |
| let header = latest.header(); |
| if header.request != self.watermark |
| || header.commit != self.latest_commit |
| || self.ended_op.is_some_and(|ended| { |
| ended < self.latest_commit |
| || (ended == self.latest_commit |
| && !matches!( |
| header.operation, |
| Operation::Logout | Operation::UpdateUser | Operation::DeleteUser |
| )) |
| }) |
| { |
| return false; |
| } |
| let mut previous = None; |
| for receipt in &self.ring { |
| let header = receipt.header(); |
| if header.client != self.client_id |
| || header.commit < self.epoch |
| || header.request > self.watermark |
| || (header.request == REGISTER_REQUEST_ID |
| && (header.operation != iggy_binary_protocol::Operation::Register |
| || header.commit != self.epoch)) |
| || (header.request != REGISTER_REQUEST_ID |
| && header.operation == iggy_binary_protocol::Operation::Register) |
| || previous.is_some_and(|(request, commit)| { |
| header.request <= request || header.commit <= commit |
| }) |
| { |
| return false; |
| } |
| previous = Some((header.request, header.commit)); |
| } |
| true |
| } |
| |
| /// Latest committed reply (register or app op). |
| /// |
| /// # Panics |
| /// Unreachable: registration seeds the ring and pops happen only when |
| /// displaced by a newer push. |
| fn latest(&self) -> &CachedReply { |
| self.ring |
| .back() |
| .expect("ring is never empty after registration") |
| } |
| |
| /// The newest replies a state transfer carries, oldest first. |
| /// |
| /// Capped at [`REPLY_RING_CAPACITY`] rather than shipping whatever |
| /// retention holds locally: an artifact a recovering node has to fetch is |
| /// worth keeping small, and the deeper history rebuilds itself from the |
| /// receiver's own commits. The cost is a late retrier's result bytes on the |
| /// transferred node, never its at-most-once fence, which is the bound |
| /// [`REPLY_RING_CAPACITY`] documents. |
| fn transferable_replies(&self) -> impl Iterator<Item = &CachedReply> { |
| self.ring |
| .iter() |
| .skip(self.ring.len().saturating_sub(REPLY_RING_CAPACITY)) |
| } |
| |
| /// Cached reply whose `request` matches (scan order is irrelevant |
| /// because request numbers in the ring are unique). |
| fn find_cached(&self, request: u64) -> Option<&CachedReply> { |
| self.ring |
| .iter() |
| .find(|cached| cached.header().request == request) |
| } |
| |
| /// Push the newest committed reply, drop the oldest ones retention no |
| /// longer covers, and refresh the denormalized `latest_commit`. |
| fn push_latest(&mut self, cached: CachedReply) { |
| self.latest_commit = cached.header().commit; |
| self.ring.push_back(cached); |
| self.trim_ring(); |
| } |
| |
| /// Drop the oldest replies once the entry holds more than |
| /// [`REPLY_RING_CAPACITY`] and exceeds [`REPLY_RING_RETENTION_BYTES`]. |
| /// |
| /// The total is summed here rather than denormalized onto the entry: no |
| /// reply is shorter than a header, so the budget holds the ring to 32 |
| /// entries and this is a bounded walk of buffer lengths with no header |
| /// casts, and it leaves no running total for the sites that write the ring |
| /// to drift out of sync with. |
| fn trim_ring(&mut self) { |
| let mut bytes: usize = self.ring.iter().map(CachedReply::byte_len).sum(); |
| while self.ring.len() > REPLY_RING_CAPACITY && bytes > REPLY_RING_RETENTION_BYTES { |
| let dropped = self.ring.pop_front().expect("length checked above"); |
| bytes -= dropped.byte_len(); |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| #[allow(clippy::cast_possible_truncation)] |
| mod tests { |
| use super::*; |
| use iggy_binary_protocol::{Command, Operation}; |
| |
| /// Arbitrary non-zero user id for register fixtures; most tests don't |
| /// assert on it (see `register_stores_user_id` for the accessor check). |
| const TEST_USER_ID: u32 = 7; |
| |
| #[allow(clippy::cast_possible_truncation)] |
| fn make_register_reply(client: u128, commit: u64) -> Message<ReplyHeader> { |
| let header_size = std::mem::size_of::<ReplyHeader>(); |
| let mut msg = Message::<ReplyHeader>::new(header_size); |
| let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>( |
| &mut msg.as_mut_slice()[..header_size], |
| ) |
| .expect("zeroed bytes are valid"); |
| *header = ReplyHeader { |
| client, |
| request: REGISTER_REQUEST_ID, |
| commit, |
| // Real size so codec-roundtripped replies re-parse. |
| size: header_size as u32, |
| command: Command::Reply, |
| operation: Operation::Register, |
| ..ReplyHeader::default() |
| }; |
| msg |
| } |
| |
| fn make_reply_for(client: u128, request: u64, commit: u64) -> Message<ReplyHeader> { |
| make_reply_with_checksum(client, request, commit, 0) |
| } |
| |
| /// A reply heavy enough that two of them exhaust |
| /// [`REPLY_RING_RETENTION_BYTES`], so only the floor keeps it cached. |
| #[allow(clippy::cast_possible_truncation)] |
| fn make_big_reply(client: u128, request: u64, commit: u64) -> Message<ReplyHeader> { |
| let header_size = std::mem::size_of::<ReplyHeader>(); |
| let size = header_size + REPLY_RING_RETENTION_BYTES; |
| let mut msg = Message::<ReplyHeader>::new(size); |
| let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>( |
| &mut msg.as_mut_slice()[..header_size], |
| ) |
| .expect("zeroed bytes are valid"); |
| *header = ReplyHeader { |
| client, |
| request, |
| commit, |
| size: size as u32, |
| command: Command::Reply, |
| operation: Operation::SendMessages, |
| ..ReplyHeader::default() |
| }; |
| msg |
| } |
| |
| #[allow(clippy::cast_possible_truncation)] |
| fn make_reply_with_checksum( |
| client: u128, |
| request: u64, |
| commit: u64, |
| request_checksum: u128, |
| ) -> Message<ReplyHeader> { |
| let header_size = std::mem::size_of::<ReplyHeader>(); |
| let mut msg = Message::<ReplyHeader>::new(header_size); |
| let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>( |
| &mut msg.as_mut_slice()[..header_size], |
| ) |
| .expect("zeroed bytes are valid"); |
| *header = ReplyHeader { |
| client, |
| request, |
| commit, |
| request_checksum, |
| // Real size so codec-roundtripped replies re-parse. |
| size: header_size as u32, |
| command: Command::Reply, |
| operation: Operation::SendMessages, |
| ..ReplyHeader::default() |
| }; |
| msg |
| } |
| |
| #[test] |
| fn to_from_snapshot_round_trips_epochs_and_watermarks() { |
| let mut table = ClientTable::new(8); |
| table |
| .commit_register(1, 11, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| table |
| .commit_register(2, 22, [0x5a; 32], make_register_reply(2, 20)) |
| .unwrap(); |
| // Client 1 committed request 5; its reply is the entry's latest. |
| table.commit_reply(1, 11, make_reply_with_checksum(1, 5, 30, 0xbeef)); |
| |
| let restored = ClientTable::from_snapshot(table.to_snapshot()).unwrap(); |
| |
| // Fences and dedup history survive, and the index is rebuilt (every |
| // accessor reads through it). |
| assert_eq!(restored.get_epoch(1), Some(10)); |
| assert_eq!(restored.get_epoch(2), Some(20)); |
| assert_eq!(restored.get_watermark(1), Some(5)); |
| assert_eq!(restored.get_watermark(2), Some(0)); |
| assert_eq!(restored.get_user_id(1), Some(11)); |
| // Replaying request 5 is a dedup hit, not a re-execution: at-most-once |
| // holds across a restart, and the original bytes still answer it. |
| match restored.check_request(1, 10, 5, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => assert_eq!(cached.header().request, 5), |
| other => panic!("expected Duplicate, got {other:?}"), |
| } |
| assert!(matches!( |
| restored.check_request(1, 10, 5, Operation::StoreConsumerOffset), |
| RequestStatus::OperationMismatch { request: 5 } |
| )); |
| // A zombie holding the pre-restart epoch of a since-rebound client is |
| // still fenced, so the fence is not weakened by the round trip. |
| assert!(matches!( |
| restored.check_request(1, 9, 6, Operation::SendMessages), |
| RequestStatus::Fenced { |
| current: 10, |
| received: 9 |
| } |
| )); |
| // A client that never registered is still unknown. |
| assert!(matches!( |
| restored.check_request(3, 1, 1, Operation::SendMessages), |
| RequestStatus::NoSession |
| )); |
| } |
| |
| // Only the entry's latest reply is persisted, so a retransmit of an older |
| // ring entry is refused execution rather than answered from cache. |
| #[test] |
| fn snapshot_drops_stale_ring_replies_but_keeps_at_most_once() { |
| let mut table = ClientTable::new(4); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 5, 20)); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 6, 21)); |
| |
| let restored = ClientTable::from_snapshot(table.to_snapshot()).unwrap(); |
| |
| assert!(matches!( |
| restored.check_request(1, 10, 5, Operation::SendMessages), |
| RequestStatus::AlreadyApplied { |
| request: 5, |
| watermark: 6 |
| } |
| )); |
| } |
| |
| #[test] |
| fn from_snapshot_preserves_committed_capacity_and_slot_identity() { |
| let mut table = ClientTable::new(8); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| let mut snapshot = table.to_snapshot(); |
| snapshot.slots[0].0 = 5; |
| |
| let restored = ClientTable::from_snapshot(snapshot).unwrap(); |
| assert_eq!(restored.capacity(), 8); |
| assert_eq!( |
| restored.get_epoch(1), |
| Some(10), |
| "the committed slot identity must survive recovery" |
| ); |
| } |
| |
| #[test] |
| fn from_snapshot_rejects_two_entries_in_one_slot() { |
| let mut table = ClientTable::new(2); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| table |
| .commit_register(2, TEST_USER_ID, [0x5a; 32], make_register_reply(2, 20)) |
| .unwrap(); |
| let mut snapshot = table.to_snapshot(); |
| snapshot.slots[1].0 = snapshot.slots[0].0; |
| |
| assert!(matches!( |
| ClientTable::from_snapshot(snapshot), |
| Err(ClientTableDecodeError::DuplicateSlot { slot: 0 }) |
| )); |
| } |
| |
| #[test] |
| fn from_snapshot_rejects_a_slot_index_past_the_ceiling() { |
| // The capacity is allocated from this index, so an out-of-range one must be |
| // refused before the allocation rather than sized from. |
| let mut table = ClientTable::new(2); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| let mut snapshot = table.to_snapshot(); |
| snapshot.slots[0].0 = u32::MAX; |
| |
| assert!(matches!( |
| ClientTable::from_snapshot(snapshot), |
| Err(ClientTableDecodeError::SlotOutOfRange { .. }) |
| )); |
| } |
| |
| #[test] |
| fn from_snapshot_rejects_duplicate_client_ids() { |
| let mut table = ClientTable::new(2); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| table |
| .commit_register(2, TEST_USER_ID, [0x5a; 32], make_register_reply(2, 20)) |
| .unwrap(); |
| let mut snapshot = table.to_snapshot(); |
| snapshot.slots[1].1 = snapshot.slots[0].1.clone(); |
| |
| assert!(matches!( |
| ClientTable::from_snapshot(snapshot), |
| Err(ClientTableDecodeError::DuplicateClientId { |
| slot: 1, |
| first_slot: 0, |
| client_id: 1 |
| }) |
| )); |
| } |
| |
| #[test] |
| fn from_snapshot_rejects_invalid_reply_bytes() { |
| let mut table = ClientTable::new(2); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| let mut snapshot = table.to_snapshot(); |
| snapshot.slots[0].1.reply = vec![0xff; 8]; |
| |
| assert!(matches!( |
| ClientTable::from_snapshot(snapshot), |
| Err(ClientTableDecodeError::InvalidReply { slot: 0, .. }) |
| )); |
| } |
| |
| #[test] |
| fn recovered_registry_refuses_inconsistent_session_protection() { |
| let mut table = ClientTable::new(2); |
| table |
| .commit_register( |
| 1, |
| TEST_USER_ID, |
| [0x5a; BIND_SECRET_BYTES], |
| make_register_reply(1, 10), |
| ) |
| .unwrap(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_with_checksum(1, 5, 30, 0xbeef)); |
| let valid = table.to_snapshot(); |
| for corruption in [ |
| "zero epoch", |
| "future epoch", |
| "watermark", |
| "end marker", |
| "regressing end marker", |
| "foreign receipt", |
| ] { |
| let mut snapshot = valid.clone(); |
| let entry = &mut snapshot.slots[0].1; |
| match corruption { |
| "zero epoch" => entry.epoch = 0, |
| "future epoch" => entry.epoch = 31, |
| "watermark" => entry.watermark = 6, |
| "end marker" => entry.ended_op = Some(30), |
| "regressing end marker" => entry.ended_op = Some(29), |
| "foreign receipt" => { |
| entry.reply = make_reply_with_checksum(2, 5, 30, 0xbeef) |
| .as_slice() |
| .to_vec(); |
| } |
| _ => unreachable!(), |
| } |
| assert!( |
| matches!( |
| ClientTable::from_snapshot(snapshot), |
| Err(ClientTableDecodeError::InvalidEntry { .. }) |
| ), |
| "accepted registry corruption: {corruption}" |
| ); |
| } |
| assert!(ClientTable::from_snapshot(valid).is_ok()); |
| } |
| |
| #[test] |
| fn given_revoked_user_when_recovered_should_preserve_tombstones_and_receipts() { |
| const CLIENT: u128 = 7; |
| const EPOCH: u64 = 10; |
| const END: u64 = 40; |
| let secret = [0x5a; BIND_SECRET_BYTES]; |
| for self_revocation in [ |
| None, |
| Some(Operation::UpdateUser), |
| Some(Operation::DeleteUser), |
| ] { |
| let mut table = ClientTable::new(1); |
| table |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID, |
| bind_verifier(CLIENT, TEST_USER_ID, &secret), |
| make_register_reply(CLIENT, EPOCH), |
| ) |
| .unwrap(); |
| let attachment = table.attach_session(CLIENT, EPOCH, TEST_USER_ID).unwrap(); |
| let mut receipt = make_reply_for(CLIENT, 5, END - 1); |
| assert_eq!( |
| table.commit_reply(CLIENT, TEST_USER_ID, receipt.clone()), |
| CommitReply::Cached |
| ); |
| let ended = table.end_user_sessions(TEST_USER_ID, END); |
| assert_eq!(ended.len(), 1); |
| if let Some(operation) = self_revocation { |
| receipt = make_reply_for(CLIENT, 6, END).transmute_header(|old, header| { |
| *header = old; |
| header.operation = operation; |
| }); |
| assert_eq!( |
| table.commit_reply(CLIENT, TEST_USER_ID, receipt.clone()), |
| CommitReply::Cached |
| ); |
| } |
| assert!(!attachment.is_valid()); |
| for mut restored in [ |
| ClientTable::from_snapshot(table.to_snapshot()).unwrap(), |
| ClientTable::decode(&table.encode()).unwrap(), |
| ] { |
| assert_eq!(restored.ended_sessions().collect::<Vec<_>>(), ended); |
| assert_eq!( |
| restored.get_reply(CLIENT).unwrap().as_bytes(), |
| receipt.as_slice() |
| ); |
| assert!(restored.bind_session(CLIENT, EPOCH, &secret).is_err()); |
| assert!(matches!( |
| restored.check_request(CLIENT, EPOCH, 7, Operation::SendMessages), |
| RequestStatus::NoSession |
| )); |
| assert!(restored.finalize_session(ended[0])); |
| assert_eq!(restored.count(), 0); |
| } |
| } |
| } |
| |
| /// Register client 1 (register commit stamped at op 10). Returns |
| /// (table, epoch=1). |
| fn table_with_client() -> (ClientTable, u64) { |
| let mut table = ClientTable::new(10); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| let epoch = table.get_epoch(1).expect("just registered"); |
| (table, epoch) |
| } |
| |
| // Registration tests |
| |
| #[test] |
| fn register_epoch_is_the_register_commit_op() { |
| let mut table = ClientTable::new(10); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 42)) |
| .unwrap(); |
| assert_eq!(table.get_epoch(1), Some(42)); |
| assert_eq!(table.get_watermark(1), Some(0)); |
| assert_eq!(table.get_user_id(1), Some(TEST_USER_ID)); |
| assert_eq!(table.count(), 1); |
| } |
| |
| // Each entry keeps the user id it registered with; lookups are per-client. |
| #[test] |
| fn register_stores_user_id() { |
| let mut table = ClientTable::new(10); |
| table |
| .commit_register(1, 11, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| table |
| .commit_register(2, 22, [0x5a; 32], make_register_reply(2, 20)) |
| .unwrap(); |
| assert_eq!(table.get_user_id(1), Some(11)); |
| assert_eq!(table.get_user_id(2), Some(22)); |
| assert_eq!( |
| table.get_user_id(3), |
| None, |
| "unregistered client has no user" |
| ); |
| } |
| |
| // Epoch fence tests |
| |
| #[test] |
| fn check_request_no_session() { |
| let table = ClientTable::new(10); |
| // Not registered: valid epoch/request but no entry. |
| assert!(matches!( |
| table.check_request(1, 99, 1, Operation::SendMessages), |
| RequestStatus::NoSession |
| )); |
| } |
| |
| // Epochs are only handed out by register replies; a newer-than-minted |
| // epoch is a client bug, distinct from the zombie case. |
| #[test] |
| fn check_request_future_epoch_is_client_bug() { |
| let (table, epoch) = table_with_client(); |
| match table.check_request(1, epoch + 1, 1, Operation::SendMessages) { |
| RequestStatus::EpochAhead { current, received } => { |
| assert_eq!(current, epoch); |
| assert_eq!(received, epoch + 1); |
| } |
| other => panic!("expected EpochAhead, got {other:?}"), |
| } |
| } |
| |
| // Watermark tests |
| |
| #[test] |
| fn check_request_above_watermark_is_new() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| assert!(matches!( |
| table.check_request(1, epoch, 2, Operation::SendMessages), |
| RequestStatus::New |
| )); |
| } |
| |
| // No contiguity requirement: a jump past the watermark executes. The |
| // watermark records the highest committed request, not a sequence. |
| #[test] |
| fn check_request_jump_above_watermark_is_new() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| assert!(matches!( |
| table.check_request(1, epoch, 9, Operation::SendMessages), |
| RequestStatus::New |
| )); |
| // And committing the jump moves the watermark to it. |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 9, 12)); |
| assert_eq!(table.get_watermark(1), Some(9)); |
| } |
| |
| // The shape a client that spends request ids off the metadata plane |
| // produces: partition-plane ids never reach this table, so the next |
| // metadata request arrives with a gap under it. It executes, moves the |
| // watermark to itself, and its retry still replays the original reply -- |
| // gaps cost the skipped ids and nothing else. |
| #[test] |
| fn check_request_dedups_a_metadata_request_that_arrives_after_a_gap() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| // Requests 2..=5 went to the partition plane, which keeps no table. |
| assert!(matches!( |
| table.check_request(1, epoch, 6, Operation::SendMessages), |
| RequestStatus::New |
| )); |
| |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 6, 12)); |
| assert_eq!(table.get_watermark(1), Some(6)); |
| match table.check_request(1, epoch, 6, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => { |
| assert_eq!(cached.header().request, 6); |
| assert_eq!( |
| cached.header().commit, |
| 12, |
| "the original reply, not a re-run" |
| ); |
| } |
| other => panic!("expected the gapped request to dedup, got {other:?}"), |
| } |
| } |
| |
| #[test] |
| fn check_request_duplicate_at_watermark() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| match table.check_request(1, epoch, 1, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => assert_eq!(cached.header().request, 1), |
| other => panic!("expected Duplicate, got {other:?}"), |
| } |
| } |
| |
| // Below-watermark duplicate with the original still in the ring answers |
| // with the original bytes. |
| #[test] |
| fn check_request_below_watermark_hits_ring() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 2, 12)); |
| match table.check_request(1, epoch, 1, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => { |
| assert_eq!(cached.header().request, 1, "original reply, not latest"); |
| assert_eq!(cached.header().commit, 11, "original commit op"); |
| } |
| other => panic!("expected Duplicate from ring, got {other:?}"), |
| } |
| } |
| |
| // Below-watermark duplicate whose reply aged out of the ring is refused |
| // execution with nothing to replay. |
| #[test] |
| fn check_request_below_watermark_past_retention_is_already_applied() { |
| let (mut table, epoch) = table_with_client(); |
| // Enough small replies to exhaust the byte budget several times over, |
| // so the oldest are certain to have been dropped. |
| let requests = (REPLY_RING_RETENTION_BYTES / size_of::<ReplyHeader>() + 8) as u64; |
| for request in 1..=requests { |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, request, 10 + request)); |
| } |
| match table.check_request(1, epoch, 1, Operation::SendMessages) { |
| RequestStatus::AlreadyApplied { request, watermark } => { |
| assert_eq!(request, 1); |
| assert_eq!(watermark, requests); |
| } |
| other => panic!("expected AlreadyApplied, got {other:?}"), |
| } |
| // The newest still answers with its own bytes. |
| match table.check_request(1, epoch, requests, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => assert_eq!(cached.header().request, requests), |
| other => panic!("expected Duplicate, got {other:?}"), |
| } |
| } |
| |
| // A retry that arrives after more commits than the floor holds still gets |
| // its original bytes back: retention past the floor is budgeted in bytes, |
| // and small replies are what a late retrier usually has outstanding. |
| #[test] |
| fn a_late_retry_replays_while_the_retention_budget_holds_it() { |
| let (mut table, epoch) = table_with_client(); |
| let requests = REPLY_RING_CAPACITY as u64 + 2; |
| for request in 1..=requests { |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, request, 10 + request)); |
| } |
| |
| match table.check_request(1, epoch, 1, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => { |
| assert_eq!(cached.header().request, 1); |
| assert_eq!( |
| cached.header().commit, |
| 11, |
| "the original reply, not a re-run" |
| ); |
| } |
| other => panic!("expected the original reply to replay, got {other:?}"), |
| } |
| } |
| |
| // Heavy replies stay bounded by the floor, so the deeper retention cannot |
| // be turned into a memory amplifier by a client polling large batches. |
| #[test] |
| fn heavy_replies_are_retained_only_to_the_floor() { |
| let (mut table, epoch) = table_with_client(); |
| let requests = REPLY_RING_CAPACITY as u64 + 2; |
| for request in 1..=requests { |
| table.commit_reply(1, TEST_USER_ID, make_big_reply(1, request, 10 + request)); |
| } |
| |
| // The floor counts the register reply out: it aged out first, leaving |
| // the last REPLY_RING_CAPACITY app replies. |
| let oldest_retained = requests - REPLY_RING_CAPACITY as u64 + 1; |
| assert!(matches!( |
| table.check_request(1, epoch, oldest_retained - 1, Operation::SendMessages), |
| RequestStatus::AlreadyApplied { .. } |
| )); |
| assert!(matches!( |
| table.check_request(1, epoch, oldest_retained, Operation::SendMessages), |
| RequestStatus::Duplicate(_) |
| )); |
| } |
| |
| // Dedup across view change. Backup inherits client_table via |
| // commit_journal; on failover, retry must return ORIGINAL cached reply |
| // (same request, same commit op), no re-execution. Pipeline state is |
| // on PipelineEntry, so view-change cleanup doesn't touch slots. |
| // Simulator test covers end-to-end; this is the unit invariant. |
| #[test] |
| fn duplicate_survives_view_change_reset() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| |
| match table.check_request(1, epoch, 1, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => { |
| assert_eq!(cached.header().client, 1, "original client_id"); |
| assert_eq!(cached.header().request, 1, "ORIGINAL request, not re-issue"); |
| assert_eq!( |
| cached.header().commit, |
| 11, |
| "ORIGINAL commit op (no re-exec)" |
| ); |
| } |
| other => panic!("expected Duplicate, got {other:?}"), |
| } |
| } |
| |
| #[test] |
| fn check_request_rejects_another_operation_at_watermark() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_with_checksum(1, 1, 11, 0xAA)); |
| match table.check_request(1, epoch, 1, Operation::StoreConsumerOffset) { |
| RequestStatus::OperationMismatch { request } => assert_eq!(request, 1), |
| other => panic!("expected OperationMismatch, got {other:?}"), |
| } |
| assert!(matches!( |
| table.check_request(1, epoch, 1, Operation::SendMessages), |
| RequestStatus::Duplicate(_) |
| )); |
| } |
| |
| #[test] |
| fn check_request_rejects_another_operation_below_watermark() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_with_checksum(1, 1, 11, 0xAA)); |
| table.commit_reply(1, TEST_USER_ID, make_reply_with_checksum(1, 2, 12, 0xBB)); |
| assert!(matches!( |
| table.check_request(1, epoch, 1, Operation::SendMessages), |
| RequestStatus::Duplicate(_) |
| )); |
| assert!(matches!( |
| table.check_request(1, epoch, 1, Operation::StoreConsumerOffset), |
| RequestStatus::OperationMismatch { request: 1 } |
| )); |
| } |
| |
| #[test] |
| fn check_request_replays_projected_operations_after_recovery() { |
| for (request_operation, prepared_operation) in [ |
| ( |
| Operation::CreateTopic, |
| Operation::CreateTopicWithAssignments, |
| ), |
| ( |
| Operation::CreatePartitions, |
| Operation::CreatePartitionsWithAssignments, |
| ), |
| (Operation::DeleteSegments, Operation::TruncatePartition), |
| ] { |
| let (mut table, epoch) = table_with_client(); |
| let reply = make_reply_for(1, 1, 11).transmute_header(|old, header| { |
| *header = old; |
| header.operation = prepared_operation; |
| }); |
| table.commit_reply(1, TEST_USER_ID, reply.clone()); |
| for restored in [ |
| ClientTable::from_snapshot(table.to_snapshot()).unwrap(), |
| ClientTable::decode(&table.encode()).unwrap(), |
| ] { |
| let RequestStatus::Duplicate(cached) = |
| restored.check_request(1, epoch, 1, request_operation) |
| else { |
| panic!("lost the receipt for {request_operation:?}"); |
| }; |
| assert_eq!(cached.into_message().as_slice(), reply.as_slice()); |
| assert!(matches!( |
| restored.check_request(1, epoch, 1, Operation::UpdateStream), |
| RequestStatus::OperationMismatch { .. } |
| )); |
| } |
| } |
| } |
| |
| // Commit tests |
| |
| #[test] |
| fn commit_caches_reply() { |
| let (mut table, _epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| let cached = table.get_reply(1).expect("should have cached reply"); |
| assert_eq!(cached.header().request, 1); |
| } |
| |
| #[test] |
| fn commit_updates_preserves_epoch() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 2, 12)); |
| assert_eq!(table.get_reply(1).unwrap().header().request, 2); |
| assert_eq!(table.get_epoch(1), Some(epoch)); |
| assert_eq!(table.count(), 1); |
| } |
| |
| // Same request re-committed (WAL replay shape): replace in place, no |
| // ring push - two cached replies for one request number would make |
| // duplicate lookups ambiguous. |
| #[test] |
| fn commit_reply_same_request_replaces_in_place() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 11)); |
| assert_eq!(table.get_watermark(1), Some(1)); |
| match table.check_request(1, epoch, 1, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => assert_eq!(cached.header().request, 1), |
| other => panic!("expected Duplicate, got {other:?}"), |
| } |
| } |
| |
| // Eviction tests |
| |
| // Capacity resize (boot-only) |
| |
| // --- ClientTableMode::PartitionSlice: watermark-only dedup --- |
| // |
| // One consensus group's slice. No register mints entries here, no reply is |
| // cached, and slots grow on demand, so these pin the behaviour the |
| // partition plane actually relies on. |
| |
| const SLICE_USER: u32 = 3; |
| |
| fn slice(clients_max: usize) -> ClientTable { |
| ClientTable::with_mode(clients_max, ClientTableMode::PartitionSlice) |
| } |
| |
| fn watermark(client: u128, watermark: u64, latest_commit: u64) -> DedupWatermark { |
| DedupWatermark { |
| client, |
| user_id: SLICE_USER, |
| watermark, |
| latest_commit, |
| committed_window: 1, |
| session: 0, |
| reply: Vec::new(), |
| } |
| } |
| |
| fn receipt_watermark(client: u128, request: u64, commit: u64) -> DedupWatermark { |
| let mut reply = Message::<ReplyHeader>::new(size_of::<ReplyHeader>() + size_of::<u32>()); |
| reply = reply.transmute_header(|_, header: &mut ReplyHeader| { |
| *header = ReplyHeader { |
| client, |
| request, |
| commit, |
| command: Command::Reply, |
| operation: iggy_binary_protocol::Operation::StoreConsumerOffset, |
| size: (size_of::<ReplyHeader>() + size_of::<u32>()) as u32, |
| ..Default::default() |
| }; |
| }); |
| DedupWatermark { |
| session: 42, |
| reply: reply.as_slice().to_vec(), |
| ..watermark(client, request, commit) |
| } |
| } |
| |
| fn clients_of(table: &ClientTable) -> Vec<u128> { |
| table |
| .watermarks_sorted() |
| .into_iter() |
| .map(|entry| entry.client) |
| .collect() |
| } |
| |
| #[test] |
| fn given_partition_slice_when_commit_replayed_should_be_idempotent() { |
| let mut table = slice(4); |
| table.commit_request(7, SLICE_USER, 5, 100).unwrap(); |
| table.commit_request(7, SLICE_USER, 5, 100).unwrap(); |
| table.commit_request(7, SLICE_USER, 5, 100).unwrap(); |
| |
| assert_eq!(table.watermarks_sorted(), vec![watermark(7, 5, 100)]); |
| } |
| |
| #[test] |
| fn given_partition_slice_when_filled_to_cap_should_grow_without_holes() { |
| // The lazily grown array must hand out every index once and never |
| // rescan for a hole that cannot exist below the cap. |
| let mut table = slice(64); |
| for client in 1..=64u128 { |
| table |
| .commit_request(client, SLICE_USER, 1, client as u64) |
| .unwrap(); |
| } |
| |
| assert_eq!(table.count(), 64); |
| assert_eq!(table.slots.len(), 64); |
| assert!(table.slots.iter().all(Option::is_some)); |
| } |
| |
| #[test] |
| fn given_partition_slice_when_install_exceeds_local_cap_should_preserve_committed_limit() { |
| let mut table = slice(2); |
| table |
| .install_watermarks( |
| 3, |
| [ |
| receipt_watermark(1, 1, 300), |
| receipt_watermark(2, 1, 100), |
| receipt_watermark(3, 1, 200), |
| ], |
| ) |
| .unwrap(); |
| assert_eq!(table.capacity(), 3); |
| assert_eq!(clients_of(&table), vec![1, 2, 3]); |
| } |
| |
| #[test] |
| fn given_partition_slice_when_exported_should_sort_ascending_by_client() { |
| let mut table = slice(8); |
| for (commit_op, client) in [30u128, 10, 20].into_iter().enumerate() { |
| table |
| .commit_request(client, SLICE_USER, 1, commit_op as u64) |
| .unwrap(); |
| } |
| |
| assert_eq!(clients_of(&table), vec![10, 20, 30]); |
| } |
| |
| #[test] |
| fn given_partition_slice_when_client_is_reserved_zero_should_record_nothing() { |
| // Zero is reserved cluster-wide and refused at every ingress; a commit |
| // or install that still carries it degrades to no entry, not a panic. |
| let mut table = slice(4); |
| assert!(table.commit_request(0, SLICE_USER, 5, 100).is_err()); |
| assert!( |
| table |
| .install_watermarks( |
| 4, |
| [receipt_watermark(0, 5, 100), receipt_watermark(1, 1, 101)] |
| ) |
| .is_err() |
| ); |
| assert_eq!(clients_of(&table), [] as [u128; 0]); |
| } |
| |
| #[cfg(debug_assertions)] |
| #[test] |
| #[should_panic(expected = "a reply-caching table must use commit_reply")] |
| fn given_metadata_table_when_commit_request_called_should_panic() { |
| ClientTable::new(4) |
| .commit_request(7, SLICE_USER, 1, 1) |
| .unwrap(); |
| } |
| |
| #[test] |
| fn login_retry_and_capacity_pressure_preserve_session_protection() { |
| const CLIENT: u128 = 7; |
| const EPOCH: u64 = 10; |
| let secret = [0x5a; BIND_SECRET_BYTES]; |
| let verifier = bind_verifier(CLIENT, TEST_USER_ID, &secret); |
| let mut table = ClientTable::new(1); |
| table.commit_capacity(1).unwrap(); |
| table |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID, |
| verifier, |
| make_register_reply(CLIENT, EPOCH), |
| ) |
| .unwrap(); |
| let (_, discovered, attached) = table.bind_session(CLIENT, 0, &secret).unwrap(); |
| assert_eq!(discovered, EPOCH); |
| let second = table.attach_session(CLIENT, EPOCH, TEST_USER_ID).unwrap(); |
| table.commit_reply( |
| CLIENT, |
| TEST_USER_ID, |
| make_reply_with_checksum(CLIENT, 1, 11, 0xbeef), |
| ); |
| let original = table.get_reply(CLIENT).unwrap().as_bytes().to_vec(); |
| table |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID, |
| verifier, |
| make_register_reply(CLIENT, 20), |
| ) |
| .unwrap(); |
| assert_eq!(table.get_epoch(CLIENT), Some(EPOCH)); |
| assert_eq!(table.get_watermark(CLIENT), Some(1)); |
| assert!( |
| table |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID + 1, |
| verifier, |
| make_register_reply(CLIENT, 21) |
| ) |
| .is_err() |
| ); |
| assert!( |
| table |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID, |
| [0x6a; BIND_SECRET_BYTES], |
| make_register_reply(CLIENT, 22) |
| ) |
| .is_err() |
| ); |
| assert!( |
| table |
| .commit_register( |
| CLIENT + 1, |
| TEST_USER_ID, |
| verifier, |
| make_register_reply(CLIENT + 1, 23) |
| ) |
| .is_err() |
| ); |
| assert!(attached.is_valid() && second.is_valid()); |
| let RequestStatus::Duplicate(reply) = |
| table.check_request(CLIENT, EPOCH, 1, Operation::SendMessages) |
| else { |
| panic!("capacity pressure lost the committed result"); |
| }; |
| assert_eq!(reply.into_message().as_slice(), original); |
| assert!(matches!( |
| table.check_request(CLIENT, EPOCH, 1, Operation::StoreConsumerOffset), |
| RequestStatus::OperationMismatch { .. } |
| )); |
| } |
| |
| #[test] |
| fn expired_logout_ends_session_after_client_max_request() { |
| let (mut table, epoch) = table_with_client(); |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, u64::MAX, epoch + 1)); |
| let logout = make_reply_for(1, EXPIRED_SESSION_REQUEST_ID, epoch + 2).transmute_header( |
| |old, header| { |
| *header = old; |
| header.operation = Operation::Logout; |
| }, |
| ); |
| assert!(table.commit_logout(1, TEST_USER_ID, epoch, logout).unwrap()); |
| assert_eq!(table.get_epoch(1), None); |
| assert_eq!(table.ended_sessions().count(), 1); |
| } |
| |
| #[test] |
| fn stale_logout_preserves_live_session_and_latest_receipt() { |
| let (mut table, epoch) = table_with_client(); |
| let original = make_reply_for(1, 5, epoch + 1); |
| table.commit_reply(1, TEST_USER_ID, original.clone()); |
| let attachment = table.attach_session(1, epoch, TEST_USER_ID).unwrap(); |
| for request in [1, 5] { |
| let logout = make_reply_for(1, request, epoch + 2).transmute_header(|old, header| { |
| *header = old; |
| header.operation = Operation::Logout; |
| }); |
| assert!(!table.commit_logout(1, TEST_USER_ID, epoch, logout).unwrap()); |
| assert!(attachment.is_valid()); |
| assert_eq!( |
| table |
| .get_reply(1) |
| .unwrap() |
| .clone() |
| .into_message() |
| .as_slice(), |
| original.as_slice() |
| ); |
| assert_eq!(table.ended_sessions().count(), 0); |
| } |
| } |
| |
| #[test] |
| fn logout_replay_survives_recovery_until_exact_finalization() { |
| const CLIENT: u128 = 7; |
| const EPOCH: u64 = 10; |
| const END: u64 = 13; |
| let secret = [0x5a; BIND_SECRET_BYTES]; |
| let verifier = bind_verifier(CLIENT, TEST_USER_ID, &secret); |
| let mut table = ClientTable::new(1); |
| table.commit_capacity(1).unwrap(); |
| table |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID, |
| verifier, |
| make_register_reply(CLIENT, EPOCH), |
| ) |
| .unwrap(); |
| let attached = table.attach_session(CLIENT, EPOCH, TEST_USER_ID).unwrap(); |
| let reply = |
| make_reply_with_checksum(CLIENT, 2, END, 0xbeef).transmute_header(|old, header| { |
| *header = old; |
| header.operation = iggy_binary_protocol::Operation::Logout; |
| }); |
| assert!( |
| !table |
| .commit_logout(CLIENT, TEST_USER_ID, EPOCH - 1, reply.clone()) |
| .unwrap() |
| ); |
| assert!(attached.is_valid()); |
| assert!( |
| table |
| .commit_logout(CLIENT, TEST_USER_ID, EPOCH, reply.clone()) |
| .unwrap() |
| ); |
| assert!(!attached.is_valid()); |
| assert!(table.bind_session(CLIENT, EPOCH, &secret).is_err()); |
| assert!( |
| table |
| .commit_register( |
| CLIENT + 1, |
| TEST_USER_ID, |
| verifier, |
| make_register_reply(CLIENT + 1, END + 1) |
| ) |
| .is_err() |
| ); |
| for mut restored in [ |
| ClientTable::from_snapshot(table.to_snapshot()).unwrap(), |
| ClientTable::decode(&table.encode()).unwrap(), |
| ] { |
| assert_eq!(restored.capacity(), 1); |
| let RequestStatus::Duplicate(cached) = |
| restored.check_request(CLIENT, EPOCH, 2, Operation::Logout) |
| else { |
| panic!("the ended session lost its Logout result"); |
| }; |
| assert_eq!(cached.into_message().as_slice(), reply.as_slice()); |
| let identity = iggy_binary_protocol::requests::system::SessionIdentity { |
| client_id: CLIENT, |
| session: EPOCH, |
| metadata_watermark: END, |
| }; |
| assert!(!restored.finalize_session( |
| iggy_binary_protocol::requests::system::SessionIdentity { |
| session: EPOCH - 1, |
| ..identity |
| } |
| )); |
| assert!(!restored.finalize_session( |
| iggy_binary_protocol::requests::system::SessionIdentity { |
| metadata_watermark: END + 1, |
| ..identity |
| } |
| )); |
| assert_eq!(restored.count(), 1); |
| assert!(restored.finalize_session(identity)); |
| restored.set_capacity(20); |
| assert_eq!(restored.capacity(), 1); |
| restored |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID, |
| verifier, |
| make_register_reply(CLIENT, END + 2), |
| ) |
| .unwrap(); |
| assert!(matches!( |
| restored.check_request(CLIENT, EPOCH, 2, Operation::SendMessages), |
| RequestStatus::Fenced { .. } |
| )); |
| } |
| } |
| |
| #[test] |
| fn given_partition_receipts_when_requests_commit_should_retain_only_the_latest() { |
| const CLIENT: u128 = 7; |
| const SESSION: u64 = 42; |
| const REQUESTS: u64 = 40; |
| let mut table = slice(1); |
| for request in 1..=REQUESTS { |
| table |
| .commit_partition_reply( |
| TEST_USER_ID, |
| SESSION, |
| make_reply_for(CLIENT, request, request), |
| ) |
| .unwrap(); |
| } |
| let entry = table.slots[table.index[&CLIENT]].as_ref().unwrap(); |
| assert_eq!( |
| entry.ring.len(), |
| 1, |
| "only the unresolved result needs bytes" |
| ); |
| assert_eq!(entry.latest().header().request, REQUESTS); |
| assert!(matches!( |
| table.check_partition_request( |
| CLIENT, |
| TEST_USER_ID, |
| SESSION, |
| REQUESTS - 1, |
| Operation::SendMessages, |
| ), |
| RequestStatus::AlreadyApplied { .. } |
| )); |
| assert!(matches!( |
| table.check_partition_request( |
| CLIENT, |
| TEST_USER_ID, |
| SESSION, |
| REQUESTS, |
| Operation::SendMessages, |
| ), |
| RequestStatus::Duplicate(_) |
| )); |
| } |
| |
| #[test] |
| fn given_pinned_receipt_when_lower_request_commits_should_preserve_retry_result() { |
| const CLIENT: u128 = 7; |
| const SESSION: u64 = 42; |
| const PINNED_REQUEST: u64 = 5; |
| const PINNED_COMMIT: u64 = 100; |
| const REORDERED_REQUEST: u64 = 3; |
| let mut table = slice(1); |
| let pinned = make_reply_with_checksum(CLIENT, PINNED_REQUEST, PINNED_COMMIT, 0xabcd); |
| table |
| .commit_partition_reply(TEST_USER_ID, SESSION, pinned.clone()) |
| .unwrap(); |
| let reordered = |
| make_reply_with_checksum(CLIENT, REORDERED_REQUEST, PINNED_COMMIT + 1, 0xef01); |
| for _ in 0..2 { |
| let committed = table |
| .commit_partition_reply(TEST_USER_ID, SESSION, reordered.clone()) |
| .unwrap(); |
| assert_eq!(committed.as_bytes(), reordered.as_slice()); |
| assert_eq!(table.watermarks_sorted()[0].latest_commit, PINNED_COMMIT); |
| let mut restored = slice(1); |
| restored |
| .install_watermarks(1, table.watermarks_sorted()) |
| .unwrap(); |
| for retained in [&table, &restored] { |
| let RequestStatus::Duplicate(cached) = retained.check_partition_request( |
| CLIENT, |
| TEST_USER_ID, |
| SESSION, |
| PINNED_REQUEST, |
| Operation::SendMessages, |
| ) else { |
| panic!("the higher request lost its pinned receipt"); |
| }; |
| assert_eq!(cached.as_bytes(), pinned.as_slice()); |
| assert!(matches!( |
| retained.check_partition_request( |
| CLIENT, |
| TEST_USER_ID, |
| SESSION, |
| REORDERED_REQUEST, |
| Operation::SendMessages, |
| ), |
| RequestStatus::AlreadyApplied { |
| request: REORDERED_REQUEST, |
| watermark: PINNED_REQUEST |
| } |
| )); |
| } |
| } |
| } |
| |
| #[test] |
| fn given_missing_session_when_binding_should_distinguish_registration_uncertainty() { |
| const CLIENT: u128 = 7; |
| const SESSION: u64 = 42; |
| let secret = [0x5a; BIND_SECRET_BYTES]; |
| let mut table = ClientTable::new(1); |
| assert!(matches!( |
| table.bind_session(CLIENT, SESSION, &secret), |
| Err(IggyError::Unauthenticated) |
| )); |
| assert!(matches!( |
| table.bind_session(CLIENT, 0, &secret), |
| Err(IggyError::TransientNotAccepted) |
| )); |
| table |
| .commit_register( |
| CLIENT, |
| TEST_USER_ID, |
| bind_verifier(CLIENT, TEST_USER_ID, &secret), |
| make_register_reply(CLIENT, SESSION), |
| ) |
| .unwrap(); |
| assert!(matches!( |
| table.bind_session(CLIENT, SESSION, &[0x6a; BIND_SECRET_BYTES]), |
| Err(IggyError::Unauthenticated) |
| )); |
| } |
| |
| #[test] |
| fn given_partition_receipts_when_request_ids_skip_should_protect_only_committed_ids() { |
| const CLIENT: u128 = 7; |
| const SESSION: u64 = 42; |
| let mut table = slice(1); |
| for (request, commit) in [(1, 100), (5, 101)] { |
| table |
| .commit_partition_reply( |
| TEST_USER_ID, |
| SESSION, |
| make_reply_for(CLIENT, request, commit), |
| ) |
| .unwrap(); |
| } |
| assert!(matches!( |
| table.check_partition_request( |
| CLIENT, |
| TEST_USER_ID, |
| SESSION, |
| 3, |
| Operation::SendMessages, |
| ), |
| RequestStatus::New |
| )); |
| assert!(matches!( |
| table.check_partition_request( |
| CLIENT, |
| TEST_USER_ID, |
| SESSION, |
| 1, |
| Operation::SendMessages, |
| ), |
| RequestStatus::AlreadyApplied { |
| request: 1, |
| watermark: 5 |
| } |
| )); |
| table |
| .commit_partition_reply( |
| TEST_USER_ID, |
| SESSION, |
| make_reply_for(CLIENT, COMMITTED_WINDOW_BITS + 10, 102), |
| ) |
| .unwrap(); |
| assert!(matches!( |
| table.check_partition_request( |
| CLIENT, |
| TEST_USER_ID, |
| SESSION, |
| 3, |
| Operation::SendMessages, |
| ), |
| RequestStatus::AlreadyApplied { .. } |
| )); |
| assert!(matches!( |
| table.check_partition_request( |
| CLIENT, |
| TEST_USER_ID + 1, |
| SESSION, |
| COMMITTED_WINDOW_BITS + 11, |
| Operation::SendMessages, |
| ), |
| RequestStatus::Fenced { .. } |
| )); |
| } |
| |
| #[test] |
| fn partition_pressure_and_transfer_preserve_the_exact_receipt() { |
| let mut table = slice(1); |
| table.commit_capacity(1).unwrap(); |
| let watermark = receipt_watermark(7, 5, 11); |
| let reply = |
| Message::<ReplyHeader>::try_from(Owned::copy_from_slice(&watermark.reply)).unwrap(); |
| let committed = table |
| .commit_partition_reply(TEST_USER_ID, watermark.session, reply.clone()) |
| .unwrap(); |
| let mut changed = reply.clone(); |
| changed.as_mut_slice()[size_of::<ReplyHeader>()] ^= 1; |
| let replayed = table |
| .commit_partition_reply(TEST_USER_ID, watermark.session, changed) |
| .unwrap(); |
| assert!( |
| committed |
| .into_wire_bytes() |
| .shares_allocation(&replayed.into_wire_bytes()) |
| ); |
| assert!( |
| table |
| .commit_partition_reply(TEST_USER_ID + 1, watermark.session, reply.clone()) |
| .is_err() |
| ); |
| let other = receipt_watermark(8, 1, 12); |
| let other_reply = |
| Message::<ReplyHeader>::try_from(Owned::copy_from_slice(&other.reply)).unwrap(); |
| assert!(matches!( |
| table.commit_partition_reply(TEST_USER_ID, other.session, other_reply), |
| Err(ClientTableWireError::TooManyEntries { .. }) |
| )); |
| let mut restored = slice(20); |
| restored |
| .install_watermarks(1, table.watermarks_sorted()) |
| .unwrap(); |
| assert_eq!(restored.capacity(), 1); |
| let RequestStatus::Duplicate(cached) = restored.check_partition_request( |
| 7, |
| TEST_USER_ID, |
| watermark.session, |
| 5, |
| Operation::StoreConsumerOffset, |
| ) else { |
| panic!("a transferred receipt must replay its original bytes"); |
| }; |
| assert_eq!(cached.into_message().as_slice(), reply.as_slice()); |
| assert!(matches!( |
| restored.check_partition_request( |
| 7, |
| TEST_USER_ID, |
| watermark.session, |
| 5, |
| Operation::SendMessages, |
| ), |
| RequestStatus::OperationMismatch { request: 5 } |
| )); |
| assert!(matches!( |
| restored.check_partition_request( |
| 7, |
| TEST_USER_ID + 1, |
| watermark.session, |
| 5, |
| Operation::StoreConsumerOffset, |
| ), |
| RequestStatus::Fenced { .. } |
| )); |
| assert!(!restored.forget_session(7, watermark.session + 1)); |
| assert_eq!(restored.count(), 1); |
| assert!(restored.forget_session(7, watermark.session)); |
| assert_eq!(restored.count(), 0); |
| assert_eq!(restored.capacity(), 1); |
| } |
| |
| #[test] |
| fn set_capacity_resizes_empty_table() { |
| let mut table = ClientTable::new(10); |
| table.set_capacity(2); |
| table |
| .commit_register(100, TEST_USER_ID, [0x5a; 32], make_register_reply(100, 10)) |
| .unwrap(); |
| table |
| .commit_register(200, TEST_USER_ID, [0x5a; 32], make_register_reply(200, 20)) |
| .unwrap(); |
| assert!(matches!( |
| table.commit_register(300, TEST_USER_ID, [0x5a; 32], make_register_reply(300, 30)), |
| Err(ClientTableWireError::TooManyEntries { max: 2, .. }) |
| )); |
| assert_eq!(table.count(), 2); |
| assert_eq!(table.get_epoch(100), Some(10)); |
| assert_eq!(table.get_epoch(200), Some(20)); |
| } |
| |
| // The empty-table contract is asserted, not silently honored: resizing a |
| // populated table would drop live sessions, so it must panic. |
| #[test] |
| #[should_panic(expected = "before any client registers")] |
| fn set_capacity_rejects_a_populated_table() { |
| let (mut table, _session) = table_with_client(); |
| table.set_capacity(2); |
| } |
| |
| // Edge cases |
| |
| // commit_reply for unregistered/evicted client must not panic; |
| // wire reply still ships, cache silently skipped. |
| #[test] |
| fn commit_reply_for_unregistered_client_is_noop() { |
| let mut table = ClientTable::new(10); |
| // No register: index has no entry. |
| let outcome = table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 1, 10)); |
| assert_eq!(outcome, CommitReply::NoEntry); |
| assert!(table.get_reply(1).is_none(), "no entry must be created"); |
| assert_eq!(table.count(), 0); |
| } |
| |
| #[test] |
| fn a_server_originated_commit_under_the_reserved_id_is_not_cached() { |
| const NO_USER: u32 = 0; |
| const DEFAULT_REQUEST: u64 = 0; |
| const COMMIT: u64 = 5; |
| |
| let mut table = ClientTable::new(2); |
| let outcome = table.commit_reply( |
| RESERVED_CLIENT_ID, |
| NO_USER, |
| make_reply_for(RESERVED_CLIENT_ID, DEFAULT_REQUEST, COMMIT), |
| ); |
| |
| assert_eq!(outcome, CommitReply::NoEntry); |
| assert!( |
| table.index.is_empty(), |
| "the reserved id must not gain an entry" |
| ); |
| assert_eq!(table.count(), 0); |
| } |
| |
| #[test] |
| fn commit_reply_watermark_regression_is_skipped() { |
| let (mut table, _epoch) = table_with_client(); |
| assert_eq!( |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 5, 15)), |
| CommitReply::Cached |
| ); |
| assert_eq!( |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, 3, 16)), |
| CommitReply::SkippedRegression { |
| stored: 5, |
| received: 3 |
| } |
| ); |
| // Watermark holds at the newer request, cache keeps the newer reply. |
| assert_eq!(table.get_watermark(1), Some(5)); |
| assert_eq!( |
| table.get_reply(1).map(|reply| reply.header().commit), |
| Some(15) |
| ); |
| } |
| |
| #[test] |
| fn different_clients_independent_epochs() { |
| let mut table = ClientTable::new(10); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| table |
| .commit_register(2, TEST_USER_ID, [0x5a; 32], make_register_reply(2, 20)) |
| .unwrap(); |
| // Retrying login must not replace either epoch. |
| table |
| .commit_register(2, TEST_USER_ID, [0x5a; 32], make_register_reply(2, 30)) |
| .unwrap(); |
| assert_eq!(table.get_epoch(1), Some(10)); |
| assert_eq!(table.get_epoch(2), Some(20)); |
| assert!(matches!( |
| table.check_request(1, 10, 1, Operation::SendMessages), |
| RequestStatus::New |
| )); |
| assert!(matches!( |
| table.check_request(2, 20, 1, Operation::SendMessages), |
| RequestStatus::New |
| )); |
| // A stale presented epoch remains fenced. |
| assert!(matches!( |
| table.check_request(2, 19, 1, Operation::SendMessages), |
| RequestStatus::Fenced { .. } |
| )); |
| } |
| |
| // Codec tests |
| |
| // State transfer ships the table as bytes; the decoded table must answer |
| // every dedup question identically to the original. |
| #[test] |
| fn encode_decode_roundtrip_preserves_dedup_state() { |
| let mut table = ClientTable::new(10); |
| table |
| .commit_register(1, 11, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| table.commit_reply(1, 11, make_reply_with_checksum(1, 1, 11, 0xAA)); |
| table.commit_reply(1, 11, make_reply_for(1, 2, 12)); |
| // A repeated login preserves the original identity and request history. |
| table |
| .commit_register(1, 11, [0x5a; 32], make_register_reply(1, 20)) |
| .unwrap(); |
| table |
| .commit_register(2, 33, [0x5a; 32], make_register_reply(2, 30)) |
| .unwrap(); |
| |
| let encoded = table.encode(); |
| let decoded = ClientTable::decode(&encoded).expect("roundtrip decodes"); |
| |
| assert_eq!(decoded.count(), 2); |
| assert_eq!(decoded.get_epoch(1), Some(10)); |
| assert_eq!(decoded.get_user_id(1), Some(11)); |
| assert_eq!(decoded.get_watermark(1), Some(2)); |
| assert_eq!(decoded.get_epoch(2), Some(30)); |
| match decoded.check_request(1, 10, 2, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => { |
| assert_eq!(cached.header().request, 2, "latest reply survives"); |
| assert_eq!(cached.header().commit, 12, "original bytes survive"); |
| } |
| other => panic!("expected Duplicate, got {other:?}"), |
| } |
| assert!(matches!( |
| decoded.check_request(1, 10, 1, Operation::StoreConsumerOffset), |
| RequestStatus::OperationMismatch { request: 1 } |
| )); |
| assert!(matches!( |
| decoded.check_request(1, 9, 1, Operation::SendMessages), |
| RequestStatus::Fenced { .. } |
| )); |
| |
| // Deterministic bytes: encoding the decoded table reproduces them. |
| assert_eq!(decoded.encode(), encoded); |
| } |
| |
| // Deep retention is a live-memory property, not a transferred one: the |
| // artifact carries the floor, so a retry the serving replica would have |
| // replayed loses its bytes on the receiver. The fence still rides along, so |
| // the answer degrades and the operation is still never re-executed. |
| #[test] |
| fn state_transfer_cuts_retention_back_to_the_floor() { |
| let (mut table, epoch) = table_with_client(); |
| let requests = REPLY_RING_CAPACITY as u64 + 3; |
| for request in 1..=requests { |
| table.commit_reply(1, TEST_USER_ID, make_reply_for(1, request, 10 + request)); |
| } |
| // The whole run is still cached locally: retention is byte-budgeted and |
| // these replies are headers. |
| assert!(matches!( |
| table.check_request(1, epoch, 1, Operation::SendMessages), |
| RequestStatus::Duplicate(_) |
| )); |
| |
| let decoded = ClientTable::decode(&table.encode()).expect("roundtrip decodes"); |
| |
| assert_eq!(decoded.get_watermark(1), Some(requests)); |
| let oldest_transferred = requests - REPLY_RING_CAPACITY as u64 + 1; |
| match decoded.check_request(1, epoch, oldest_transferred - 1, Operation::SendMessages) { |
| RequestStatus::AlreadyApplied { request, watermark } => { |
| assert_eq!(request, oldest_transferred - 1); |
| assert_eq!(watermark, requests, "the fence survives the transfer"); |
| } |
| other => panic!("expected AlreadyApplied past the transferred floor, got {other:?}"), |
| } |
| match decoded.check_request(1, epoch, oldest_transferred, Operation::SendMessages) { |
| RequestStatus::Duplicate(cached) => { |
| assert_eq!(cached.header().request, oldest_transferred); |
| } |
| other => panic!("expected Duplicate, got {other:?}"), |
| } |
| } |
| |
| #[test] |
| fn decode_rejects_corruption() { |
| let mut table = ClientTable::new(4); |
| table |
| .commit_register(1, TEST_USER_ID, [0x5a; 32], make_register_reply(1, 10)) |
| .unwrap(); |
| let encoded = table.encode(); |
| |
| let mut previous_format = encoded[..encoded.len() - size_of::<u64>()].to_vec(); |
| previous_format[..CLIENT_TABLE_MAGIC.len()].copy_from_slice(b"ICT4"); |
| assert!(matches!( |
| ClientTable::decode(&reseal(previous_format)), |
| Err(ClientTableWireError::BadMagic) |
| )); |
| |
| // Flipped content byte -> checksum mismatch. |
| let mut flipped = encoded.clone(); |
| flipped[8] ^= 0xFF; |
| assert!(matches!( |
| ClientTable::decode(&flipped), |
| Err(ClientTableWireError::ChecksumMismatch { .. }) |
| )); |
| |
| // Truncation. |
| assert!(matches!( |
| ClientTable::decode(&encoded[..encoded.len() - 1]), |
| Err(ClientTableWireError::ChecksumMismatch { .. } | ClientTableWireError::Truncated) |
| )); |
| |
| let empty = ClientTable::new(4).encode(); |
| assert_eq!( |
| ClientTable::decode(&empty) |
| .expect("empty table decodes") |
| .count(), |
| 0 |
| ); |
| } |
| |
| /// Re-stamp a hand-edited body so it passes the trailer check and the |
| /// per-field validations are what the decode actually exercises. |
| fn reseal(mut content: Vec<u8>) -> Vec<u8> { |
| let trailer = crate::state_manifest::state_artifact_checksum(&content); |
| content.extend_from_slice(&trailer.to_le_bytes()); |
| content |
| } |
| |
| // A duplicate client_id would collapse the index onto one slot, leaving the |
| // other occupied but unindexed, breaking capacity accounting and lookup. |
| #[test] |
| fn decode_rejects_a_duplicate_client_id() { |
| let mut table = ClientTable::new(2); |
| table |
| .commit_register(7, TEST_USER_ID, [0x5a; 32], make_register_reply(7, 10)) |
| .unwrap(); |
| table |
| .commit_register(9, TEST_USER_ID, [0x5a; 32], make_register_reply(9, 20)) |
| .unwrap(); |
| let encoded = table.encode(); |
| |
| // Rewrite the second entry's client_id to match the first. Entries are |
| // fixed-width up to their ring, and both rings hold one register reply |
| // of equal length, so the second entry starts at a computable offset. |
| let content = &encoded[..encoded.len() - size_of::<u64>()]; |
| let header_len = CLIENT_TABLE_MAGIC.len() + 2 * size_of::<u32>(); |
| let reply_len = (content.len() - header_len - 2 * ENCODED_ENTRY_FIXED_LEN) / 2; |
| let second = header_len + ENCODED_ENTRY_FIXED_LEN + reply_len; |
| let mut duped = content.to_vec(); |
| duped[second..second + size_of::<u128>()].copy_from_slice(&7u128.to_le_bytes()); |
| let receipt_client = second |
| + ENCODED_ENTRY_FIXED_LEN |
| + size_of::<u32>() |
| + std::mem::offset_of!(ReplyHeader, client); |
| duped[receipt_client..receipt_client + size_of::<u128>()] |
| .copy_from_slice(&7u128.to_le_bytes()); |
| |
| assert!(matches!( |
| ClientTable::decode(&reseal(duped)), |
| Err(ClientTableWireError::DuplicateClientId { client_id: 7, .. }) |
| )); |
| } |
| |
| // The received count is the only bound between a peer-supplied u32 and the |
| // eager slot allocation, so it must stop at the same ceiling |
| // `from_snapshot` enforces. |
| #[test] |
| fn decode_rejects_a_count_past_the_slot_ceiling() { |
| let mut table = ClientTable::new(1); |
| table |
| .commit_register(3, TEST_USER_ID, [0x5a; 32], make_register_reply(3, 10)) |
| .unwrap(); |
| let encoded = table.encode(); |
| |
| let content = &encoded[..encoded.len() - size_of::<u64>()]; |
| let mut oversized = content.to_vec(); |
| let count_at = CLIENT_TABLE_MAGIC.len(); |
| #[allow(clippy::cast_possible_truncation)] |
| { |
| oversized[count_at..count_at + size_of::<u32>()] |
| .copy_from_slice(&(CLIENTS_TABLE_SLOT_MAX as u32 + 1).to_le_bytes()); |
| } |
| |
| assert!(matches!( |
| ClientTable::decode(&reseal(oversized)), |
| Err(ClientTableWireError::TooManyEntries { |
| max: CLIENTS_TABLE_SLOT_MAX, |
| .. |
| }) |
| )); |
| } |
| |
| // A ring longer than `transferable_replies` emits comes from a peer this |
| // one cannot model; admitting it would eventually wrap `encode`'s u8 length |
| // and leave the table untransferable onward. |
| #[test] |
| fn decode_rejects_a_ring_longer_than_capacity() { |
| let mut table = ClientTable::new(1); |
| table |
| .commit_register(3, TEST_USER_ID, [0x5a; 32], make_register_reply(3, 10)) |
| .unwrap(); |
| let encoded = table.encode(); |
| |
| let content = &encoded[..encoded.len() - size_of::<u64>()]; |
| let mut oversized = content.to_vec(); |
| // ring_len is the last fixed field of the entry. |
| let ring_len_at = |
| CLIENT_TABLE_MAGIC.len() + 2 * size_of::<u32>() + ENCODED_ENTRY_FIXED_LEN - 1; |
| assert_eq!(oversized[ring_len_at], 1, "entry's ring holds one reply"); |
| #[allow(clippy::cast_possible_truncation)] |
| { |
| oversized[ring_len_at] = REPLY_RING_CAPACITY as u8 + 1; |
| } |
| |
| assert!(matches!( |
| ClientTable::decode(&reseal(oversized)), |
| Err(ClientTableWireError::RingTooLong { .. }) |
| )); |
| } |
| } |