blob: 0c19eb156339d312c0ee37e733f6eeae78266a1e [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::MuxStateMachine;
use crate::stm::authz::gated_apply;
use crate::stm::consumer_group::CompleteConsumerGroupRevocationRequest;
use crate::stm::snapshot::{
FillSnapshot, MetadataSnapshot, RestoreSnapshotInPlace, Snapshot, SnapshotError,
};
use crate::stm::stream::{Streams, TruncatePartitionRequest};
use crate::stm::user::{DeletePersonalAccessTokenRequest, Users};
use crate::stm::{ConsensusGroupAllocator, StateMachine};
use consensus::{
CLIENTS_TABLE_MAX, Canceled, ClientTable, ClientTableSnapshot, CommitLogEvent, CommitReply,
Consensus, EvictionContext, FatalReason, Pipeline, PipelineEntry, Plane, PlaneIdentity,
PlaneKind, PreflightOutcome, PrepareRollback, Project, ReplicaLogContext, RequestLogEvent,
Sequencer, SessionEnd, SimEventKind, VsrConsensus, ack_preflight, ack_quorum_reached,
apply_preflight_consensus_plane, build_eviction_message, build_reply_message,
build_reply_message_with, build_result_rejection_reply, emit_sim_event, fatal,
fence_old_prepare_by_commit, is_caught_up_primary,
panic_if_hash_chain_would_break_in_same_view, peek_committable_head, pipeline_prepare_common,
register_preflight, replicate_preflight, replicate_to_next_in_chain, request_preflight,
send_eviction_to_client, send_prepare_ok as send_prepare_ok_common, verify_prepare_integrity,
};
use iggy_binary_protocol::WireIdentifier;
use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
use iggy_binary_protocol::requests::partitions::CreatePartitionsRequest as WireCreatePartitionsRequest;
use iggy_binary_protocol::requests::partitions::CreatePartitionsWithAssignmentsRequest as PersistedCreatePartitionsRequest;
use iggy_binary_protocol::requests::topics::CreateTopicRequest as WireCreateTopicRequest;
use iggy_binary_protocol::requests::topics::CreateTopicWithAssignmentsRequest as PersistedCreateTopicRequest;
use iggy_binary_protocol::{
Command, ConsensusHeader, EvictionReason, GenericHeader, Operation, PrepareHeader,
PrepareOkHeader, ProtocolVersion, ReplyHeader, RoutedRequestHeader, WireDecode, WireEncode,
WireName,
};
use iggy_common::calculate_checksum;
use iggy_common::variadic;
use iggy_common::{
IggyByteSize, IggyError, IggyExpiry, MaxTopicSize, TopicCreateOptions, TopicRuntimeDefaults,
UserId, topic_option_keys, validate_topic_segment_size,
};
use journal::local_gate::LocalGate;
use journal::superblock::{
PingPongSuperblock, SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS, SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS,
SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT, SuperblockStore,
};
use journal::{Journal, JournalHandle};
use message_bus::MessageBus;
use server_common::Message;
use server_common::iobuf::{Frozen, Owned};
use std::cell::{Cell, RefCell};
use std::mem::size_of;
use std::path::Path;
use std::rc::Rc;
use tracing::{debug, error, info, warn};
fn freeze_client_reply(
message: Message<GenericHeader>,
) -> server_common::iobuf::Frozen<{ server_common::MESSAGE_ALIGN }> {
message.into_frozen()
}
pub trait StreamsFrontend {
#[must_use]
fn users(&self) -> &Users;
#[must_use]
fn streams(&self) -> &Streams;
}
impl StreamsFrontend for MuxStateMachine<variadic!(Users, Streams)> {
fn users(&self) -> &Users {
&self.inner().0
}
fn streams(&self) -> &Streams {
&self.inner().1.0
}
}
#[derive(Debug, Clone)]
#[allow(unused)]
pub struct IggySnapshot {
snapshot: MetadataSnapshot,
}
#[allow(unused)]
impl IggySnapshot {
#[must_use]
pub const fn new(sequence_number: u64) -> Self {
Self {
snapshot: MetadataSnapshot::new(sequence_number),
}
}
#[must_use]
pub const fn snapshot(&self) -> &MetadataSnapshot {
&self.snapshot
}
/// Mutable view for tests that need to fold state into a checkpoint by hand,
/// the way [`Self::persist_snapshot`] does on the live path.
#[cfg(test)]
pub(crate) const fn snapshot_mut(&mut self) -> &mut MetadataSnapshot {
&mut self.snapshot
}
/// Persist the snapshot to disk.
///
/// # Errors
/// Returns `SnapshotError` if serialization or I/O fails.
pub fn persist(&self, path: &Path) -> Result<(), SnapshotError> {
Self::write_durably(path, &self.encode()?)
}
/// Write already-encoded snapshot bytes to `path` durably: temp, fsync,
/// rename, parent-dir fsync, with the integrity trailer appended.
///
/// Split from [`Self::persist`] so the checkpoint path can hash and write one
/// buffer instead of encoding the whole snapshot twice under the durability
/// lock (the client table is folded in, so a second encode is a full
/// re-serialization).
///
/// # Errors
/// `SnapshotError::Persist` if any write, fsync, or rename fails.
fn write_durably(path: &Path, encoded: &[u8]) -> Result<(), SnapshotError> {
use crate::stm::snapshot::PersistStage;
use std::fs;
use std::io::Write;
let tmp_path = path.with_extension("bin.tmp");
let mut file = fs::File::create(&tmp_path).map_err(|e| SnapshotError::Persist {
stage: PersistStage::Write,
source: e,
})?;
file.write_all(encoded)
.map_err(|e| SnapshotError::Persist {
stage: PersistStage::Write,
source: e,
})?;
// Self-verifying trailer. The superblock's checkpoint pairing cannot stand in:
// phase 1 of a checkpoint renames the new snapshot over `snapshot.bin`, so a
// crash before the pairing write is the NORMAL crash-inside-a-checkpoint
// outcome, and it recovers through the `checkpoint_op < snapshot_op` arm, which
// accepts the snapshot with nothing to check it against.
file.write_all(&snapshot_trailer(encoded))
.map_err(|e| SnapshotError::Persist {
stage: PersistStage::Write,
source: e,
})?;
file.sync_all().map_err(|e| SnapshotError::Persist {
stage: PersistStage::Sync,
source: e,
})?;
drop(file);
fs::rename(&tmp_path, path).map_err(|e| SnapshotError::Persist {
stage: PersistStage::Rename,
source: e,
})?;
// Fsync the parent directory to ensure the rename is durable.
if let Some(parent) = path.parent() {
let dir = fs::File::open(parent).map_err(|e| SnapshotError::Persist {
stage: PersistStage::DirSync,
source: e,
})?;
dir.sync_all().map_err(|e| SnapshotError::Persist {
stage: PersistStage::DirSync,
source: e,
})?;
}
Ok(())
}
/// Load a snapshot from disk, with the [`checkpoint_checksum`] of the exact bytes
/// read.
///
/// The checksum comes from the file's bytes, never from re-encoding what was
/// decoded: the pairing must survive a schema change. Adding a trailing
/// `#[serde(default)]` field is the repo's forward-compatible migration (see
/// [`SNAPSHOT_FORMAT_VERSION`](crate::stm::snapshot::SNAPSHOT_FORMAT_VERSION) for
/// the rules), and an older file re-encodes with one MORE msgpack array element
/// after it, so a re-encode checksum would diverge on the first boot of the new
/// build and refuse every checkpointed node with its WAL prefix already drained.
///
/// # Errors
/// `SnapshotError::ChecksumMismatch` if the file carries an integrity trailer that
/// does not match its payload, `SnapshotError::UnsupportedFormatVersion` if it was
/// written in a format version this build does not read, or `SnapshotError` if the
/// file cannot be read or deserialized.
pub fn load(path: &Path) -> Result<(Self, u128), SnapshotError> {
let data = std::fs::read(path)?;
let (payload, checksum) = split_trailer(&data, path)?;
Ok((Self::decode(payload)?, checksum))
}
}
/// Framing marker for the snapshot integrity trailer, "ISNP". Distinguishes a sealed
/// snapshot from one written before the trailer existed, so a MISSING trailer can be
/// accepted (unverified, loudly) while a PRESENT but mismatching one refuses boot. A
/// bare checksum could not tell those apart, and guessing wrong in either direction is
/// unacceptable: silently accepting corruption, or bricking a healthy node.
const SNAPSHOT_TRAILER_MAGIC: u32 = 0x4953_4E50;
/// `magic` + the payload's [`checkpoint_checksum`].
const SNAPSHOT_TRAILER_LEN: usize = size_of::<u32>() + size_of::<u128>();
/// The integrity trailer for an encoded snapshot.
fn snapshot_trailer(encoded: &[u8]) -> [u8; SNAPSHOT_TRAILER_LEN] {
let mut trailer = [0u8; SNAPSHOT_TRAILER_LEN];
trailer[..4].copy_from_slice(&SNAPSHOT_TRAILER_MAGIC.to_le_bytes());
trailer[4..].copy_from_slice(&checkpoint_checksum(encoded).to_le_bytes());
trailer
}
/// Split a snapshot file into `(payload, checkpoint_checksum)`, verifying the trailer
/// when one is present.
///
/// A file without the trailer is a snapshot written before sealing: its whole contents
/// are the payload and the checksum is computed over them, exactly as the pairing
/// recorded it, so an upgrade boots. It replays unverified, which is the same trade the
/// WAL makes for entries no producer sealed, so it is warned about rather than
/// silently accepted.
fn split_trailer<'a>(data: &'a [u8], path: &Path) -> Result<(&'a [u8], u128), SnapshotError> {
let sealed = data.len() >= SNAPSHOT_TRAILER_LEN
&& data[data.len() - SNAPSHOT_TRAILER_LEN..][..4] == SNAPSHOT_TRAILER_MAGIC.to_le_bytes();
if !sealed {
tracing::warn!(
path = %path.display(),
"metadata snapshot carries no integrity trailer; restored unverified \
(written before snapshot sealing)"
);
return Ok((data, checkpoint_checksum(data)));
}
let (payload, trailer) = data.split_at(data.len() - SNAPSHOT_TRAILER_LEN);
let expected = u128::from_le_bytes(
trailer[4..]
.try_into()
.expect("a sealed trailer holds 16 checksum bytes"),
);
let actual = checkpoint_checksum(payload);
if actual != expected {
return Err(SnapshotError::ChecksumMismatch { expected, actual });
}
Ok((payload, expected))
}
/// The superblock's `checkpoint_checksum` over a snapshot's on-disk bytes: the same
/// `XxHash3_64` the WAL and superblock use, widened to the `u128` the durable record
/// reserves.
///
/// Both sides of the pairing hash BYTES rather than a state: the checkpoint hashes
/// what it wrote (`SnapshotCoordinator::persist_snapshot`), recovery hashes what it
/// read ([`IggySnapshot::load`]). Nothing re-serializes a decoded snapshot, so the
/// cross-check does not depend on decode-then-encode being byte-identical across
/// builds.
#[must_use]
pub fn checkpoint_checksum(encoded: &[u8]) -> u128 {
u128::from(calculate_checksum(encoded))
}
impl Snapshot for IggySnapshot {
type Error = SnapshotError;
type SequenceNumber = u64;
type Timestamp = u64;
type Inner = MetadataSnapshot;
fn create<T>(stm: &T, sequence_number: u64, created_at: u64) -> Result<Self, SnapshotError>
where
T: FillSnapshot<MetadataSnapshot>,
{
let mut snapshot = MetadataSnapshot::new(sequence_number);
snapshot.created_at = created_at;
stm.fill_snapshot(&mut snapshot)?;
Ok(Self { snapshot })
}
fn encode(&self) -> Result<Vec<u8>, SnapshotError> {
self.snapshot.encode()
}
fn decode(bytes: &[u8]) -> Result<Self, SnapshotError> {
let snapshot = MetadataSnapshot::decode(bytes)?;
Ok(Self { snapshot })
}
fn sequence_number(&self) -> u64 {
self.snapshot.sequence_number
}
fn created_at(&self) -> u64 {
self.snapshot.created_at
}
}
/// Coordinates snapshot creation, persistence, and WAL compaction.
///
/// Owns the data directory path and the snapshot creation function. The
/// three-phase checkpoint (persist snapshot, record the pairing durably, drain the
/// WAL) is orchestrated one layer up in `IggyMetadata::checkpoint_if_needed`, so
/// the superblock write can sit between persist and drain; this type owns only the
/// snapshot I/O and the last-checkpoint bookkeeping.
pub struct SnapshotCoordinator<M> {
data_dir: std::path::PathBuf,
create_snapshot: fn(&M, u64, u64) -> Result<IggySnapshot, SnapshotError>,
/// Remaining-journal-slots threshold at which a checkpoint is forced.
/// Defaults to [`Self::CHECKPOINT_MARGIN`]; bootstrap raises it to at
/// least the configured prepare-queue depth (see the static assert and
/// [`Self::set_checkpoint_margin`]).
checkpoint_margin: Cell<usize>,
/// `(checkpoint_op, checkpoint_checksum)` of the last snapshot persisted or
/// recovered at boot, `(0, 0)` when none. A view-change superblock write reads
/// this so it records the current pairing instead of regressing it to zero.
last_checkpoint: Cell<(u64, u128)>,
}
impl<M> SnapshotCoordinator<M> {
/// Default number of remaining journal slots at which a checkpoint is
/// forced. Must stay >= the prepare-queue depth: the ops already
/// pipelined while a checkpoint runs skip it and append into this
/// margin.
///
/// Public so a caller sizing a journal can refuse a slot count at or below it:
/// such a journal checkpoints on every commit rather than on occupancy.
pub const CHECKPOINT_MARGIN: usize = 64;
#[must_use]
pub fn new(
data_dir: std::path::PathBuf,
create_snapshot: fn(&M, u64, u64) -> Result<IggySnapshot, SnapshotError>,
) -> Self {
Self {
data_dir,
create_snapshot,
checkpoint_margin: Cell::new(Self::CHECKPOINT_MARGIN),
last_checkpoint: Cell::new((0, 0)),
}
}
/// Raise (never lower) the forced-checkpoint margin. Bootstrap calls
/// this with the configured prepare-queue depth so a deeper pipeline
/// keeps its guarantee of journal room while a checkpoint runs; the
/// default margin stays the floor.
pub fn set_checkpoint_margin(&self, margin: usize) {
self.checkpoint_margin
.set(margin.max(Self::CHECKPOINT_MARGIN));
}
/// On-disk location of the persisted snapshot; also the artifact state
/// transfer serves and installs.
#[must_use]
pub fn snapshot_path(&self) -> std::path::PathBuf {
self.data_dir
.join(super::METADATA_DIR)
.join(super::SNAPSHOT_FILE_NAME)
}
/// The last persisted checkpoint's `(op, checksum)`, `(0, 0)` when none.
const fn last_checkpoint(&self) -> (u64, u128) {
self.last_checkpoint.get()
}
/// Seed the last-checkpoint pairing at boot from the recovered snapshot, so the
/// first post-boot view-change superblock write records the real pairing rather
/// than `(0, 0)`.
fn seed_last_checkpoint(&self, checkpoint_op: u64, checkpoint_checksum: u128) {
self.last_checkpoint
.set((checkpoint_op, checkpoint_checksum));
}
/// Whether the journal is low enough on capacity to force a checkpoint. Gates
/// on the configurable margin, which bootstrap raises to at least the
/// prepare-queue depth; default [`Self::CHECKPOINT_MARGIN`].
fn should_checkpoint<J: JournalHandle>(&self, journal: &J) -> bool {
journal
.handle()
.remaining_capacity()
.is_some_and(|c| c <= self.checkpoint_margin.get())
}
/// Create and durably persist a snapshot at `commit_op`, record the pairing, and
/// return its checksum. Does NOT drain the WAL: the caller must durably record
/// the pairing in the superblock first, so a crash between persist and drain
/// recovers a consistent checkpoint with the WAL intact. Synchronous, since
/// snapshot creation and `std::fs` persistence never await.
fn persist_snapshot(
&self,
stm: &M,
commit_op: u64,
created_at: u64,
client_table: Option<ClientTableSnapshot>,
) -> Result<u128, SnapshotError> {
let mut snapshot = (self.create_snapshot)(stm, commit_op, created_at)?;
// Fold in the client table, which `create_snapshot` does not see since it is
// not a state machine. Recovery restores it as the reconstruction floor for
// the drained WAL prefix.
snapshot.snapshot.client_table = client_table;
// Encode once: the checksum and the on-disk record share one buffer, so the
// snapshot is not serialized twice while the checkpoint lock freezes this
// core, and the pairing is provably over the bytes that reach the file.
let encoded = snapshot.encode()?;
let checksum = checkpoint_checksum(&encoded);
let path = self.snapshot_path();
IggySnapshot::write_durably(&path, &encoded)?;
self.last_checkpoint.set((commit_op, checksum));
Ok(checksum)
}
/// Drain the snapshotted prefix below `last_op` to reclaim WAL space. Runs
/// only after the pairing is durable (see [`Self::persist_snapshot`]).
///
/// `last_op` itself is retained, one entry the snapshot has already
/// superseded. It is this replica's commit point, and a `DoViewChange`
/// carries a header for every op from there up. Draining it inclusively
/// leaves that entry blank, and blank at the commit point is the one slot
/// the merge can neither adopt nor discard: a quorum of senders that all
/// checkpointed at the same op deadlocks the view change
/// (`dvc_merge::merge_dvc_quorum`). Reclaiming one more entry is not worth
/// a group that cannot elect.
#[allow(clippy::future_not_send)]
async fn drain<J: JournalHandle>(
&self,
journal: &J,
last_op: u64,
) -> Result<(), SnapshotError> {
let Some(drain_to) = last_op.checked_sub(1) else {
return Ok(());
};
journal
.handle()
.drain(0..=drain_to)
.await
.map_err(SnapshotError::Io)?;
Ok(())
}
}
// A checkpoint pauses journal reclamation, not admission: while one runs,
// up to a full prepare queue of already-pipelined ops still needs journal
// room (their `on_replicate` drivers skip the in-flight checkpoint and
// append). The margin must cover them or the journal wraps mid-checkpoint.
const _: () =
assert!(SnapshotCoordinator::<()>::CHECKPOINT_MARGIN >= consensus::PIPELINE_PREPARE_QUEUE_MAX);
/// Failures shared by the in-process metadata submit helpers.
///
/// Returned by [`IggyMetadata::submit_register_in_process`],
/// [`IggyMetadata::submit_logout_in_process`],
/// [`IggyMetadata::submit_request_in_process`], and
/// [`IggyMetadata::submit_delete_personal_access_token_in_process`]. Every variant is
/// transient: the caller retries on a later attempt, and the login/register
/// A committed bind, as returned by
/// [`IggyMetadata::submit_register_in_process`].
///
/// `epoch` is the fence the client must echo in the wire `session` field
/// (the register's commit op). `watermark` is the entry's highest committed
/// request number: 0 for a fresh session, the inherited value on a resume.
/// Callers that kept their own counter can ignore it; the HTTP gateway --
/// whose counter lives in the process that restarted -- seeds its
/// per-session numbering at `watermark + 1`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BoundSession {
pub epoch: u64,
pub watermark: u64,
}
/// handler wraps them in `LoginRegisterError::Transient` so the SDK
/// read-timeout replays.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MetadataSubmitError {
/// Not primary / not Normal.
NotPrimary,
/// Primary but `commit_min < commit_max` (committed prefix not yet
/// drained). Dispatching now would race ops inherited from a prior view;
/// for `Register` that double-commits a register and bumps the epoch
/// past the first reply's, fencing a live client.
NotCaughtUp,
/// Prepare queue full.
PipelineFull,
/// In-flight prepare from this client.
InProgress,
/// The pending prepare was canceled before commit (a view change reset
/// the pipeline). The caller retries; the SDK read-timeout replay reaches
/// the new primary.
Canceled,
/// The node this view names primary is not reachable from this shard, so
/// a forwarded session operation never left. Nothing was proposed.
PrimaryUnreachable,
/// A forwarded session operation left but no verdict came back within the
/// forward timeout. The proposal's outcome is unknown.
ForwardTimedOut,
/// The presented `client_id` already has a table entry owned by a
/// DIFFERENT user. TERMINAL, unlike every sibling: retrying cannot help,
/// and admitting it would run the caller's replicated ops under the
/// entry owner's authority (`resolve_acting_user_id` reads the table).
///
/// Reachable two ways, both of which this refusal closes:
/// - a caller authenticating with its own valid credentials while
/// presenting someone else's `client_id` (the login frame's `client`
/// field is caller-supplied);
/// - after a restart, when WAL-replay recovery has rebuilt entries under
/// the previous boot's ids while the HTTP id minter restarts at 1, so
/// an honest login lands on a recovered entry owned by another user.
ClientIdOwnedByAnotherUser,
}
impl MetadataSubmitError {
/// Whether a retry (here, or against another replica) could succeed.
/// Every variant is transient by contract except the ownership refusal.
/// Deliberately a deny-list: a new variant is transient by default, so
/// adding one cannot silently surface a terminal error to clients.
#[must_use]
pub const fn is_transient(&self) -> bool {
!matches!(self, Self::ClientIdOwnedByAnotherUser)
}
}
impl std::fmt::Display for MetadataSubmitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotPrimary => f.write_str("not primary in normal status"),
Self::NotCaughtUp => f.write_str("primary not yet caught up on commit_journal"),
Self::PipelineFull => f.write_str("metadata prepare queue is full"),
Self::InProgress => f.write_str("another in-flight prepare from this client"),
Self::Canceled => f.write_str("view change canceled the pending prepare"),
Self::PrimaryUnreachable => f.write_str("no route to the metadata primary"),
Self::ForwardTimedOut => {
f.write_str("the metadata primary did not answer the forwarded register")
}
Self::ClientIdOwnedByAnotherUser => {
f.write_str("client id already registered to a different user")
}
}
}
}
impl std::error::Error for MetadataSubmitError {}
/// Log + surface `None` when a metadata callback runs on a peer shard
/// (whose `consensus` / `journal` slot is `None`). The `Plane` trait
/// callbacks are addressed to the shard 0 owner; if the routing layer
/// ever dispatches one to a peer the only honest answer is "drop and
/// alert" - panicking would crash the bus and mask the routing bug.
fn require_shard_zero<'a, T>(
slot: Option<&'a T>,
callback: &'static str,
field: &'static str,
) -> Option<&'a T> {
if slot.is_none() {
error!(
target: "iggy.metadata.diag",
plane = "metadata",
callback,
field,
"metadata callback fired on a peer shard (field is None); routing layer \
must direct metadata traffic to shard 0 - dropping the message"
);
}
slot
}
/// Apply one committed prepare to the state machine and client table.
///
/// The backup commit walk's per-op logic, shared so the simulator's WAL
/// reconstruction reaches identical state from the same log through one apply path.
/// Register creates or rebinds a session (no state-machine op); Logout drops the
/// session and rebalances consumer groups; every other op applies to the state
/// machine and caches the reply for at-most-once dedup. `fire_notifier` runs the
/// post-commit hook (a no-op during reconstruction, before it is wired). Does not
/// advance `commit_min`; the caller owns that counter.
///
/// `table_mutations_allowed` gates the CLIENT-TABLE half only; the state
/// machine always applies. After a state transfer the two artifacts sit at
/// different frontiers -- the snapshot at `S`, the transferred table at
/// `C >= S` -- so the tail replay over `(S, commit_max]` must run every state
/// machine effect (they are above the snapshot) while skipping table effects at
/// or below `C` (they are already in the transferred table). Pass `true`
/// wherever no transfer is in play.
///
/// # Panics
/// If a committed op fails to apply, which is a decode/corruption bug, since a
/// business rejection commits as a no-op rather than erroring: the committed log
/// must apply cleanly on every replica.
pub fn apply_committed_prepare<M>(
mux_stm: &M,
client_table: &RefCell<ClientTable>,
table_mutations_allowed: bool,
fire_notifier: impl Fn(Operation),
prepare: Message<PrepareHeader>,
) where
M: StreamsFrontend
+ StateMachine<
Input = Message<PrepareHeader>,
Output = crate::stm::result::ApplyReply,
Error = iggy_common::IggyError,
>,
{
let header = *prepare.header();
if header.operation == Operation::Register {
// Register: commit_register creates the session, no state-machine op.
if table_mutations_allowed {
let reply = build_reply_message(&header, &bytes::Bytes::new());
client_table
.borrow_mut()
.commit_register(header.client, header.user_id, reply);
}
return;
}
if header.operation == Operation::Logout {
if table_mutations_allowed {
client_table.borrow_mut().remove_client(
header.client,
header.user_id,
SessionEnd::from_logout_request(header.request),
);
}
// Drop the disconnected client from every consumer group it joined and
// rebalance, Logout's only state-machine effect.
mux_stm.streams().remove_consumer_group_member(
header.client,
iggy_common::IggyTimestamp::from(header.timestamp),
);
return;
}
// Normal op: apply, build the reply. `Err` is decode/corruption only; a
// business rejection commits as a deterministic no-op whose code rides
// the reply body, replayed on retry.
let apply = gated_apply(mux_stm, prepare).unwrap_or_else(|err| {
panic!(
"apply_committed_prepare: committed metadata op={} failed to apply: {err}",
header.op
);
});
fire_notifier(header.operation);
let reply = build_reply_message_with(&header, apply.reply_body_len(), |dst| {
apply.write_reply_body(dst);
});
// Best-effort cache; a WAL replay may carry a reply for a later-evicted
// client, and replica-local eviction makes a stale-request replay
// reachable. Both are skips, not faults.
if table_mutations_allowed {
let outcome = client_table
.borrow_mut()
.commit_reply(header.client, header.user_id, reply);
log_commit_reply_outcome(outcome, header.client, header.op);
}
}
/// Late-bound callback invoked after every committed op on shard 0's metadata
/// commit path (via `gated_apply`, including a gated no-op).
///
/// Wired by the server bootstrap once the metadata bundle has broadcast;
/// receives the committed [`Operation`] so the recipient can filter (the
/// partition reconciliation loop only cares about partition-shaped
/// events). Wrapped in [`RefCell`] for late binding; the per-shard
/// single-thread invariant keeps access safe without [`Sync`].
pub type CommitNotifier = std::rc::Rc<dyn Fn(Operation)>;
pub struct IggyMetadata<C, J, S, M, SB = PingPongSuperblock> {
/// `Some` on shard 0, `None` on other shards. Server-ng bootstrap
/// holds the invariant: only shard 0 owns the metadata consensus
/// replica; every other shard reconstructs `mux_stm` from the
/// `MetadataHandoff::Waiter` factory bundle broadcast by shard 0
/// (no consensus replica, no journal access).
pub consensus: Option<C>,
/// `Some` on shard 0, `None` on other shards. Shard 0 owns the WAL
/// writer (via `PrepareJournal::open`); non-owning shards never open
/// the WAL at all. They receive a `MetadataHandoff::Waiter` factory
/// bundle from shard 0 over the bootstrap broadcast channel and
/// reconstruct `mux_stm` from the in-memory snapshot it carries (see
/// `server/src/boot/handoff.rs` `await_metadata_bundle` /
/// `broadcast_metadata_bundle`).
pub journal: Option<J>,
/// `Some` on shard 0, `None` on other shards.
pub snapshot: Option<S>,
/// Durable VSR-state record (`view`/`log_view`/`commit`). `Some` only on the
/// shard owning metadata consensus (shard 0); `None` on peer shards. Generic
/// (`PingPongSuperblock` in production, `SimSuperblock` in the simulator,
/// recording doubles in tests) and behind `Rc` so the simulator harness can
/// keep a clone outliving a replica across a restart.
pub superblock: Option<Rc<SB>>,
/// Serializes superblock writes on shard 0 so at most one is in flight.
/// View-change persists ([`Self::persist_superblock_if_needed`]) and checkpoints
/// ([`Self::checkpoint_if_needed`]) share the one ping-pong superblock, and
/// in-process metadata submits each run on their own spawned task, so both can
/// reach a write concurrently. `PingPongSuperblock::write` picks its slot before
/// it awaits, so two overlapping writers would target the same slot and could
/// tear it.
///
/// Scoped to the write itself, NOT to a whole checkpoint. A pending view persist
/// blocks every gated send behind it, including the ack path's
/// `send_prepare_ok`, so it must not also wait out a checkpoint's snapshot
/// encode, two `std::fs` fsyncs and an async WAL drain. Whoever writes builds
/// its `VsrState` inside this section with no await in between, so the last
/// writer carries the freshest view and the durable view cannot regress.
///
/// Held across the write `.await`, so it uses the same single-threaded,
/// cancel-safe [`LocalGate`] as `journal_gate`, a `Cell` flag with no atomics:
/// this shard is never `Sync`, so a `tokio::sync::Mutex` would only add an
/// atomic RMW per gated send for exclusion the `Cell` already provides.
superblock_lock: LocalGate,
/// Serializes whole checkpoints against each other: `persist_snapshot` renames
/// over the single `snapshot.bin` and `drain` rewrites the WAL through a shared
/// `wal.tmp`, so two concurrent checkpoints would race both. Distinct from
/// [`Self::superblock_lock`], which a checkpoint takes only for its own pairing
/// write. A checkpoint holds this one and then acquires that one; nothing takes
/// them in the other order.
checkpoint_lock: LocalGate,
/// Consecutive failed superblock writes, and the clock reading after which the
/// next attempt may run. A persistent `ENOSPC` / `EIO` would otherwise re-run a
/// full `atomic_replace` (create, write, fsync, rename, dir fsync) on every 10 ms
/// consensus tick, on the executor that also serves partition traffic. Reset on
/// the first success. See [`Self::persist_superblock_if_needed`] for the terminal
/// policy.
superblock_write_failures: Cell<u64>,
superblock_retry_after_micros: Cell<u64>,
/// State machine - lives on all shards
pub mux_stm: M,
pub allocator: ConsensusGroupAllocator,
/// Snapshot coordinator - present when persistent checkpointing is configured.
pub coordinator: Option<SnapshotCoordinator<M>>,
/// Serializes `on_replicate`'s journal-mutation section (forced
/// checkpoint + WAL append) across concurrent drivers (the pump loop,
/// detached per-client submit tasks, repair). Ungated they race
/// `SnapshotCoordinator::checkpoint`: every driver crossing the
/// `remaining_capacity <= CHECKPOINT_MARGIN` boundary runs a full
/// checkpoint, and the concurrent `journal.drain()` calls collide on the
/// WAL rewrite -- shared `wal.tmp`, ENOENT for every rename that loses,
/// short reads after the winner's reopen. Appends racing a drain are just
/// as unsound: the drain's live-set partition misses an append landing
/// mid-rewrite and the rewrite silently discards it. See [`LocalGate`].
journal_gate: LocalGate,
/// Per-client session state (sessions, dedup, eviction). Metadata-only.
pub client_table: RefCell<ClientTable>,
/// Late-bound post-commit notifier. Fires once per committed normal op
/// after `gated_apply` returns (including a gated `Unauthorized` no-op that
/// never reaches [`crate::stm::StateMachine::update`]) in both
/// [`Plane::on_ack`] and [`Self::commit_journal`]. `None` until
/// [`Self::set_commit_notifier`] runs (the server bootstrap on shard
/// 0 sets it; peer shards and tests leave it `None`).
commit_notifier: RefCell<Option<CommitNotifier>>,
/// Client-table mutations at or below this op are already reflected in a
/// state-transferred table, so the tail-repair commit walk must skip
/// them (re-running `commit_register` would double-bump epochs). `0`
/// outside state transfer (no op is skipped). Monotone per install.
client_table_frontier: Cell<u64>,
/// Last built [`StateTransferOffer`], shared by every requester of the same
/// snapshot generation. Rebuilding per request re-reads and re-decodes the
/// whole snapshot on shard 0's pump, and hands each requester its own
/// multi-MB copy.
transfer_offer_cache: RefCell<Option<Rc<StateTransferOffer>>>,
}
impl<C, J, S, M, SB> IggyMetadata<C, J, S, M, SB>
where
M: StreamsFrontend + FillSnapshot<MetadataSnapshot>,
{
/// Create a new `IggyMetadata` instance.
///
/// The `FillSnapshot<MetadataSnapshot>` bound is captured here via a
/// function pointer so that no downstream caller needs the bound.
#[must_use]
pub fn new(
consensus: Option<C>,
journal: Option<J>,
snapshot: Option<S>,
superblock: Option<Rc<SB>>,
mux_stm: M,
data_dir: Option<std::path::PathBuf>,
) -> Self {
let allocator =
ConsensusGroupAllocator::new(mux_stm.streams().highest_partition_consensus_group_id());
let coordinator = data_dir.map(|dir| SnapshotCoordinator::new(dir, IggySnapshot::create));
Self {
consensus,
journal,
snapshot,
superblock,
superblock_lock: LocalGate::new(),
checkpoint_lock: LocalGate::new(),
superblock_write_failures: Cell::new(0),
superblock_retry_after_micros: Cell::new(0),
mux_stm,
allocator,
coordinator,
journal_gate: LocalGate::new(),
client_table: RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX)),
commit_notifier: RefCell::new(None),
client_table_frontier: Cell::new(0),
transfer_offer_cache: RefCell::new(None),
}
}
}
impl<C, J, S, M, SB> IggyMetadata<C, J, S, M, SB> {
/// Slot capacity of the LIVE client table, i.e. the largest transferred
/// table this replica can absorb.
///
/// Read at decode instead of a separately plumbed `clients_table_max`: the
/// live table sizes itself to `max(configured, highest recovered slot + 1)`,
/// so a serving primary can legitimately hold more entries than this node's
/// raw config value and decoding against that value would reject every
/// round.
#[must_use]
pub fn client_table_capacity(&self) -> usize {
self.client_table.borrow().capacity()
}
/// Drop the cached state-transfer offer, releasing its snapshot copy.
///
/// Called by the shard's expiry sweep once no requester holds an offer:
/// the cache exists to collapse repeat builds within one rejoin, not to
/// pin a snapshot for the life of the process.
pub fn clear_state_transfer_offer_cache(&self) {
self.transfer_offer_cache.borrow_mut().take();
}
/// Install (or replace) the post-commit notifier. Passing `None`
/// removes any previous one. Server-ng bootstrap calls this on shard 0
/// only; peer shards never commit metadata locally.
pub fn set_commit_notifier(&self, notifier: Option<CommitNotifier>) {
*self.commit_notifier.borrow_mut() = notifier;
}
/// Seed the coordinator's last-checkpoint pairing at boot from the recovered
/// snapshot, so the first post-boot view-change superblock write records the real
/// `(checkpoint_op, checksum)` instead of `(0, 0)`. No-op without a coordinator
/// (peer shards, the simulator). Server-ng bootstrap calls this on shard 0 after
/// cross-checking the pairing.
pub fn seed_checkpoint_ref(&self, checkpoint_op: u64, checkpoint_checksum: u128) {
if let Some(coordinator) = &self.coordinator {
coordinator.seed_last_checkpoint(checkpoint_op, checkpoint_checksum);
}
}
/// Install the client table rebuilt by WAL-replay recovery
/// ([`crate::impls::recovery::recover`]). Boot-time only, on the owning
/// shard, before it serves traffic - replacing a live table would drop
/// committed session state. (State transfer replaces a LIVE table via
/// [`IggyMetadata::install_state_transfer`], which also stamps the
/// frontier.)
///
/// Refuses (leaving the live table in place) when the current table
/// already holds sessions, which means a client registered before recovery
/// installed its table. Dropping those entries would leave each client
/// holding an epoch the table no longer knows, so its next request reads
/// as `NoSession`. A refusal is deliberately not a panic: this runs on the
/// boot path, where taking the node down is a worse outcome than booting
/// with the sessions it already has.
///
/// # Returns
/// `true` when the recovered table was installed.
pub fn install_client_table(&self, client_table: ClientTable) -> bool {
let mut current = self.client_table.borrow_mut();
if current.count() > 0 {
error!(
live_sessions = current.count(),
recovered_sessions = client_table.count(),
"install_client_table: refusing to replace a table that already holds sessions; \
keeping the live one"
);
return false;
}
*current = client_table;
true
}
/// Client-table mutations at or below the frontier are already in the
/// state-transferred table; the commit walk skips them (re-running
/// `commit_register` would double-bump epochs). STM effects still apply
/// -- the frontier fences the TABLE only.
const fn client_table_mutation_allowed(&self, op: u64) -> bool {
op > self.client_table_frontier.get()
}
/// Raise the forced-checkpoint margin to cover a configured
/// prepare-queue depth (`[metadata] prepare_queue_depth`). Clamped to
/// the built-in floor by the coordinator; no-op on shards without a
/// coordinator.
pub fn set_checkpoint_margin(&self, margin: usize) {
if let Some(coordinator) = &self.coordinator {
coordinator.set_checkpoint_margin(margin);
}
}
/// Size the VSR client table to `[metadata] clients_table_max`
/// (see [`ClientTable::set_capacity`]). Boot-only, before any client
/// registers and before [`Self::install_client_table`]: the resize
/// rebuilds the table, so a recovered one installed first would be lost.
pub fn set_clients_table_max(&self, max_clients: usize) {
self.client_table.borrow_mut().set_capacity(max_clients);
}
/// Fire post-commit notifier. Clones the `Rc` out under a short
/// borrow so a re-entrant `set_commit_notifier` from inside the
/// closure cannot panic on `borrow_mut`.
fn fire_commit_notifier(&self, operation: Operation) {
let notifier = self.commit_notifier.borrow().as_ref().map(Rc::clone);
if let Some(notifier) = notifier {
notifier(operation);
}
}
}
/// Stop the process after a WAL append failed and left a claim
/// [`VsrConsensus::rollback_pipelined_prepare`] could not prove was still its own.
///
/// The frontier is then an op ahead of the WAL with no local path back: the failed
/// prepare was never broadcast, so no peer can repair from it. Stopping IS the repair,
/// not an escalation: recovery re-derives the frontier from the WAL, which the failed
/// write never reached. Every alternative keeps serving on numbers this replica just
/// proved it cannot trust.
fn fatal_on_unreconcilable_frontier<B>(
consensus: &VsrConsensus<B>,
op: u64,
error: &std::io::Error,
rollback: PrepareRollback,
) -> !
where
B: MessageBus,
{
fatal(
FatalReason::UnreconcilableLogFrontier,
&format!(
"metadata replica {replica} failed to append op {op} ({error}) and could not hand \
the op back ({rollback:?}); the in-memory frontier is ahead of the WAL with no \
local path back, so this node stops and recovers its frontier from the log",
replica = consensus.replica(),
),
);
}
#[allow(clippy::future_not_send)]
impl<B, J, S, M, SB> Plane<VsrConsensus<B>> for IggyMetadata<VsrConsensus<B>, J, S, M, SB>
where
B: MessageBus,
SB: SuperblockStore,
J: JournalHandle,
J::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: StreamsFrontend
+ StateMachine<
Input = Message<PrepareHeader>,
Output = crate::stm::result::ApplyReply,
Error = iggy_common::IggyError,
>,
{
async fn on_request(
&self,
message: <VsrConsensus<B> as Consensus>::Message<RoutedRequestHeader>,
) {
let Some(consensus) =
require_shard_zero(self.consensus.as_ref(), "on_request", "consensus")
else {
return;
};
let client_id = message.header().client;
let session = message.header().session;
let request = message.header().request;
let request_checksum = message.header().request_checksum;
let operation = message.header().operation;
let user_id = message.header().user_id;
// Preflight first: dedup, eviction sends, cached-reply replay all
// must run regardless of pipeline pressure. Wire-path ingress has no
// home-shard transport context, so resends fall back to the
// consensus-plane (best-effort by VSR id).
let dispatch = if operation == Operation::Register {
register_preflight(consensus, &self.client_table, client_id, user_id)
} else {
let outcome = request_preflight(
consensus,
&self.client_table,
client_id,
session,
request,
request_checksum,
);
apply_preflight_consensus_plane(consensus, outcome, client_id).await
};
if !dispatch {
return;
}
emit_sim_event(
SimEventKind::ClientRequestReceived,
&RequestLogEvent {
replica: ReplicaLogContext::from_consensus(consensus, PlaneKind::Metadata),
client_id: message.header().client,
request_id: message.header().request,
operation: message.header().operation,
},
);
// Two-queue admission: prepare slot then project+replicate; prepare
// full + request room then buffer; both full then drop+warn (SDK
// retries via read-timeout).
if consensus.pipeline_is_full() {
let push_result = consensus.push_queued_request(consensus::RequestEntry::new(message));
if push_result.is_err() {
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
client = client_id,
request = request,
"on_request: prepare and request queues both full, dropping"
);
}
return;
}
let prepare = match self.prepare_request(message) {
Ok(prepare) => prepare,
Err(error) => {
// Structurally-invalid request (not client-allowed, undecodable
// body, or partition-id overflow). Evict instead of dropping: a
// silent drop leaves the client unable to tell rejection from
// loss, retrying forever.
let reason = eviction_reason_for_invalid(operation);
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
error = %error,
?reason,
"rejecting invalid metadata request with eviction"
);
send_eviction_to_client(consensus, client_id, reason).await;
return;
}
};
pipeline_prepare_common(consensus, PlaneKind::Metadata, prepare, |prepare| {
self.on_replicate(prepare)
})
.await;
}
#[allow(clippy::too_many_lines)]
async fn on_replicate(&self, message: <VsrConsensus<B> as Consensus>::Message<PrepareHeader>) {
let Some(consensus) =
require_shard_zero(self.consensus.as_ref(), "on_replicate", "consensus")
else {
return;
};
let Some(journal) = require_shard_zero(self.journal.as_ref(), "on_replicate", "journal")
else {
return;
};
let header = *message.header();
// Before anything trusts `checksum` as an identity token, and before the WAL
// takes the bytes. Every live prepare travels this path: unverified, a frame
// corrupted between primary and backup is journaled as-is and re-served to
// peers, which the interior-corruption boot refusal turns into an unbootable
// node on the next restart.
if let Err(reason) = verify_prepare_integrity(&header, message.as_slice()) {
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
view = consensus.view(),
op = header.op,
"discarding prepare: {reason}"
);
return;
}
let current_op = match replicate_preflight(consensus, &header) {
Ok(current_op) => current_op,
Err(reason) => {
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
view = consensus.view(),
op = header.op,
operation = ?header.operation,
reason = reason.as_str(),
"ignoring prepare during replicate preflight"
);
return;
}
};
// Fenced by commit: the whole chain has already committed this op, so
// nobody needs it again. Drop entirely. (Mirror of the partition
// plane's split in `IggyPartition::on_replicate`.)
#[allow(clippy::cast_possible_truncation)]
if fence_old_prepare_by_commit(consensus, &header) {
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
view = consensus.view(),
op = header.op,
commit = consensus.commit_max(),
operation = ?header.operation,
"received old prepare (<= commit), skipping replication"
);
return;
}
// Durable here but not yet committed, and the primary is retransmitting
// it: our original PrepareOk was lost (e.g. the primary's inbox
// overflowed under a client burst). Re-forward the tail down the chain
// so a downstream replica that missed it recovers, then re-ack ONLY
// the retransmitted op. The primary's retransmit cycle walks every
// un-acked op in the window (`retransmit_targets`), so a lost ack for
// a lower op gets its own retransmit and its own re-ack; re-acking the
// whole suffix here is O(window^2) PrepareOks per cycle across the
// backups, which can overflow the primary's inbox -- the very failure
// this path recovers from. Both downstream and primary are idempotent
// on a duplicate (replica, op).
#[allow(clippy::cast_possible_truncation)]
if journal.handle().header(header.op as usize).is_some() {
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
view = consensus.view(),
op = header.op,
commit = consensus.commit_max(),
operation = ?header.operation,
"journal already holds prepare, re-forwarding + re-acking it"
);
self.replicate(&message).await;
self.send_prepare_ok(&header).await;
return;
}
// Verify hash chain integrity BEFORE checkpoint. `checkpoint_if_needed`
// can drain WAL entries, making previous_header return None.
if let Some(previous) = journal.handle().previous_header(&header) {
panic_if_hash_chain_would_break_in_same_view(&previous, &header);
}
// Serialize the journal-mutation section (forced checkpoint + append)
// across concurrent `on_replicate` drivers. Ungated, every driver
// crossing the checkpoint boundary ran its own checkpoint and the
// concurrent `drain()`s raced the WAL rewrite (`snapshot I/O error:
// No such file or directory`) — the single-node "metadata prepare
// queue is full" wedge. Held through the append so a drain can never
// rewrite the WAL out from under a racing append either.
let journal_gate = self.journal_gate.acquire().await;
// Best-effort WAL reclamation. A failed checkpoint must NOT drop the
// prepare: `pipeline_message` already pushed the pipeline entry and
// pre-advanced the sequencer, so bailing out here leaves a phantom op
// that no repair path re-prepares — the commit frontier gaps behind
// it permanently and the pipeline wedges full. `CHECKPOINT_MARGIN >=
// PIPELINE_PREPARE_QUEUE_MAX` (static assert above) guarantees the
// append below still has room after a failed or skipped attempt; a
// journal that truly wraps is refused by append's slot-collision
// guard, not here.
self.checkpoint_if_needed(consensus, journal).await;
// Backup: gap check against the JOURNAL head, not the sequencer.
//
// The two frontiers can disagree. The sequencer is pre-advanced on the
// primary by `push_prepare_entry` and re-synced on a backup only after a
// successful append, so a replica can carry a sequencer one ahead of what
// its WAL holds. Gating admission on it then rejects the very prepare that
// would heal the log: a backup with `last_op = 44` refused op 45 because
// its sequencer said to expect 46. The primary retransmits that op for the
// life of the process, every backup logs an out-of-order gap, it never
// reaches a commit quorum, and its client is never answered.
//
// `max(last_op, snapshot_op)`, never `last_op` alone. A state transfer
// installs a snapshot that IS ops `..=snapshot_op` applied and truncates the
// WAL above that floor rather than refilling below it, so `last_op` reads the
// receiver as needing an op the snapshot already contains and no peer will
// send again. That drop
// never heals: an offer built on a quiet cluster carries `commit_op ==
// snapshot_seq`, so the install lands `commit_min == commit_max`, and
// `maybe_request_metadata_repair`, the only path that refills the head,
// arms on `commit_min < commit_max`. With the other backup down the primary
// needs this replica's ack to commit anything, so the plane stops on a
// cluster still inside its quorum.
//
// The journal is the only frontier that answers "what can be appended
// next", which is what this check is for, and the hash-chain verification
// below is stated against it too. A prepare at or below the head that this
// replica already holds was re-acked and returned above. What reaches HERE
// is the next op or a gap, and not every gap is fillable: metadata repair
// covers only `commit_min + 1 ..= commit_max`, so an interior hole below
// the head and a forward gap above `commit_max` both sit outside it.
let is_backup = consensus.is_follower();
if is_backup {
let handle = journal.handle();
let journal_head = handle.last_op().unwrap_or(0).max(handle.snapshot_op());
if header.op != journal_head + 1 {
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
op = header.op,
expected = journal_head + 1,
sequencer_op = current_op,
"on_replicate: dropping out-of-order prepare (gap)"
);
return;
}
} else {
debug_assert_eq!(
header.op, current_op,
"primary: sequencer pre-advance broken"
);
}
// Journal append first; sequencer + checksum after successful append
// so a failed write doesn't leave state pointing at a phantom entry.
//
// Durability BEFORE chain-replicate / PrepareOk: forwarding an
// un-persisted prepare advertises an op the WAL doesn't hold,
// violates VSR tail-ahead-of-head, recoverable only via hash-chain
// fence + view change (burns a view).
//
// On the primary the pre-advance in `push_prepare_entry` already claimed
// this op, so a failed append has to hand it back or the next prepare
// chains off a phantom (see `rollback_pipelined_prepare`). A refused rollback
// leaves the op claimed with nothing durable behind it and no protocol path
// back, so the process stops rather than serving on it.
if let Err(e) = journal.handle().append(message.clone()).await {
let rollback = consensus.rollback_pipelined_prepare(&header);
error!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
op = header.op,
operation = ?header.operation,
error = %e,
rollback = ?rollback,
"journal append failed"
);
match rollback {
// `Unwound`: the op went back and the waiting client wakes with
// `Canceled`. `NotPreAdvanced`: a backup never claimed it, advancing
// only after its own append succeeds. Neither leaves a disagreement.
PrepareRollback::Unwound | PrepareRollback::NotPreAdvanced => {}
// Every refusal means the same thing: the claim could not be proved
// still this prepare's, so it cannot be safely reversed. Not split
// further, since deciding per variant which disagreements are
// survivable is the case analysis stopping exists to avoid.
PrepareRollback::Superseded { .. }
| PrepareRollback::Overtaken { .. }
| PrepareRollback::TailMismatch => {
fatal_on_unreconcilable_frontier(consensus, header.op, &e, rollback);
}
}
return;
}
// Journal mutation done; wire traffic below must not hold the gate.
drop(journal_gate);
// Durable; chain-replicate. `replicate` borrows + freezes; we keep
// message for the sequencer/checksum bookkeeping below.
self.replicate(&message).await;
self.observe_prepare_runtime_state(&message);
// Backup only: advance sequencer + checksum post-append. Primary
// already advanced in push_prepare_entry; re-setting here would
// rewind a sibling prepare pipelined during the append await to a
// stale op + parent, projecting a duplicate next.
if is_backup {
consensus.sequencer().set_sequence(header.op);
consensus.set_last_prepare_checksum(header.checksum);
consensus.observe_prepare_timestamp(header.timestamp);
}
// After successful journal write, send prepare_ok to primary.
self.send_prepare_ok(&header).await;
// If follower, commit any newly committable entries.
if consensus.is_follower() {
self.commit_journal().await;
}
}
#[allow(clippy::too_many_lines)]
async fn on_ack(&self, message: <VsrConsensus<B> as Consensus>::Message<PrepareOkHeader>) {
let consensus = self.consensus.as_ref().unwrap();
let header = message.header();
if let Err(reason) = ack_preflight(consensus) {
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
view = consensus.view(),
op = header.op,
reason = reason.as_str(),
"ignoring ack during preflight"
);
return;
}
{
if !consensus.pipeline_holds_entry(header.op, header.prepare_checksum) {
debug!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
op = header.op,
prepare_checksum = header.prepare_checksum,
"ack target prepare not in pipeline"
);
return;
}
}
let quorum = ack_quorum_reached(consensus, PlaneKind::Metadata, header);
if quorum {
debug!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
op = header.op,
"ack quorum received"
);
self.commit_committable_prefix().await;
}
}
}
impl<B, P, J, S, M, SB> PlaneIdentity<VsrConsensus<B, P>>
for IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
J: JournalHandle,
J::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: StateMachine<Input = Message<PrepareHeader>>,
{
fn is_applicable<H>(&self, message: &<VsrConsensus<B, P> as Consensus>::Message<H>) -> bool
where
H: ConsensusHeader,
{
assert!(matches!(
message.header().command(),
Command::Request | Command::Prepare | Command::PrepareOk
));
message.header().operation().is_metadata_plane()
}
}
/// One state-transfer serving payload.
///
/// The on-disk snapshot payload plus the live client table, both
/// frontier-stamped. Built by [`IggyMetadata::state_transfer_offer`] on the
/// serving primary; the shard serves chunks out of it and shares one instance
/// across every requester of the same snapshot generation.
pub struct StateTransferOffer {
/// Serving primary's applied frontier when the offer was built; the
/// receiver's tail repair targets past this.
pub commit_op: u64,
/// The offered snapshot's `sequence_number`, i.e. the generation this
/// offer describes. Reused as the cache key: a later checkpoint rewrites
/// `snapshot.bin` and invalidates every payload below.
pub snapshot_seq: u64,
/// Manifest entries paired with their bytes. One `Vec` of pairs rather
/// than two index-aligned `Vec`s: the manifest is encoded in one file and
/// the chunks served in another, so a desync would be invisible at both
/// ends. Metadata plane: `[METADATA_SNAPSHOT (frontier = sequence_number),
/// CLIENT_TABLE (frontier = commit_min at encode)]`.
///
/// Payloads are refcounted so n simultaneous rejoiners share one copy
/// rather than pinning n multi-MB snapshots on shard 0.
pub artifacts: Vec<(consensus::StateArtifact, Rc<Vec<u8>>)>,
}
impl StateTransferOffer {
/// Manifest entries for the descriptor body.
#[must_use]
pub fn manifest(&self) -> Vec<consensus::StateArtifact> {
self.artifacts.iter().map(|(entry, _)| *entry).collect()
}
/// Bytes of the artifact at `index` in manifest order.
#[must_use]
pub fn payload(&self, index: usize) -> Option<&[u8]> {
self.artifacts.get(index).map(|(_, bytes)| bytes.as_slice())
}
/// Number of artifacts on offer.
#[must_use]
pub const fn len(&self) -> usize {
self.artifacts.len()
}
/// Whether the offer carries no artifacts at all.
#[must_use]
pub const fn is_empty(&self) -> bool {
self.artifacts.is_empty()
}
/// Total advertised bytes across every artifact.
#[must_use]
pub fn total_len(&self) -> u64 {
self.artifacts.iter().map(|(entry, _)| entry.len).sum()
}
}
/// Why this replica cannot serve a state transfer right now.
///
/// Named rather than folded into `None` so the refusal the requester sees is
/// logged with its actual cause: "no snapshot persisted" and "snapshot.bin is
/// corrupt" call for opposite operator responses.
#[derive(Debug)]
pub enum StateTransferUnavailable {
/// Not a caught-up primary, so a client-table read would not be
/// authoritative.
NotCaughtUpPrimary,
/// This shard has no snapshot coordinator, so it never checkpoints.
NoCoordinator,
/// No snapshot has ever been persisted. The WAL still holds the full
/// history, so the requester's journal repair covers its whole gap.
NoSnapshot,
/// `snapshot.bin` exists but could not be read, or failed its integrity
/// trailer. Refusing is strictly better than shipping it: the receiver
/// would re-seal the corruption under a fresh valid trailer.
SnapshotUnreadable(SnapshotError),
}
impl std::fmt::Display for StateTransferUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotCaughtUpPrimary => write!(f, "not a caught-up primary"),
Self::NoCoordinator => write!(f, "no snapshot coordinator on this shard"),
Self::NoSnapshot => write!(f, "no snapshot has been persisted yet"),
Self::SnapshotUnreadable(source) => {
write!(f, "persisted snapshot is unreadable: {source}")
}
}
}
}
impl std::error::Error for StateTransferUnavailable {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::SnapshotUnreadable(source) => Some(source),
_ => None,
}
}
}
/// What a completed [`IggyMetadata::install_state_transfer`] landed.
///
/// A degraded install is reported HERE rather than as an `Err`, because it is
/// a success: the snapshot, table, frontiers and commit point are all in
/// place by the time the pairing write is attempted. Returning it as an error
/// invites a caller to treat a completed install as a failure and redo it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InstallOutcome {
/// The receiver's new applied frontier, `max(snapshot_seq,
/// local_applied)`. These differ whenever a serving peer offered a
/// snapshot BEHIND this replica and the local state machine was kept.
pub applied_frontier: u64,
/// Whether the transferred checkpoint's `(checkpoint_op, checksum)`
/// pairing reached the durable superblock.
///
/// `false` leaves the install fully usable: the coordinator already holds
/// the new pairing, so the next superblock write (view change or
/// checkpoint) records it. Until then a crash recovers the PREVIOUS
/// checkpoint and this replica transfers again -- correct, just wasted
/// work.
pub pairing_durable: bool,
}
impl<B, J, S, M, SB> IggyMetadata<VsrConsensus<B>, J, S, M, SB>
where
B: MessageBus,
SB: SuperblockStore,
J: JournalHandle,
J::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: StreamsFrontend
+ StateMachine<
Input = Message<PrepareHeader>,
Output = crate::stm::result::ApplyReply,
Error = iggy_common::IggyError,
>,
{
/// Build a state-transfer offer for a restarted peer.
///
/// The snapshot is served as the on-disk PAYLOAD, with its integrity
/// trailer verified and stripped. Both halves matter. Verified, because a
/// flipped bit inside the payload that still msgpack-decodes would
/// otherwise be re-sealed on the receiver under a fresh valid trailer and
/// a matching pairing: a fault the source node refuses to boot over would
/// become undetectable on the second node. Stripped, because the receiver
/// re-persists what it is sent through `write_durably`, which appends a
/// trailer of its own -- shipping the sealed file grows `snapshot.bin` by
/// one trailer per transfer generation and leaves it byte-shape-different
/// from a locally checkpointed one.
///
/// The snapshot may be stale, which costs nothing: the receiver
/// journal-repairs `(snapshot_seq, commit_max]` afterwards through the
/// existing repair machinery. The table is encoded live at this instant;
/// both frontier stamps read `commit_min` inside one synchronous region,
/// so they are mutually consistent.
///
/// The result is cached and shared: a repeat request for the same snapshot
/// generation reuses it instead of re-reading and re-decoding the file on
/// shard 0's pump.
///
/// # Errors
/// [`StateTransferUnavailable`] naming why this replica cannot serve.
pub fn state_transfer_offer(&self) -> Result<Rc<StateTransferOffer>, StateTransferUnavailable> {
let consensus = self
.consensus
.as_ref()
.ok_or(StateTransferUnavailable::NoCoordinator)?;
if !is_caught_up_primary(consensus) {
return Err(StateTransferUnavailable::NotCaughtUpPrimary);
}
let coordinator = self
.coordinator
.as_ref()
.ok_or(StateTransferUnavailable::NoCoordinator)?;
let path = coordinator.snapshot_path();
if !path.exists() {
return Err(StateTransferUnavailable::NoSnapshot);
}
let sealed = std::fs::read(&path)
.map_err(|source| StateTransferUnavailable::SnapshotUnreadable(source.into()))?;
// Verifies the trailer and hands back the payload alone.
let (payload, _) =
split_trailer(&sealed, &path).map_err(StateTransferUnavailable::SnapshotUnreadable)?;
// Still decoded rather than read off `last_checkpoint()`: `write_durably`
// renames before the parent-dir fsync, so a DirSync failure leaves the new
// file live with that cell stale, and the offer would then under-advertise
// the frontier it is actually shipping.
let snapshot_seq = IggySnapshot::decode(payload)
.map_err(StateTransferUnavailable::SnapshotUnreadable)?
.sequence_number();
// Reuse the cached offer for this generation. Only the SNAPSHOT half is
// expensive to rebuild, and the cached table is merely older, never
// incoherent: its frontier is stamped at its own encode, and the receiver
// replays everything above that frontier during tail repair.
if let Some(cached) = self.transfer_offer_cache.borrow().as_ref()
&& cached.snapshot_seq == snapshot_seq
{
return Ok(Rc::clone(cached));
}
let commit_op = consensus.commit_min();
let table = self.client_table.borrow().encode();
let offer = Rc::new(StateTransferOffer {
commit_op,
snapshot_seq,
artifacts: vec![
(
consensus::StateArtifact::for_bytes(
consensus::artifact_kind::METADATA_SNAPSHOT,
snapshot_seq,
payload,
),
Rc::new(payload.to_vec()),
),
(
consensus::StateArtifact::for_bytes(
consensus::artifact_kind::CLIENT_TABLE,
commit_op,
&table,
),
Rc::new(table),
),
],
});
*self.transfer_offer_cache.borrow_mut() = Some(Rc::clone(&offer));
Ok(offer)
}
/// Install a fetched state transfer: persist + restore the snapshot,
/// replace the client table, and jump the commit state to the snapshot
/// floor so the tail repair takes over from there.
///
/// Ordering: persist FIRST (a crash mid-install must reboot from the
/// transferred state, not the pre-transfer one), then the in-place STM
/// restore (readers observe it on their next read), then the table +
/// frontier, then journal/commit bookkeeping.
///
/// The partition plane is deliberately untouched: partitions load
/// whatever their disks hold at boot and repair through their own
/// consensus groups. Topology changes the snapshot carries below the
/// receiver's old frontier (topics created/deleted while it was down)
/// fire no commit notifier -- convergence rests on the partition
/// reconciler's periodic full diff against the committed STM, which
/// reads the restored state on its next tick.
///
/// Returns an [`InstallOutcome`]: the new applied frontier, plus whether
/// the transferred checkpoint's pairing reached the durable superblock.
///
/// # Errors
/// [`SnapshotError`] when the snapshot bytes do not decode, the persist
/// fails, or the in-place restore is rejected. Every `Err` here means
/// NOTHING was installed.
///
/// # Panics
/// If called on a shard without consensus (state transfer is a shard-0
/// concern).
#[allow(clippy::future_not_send, clippy::too_many_lines)]
pub async fn install_state_transfer(
&self,
snapshot_bytes: &[u8],
client_table: ClientTable,
table_frontier: u64,
commit_op: u64,
) -> Result<InstallOutcome, SnapshotError>
where
M: RestoreSnapshotInPlace<MetadataSnapshot>,
{
let consensus = self
.consensus
.as_ref()
.expect("install_state_transfer: consensus only exists on shard 0");
// Refuses a format version this build does not read, ahead of every frontier
// move below: the bytes come from a peer, so its build picked the shape.
let snapshot = IggySnapshot::decode(snapshot_bytes)?;
let snapshot_seq = snapshot.sequence_number();
// The one place a snapshot crosses builds, so the only place the release
// stamp answers a question the local logs cannot.
tracing::info!(
snapshot_seq,
format_version = snapshot.snapshot().version,
writer_release = %ProtocolVersion(snapshot.snapshot().writer_release),
"decoded a transferred metadata snapshot"
);
// Manifest coherence. `commit_op` and `table_frontier` arrive from the
// serving peer and are applied to THIS replica's frontiers, so a
// malformed descriptor would move them somewhere the artifacts do not
// justify. A peer cannot have committed less than its own snapshot
// contains, nor have encoded a table below its commit point: both are
// built from one caught-up-primary read in `state_transfer_offer`.
// Refuse rather than install, which drops the caller back to journal
// repair with the local state untouched.
if commit_op < snapshot_seq || table_frontier > commit_op {
tracing::error!(
snapshot_seq,
commit_op,
table_frontier,
"incoherent state transfer manifest; refusing to install"
);
return Err(SnapshotError::IncoherentManifest {
snapshot_seq,
commit_op,
table_frontier,
});
}
// Checkpoints are node-local, so a healthy serving primary can offer
// a snapshot BEHIND this replica's own applied frontier (each node
// snapshots at its own watermark; a backup checkpoints an op or two
// below the primary it later replaces). Restoring such a snapshot
// would rewind the STM below `commit_min` with no way back: the
// commit walk never revisits ops it already counted as applied, so
// the rewound-over effects would be lost until the next transfer.
// Keep the local STM (it is a superset) and let tail repair cover
// `(commit_min, commit_op]`. The client table still installs below:
// it comes from the serving primary's LIVE state at `table_frontier
// == commit_op`, which is never behind this replica.
// Preliminary read, only to decide whether the gates are needed; the
// binding decision is re-derived under them below.
let snapshot_ahead = snapshot_seq > consensus.commit_min();
// Serialize the whole install against a concurrent checkpoint, in the
// checkpoint's own lock order (`checkpoint_lock` then
// `superblock_lock`), so the two cannot deadlock against each other.
//
// Both do the same pair of steps -- rewrite `snapshot.bin`, record
// `(checkpoint_op, checksum)` -- and `checkpoint_if_needed` holds
// `checkpoint_lock` across BOTH while taking `superblock_lock` only
// around the pairing write. `superblock_lock` alone therefore
// serializes nothing against the checkpoint's file rewrite: interleave
// them and the file comes from one while the durable pairing describes
// the other, which is exactly the torn pairing the superblock exists to
// detect (a crash inside that window refuses boot with
// `CheckpointChecksumMismatch`). Checkpoints run on spawned tasks, so a
// prepare that passed preflight before the transfer armed can drive one
// during this install's superblock await -- a transferring replica
// withholds acks, but "should not be committing" is not an invariant
// this path can rest on.
//
// Deadlock-free: nothing between here and the superblock write awaits,
// and `write_superblock` takes no lock of its own.
let _install_gates = if snapshot_ahead {
let checkpoint = self.checkpoint_lock.acquire().await;
let superblock = if self.superblock.is_some() {
Some(self.superblock_lock.acquire().await)
} else {
None
};
Some((checkpoint, superblock))
} else {
None
};
// The gate waits above suspend this task while commits -- and whole
// checkpoints -- run, so the preliminary read is stale once the locks
// are held. `commit_min` is monotonic, so the only possible flip is
// ahead -> not-ahead, landing in the table-only arm below; deciding on
// the stale value instead would overwrite a newer checkpoint's
// snapshot.bin, regress its pairing, and rewind the STM below the
// applied frontier -- then panic on `set_commit_floor`'s anti-rewind
// assert with the damage already durable.
let local_applied = consensus.commit_min();
let snapshot_ahead = snapshot_seq > local_applied;
if snapshot_ahead && let Some(journal) = &self.journal {
// Discard the WAL suffix above the incoming floor BEFORE anything
// installs: the commit walk matches entries by op number alone, so
// a pre-crash suffix a view change has since reassigned would be
// applied as committed once the floor jump below pulls the walk
// past it. Ahead of the snapshot restore so a failed truncate
// aborts a not-yet-started install (the transfer retries) instead
// of stranding a restored state machine without its floor jump,
// which would double-apply the snapshot's ops on the next walk.
// Committed ops the range covered come back through the
// post-install tail repair; uncommitted ones were decided away by
// the view change that made this replica a transfer receiver.
// Serialization against appends holds as in
// `reconcile_metadata_view_divergence`: the pump is
// single-threaded and `replicate_preflight` refuses prepares
// while `is_transferring`.
let removed = journal
.handle()
.truncate_from(snapshot_seq + 1)
.await
.map_err(SnapshotError::Io)?;
if removed > 0 {
// The DVC snapshot's `(op, commit)` tag does not move when
// entries are removed under it; left stale it would advertise
// headers this replica can no longer serve.
consensus.invalidate_local_dvc_suffix();
tracing::warn!(
snapshot_seq,
removed,
"state transfer dropped {removed} journal entries above the incoming floor"
);
}
}
if snapshot_ahead {
if let Some(coordinator) = &self.coordinator {
// The transferred snapshot REPLACES the one the superblock's
// `(checkpoint_op, checksum)` pairing describes, so the pairing has
// to move with it. Left stale it does not refuse boot -- the
// `checkpoint_op < snapshot_op` arm of `verify_checkpoint_pairing`
// reads it as a lagging local checkpoint and accepts -- which is
// worse than a refusal: the recorded checksum belongs to a snapshot
// that no longer exists, so a torn or corrupt transferred snapshot
// stops being detectable until some later local checkpoint happens
// to rewrite the pairing.
//
// Write the received bytes verbatim rather than re-encoding the
// decoded snapshot: the checksum below is taken over exactly the
// bytes that reach the file, so the pairing provably describes it.
debug_assert!(
snapshot_seq >= coordinator.last_checkpoint().0,
"a transferred snapshot must not land below the recorded \
checkpoint op ({} < {}); recovery would refuse boot with \
CheckpointAheadOfSnapshot",
snapshot_seq,
coordinator.last_checkpoint().0
);
let checksum = checkpoint_checksum(snapshot_bytes);
IggySnapshot::write_durably(&coordinator.snapshot_path(), snapshot_bytes)?;
coordinator.seed_last_checkpoint(snapshot_seq, checksum);
tracing::info!(
checkpoint_op = snapshot_seq,
"state transfer recorded its checkpoint pairing"
);
} else {
tracing::warn!(
snapshot_seq,
"installing state transfer without a snapshot coordinator; \
the transferred state will not survive a further restart"
);
}
self.mux_stm
.restore_snapshot_in_place(snapshot.snapshot())?;
} else {
tracing::info!(
snapshot_seq,
local_applied,
"transferred snapshot at or below the local applied frontier; \
keeping the local state machine and installing the table only"
);
}
*self.client_table.borrow_mut() = client_table;
self.client_table_frontier.set(table_frontier);
if snapshot_ahead {
// Entries at or below the installed floor are superseded by the
// snapshot; without this the journal's wrap-eviction assert trips
// on pre-transfer residents the next time slots recycle.
if let Some(journal) = &self.journal {
let handle = journal.handle();
if snapshot_seq > handle.snapshot_op() {
handle.set_snapshot_op(snapshot_seq);
}
}
// The snapshot IS ops `..=snapshot_seq` applied: jump the applied
// frontier (this is the op-jump the tail repair resumes from) and
// let the announced commit point pull the walk target forward.
// The walk matches WAL entries by op number alone, so this floor
// is only safe because the truncate at the top of this install
// already discarded every journal entry above it.
consensus.set_commit_floor(snapshot_seq);
if snapshot_seq > consensus.sequencer().current_sequence() {
consensus.sequencer().set_sequence(snapshot_seq);
}
}
// Before the superblock write, so the durable record carries the frontier
// this transfer just established rather than the pre-transfer one.
consensus.advance_commit_max(commit_op);
// Make the transferred checkpoint durable, mirroring the ordering a local
// checkpoint uses (persist snapshot -> record the pairing -> only then treat
// it as the recovery floor). A crash before this lands recovers the previous
// checkpoint with the WAL intact and the transfer simply retries; a crash
// after it recovers the transferred state. Failing here withholds nothing
// already written -- the snapshot on disk subsumes the recorded pairing, which
// `verify_checkpoint_pairing` accepts -- so it is reported as a DEGRADED
// install rather than a failed one.
let mut pairing_durable = true;
if snapshot_ahead && let Some(superblock) = self.superblock.as_ref() {
// Already under `_install_gates`, acquired above; re-acquiring here
// would deadlock on the same non-reentrant gate.
pairing_durable = self.write_superblock(consensus, superblock.as_ref()).await;
if !pairing_durable {
tracing::error!(
snapshot_seq,
commit_op,
"state transfer installed but the superblock write failed; the \
transferred checkpoint is not durable yet"
);
}
}
Ok(InstallOutcome {
applied_frontier: snapshot_seq.max(local_applied),
pairing_durable,
})
}
/// Submit `Register` from in-process, await commit. Wire reply still fires
/// via `message_bus.send_to_client`; subscriber is additive.
///
/// Every bind proposes -- there is deliberately no fast path returning an
/// existing entry's state. A bind is a fencing event: only a committed
/// Register moves the entry's epoch (to the register's commit op), and
/// that bump is what fences the previous holder of this session
/// (`RequestStatus::Fenced`). Short-circuiting a rebind would leave two
/// live holders sharing one fence, the zombie scenario the epoch exists
/// to kill. Rebinding onto an existing entry preserves its watermark and
/// reply ring, which is how session resume works.
///
/// # Returns
/// [`BoundSession`]: the fence epoch the client must stamp into `session`,
/// plus the entry's current watermark so a caller that lost its position
/// (the HTTP gateway after a restart) can resume numbering above it.
///
/// # Errors
/// [`MetadataSubmitError`]. All transient except
/// `ClientIdOwnedByAnotherUser`, which is terminal: `NotPrimary`,
/// `PipelineFull`, `InProgress`, `Canceled`. Never `NotCaughtUp`: a
/// not-caught-up primary parks the register in the request queue instead
/// of bouncing it. `Canceled` dominates on view change; the new primary
/// inherits via `commit_journal` and the SDK retries.
///
/// # Panics
/// On `client_id == 0` or shard without consensus.
///
/// # Safety
/// Catch-up gate load-bearing: a Register dispatched with
/// `commit_min < commit_max` can double-commit against an inherited one,
/// fencing the live client's fresh reply for no reason.
#[allow(clippy::future_not_send)]
pub async fn submit_register_in_process(
&self,
client_id: u128,
user_id: u32,
) -> Result<BoundSession, MetadataSubmitError> {
assert!(client_id != 0, "client_id 0 is reserved for internal use");
let consensus = self
.consensus
.as_ref()
.expect("submit_register_in_process: consensus only exists on shard 0");
// Wrong node: waiting or queueing cannot fix that, the client must
// re-route to the primary.
if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring()) {
return Err(MetadataSubmitError::NotPrimary);
}
// OWNERSHIP GATE: the login frame's `client` field is caller-supplied,
// and `resolve_acting_user_id` resolves authority for every replicated
// op from this entry, so rebinding someone else's entry would run the
// caller's ops under that user (and `commit_register` would clobber
// its `user_id`). Refuse unless the authenticated user owns it.
// Terminal (see `ClientIdOwnedByAnotherUser`). An owned entry falls
// through: the rebind must commit so the epoch actually moves.
//
// Only a CAUGHT-UP primary may issue it, like both sibling readers of
// this table (`request_preflight` and `register_preflight`, which gate
// the same way): the refusal is terminal, so a lagging or diverged
// replica answering it would deny a legitimate login off state it has
// not finished applying, and the client would never learn to redirect.
// Not caught up therefore SKIPS the check rather than refusing -- the
// register goes on to park in the request queue below, and
// `register_preflight` re-applies this gate when the commit path
// promotes it, by which point the table is authoritative.
if is_caught_up_primary(consensus) {
let table = self.client_table.borrow();
if let Some(owner) = table.get_user_id(client_id)
&& owner != user_id
{
warn!(
target: "iggy.metadata.diag",
client_id,
authenticated_user = user_id,
entry_owner = owner,
"refusing register: client id is registered to a different user"
);
return Err(MetadataSubmitError::ClientIdOwnedByAnotherUser);
}
}
// Mirror wire-path register_preflight: a racing second prepare would
// commit a second register and bump the epoch past the first reply's.
// Surface pre-synthesis. Scans both the prepare queue and the request
// queue, so a register absorbed below dedups its own replays.
if consensus.pipeline_has_message_from_client(client_id) {
return Err(MetadataSubmitError::InProgress);
}
let request = build_register_request_message(consensus, client_id, user_id);
// Wire path runs `RoutedRequestHeader::validate` at network boundary;
// in-process skips it. debug_assert pins drift.
debug_assert!(
{
use iggy_binary_protocol::ConsensusHeader;
request.header().validate().is_ok()
},
"build_register_request_message produced a header that fails validate()"
);
// Fence floor, snapshotted BEFORE dispatch. This register's op is
// assigned above the journal tail, so it is strictly greater than
// `commit_max` is now -- which is what lets the cancel path below tell
// OUR fence from an older entry's that happened to survive.
let epoch_floor = consensus.commit_max();
// Not caught up (admitting a register while a committed op is still
// unapplied risks a double-register fence bump) or prepare queue full:
// absorb into the request queue instead of bouncing with a transient
// error. The queued entry carries this caller's reply subscriber; the
// commit path promotes it (`drain_request_queue_into_prepares`, which
// re-runs `register_preflight` and so applies the ownership gate) as
// soon as the in-flight batch drains, and the await below resolves
// exactly like the direct dispatch would.
if !is_caught_up_primary(consensus) || consensus.pipeline_is_full() {
let (entry, receiver) = consensus::RequestEntry::with_subscriber(request);
if consensus.push_queued_request(entry).is_err() {
// Both queues full: honest terminal backpressure.
return Err(MetadataSubmitError::PipelineFull);
}
return match receiver.await {
// The reply's `commit` IS the fence `commit_register` just
// stored (`build_reply_message` stamps it from the prepare's
// op), so take it from there rather than re-reading the table.
Ok(reply) => {
self.bound_session(client_id, Some(reply.header().commit), epoch_floor)
}
// Entry dropped before commit: view-change reset, or a
// promotion-time preflight rejection.
Err(Canceled) => self.bound_session(client_id, None, epoch_floor),
};
}
// `prepare_request` only fails on `!is_client_allowed`; Register is
// allowed, so unreachable. Panic loudly on regression instead of
// smuggling through wire-eviction.
let prepare = self
.prepare_request(request)
.expect("Operation::Register is client-allowed; prepare projection cannot fail");
match self.dispatch_prepare_and_await(consensus, prepare).await {
Ok(reply) => self.bound_session(client_id, Some(reply.header().commit), epoch_floor),
Err(Canceled) => self.bound_session(client_id, None, epoch_floor),
}
}
/// Assemble the bind result in one table borrow.
///
/// `committed_epoch` is `Some` when this call's own Register committed, in
/// which case the fence comes from the reply that carries it. `None` is the
/// view-change cancel path, where the fence has to be read back -- and is
/// only ours if it sits above `epoch_floor`. An entry at or below the floor
/// predates this register, so returning its epoch would hand the caller a
/// fence that never moved, and nothing downstream would notice: a stale
/// epoch satisfies `check_request`'s equality test, so there is no `Fenced`
/// and no `EpochAhead` to surface it. `Canceled` instead, and the retry
/// gets a real bind.
///
/// `Canceled` also covers an absent entry (evicted between commit and
/// read).
fn bound_session(
&self,
client_id: u128,
committed_epoch: Option<u64>,
epoch_floor: u64,
) -> Result<BoundSession, MetadataSubmitError> {
let table = self.client_table.borrow();
let epoch = committed_epoch.or_else(|| {
table
.get_epoch(client_id)
.filter(|&epoch| epoch > epoch_floor)
});
epoch
.zip(table.get_watermark(client_id))
.map(|(epoch, watermark)| BoundSession { epoch, watermark })
.ok_or(MetadataSubmitError::Canceled)
}
/// Turn a non-`Dispatch` [`PreflightOutcome`] into the answer the home
/// shard writes to the originating socket, or `None` to dispatch.
///
/// Shard 0 cannot route by the VSR consensus `client_id` (its top bits are
/// random, not home-shard routing bits), so the frame is returned to the
/// home shard rather than sent from here;
/// `handle_client_request` writes it by transport id, exactly like a fresh
/// commit.
fn answer_preflight(
consensus: &VsrConsensus<B>,
request_header: &RoutedRequestHeader,
outcome: PreflightOutcome,
) -> Option<Result<Message<GenericHeader>, MetadataSubmitError>> {
let client_id = request_header.client;
match outcome {
PreflightOutcome::Dispatch => None,
PreflightOutcome::Replay(reply) => {
if let Some(refusal) = unreplayable_secret_refusal(
request_header,
&reply,
consensus.commit_max(),
client_id,
) {
return Some(Ok(refusal));
}
let owned =
Owned::<{ server_common::MESSAGE_ALIGN }>::copy_from_slice(reply.as_slice());
Some(
Message::<GenericHeader>::try_from(owned)
.map_err(|_| MetadataSubmitError::Canceled),
)
}
PreflightOutcome::Evict(reason) => {
let ctx = EvictionContext::from_consensus(consensus);
Some(Ok(
build_eviction_message(ctx, client_id, reason).into_generic()
))
}
// In-flight prepare from this client: replaying the same request_id
// is absorbed until the original commits, then served from cache.
PreflightOutcome::NotReady => Some(Ok(build_result_rejection_reply(
request_header,
consensus.commit_max(),
IggyError::TransientNotCommitted.as_code(),
)
.into_generic())),
// Terminal refusal with a correlated reply so the SDK surfaces the
// typed error instead of blocking until its read timeout.
PreflightOutcome::Reject(code) => Some(Ok(build_result_rejection_reply(
request_header,
consensus.commit_max(),
code,
)
.into_generic())),
// Client-bug shapes (future epoch, id reused for a different
// operation): replaying cannot help, so the home shard stays silent.
PreflightOutcome::Drop => Some(Err(MetadataSubmitError::Canceled)),
}
}
/// Submit `Logout` from in-process, await commit.
///
/// # Returns
/// Commit op for the logout. If the client session is already absent, this
/// is idempotent and returns the current metadata commit.
///
/// # Errors
/// Returns a consensus submission error when this node cannot accept the
/// logout prepare, the metadata pipeline is saturated, or the pending
/// request is canceled before commit.
///
/// # Panics
/// Panics when called with the reserved client id `0`, on a non-consensus
/// metadata shard, or if the prepare gate flips between validation and
/// local dispatch.
#[allow(clippy::future_not_send)]
pub async fn submit_logout_in_process(
&self,
client_id: u128,
session: u64,
request: u64,
) -> Result<u64, MetadataSubmitError> {
assert!(client_id != 0, "client_id 0 is reserved for internal use");
let consensus = self
.consensus
.as_ref()
.expect("submit_logout_in_process: consensus only exists on shard 0");
// Epoch guard: only propose a Logout when the slot still holds the
// exact epoch this logout targets. A late disconnect-logout for a
// reused client id (slot since rebound to a newer epoch) carries the
// stale epoch and is dropped here, so it can never wipe the fresh
// registration. A missing slot also fails the match and short-circuits.
if self.client_table.borrow().get_epoch(client_id) != Some(session) {
return Ok(consensus.commit_min());
}
// No catch-up gate here: a logout admitted mid-commit-window is
// safe — the wire path has always dispatched non-register ops
// without one, and the per-client dedup below covers the only
// logout-vs-logout race. It simply pipelines behind the in-flight
// batch and commits with it, so a one-shot client's session
// teardown is latency, never an error.
if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring()) {
return Err(MetadataSubmitError::NotPrimary);
}
if consensus.pipeline_has_message_from_client(client_id) {
return Err(MetadataSubmitError::InProgress);
}
let request = build_logout_request_message(consensus, client_id, session, request);
debug_assert!(
{
use iggy_binary_protocol::ConsensusHeader;
request.header().validate().is_ok()
},
"build_logout_request_message produced a header that fails validate()"
);
// Prepare queue full: absorb into the request queue with this
// caller's reply subscriber, promoted as
// commits free slots.
if consensus.pipeline_is_full() {
let (entry, receiver) = consensus::RequestEntry::with_subscriber(request);
if consensus.push_queued_request(entry).is_err() {
return Err(MetadataSubmitError::PipelineFull);
}
return match receiver.await {
Ok(reply) => Ok(reply.header().commit),
Err(Canceled) => {
if self.client_table.borrow().get_epoch(client_id).is_none() {
Ok(consensus.commit_min())
} else {
Err(MetadataSubmitError::Canceled)
}
}
};
}
let prepare = self
.prepare_request(request)
.expect("Operation::Logout is client-allowed; prepare projection cannot fail");
match self.dispatch_prepare_and_await(consensus, prepare).await {
Ok(reply) => Ok(reply.header().commit),
Err(Canceled) => {
if self.client_table.borrow().get_epoch(client_id).is_none() {
Ok(consensus.commit_min())
} else {
Err(MetadataSubmitError::Canceled)
}
}
}
}
/// Submit a server-originated `CompleteConsumerGroupRevocation` through the
/// metadata consensus group (shard 0). The partition reconciler calls this
/// to complete a cooperative revocation once the source has drained the
/// partition (or it timed out).
///
/// Unlike a client op there is no session: a reserved internal client id
/// (never coordinator-minted) carries it, `request_preflight` is skipped
/// (server-originated), and the normal-op commit path skips reply-caching
/// when the client has no session. The op is internal (not client-allowed),
/// so it bypasses `prepare_request` and projects directly.
///
/// # Errors
/// `NotPrimary` / `NotCaughtUp` when this node cannot accept the prepare,
/// `InProgress` / `PipelineFull` on pipeline pressure (the reconciler
/// retries next tick; completion is idempotent), `Canceled` if the pending
/// prepare was canceled before commit.
///
/// # Panics
/// On a shard without consensus (only shard 0 owns the metadata consensus
/// group); callers must route here only on shard 0.
#[allow(clippy::future_not_send)]
pub async fn submit_complete_revocation_in_process(
&self,
stream_id: u32,
topic_id: u32,
group_id: u64,
source_client_id: u128,
partition_id: u32,
) -> Result<u64, MetadataSubmitError> {
const INTERNAL_REQUEST_ID: u64 = u64::MAX;
// Reserved internal client id, distinct per (group, partition) target.
// The high 64 bits are all-ones -- never coordinator-minted (those carry
// a small home-shard number in the top bits) -- and the low 64 bits pack
// (group_id, partition_id). Distinct ids matter: the pipeline dedups by
// client id, so a shared id would cap internal completions at one
// in-flight cluster-wide and drain a wide rebalance one per consensus
// round-trip. Per-target ids let completions for different partitions
// pipeline concurrently while still deduping a retry of the same target.
let internal_client_id: u128 =
(u128::from(u64::MAX) << 64) | (u128::from(group_id) << 32) | u128::from(partition_id);
let consensus = self
.consensus
.as_ref()
.expect("submit_complete_revocation_in_process: consensus only exists on shard 0");
// Deliberately bounce-based (no request-queue absorption, unlike the
// client submit paths above): the caller is the partition
// reconciler's completion loop, which retries on its own tick, and
// parking internal completions would tie up request slots that
// client submits compete for.
if !is_caught_up_primary(consensus) {
return Err(
if consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring() {
MetadataSubmitError::NotCaughtUp
} else {
MetadataSubmitError::NotPrimary
},
);
}
if consensus.pipeline_has_message_from_client(internal_client_id) {
return Err(MetadataSubmitError::InProgress);
}
if consensus.pipeline_is_full() {
return Err(MetadataSubmitError::PipelineFull);
}
let request = CompleteConsumerGroupRevocationRequest {
stream_id: WireIdentifier::numeric(stream_id),
topic_id: WireIdentifier::numeric(topic_id),
group_id,
source_client_id,
partition_id,
};
let body = request.to_bytes();
let message = build_complete_revocation_request_message(
consensus,
internal_client_id,
INTERNAL_REQUEST_ID,
&body,
);
let prepare = message.project(consensus);
match self.dispatch_prepare_and_await(consensus, prepare).await {
Ok(reply) => Ok(reply.header().commit),
Err(Canceled) => Err(MetadataSubmitError::Canceled),
}
}
/// `true` when this node is the caught-up primary of the metadata
/// consensus group. Gates leader-only maintenance (the PAT cleaner)
/// off backups and lagging primaries.
#[must_use]
pub fn is_caught_up_primary(&self) -> bool {
self.consensus.as_ref().is_some_and(is_caught_up_primary)
}
/// Submit a replicated `DeletePersonalAccessToken` originated by the
/// server (the PAT cleaner), not a client.
///
/// No client session exists, so this skips `request_preflight` (like
/// the logout precedent) and uses the reserved internal `client` id
/// `0`: never registered, so the commit path's `get_epoch(0)` is
/// `None` and skips `commit_reply` (and its `assert!(client_id != 0)`),
/// while the preflight and register asserts never run. Delete is
/// idempotent, so the dropped dedup is harmless and a re-proposal on the
/// next tick is a no-op.
///
/// # Errors
/// `NotPrimary` / `NotCaughtUp` when this node cannot replicate,
/// `PipelineFull` under pipeline pressure, `Canceled` if the prepare is
/// dropped before commit.
///
/// # Panics
/// On a shard without consensus (shard 0 only), or if the prepare gate
/// flips between validation and dispatch.
#[allow(clippy::future_not_send)]
pub async fn submit_delete_personal_access_token_in_process(
&self,
user_id: UserId,
name: WireName,
) -> Result<u64, MetadataSubmitError> {
let consensus = self.consensus.as_ref().expect(
"submit_delete_personal_access_token_in_process: consensus only exists on shard 0",
);
// Deliberately bounce-based (no request-queue absorption): the
// caller is the background PAT cleaner, which simply retries the
// deletion on its next sweep.
if !is_caught_up_primary(consensus) {
return Err(
if consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring() {
MetadataSubmitError::NotCaughtUp
} else {
MetadataSubmitError::NotPrimary
},
);
}
if consensus.pipeline_is_full() {
return Err(MetadataSubmitError::PipelineFull);
}
let body = DeletePersonalAccessTokenRequest {
user_id,
name,
// Expiry-gated: a token recreated under the same name between the
// cleaner's snapshot and this commit must not be purged. Apply
// re-checks the stored token's expiry against the prepare timestamp.
only_if_expired: true,
}
.to_bytes();
// Build the prepare directly so the `client = 0` header skips the
// client-header validation in `prepare_request` / `Project::project`
// (the in-process path `build_prepare_message` documents).
let header = RoutedRequestHeader {
client: 0,
group: server_common::sharding::METADATA_GROUP,
..RoutedRequestHeader::default()
};
let prepare = build_prepare_message(
consensus,
&header,
Operation::DeletePersonalAccessToken,
&body,
);
match self.dispatch_prepare_and_await(consensus, prepare).await {
Ok(reply) => Ok(reply.header().commit),
Err(Canceled) => Err(MetadataSubmitError::Canceled),
}
}
/// Submit a replicated client request from in-process and await the
/// committed reply.
///
/// A peer (home) shard relays a client's replicated request here (shard
/// 0 owns the metadata consensus group) and awaits the full committed
/// reply over the pipeline subscriber. The home shard then writes the
/// reply to the originating socket -- it holds the connection and the
/// `vsr -> transport` mapping that this side cannot reconstruct.
///
/// Mirrors [`Self::submit_register_in_process`] but: (1) uses
/// `request_preflight` (dedup / session check) instead of the register
/// gate, (2) returns the committed reply as a `Message<GenericHeader>`
/// (body = state machine output) rather than just the commit op. A
/// `Duplicate`/eviction preflight outcome is returned here as the reply
/// frame so the home shard resends it by transport id.
///
/// # Errors
/// `NotPrimary` / `NotCaughtUp` when this node cannot accept the
/// prepare, `InProgress` / `PipelineFull` on pipeline pressure,
/// `Canceled` when preflight absorbed the request (dedup / eviction /
/// gap) or the pending prepare was canceled before commit.
///
/// # Panics
/// On a shard without consensus (only shard 0 owns the metadata
/// consensus group); callers must route here only on shard 0.
#[allow(clippy::future_not_send)]
pub async fn submit_request_in_process(
&self,
message: Message<RoutedRequestHeader>,
) -> Result<Message<GenericHeader>, MetadataSubmitError> {
let request_header = *message.header();
let client_id = request_header.client;
let session = request_header.session;
let request = request_header.request;
let request_checksum = request_header.request_checksum;
let consensus = self
.consensus
.as_ref()
.expect("submit_request_in_process: consensus only exists on shard 0");
// Not-primary is transient: the same request replayed against the
// current primary commits fine. Reply with the explicit transient
// frame (relayed to the socket by the home shard) so the client
// replays immediately rather than waiting out its read-timeout.
// `TransientNotAccepted` specifically: the request never entered the
// pipeline here, so the client may re-issue it ANYWHERE -- including
// under a fresh session after failing over to the current leader --
// without double-apply risk. (`TransientNotCommitted` conversely means
// the outcome is unknown and only a same-session replay is safe.)
//
// No catch-up gate: a non-register op admitted mid-commit-window
// simply pipelines behind the in-flight batch (the wire path has
// always done this); the register-specific invariant is guarded in
// `submit_register_in_process` / `register_preflight`.
if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring()) {
return Ok(build_result_rejection_reply(
&request_header,
consensus.commit_max(),
IggyError::TransientNotAccepted.as_code(),
)
.into_generic());
}
// Dedup / epoch fence / eviction. shard 0 cannot route by the VSR
// consensus `client_id` (its top bits are random, not home-shard
// routing), so a Replay/Evict/NotReady is returned to the home shard as
// the reply -- `handle_client_request` writes it to the originating
// socket by transport id, exactly like a fresh commit. Drop (client-bug
// already-applied / future-epoch) surfaces as Canceled so the home
// shard stays silent.
let outcome = request_preflight(
consensus,
&self.client_table,
client_id,
session,
request,
request_checksum,
);
if let Some(answer) = Self::answer_preflight(consensus, &request_header, outcome) {
return answer;
}
// Prepare queue full: backpressure, not failure. Absorb into the
// request queue with this caller's reply subscriber; the commit
// path promotes it as slots free up and the await below resolves
// with the committed reply. Only a full request queue is terminal
// (`TransientNotAccepted`, re-issuable anywhere: the request never
// entered a queue).
if consensus.pipeline_is_full() {
let (entry, receiver) = consensus::RequestEntry::with_subscriber(message);
if consensus.push_queued_request(entry).is_err() {
return Ok(build_result_rejection_reply(
&request_header,
consensus.commit_max(),
IggyError::TransientNotAccepted.as_code(),
)
.into_generic());
}
return match receiver.await {
Ok(reply) => Ok(reply.into_generic()),
// Queued entry dropped (view-change reset) or promoted then
// canceled: outcome unknown, same-session replay only.
Err(Canceled) => Ok(build_result_rejection_reply(
&request_header,
consensus.commit_max(),
IggyError::TransientNotCommitted.as_code(),
)
.into_generic()),
};
}
// The acting-user RBAC stamp lives in the shared `prepare_request`
// (op-guarded, fail-closed on an unknown session). `request_preflight`
// above proved this client's session is live, so it resolves there.
let Ok(prepare) = self.prepare_request(message) else {
// Structurally-invalid request. Return an eviction frame (relayed to
// the socket by the home shard) rather than `Canceled`, which leaves
// the shard silent and the SDK retrying forever.
let reason = eviction_reason_for_invalid(request_header.operation);
let ctx = EvictionContext::from_consensus(consensus);
return Ok(build_eviction_message(ctx, client_id, reason).into_generic());
};
// A view change canceled the pending prepare before commit. The op may
// or may not have committed; replaying the same request_id is idempotent
// (the new primary serves it from cache if committed, else re-dispatches),
// so reply with the transient frame rather than staying silent.
match self.dispatch_prepare_and_await(consensus, prepare).await {
Ok(reply) => Ok(reply.into_generic()),
Err(Canceled) => Ok(build_result_rejection_reply(
&request_header,
consensus.commit_max(),
IggyError::TransientNotCommitted.as_code(),
)
.into_generic()),
}
}
/// Subscribe to a prepared metadata write, dispatch it into the pipeline,
/// drain the self-loopback acks, and await the committed reply.
///
/// Shared tail of every in-process submit path
/// ([`Self::submit_register_in_process`],
/// [`Self::submit_logout_in_process`], [`Self::submit_request_in_process`],
/// and [`Self::submit_delete_personal_access_token_in_process`]). The
/// caller owns its own preflight / dedup gate and builds `prepare`; this
/// owns the dispatch mechanics. The view-change `Canceled` is returned
/// verbatim so each caller can apply its own idempotent recheck.
///
/// Subscribes before dispatch so the receiver is registered before any
/// self-loopback ack fires (compio is single-threaded; explicit anyway).
#[allow(clippy::future_not_send)]
async fn dispatch_prepare_and_await(
&self,
consensus: &VsrConsensus<B>,
prepare: Message<PrepareHeader>,
) -> Result<Message<ReplyHeader>, Canceled> {
consensus.verify_pipeline();
let receiver = consensus.pipeline_message_with_subscriber(PlaneKind::Metadata, &prepare);
// Register is the one op whose admission requires the catch-up gate
// (double-register epoch bump); its submit path checks the gate and
// the check-to-dispatch section is synchronous. Non-register ops
// dispatch mid-window by design (they pipeline behind the in-flight
// batch, like the wire path always has).
debug_assert!(
prepare.header().operation != Operation::Register || is_caught_up_primary(consensus),
"dispatch_prepare_and_await: register dispatched with the catch-up gate closed"
);
// `on_replicate` awaits: a sibling in-process submit may commit
// (commit_min advances) or a view change may land (view advances) while
// parked here. Both are handled downstream - a view change drops the
// reply_sender so `receiver` resolves `Canceled`, and loopback acks are
// op-routed - so no post-await view/commit invariant holds or is needed.
self.on_replicate(prepare).await;
let mut loopback = Vec::new();
consensus.drain_loopback_into(&mut loopback);
for message in loopback {
match message.header().command {
Command::PrepareOk => match message.try_into_typed::<PrepareOkHeader>() {
Ok(prepare_ok) => self.on_ack(prepare_ok).await,
Err(error) => warn!(
error = %error,
"dropping malformed PrepareOk from metadata loopback queue"
),
},
command => warn!(
?command,
"dropping unexpected message from metadata loopback queue"
),
}
}
receiver.await
}
/// Repair the primary's own missing self-acks.
///
/// The primary's `PrepareOk` for its own prepare is produced exactly once,
/// as a loopback right after the WAL append (see `on_replicate`). If that
/// one-shot is lost or suppressed (e.g. the `send_prepare_ok` persistence
/// gate races the sequencer pre-advance under a client burst), no
/// retransmit path regenerates it: `retransmit_targets` lists the primary
/// itself among the missing replicas, but `RetransmitPrepares` to self is a
/// no-op. The op then sits one vote short of quorum forever and pins the
/// contiguous commit prefix, so `commit_min` never catches up to
/// `commit_max` and the cluster wedges.
///
/// This is a re-ack-only repair: for each pending op the primary holds
/// DURABLY but has not self-acked, re-emit the self `PrepareOk` and drain
/// it through `on_ack`. A pending op the primary does NOT yet hold durably
/// is skipped - filling that hole needs full message repair, which is out
/// of scope here. Driven each consensus tick;
/// `on_ack` dedups a redundant self-ack via `has_ack`, so re-running is
/// idempotent and stops once the op commits and leaves the pending range.
#[allow(clippy::future_not_send, clippy::cast_possible_truncation)]
pub async fn repair_primary_self_acks(&self) {
let Some(consensus) = self.consensus.as_ref() else {
return;
};
if !consensus.is_primary() || !consensus.is_normal() || consensus.is_transferring() {
return;
}
let Some(journal) = self.journal.as_ref() else {
return;
};
let self_replica = consensus.replica();
// Snapshot durable, self-unacked pending ops, dropping the pipeline and
// journal borrows before the `send_prepare_ok` awaits below.
let mut headers: Vec<PrepareHeader> = Vec::new();
consensus.with_pipeline(|pipeline| {
let from = consensus.commit_max() + 1;
let to = consensus.sequencer().current_sequence();
for op in from..=to {
let Some(entry) = pipeline.entry_by_op(op) else {
continue;
};
if entry.has_ack(self_replica) {
continue;
}
// Durable only: re-acking implies "I hold this op". A gap (op not
// in the journal) must not be self-acked - that path needs repair.
if let Some(header) = journal.handle().header(op as usize).map(|header| *header) {
headers.push(header);
}
}
});
if headers.is_empty() {
return;
}
// Interleave push + drain per header instead of push-all-then-drain-once:
// each `on_ack` below can promote a full window of buffered requests
// (`drain_request_queue_into_prepares`), and every promoted prepare
// self-acks through `send_or_loopback(self)` -> `push_loopback`. The
// consensus-tick arm of the shard pump never drains the loopback
// queue, so residuals would accumulate across ticks and trip the
// `push_loopback` capacity assert (`PIPELINE_PREPARE_QUEUE_MAX`).
// Draining to empty BEFORE each push bounds queue occupancy to one
// promotion window; the trailing drain applies the acks this pass
// produced (including promotion self-acks) instead of leaving them
// for a tick that never comes.
let mut loopback = Vec::new();
for header in &headers {
while self.apply_self_ack_loopback(&mut loopback).await {}
self.send_prepare_ok(header).await;
}
while self.apply_self_ack_loopback(&mut loopback).await {}
}
/// Drain the consensus loopback queue once and feed every self-`PrepareOk`
/// through [`Self::on_ack`], dropping anything else with a warning.
/// Returns whether any message was processed, so callers can loop until
/// the queue is empty (an `on_ack` can promote buffered requests whose
/// self-acks land back on the queue).
#[allow(clippy::future_not_send)]
async fn apply_self_ack_loopback(&self, loopback: &mut Vec<Message<GenericHeader>>) -> bool {
let consensus = self.consensus.as_ref().unwrap();
consensus.drain_loopback_into(loopback);
if loopback.is_empty() {
return false;
}
for message in loopback.drain(..) {
match message.header().command {
Command::PrepareOk => match message.try_into_typed::<PrepareOkHeader>() {
Ok(prepare_ok) => self.on_ack(prepare_ok).await,
Err(error) => warn!(
error = %error,
"dropping malformed PrepareOk from self-ack repair loopback"
),
},
command => warn!(
?command,
"dropping unexpected message from self-ack repair loopback"
),
}
}
true
}
/// Commit the committable prefix, ship the resulting wire replies, and
/// promote queued requests into the freed prepare slots.
///
/// Runs at the tail of every quorum-advancing `on_ack` and from the
/// shard tick via [`Self::resume_stranded_commits`]. Safe under
/// concurrent drivers: ownership of each op is arbitrated by the head
/// revalidation inside the loop.
#[allow(clippy::too_many_lines)]
#[allow(clippy::future_not_send)]
async fn commit_committable_prefix(&self) {
let consensus = self.consensus.as_ref().unwrap();
let journal = self.journal.as_ref().unwrap();
// Commit loop: peek -> await journal read -> revalidate head ->
// sync {pop, apply, advance_commit_min}.
//
// The entry stays in the pipeline across the journal-read await,
// so a driver of this function that is dropped there — a hyper
// HTTP handler future canceled by peer disconnect, or a parked
// in-process submitter — strands nothing: the next driver
// (sibling submit, shard pump, repair tick) re-peeks the same
// head and commits it. Popping BEFORE the await loses the entry
// forever on cancellation (nothing re-applies a popped entry;
// `repair_primary_self_acks` is re-ack-only), pinning
// `commit_min` below `commit_max` and panicking the next commit
// with "commit_min must advance sequentially".
//
// Concurrent drivers are serialized by the head revalidation:
// only the driver that still finds its peeked header at the
// pipeline head after the await owns that op's commit; everyone
// else re-peeks and moves on to the next committable op.
let mut wire_replies: Vec<(CommitLogEvent, Message<ReplyHeader>)> = Vec::new();
while let Some(prepare_header) = peek_committable_head(consensus) {
// A committed prepare missing from the journal is divergence; a
// warn-and-return here would strand `commit_min` behind
// `commit_max` forever (nothing re-applies a skipped op), so
// panicking is the answer. Journal compaction, if ever added,
// must not remove entries at or above the commit floor.
let prepare = journal
.handle()
.entry(&prepare_header)
.await
.unwrap_or_else(|| {
panic!(
"on_ack: committed prepare op={} checksum={} must be in journal",
prepare_header.op, prepare_header.checksum
)
});
// Revalidate after the await: a sibling driver may have
// committed this op (and more) while we were parked.
let head_is_ours = consensus.pipeline_head_header().is_some_and(|head| {
head.op == prepare_header.op && head.checksum == prepare_header.checksum
});
if !head_is_ours {
continue;
}
let mut entry = consensus
.pop_committed_prepare()
.expect("on_ack: revalidated head exists");
let pipeline_depth = consensus.pipeline_len();
let event = CommitLogEvent {
replica: ReplicaLogContext::from_consensus(consensus, PlaneKind::Metadata),
op: prepare_header.op,
client_id: prepare_header.client,
request_id: prepare_header.request,
operation: prepare_header.operation,
pipeline_depth,
};
// Apply SM + mutate client_table BEFORE advancing commit_min.
// `is_caught_up_primary` reads `commit_min == commit_max` as
// proof the table is caught up. Table first, counter last:
// panic mid-commit leaves the gate closed.
//
// Invariant: no .await or panic from the pop above through
// `advance_commit_min` and the subscriber fire below.
// Sync-only — this is what makes pop/apply/advance atomic on
// the single-threaded shard and keeps the head revalidation
// sound.
let reply = if prepare_header.operation == Operation::Register {
// Register: commit_register creates session, no SM.
let reply = build_reply_message(&prepare_header, &bytes::Bytes::new());
if self.client_table_mutation_allowed(prepare_header.op) {
self.client_table.borrow_mut().commit_register(
prepare_header.client,
prepare_header.user_id,
reply.clone(),
);
}
reply
} else if prepare_header.operation == Operation::Logout {
// Logout unregisters the VSR client session on every replica.
let reply = build_reply_message(&prepare_header, &bytes::Bytes::new());
if self.client_table_mutation_allowed(prepare_header.op) {
self.client_table.borrow_mut().remove_client(
prepare_header.client,
prepare_header.user_id,
SessionEnd::from_logout_request(prepare_header.request),
);
}
// Drop the disconnected client from every consumer group it
// joined and rebalance. Deterministic side-effect of the
// Logout commit, applied identically on every replica. Runs
// regardless of the table frontier: the STM was restored at
// the snapshot floor, so ops above it still owe their STM
// effects even where the table already reflects them.
self.mux_stm.streams().remove_consumer_group_member(
prepare_header.client,
iggy_common::IggyTimestamp::from(prepare_header.timestamp),
);
reply
} else {
// Normal op: apply SM, commit_reply. `Err` is decode/corruption
// only; a business rejection commits as a deterministic no-op
// whose `code` rides the reply body, replayed on retry.
let apply = gated_apply(&self.mux_stm, prepare).unwrap_or_else(|err| {
panic!(
"on_ack: committed metadata op={} failed to apply: {err}",
prepare_header.op
);
});
// Post-commit notifier (e.g. partition reconciler
// wake-up). Filtering by operation is the
// recipient's responsibility.
self.fire_commit_notifier(prepare_header.operation);
let reply =
build_reply_message_with(&prepare_header, apply.reply_body_len(), |dst| {
apply.write_reply_body(dst);
});
// Best-effort cache; the wire reply ships either way. Ops at
// or below the state-transfer frontier are already reflected
// in the transferred table and are skipped.
if self.client_table_mutation_allowed(prepare_header.op) {
let outcome = self.client_table.borrow_mut().commit_reply(
prepare_header.client,
prepare_header.user_id,
reply.clone(),
);
log_commit_reply_outcome(outcome, prepare_header.client, prepare_header.op);
}
reply
};
consensus.advance_commit_min(prepare_header.op);
emit_sim_event(SimEventKind::OperationCommitted, &event);
// Fire subscriber BEFORE wire send. Slot already updated
// (slot-first ordering, see take_reply_sender). Dropped
// receiver: ignored. Still inside the sync region, so an
// in-process awaiter is woken atomically with its commit.
let had_in_process_subscriber = entry.has_reply_sender();
if let Some(sender) = entry.take_reply_sender() {
let _ = sender.send(reply.clone());
}
// Skip wire send when an in-process subscriber consumed the
// reply: the caller (e.g. `complete_login_register`,
// `handle_logout_request`) ships its own full-body reply on
// the same socket. Sending both desyncs the SDK -- it reads
// the first frame, fails to decode the typed body, and
// leaves the second frame stuck in the socket buffer.
if !had_in_process_subscriber {
wire_replies.push((event, reply));
}
}
// Wire replies AFTER the commit loop: this region may await, and
// a driver dropped here loses only reply frames — every commit
// above is applied and its reply cached in the client_table, so
// the SDK recovers it via request replay.
for (event, reply) in wire_replies {
let generic_reply = reply.into_generic();
let reply_buffers = freeze_client_reply(generic_reply);
emit_sim_event(SimEventKind::ClientReplyEmitted, &event);
if let Err(e) = consensus
.message_bus()
.send_to_client(event.client_id, reply_buffers)
.await
{
error!(
client = event.client_id,
op = event.op,
request_id = event.request_id,
operation = ?event.operation,
%e,
"client reply forward failed, no retransmit path; client will time out",
);
}
}
// Commits freed prepare slots and reopened the catch-up gate;
// promote buffered requests so the pipeline stays busy and
// absorbed submits (queued while this batch was mid-commit)
// dispatch immediately.
self.drain_request_queue_into_prepares().await;
}
/// Timer-driven backstop (shard pump tick) for commit work stranded by
/// a canceled `on_ack` driver.
///
/// The commit loop and the promotion of queued requests run at the tail
/// of the quorum-advancing `on_ack` — inside whichever future delivered
/// that ack, and that future can be dropped at any of its awaits
/// (journal read, wire-reply send). The pipeline is then left with
/// committed-but-unapplied entries (`commit_min < commit_max`) and/or
/// still-queued requests, and on an idle server nothing re-drives them:
/// `ack_quorum_reached` opens the commit path only when `commit_max`
/// advances, which duplicate and repair acks never do. This tick entry
/// re-runs the same commit path; a still-parked sibling driver loses
/// the head revalidation and exits clean.
///
/// Ordering note: register promotion requires the catch-up gate open
/// (`register_preflight` drops the entry otherwise), and the gate can
/// only be closed here while stranded commits exist — which the commit
/// loop applies, reopening the gate, before promotion runs.
#[allow(clippy::future_not_send)]
pub async fn resume_stranded_commits(&self) {
let Some(consensus) = self.consensus.as_ref() else {
return;
};
if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_transferring()) {
return;
}
let stranded_commits = consensus.commit_min() < consensus.commit_max();
let promotable_requests = consensus
.with_pipeline(|pipeline| !pipeline.request_queue_is_empty() && !pipeline.is_full());
if !stranded_commits && !promotable_requests {
return;
}
self.commit_committable_prefix().await;
}
/// Promote buffered requests into free prepare slots after a commit
/// batch drains.
///
/// # Safety
/// Re-preflight per iteration: `commit_journal` may have advanced the
/// client's watermark between push and drain (`Duplicate` /
/// `AlreadyApplied` / `AlreadyRegistered`). Skipping produces a duplicate
/// prepare and panics.
#[allow(clippy::future_not_send)]
async fn drain_request_queue_into_prepares(&self) {
let consensus = self.consensus.as_ref().unwrap();
// Promote while prepare slots exist. Requests are queued for two
// reasons — prepare queue full at arrival, or (in-process register)
// the catch-up gate was closed — so promotion is bounded by slots,
// not by how many commits just freed: a whole burst absorbed during
// one commit window drains the moment the window closes. Promoted
// prepares are un-quorum'd, so they never re-close the gate here.
loop {
if consensus.pipeline_is_full() {
break;
}
let req = consensus.pop_queued_request();
let Some(mut req) = req else { break };
let client_id = req.message.header().client;
let session = req.message.header().session;
let request = req.message.header().request;
let request_checksum = req.message.header().request_checksum;
let operation = req.message.header().operation;
let user_id = req.message.header().user_id;
// If preflight or projection rejects below, dropping `req` (and
// the sender taken from it) wakes an in-process awaiter with
// `Canceled`; its submit path re-checks the client table.
let reply_sender = req.take_reply_sender();
let dispatch = if operation == Operation::Register {
register_preflight(consensus, &self.client_table, client_id, user_id)
} else {
let outcome = request_preflight(
consensus,
&self.client_table,
client_id,
session,
request,
request_checksum,
);
apply_preflight_consensus_plane(consensus, outcome, client_id).await
};
if !dispatch {
continue;
}
let prepare = match self.prepare_request(req.message) {
Ok(prepare) => prepare,
Err(error) => {
// Same invariant as `on_request`: a structurally-invalid
// request evicts, never a silent drop, or the SDK retries
// forever. Reachable here because requests are queued
// unvalidated (prepare queue full at arrival), projected now.
let reason = eviction_reason_for_invalid(operation);
warn!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
error = %error,
?reason,
"drain_request_queue: rejecting invalid queued request with eviction"
);
send_eviction_to_client(consensus, client_id, reason).await;
continue;
}
};
// Mirror `pipeline_prepare_common`, threading the queued
// subscriber into the pipeline entry so the awaiter that parked
// at enqueue time resolves on this prepare's commit.
assert!(!consensus.is_follower(), "promotion: primary only");
assert!(consensus.is_normal(), "promotion: status must be normal");
assert!(
!consensus.is_transferring(),
"promotion: must not be transferring state"
);
consensus.verify_pipeline();
match reply_sender {
Some(sender) => {
consensus.pipeline_message_with_sender(PlaneKind::Metadata, &prepare, sender);
}
None => consensus.pipeline_message(PlaneKind::Metadata, &prepare),
}
self.on_replicate(prepare).await;
}
}
}
impl<B, P, J, S, M, SB> IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
SB: SuperblockStore,
{
/// Persist the current VSR state to the superblock when the view changed since
/// the last write. The split-brain gate: callers MUST invoke this before
/// dispatching any view-scoped VSR message, so a replica that acted in a view can
/// never recover an older one after a crash.
///
/// It fences the SEND, not the ACT. By the time a caller reaches here the handler
/// has already moved `view`, `log_view`, `status`, the sequencer and the pipeline,
/// and `commit_journal` runs outside the gate, so a failed persist still applies
/// committed ops locally. That is the VSR fence and it is sufficient: local state a
/// crash forgets is state no peer ever saw, whereas an externalized view must be
/// recoverable. Do not read this as "nothing changed until the write lands".
///
/// `true` when the send may proceed, either because the state is now durable or
/// because there was nothing to persist (peer shard, partition plane, or an
/// unchanged view). `false` only when a write was attempted and failed, and the
/// caller must withhold the send. The in-memory view stays ahead of the durable
/// one, which a crash safely rolls back, and the next tick retries.
///
/// Kept on a `B`/`P`-only impl, with no journal/snapshot/state-machine bounds, so
/// every VSR dispatch site can gate on it regardless of its own bounds.
#[allow(clippy::future_not_send)]
pub async fn persist_superblock_if_needed(&self, consensus: &VsrConsensus<B, P>) -> bool {
let Some(superblock) = self.superblock.as_ref() else {
return true;
};
// Lock-free fast path: the steady state is an unchanged view with nothing to
// write, and skipping the lock keeps every gated send off it, notably
// `send_prepare_ok`, which runs this per metadata prepare. Safe because
// `view`/`log_view` advance only on this single-threaded executor and no
// `.await` sits between the `Cell` read and the return, so the value cannot
// change under us; a concurrent advance is caught by the re-check below.
if !consensus.needs_superblock_persist() {
return true;
}
// A write that keeps failing must not re-run a full `atomic_replace` on every
// 10 ms tick. Back off first, while still reporting `false` so the send stays
// withheld: fail-closed is the point of this gate, and the backoff only bounds
// what the retry costs.
if consensus.clock_realtime_micros() < self.superblock_retry_after_micros.get() {
return false;
}
// Serialize superblock writes on this shard: view-change persists here and
// checkpoints share the one ping-pong superblock, whose `write` picks its slot
// before it awaits, so two overlapping writers would target the same slot and
// could tear it while both report success. Re-check needs-persist AFTER
// acquiring the lock so check and write are atomic and a redundant caller
// coalesces, finding the state already made durable by the writer it queued
// behind.
let _superblock = self.superblock_lock.acquire().await;
if !consensus.needs_superblock_persist() {
return true;
}
self.write_superblock(consensus, superblock.as_ref()).await
}
/// Write the current VSR state, paired with the last durable checkpoint, under
/// [`Self::superblock_lock`].
///
/// The caller must hold that lock. The state is captured HERE rather than passed
/// in: with writes serialized and no await between the capture and the write, the
/// last writer carries the freshest view, so the durable view cannot regress even
/// when a checkpoint and a view change interleave. See `mark_superblock_durable`
/// for why the written values, not a re-read, mark durability.
///
/// # Terminal policy
/// There is none beyond staying fenced: a replica that cannot record the view it
/// is in must not act in it, so it withholds every view-scoped send, goes quiet,
/// and its peers elect around it. Failures are counted and the retry interval backs
/// off to [`SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS`], with the error logged on the
/// first failure and then at each backoff step rather than per tick.
///
/// TODO(fail-stop): a replica wedged here is dead weight an operator has to notice
/// from logs. Fail-stopping the process is the answer, and
/// [`fatal`] is now that primitive; wire it where the shard owns shutdown.
#[allow(clippy::future_not_send)]
async fn write_superblock(&self, consensus: &VsrConsensus<B, P>, superblock: &SB) -> bool {
// Carry the last durable pairing forward so a view-change write never
// regresses the `(checkpoint_op, checksum)` a checkpoint recorded. `(0, 0)`
// with no checkpoint taken, or no coordinator (peer shards, the simulator).
let (checkpoint_op, checkpoint_checksum) = self
.coordinator
.as_ref()
.map_or((0, 0), SnapshotCoordinator::last_checkpoint);
let state = consensus.vsr_state(checkpoint_op, checkpoint_checksum);
match superblock.write(&state.to_bytes()).await {
Ok(()) => {
consensus.mark_superblock_durable(state.view, state.log_view);
self.superblock_write_failures.set(0);
self.superblock_retry_after_micros.set(0);
true
}
Err(error) => {
let failures = self.superblock_write_failures.get() + 1;
self.superblock_write_failures.set(failures);
let backoff = SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS
.saturating_mul(1 << failures.min(SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT))
.min(SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS);
self.superblock_retry_after_micros
.set(consensus.clock_realtime_micros() + backoff);
// Rate-limited to the backoff steps: the tick would otherwise emit this
// every 10 ms for as long as the disk stays broken.
if failures.is_power_of_two() {
tracing::error!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
view = state.view,
log_view = state.log_view,
superblock_write_failures = failures,
retry_in_micros = backoff,
%error,
"superblock persist failed; withholding every view-scoped send \
until it succeeds, so this replica stays quorum-invisible"
);
}
false
}
}
}
/// Consecutive failed superblock writes, `0` when the last one succeeded. Read by
/// diagnostics: a non-zero value means this replica is fenced out of view changes.
#[must_use]
pub const fn superblock_write_failures(&self) -> u64 {
self.superblock_write_failures.get()
}
}
impl<B, P, J, S, M, SB> IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
SB: SuperblockStore,
J: JournalHandle,
J::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: StreamsFrontend
+ StateMachine<
Input = Message<PrepareHeader>,
Output = crate::stm::result::ApplyReply,
Error = iggy_common::IggyError,
>,
{
/// Run a forced checkpoint when the journal is low on capacity.
///
/// Diagnostic-only outcome: the caller holds the `journal_gate`, so this
/// is single-flight by construction, and a failure is deliberately not
/// surfaced as control flow — the prepare being replicated must proceed
/// to its append regardless (see the phantom-op comment at the call
/// site). The next prepare over the boundary simply retries.
#[allow(clippy::future_not_send)]
async fn checkpoint_if_needed(&self, consensus: &VsrConsensus<B, P>, journal: &J) {
let Some(coordinator) = &self.coordinator else {
return;
};
// Serialize whole checkpoints against each other. In-process metadata submits
// each run on their own spawned task (`bus.spawn` in the server's metadata submit
// handler), so at the checkpoint margin two can enter here concurrently; without
// this lock they would run concurrent `persist_snapshot`s over the single
// `snapshot.bin` and concurrently `drain` the WAL, which rewrites through a
// shared `wal.tmp`. Acquire BEFORE `should_checkpoint` so check and sequence are
// atomic: the second caller re-checks under the lock, finds the margin restored
// by the first's drain, and coalesces away the redundant work.
//
// The superblock write below takes `superblock_lock` for itself, so a
// concurrent view persist (and with it every gated send, including the ack
// path) waits only for that write and not for this whole sequence.
let _checkpoint = self.checkpoint_lock.acquire().await;
if !coordinator.should_checkpoint(journal) {
return;
}
// Use commit_min (locally executed), not commit_max. WAL entries
// between commit_min+1 and commit_max haven't been applied to the
// state machine yet, draining them would lose data on crash.
let snap_op = consensus.commit_min();
// Stamp created_at from the injected consensus clock (seed-derived
// under the simulator), not the wall clock, so replayed snapshots are
// byte-identical.
let created_at = consensus.clock_realtime_micros();
// Durability ordering, must not be reordered: persist the snapshot, durably
// record the (checkpoint_op, checksum, commit_max) pairing in the superblock,
// THEN drain the snapshotted prefix from the WAL. A crash before the
// superblock write recovers the prior checkpoint with the WAL intact; a crash
// after it recovers the new one. Draining before the superblock points at the
// new snapshot could strand committed ops on a crash. Each fallible step
// withholds the rest and returns early, leaving the WAL undrained, so
// `should_checkpoint` stays true and the next tick retries from the top at the
// then-current commit_min. The prepare being replicated appends regardless
// (see the phantom-op comment at the call site).
let client_table = self.client_table.borrow().to_snapshot();
let checksum = match coordinator.persist_snapshot(
&self.mux_stm,
snap_op,
created_at,
Some(client_table),
) {
Ok(checksum) => checksum,
Err(e) => {
error!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
checkpoint_op = snap_op,
error = %e,
"checkpoint snapshot persist failed"
);
return;
}
};
if let Some(superblock) = self.superblock.as_ref() {
// `persist_snapshot` already recorded the new pairing on the coordinator, so
// `write_superblock` picks it up from there. A view persist that interleaves
// between those two steps writes the same new pairing, which only makes it
// durable sooner.
debug_assert_eq!(
self.coordinator
.as_ref()
.map(SnapshotCoordinator::last_checkpoint),
Some((snap_op, checksum)),
"the checkpoint's pairing must be what the superblock write records"
);
let _superblock = self.superblock_lock.acquire().await;
if !self.write_superblock(consensus, superblock.as_ref()).await {
error!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
checkpoint_op = snap_op,
"checkpoint superblock write failed; withholding WAL drain"
);
return;
}
}
if let Err(e) = coordinator.drain(journal, snap_op).await {
error!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
checkpoint_op = snap_op,
error = %e,
"checkpoint WAL drain failed"
);
return;
}
// Info, not debug: checkpoints are rare and change what a restart can
// recover locally, and spec tests pin checkpoint placement by grepping
// this line off stdout at the default `info` level
// (`metadata_checkpoint_restart`, `metadata_state_transfer`).
info!(
target: "iggy.metadata.diag",
plane = "metadata",
replica_id = consensus.replica(),
checkpoint_op = snap_op,
"forced checkpoint completed"
);
}
#[allow(clippy::too_many_lines)]
fn prepare_request(
&self,
mut message: Message<RoutedRequestHeader>,
) -> Result<Message<PrepareHeader>, iggy_common::IggyError> {
let consensus = self.consensus.as_ref().unwrap();
let operation = message.header().operation;
let client_id = message.header().client;
// `TruncatePartition` is server-originated (the owning shard resolves a
// client `DeleteSegments` count to a concrete offset) but replicated AS
// the client's own request, so the commit records the client's request
// sequence in the `ClientTable`. It is internal -- no wire command code
// maps to it, so a client cannot construct one directly -- hence the
// `is_client_allowed` gate excludes it; admit it explicitly. The default
// match arm below projects it through unchanged.
if !operation.is_client_allowed() && operation != Operation::TruncatePartition {
return Err(IggyError::InvalidCommand);
}
// Stamp the acting user id into the replicated header so the in-apply
// RBAC gate (`crate::stm::authz`) resolves the same identity on every
// replica, WAL replay included (no session table there). Every client-op
// prepare funnels through here, primary-only by construction, so stamping
// here -- not in the in-process client path alone -- also covers the
// wire-plane ingresses (`on_request` and the request-queue drain). The
// wire `user_id` is never trusted: it is overwritten for every gated
// client op, and a client whose session is unknown is denied here
// (fail-closed), never defaulted to root. `Register` / `Logout` are exempt
// (see `resolve_acting_user_id`); server-originated internal ops
// (`CompleteConsumerGroupRevocation`, the PAT-cleaner delete) build their
// prepare directly, bypassing this path, and keep `user_id` 0 (gate skips).
// A Logout is not gated, so it gets no acting user above, yet its apply
// removes a dedup fence keyed by user. Resolve the owner of the session
// it ends here, on the primary, from the live entry or its fence, so
// every replica drops the same fence; an unmatched session stamps 0
// and the apply leaves the fences alone.
if operation == Operation::Logout {
let request_header = bytemuck::checked::try_from_bytes_mut::<RoutedRequestHeader>(
&mut message.as_mut_slice()[..size_of::<RoutedRequestHeader>()],
)
.expect("a routed request header was validated on receipt");
request_header.user_id = self
.client_table
.borrow()
.user_id_for_session(client_id, request_header.session)
.unwrap_or(0);
}
if let Some(acting_user_id) =
resolve_acting_user_id(operation, client_id, &self.client_table)?
{
let request_header = bytemuck::checked::from_bytes_mut::<RoutedRequestHeader>(
&mut message.as_mut_slice()[..size_of::<RoutedRequestHeader>()],
);
request_header.user_id = acting_user_id;
}
// Must be read AFTER the stamp: the `CreateTopic` / `CreatePartitions`
// arms hand this `header` copy to `build_prepare_message`, so it has to
// carry the stamped acting user. Reading it before the stamp would ship
// those prepares with the untrusted wire `user_id` (0 = root from
// well-behaved SDKs, attacker-chosen otherwise), silently bypassing the
// authz gate. The default arm projects the mutated buffer directly and
// is order-independent.
let header = *message.header();
let body = &message.as_slice()[size_of::<RoutedRequestHeader>()..header.size as usize];
match header.operation {
Operation::CreateTopic => {
let mut request = WireCreateTopicRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
// Resolve every absent catalog key against server config here,
// at primary admission, so the replicated payload carries
// concrete values and every replica commits the same state
// regardless of local config. Resolved defaults ride a separate
// derived block, preserving per-key provenance for `GetTopic`.
let explicit = TopicCreateOptions::parse(&request.options)?;
// Re-encode the explicit block from the parse rather than
// forwarding the client's bytes. Parsing normalizes a zero
// sentinel to "absent", so the resolved value goes in the
// derived block -- but apply merges with explicit winning, so a
// forwarded literal `0` would land back on top as the stored
// effective value. `GetTopic` would report 0, and a restart
// would re-parse that 0 to absent and fall back to whatever the
// node default is by then, not the value resolved at creation.
// Re-encoding also canonicalizes kinds (a `"128MiB"` string
// becomes `Uint64`), so the stored map reads back uniformly.
request.options = explicit.to_wire()?;
let resolved_segment_size = explicit
.segment_size
.unwrap_or_else(|| IggyByteSize::from(iggy_common::DEFAULT_SEGMENT_SIZE));
let resolved_max_topic_size = explicit
.max_topic_size
.unwrap_or_else(|| MaxTopicSize::from(iggy_common::DEFAULT_MAX_TOPIC_SIZE));
// Backstop for the transport-side typed checks: an explicit
// segment size outside its bounds, or a topic cap below one
// segment, must never enter the WAL.
if let Some(segment_size) = explicit.segment_size {
validate_topic_segment_size(
segment_size.as_bytes_u64(),
iggy_common::MAX_TOPIC_SEGMENT_SIZE,
)?;
}
if resolved_max_topic_size.as_bytes_u64() < resolved_segment_size.as_bytes_u64() {
return Err(IggyError::InvalidOptionValue(
topic_option_keys::MAX_TOPIC_SIZE.to_string(),
));
}
let derived_options = explicit.derived_block(
explicit.compression_algorithm.unwrap_or_default(),
explicit
.message_expiry
.unwrap_or_else(|| IggyExpiry::from(iggy_common::DEFAULT_MESSAGE_EXPIRY)),
resolved_max_topic_size,
TopicRuntimeDefaults {
segment_size: resolved_segment_size,
enforce_fsync: explicit
.enforce_fsync
.unwrap_or(iggy_common::DEFAULT_ENFORCE_FSYNC),
messages_required_to_save: explicit
.messages_required_to_save
.unwrap_or(iggy_common::DEFAULT_MESSAGES_REQUIRED_TO_SAVE),
size_of_messages_required_to_save: explicit
.size_of_messages_required_to_save
.unwrap_or_else(|| {
IggyByteSize::from(
iggy_common::DEFAULT_SIZE_OF_MESSAGES_REQUIRED_TO_SAVE,
)
}),
preallocate_segments: explicit
.preallocate_segments
.unwrap_or(iggy_common::DEFAULT_PREALLOCATE_SEGMENTS),
},
)?;
let partitions = self
.allocator
.allocate_many(request.partitions_count as usize)
.into_iter()
.enumerate()
.map(|(partition_id, consensus_group_id)| {
Ok(CreatedPartitionAssignment {
partition_id: u32::try_from(partition_id)
.map_err(|_| IggyError::InvalidCommand)?,
consensus_group_id,
})
})
.collect::<Result<Vec<_>, _>>()?;
let body = PersistedCreateTopicRequest {
request,
derived_options,
partitions,
// Minted once here, on the admitting primary, and sealed by
// `checksum_body`: the header's `view` is restamped on a
// post-view-change retransmit, so a body-carried copy is the
// only per-op view every replica commits identically.
created_view: consensus.view(),
}
.to_bytes();
Ok(build_prepare_message(
consensus,
&header,
Operation::CreateTopicWithAssignments,
&body,
))
}
Operation::CreatePartitions => {
let request = WireCreatePartitionsRequest::decode_from(body)
.map_err(|_| IggyError::InvalidCommand)?;
// Parent-existence is validated at apply, returning
// `CreatePartitionsResult::{Stream,Topic}NotFound`. A preflight
// read here would decide against possibly-uncommitted state and
// drop without a reply (TOCTOU + wedge). See the metadata
// validation design doc.
let partitions = self
.allocator
.allocate_many(request.partitions_count as usize)
.into_iter()
.enumerate()
.map(|(offset, consensus_group_id)| {
Ok(CreatedPartitionAssignment {
partition_id: u32::try_from(offset)
.map_err(|_| IggyError::InvalidCommand)?,
consensus_group_id,
})
})
.collect::<Result<Vec<_>, _>>()?;
let body = PersistedCreatePartitionsRequest {
request,
partitions,
// Same body-carried view as `PersistedCreateTopicRequest`.
created_view: consensus.view(),
}
.to_bytes();
Ok(build_prepare_message(
consensus,
&header,
Operation::CreatePartitionsWithAssignments,
&body,
))
}
// `UpdateTopic` deliberately takes the default arm: unlike create,
// an update stores `ServerDefault` sentinels verbatim (legacy
// parity), so a later get echoes `ServerDefault` instead of the
// node default frozen at update time.
_ => Ok(message.project(consensus)),
}
}
/// Replicate a prepare message to the next replica in the chain.
///
/// Chain replication pattern:
/// - Primary sends to first backup
/// - Each backup forwards to the next
/// - Stops when we would forward back to primary
///
/// Caller must have already appended `message` to the local journal
/// before invoking this helper (VSR tail-ahead-of-head). Forwarding
/// an un-persisted prepare would leave downstream WALs with an op
/// this replica's journal does not hold.
#[allow(clippy::future_not_send)]
async fn replicate(&self, message: &Message<PrepareHeader>) {
let consensus = self.consensus.as_ref().unwrap();
let journal = self.journal.as_ref().unwrap();
let header = *message.header();
#[allow(clippy::cast_possible_truncation)]
let op = header.op as usize;
assert_eq!(header.command, Command::Prepare);
assert!(
journal.handle().header(op).is_some(),
"replicate: prepare must be durable in local journal before chain-forward"
);
if let Err(e) = replicate_to_next_in_chain(consensus, message).await {
tracing::warn!(op = header.op, error = ?e, "chain replication failed");
}
}
/// Apply ops `[commit_min+1 .. commit_max]` to state machine and
/// `client_table`. Backup does NOT ship wire replies (primary's job).
///
/// # Safety: ordering invariant
///
/// `advance_commit_min(op)` and matching `client_table` mutation
/// (`commit_register` / `commit_reply`) run back-to-back, no `.await`
/// between. [`consensus::is_caught_up_primary`] reads
/// `commit_min == commit_max` as proof the table is caught up; an await
/// here lets another task observe transient equality with stale table,
/// dispatch a fresh Register on an already-registered client, and bump
/// the epoch past the reply the live client holds.
///
/// Inner block sync today. Future async state-machine must either:
/// 1. Apply SM + bump `commit_min` in one `RefCell` borrow, or
/// 2. Buffer apply, bump `commit_min` post table-mutation, gate
/// `is_caught_up_primary` on a higher "applied frontier".
///
/// `is_caught_up_primary_gate_states` pins clauses, NOT intra-loop window.
#[allow(clippy::cast_possible_truncation, clippy::missing_panics_doc)]
#[allow(clippy::future_not_send)]
pub async fn commit_journal(&self) {
let consensus = self.consensus.as_ref().unwrap();
let journal = self.journal.as_ref().unwrap();
while consensus.commit_min() < consensus.commit_max() {
let op = consensus.commit_min() + 1;
let Some(header) = journal.handle().header(op as usize) else {
// Gap-stop: the walk halts at the first missing prepare and
// resumes once it is refilled. Live drops refill via the
// primary's prepare retransmit; a replica behind at recovery
// or after StartView adoption arms a `MetadataRepairSession`
// (shard) that re-requests the missing window.
break;
};
let header = *header;
let Some(prepare) = journal.handle().entry(&header).await else {
warn!("commit_journal: prepare body missing for op={op}, stopping");
break;
};
// SM apply + client_table mutation BEFORE `advance_commit_min`
// (see `on_ack` for matching invariant). No await between table
// mutation and counter bump. The post-commit notifier (e.g. partition
// reconciler wake-up) fires on backups too, so reconcilers converge
// after replicated commits, not only quorum-acked ones reached via
// `on_ack` on the primary.
//
// Table mutations are skipped at or below the state-transfer
// frontier: those ops are already reflected in the transferred
// table, while their state-machine effects still have to replay
// (the snapshot sits at a lower op).
apply_committed_prepare(
&self.mux_stm,
&self.client_table,
self.client_table_mutation_allowed(header.op),
|operation| self.fire_commit_notifier(operation),
prepare,
);
consensus.advance_commit_min(op);
debug!("commit_journal: committed op={op}");
}
}
fn observe_prepare_runtime_state(&self, prepare: &Message<PrepareHeader>) {
let header = prepare.header();
let body = &prepare.as_slice()[size_of::<PrepareHeader>()..header.size as usize];
match header.operation {
Operation::CreateTopicWithAssignments => {
let request = PersistedCreateTopicRequest::decode_from(body)
.expect("create topic with assignments prepare must decode");
// A topic may be created with zero partitions and grown later,
// so there may be no consensus group to observe yet.
if let Some(highest_consensus_group_id) = request
.partitions
.iter()
.map(|partition| partition.consensus_group_id)
.max()
{
self.allocator.observe(highest_consensus_group_id);
}
}
Operation::CreatePartitionsWithAssignments => {
let request = PersistedCreatePartitionsRequest::decode_from(body)
.expect("create partitions with assignments prepare must decode");
if let Some(highest_consensus_group_id) = request
.partitions
.iter()
.map(|partition| partition.consensus_group_id)
.max()
{
self.allocator.observe(highest_consensus_group_id);
}
}
_ => {}
}
}
#[allow(clippy::future_not_send, clippy::cast_possible_truncation)]
async fn send_prepare_ok(&self, header: &PrepareHeader) {
let consensus = self.consensus.as_ref().unwrap();
// Durable-before-send: a PrepareOk implies this replica's (view, log_view), so
// it must not leave until they are durable, or a crash could recover an older
// view than the one this ack helped commit in, losing a committed op. Mirrors
// the view-change dispatch gate; withhold on persist failure and let the
// primary's prepare retransmit re-drive the ack once the next tick persists.
if !self.persist_superblock_if_needed(consensus).await {
return;
}
// Containment, not occupancy: after a refused append (slot collision,
// view-change race) the slot can hold a DIFFERENT prepare at this op,
// and acking it would vouch durability for bytes this replica never
// journaled. Checksum equality withholds the ack; the primary's
// retransmit re-drives it once the right prepare lands.
let journal = self.journal.as_ref().unwrap();
let persisted = journal
.handle()
.header(header.op as usize)
.is_some_and(|stored| stored.checksum == header.checksum);
send_prepare_ok_common(consensus, header, persisted).await;
}
}
/// In-process Register `Message<RoutedRequestHeader>`. Mirrors
/// `SimClient::register`: `session=0`, `request=0` per
/// [`RoutedRequestHeader::validate`]; empty body.
///
/// `cluster` + `view` from `consensus` for self-consistency before
/// `Project::project` overwrites. `release = 0` matches wire today; both
/// paths should switch to `consensus.release()` once
/// `ClientReleaseTooLow/TooHigh` lands.
///
/// Buffer is `size_of::<RoutedRequestHeader>()`; `prepare_request` transmutes into
/// `PrepareHeader` (also 256 bytes), no realloc.
fn build_register_request_message<B, P>(
consensus: &VsrConsensus<B, P>,
client_id: u128,
user_id: u32,
) -> Message<RoutedRequestHeader>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
let header_size = size_of::<RoutedRequestHeader>();
let mut msg = Message::<RoutedRequestHeader>::new(header_size);
let header = bytemuck::checked::try_from_bytes_mut::<RoutedRequestHeader>(
&mut msg.as_mut_slice()[..header_size],
)
.expect("zeroed bytes are a valid RoutedRequestHeader");
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::Register,
size: u32::try_from(header_size).expect("RoutedRequestHeader size fits u32"),
cluster: consensus.cluster(),
view: consensus.view(),
release: 0,
client: client_id,
session: 0,
request: 0,
// Replicated on the prepare so every replica resolves session -> user.
user_id,
// Route through the metadata consensus group. The chain-forwarded
// prepare is re-routed on each peer by namespace; a `0` here would
// hash to a non-zero shard with no metadata consensus and be
// silently dropped (see `shard::router::route_typed`).
group: server_common::sharding::METADATA_GROUP,
..RoutedRequestHeader::default()
};
msg
}
fn build_logout_request_message<B, P>(
consensus: &VsrConsensus<B, P>,
client_id: u128,
session: u64,
request: u64,
) -> Message<RoutedRequestHeader>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
let header_size = size_of::<RoutedRequestHeader>();
let mut msg = Message::<RoutedRequestHeader>::new(header_size);
let header = bytemuck::checked::try_from_bytes_mut::<RoutedRequestHeader>(
&mut msg.as_mut_slice()[..header_size],
)
.expect("zeroed bytes are a valid RoutedRequestHeader");
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::Logout,
size: u32::try_from(header_size).expect("RoutedRequestHeader size fits u32"),
cluster: consensus.cluster(),
view: consensus.view(),
release: 0,
client: client_id,
session,
request,
// Metadata consensus group (see `build_register_request_message`).
group: server_common::sharding::METADATA_GROUP,
..RoutedRequestHeader::default()
};
msg
}
fn build_complete_revocation_request_message<B, P>(
consensus: &VsrConsensus<B, P>,
client_id: u128,
request: u64,
body: &[u8],
) -> Message<RoutedRequestHeader>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
let header_size = size_of::<RoutedRequestHeader>();
let total = header_size + body.len();
let mut msg = Message::<RoutedRequestHeader>::new(total);
{
let slice = msg.as_mut_slice();
slice[header_size..total].copy_from_slice(body);
let header =
bytemuck::checked::try_from_bytes_mut::<RoutedRequestHeader>(&mut slice[..header_size])
.expect("zeroed bytes are a valid RoutedRequestHeader");
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::CompleteConsumerGroupRevocation,
size: u32::try_from(total).expect("request size fits u32"),
cluster: consensus.cluster(),
view: consensus.view(),
release: 0,
client: client_id,
// `validate()` requires session/request > 0 for non-register ops;
// there is no real session (the commit path skips reply-caching).
session: 1,
request,
group: server_common::sharding::METADATA_GROUP,
..RoutedRequestHeader::default()
};
}
msg
}
/// Build a `TruncatePartition` request attributed to the originating client.
///
/// Replicated through the standard client-request path so the commit records
/// `(client, session, request)` in the `ClientTable` and advances that
/// session's watermark. Attributing the truncate to an internal id (or
/// skipping the commit) would leave this request id unrecorded, so the
/// client's own retry of it would re-execute instead of deduping.
///
/// `template` is the client's own `DeleteSegments` header: it supplies the wire
/// `cluster` / `view` / `release` and the client's `request` number.
/// `client_id` / `session` are the bound VSR identity.
///
/// # Panics
/// If the total request size exceeds `u32::MAX`; a `TruncatePartition` body is
/// a few fixed-width fields, so this cannot happen in practice.
#[must_use]
pub fn build_truncate_partition_client_message(
template: &RoutedRequestHeader,
client_id: u128,
session: u64,
stream_id: u32,
topic_id: u32,
partition_id: u32,
up_to_offset: u64,
) -> Message<RoutedRequestHeader> {
build_truncate_partition_client_message_with_identifiers(
template,
client_id,
session,
WireIdentifier::numeric(stream_id),
WireIdentifier::numeric(topic_id),
partition_id,
up_to_offset,
)
}
/// [`build_truncate_partition_client_message`] with the client's raw wire
/// identifiers (name or id) instead of resolved numeric ids.
///
/// Used when the target does not resolve on the handling node: the truncate
/// still commits, and the apply rejects it as a committed result, keeping the
/// client's request sequence contiguous while surfacing the typed error.
///
/// # Panics
/// If the total request size exceeds `u32::MAX`; a `TruncatePartition` body is
/// a few small fields, so this cannot happen in practice.
#[must_use]
pub fn build_truncate_partition_client_message_with_identifiers(
template: &RoutedRequestHeader,
client_id: u128,
session: u64,
stream_id: WireIdentifier,
topic_id: WireIdentifier,
partition_id: u32,
up_to_offset: u64,
) -> Message<RoutedRequestHeader> {
let body = TruncatePartitionRequest {
stream_id,
topic_id,
partition_id,
up_to_offset,
}
.to_bytes();
let header_size = size_of::<RoutedRequestHeader>();
let total = header_size + body.len();
let mut msg = Message::<RoutedRequestHeader>::new(total);
{
let slice = msg.as_mut_slice();
slice[header_size..total].copy_from_slice(&body);
let header =
bytemuck::checked::try_from_bytes_mut::<RoutedRequestHeader>(&mut slice[..header_size])
.expect("zeroed bytes are a valid RoutedRequestHeader");
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::TruncatePartition,
size: u32::try_from(total).expect("request size fits u32"),
cluster: template.cluster,
view: template.view,
release: template.release,
client: client_id,
session,
request: template.request,
group: server_common::sharding::METADATA_GROUP,
..RoutedRequestHeader::default()
};
}
msg
}
fn build_prepare_message<B, P>(
consensus: &VsrConsensus<B, P>,
request: &RoutedRequestHeader,
operation: Operation,
body: &[u8],
) -> Message<PrepareHeader>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
let op = consensus.sequencer().current_sequence() + 1;
let size = size_of::<PrepareHeader>() + body.len();
let mut prepare = Message::<PrepareHeader>::new(size);
let prepare_bytes = prepare.as_mut_slice();
prepare_bytes[size_of::<PrepareHeader>()..size].copy_from_slice(body);
let header_bytes = &mut prepare_bytes[..size_of::<PrepareHeader>()];
let new_header = bytemuck::checked::try_from_bytes_mut::<PrepareHeader>(header_bytes)
.expect("prepare header bytes should be valid");
// Match `Project::project` (core/consensus/src/impls.rs): the primary
// stamps the injected clock once here (wall time in production, virtual
// under the simulator) so every replica's `StateHandler::apply` reads the
// same `created_at`. A `0` stamp would persist a 1970-01-01
// `created_at` on every CreateStream/CreateTopic/CreatePartitions. The
// in-process callers that bypass `Project::project` build their prepare
// through this helper directly (the CreateTopic/CreatePartitions
// assignment rewrites and the PAT-cleaner delete); the stamp is
// load-bearing for the creates and inert for the delete, whose apply
// ignores it.
// Shared `next_monotonic_timestamp` keeps the in-process path on the same
// monotonic-clock guard as the wire path.
let timestamp = consensus.next_monotonic_timestamp();
*new_header = PrepareHeader {
cluster: consensus.cluster(),
size: u32::try_from(size).expect("prepare message size exceeds u32"),
view: consensus.view(),
release: request.release,
command: Command::Prepare,
replica: consensus.replica(),
client: request.client,
parent: consensus.last_prepare_checksum(),
request_checksum: request.request_checksum,
request: request.request,
commit: consensus.commit_max(),
op,
timestamp,
operation,
// The group's namespace, never the request's: clients send 0, and a
// journaled 0 mis-routes the entry when repair replays it verbatim.
group: consensus.group(),
// Carry the acting user id so the in-apply RBAC gate sees the same
// identity on every replica. The default projection copies it (see
// `Project::project`); this helper builds prepares for the ops it
// rewrites (the CreateTopic/CreatePartitions assignment rewrites and
// the PAT-cleaner delete), which would otherwise reset it to 0 via
// `..Default::default()`.
user_id: request.user_id,
// Seal the body integrity field over the rewritten body, exactly as
// `Project::project` does for wire-projected prepares. This helper builds
// a NEW body, so the wire header's stamp does not describe it; leaving it
// zero makes the journal scan read every rewritten entry as corrupt and
// refuse boot on the next restart.
checksum_body: u128::from(iggy_common::calculate_checksum(body)),
..Default::default()
};
// Last, because the identity checksum covers every other field. Same contract as
// the wire path in `Project::project`; skipping it would leave the rewritten
// prepares (CreateTopic/CreatePartitions assignments, the UpdateTopic default-size
// rewrite, the PAT-cleaner delete) as the only ops the merge cannot tell apart
// from a competing prepare.
consensus::seal_prepare_checksum(prepare)
}
/// Eviction reason for a request `prepare_request` rejected as structurally
/// invalid.
const fn eviction_reason_for_invalid(operation: Operation) -> EvictionReason {
if operation.is_client_allowed() {
EvictionReason::InvalidRequestBody
} else {
EvictionReason::InvalidRequestOperation
}
}
/// Resolve the acting user id to stamp into a client op's replicated
/// `RoutedRequestHeader`, so the in-apply RBAC gate (`crate::stm::authz`) reads the
/// same identity on every replica (WAL replay has no session table).
///
/// - `Ok(Some(id))`: overwrite the header's `user_id` with the committed
/// session's user; the wire-supplied value is never trusted.
/// - `Ok(None)`: leave it untouched. `Register` carries the credential-verified
/// user from login (a mid-`Register` client has no session yet, so resolution
/// would miss and fail-close, denying every login) and `Logout` is not gated
/// -- both keep their builder value.
/// - `Err(Unauthenticated)`: fail-closed. A client op whose `client_id` has no
/// session cannot be attributed, so it is denied rather than defaulted to
/// root (`user_id` 0 is the gate's all-allow short-circuit). Every caller
/// preflights a live session first (`request_preflight` dispatches only on
/// `New`), so this guards a future ingress that reaches here without one.
fn resolve_acting_user_id(
operation: Operation,
client_id: u128,
client_table: &RefCell<ClientTable>,
) -> Result<Option<u32>, IggyError> {
if matches!(operation, Operation::Register | Operation::Logout) {
return Ok(None);
}
client_table
.borrow()
.get_user_id(client_id)
.ok_or(IggyError::Unauthenticated)
.map(Some)
}
/// Surface a non-`Cached` [`CommitReply`]. Both non-cached outcomes are
/// expected under replica-local eviction, so they are diagnostics, never
/// faults: the wire reply already shipped and only this entry's dedup is
/// degraded.
fn log_commit_reply_outcome(outcome: CommitReply, client_id: u128, op: u64) {
match outcome {
CommitReply::Cached => {}
CommitReply::NoEntry => tracing::trace!(
target: "iggy.metadata.diag",
client_id,
op,
"commit_reply: client evicted while being prepared; reply shipped, cache skipped"
),
CommitReply::SkippedRegression { stored, received } => warn!(
target: "iggy.metadata.diag",
client_id,
op,
stored,
received,
"commit_reply: committed op is older than the cached entry \
(replica-local eviction replayed out of order); cache skipped"
),
CommitReply::AdvancedFence => tracing::trace!(
target: "iggy.metadata.diag",
client_id,
op,
"commit_reply: client evicted while being prepared; reply shipped, fence advanced"
),
}
}
/// Refusal reply for a replayed request whose committed answer carried a
/// secret the cache cannot reproduce; `None` when the cached reply is safe to
/// replay verbatim.
///
/// Only `CreatePersonalAccessToken` qualifies today. Its raw secret is
/// deliberately never replicated (minting inside the apply would re-roll
/// `ring::rand` per replica and diverge the token index; see
/// `CreatePersonalAccessTokenRequest::apply`), so the committed reply is
/// `ApplyReply::ok(Bytes::new())` and the cache holds no token. The secret
/// existed only on the wire of the original reply, spliced in by
/// `build_raw_pat_reply`, and is unrecoverable once that reply is lost.
/// Meanwhile the ingress rewrite has already minted a FRESH secret for the
/// replayed frame whose hash never reached consensus, so serving the cached
/// success would splice that orphan onto it and hand the caller a credential
/// that authenticates against nothing.
///
/// `PersonalAccessTokenAlreadyExists` is the honest code: the original create
/// committed, so the name IS taken, and the remedy it implies (delete by name,
/// then recreate) is exactly right.
///
/// A cached REJECTION replays untouched: it carries no secret, so serving it
/// is both safe and useful.
fn unreplayable_secret_refusal(
request_header: &RoutedRequestHeader,
cached: &Frozen<{ server_common::MESSAGE_ALIGN }>,
commit: u64,
client_id: u128,
) -> Option<Message<GenericHeader>> {
if request_header.operation != Operation::CreatePersonalAccessToken {
return None;
}
let cached_body = cached
.as_slice()
.get(size_of::<ReplyHeader>()..)
.unwrap_or_default();
if iggy_binary_protocol::result_code(cached_body) != Some(0) {
return None;
}
warn!(
target: "iggy.metadata.diag",
client_id,
request = request_header.request,
"refusing replayed CreatePersonalAccessToken: the committed secret is \
unrecoverable and a re-minted one would not match the stored hash"
);
Some(
build_result_rejection_reply(
request_header,
commit,
IggyError::PersonalAccessTokenAlreadyExists(String::new(), 0).as_code(),
)
.into_generic(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stm::stream::Streams;
use crate::stm::user::Users;
use consensus::LocalPipeline;
use iggy_binary_protocol::WireOptions;
use iggy_binary_protocol::requests::topics::CreateTopicRequest;
use iggy_common::variadic;
use journal::prepare_journal::PrepareJournal;
use message_bus::{
BusMessage, ClientForwardFn, ConnectionLostFn, JoinHandle, ReplicaForwardFn, SendError,
};
use server_common::MESSAGE_ALIGN;
use server_common::iobuf::Frozen;
use std::cell::RefCell;
use std::rc::Rc;
#[test]
fn eviction_reason_splits_client_and_internal_ops() {
// Client-allowed op with a bad body evicts InvalidRequestBody; an internal
// / unknown op (here `CreateTopicWithAssignments`) evicts
// InvalidRequestOperation.
assert_eq!(
eviction_reason_for_invalid(Operation::CreateStream),
EvictionReason::InvalidRequestBody,
);
assert_eq!(
eviction_reason_for_invalid(Operation::CreateTopicWithAssignments),
EvictionReason::InvalidRequestOperation,
);
}
#[test]
fn populated_snapshot_reencode_and_checksum_are_stable() {
// Two replicas holding identical state must serialize it identically, which
// holds only while every serialized collection keeps a deterministic order.
// Fold a multi-slot client table (the envelope's own Vec) and assert the raw
// re-encode survives the round-trip. The streams sub-tree's map-derived Vecs are
// covered by
// `crate::stm::stream::tests::populated_streams_snapshot_reencode_is_byte_stable`.
let mut snapshot = IggySnapshot::new(7);
snapshot.snapshot.client_table = Some(consensus::ClientTableSnapshot {
fences: vec![],
slots: vec![
(
0,
consensus::ClientEntrySnapshot {
client_id: 1,
epoch: 10,
user_id: 1,
watermark: 3,
watermark_checksum: 0xabc,
reply: vec![1, 2, 3],
},
),
(
2,
consensus::ClientEntrySnapshot {
client_id: 2,
epoch: 20,
user_id: 2,
watermark: 0,
watermark_checksum: 0,
reply: vec![4, 5],
},
),
],
});
let encoded = snapshot.encode().unwrap();
let decoded = IggySnapshot::decode(&encoded).unwrap();
assert_eq!(
encoded,
decoded.encode().unwrap(),
"a populated snapshot must re-encode byte-identically after a decode; an \
unordered collection in the serialized form would make two replicas with \
identical state serialize differently"
);
assert_ne!(
checkpoint_checksum(&encoded),
checkpoint_checksum(&IggySnapshot::new(7).encode().unwrap()),
"the checksum must track content, else it could not detect a torn snapshot"
);
}
#[test]
fn client_table_reply_bytes_encode_as_a_msgpack_blob() {
// A `Vec<u8>` serialized through serde's sequence path spends 2 bytes on every
// byte >= 0x80, so a checkpoint's reply payload runs up to roughly double.
// Reply bytes are wire messages, mostly high bytes, so pin the `bin` encoding:
// the format freezes at release and this is much cheaper to fix now.
const REPLY_LEN: usize = 512;
let mut snapshot = IggySnapshot::new(1);
snapshot.snapshot.client_table = Some(consensus::ClientTableSnapshot {
fences: vec![],
slots: vec![(
0,
consensus::ClientEntrySnapshot {
client_id: 1,
epoch: 1,
user_id: 1,
watermark: 0,
watermark_checksum: 0,
reply: vec![0xFF; REPLY_LEN],
},
)],
});
let encoded = snapshot.encode().unwrap();
let baseline = IggySnapshot::new(1).encode().unwrap().len();
let reply_cost = encoded.len() - baseline;
assert!(
reply_cost < REPLY_LEN * 2,
"a {REPLY_LEN}-byte reply of high bytes cost {reply_cost} bytes, so it is \
still encoding as an integer array rather than a msgpack blob"
);
// And it must decode back to the same bytes.
let decoded = IggySnapshot::decode(&encoded).unwrap();
let table = decoded.snapshot().client_table.as_ref().unwrap();
assert_eq!(table.slots[0].1.reply, vec![0xFF; REPLY_LEN]);
}
type TestMux = MuxStateMachine<variadic!(Users, Streams)>;
/// Build a peer-shard-style `IggyMetadata` with `consensus`,
/// `journal`, and `snapshot` all `None`. Enough to test the
/// commit-notifier slot without standing up VSR / WAL infrastructure:
/// the test picks `()` for `C` / `J` / `S` since no notifier code path
/// touches their methods.
fn peer_metadata() -> IggyMetadata<(), (), (), TestMux> {
IggyMetadata::new(None, None, None, None, TestMux::default(), None)
}
#[test]
fn commit_notifier_fires_with_received_operation() {
let md = peer_metadata();
let captured: Rc<RefCell<Vec<Operation>>> = Rc::new(RefCell::new(Vec::new()));
let observer = Rc::clone(&captured);
md.set_commit_notifier(Some(Rc::new(move |op| {
observer.borrow_mut().push(op);
})));
md.fire_commit_notifier(Operation::CreateTopicWithAssignments);
md.fire_commit_notifier(Operation::DeletePartitions);
md.fire_commit_notifier(Operation::DeleteStream);
let seen = captured.borrow();
assert_eq!(
seen.as_slice(),
&[
Operation::CreateTopicWithAssignments,
Operation::DeletePartitions,
Operation::DeleteStream,
],
"notifier must observe every fired operation in order"
);
}
#[test]
fn commit_notifier_is_no_op_when_unset() {
// No notifier installed: firing must not panic, must not allocate.
// Mirrors the production-side guarantee that peer shards (no
// notifier) take the same commit path as shard 0 (with notifier).
let md = peer_metadata();
md.fire_commit_notifier(Operation::CreateStream);
}
#[test]
fn commit_notifier_can_be_replaced_and_cleared() {
let md = peer_metadata();
let first_count: Rc<RefCell<usize>> = Rc::new(RefCell::new(0));
let second_count: Rc<RefCell<usize>> = Rc::new(RefCell::new(0));
let first_observer = Rc::clone(&first_count);
md.set_commit_notifier(Some(Rc::new(move |_op| {
*first_observer.borrow_mut() += 1;
})));
md.fire_commit_notifier(Operation::CreateStream);
assert_eq!(*first_count.borrow(), 1);
// Replace: the first closure must no longer run.
let second_observer = Rc::clone(&second_count);
md.set_commit_notifier(Some(Rc::new(move |_op| {
*second_observer.borrow_mut() += 1;
})));
md.fire_commit_notifier(Operation::DeleteStream);
assert_eq!(*first_count.borrow(), 1, "old notifier must be detached");
assert_eq!(*second_count.borrow(), 1, "new notifier must take over");
// Clear: subsequent fires must be no-ops.
md.set_commit_notifier(None);
md.fire_commit_notifier(Operation::DeleteTopic);
assert_eq!(
*second_count.borrow(),
1,
"cleared notifier must stay quiet"
);
}
/// Minimal committed `Register` reply for `ClientTable::commit_register`,
/// which reads only `client` and `commit` (the assigned session).
fn register_reply(client: u128, session: u64) -> Message<ReplyHeader> {
let header_size = size_of::<ReplyHeader>();
let mut reply = Message::<ReplyHeader>::new(header_size);
let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(
&mut reply.as_mut_slice()[..header_size],
)
.expect("zeroed bytes are a valid ReplyHeader");
*header = ReplyHeader {
client,
request: 0,
commit: session,
command: Command::Reply,
operation: Operation::Register,
..Default::default()
};
reply
}
#[test]
fn resolve_acting_user_id_skips_register_and_logout() {
// Session-lifecycle ops keep the user id their request builder set
// (Register's login identity, Logout's ungated value); the ClientTable
// is never consulted, so an empty table still yields `Ok(None)`.
let client_table = RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX));
for operation in [Operation::Register, Operation::Logout] {
assert!(
matches!(
resolve_acting_user_id(operation, 1, &client_table),
Ok(None)
),
"{operation:?} must not be stamped"
);
}
}
// The login frame's `client` field is caller-supplied and
// `resolve_acting_user_id` resolves authority from the entry it names, so
// the register ownership gate must refuse an entry owned by another user
// rather than resume the caller onto it. Two shapes reach this: a caller
// presenting someone else's id with its own valid credentials, and an
// honest login landing on a recovered entry after a restart (the HTTP id
// minter restarts at 1 while WAL replay rebuilds the previous boot's
// entries).
#[compio::test]
async fn register_gate_refuses_an_entry_owned_by_another_user() {
const CLIENT: u128 = 1;
const OWNER: u32 = 7;
const IMPOSTOR: u32 = 9;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
md.client_table
.borrow_mut()
.commit_register(CLIENT, OWNER, register_reply(CLIENT, 1));
assert_eq!(
md.submit_register_in_process(CLIENT, IMPOSTOR).await,
Err(MetadataSubmitError::ClientIdOwnedByAnotherUser),
"a different user must not be resumed onto this entry"
);
assert!(
!MetadataSubmitError::ClientIdOwnedByAnotherUser.is_transient(),
"the refusal is terminal; retrying anywhere cannot help"
);
assert_eq!(
md.client_table.borrow().get_user_id(CLIENT),
Some(OWNER),
"the refused attempt must not rewrite the entry's owner"
);
// The owner itself passes the gate. Its rebind now goes through
// consensus (a bind is a fencing event), which this NoopBus harness
// never commits -- so passing the gate is observable as Pending,
// while a refusal resolves immediately.
let mut rebind = std::pin::pin!(md.submit_register_in_process(CLIENT, OWNER));
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
assert!(
rebind.as_mut().poll(&mut cx).is_pending(),
"the owner's rebind must pass the gate and dispatch"
);
}
#[test]
fn resolve_acting_user_id_stamps_from_client_table() {
// A gated client op takes the acting user from the committed session,
// independent of any wire-supplied header value.
const CLIENT: u128 = 1;
const SESSION: u64 = 10;
const ACTING_USER: u32 = 7;
let mut table = ClientTable::new(CLIENTS_TABLE_MAX);
table.commit_register(CLIENT, ACTING_USER, register_reply(CLIENT, SESSION));
let client_table = RefCell::new(table);
match resolve_acting_user_id(Operation::CreateStream, CLIENT, &client_table) {
Ok(Some(user_id)) => assert_eq!(user_id, ACTING_USER),
other => panic!("expected Ok(Some({ACTING_USER})), got {other:?}"),
}
}
#[test]
fn resolve_acting_user_id_fails_closed_for_unknown_session() {
// A gated client op with no committed session is denied, never
// defaulted to root (user id 0).
let client_table = RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX));
match resolve_acting_user_id(Operation::CreateStream, 999, &client_table) {
Err(IggyError::Unauthenticated) => {}
other => panic!("expected Err(Unauthenticated), got {other:?}"),
}
}
/// No-op bus: `prepare_request` builds a prepare without ever sending, so
/// every method is an unused stub.
#[derive(Debug, Default)]
struct NoopBus;
impl MessageBus for NoopBus {
fn track_background(&self, _handle: JoinHandle<()>) {}
async fn send_to_client(
&self,
_client_id: u128,
_data: impl Into<BusMessage>,
) -> Result<(), SendError> {
Ok(())
}
async fn send_to_replica(
&self,
_replica: u8,
_data: Frozen<MESSAGE_ALIGN>,
) -> Result<(), SendError> {
Ok(())
}
fn set_connection_lost_fn(&self, _f: ConnectionLostFn) {}
fn set_replica_forward_fn(&self, _f: ReplicaForwardFn) {}
fn set_client_forward_fn(&self, _f: ClientForwardFn) {}
}
/// Single-node metadata plane whose `prepare_request` is callable. `J` is
/// `PrepareJournal` in name only (the value is `None`) to satisfy the
/// impl-block bound; no journal or snapshot is constructed.
fn metadata_plane() -> IggyMetadata<VsrConsensus<NoopBus>, PrepareJournal, (), TestMux> {
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
IggyMetadata::new(Some(consensus), None, None, None, TestMux::default(), None)
}
fn create_topic_request(client: u128, wire_user_id: u32) -> Message<RoutedRequestHeader> {
let body = CreateTopicRequest {
stream_id: WireIdentifier::numeric(1),
partitions_count: 1,
name: WireName::new("t").unwrap(),
options: WireOptions::empty(),
}
.to_bytes();
let header_size = size_of::<RoutedRequestHeader>();
let total = header_size + body.len();
let mut message = Message::<RoutedRequestHeader>::new(total);
{
let slice = message.as_mut_slice();
slice[header_size..total].copy_from_slice(&body);
let header =
bytemuck::checked::from_bytes_mut::<RoutedRequestHeader>(&mut slice[..header_size]);
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::CreateTopic,
size: u32::try_from(total).unwrap(),
client,
session: 1,
request: 1,
user_id: wire_user_id,
group: server_common::sharding::METADATA_GROUP,
..Default::default()
};
}
message
}
#[test]
fn prepare_request_stamps_create_topic_from_client_table_not_wire() {
// `CreateTopic` is the projection that reaches `build_prepare_message`
// through the post-stamp `header` copy, so the built prepare must carry
// the ClientTable identity, not the (bogus) wire value. This pins the
// stamp-then-re-read ordering: a hoist would ship the untrusted wire
// value.
const CLIENT: u128 = 1;
const SESSION: u64 = 10;
const ACTING_USER: u32 = 7;
const WIRE_USER: u32 = 999;
let plane = metadata_plane();
plane.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
let prepare = plane
.prepare_request(create_topic_request(CLIENT, WIRE_USER))
.expect("CreateTopic is client-allowed");
assert_eq!(
prepare.header().operation,
Operation::CreateTopicWithAssignments,
"CreateTopic projects to the enriched form"
);
assert_eq!(
prepare.header().user_id,
ACTING_USER,
"prepare must carry the ClientTable identity, not the wire value"
);
}
#[test]
fn prepare_request_stamps_create_topic_message_expiry_default() {
// A `CreateTopic` without an explicit `message_expiry` option must be
// resolved at primary admission to the build default, riding the
// derived block, so the replicated prepare -- and thus every
// replica's commit -- holds a concrete expiry.
const CLIENT: u128 = 1;
const SESSION: u64 = 10;
const ACTING_USER: u32 = 7;
let plane = metadata_plane();
plane.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
// `create_topic_request` builds the body with no options at all.
let prepare = plane
.prepare_request(create_topic_request(CLIENT, ACTING_USER))
.expect("CreateTopic is client-allowed");
let body = &prepare.as_slice()[size_of::<PrepareHeader>()..prepare.header().size as usize];
let persisted = PersistedCreateTopicRequest::decode_from(body)
.expect("create topic with assignments prepare must decode");
assert!(
persisted.request.options.is_empty(),
"a client that sent no options gets an empty explicit block"
);
let derived = iggy_common::TopicCreateOptions::parse(&persisted.derived_options)
.expect("derived block parses against the catalog");
assert_eq!(
derived.message_expiry,
Some(iggy_common::IggyExpiry::from(
iggy_common::DEFAULT_MESSAGE_EXPIRY
)),
"ServerDefault expiry must be resolved into the derived block at admission"
);
assert_eq!(
persisted.partitions.len(),
1,
"absent partitions_count defaults to one partition"
);
}
/// Bus whose `send_to_client` parks forever while `stall` is set,
/// recording each parked send in `stall_hits`. Models a client whose
/// connection writer stalled, so an `on_ack` driver suspends at a wire
/// send — an await the test can then cancel the driver at.
#[derive(Debug, Default)]
struct StallBus {
stall: std::cell::Cell<bool>,
stall_hits: std::cell::Cell<u32>,
}
// Cell fields make the futures !Send; fine on the single-threaded shard.
#[allow(clippy::future_not_send)]
impl MessageBus for StallBus {
fn track_background(&self, _handle: JoinHandle<()>) {}
async fn send_to_client(
&self,
_client_id: u128,
_data: impl Into<BusMessage>,
) -> Result<(), SendError> {
if self.stall.get() {
self.stall_hits.set(self.stall_hits.get() + 1);
std::future::pending::<()>().await;
}
Ok(())
}
async fn send_to_replica(
&self,
_replica: u8,
_data: Frozen<MESSAGE_ALIGN>,
) -> Result<(), SendError> {
Ok(())
}
fn set_connection_lost_fn(&self, _f: ConnectionLostFn) {}
fn set_replica_forward_fn(&self, _f: ReplicaForwardFn) {}
fn set_client_forward_fn(&self, _f: ClientForwardFn) {}
}
/// A replayed `CreatePersonalAccessToken` must be refused, not served from
/// the dedup cache: the committed secret is unrecoverable (never
/// replicated) and the rewrite has already minted a fresh one whose hash
/// never reached consensus, so replaying would hand back a credential that
/// authenticates against nothing. A cached REJECTION still replays -- it
/// carries no secret.
#[compio::test]
async fn replayed_pat_create_is_refused_but_other_replays_pass_through() {
const CLIENT: u128 = 1;
const USER: u32 = 7;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
md.client_table
.borrow_mut()
.commit_register(CLIENT, USER, register_reply(CLIENT, 1));
// Cache a committed SUCCESS for request 1 under the PAT operation,
// exactly as the commit path would (empty apply body + result section).
let pat_success = committed_reply(CLIENT, 1, Operation::CreatePersonalAccessToken, 0);
md.client_table
.borrow_mut()
.commit_reply(CLIENT, USER, pat_success);
// Replaying request 1 as a PAT create must NOT return the cached
// success; it must refuse with the name-taken code.
let reply = md
.submit_request_in_process(pat_create_request(CLIENT, 1))
.await
.expect("refusal is a reply, not a submit error");
let body = &reply.as_slice()[size_of::<ReplyHeader>()..];
assert_eq!(
iggy_binary_protocol::result_code(body),
Some(IggyError::PersonalAccessTokenAlreadyExists(String::new(), 0).as_code()),
"a replayed PAT create must be refused, never answered from cache"
);
// A cached REJECTION for the same operation carries no secret, so it
// replays untouched.
let rejected_code = IggyError::InvalidPersonalAccessTokenExpiry.as_code();
let pat_rejection = committed_reply(
CLIENT,
2,
Operation::CreatePersonalAccessToken,
rejected_code,
);
md.client_table
.borrow_mut()
.commit_reply(CLIENT, USER, pat_rejection);
let reply = md
.submit_request_in_process(pat_create_request(CLIENT, 2))
.await
.expect("cached rejection replays");
let body = &reply.as_slice()[size_of::<ReplyHeader>()..];
assert_eq!(
iggy_binary_protocol::result_code(body),
Some(rejected_code),
"a cached PAT rejection is safe to replay verbatim"
);
}
/// Committed-reply fixture shaped like the commit path's output: a result
/// section carrying `code`, no payload.
fn committed_reply(
client: u128,
request: u64,
operation: Operation,
code: u32,
) -> Message<ReplyHeader> {
let mut body = bytes::BytesMut::new();
body.extend_from_slice(&1u32.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&code.to_le_bytes());
let header_size = size_of::<ReplyHeader>();
let total = header_size + body.len();
let mut reply = Message::<ReplyHeader>::new(total);
{
let slice = reply.as_mut_slice();
slice[header_size..total].copy_from_slice(&body);
let header =
bytemuck::checked::from_bytes_mut::<ReplyHeader>(&mut slice[..header_size]);
*header = ReplyHeader {
client,
request,
commit: request,
size: u32::try_from(total).unwrap(),
command: Command::Reply,
operation,
..Default::default()
};
}
reply
}
fn pat_create_request(client: u128, request: u64) -> Message<RoutedRequestHeader> {
let header_size = size_of::<RoutedRequestHeader>();
let mut message = Message::<RoutedRequestHeader>::new(header_size);
let header = bytemuck::checked::from_bytes_mut::<RoutedRequestHeader>(
&mut message.as_mut_slice()[..header_size],
);
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::CreatePersonalAccessToken,
size: u32::try_from(header_size).unwrap(),
client,
session: 1,
request,
group: server_common::sharding::METADATA_GROUP,
..Default::default()
};
message
}
fn create_stream_request(
client: u128,
request: u64,
name: &str,
) -> Message<RoutedRequestHeader> {
let body = iggy_binary_protocol::requests::streams::CreateStreamRequest {
name: WireName::new(name).unwrap(),
options: WireOptions::empty(),
}
.to_bytes();
let header_size = size_of::<RoutedRequestHeader>();
let total = header_size + body.len();
let mut message = Message::<RoutedRequestHeader>::new(total);
{
let slice = message.as_mut_slice();
slice[header_size..total].copy_from_slice(&body);
let header =
bytemuck::checked::from_bytes_mut::<RoutedRequestHeader>(&mut slice[..header_size]);
*header = RoutedRequestHeader {
command: Command::Request,
operation: Operation::CreateStream,
size: u32::try_from(total).unwrap(),
client,
session: 1,
request,
user_id: 0,
group: server_common::sharding::METADATA_GROUP,
..Default::default()
};
}
message
}
/// Reproduces the `commit_min must advance sequentially` shard-0 crash.
///
/// `on_ack` drains (pops) the committable prefix off the pipeline and only
/// then applies it, with awaits in between (journal read, wire send). Any
/// driver of `on_ack` that is dropped at one of those awaits — a hyper
/// HTTP handler future canceled by peer disconnect (`http/state.rs`), or
/// any parked in-process submitter — strands the popped-but-unapplied
/// entries: nothing can re-apply them (`repair_primary_self_acks` is
/// re-ack-only, above `commit_max`), `commit_min` is pinned below
/// `commit_max` (every login rejected `NotCaughtUp`), and the next commit
/// that quorums panics the shard.
///
/// The test parks a driver mid-commit exactly there, cancels it, and then
/// delivers the next ack. Correct behavior: the stranded op is still in
/// the pipeline and the late ack commits it and everything after it, in
/// order. Broken behavior: panic "expected 2, got 3".
#[compio::test]
async fn dropped_on_ack_driver_must_not_lose_popped_commits() {
use std::future::Future;
const CLIENT: u128 = 1;
// The session is the Register op's commit number; it must be > 0 and
// sort at-or-under the commits of the three ops below (1..=3).
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
StallBus::default(),
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
let consensus = md.consensus.as_ref().unwrap();
md.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
// Three prepares through the real primary path: pipeline entry, WAL
// append, self-ack onto the loopback queue.
for (i, name) in ["s1", "s2", "s3"].iter().enumerate() {
let prepare = md
.prepare_request(create_stream_request(CLIENT, i as u64 + 1, name))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &prepare);
md.on_replicate(prepare).await;
}
let mut loopback = Vec::new();
consensus.drain_loopback_into(&mut loopback);
let mut acks = loopback
.into_iter()
.map(|message| {
message
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks")
})
.collect::<Vec<_>>();
assert_eq!(acks.len(), 3, "one self-ack per replicated prepare");
let ack3 = acks.pop().unwrap();
let ack2 = acks.pop().unwrap();
let ack1 = acks.pop().unwrap();
// Ack op 2 first: quorum for op 2 alone, but the contiguous prefix
// still starts at the un-acked op 1, so nothing commits yet.
md.on_ack(ack2).await;
assert_eq!(consensus.commit_max(), 0);
assert_eq!(consensus.commit_min(), 0);
// Ack op 1: the quorum walk covers ops 1..=2, so this single driver
// commits both. Poll it by hand until it parks at a stalled wire
// send mid-`on_ack`, then cancel it — the moral equivalent of hyper
// dropping an HTTP handler future on peer disconnect.
consensus.message_bus().stall.set(true);
{
let mut driver = Box::pin(md.on_ack(ack1));
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
let mut parked_at_send = false;
for _ in 0..1_000 {
assert!(
driver.as_mut().poll(&mut cx).is_pending(),
"driver must park at the stalled wire send, not complete"
);
if consensus.message_bus().stall_hits.get() > 0 {
parked_at_send = true;
break;
}
// Let the runtime process the journal-read completion the
// driver is waiting on, then poll again.
compio::time::sleep(std::time::Duration::from_millis(1)).await;
}
assert!(
parked_at_send,
"driver never reached a wire send (commit_min={})",
consensus.commit_min()
);
// Cancel mid-`on_ack`, with at least op 1 applied and the reply
// send in flight.
drop(driver);
}
consensus.message_bus().stall.set(false);
assert_eq!(consensus.commit_max(), 2, "quorum walk advanced commit_max");
assert!(
consensus.commit_min() >= 1,
"driver applied op 1 before parking at the wire send"
);
// Whatever the canceled driver left behind must still be
// committable: delivering the ack for op 3 has to commit every
// remaining op, in order. The broken commit path lost op 2 with the
// dropped driver (popped, never applied) and panics here with
// "commit_min must advance sequentially: expected 2, got 3".
md.on_ack(ack3).await;
assert_eq!(
consensus.commit_min(),
3,
"late ack must commit the stranded op 2 and then op 3"
);
assert_eq!(consensus.commit_max(), 3);
assert!(
is_caught_up_primary(consensus),
"gate must reopen once the prefix is fully applied"
);
}
/// A backup admits the prepare its JOURNAL needs next, even when its
/// sequencer has run ahead of the journal.
///
/// The two frontiers legitimately disagree: `on_start_view` sets the sequencer
/// to the view's announced head, deliberately ahead of what this replica
/// holds, because the bodies arrive afterwards by retransmit or repair. Gating
/// admission on the sequencer therefore rejected exactly the prepare that
/// would heal the log: a backup with journal head 44 refusing op 45 because
/// its adopted head said to expect 46. The primary retransmits that op
/// forever, every backup logs an out-of-order gap, it never reaches a commit
/// quorum, and its client is never answered. Systematic for any rejoining
/// replica, so the deterministic simulator wedged on every metadata workload
/// under crash/restart injection until this was gated on the journal.
#[compio::test]
async fn backup_admits_the_prepare_its_journal_needs_despite_a_leading_sequencer() {
const CLIENT: u128 = 1;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
/// Stands in for a head adopted from a `StartView` whose bodies have not
/// arrived, so it sits well above the empty journal.
const ADOPTED_HEAD: u64 = 5;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(crate::impls::METADATA_DIR)).unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
// Replica 1 of 3 at view 0, so `primary_index(0) == 0` makes this a backup
// and `on_replicate` takes the gap-check branch.
let consensus = VsrConsensus::new(
1,
1,
3,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
Some(dir.path().to_path_buf()),
);
let consensus = md.consensus.as_ref().unwrap();
assert!(
consensus.is_follower(),
"replica 1 of 3 at view 0 must be a backup for this to exercise the gap check"
);
md.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
// Minted while the sequencer is still at 0, so it carries op 1: exactly
// what the empty journal needs next.
let prepare = md
.prepare_request(create_stream_request(CLIENT, 1, "s1"))
.expect("CreateStream is client-allowed");
assert_eq!(prepare.header().op, 1, "the first prepare must be op 1");
// Now run the sequencer ahead, as adopting a started view does.
consensus.sequencer().set_sequence(ADOPTED_HEAD);
let journal = md.journal.as_ref().unwrap();
assert_eq!(
journal.last_op(),
None,
"the journal must still be empty, else the divergence under test is absent"
);
md.on_replicate(prepare).await;
assert!(
journal.header(1).is_some(),
"backup dropped the prepare its journal needed next because its sequencer \
was ahead; the primary's retransmit of this op can never be accepted, so \
the op never commits and its client never gets a reply"
);
}
/// A state-transfer receiver admits the first live prepare above the floor it
/// installed, instead of waiting for an op the snapshot already contains.
///
/// `install_state_transfer` moves the snapshot floor, the commit floor, the
/// sequencer and `commit_max`, and leaves the WAL head where it was: the
/// snapshot IS every op below the floor, so there is nothing left to append
/// for them. A gap check reading `last_op` alone therefore has the receiver
/// ask for `last_op + 1`, an op inside the snapshot that no peer will send
/// again, and it drops every live prepare forever.
///
/// Nothing recovers it. An offer built on a quiet cluster carries `commit_op
/// == snapshot_seq`, so the install lands `commit_min == commit_max` and
/// `maybe_request_metadata_repair`, gated on `commit_min < commit_max`, never
/// arms; repair is the only path that could refill the head. With the
/// other backup down the primary needs this replica's ack to commit at all,
/// so a 3-node cluster still inside its quorum stops serving metadata.
#[compio::test]
async fn state_transfer_receiver_admits_the_first_prepare_above_the_installed_floor() {
const CLIENT: u128 = 1;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
/// The `snapshot_seq` of a transferred offer, far above anything this
/// replica's own WAL holds.
const INSTALLED_FLOOR: u64 = 400;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(crate::impls::METADATA_DIR)).unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
// Replica 1 of 3 at view 0, so `primary_index(0) == 0` makes this a backup
// and `on_replicate` takes the gap-check branch.
let consensus = VsrConsensus::new(
1,
1,
3,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
Some(dir.path().to_path_buf()),
);
let consensus = md.consensus.as_ref().unwrap();
assert!(
consensus.is_follower(),
"replica 1 of 3 at view 0 must be a backup for this to exercise the gap check"
);
md.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
// Give the WAL a head far below the floor about to be installed, which is
// what a replica that fell behind its peers' retention actually carries.
let first = md
.prepare_request(create_stream_request(CLIENT, 1, "s1"))
.expect("CreateStream is client-allowed");
md.on_replicate(first).await;
let journal = md.journal.as_ref().unwrap();
assert_eq!(
journal.last_op(),
Some(1),
"the WAL head must sit below the installed floor, else the divergence \
under test is absent"
);
// Exactly the frontiers `install_state_transfer` leaves for a quiet-cluster
// offer, where the manifest's `commit_op` equals its `snapshot_seq`.
journal.set_snapshot_op(INSTALLED_FLOOR);
consensus.set_commit_floor(INSTALLED_FLOOR);
consensus.sequencer().set_sequence(INSTALLED_FLOOR);
consensus.advance_commit_max(INSTALLED_FLOOR);
assert_eq!(
consensus.commit_min(),
consensus.commit_max(),
"the wedge needs an install with no repair left to arm; diverged \
frontiers heal through `maybe_request_metadata_repair`"
);
let next = md
.prepare_request(create_stream_request(CLIENT, 2, "s2"))
.expect("CreateStream is client-allowed");
assert_eq!(
next.header().op,
INSTALLED_FLOOR + 1,
"the primary numbers the next op off the installed floor"
);
md.on_replicate(next).await;
assert!(
journal
.header(usize::try_from(INSTALLED_FLOOR + 1).unwrap())
.is_some(),
"state-transfer receiver dropped the first prepare above its installed \
floor; the ops the snapshot already holds are never re-sent, and with \
`commit_min == commit_max` no repair arms, so this op never commits"
);
}
/// A checkpoint reclaims the WAL prefix the snapshot supersedes, but must
/// stop one op short of the checkpoint op itself.
///
/// That op is the replica's commit point, and its `DoViewChange` suffix is
/// floored there. The merge scans the commit point and may not discard it,
/// so a sender with no header to put there is deferring to a peer; when
/// every sender has checkpointed at the same op the view change deadlocks
/// (`dvc_merge::merge_dvc_quorum`). Checkpoints fire on local journal
/// occupancy, which is symmetric across replicas seeing the same ops, so
/// "every sender" is the ordinary case, not a coincidence.
#[compio::test]
async fn checkpoint_drain_retains_the_commit_point_header() {
const CLIENT: u128 = 1;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
const OPS: u64 = 5;
const CHECKPOINT_OP: u64 = 3;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(crate::impls::METADATA_DIR)).unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
Some(dir.path().to_path_buf()),
);
let consensus = md.consensus.as_ref().unwrap();
md.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
for op in 1..=OPS {
let prepare = md
.prepare_request(create_stream_request(CLIENT, op, &format!("s{op}")))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &prepare);
md.on_replicate(prepare).await;
}
let journal = md.journal.as_ref().unwrap();
md.coordinator
.as_ref()
.expect("data_dir present arms the coordinator")
.drain(journal, CHECKPOINT_OP)
.await
.expect("drain the snapshotted prefix");
let header_at = |op: u64| journal.header(usize::try_from(op).expect("test ops fit usize"));
for op in 1..CHECKPOINT_OP {
assert!(
header_at(op).is_none(),
"op {op} is below the checkpoint and must be reclaimed"
);
}
assert!(
header_at(CHECKPOINT_OP).is_some(),
"the checkpoint op is the commit point and must stay describable in a DVC"
);
for op in CHECKPOINT_OP + 1..=OPS {
assert!(header_at(op).is_some(), "op {op} was never snapshotted");
}
}
/// Reproduces the single-node "metadata prepare queue is full" wedge
///
/// `checkpoint_if_needed` runs inside `on_replicate`, once per submit.
/// Under a concurrent login/create burst, several `on_replicate` futures
/// cross the forced-checkpoint boundary (journal `remaining_capacity <=
/// CHECKPOINT_MARGIN`, i.e. op `SLOT_COUNT - MARGIN = 960`) together, and
/// every one of them runs a full checkpoint concurrently. The concurrent
/// `journal.drain()` calls race on the one fixed `wal.tmp`: the losers
/// surface `snapshot I/O error: No such file or directory` (or a short
/// read after the winner's reopen). Fatally, `on_replicate` then dropped
/// the loser's prepare — AFTER `pipeline_message` had pushed the pipeline
/// entry and pre-advanced the sequencer — so the op was never journaled,
/// never acked, never committed. The commit frontier gaps permanently:
/// logins first bounce `NotCaughtUp`, in-flight clients wedge
/// (`InProgress` on logout), and once the pipeline fills every submit is
/// rejected `PipelineFull` forever.
///
/// Correct behavior: checkpoints are single-flight, a failed or skipped
/// checkpoint never discards a pipelined prepare, all racers' ops are
/// journaled and commit, and the caught-up gate reopens.
#[compio::test]
async fn concurrent_checkpoint_boundary_must_not_drop_prepares() {
const CLIENT: u128 = 1;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
/// One op below the forced-checkpoint trigger: the journal holds
/// 1024 slots and forces a checkpoint when 64 or fewer remain.
const FILL: u64 = 960;
let dir = tempfile::tempdir().unwrap();
// Bootstrap creates the metadata dir; the coordinator's snapshot
// persist expects it to exist.
std::fs::create_dir_all(dir.path().join(crate::impls::METADATA_DIR)).unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
// `data_dir` present => SnapshotCoordinator armed, checkpoints live.
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
Some(dir.path().to_path_buf()),
);
let consensus = md.consensus.as_ref().unwrap();
md.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
// Fill to one op under the boundary through the real primary path,
// acking each op so `commit_min` tracks `last_op` and the pipeline
// stays shallow — the steady state the production server was in.
let mut loopback = Vec::new();
for i in 1..=FILL {
let prepare = md
.prepare_request(create_stream_request(CLIENT, i, &format!("s{i}")))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &prepare);
md.on_replicate(prepare).await;
loopback.clear();
consensus.drain_loopback_into(&mut loopback);
let ack = loopback
.pop()
.expect("one self-ack per prepare")
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks");
md.on_ack(ack).await;
}
assert_eq!(consensus.commit_min(), FILL);
assert_eq!(
md.journal.as_ref().unwrap().remaining_capacity(),
Some(64),
"fill must stop exactly at the forced-checkpoint boundary"
);
// Three submits race across the boundary — the concurrent
// login/create burst from the incident. Every racer sees
// `remaining_capacity <= CHECKPOINT_MARGIN` before any drain
// completes.
let md_ref = &md;
let race = |request: u64, name: String| async move {
let prepare = md_ref
.prepare_request(create_stream_request(CLIENT, request, &name))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &prepare);
md_ref.on_replicate(prepare).await;
};
futures::join!(
race(FILL + 1, format!("s{}", FILL + 1)),
race(FILL + 2, format!("s{}", FILL + 2)),
race(FILL + 3, format!("s{}", FILL + 3)),
);
// Every racer's prepare must be durably journaled: a dropped one is
// unrepairable (nothing re-prepares it) and gaps the frontier.
let journal = md.journal.as_ref().unwrap();
for op in FILL + 1..=FILL + 3 {
assert!(
journal
.header(usize::try_from(op).expect("test ops fit in usize"))
.is_some(),
"op {op} vanished from the WAL: a failed checkpoint dropped a pipelined prepare"
);
}
// The checkpoint itself must have happened — once: WAL reclaimed,
// snapshot on disk.
assert!(
journal.remaining_capacity().unwrap() > 900,
"checkpoint must have drained the snapshotted prefix, got {:?}",
journal.remaining_capacity()
);
assert!(
dir.path()
.join(crate::impls::METADATA_DIR)
.join("snapshot.bin")
.exists(),
"checkpoint must persist the snapshot"
);
// The self-acks commit all three racers; any gap here is the
// production wedge (commit frontier pinned, PipelineFull forever).
loopback.clear();
consensus.drain_loopback_into(&mut loopback);
assert_eq!(loopback.len(), 3, "one self-ack per racer");
for message in loopback {
let ack = message
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks");
md.on_ack(ack).await;
}
assert_eq!(
consensus.commit_min(),
FILL + 3,
"commit frontier must cross the checkpoint boundary"
);
assert!(
is_caught_up_primary(consensus),
"caught-up gate must reopen after the boundary"
);
}
/// The exact window behind the historical "logout/unregister failed
/// ... primary not yet caught up on `commit_journal`".
/// ANOTHER client's op sits between quorum-ack (`commit_max` advanced
/// inside `on_ack`) and apply (`commit_min` behind, driver parked at
/// the journal read).
///
/// New contract (queue absorption): a logout landing in
/// that window is NOT bounced with `NotCaughtUp` — non-register ops
/// carry no catch-up gate. It pipelines behind the in-flight batch,
/// and its submit's inline loopback pump commits both ops in order.
/// The parked sibling driver then resumes onto an already-drained
/// pipeline and exits via head-revalidation, exercising the
/// concurrent-driver safety of the commit loop.
#[compio::test]
async fn logout_in_mid_commit_window_commits_instead_of_rejecting() {
use std::future::Future;
/// The client logging out; its session is already committed.
const CLIENT_A: u128 = 1;
/// The client whose in-flight commit closes the gate.
const CLIENT_B: u128 = 2;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
let consensus = md.consensus.as_ref().unwrap();
for client in [CLIENT_A, CLIENT_B] {
md.client_table.borrow_mut().commit_register(
client,
ACTING_USER,
register_reply(client, SESSION),
);
}
// B's op: prepared, journaled, self-acked onto the loopback queue.
let prepare = md
.prepare_request(create_stream_request(CLIENT_B, 1, "s1"))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &prepare);
md.on_replicate(prepare).await;
let mut loopback = Vec::new();
consensus.drain_loopback_into(&mut loopback);
let ack = loopback
.pop()
.expect("one self-ack per prepare")
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks");
// Open the window: the first poll of `on_ack` reaches quorum and
// advances commit_max synchronously, then parks at the journal
// read — commit_min has not moved. This is the exact server state
// every production NotCaughtUp line was emitted from.
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
let mut driver = Box::pin(md.on_ack(ack));
assert!(
driver.as_mut().poll(&mut cx).is_pending(),
"driver must park at the journal read inside the commit"
);
assert_eq!(consensus.commit_max(), 1, "quorum advanced commit_max");
assert_eq!(consensus.commit_min(), 0, "apply has not landed yet");
assert!(
!is_caught_up_primary(consensus),
"gate must be closed mid-commit"
);
// A's logout lands in the window. No gate for non-register ops: it
// pipelines behind B's committing op, and its inline loopback pump
// drives BOTH commits (B's op 1 via head-revalidated takeover, then
// its own op 2) before resolving.
let outcome = md.submit_logout_in_process(CLIENT_A, SESSION, 2).await;
assert_eq!(
outcome,
Ok(2),
"mid-window logout must commit and reply, never bounce NotCaughtUp"
);
assert_eq!(consensus.commit_min(), 2, "both ops committed in order");
assert!(
is_caught_up_primary(consensus),
"gate reopens once the batch drains"
);
// The parked sibling driver resumes onto a drained pipeline: the
// head it peeked is gone, revalidation sends it out without
// touching commit state. Drive it to completion to prove it.
let mut resumed = false;
for _ in 0..1_000 {
if driver.as_mut().poll(&mut cx).is_ready() {
resumed = true;
break;
}
// Let the runtime deliver the journal-read completion.
compio::time::sleep(std::time::Duration::from_millis(1)).await;
}
assert!(resumed, "parked driver must exit via head-revalidation");
assert_eq!(
consensus.commit_min(),
2,
"resumed driver commits nothing new"
);
assert_eq!(
md.client_table.borrow().get_epoch(CLIENT_A),
None,
"session removed by the committed logout"
);
}
/// Register is the one op that still honors the catch-up gate (its
/// admission races a committed-but-unapplied register; a double commit
/// bumps the epoch past the first reply's and fences a live client).
/// New contract: a register arriving in the mid-commit window is
/// ABSORBED into the pipeline's request queue with its reply subscriber
/// attached, promoted by the commit path once the batch drains, and the
/// caller's await resolves with the committed epoch — instead of the
/// historical `NotCaughtUp` bounce that one-shot CLI clients surfaced as
/// "Disconnected" login failures.
#[compio::test]
async fn register_in_mid_commit_window_is_queued_then_committed() {
use std::future::Future;
/// The client whose in-flight commit closes the gate.
const CLIENT_B: u128 = 2;
/// The client registering mid-window.
const CLIENT_C: u128 = 3;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
let consensus = md.consensus.as_ref().unwrap();
md.client_table.borrow_mut().commit_register(
CLIENT_B,
ACTING_USER,
register_reply(CLIENT_B, SESSION),
);
// B's op journaled + self-acked; park its commit mid-window.
let prepare = md
.prepare_request(create_stream_request(CLIENT_B, 1, "s1"))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &prepare);
md.on_replicate(prepare).await;
let mut loopback = Vec::new();
consensus.drain_loopback_into(&mut loopback);
let ack = loopback
.pop()
.expect("one self-ack per prepare")
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks");
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
let mut driver = Box::pin(md.on_ack(ack));
assert!(driver.as_mut().poll(&mut cx).is_pending());
assert_eq!(consensus.commit_max(), 1);
assert_eq!(consensus.commit_min(), 0);
// C's register lands in the window: absorbed, not bounced.
let mut register = Box::pin(md.submit_register_in_process(CLIENT_C, ACTING_USER));
assert!(
register.as_mut().poll(&mut cx).is_pending(),
"mid-window register must park in the request queue, not error"
);
assert_eq!(
consensus.request_queue_len(),
1,
"register buffered in the request queue"
);
// The committing driver drains its batch, then promotes the queued
// register into a prepare (its self-ack lands on the loopback).
let mut resumed = false;
for _ in 0..1_000 {
if driver.as_mut().poll(&mut cx).is_ready() {
resumed = true;
break;
}
compio::time::sleep(std::time::Duration::from_millis(1)).await;
}
assert!(resumed, "B's commit must complete and promote the register");
assert_eq!(consensus.commit_min(), 1, "B's op committed");
assert_eq!(
consensus.request_queue_len(),
0,
"promotion emptied the request queue"
);
// Commit the promoted register (production: the shard pump or any
// sibling submit drains this ack) and the parked caller resolves.
loopback.clear();
consensus.drain_loopback_into(&mut loopback);
let ack = loopback
.pop()
.expect("promoted register must self-ack")
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks");
md.on_ack(ack).await;
let mut outcome = None;
for _ in 0..1_000 {
if let std::task::Poll::Ready(result) = register.as_mut().poll(&mut cx) {
outcome = Some(result);
break;
}
compio::time::sleep(std::time::Duration::from_millis(1)).await;
}
assert_eq!(
outcome.expect("absorbed register must resolve"),
Ok(BoundSession {
epoch: 2,
watermark: 0
}),
"queued register commits with the next batch; the bind fences at its commit op"
);
assert_eq!(
md.client_table.borrow().get_epoch(CLIENT_C),
Some(2),
"entry created by the promoted register"
);
}
/// The commit loop and the promotion of queued requests run at the tail
/// of `on_ack`, inside whichever future delivered the quorum ack. Drop
/// that future mid-commit and — on an idle server — nothing re-drives
/// the work: duplicate/repair acks do not re-open the commit path
/// (quorum already recorded, `commit_max` does not advance), so the
/// committed-but-unapplied op pins the catch-up gate closed and an
/// absorbed register parks in the request queue indefinitely.
///
/// `resume_stranded_commits` (wired into the shard pump tick) is the
/// backstop: it re-enters the commit path, applies the stranded prefix,
/// and promotes the queued register, whose awaiter then resolves.
/// A state-transfer receiver's WAL can hold a pre-crash suffix above the
/// incoming floor. The commit walk matches entries by op number alone, so
/// installing without discarding that suffix would later apply its stale
/// bodies as committed. The install must truncate the WAL above
/// `snapshot_seq` and keep everything at or below it, which tail repair
/// resumes from.
#[compio::test]
async fn state_transfer_install_truncates_the_wal_above_the_incoming_floor() {
const CLIENT: u128 = 9;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
const SNAPSHOT_SEQ: u64 = 2;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
let consensus = md.consensus.as_ref().unwrap();
md.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
// Journal ops 1..=3 directly (no acks, so `commit_min` stays 0 and the
// transfer is "ahead"): 1 and 2 sit at or below the incoming floor, 3 is
// the relic suffix a view change has since reassigned.
for (request_id, name) in [(1, "s1"), (2, "s2"), (3, "s3")] {
let projected = md
.prepare_request(create_stream_request(CLIENT, request_id, name))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &projected);
md.journal
.as_ref()
.unwrap()
.handle()
.append(projected)
.await
.expect("seeding the WAL succeeds");
}
let journal_handle = md.journal.as_ref().unwrap().handle();
assert_eq!(journal_handle.last_op(), Some(3));
// A donor mux fills the snapshot the way a serving primary would, so
// the restore path sees populated sections rather than a bare envelope.
let snapshot_bytes =
<IggySnapshot as Snapshot>::create(&TestMux::default(), SNAPSHOT_SEQ, 1)
.expect("donor snapshot builds")
.encode()
.expect("donor snapshot encodes");
md.install_state_transfer(
&snapshot_bytes,
ClientTable::new(CLIENTS_TABLE_MAX),
0,
SNAPSHOT_SEQ,
)
.await
.expect("install succeeds");
assert_eq!(
journal_handle.last_op(),
Some(SNAPSHOT_SEQ),
"the relic above the incoming floor must be gone"
);
assert!(
journal_handle.header(3).is_none(),
"op 3 was above the floor; the commit walk must never see it again"
);
assert!(
journal_handle.header(1).is_some() && journal_handle.header(2).is_some(),
"ops at or below the floor stay for the walk and tail repair"
);
}
#[compio::test]
async fn tick_backstop_must_resume_stranded_commits_and_promotions() {
use std::future::Future;
/// The client whose in-flight commit is stranded by the dropped driver.
const CLIENT_B: u128 = 2;
/// The client whose register parks in the request queue.
const CLIENT_C: u128 = 3;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
let consensus = md.consensus.as_ref().unwrap();
md.client_table.borrow_mut().commit_register(
CLIENT_B,
ACTING_USER,
register_reply(CLIENT_B, SESSION),
);
// B's op journaled + self-acked; park its commit driver mid-window
// at the journal read.
let prepare = md
.prepare_request(create_stream_request(CLIENT_B, 1, "s1"))
.expect("CreateStream is client-allowed");
consensus.pipeline_message(PlaneKind::Metadata, &prepare);
md.on_replicate(prepare).await;
let mut loopback = Vec::new();
consensus.drain_loopback_into(&mut loopback);
let ack = loopback
.pop()
.expect("one self-ack per prepare")
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks");
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
let mut driver = Box::pin(md.on_ack(ack));
assert!(driver.as_mut().poll(&mut cx).is_pending());
assert_eq!(consensus.commit_max(), 1);
assert_eq!(consensus.commit_min(), 0);
// C's register lands in the window: absorbed into the request queue.
let mut register = Box::pin(md.submit_register_in_process(CLIENT_C, ACTING_USER));
assert!(register.as_mut().poll(&mut cx).is_pending());
assert_eq!(consensus.request_queue_len(), 1);
// The committing driver dies at its await — the hyper-disconnect
// analogue. Commit and promotion are now stranded: op 1 is quorum'd
// (commit_max = 1) but unapplied (commit_min = 0), and no further
// ack will arrive to re-drive either.
drop(driver);
assert_eq!(consensus.commit_max(), 1);
assert_eq!(consensus.commit_min(), 0);
assert_eq!(consensus.request_queue_len(), 1);
assert!(
register.as_mut().poll(&mut cx).is_pending(),
"queued register must still be parked with no driver alive"
);
// The pump tick backstop re-drives: commits op 1 (reopening the
// catch-up gate) and promotes the queued register into a prepare
// (its self-ack lands on the loopback).
md.resume_stranded_commits().await;
assert_eq!(consensus.commit_min(), 1, "stranded op 1 applied");
assert_eq!(consensus.request_queue_len(), 0, "queued register promoted");
// Commit the promoted register (production: pump loopback drain)
// and the parked caller resolves with its session.
loopback.clear();
consensus.drain_loopback_into(&mut loopback);
let ack = loopback
.pop()
.expect("promoted register must self-ack")
.try_into_typed::<PrepareOkHeader>()
.expect("loopback holds self PrepareOks");
md.on_ack(ack).await;
let mut outcome = None;
for _ in 0..1_000 {
if let std::task::Poll::Ready(result) = register.as_mut().poll(&mut cx) {
outcome = Some(result);
break;
}
compio::time::sleep(std::time::Duration::from_millis(1)).await;
}
assert_eq!(
outcome.expect("promoted register must resolve"),
Ok(BoundSession {
epoch: 2,
watermark: 0
})
);
assert_eq!(md.client_table.borrow().get_epoch(CLIENT_C), Some(2));
assert!(is_caught_up_primary(consensus));
}
#[compio::test]
async fn failed_journal_append_hands_the_op_back_instead_of_leaving_a_phantom() {
// The primary claims its op before the append (`push_prepare_entry`), so a
// failed append used to leave the sequencer one ahead of the WAL forever:
// the next request projected over the hole, and no repair path refilled it.
const CLIENT: u128 = 1;
const SESSION: u64 = 1;
const ACTING_USER: u32 = 7;
let dir = tempfile::tempdir().unwrap();
let journal =
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 0)
.await
.unwrap();
let consensus = VsrConsensus::new(
1,
0,
1,
server_common::sharding::METADATA_GROUP,
NoopBus,
LocalPipeline::new(),
);
consensus.init();
let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), TestMux> =
IggyMetadata::new(
Some(consensus),
Some(journal),
None,
None,
TestMux::default(),
None,
);
let consensus = md.consensus.as_ref().unwrap();
md.client_table.borrow_mut().commit_register(
CLIENT,
ACTING_USER,
register_reply(CLIENT, SESSION),
);
let projected = md
.prepare_request(create_stream_request(CLIENT, 1, "s1"))
.expect("CreateStream is client-allowed");
let op = projected.header().op;
let parent = projected.header().parent;
let sequence_before = consensus.sequencer().current_sequence();
// Forces the append to fail deterministically, before any disk write: the
// buffer carries eight bytes of slack past the header's `size`, which
// `PrepareJournal::append` refuses rather than write slack that would
// mis-frame the recovery scan. Any append failure reaches the same arm.
let size = projected.header().size as usize;
let mut padded = Message::<PrepareHeader>::new(size + 8);
padded.as_mut_slice()[..size].copy_from_slice(projected.as_slice());
consensus.pipeline_message(PlaneKind::Metadata, &padded);
assert_eq!(consensus.sequencer().current_sequence(), op);
md.on_replicate(padded).await;
assert_eq!(
consensus.sequencer().current_sequence(),
sequence_before,
"the claimed op must be handed back so the next request reuses it"
);
assert_eq!(consensus.last_prepare_checksum(), parent);
assert!(
consensus.pipeline_is_empty(),
"the undurable prepare must not stay live in the pipeline"
);
#[allow(clippy::cast_possible_truncation)]
let journaled = md.journal.as_ref().unwrap().handle().header(op as usize);
assert!(
journaled.is_none(),
"the append failed, so the WAL must hold nothing at that op"
);
}
}