blob: 3d2fd3feaa449226a376f9cb3a98d63040e9c6ab [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.
pub mod bus;
pub mod client;
pub mod deps;
pub mod executor;
pub mod network;
pub mod packet;
pub mod ready_queue;
pub mod replica;
pub mod seeds;
pub mod workload;
use bus::SimOutbox;
use client::SimClient;
use consensus::{ConsensusClock, MetadataHandle, PartitionsHandle, VsrState};
use deps::SimClock;
use deps::SimSuperblock;
use deps::{MemStorage, SimJournal};
use executor::{DetExecutor, RunOutcome, TaskId};
use iggy_binary_protocol::{Command, GenericHeader, ReplyHeader};
use iggy_common::IggyError;
use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
use metadata::impls::metadata::StreamsFrontend;
use network::Network;
use packet::{PacketSimulatorOptions, ProcessId};
use partitions::{
Partition, PartitionOffsets, PollFragments, PollingArgs, PollingConsumer,
RetainedPartitionState,
};
use rand::RngExt;
use rand_xoshiro::Xoshiro256PlusPlus;
use rand_xoshiro::rand_core::SeedableRng;
use replica::{Replica, SIM_INBOX_CAPACITY, new_shard};
use seeds::SimSeeds;
use server_common::Message;
use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
use shard::shards_table::{ShardsTable, calculate_shard_assignment};
use shard::{CONSENSUS_TICK_INTERVAL, PartitionMaterialisation};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
/// Poll budget per [`DetExecutor::run_until_stalled`]. Pumps are event-driven, so
/// hitting it means a task is spin-waking: a bug, panicked with the seed.
const POLL_BUDGET: u32 = 100_000;
/// Retry interval and total budget for a setup handshake
/// (`register_client_with_primary`, `shell_login`).
///
/// Submit-once dies under injected loss. Both are metadata ops with a stable request
/// id, so the client table dedups the resend.
const SETUP_RETRY_STEPS: u32 = 50;
const SETUP_TOTAL_STEPS: u32 = 4_000;
/// One simulated replica: shards plus the executor bookkeeping to crash it. One
/// entry per shard in `shards` / `pump_tasks`.
pub struct SimReplica {
/// Shards of this replica, indexed by shard id.
pub shards: Vec<Rc<Replica>>,
/// Shard 0's durable superblock. Held here, not in the shard, so its bytes
/// survive the shard being rebuilt across a restart.
pub superblock: Rc<SimSuperblock>,
/// Shard 0's metadata WAL, retained across a restart like `superblock`, so a
/// rebuilt replica recovers op/commit and committed metadata from its own disk.
/// Shard 0 owns the only metadata consensus, so this is the only journal.
pub metadata_journal: Rc<SimJournal<MemStorage>>,
/// Shard 0's metadata incarnation nonce. Seed-derived, bumped by one per
/// restart: distinct incarnations, byte-identical replay. See
/// `VsrConsensus::set_incarnation`.
pub metadata_incarnation: u128,
/// One durable superblock per materialised partition group, retained like
/// `superblock` so a re-materialised group recovers its recorded
/// `(view, log_view)` instead of re-entering view 0. Storeless, the persist gate
/// marks every view durable without writing, leaving the gate, its write-failure
/// fence and view recovery unexercised.
pub partition_superblocks: RefCell<HashMap<IggyNamespace, Rc<SimSuperblock>>>,
/// One retained message log per partition group, plus the offsets recovered from
/// it. A real server's messages are in segment files; the simulator has none, so
/// without this a rebuilt partition comes back empty and the monotonicity
/// invariant calls a discarded log a consensus regression. Populated by
/// [`Simulator::replica_restart`], consumed by `materialise_partition`.
partition_logs: RefCell<HashMap<IggyNamespace, RetainedPartitionState>>,
/// This replica's data directory when checkpoints are enabled. Retained so a
/// restart reads back the snapshot its previous incarnation wrote.
data_dir: Option<std::path::PathBuf>,
/// Keeps each pump's stop channel alive. Dropping one ends that pump gracefully,
/// reserved for shutdown tests; crash uses `DetExecutor::abort`.
_stop_txs: Vec<shard::Sender<()>>,
/// Pump task per shard, aborted on crash.
pump_tasks: Vec<TaskId>,
}
impl SimReplica {
/// The shard owning `namespace`'s partition data, by the same
/// deterministic hash the router uses.
///
/// # Panics
/// If the shard count does not fit `u32`; mesh construction caps it at `u16`.
#[must_use]
pub fn partition_shard(&self, namespace: IggyNamespace) -> &Rc<Replica> {
let shard_count = u32::try_from(self.shards.len()).expect("shard count fits u32");
let owner = calculate_shard_assignment(&namespace, shard_count);
&self.shards[usize::from(owner)]
}
}
/// One replica's view of a partition group's consensus. Read by the quiesce oracle;
/// see [`Simulator::partition_consensus_state`].
#[derive(Debug, Clone, Copy)]
pub(crate) struct PartitionConsensusState {
pub status: consensus::Status,
pub view: u32,
pub is_primary: bool,
/// Ops committed in the group. Not `PartitionOffsets::commit_offset`, the
/// highest durably PERSISTED offset, which counts an uncommitted suffix.
pub commit_min: u64,
}
pub struct Simulator {
/// All replicas, indexed by replica id. Always fully populated; crashed replicas
/// stay alive but are skipped during dispatch.
pub replicas: Vec<SimReplica>,
/// Per-replica outbox, indexed by replica id. Shared with consensus via
/// [`SharedSimOutbox`](bus::SharedSimOutbox).
pub outboxes: Vec<Rc<SimOutbox>>,
/// Currently-crashed replica ids. Dispatch and outbox drain skip these.
pub crashed: HashSet<u8>,
pub network: Network,
pub replica_count: u8,
pub client_ids: Vec<u128>,
/// Drives every shard pump. Scheduling picks and virtual time both derive from
/// the seed, so the schedule replays with it.
executor: DetExecutor,
/// Picks which shard receives each inbound packet, modelling the coordinator's
/// connection homing: production round-robins inbound connections, so the shard
/// receiving a peer's bytes is unrelated to the one owning the target group. Own
/// stream ([`SimSeeds::entry_shard`]) so one draw per delivered packet cannot
/// perturb the network or workload traces.
entry_rng: Xoshiro256PlusPlus,
/// Network seed, kept for livelock diagnostics.
seed: u64,
/// Clients the cluster has evicted since the last drain, in delivery order.
///
/// An eviction ends a session: outstanding requests go unanswered and the client
/// must log in again. Recorded, not acted on, because re-establishing a session
/// steps the simulator and drops workload expectations, neither of which belongs
/// inside packet delivery.
evicted: Vec<u128>,
/// Whether a rebuilt partition recovers its consensus frontier from the carried
/// log. OFF by default: production restores the view alone (`load_partition`), so
/// a run with it on studies a system more durable than Iggy is. See
/// `IggyShard::init_partition`.
restore_partition_frontier: bool,
/// The view each namespace was created in, what production records on the
/// committed partition and every replica seeds its group from. Read from the
/// metadata plane once, at the first seed, and reused for every later seed
/// of that namespace, a restart's included, so no replica seeds a view its
/// peers did not.
partition_created_views: HashMap<IggyNamespace, u32>,
/// Replies a setup handshake pulled off the wire that were not its own.
///
/// `await_setup_reply` steps the whole simulator, so it sees every client's
/// replies. Dropping the rest strands their auditor expectations until a resend
/// re-commits, so they are parked here and returned by the next [`Self::step`].
deferred_client_replies: Vec<Message<ReplyHeader>>,
/// Dispatch-shell mode: inbound client packets go through the real
/// `on_client_request` handler (see
/// [`shard::IggyShard::deliver_client_request`]) instead of raw `dispatch`
/// routing. Set at construction by [`Simulator::with_shards_shell`].
shell: bool,
}
impl Simulator {
/// New simulator with per-replica outboxes routed through a [`Network`].
///
/// # Panics
/// If `clients` yields duplicate `client_id`s. The auditor keys in-flight
/// entries by `(client_id, request)` and the network indexes routes by
/// `client_id`; duplicates collide on both.
pub fn new(
replica_count: usize,
clients: impl Iterator<Item = u128>,
network_options: PacketSimulatorOptions,
) -> Self {
Self::with_shards(replica_count, 1, clients, network_options)
}
/// [`Simulator::new`] with `shards_per_replica` shards per replica, meshed as
/// the server bootstrap does: metadata plane on shard 0, partitions
/// hash-assigned, one pump task per shard.
///
/// # Panics
/// On duplicate `client_id`s (see [`Simulator::new`]) or
/// `shards_per_replica == 0`.
pub fn with_shards(
replica_count: usize,
shards_per_replica: u16,
clients: impl Iterator<Item = u128>,
network_options: PacketSimulatorOptions,
) -> Self {
Self::build(
replica_count,
shards_per_replica,
clients,
network_options,
false,
)
}
/// [`Simulator::with_shards`] with the dispatch shell on: every shard wires the
/// server's real dispatch handlers, so a client request runs as a task the seeded
/// executor interleaves with the pump. Off, the default, keeps the raw
/// `on_message` fast path.
///
/// # Panics
/// On duplicate `client_id`s or `shards_per_replica == 0`.
pub fn with_shards_shell(
replica_count: usize,
shards_per_replica: u16,
clients: impl Iterator<Item = u128>,
network_options: PacketSimulatorOptions,
) -> Self {
Self::build(
replica_count,
shards_per_replica,
clients,
network_options,
true,
)
}
/// [`Simulator::new`] with checkpoints enabled, each replica rooted at
/// `<data_dir_root>/replica-N`.
///
/// A data directory arms the metadata `SnapshotCoordinator`; without one
/// `checkpoint_if_needed` returns immediately and nothing produces the snapshot a
/// state transfer serves.
///
/// Opt-in, and separate from the other constructors, because the coordinator
/// persists through `std::fs`: a harness that touches nothing outside memory
/// should not start writing files by omission. Writes are synchronous and never
/// touch the executor, so replay stays deterministic; the caller owns the
/// directory's lifetime.
///
/// Pair with [`Simulator::set_metadata_journal_slots`]: a checkpoint is forced by
/// the journal running low on slots, and it is unbounded until told otherwise.
///
/// # Panics
/// On duplicate `client_id`s, or if the per-replica directories cannot be
/// created.
pub fn with_checkpoints(
replica_count: usize,
clients: impl Iterator<Item = u128>,
network_options: PacketSimulatorOptions,
shell: bool,
data_dir_root: &std::path::Path,
) -> Self {
Self::build_inner(
replica_count,
1,
clients,
network_options,
shell,
Some(data_dir_root),
)
}
/// Bound every replica's metadata journal to `slots`, so filling it forces a
/// checkpoint. See [`deps::SimJournal::set_slot_count`].
pub fn set_metadata_journal_slots(&self, slots: usize) {
for replica in &self.replicas {
replica.metadata_journal.set_slot_count(slots);
}
}
#[allow(clippy::cast_possible_truncation, clippy::too_many_lines)]
fn build(
replica_count: usize,
shards_per_replica: u16,
clients: impl Iterator<Item = u128>,
network_options: PacketSimulatorOptions,
shell: bool,
) -> Self {
Self::build_inner(
replica_count,
shards_per_replica,
clients,
network_options,
shell,
None,
)
}
#[allow(clippy::cast_possible_truncation, clippy::too_many_lines)]
fn build_inner(
replica_count: usize,
shards_per_replica: u16,
clients: impl Iterator<Item = u128>,
network_options: PacketSimulatorOptions,
shell: bool,
data_dir_root: Option<&std::path::Path>,
) -> Self {
assert!(
shards_per_replica >= 1,
"a replica needs at least one shard"
);
let client_ids: Vec<u128> = clients.collect();
{
let mut seen = HashSet::with_capacity(client_ids.len());
for &cid in &client_ids {
assert!(
seen.insert(cid),
"Simulator::new: duplicate client_id {cid}; \
auditor and network both key on client_id"
);
}
}
let seed = network_options.seed;
let mut network = Network::new(network_options);
for &cid in &client_ids {
network.register_client(cid);
}
let mut executor = DetExecutor::new(seed);
let timer = executor.timer();
let spawns = executor.spawner();
// One virtual clock for every consensus group, so prepare timestamps are a
// pure function of the seed rather than the wall clock.
let consensus_clock = ConsensusClock::new(Rc::new(SimClock::new(timer.clone())));
let rc = replica_count as u8;
let mut replicas = Vec::with_capacity(replica_count);
let mut outboxes = Vec::with_capacity(replica_count);
for i in 0..replica_count {
let id = i as u8;
let mut bus = SimOutbox::new(id, timer.clone(), spawns.clone());
for &cid in &client_ids {
bus.add_client(cid);
}
for j in 0..rc {
bus.add_replica(j);
}
let outbox = Rc::new(bus);
// Harness-owned so the superblock's VSR state and the metadata WAL
// outlive a restart, which drops and rebuilds the shards.
let superblock = Rc::new(SimSuperblock::default());
let metadata_journal = Rc::new(SimJournal::<MemStorage>::default());
// Spread across the 128-bit space per replica so `replica_restart`'s
// increments never collide across replicas. Non-zero.
let metadata_incarnation = 1 + (u128::from(id) << 64);
// Created up front: the snapshot coordinator writes into
// `<dir>/metadata/` and does not create it. Retained on `SimReplica` so a
// restart reads back the snapshot its previous incarnation wrote.
let replica_data_dir = data_dir_root.as_ref().map(|root| {
let dir = root.join(format!("replica-{i}"));
std::fs::create_dir_all(dir.join(metadata::impls::METADATA_DIR))
.expect("simulator data directory is creatable");
dir
});
// One crossfire mesh per replica. Every shard clones the canonical
// senders vec and exclusively takes its inbox.
let (senders, mut inboxes, mut reply_inboxes) = shard::shard_mesh_channels(
shards_per_replica,
SIM_INBOX_CAPACITY,
SIM_INBOX_CAPACITY,
);
let mut shards = Vec::with_capacity(usize::from(shards_per_replica));
let mut stop_txs = Vec::with_capacity(usize::from(shards_per_replica));
let mut pump_tasks = Vec::with_capacity(usize::from(shards_per_replica));
// Single-writer metadata, as the server bootstrap does: shard 0 builds
// the writable STM and mints a factory bundle, every peer rebuilds a
// reader-mode mirror from it and reads committed metadata through the
// shared handle. Built in index order, so shard 0's bundle exists first.
let mut metadata_bundle: Option<replica::SimMetadataBundle> = None;
for shard_idx in 0..shards_per_replica {
let inbox = inboxes[usize::from(shard_idx)]
.take()
.expect("mesh yields exactly one inbox per shard");
let reply_inbox = reply_inboxes[usize::from(shard_idx)]
.take()
.expect("mesh yields exactly one reply inbox per shard");
// Shard 0 owns metadata consensus, so only it carries the
// superblock. Peers persist nothing.
let shard_superblock = if shard_idx == 0 {
Some(superblock.clone())
} else {
None
};
let shard_journal = (shard_idx == 0).then(|| Rc::clone(&metadata_journal));
let (shard, writer_bundle) = new_shard(
id,
shard_idx,
format!("replica-{i}-shard-{shard_idx}"),
&outbox,
rc,
senders.clone(),
inbox,
reply_inbox,
consensus_clock.clone(),
shell,
metadata_bundle.clone(),
shard_superblock,
shard_journal,
None, // fresh boot: no recovered VSR state
metadata_incarnation,
(shard_idx == 0).then(|| replica_data_dir.clone()).flatten(),
// Fresh boot: `init_partition` seeds later, before any workload.
&[],
);
if shard_idx == 0 {
metadata_bundle = Some(
writer_bundle
.expect("shard 0 returns the metadata factory bundle for peers"),
);
}
// Server-bootstrap wiring: one pump task per shard, stopped only by
// the held stop channel or a crash abort.
let (stop_tx, stop_rx) = shard::channel::<()>(1);
let pump_shard = Rc::clone(&shard);
// The simulator crashes replicas explicitly, so nothing reads
// the shutdown flag a commit fault would flip.
let pump_shutdown_flag = Arc::new(AtomicBool::new(false));
pump_tasks.push(executor.spawn(async move {
pump_shard
.run_message_pump(stop_rx, pump_shutdown_flag)
.await;
}));
stop_txs.push(stop_tx);
shards.push(shard);
}
replicas.push(SimReplica {
shards,
superblock,
metadata_journal,
metadata_incarnation,
partition_superblocks: RefCell::new(HashMap::new()),
partition_logs: RefCell::new(HashMap::new()),
data_dir: replica_data_dir,
_stop_txs: stop_txs,
pump_tasks,
});
outboxes.push(outbox);
}
Self {
replicas,
outboxes,
crashed: HashSet::new(),
network,
replica_count: rc,
client_ids,
executor,
entry_rng: Xoshiro256PlusPlus::seed_from_u64(SimSeeds::derive(seed).entry_shard),
evicted: Vec::new(),
restore_partition_frontier: false,
partition_created_views: HashMap::new(),
deferred_client_replies: Vec::new(),
seed,
shell,
}
}
/// Init a partition with its own consensus group on every live replica.
///
/// The reconciler's outcome without running it: the namespace is committed to
/// metadata, the partition materialises only on its hash-owning shard, and every
/// shard gets the routing row stamped with the committed `created_revision`.
/// Production seeds rows through `ReconcileOp::{InsertOwned,InsertRouted}`.
///
/// # Panics
/// If a replica's shard count does not fit `u32`; mesh construction caps it at
/// `u16`.
// TODO(hubcio): partitions created down this path are built via
// `IggyPartition::with_in_memory_storage` and rely on the writer-less
// persist branch in `IggyPartition`; give them first-class in-memory
// segment storage so that branch can be deleted.
#[allow(clippy::cast_possible_truncation)]
pub fn init_partition(&mut self, namespace: IggyNamespace) {
let Some(created_view) = self.partition_created_view(namespace) else {
return;
};
for (i, replica) in self.replicas.iter().enumerate() {
if self.crashed.contains(&(i as u8)) {
continue;
}
materialise_partition(
replica,
namespace,
self.restore_partition_frontier,
created_view,
);
}
}
/// Seed the metadata `Streams` STM on each live replica's shard-0 writer, so a
/// poll's `resolve_partition_namespace` succeeds for `namespace`, which the
/// partition-plane-only [`Self::init_partition`] does not populate.
///
/// Shard 0 only: peers observe the seed through the shared left-right read
/// handle, and seeding a reader-mode peer STM directly would panic. The
/// reconciler is unwired here, so this bypasses it exactly as `init_partition`
/// does for the partition plane. Pair the two for the same namespace on the
/// dispatch-shell poll path.
///
#[allow(clippy::cast_possible_truncation)]
pub fn seed_stream_topic_partition(&mut self, namespace: IggyNamespace) {
let Some(created_view) = self.partition_created_view(namespace) else {
return;
};
for (i, replica) in self.replicas.iter().enumerate() {
if self.crashed.contains(&(i as u8)) {
continue;
}
// Shard 0 is the sole metadata writer. Peers see the seed through the
// left-right publish, so seeding a reader-mode peer STM would panic.
replica.shards[0]
.plane
.metadata()
.mux_stm
.streams()
.seed_namespace(namespace, namespace.inner(), created_view);
}
}
/// The view `namespace` was created in: the metadata plane's view at its
/// first seed, as the prepare of a real create carries it, and the same value
/// for every seed after. `None` with no live metadata consensus to read.
fn partition_created_view(&mut self, namespace: IggyNamespace) -> Option<u32> {
if let Some(&created_view) = self.partition_created_views.get(&namespace) {
return Some(created_view);
}
let created_view = self.metadata_view()?;
self.partition_created_views.insert(namespace, created_view);
Some(created_view)
}
/// Log `client` in against the deterministic root user through the dispatch
/// shell: the real `on_client_request` path verifies the seeded root credentials
/// and runs the consensus `Register`, then this binds the assigned session.
/// Needs the shell on and the root user seeded (see [`new_shard`]); targets the
/// primary, as [`Self::register_client_with_primary`] does.
///
/// # Panics
/// If no login reply arrives within `SETUP_TOTAL_STEPS`, or it carries no
/// session.
pub fn shell_login(&mut self, client: &SimClient) {
self.shell_login_via(client, 0);
}
/// [`Self::shell_login`] against a chosen replica.
///
/// Dialing a BACKUP is the only way to reach register forwarding: the backup
/// verifies the credentials itself and sends only the consensus proposal on as
/// `ForwardRegister`, parking the login until the matching
/// `ForwardRegisterResult` returns, then answering on the connection it owns. A
/// client that always dials the primary never produces those frames.
///
/// # Panics
/// If no login reply arrives within `SETUP_TOTAL_STEPS`, or it carries no
/// session.
pub fn shell_login_via(&mut self, client: &SimClient, target: u8) {
// Connection metadata on every replica, as `install_client_fd` does in
// production. `ensure_transport_connection` reads it to admit the connection
// into the SessionManager, which the login's bind
// (Connected, Authenticated, Bound) requires.
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
for outbox in &self.outboxes {
outbox.insert_client_meta(ClientConnMeta::new(
client.client_id(),
addr,
ClientTransportKind::Tcp,
));
}
// From here this client talks the real client protocol. See
// `SimClient::shell_wire`.
client.set_shell_wire();
let msg = client
.login(replica::SHELL_ROOT_USERNAME, replica::SHELL_ROOT_PASSWORD)
.into_generic();
// `build_reply_with_body` maps the session field to `op`.
let session = self
.await_setup_reply(
client.client_id(),
target,
&msg,
iggy_binary_protocol::Operation::Register,
"shell_login",
)
.header()
.op;
assert!(session > 0, "shell_login: login reply carried no session");
client.bind_session(session);
}
/// Submit `message` to `target` and step until a client reply arrives,
/// resubmitting every [`SETUP_RETRY_STEPS`] steps.
///
/// Resubmitted verbatim, so the request id is stable. Not free, though:
/// `register_preflight` dispatches every `Register` past its gates, even one
/// whose client already holds an entry, because a bind is a fencing event and
/// absorbing it would hand back an un-bumped epoch. Each landed retry therefore
/// commits another register and fences the previous holder, recoverable in one
/// round trip but a reason to keep the interval well above it.
///
/// # Panics
/// If no reply arrives within `SETUP_TOTAL_STEPS`. A handshake that never
/// completes leaves the fixture unusable, so there is no useful `None`.
fn await_setup_reply(
&mut self,
client_id: u128,
target: u8,
message: &Message<GenericHeader>,
expected_operation: iggy_binary_protocol::Operation,
label: &str,
) -> Message<ReplyHeader> {
let mut target = target;
for step in 0..SETUP_TOTAL_STEPS {
if step % SETUP_RETRY_STEPS == 0 {
self.submit_request(client_id, target, message.deep_copy());
// Rotate, as the workload's resend path does. Retrying one replica
// forever suffices on a perfect network and is useless once it is
// partitioned or crashed mid-handshake: a register has to reach the
// metadata primary, and this one may be neither reachable nor able
// to forward.
target = (target + 1) % self.replica_count.max(1);
}
let mut answer = None;
for reply in self.step() {
let header = reply.header();
// Correlate. Stepping the whole simulator surfaces every client's
// replies, and taking the first hands a two-client run another
// client's committed op as this answer, whose `op` is then bound as
// a session: a fenced client that evicts, recovers and repeats.
//
// Nor is a result-framed transport rejection an answer. Dispatch
// refused to place the request (not primary, transferring, queue
// full), stamped `op` with its commit point instead of a session and
// left `status` at 0, so it reads as a completed handshake and hands
// back a session the cluster never granted, silently, whenever that
// commit point is nonzero.
if answer.is_none()
&& header.client == client_id
&& header.operation == expected_operation
&& !setup_reply_is_transient(&reply)
{
answer = Some(reply);
continue;
}
self.deferred_client_replies.push(reply);
}
if let Some(reply) = answer {
return reply;
}
}
panic!(
"{label}: no reply for client {client_id} within {SETUP_TOTAL_STEPS} steps \
(seed {:#x})",
self.seed,
);
}
/// Advance the simulation by one tick. Returns client replies delivered.
///
/// Every shard runs its real message pump as an executor task. A step fires the
/// virtual consensus tick, runs the pumps to quiescence, feeds network packets
/// into the shard routers, runs the pumps over the resulting frames, then
/// exchanges outboxes with the network. Pump interleaving is a seeded executor
/// pick, and everything a step produces lands on the wire before network time
/// advances.
///
/// # Panics
/// If a client-addressed packet does not decode as `ReplyHeader`, or a pump
/// livelocks (poll budget exhausted).
#[allow(clippy::cast_possible_truncation)]
pub fn step(&mut self) -> Vec<Message<ReplyHeader>> {
// Ahead of this step's own traffic, so a driver sees replies in delivery
// order rather than the order a handshake happened to interrupt.
let mut client_replies: Vec<Message<ReplyHeader>> =
std::mem::take(&mut self.deferred_client_replies);
// Phase 0: Fire the pumps' consensus-tick timers (view change,
// retransmits) and run them to quiescence. Crashed replicas have no
// live pump tasks, so they are skipped implicitly.
self.executor.advance_time(CONSENSUS_TICK_INTERVAL);
self.run_pumps();
// Phase 1: Deliver ready packets from the network into the shard
// routers. `dispatch` classifies and enqueues onto the owning
// shard's inbox; the pumps drain those frames in phase 1b.
let packets = self.network.step();
for packet in &packets {
match packet.to {
ProcessId::Replica(id) => {
if !self.crashed.contains(&id)
&& let Some(replica) = self.replicas.get(id as usize)
{
// Seeded homing: the receiving shard is usually NOT the
// owner, so the frame takes the real router hop (dispatch,
// mesh, owning pump), as when the coordinator homes a peer
// connection on an arbitrary shard.
let entry = self.entry_rng.random_range(0..replica.shards.len());
match packet.from {
// Shell mode: client requests enter through the real
// `on_client_request` handler, as the client-fd listener
// does in production, and drain as a task the executor
// interleaves with the pump. Partition writes carry the
// legacy `SendMessages` shape the real SDK sends, so
// `resolve_partition_request_namespace` decodes them
// here. Replica-sourced consensus frames route raw.
ProcessId::Client(client_id) if self.shell => {
replica.shards[entry]
.deliver_client_request(client_id, packet.message.deep_copy());
}
_ => replica.shards[entry].dispatch(packet.message.deep_copy()),
}
}
// Crashed or missing: packet silently dropped.
}
ProcessId::Client(client_id) => {
// Not every client-addressed frame is a reply. `Eviction` tells
// a client its session is gone, sent once the client table drops
// it, which the dispatch shell reaches as soon as replicas crash
// and restart. Decoding it as a reply fails on the command
// discriminant, so classify first and record it for the driver.
if packet.message.header().command == Command::Eviction {
self.evicted.push(client_id);
continue;
}
let reply: Message<ReplyHeader> = packet
.message
.deep_copy()
.try_into_typed()
.expect("invalid message, wrong command type for a client response");
client_replies.push(reply);
}
}
}
self.network.recycle_buffer(packets);
// Phase 1b: Pumps process the delivered frames (and their loopback
// and reconcile follow-ups) to quiescence.
self.run_pumps();
// Phase 2: Drain each replica's outbox into the network.
for (i, outbox) in self.outboxes.iter().enumerate() {
let envelopes = outbox.drain();
if self.crashed.contains(&(i as u8)) {
// Defensive: discard any messages from a crashed node's outbox.
continue;
}
for envelope in envelopes {
let from = ProcessId::Replica(i as u8);
let to = if let Some(rid) = envelope.to_replica {
ProcessId::Replica(rid)
} else if let Some(cid) = envelope.to_client {
ProcessId::Client(cid)
} else {
continue;
};
let message = match envelope.payload {
bus::EnvelopePayload::Replica(m) | bus::EnvelopePayload::Client(m) => m,
};
self.network.submit(from, to, message);
}
}
// Phase 3: Advance network time.
self.network.tick();
client_replies
}
/// Stamp a metadata snapshot watermark on one replica, standing in for a
/// checkpoint that superseded everything at or below `op`.
///
/// Without a data directory there is no `SnapshotCoordinator`, so
/// `checkpoint_if_needed` returns immediately and the watermark stays at zero.
/// Nothing is evictable, the repair server has no compacted prefix to skip, and
/// `RangeEvicted`, the only signal converting a repair into a state transfer,
/// cannot occur however the cluster is driven.
///
/// Stamping the number alone reaches the whole escalation without snapshot bytes
/// to transfer, so the protocol path is coverable without the coordinator seam a
/// real installing transfer needs.
///
/// # Panics
/// If `replica_idx` is out of range.
pub fn stamp_metadata_snapshot(&self, replica_idx: usize, op: u64) {
use journal::Journal;
self.replicas[replica_idx]
.metadata_journal
.set_snapshot_op(op);
}
/// Submit this client's handshake to `target` WITHOUT stepping.
///
/// The blocking helpers ([`Self::shell_login_via`],
/// [`Self::register_client_with_primary`]) are for fixture setup, before a driver
/// exists. Mid-run they step the simulator up to `SETUP_TOTAL_STEPS` times inside
/// the driver's tick, with no `Workload::tick`, no fault injection and no
/// invariant check, which is what `run_with_faults` is for. A driver recovering an
/// evicted client submits here and picks the reply up from its normal loop.
///
/// Sends the handshake this simulator's mode can answer: a login on the shell, a
/// bare register on the raw path.
pub fn submit_handshake(&mut self, client: &SimClient, target: u8) {
let message = if self.shell {
client.set_shell_wire();
client.login(replica::SHELL_ROOT_USERNAME, replica::SHELL_ROOT_PASSWORD)
} else {
client.register()
};
self.submit_request(client.client_id(), target, message.into_generic());
}
/// The session a handshake reply carries, or `None` if it carries none. Not the
/// caller's business because the field differs by path: the shell's login answers
/// in `op`, the raw register in `commit`.
#[must_use]
pub fn handshake_session(&self, reply: &Message<ReplyHeader>) -> Option<u64> {
if setup_reply_is_transient(reply) {
return None;
}
let header = reply.header();
let session = if self.shell { header.op } else { header.commit };
(session > 0).then_some(session)
}
/// Have a rebuilt partition recover `(sequencer, commit, checksum)` from the log
/// this harness carried across the restart.
///
/// Off by default, deliberately: the partition journal is in-memory and segments
/// carry no op numbers, so a real replica cannot do this and instead boots
/// quorum-invisible and asks the view's primary. Turn it on only to look past the
/// empty-frontier restart, which trips `advance_commit_min`'s sequential-advance
/// assert, at something later in the run; the run then tests a durability
/// guarantee production does not offer.
pub const fn set_restore_partition_frontier(&mut self, restore: bool) {
self.restore_partition_frontier = restore;
}
/// Take the clients evicted since the last call.
///
/// A driver must consume these: the session is gone, so outstanding requests are
/// unanswerable and the next one is refused until the client logs in again.
/// Ignoring them looks exactly like a wedge.
pub fn take_evictions(&mut self) -> Vec<u128> {
std::mem::take(&mut self.evicted)
}
/// Rolling hash of the executor schedule: every poll and timer fire. Two runs
/// from the same seed and inputs must agree; determinism tests assert on it
/// alongside the reply-trace hash.
#[must_use]
pub const fn schedule_hash(&self) -> u64 {
self.executor.schedule_hash()
}
/// Run the executor until every pump is parked again.
///
/// # Panics
/// On budget exhaustion: pumps are event-driven, so it means a spin-waking task,
/// a livelock bug, reproducible from the seed. Also on a lost wakeup, a
/// non-crashed pump quiescing with a non-empty inbox (see
/// [`Self::assert_inboxes_drained`]).
fn run_pumps(&mut self) {
match self.executor.run_until_stalled(POLL_BUDGET) {
RunOutcome::Quiescent { .. } => self.assert_inboxes_drained(),
RunOutcome::BudgetExhausted { polls } => panic!(
"simulator livelock: {polls} polls without quiescing \
(seed {:#x}, schedule hash {:#x})",
self.seed,
self.executor.schedule_hash(),
),
}
}
/// Lost-work tripwire. At executor quiescence every live pump must have drained
/// both inbox lanes and its parked-frame redispatch queue. A non-empty lane on a
/// non-crashed replica means a frame reached the channel without waking the
/// target pump. A non-empty redispatch queue means a materialisation staged work
/// without the pump's priority drain completing. Every pump holds a standing
/// `CONSENSUS_TICK_INTERVAL` timer, so the next `advance_time` would re-poll and
/// silently drain either case, masking the fault. Trip here instead.
///
/// Incomplete by construction: only catches a lost wakeup while the un-woken
/// frame is still queued at quiescence, since a later frame that does wake the
/// pump drains the whole inbox. Safe in direction, though: a non-empty inbox at
/// true quiescence is always a real lost wake, so it never false-trips.
///
/// Crashed replicas are skipped: their pump tasks are aborted, so a stranded
/// frame has no drainer and is expected.
#[allow(clippy::cast_possible_truncation)]
fn assert_inboxes_drained(&self) {
for (replica_id, replica) in self.replicas.iter().enumerate() {
if self.crashed.contains(&(replica_id as u8)) {
continue;
}
for shard in &replica.shards {
let pending = shard.inbox_len();
assert_eq!(
pending,
0,
"lost wakeup: replica {replica_id} shard {} inbox holds {pending} \
frame(s) at quiescence (seed {:#x}, schedule hash {:#x})",
shard.id,
self.seed,
self.executor.schedule_hash(),
);
let pending_replies = shard.reply_inbox_len();
assert_eq!(
pending_replies,
0,
"lost wakeup: replica {replica_id} shard {} reply lane holds \
{pending_replies} frame(s) at quiescence (seed {:#x}, schedule hash {:#x})",
shard.id,
self.seed,
self.executor.schedule_hash(),
);
let pending_redispatch = shard.redispatched_frame_count();
assert_eq!(
pending_redispatch,
0,
"lost redispatch: replica {replica_id} shard {} redispatch queue holds \
{pending_redispatch} frame(s) at quiescence (seed {:#x}, schedule hash {:#x})",
shard.id,
self.seed,
self.executor.schedule_hash(),
);
}
}
}
/// Submit a client request into the simulated network: a client opening a TCP
/// connection and sending a message to a replica.
pub fn submit_request(
&mut self,
client_id: u128,
target_replica: u8,
message: Message<GenericHeader>,
) {
self.network.submit(
ProcessId::Client(client_id),
ProcessId::Replica(target_replica),
message,
);
}
/// Whether client requests go through the real dispatch shell. A driver has to
/// know: the paths do not share a handshake (`Register` versus a login that mints
/// a session), so re-establishing a client mid-run has to pick the served one.
#[must_use]
pub const fn is_shell(&self) -> bool {
self.shell
}
/// Register a client via the primary (replica 0): sends `Register` through the
/// metadata plane and binds the assigned session on `SimClient`.
///
/// # Panics
/// If no reply arrives within `SETUP_TOTAL_STEPS`.
pub fn register_client_with_primary(&mut self, client: &SimClient) {
self.register_client_via(client, 0);
}
/// [`Self::register_client_with_primary`] against a chosen replica, the raw
/// counterpart of [`Self::shell_login_via`]. A driver re-registering an evicted
/// client picks a live replica, and the primary may be the one whose restart
/// caused the eviction.
///
/// # Panics
/// If no reply arrives within `SETUP_TOTAL_STEPS`.
#[allow(clippy::cast_possible_truncation)]
pub fn register_client_via(&mut self, client: &SimClient, target: u8) {
let msg = client.register().into_generic();
let reply = self.await_setup_reply(
client.client_id(),
target,
&msg,
iggy_binary_protocol::Operation::Register,
"register_client_with_primary",
);
let header = reply.header();
debug_assert_eq!(
header.operation,
iggy_binary_protocol::Operation::Register,
"register_client_with_primary: first reply was not Register"
);
assert_eq!(
header.client,
client.client_id(),
"register_client_with_primary: reply client_id mismatch (expected {}, got {})",
client.client_id(),
header.client,
);
client.bind_session(header.commit);
// Partitions have no `client_table`: at-least-once, no per-client dedup, so
// consumers dedup on message id, content or producer-id+seq. Sessions,
// dedup and eviction live on metadata only.
}
/// Crash a replica: abort its pump tasks, disable its network links, discard its
/// outbox. The object stays alive but receives nothing; a following
/// [`Self::replica_restart`] drops and rebuilds it from the durable superblock,
/// which is where volatile state is actually lost and recovered.
///
/// # Panics
/// If the replica is already crashed.
pub fn replica_crash(&mut self, replica_index: u8) {
assert!(
!self.crashed.contains(&replica_index),
"cannot crash replica {replica_index}: already down"
);
// Hard stop: futures drop mid-await, destructors cancel their channel and
// timer registrations, and the graceful inbox drain never runs. A crash, not
// a shutdown.
for task in &self.replicas[replica_index as usize].pump_tasks {
self.executor.abort(*task);
}
// Detached dispatch tasks this replica's bus spawned (off-pump poll IO,
// request drains), so a crash leaves none running against a dead replica.
self.executor.abort_replica_spawned(replica_index);
// Discard any unsent messages (never reached the wire).
self.outboxes[replica_index as usize].drain();
// Block all network links to/from this process.
self.network
.process_disable(ProcessId::Replica(replica_index));
self.crashed.insert(replica_index);
}
/// `true` if the replica is currently crashed.
#[must_use]
pub fn is_crashed(&self, replica_index: u8) -> bool {
self.crashed.contains(&replica_index)
}
/// Restart a crashed replica: drop its shards, losing all volatile consensus
/// state as a real restart does, and rebuild against the retained superblock,
/// recovering `(view, log_view)` from disk as production's
/// `restore_metadata_consensus` does. Superblock and outbox are harness-owned and
/// survive the drop; a fresh mesh and pump tasks are wired and the network
/// re-enabled.
///
/// # Panics
/// If the replica is not crashed, or its shard count does not fit `u16`; mesh
/// construction caps it.
pub fn replica_restart(&mut self, replica_index: u8) {
assert!(
self.crashed.contains(&replica_index),
"cannot restart replica {replica_index}: not crashed"
);
let idx = replica_index as usize;
let shards_per_replica =
u16::try_from(self.replicas[idx].shards.len()).expect("shard count fits u16");
let superblock = Rc::clone(&self.replicas[idx].superblock);
// The metadata WAL is harness-owned too, so the rebuilt shard 0 recovers
// op/commit and committed state from it rather than from an empty journal.
let metadata_journal = Rc::clone(&self.replicas[idx].metadata_journal);
// Bumped so an in-flight StartView addressed to the previous incarnation is
// ignored. Deterministic, so replay stays byte-identical.
let metadata_incarnation = self.replicas[idx].metadata_incarnation + 1;
// Partition superblocks carry forward too: a re-materialised group must
// recover its recorded view from the same store, as a rebooted server
// partition reads the record in its directory.
let partition_superblocks =
std::mem::take(&mut *self.replicas[idx].partition_superblocks.borrow_mut());
// Take each live partition's log while its shard still stands: a real
// server's messages are in segment files its boot recovers the offset counter
// from, so rebuilding with nothing would model total data loss rather than a
// restart. Before the rebuild, which drops the shards.
let partition_logs = self.retain_partition_logs(idx, &partition_superblocks);
// SORTED: seed order decides slab ids and `HashMap` order is per-process, so
// an unsorted walk would stop replay being byte-identical. Also drives the
// re-materialisation loop below, which must agree with it. Each carries the
// view it was created in, as the metadata a real boot replays would.
let mut seed_namespaces: Vec<(IggyNamespace, u32)> = partition_superblocks
.keys()
.map(|&namespace| (namespace, self.partition_created_views[&namespace]))
.collect();
seed_namespaces.sort_unstable_by_key(|(namespace, _)| namespace.inner());
// Durable VSR state from the retained superblock, before the rebuild, as
// production reads it in `restore_metadata_consensus`.
let recovered_state = superblock
.read_latest_sync()
.and_then(|bytes| VsrState::try_from(bytes.as_slice()).ok());
// Carried like the WAL and superblocks: the rebuilt replica has to find the
// snapshot its previous incarnation persisted.
let replica_data_dir = self.replicas[idx].data_dir.clone();
let consensus_clock = ConsensusClock::new(Rc::new(SimClock::new(self.executor.timer())));
let outbox = Rc::clone(&self.outboxes[idx]);
let (senders, mut inboxes, mut reply_inboxes) =
shard::shard_mesh_channels(shards_per_replica, SIM_INBOX_CAPACITY, SIM_INBOX_CAPACITY);
let mut shards = Vec::with_capacity(usize::from(shards_per_replica));
let mut stop_txs = Vec::with_capacity(usize::from(shards_per_replica));
let mut pump_tasks = Vec::with_capacity(usize::from(shards_per_replica));
let mut metadata_bundle: Option<replica::SimMetadataBundle> = None;
for shard_idx in 0..shards_per_replica {
let inbox = inboxes[usize::from(shard_idx)]
.take()
.expect("mesh yields exactly one inbox per shard");
let reply_inbox = reply_inboxes[usize::from(shard_idx)]
.take()
.expect("mesh yields exactly one reply inbox per shard");
let shard_superblock = if shard_idx == 0 {
Some(superblock.clone())
} else {
None
};
let shard_journal = (shard_idx == 0).then(|| Rc::clone(&metadata_journal));
let (shard, writer_bundle) = new_shard(
replica_index,
shard_idx,
format!("replica-{replica_index}-shard-{shard_idx}"),
&outbox,
self.replica_count,
senders.clone(),
inbox,
reply_inbox,
consensus_clock.clone(),
self.shell,
metadata_bundle.clone(),
shard_superblock,
shard_journal,
recovered_state,
metadata_incarnation,
(shard_idx == 0).then(|| replica_data_dir.clone()).flatten(),
&seed_namespaces,
);
if shard_idx == 0 {
metadata_bundle =
Some(writer_bundle.expect("shard 0 returns the metadata factory bundle"));
}
let (stop_tx, stop_rx) = shard::channel::<()>(1);
let pump_shard = Rc::clone(&shard);
let pump_shutdown_flag = Arc::new(AtomicBool::new(false));
pump_tasks.push(self.executor.spawn(async move {
pump_shard
.run_message_pump(stop_rx, pump_shutdown_flag)
.await;
}));
stop_txs.push(stop_tx);
shards.push(shard);
}
// Replacing the replica drops the old shards, losing all volatile consensus
// state as a real restart does. The harness-owned superblock carries over.
self.replicas[idx] = SimReplica {
shards,
superblock,
metadata_journal,
metadata_incarnation,
partition_superblocks: RefCell::new(partition_superblocks),
partition_logs: RefCell::new(partition_logs),
data_dir: replica_data_dir,
_stop_txs: stop_txs,
pump_tasks,
};
// Re-materialise every group this replica had before the crash, as a
// rebooted server re-opens every partition directory it owns. This is what
// makes the carried-forward superblock load-bearing: the group recovers its
// recorded `(view, log_view)` instead of re-entering view 0. The metadata half
// of the seed already ran inside `new_shard`, ahead of the replay.
for (namespace, created_view) in seed_namespaces {
materialise_partition(
&self.replicas[idx],
namespace,
self.restore_partition_frontier,
created_view,
);
}
// Reconnect to the network and mark the replica live again.
self.network
.process_enable(ProcessId::Replica(replica_index));
self.crashed.remove(&replica_index);
}
/// Take the message log out of every materialised partition, with the offsets
/// recovered from it.
///
/// Called while the outgoing shards are still alive, the last point the data can
/// be read. `std::mem::take` leaves an empty log behind, which nothing observes:
/// that shard is dropped moments later.
///
/// Keyed off `partition_superblocks`, already the record of which groups this
/// replica materialised and what the restart re-materialises from.
fn retain_partition_logs(
&self,
replica_idx: usize,
materialised: &HashMap<IggyNamespace, Rc<SimSuperblock>>,
) -> HashMap<IggyNamespace, RetainedPartitionState> {
let replica = &self.replicas[replica_idx];
let mut retained = HashMap::with_capacity(materialised.len());
for &namespace in materialised.keys() {
let partitions = replica.partition_shard(namespace).plane.partitions();
let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
continue;
};
let offsets = partition.offsets();
// `offset_space_used` off the RETIRING partition: an untouched one and
// one holding a single message at offset 0 both report `(0, 0)`, and only
// this instance still knows which it is.
retained.insert(
namespace,
RetainedPartitionState {
log: std::mem::take(&mut partition.log),
durable_offset: offsets.commit_offset,
write_offset: offsets.write_offset,
offset_space_used: partition.offset_space_used(),
},
);
}
retained
}
/// Advance consensus timeouts on every live replica without a full step cycle:
/// fire the pumps' virtual tick timers, run the executor to quiescence.
///
/// # Panics
/// If a pump livelocks (poll budget exhausted).
pub fn tick(&mut self) {
self.executor.advance_time(CONSENSUS_TICK_INTERVAL);
self.run_pumps();
}
/// Poll messages directly from a replica's partition.
///
/// # Errors
/// `IggyError::ResourceNotFound` if the namespace is not on this replica.
pub fn poll_messages(
&self,
replica_idx: usize,
namespace: IggyNamespace,
consumer: PollingConsumer,
args: &PollingArgs,
) -> Result<PollFragments<4096>, IggyError> {
let shard = self.replicas[replica_idx].partition_shard(namespace);
// Build the owned poll plan synchronously, then execute off the borrow.
//
// The one `block_on` allowed to stay, and only because `plan.execute()`
// cannot suspend here: the sim's partitions are in-memory (no
// `partition_dir`), so the plan serves the resident journal tier with no disk
// IO and no `bus.sleep`. A suspending await would fail two ways. On the
// virtual clock it would hang this thread forever, the clock advancing only
// through `advance_time`, which does not run during `block_on`. On the retry
// path it would panic on the compio timer outside a compio runtime. Safe only
// because it runs between `run_pumps` calls, with the executor quiescent and
// no pump holding the partition commit lock in a suspended frame.
let Some(plan) = shard
.plane
.partitions()
.build_poll_snapshot(&namespace, consumer, args)
else {
return Err(IggyError::ResourceNotFound(format!(
"partition not found for namespace {namespace:?} on replica {replica_idx}"
)));
};
// Partitions are driven directly, so a poll's auto-commit is never
// replicated (the serving shard's job in the real server). Offset discarded.
let (fragments, _commit_offset, _auto_commit) = futures::executor::block_on(plan.execute());
Ok(fragments)
}
/// Partition offsets from a replica.
#[must_use]
pub fn offsets(
&self,
replica_idx: usize,
namespace: IggyNamespace,
) -> Option<PartitionOffsets> {
let shard = self.replicas[replica_idx].partition_shard(namespace);
let partition = shard.plane.partitions().get_by_ns(&namespace)?;
Some(partition.offsets())
}
/// Consensus view for a replica's partition-plane group, or `None` if that
/// replica does not host the namespace.
#[must_use]
pub(crate) fn consensus_view(
&self,
replica_idx: usize,
namespace: IggyNamespace,
) -> Option<u64> {
let shard = self.replicas[replica_idx].partition_shard(namespace);
let partition = shard.plane.partitions().get_by_ns(&namespace)?;
Some(u64::from(partition.consensus().view()))
}
/// One replica's view of a partition group's consensus, or `None` when it does
/// not host the namespace. Read by the quiesce oracle to decide whether a group
/// has settled into one view, which its leader-relative checks depend on once
/// partition primaries can be crashed.
#[must_use]
pub(crate) fn partition_consensus_state(
&self,
replica_idx: usize,
namespace: IggyNamespace,
) -> Option<PartitionConsensusState> {
let shard = self.replicas[replica_idx].partition_shard(namespace);
let partition = shard.plane.partitions().get_by_ns(&namespace)?;
let consensus = partition.consensus();
Some(PartitionConsensusState {
status: consensus.status(),
view: consensus.view(),
is_primary: consensus.is_primary(),
commit_min: consensus.commit_min(),
})
}
/// Index of the current primary for `namespace`, as seen by the first live
/// replica hosting it, or `None` if no live replica hosts it.
///
/// Reads one replica's view, so it assumes live replicas agree on the primary.
/// That no longer holds by construction: `spare_primary` is off under
/// `--crash-primary`, so a crash-triggered view change can run mid-run and this
/// may name a stale or crashed primary. Callers needing a real answer run
/// `workload::oracle::settle_to_stable_view` first.
/// The replica the METADATA plane currently names primary, read from the
/// first live replica that owns a metadata consensus.
///
/// The twin of [`Self::primary_index`], which answers for a partition
/// group. The two planes count views independently, so they agree only
/// while their views are congruent mod the replica count, and a group
/// materialised after a metadata election is the case where they part.
///
/// Test-only. The workload oracle deliberately does NOT assert the two
/// planes agree: that holds for a group at the view it was seeded in, and
/// a later election on either plane parts them again with nothing to pull
/// them back, so a live invariant would fire on correct runs.
#[cfg(test)]
#[must_use]
pub(crate) fn metadata_primary_index(&self) -> Option<u8> {
(0..self.replica_count)
.filter(|replica_idx| !self.crashed.contains(replica_idx))
.find_map(|replica_idx| {
let consensus = self.replicas[usize::from(replica_idx)].shards[0]
.plane
.metadata()
.consensus
.as_ref()?;
Some(consensus.primary_index(consensus.view()))
})
}
/// The metadata plane's view, as the first live replica owning a metadata
/// consensus sees it.
fn metadata_view(&self) -> Option<u32> {
(0..self.replica_count)
.filter(|replica_idx| !self.crashed.contains(replica_idx))
.find_map(|replica_idx| {
self.replicas[usize::from(replica_idx)].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.map(consensus::VsrConsensus::view)
})
}
#[must_use]
pub(crate) fn primary_index(&self, namespace: IggyNamespace) -> Option<u8> {
(0..self.replica_count)
.filter(|replica_idx| !self.crashed.contains(replica_idx))
.find_map(|replica_idx| {
let partition = self.replicas[usize::from(replica_idx)]
.partition_shard(namespace)
.plane
.partitions()
.get_by_ns(&namespace)?;
let consensus = partition.consensus();
Some(consensus.primary_index(consensus.view()))
})
}
}
/// Materialises `namespace` on its hash-owning shard of one replica and stamps
/// the routing row on every shard of that replica.
///
/// Shared by [`SimCluster::init_partition`] and the restart path: a rebooted server
/// re-opens every partition directory it owns, so the sim must re-materialise too,
/// else the superblock a restart carries forward is never read back and the
/// recovered-view branch is dead code.
fn materialise_partition(
replica: &SimReplica,
namespace: IggyNamespace,
restore_frontier: bool,
created_view: u32,
) {
let shard_count = u32::try_from(replica.shards.len()).expect("shard count fits u32");
let owner = calculate_shard_assignment(&namespace, shard_count);
// Commit the namespace first: a partition the metadata plane never heard of is
// a shape production cannot produce, and the shard refuses client traffic whose
// routing-row epoch it cannot match against a committed `created_revision`.
let streams = replica.shards[0].plane.metadata().mux_stm.streams();
streams.seed_namespace(namespace, namespace.inner(), created_view);
// No committed revision means the seed could not re-add the namespace, which
// happens once a metadata workload has deleted its stream or topic: the seed's
// `CreatePartitions` is then a committed REJECTION rather than an error, so it
// reports nothing. Skip the group rather than build a partition no committed
// metadata names, as a rebooted server does not re-open a deleted partition's
// directory either. Before the build, so a skipped group leaves neither a
// partition nor a routing row behind.
let Some(epoch) = streams.created_revision_for_namespace(namespace) else {
return;
};
// Read back rather than trusted from the argument: the seed above is a no-op
// for a namespace a metadata op already committed, and production seeds from
// the committed partition, never from a live view.
let created_view = streams
.created_view_for_namespace(namespace)
.expect("a committed partition records its creation view");
// One store per group, minted on first materialisation and reused after, so the
// recorded view survives a replica restart.
let superblock = Rc::clone(
replica
.partition_superblocks
.borrow_mut()
.entry(namespace)
.or_default(),
);
let recovered_state = superblock
.read_latest_sync()
.and_then(|bytes| VsrState::try_from(bytes.as_slice()).ok());
// Hand back the log this group left behind, if it was materialised here before.
// Removed rather than cloned: the rebuilt partition becomes its sole owner, and
// a second materialisation with no restart between would otherwise resurrect a
// log the live partition has moved past.
let retained = replica.partition_logs.borrow_mut().remove(&namespace);
replica.shards[usize::from(owner)].init_partition(
namespace,
Some(superblock),
recovered_state,
retained,
restore_frontier,
PartitionMaterialisation::new(epoch, created_view),
);
for shard in &replica.shards {
shard.shards_table().insert(
namespace,
PartitionLocation::new(ShardId::new(owner), epoch),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::SimClient;
use crate::workload::apply_sim_commands;
use bytes::Bytes;
use consensus::Status;
use server_common::sharding::IggyNamespace;
/// Crashing the primary in a 5-node cluster: 4 survivors detect via
/// heartbeat timeout and elect a new primary via view change.
#[test]
fn view_change_after_primary_crash() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 5;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
// Register the client with the consensus cluster.
sim.register_client_with_primary(&client);
// Send a message through the primary (replica 0) to verify normal operation.
let msg = client.send_messages(ns, &[Bytes::from_static(b"before crash")]);
sim.submit_request(client_id, 0, msg.into_generic());
let mut got_reply = false;
for _ in 0..100 {
if !sim.step().is_empty() {
got_reply = true;
break;
}
}
assert!(got_reply, "expected reply before crash");
// Crash the primary.
sim.replica_crash(0);
// Run enough steps for the heartbeat timeout to fire
// and the view change to complete across 4 surviving replicas.
for _ in 0..800 {
sim.step();
}
// Verify that a new primary was elected in a higher view.
let mut new_primary_found = false;
for replica_idx in 1..replica_count {
let replica = &sim.replicas[replica_idx as usize].shards[0];
let partitions = replica.plane.partitions();
let consensus = partitions
.get_by_ns(&ns)
.expect("partition must exist on every live replica")
.consensus();
if consensus.view() > 0
&& consensus.status() == Status::Normal
&& consensus.is_primary()
{
new_primary_found = true;
}
}
assert!(
new_primary_found,
"expected a new primary after crashing replica 0"
);
// Submit a request to the new primary and verify it commits.
let c = sim.replicas[1].shards[0]
.plane
.partitions()
.get_by_ns(&ns)
.expect("partition must exist on replica 1")
.consensus();
let new_primary_idx = c.primary_index(c.view());
let msg2 = client.send_messages(ns, &[Bytes::from_static(b"after view change")]);
sim.submit_request(client_id, new_primary_idx, msg2.into_generic());
let mut got_reply_after = false;
for _ in 0..200 {
if !sim.step().is_empty() {
got_reply_after = true;
break;
}
}
assert!(
got_reply_after,
"expected reply from new primary after view change"
);
}
/// A partition group materialised AFTER a metadata election starts in the
/// metadata plane's view, so both planes name the same primary.
///
/// Left at view 0 the group names replica 0 whatever the metadata plane has
/// got to. Nothing on the wire can express a partition primary
/// (`ClusterNode` carries one cluster-wide `role`) and partition ops route
/// within a node rather than to a peer, so the node clients are sent to
/// refuses every write to that group and the SDK burns its budget
/// rediscovering the same wrong answer.
///
/// The simulator reaches this where the integration tests cannot: no
/// client, no transport, just the two planes' views read directly. It is
/// also the only place the SEED itself is asserted rather than inferred
/// from a send succeeding.
#[test]
fn given_a_metadata_election_when_a_group_materialises_should_seed_the_metadata_view() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
// Move the metadata plane off view 0 by crashing its view-0 primary.
// Nothing has been written, so no partition group exists to move with
// it: the group created below is genuinely fresh.
sim.replica_crash(0);
for _ in 0..800 {
sim.step();
}
let metadata_primary = sim
.metadata_primary_index()
.expect("a live replica must own metadata consensus");
assert_ne!(
metadata_primary, 0,
"crashing replica 0 must have moved the metadata plane off view 0; with the \
primary back at replica 0 both planes agree and the split cannot show"
);
// Materialise a brand-new group. Every live replica seeds from the view
// the create was committed in, which production reads off the committed
// partition.
let namespace = IggyNamespace::new(1, 1, 0);
sim.init_partition(namespace);
let partition_primary = sim
.primary_index(namespace)
.expect("the group must exist on a live replica after materialisation");
assert_eq!(
partition_primary, metadata_primary,
"a group materialised after a metadata election must name the same primary as the \
metadata plane; seeded at view 0 instead it names replica 0, which no client can \
be routed to"
);
// Per replica, not just the aggregate: the seed is read locally on each
// one, so a single replica left at view 0 would still elect itself
// primary of that group while its peers disagree, and the aggregate
// read above would not see it.
for replica_idx in 0..replica_count {
if sim.is_crashed(replica_idx) {
continue;
}
let state = sim
.partition_consensus_state(usize::from(replica_idx), namespace)
.expect("a live replica must host the freshly materialised group");
// Compared as primaries, not as view numbers: the two coincide only
// while the view is below `replica_count`, and pinning the view
// itself would make this fail on a second election for no reason.
let seeded_primary = u8::try_from(state.view % u32::from(replica_count))
.expect("a value modulo replica_count fits the u8 replica_count");
assert_eq!(
seeded_primary, metadata_primary,
"replica {replica_idx} seeded its group at view {}, naming replica \
{seeded_primary} primary while the metadata plane names {metadata_primary}; \
replicas that seed different views disagree on their own group's primary",
state.view
);
}
}
/// A replica that missed a group's creation materialises it after the
/// metadata plane elected again. It must seed the view the group was CREATED
/// in, not the live metadata view: seeded above the group's real view, its
/// empty log outranks every peer in the next DVC merge and the committed ops
/// collect a nack quorum, wedging the group for good.
#[test]
fn given_a_late_materialiser_when_the_metadata_view_moved_on_should_seed_the_creation_view() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let settle = |sim: &mut Simulator| {
for _ in 0..800 {
sim.step();
}
};
let reply_within = |sim: &mut Simulator, budget: usize| {
(0..budget).find_map(|_| {
let replies = sim.step();
(!replies.is_empty()).then(|| replies[0].deep_copy())
})
};
// Replica 0 is down while the group is created, at a metadata view its
// crash moved off 0.
sim.replica_crash(0);
settle(&mut sim);
let namespace = IggyNamespace::new(1, 1, 0);
sim.init_partition(namespace);
let created_view = sim
.partition_consensus_state(1, namespace)
.expect("replica 1 materialised the group")
.view;
assert!(
created_view > 0,
"crashing replica 0 must have moved the metadata plane off view 0, or a \
creation-view seed is indistinguishable from a view-0 start"
);
// Commit a write, so the group holds history a wrong seed could outrank.
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
let primary = sim
.primary_index(namespace)
.expect("a live replica hosts the group");
let request = client.send_messages(namespace, &[Bytes::from_static(b"before")]);
sim.submit_request(client_id, primary, request.into_generic());
reply_within(&mut sim, 200).expect("the write commits on the fresh group");
// Replica 0 returns without the group, then the metadata plane elects
// again, so its live view now exceeds the view the group was created in.
sim.replica_restart(0);
settle(&mut sim);
let metadata_primary = sim
.metadata_primary_index()
.expect("a live replica owns metadata consensus");
sim.replica_crash(metadata_primary);
settle(&mut sim);
let moved_view = sim
.metadata_view()
.expect("a live replica owns metadata consensus");
assert!(
moved_view > created_view,
"the second election must move the metadata view ({moved_view}) past the \
group's creation view ({created_view})"
);
// Replica 0 materialises the group now.
sim.init_partition(namespace);
let seeded = sim
.partition_consensus_state(0, namespace)
.expect("replica 0 materialised the group")
.view;
assert_eq!(
seeded, created_view,
"a late materialiser must seed the group's creation view, not the live metadata \
view {moved_view}: above the group's real view its empty log wins the next merge"
);
// With replica 0 in, the group has a quorum again: it elects past its
// dead primary and commits a new write.
let settled_primary = (0..4)
.find_map(|_| {
settle(&mut sim);
(0..replica_count)
.filter(|replica_idx| !sim.is_crashed(*replica_idx))
.find(|replica_idx| {
sim.partition_consensus_state(usize::from(*replica_idx), namespace)
.is_some_and(|state| state.status == Status::Normal && state.is_primary)
})
})
.expect("the group must elect a live primary once replica 0 joins it");
let request = client.send_messages(namespace, &[Bytes::from_static(b"after")]);
sim.submit_request(client_id, settled_primary, request.into_generic());
reply_within(&mut sim, 400).expect(
"a write must commit after the late materialiser joined; a wedged merge \
answers nothing",
);
}
/// A replica that advanced its view, persisted it through the superblock gate,
/// then crashed recovers that view from its own disk, not a fresh 0. The
/// split-brain guarantee: a replica never forgets a view it acted in. Impossible
/// before the superblock, a rebuilt consensus starting at view 0.
#[test]
fn given_advanced_view_when_metadata_replica_restarts_should_recover_view_from_superblock() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 5;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
// Metadata consensus view of a replica's shard 0, if it owns one.
let metadata_view = |sim: &Simulator, replica: u8| -> Option<u32> {
let consensus = sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.consensus
.as_ref()?;
Some(consensus.view())
};
// Crash the metadata primary (replica 0) to force a view change on the
// survivors; each persists the new view through the gate.
sim.replica_crash(0);
for _ in 0..800 {
sim.step();
}
// Find the new metadata primary, elected at view >= 1.
let primary = (1..replica_count)
.find(|&replica| {
sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.is_some_and(|consensus| {
consensus.view() > 0
&& consensus.status() == Status::Normal
&& consensus.is_primary()
})
})
.expect("a metadata primary elected at view >= 1 after crashing replica 0");
let view_before = metadata_view(&sim, primary).expect("primary owns metadata consensus");
assert!(
view_before >= 1,
"expected an advanced view, got {view_before}"
);
// Crash and restart the new primary. Restart drops its shards, losing the
// in-memory view, and rebuilds from the retained superblock.
sim.replica_crash(primary);
sim.replica_restart(primary);
// It recovered its persisted view from the superblock, not a fresh 0.
let view_after = metadata_view(&sim, primary).expect("restarted primary owns consensus");
assert_eq!(
view_after, view_before,
"restarted replica must recover its persisted view from the superblock, not reset to 0"
);
// Zero traffic, so every WAL is empty and the recovered view came from the
// superblock alone. Pinned, since it is what makes the gate below
// load-bearing.
assert!(
sim.replicas[primary as usize]
.metadata_journal
.last_op()
.is_none(),
"no metadata traffic in this test, so the restarted replica's WAL must be empty"
);
// A recovered view is a prior life even with an empty WAL, so the replica
// rejoins as a probing backup. Still primary-by-index for that view, so
// resuming primaryship would have it act as primary in a view the survivors
// may already have left, with no probe to correct it.
let restarted = sim.replicas[primary as usize].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.expect("restarted primary owns metadata consensus");
assert!(
restarted.is_primary(),
"the restarted replica is still primary-by-index for the recovered view, \
which is what makes ceding it necessary"
);
assert_eq!(
restarted.status(),
Status::Recovering,
"a replica with a recovered view must probe for the current view, not \
resume primaryship"
);
assert!(
restarted.has_ceded_primaryship(),
"a probing replica must be quorum-invisible until a StartView brings it \
forward"
);
}
#[test]
fn given_superblock_write_fails_when_primary_crashes_should_withhold_votes_and_not_elect() {
// The gate under test: a view-scoped send (SVC, DVC, StartView) must not go
// out until the new view is durable. Failing one survivor's superblock writes
// makes its persist gate return false, advancing its view in-memory while
// withholding every view-scoped send. Quorum 2 of 3, so crashing the primary
// leaves one working survivor whose lone vote cannot reach quorum and NO new
// primary is elected. Sending before persisting would instead elect a primary
// with no durable record of the new view, reintroducing split-brain on its
// restart. Positive control:
// `given_advanced_view_when_metadata_replica_restarts_...`, same crash with
// healthy superblocks.
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
// Replica 1 survives and is primary-by-index for view 1. Failing its
// superblock keeps it from durably taking any new view, so it never emits a
// view-scoped vote.
sim.replicas[1].superblock.set_fail_writes();
sim.replica_crash(0);
for _ in 0..800 {
sim.step();
}
for replica in 1..replica_count {
let elected = sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.is_some_and(|consensus| {
consensus.view() > 0
&& consensus.status() == Status::Normal
&& consensus.is_primary()
});
assert!(
!elected,
"replica {replica} must not be elected primary while a survivor's \
superblock write fails: the persist gate withholds its view-scoped \
votes, so quorum cannot form"
);
}
// The faulted replica never durably recorded a new view, so a restart recovers
// its old view, never one it might have voted in.
let faulted = sim.replicas[1].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.expect("replica 1 owns metadata consensus");
assert!(
faulted.view() > 0,
"the gate must withhold the SEND, not the view advance: with the view still \
at 0 there was nothing to persist and this test would pass vacuously"
);
assert_eq!(
sim.replicas[1].superblock.read_latest_sync(),
None,
"a failing superblock persists nothing, so no new view is durable"
);
// Bounded retry. Without a backoff the 10 ms consensus tick would run a full
// `atomic_replace` (create, write, fsync, rename, dir fsync) every tick for as
// long as the disk stays broken, on the executor that serves partition
// traffic too.
let attempts = sim.replicas[1].shards[0]
.plane
.metadata()
.superblock_write_failures();
assert!(attempts > 0, "the gate must have attempted a persist");
assert!(
attempts < 40,
"superblock writes must back off, not retry every tick (attempted \
{attempts} times)"
);
}
/// Determinism: fresh simulator + workload from the same seed (network
/// and workload) produces an identical reply-header sequence.
#[test]
fn workload_replay_is_deterministic() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let h1 = workload_hash_for_seed(0xDEAD_BEEF);
let h2 = workload_hash_for_seed(0xDEAD_BEEF);
assert_eq!(
h1, h2,
"workload reply hash diverged across runs with the same seed"
);
// Sanity: a different seed should generally produce a different
// trace. (Theoretically possible to collide, but vanishingly so.)
let h3 = workload_hash_for_seed(0xCAFE_BABE);
assert_ne!(
h1, h3,
"different seeds produced identical reply hashes; determinism collapsed"
);
// Fragile cross-run baseline, pinned to seed 0xDEAD_BEEF under the default
// `ActionWeights`. Drifts on any change to reply shape, partition commit
// values, or PRNG draw order. Draw order is sensitive to `pick_outcome`, so
// adding an outcome to an op sampled in this window, or bumping a weight,
// shifts the trace. Expect re-locks until error discriminants and reply bodies
// stabilize the wire format.
//
// Re-locked for: METADATA_GROUP (1<<63) replacing the sim-only 0, moving both
// reply headers and the `replica_id ^ namespace` jitter seed; replies dropping
// the group id from the client wire; the v1 consumer-offset ops being removed,
// shifting `Action` discriminants and draw order; those ops drawing the WIRE
// consumer kind (1 / 2) instead of a bare boolean, kind 0 being no
// `WireConsumer` discriminant so every such request was dropped unparsed (see
// `ops::sample_consumer_kind`); and per-stream seeds moving from XOR salts to
// [`SimSeeds`] alongside `Xoshiro256Plus` becoming `Xoshiro256PlusPlus`, which
// together remap every stream; and partition ops drawing from the one shared
// request counter instead of a separate sequence based at `1<<63`, which
// renumbers every partition request id and so every reply header in the trace.
assert_eq!(
h1, 0x5C2B_6057_2DA9_908B,
"workload reply hash drifted from locked baseline"
);
}
/// After a mixed metadata and partition run drains, the shadow's predicted
/// streams equal the metadata committed on the leader.
///
/// Interleaves creates, deletes and partition sends from one client. A send
/// between two metadata ops once consumed a metadata request number, gapping the
/// next create; `SimClient`'s per-plane numbering keeps the metadata sequence
/// contiguous, so the mix drains and the auditor never misattributes a partition
/// reply to a metadata entry. Compares
/// against the leader only: a quorum-excluded backup has no idle catch-up, so
/// full cross-replica equality stays deferred (see [`oracle`]).
#[test]
fn quiesce_stream_entity_oracle_matches_leader() {
use crate::workload::{
self, Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
oracle,
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0xC0FF_EE00,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = client::SimClient::new(client_id);
let ns_a = server_common::sharding::IggyNamespace::new(1, 1, 0);
sim.init_partition(ns_a);
sim.register_client_with_primary(&client);
let mut options = WorkloadOptions::new(0xC0FF_EE00, replica_count, vec![ns_a]);
// The send between two metadata ops is the case that previously wedged the
// next create. Sends do not touch the metadata entity sets, so the oracle
// below still compares stream state cleanly.
options.weights = ActionWeights::new(&[
(Action::CreateStream, 50),
(Action::DeleteStream, 25),
(Action::SendMessages, 25),
]);
let mut wl = Workload::new(options);
let clients = [client];
let replies = workload::run(&mut sim, &mut wl, &clients, 2_000, u64::MAX);
assert!(replies > 0, "workload produced no replies");
assert!(
oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000),
"system did not drain within the tick budget"
);
// Cross-replica agreement + entity oracle (single client => strict).
oracle::assert_converged(&sim, &mut wl);
}
/// Under follower crashes the surviving quorum still drains and
/// agrees on the partition commit offset at quiesce.
#[test]
fn quiesce_partition_offsets_converge_under_crashes() {
use crate::workload::{
self, Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
oracle,
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 5;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0xC0FF_EE00,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = client::SimClient::new(client_id);
let ns_a = server_common::sharding::IggyNamespace::new(1, 1, 0);
sim.init_partition(ns_a);
sim.register_client_with_primary(&client);
// Partition-only by design, isolating partition-offset convergence from
// metadata lost to a crashed primary; the entity oracle runs in the no-crash
// test. One follower crashed with quorum slack (5 replicas, floor 4, quorum
// 3), so commits still reach quorum and the run drains.
let mut options = WorkloadOptions::new(0xC0FF_EE00, replica_count, vec![ns_a]);
options.weights = ActionWeights::new(&[(Action::SendMessages, 100)]);
options.crash_per_tick_ratio = 0.05;
options.min_survivors = 4;
let mut wl = Workload::new(options);
let clients = [client];
let replies = workload::run(&mut sim, &mut wl, &clients, 3_000, u64::MAX);
assert!(replies > 0, "workload produced no replies");
assert!(
!sim.crashed.is_empty(),
"expected at least one follower crash"
);
assert!(
oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000),
"surviving quorum did not drain within the tick budget"
);
oracle::assert_converged(&sim, &mut wl);
}
/// A lossy network drains, because the client resends.
///
/// Without [`workload::Workload::due_resends`] this wedges immediately and
/// permanently: one in-flight slot per client, nothing times out, so the first
/// dropped request or reply strands that slot for the run. At 5% loss a 3000-tick
/// run drained a handful of replies and stopped, and `drive_to_quiesce` could
/// never finish, waiting on a reply the network had discarded.
///
/// Asserts the resend path ran rather than the seed getting lucky.
#[test]
fn packet_loss_resends_and_drains() {
use crate::workload::{
self, Workload,
options::{ActionWeights, WorkloadOptions},
oracle,
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let seed = 0x105_5A1A;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
packet_loss_probability: 0.05,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
network_opts,
);
let client = client::SimClient::new(client_id);
let ns_a = server_common::sharding::IggyNamespace::new(1, 1, 0);
sim.init_partition(ns_a);
sim.register_client_with_primary(&client);
let mut options = WorkloadOptions::new(seed, replica_count, vec![ns_a]);
options.weights = ActionWeights::partition_only();
let mut wl = Workload::new(options);
let clients = [client];
let replies = workload::run(&mut sim, &mut wl, &clients, 3_000, u64::MAX);
assert!(replies > 0, "lossy workload produced no replies");
assert!(
wl.resends() > 0,
"no request timed out at 5% packet loss, so the resend path never ran; \
raise the loss rate or lower request_timeout_ticks"
);
assert!(
oracle::drive_to_quiesce(&mut sim, &mut wl, 20_000),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
oracle::assert_converged(&sim, &mut wl);
}
fn workload_hash_for_seed(seed: u64) -> u64 {
workload_hash(seed, 1).0
}
/// Reply-trace and executor-schedule hashes for a full workload run at
/// `shards_per_replica` shards. Shared by the single-shard locked
/// baseline and the multi-shard replay tests.
fn workload_hash(seed: u64, shards_per_replica: u16) -> (u64, u64) {
use crate::workload::{
Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
};
use std::hash::{DefaultHasher, Hash, Hasher};
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards(
replica_count as usize,
shards_per_replica,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
let ns_a = IggyNamespace::new(1, 1, 0);
let ns_b = IggyNamespace::new(1, 1, 1);
sim.init_partition(ns_a);
sim.init_partition(ns_b);
sim.register_client_with_primary(&client);
let mut options = WorkloadOptions::new(seed, replica_count, vec![ns_a, ns_b]);
options.weights = ActionWeights::new(&[
(Action::CreateStream, 5),
(Action::SendMessages, 70),
(Action::StoreConsumerOffset, 25),
]);
let mut wl = Workload::new(options);
let mut hasher = DefaultHasher::new();
let mut replies_seen = 0u64;
// Inline driver: hash each reply tuple, so divergence is caught at the first
// non-matching reply rather than in end-of-run aggregates. The cap sits inside
// the per-reply loop so a multi-reply tick at `replies_seen=49` cannot leak a
// 50th into the hash.
'outer: for _tick in 0..5_000u32 {
if let Some((target, msg)) = wl.build_request(&client) {
sim.submit_request(client.client_id(), target, msg.into_generic());
}
for reply in sim.step() {
let h = reply.header();
(h.client, h.request, h.op, h.commit, h.operation as u8).hash(&mut hasher);
let cmds = wl.on_reply(&reply);
apply_sim_commands(&mut sim, &cmds);
replies_seen += 1;
if replies_seen >= 50 {
break 'outer;
}
}
}
assert!(
replies_seen > 0,
"workload produced no replies; driver / sim wiring is broken"
);
replies_seen.hash(&mut hasher);
wl.shadow.sends_committed(ns_a).hash(&mut hasher);
wl.shadow.sends_committed(ns_b).hash(&mut hasher);
// Catches PRNG-trace shifts from `sample` returning `None`.
// Stays 0 on the current seed mix; non-zero drifts the baseline.
wl.samples_none().hash(&mut hasher);
(hasher.finish(), sim.schedule_hash())
}
/// No shard of any replica dropped an inter-shard frame. Runs without injected
/// loss must keep the counters at zero; non-zero means an inbox silently shed a
/// frame, from undersized capacity or a routing bug, which would otherwise hide
/// behind VSR retransmit.
///
/// `park_overflow` is deliberately NOT excluded. The reconciler is unwired here
/// and only an explicit `init_partition` mirrors its materialisation outcome and
/// drains the matching park entry. Non-zero `park_overflow` therefore means
/// frames shed for a namespace the driver never materialised, which is the fault
/// class this catches rather than the back-pressure it would be in production.
///
/// Same reason the park buffer must be empty at quiescence: a frame still parked
/// has no drainer, so it is neither delivered nor answered.
fn assert_no_frame_drops(sim: &Simulator) {
for (replica_idx, replica) in sim.replicas.iter().enumerate() {
for (shard_idx, shard) in replica.shards.iter().enumerate() {
assert_eq!(
shard.metrics().frame_drops_value(),
0,
"replica {replica_idx} shard {shard_idx} dropped frames without injected loss"
);
assert!(
shard.parked_namespaces().is_empty(),
"replica {replica_idx} shard {shard_idx} left partition frames parked; the \
simulator wires no reconciler, so only explicit materialisation can \
deliver or answer them"
);
}
}
}
/// Drive a full dispatch-shell round-trip for `seed`: seed a partition
/// plus its metadata, log a client in against root, produce one message,
/// then poll. Returns the poll reply's raw bytes and the schedule hash.
fn shell_produce_poll(seed: u64) -> (Vec<u8>, u64) {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: 3,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards_shell(3, 1, std::iter::once(client_id), network_opts);
let ns = IggyNamespace::new(0, 0, 0);
sim.init_partition(ns);
sim.seed_stream_topic_partition(ns);
let client = SimClient::new(client_id);
sim.shell_login(&client);
// Produce through the shell too: `SimClient` emits the legacy
// `SendMessagesHeader` shape the real SDK sends, so
// `resolve_partition_request_namespace` decodes it on the
// `handle_client_request` path. Write and poll both hit real dispatch.
let payload = Bytes::from_static(b"shell-poll-payload");
let produce = client.send_messages(ns, std::slice::from_ref(&payload));
sim.submit_request(client_id, 0, produce.into_generic());
for _ in 0..200 {
sim.step();
}
// Poll through the dispatch shell (`on_client_request`, drain,
// `handle_poll_messages`, `partition_read`, `on_partition_read`), running as a
// task the executor interleaves with the pump.
let poll = client.poll_messages(ns, 10);
sim.submit_request(client_id, 0, poll.into_generic());
let mut poll_reply = None;
for _ in 0..200 {
if let Some(reply) = sim.step().into_iter().next() {
poll_reply = Some(reply);
break;
}
}
let poll_reply = poll_reply.expect("shell poll: no reply within 200 steps");
(poll_reply.as_slice().to_vec(), sim.schedule_hash())
}
/// A `SimClient` poll returns the produced messages through the real dispatch
/// read path (`on_client_request`, `handle_poll_messages`, `partition_read`,
/// `on_partition_read`), running as a task the executor interleaves with the pump,
/// and the whole login/produce/poll round-trip replays byte-for-byte on one seed.
#[test]
fn shell_poll_returns_produced_messages_deterministically() {
const PAYLOAD: &[u8] = b"shell-poll-payload";
let (reply_a, schedule_a) = shell_produce_poll(0x5CED_0011);
let (reply_b, schedule_b) = shell_produce_poll(0x5CED_0011);
assert!(
reply_a
.windows(PAYLOAD.len())
.any(|window| window == PAYLOAD),
"poll reply did not carry the produced payload through the real read handler"
);
assert!(
reply_b
.windows(PAYLOAD.len())
.any(|window| window == PAYLOAD),
"second run's poll reply did not carry the produced payload"
);
// The produce stamps a random UUID per message (`random_id::get_uuid`), so
// reply bytes differ run to run while the seeded executor schedule must
// replay. Same reason the workload tests hash reply headers, not bodies.
assert_eq!(
schedule_a, schedule_b,
"shell schedule diverged at same seed"
);
}
fn successful_send_reply_count(replies: &[Message<ReplyHeader>]) -> usize {
replies
.iter()
.filter(|reply| {
reply.header().operation == iggy_binary_protocol::Operation::SendMessages
&& reply.header().status == 0
})
.count()
}
fn retained_prepare(
sim: &Simulator,
replica: usize,
namespace: IggyNamespace,
op: u64,
) -> Message<iggy_binary_protocol::PrepareHeader> {
let partitions = sim.replicas[replica]
.partition_shard(namespace)
.plane
.partitions();
let partition = partitions.get_by_ns(&namespace).expect("partition");
let stored = partition
.log
.journal()
.inner
.repair_entry(op)
.unwrap_or_else(|| panic!("replica {replica} retains prepare op {op} for repair"));
Message::<iggy_binary_protocol::PrepareHeader>::try_from(server_common::iobuf::Owned::<
{ server_common::MESSAGE_ALIGN },
>::copy_from_slice(
stored.as_slice()
))
.expect("journaled prepare remains a valid frame")
}
/// Leave one backup without the partition while the primary commits two
/// sends through the other backup. The lagging replica parks both replicated
/// prepares. A third prepare is withheld from it, then placed on its inbox
/// after simulator materialisation stages the parked prefix. Running the pump
/// without advancing its tick forces the inbox arm to drain the prefix first.
fn parked_prepare_redispatch_trace(seed: u64) -> (usize, Vec<PartitionOffsets>, u64) {
const CLIENT_ID: u128 = 1;
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let network_opts = packet::PacketSimulatorOptions {
node_count: 3,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards_shell(3, 1, std::iter::once(CLIENT_ID), network_opts);
let namespace = IggyNamespace::new(0, 0, 0);
sim.seed_stream_topic_partition(namespace);
let created_view = sim.partition_created_views[&namespace];
// Replica 0 is the view-0 primary. Replica 2 supplies quorum while
// replica 1 has committed metadata but no local partition yet.
materialise_partition(&sim.replicas[0], namespace, false, created_view);
materialise_partition(&sim.replicas[2], namespace, false, created_view);
let client = SimClient::new(CLIENT_ID);
sim.shell_login(&client);
// Both sends in flight at once. The simulated link delays each packet
// independently, so the lower request id can reach the primary after
// the higher one committed; the dedup slice's committed-id window is
// what keeps that reordered arrival a new write rather than an absorbed
// duplicate, and this loop is the check that both payloads commit.
for payload in [
Bytes::from_static(b"parked-redispatch-0"),
Bytes::from_static(b"parked-redispatch-1"),
] {
let request = client.send_messages(namespace, std::slice::from_ref(&payload));
sim.submit_request(CLIENT_ID, 0, request.into_generic());
}
let lagging_shard = Rc::clone(&sim.replicas[1].shards[0]);
let mut successful_replies = 0usize;
let mut parked = 0usize;
for _ in 0..500 {
successful_replies += successful_send_reply_count(&sim.step());
parked = lagging_shard.parked_frame_count(namespace);
if successful_replies >= 2 && parked >= 2 {
break;
}
}
assert!(
successful_replies >= 2,
"primary never committed both sends"
);
assert!(parked >= 2, "lagging backup never parked both prepares");
// Keep the third prepare off replica 1's network path. Replica 0 can
// still commit it through replica 2, after which its journal supplies
// the exact wire frame to put on the lagging backup's inbox below.
sim.network.process_disable(ProcessId::Replica(1));
let third = client.send_messages(namespace, &[Bytes::from_static(b"parked-redispatch-2")]);
sim.submit_request(CLIENT_ID, 0, third.into_generic());
for _ in 0..500 {
successful_replies += successful_send_reply_count(&sim.step());
if successful_replies >= 3 {
break;
}
}
assert!(
successful_replies >= 3,
"primary never committed the later send"
);
let later_prepare = retained_prepare(&sim, 0, namespace, 3);
sim.network.process_enable(ProcessId::Replica(1));
materialise_partition(&sim.replicas[1], namespace, false, created_view);
assert_eq!(lagging_shard.parked_frame_count(namespace), 0);
assert_eq!(
lagging_shard.redispatched_frame_count(),
parked,
"materialisation must move every parked prepare to the pump queue"
);
// No virtual-time advance here. The materialisation marker and inbox
// wake poll the pump, whose ranked redispatch arm must put ops 1 and 2
// ahead of this op 3 frame.
lagging_shard.dispatch(later_prepare.into_generic());
sim.run_pumps();
let lagging_holds_later = sim.replicas[1]
.partition_shard(namespace)
.plane
.partitions()
.get_by_ns(&namespace)
.is_some_and(|partition| partition.log.journal().inner.header_by_op(3).is_some());
assert!(
lagging_holds_later,
"inbox op 3 reached the gap check before the redispatched prefix"
);
let expected = sim
.offsets(0, namespace)
.expect("primary partition offsets");
let mut converged = false;
for _ in 0..500 {
sim.step();
converged = (0..3).all(|replica| sim.offsets(replica, namespace) == Some(expected));
if converged {
break;
}
}
assert!(
converged,
"redispatched prepares did not converge the backup"
);
assert_eq!(lagging_shard.redispatched_frame_count(), 0);
assert_no_frame_drops(&sim);
let offsets = (0..3)
.map(|replica| sim.offsets(replica, namespace).expect("partition offsets"))
.collect();
(parked, offsets, sim.schedule_hash())
}
#[test]
fn parked_prepare_redispatch_is_seed_replayable() {
let first = parked_prepare_redispatch_trace(0x5CED_4005);
let second = parked_prepare_redispatch_trace(0x5CED_4005);
assert_eq!(first, second, "same seed diverged on parked redispatch");
}
#[test]
fn redispatched_solo_request_commits_without_another_inbox_frame() {
const CLIENT_ID: u128 = 1;
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let network_opts = packet::PacketSimulatorOptions {
node_count: 1,
client_count: 1,
seed: 0x5CED_4006,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards_shell(1, 1, std::iter::once(CLIENT_ID), network_opts);
let namespace = IggyNamespace::new(0, 0, 0);
sim.seed_stream_topic_partition(namespace);
let client = SimClient::new(CLIENT_ID);
sim.shell_login(&client);
let request = client.send_messages(namespace, &[Bytes::from_static(b"solo-redispatch")]);
let shard = Rc::clone(&sim.replicas[0].shards[0]);
let request = server_common::MessageBag::try_from(request.into_generic())
.expect("valid send request");
futures::executor::block_on(shard.on_message(request));
assert_eq!(
shard.parked_frame_count(namespace),
1,
"the request must park before materialisation"
);
sim.init_partition(namespace);
assert_eq!(
shard.redispatched_frame_count(),
1,
"materialisation must stage and wake the parked request"
);
// No network or virtual-time step follows materialisation. The ranked
// redispatch arm must deliver this request and process its self-ack in
// the same pump iteration.
sim.run_pumps();
let state = sim
.partition_consensus_state(0, namespace)
.expect("the materialised solo partition has consensus state");
assert_eq!(state.commit_min, 1, "the self-ack must commit the request");
assert_eq!(shard.redispatched_frame_count(), 0);
}
/// The dispatch shell's reason to exist: detect the PR #3557 async-concurrency
/// class, a partition reference held across an `.await` while a sibling task
/// mutates the partitions vec. Under the deterministic executor a parked read with
/// a live borrow IS a borrow held across a suspension, so a concurrent `remove`
/// trips the `#[cfg(debug_assertions)]` tripwire. The correct `with_partition`
/// read drops the borrow first, so the same interleaving is sound. Debug-only, as
/// is the `BorrowGuard` it rides on.
///
/// Injected through the synthetic `hold_borrow_across_await` rather than the real
/// read, because the production read has no borrow-holding suspension to seed: the
/// journal read is a synchronous memory copy, and `with_partition` returns an owned
/// `PollPlan` before the only awaits (disk read, offset persist) run off the borrow
/// in `spawn_poll_io`.
///
/// TODO: once storage faults are modelled, the disk-tier read
/// (`PollPlan::execute`, `read_disk`) becomes a real seedable await in the read
/// path. A regression holding a borrow across it, against a concurrent reconcile
/// `InsertOwned` reallocation, would trip this through the real handler and retire
/// the synthetic seam.
#[cfg(debug_assertions)]
#[test]
fn shell_detects_partition_borrow_held_across_await() {
use crate::executor::DetExecutor;
use consensus::PartitionsHandle;
use std::panic::{AssertUnwindSafe, catch_unwind};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let network_opts = packet::PacketSimulatorOptions {
node_count: 3,
client_count: 1,
seed: 0x5CED_0021,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(3, std::iter::once(1u128), network_opts);
let ns_a = IggyNamespace::new(0, 0, 0);
let ns_b = IggyNamespace::new(0, 0, 1);
sim.init_partition(ns_a);
sim.init_partition(ns_b);
// BAD read: holds a partition borrow across a suspension. The mutator runs
// while it is parked with the borrow live, so the tripwire fires.
// `catch_unwind` builds the executor inline, so unwinding drops the parked
// read's guard and restores the borrow count for the next case.
let tripped = catch_unwind(AssertUnwindSafe(|| {
let mut executor = DetExecutor::new(7);
let read = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
read.plane
.partitions()
.hold_borrow_across_await(std::future::pending())
.await;
});
executor.run_until_stalled(POLL_BUDGET); // borrow acquired; task parks
let mutate = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
mutate.plane.partitions().remove(&ns_b);
});
executor.run_until_stalled(POLL_BUDGET); // mutate while borrow live
}))
.is_err();
assert!(
tripped,
"borrow-held-across-await went undetected: the concurrent mutation \
did not trip the #3557 borrow tripwire under the executor"
);
// The tripwire fires BEFORE `remove` touches the vec, its assert being the
// first statement, so the detector aborts the mutation that would have
// dangled the live borrow. Both partitions survive intact: the class is caught
// before it can corrupt state.
assert!(
sim.offsets(0, ns_a).is_some() && sim.offsets(0, ns_b).is_some(),
"tripwire must abort the mutation before it corrupts the partitions vec"
);
// REAL read: `with_partition` scopes the borrow, dropping it before the
// suspension, so the identical interleaving is sound (no tripwire).
let mut executor = DetExecutor::new(7);
let read = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
let _ = read
.plane
.partitions()
.with_partition(&ns_a, |_partition| ());
std::future::pending::<()>().await;
});
executor.run_until_stalled(POLL_BUDGET);
let mutate = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
mutate.plane.partitions().remove(&ns_b);
});
executor.run_until_stalled(POLL_BUDGET);
// The mutation the bad read's tripwire aborted now applies cleanly, no borrow
// being live across the suspension: `ns_b` gone, `ns_a` intact, so the correct
// read is sound under the same schedule.
assert!(
sim.offsets(0, ns_a).is_some() && sim.offsets(0, ns_b).is_none(),
"correct with_partition read must leave the concurrent remove sound"
);
}
/// The realloc half of the PR #3557 class, and the stronger one: a pump `insert`
/// that grows the partitions vec MOVES every element, so a stale reference to ANY
/// partition dangles, not just the one a `swap_remove` displaced.
/// `shell_detects_partition_borrow_held_across_await` covers the remove; this
/// covers the grow, on the same two-task interleave (reader parked with a live
/// borrow, pump-shaped task mutating the container under it).
///
/// The buffer address is asserted to have MOVED, so this cannot pass on a push
/// into spare capacity, which relocates nothing. Debug-only, like the
/// `BorrowGuard` it drives.
#[cfg(debug_assertions)]
#[test]
fn shell_detects_partition_borrow_held_across_a_pump_realloc() {
use crate::executor::DetExecutor;
use consensus::PartitionsHandle;
use std::panic::{AssertUnwindSafe, catch_unwind};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let network_opts = packet::PacketSimulatorOptions {
node_count: 3,
client_count: 1,
seed: 0x5CED_0022,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(3, std::iter::once(1u128), network_opts);
let ns_a = IggyNamespace::new(0, 0, 0);
let ns_b = IggyNamespace::new(0, 0, 1);
// The namespace the pump grows the vec with. Never materialised up front:
// inserting it IS the mutation under test.
let ns_grow = IggyNamespace::new(0, 0, 2);
sim.init_partition(ns_a);
sim.init_partition(ns_b);
// BAD read: the borrow is live across the suspension, so the pump's growing
// insert lands while a stale reference to every partition is outstanding.
// `catch_unwind` builds the executor inline, so unwinding drops the parked
// read's guard and restores the borrow count.
let tripped = catch_unwind(AssertUnwindSafe(|| {
let mut executor = DetExecutor::new(11);
let read = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
read.plane
.partitions()
.hold_borrow_across_await(std::future::pending())
.await;
});
executor.run_until_stalled(POLL_BUDGET); // borrow acquired; task parks
let grow = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
grow.init_partition(
ns_grow,
None,
None,
None,
false,
PartitionMaterialisation::new(0, 0),
);
});
executor.run_until_stalled(POLL_BUDGET); // grow while the borrow is live
}))
.is_err();
assert!(
tripped,
"a pump realloc under a live partition borrow went undetected: the \
#3557 tripwire did not fire on the growing insert"
);
// The tripwire asserts before `push`, so the vec is untouched: the two
// originals survive and the grow namespace never materialised.
let partitions = sim.replicas[0].shards[0].plane.partitions();
assert_eq!(
partitions.len(),
2,
"tripwire must abort the insert before it relocates the vec"
);
assert!(!partitions.contains(&ns_grow));
// REAL read: `with_partition` drops the borrow before the suspension, so
// the identical schedule is sound and the grow applies.
let addr_before = partitions.buffer_addr();
let mut executor = DetExecutor::new(11);
let read = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
let _ = read
.plane
.partitions()
.with_partition(&ns_a, |_partition| ());
std::future::pending::<()>().await;
});
executor.run_until_stalled(POLL_BUDGET);
let grow = Rc::clone(&sim.replicas[0].shards[0]);
executor.spawn(async move {
grow.init_partition(
ns_grow,
None,
None,
None,
false,
PartitionMaterialisation::new(0, 0),
);
});
executor.run_until_stalled(POLL_BUDGET);
let partitions = sim.replicas[0].shards[0].plane.partitions();
assert!(
partitions.contains(&ns_grow),
"correct with_partition read must leave the concurrent insert sound"
);
assert_ne!(
partitions.buffer_addr(),
addr_before,
"the insert landed in spare capacity, so nothing moved and this test \
proves nothing about a realloc; seed more partitions before the grow"
);
// Every pre-existing partition is still addressable after the move, which
// is what a stale reference would have missed.
assert!(partitions.contains(&ns_a) && partitions.contains(&ns_b));
}
/// Committed stream names on one replica, read out of the committed (left)
/// buffer so an uncommitted write is invisible.
fn committed_stream_names(
sim: &Simulator,
replica_idx: usize,
) -> std::collections::BTreeSet<String> {
use metadata::impls::metadata::StreamsFrontend;
sim.replicas[replica_idx].shards[0]
.plane
.metadata()
.mux_stm
.streams()
.read(|inner| {
inner
.items
.iter()
.map(|(_, stream)| stream.name.to_string())
.collect()
})
}
/// How much of the WAL a checkpoint reclaimed. Non-zero is the precondition every
/// checkpoint-recovery test needs: at zero the WAL still holds everything.
fn snapshot_floor(sim: &Simulator, replica_idx: usize) -> u64 {
use journal::Journal;
sim.replicas[replica_idx].metadata_journal.snapshot_op()
}
/// A client's committed request watermark on one replica.
fn client_watermark(sim: &Simulator, replica_idx: usize, client_id: u128) -> Option<u64> {
sim.replicas[replica_idx].shards[0]
.plane
.metadata()
.client_table
.borrow()
.get_watermark(client_id)
}
/// A checkpointing cluster with `streams` committed streams behind it, named
/// `wl-{prefix}-N`. The returned `TempDir` keeps the snapshots alive.
fn checkpointing_cluster(
replicas: u8,
seed: u64,
prefix: &str,
streams: u32,
) -> (Simulator, u128, tempfile::TempDir) {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let root = tempfile::tempdir().expect("temp dir for the simulator's snapshots");
let client_id: u128 = 1;
let mut sim = Simulator::with_checkpoints(
usize::from(replicas),
std::iter::once(client_id),
packet::PacketSimulatorOptions {
node_count: replicas,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
},
false,
root.path(),
);
// Small enough that the ops below cross the coordinator's margin.
sim.set_metadata_journal_slots(80);
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
for sequence in 0..streams {
let msg = client.create_stream(&format!("wl-{prefix}-{sequence}"));
sim.submit_request(client_id, 0, msg.into_generic());
for _ in 0..40 {
sim.step();
}
}
(sim, client_id, root)
}
#[test]
fn given_committed_metadata_when_solo_replica_restarts_should_recover_from_own_wal() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: 1,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
// Solo cluster: 1-of-1 quorum commits every metadata op the instant it is
// journaled, giving a fully-committed WAL with no uncommitted suffix to
// reconcile on restart.
let mut sim = Simulator::new(1, std::iter::once(client_id), network_opts);
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
// Resolves the namespace of the stream and topic created below; `Some` exactly
// when the Streams STM holds them.
let resolve = |sim: &Simulator| {
sim.replicas[0].shards[0]
.plane
.metadata()
.mux_stm
.streams()
.namespace_from_partition(
&iggy_binary_protocol::WireIdentifier::named("events").unwrap(),
&iggy_binary_protocol::WireIdentifier::named("logs").unwrap(),
0,
)
};
// Drive committed metadata through consensus: a stream, then a topic with one
// partition under it. Each appends a prepare to shard 0's WAL and mutates the
// Streams STM. The topic references the stream, so the stream commits first.
for msg in [
client.create_stream("events"),
client.create_topic("events", "logs", 1),
] {
sim.submit_request(client_id, 0, msg.into_generic());
for _ in 0..50 {
sim.step();
}
}
let namespace_before = resolve(&sim).expect("stream + topic must resolve after creation");
let head_before = sim.replicas[0]
.metadata_journal
.last_op()
.expect("metadata ops must have been appended to the WAL");
let commit_before = sim.replicas[0].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.expect("solo shard 0 owns metadata consensus")
.commit_min();
assert_eq!(
commit_before, head_before,
"a solo replica commits every durable op, so commit tracks the WAL head"
);
// Crash and restart: the shards are dropped, losing all volatile consensus and
// state-machine state, and rebuilt against the RETAINED WAL and superblock, so
// recovery is from this replica's own disk with no peer.
sim.replica_crash(0);
sim.replica_restart(0);
// The WAL bytes and index survived the restart.
assert_eq!(
sim.replicas[0].metadata_journal.last_op(),
Some(head_before),
"the metadata WAL head must survive a restart, bytes and index retained"
);
// Consensus recovered its op/commit from its own disk, not a fresh 0.
let consensus_ref = sim.replicas[0].shards[0].plane.metadata();
let consensus = consensus_ref
.consensus
.as_ref()
.expect("restarted solo shard 0 owns metadata consensus");
assert_eq!(
consensus.commit_min(),
head_before,
"commit must be recovered from the retained WAL, not reset to 0"
);
// Replaying the retained WAL reconstructed the committed Streams STM, so the
// stream and topic survive the restart from this replica's own disk.
assert_eq!(
resolve(&sim),
Some(namespace_before),
"the created stream/topic must survive the restart via WAL replay"
);
}
#[test]
fn given_registered_client_when_solo_replica_restarts_should_recover_session_from_own_wal() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: 1,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(1, std::iter::once(client_id), network_opts);
let client = SimClient::new(client_id);
// Register creates the client-table session; one committed metadata op caches
// a reply for at-most-once dedup. Both live in the client table, which is not
// part of the state machine and would otherwise reset to empty on restart.
sim.register_client_with_primary(&client);
sim.submit_request(client_id, 0, client.create_stream("events").into_generic());
for _ in 0..50 {
sim.step();
}
let epoch_before = sim.replicas[0].shards[0]
.plane
.metadata()
.client_table
.borrow()
.get_epoch(client_id);
assert!(
epoch_before.is_some(),
"client must hold a session before the crash"
);
// Crash and restart: the client table drops with the shard and is rebuilt by
// replaying the retained WAL through the same commit apply path the live
// cluster uses.
sim.replica_crash(0);
sim.replica_restart(0);
let epoch_after = sim.replicas[0].shards[0]
.plane
.metadata()
.client_table
.borrow()
.get_epoch(client_id);
assert_eq!(
epoch_after, epoch_before,
"the client session must survive a restart, reconstructed from the retained WAL, \
so a returning client is recognized instead of hitting NoSession"
);
}
/// Failover retry absorbed by the partition dedup slice: a `SendMessages`
/// replay of an already-committed `(client, request)` on a NEW primary is
/// answered without re-executing. The slice is folded in on every replica
/// at commit, so the promoted primary knows the watermark its predecessor
/// established -- that inheritance is what this test proves.
#[test]
#[allow(clippy::too_many_lines)]
fn failover_retry_absorbed_by_partition_dedup() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 5;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.register_client_with_primary(&client);
// Same `(client, session, request)` for replay; mirrors SDK's
// connection-loss retry.
let original_req = client.send_messages(ns, &[Bytes::from_static(b"failover-test")]);
let replay_req = original_req.deep_copy();
let original_request_id = original_req.header().request;
sim.submit_request(client_id, 0, original_req.into_generic());
let mut original_reply: Option<Message<ReplyHeader>> = None;
for _ in 0..200 {
let replies = sim.step();
if !replies.is_empty() {
original_reply = Some(replies[0].deep_copy());
break;
}
}
let original_reply = original_reply.expect("commit reply must arrive before primary crash");
let original_commit_op = original_reply.header().commit;
// Offset after exactly one committed batch: the duplicate must not
// move it.
let offset_after_original = sim.replicas[1].shards[0]
.plane
.partitions()
.get_by_ns(&ns)
.expect("partition must exist on a live replica")
.stats
.current_offset();
assert_eq!(
original_reply.header().request,
original_request_id,
"sanity: original reply must echo the request id"
);
// Crash primary. Real-world: TCP buffer might have lost reply
// before ack; same retry path.
sim.replica_crash(0);
// Steps for view change across 4 survivors.
for _ in 0..800 {
sim.step();
}
// Find new primary via any live replica.
let live = &sim.replicas[1].shards[0];
let live_consensus = live
.plane
.partitions()
.get_by_ns(&ns)
.expect("partition must exist on a live replica")
.consensus();
assert!(
live_consensus.view() > 0,
"view must have advanced past the crashed primary"
);
let new_primary_idx = live_consensus.primary_index(live_consensus.view());
assert_ne!(
new_primary_idx, 0,
"new primary must not be the crashed replica"
);
// Replay the SAME request to the new primary: the dedup slice it
// inherited at commit must absorb it.
sim.submit_request(client_id, new_primary_idx, replay_req.into_generic());
let mut retry_reply: Option<Message<ReplyHeader>> = None;
for _ in 0..200 {
let replies = sim.step();
if !replies.is_empty() {
retry_reply = Some(replies[0].deep_copy());
break;
}
}
let retry_reply = retry_reply
.expect("reply must arrive after retry; the new primary absorbs it as a duplicate");
assert_eq!(
retry_reply.header().request,
original_request_id,
"retry's reply must correlate to the request id"
);
assert_eq!(
retry_reply.header().client,
client_id,
"retry must echo original client_id"
);
assert_eq!(
retry_reply.header().status,
0,
"an absorbed duplicate is a success, not an error"
);
// The absorbed answer is synthesized at admission, so it never earns a
// new op. Re-execution would have committed past the original.
assert!(
retry_reply.header().op <= original_commit_op,
"retry must NOT re-execute (original commit={original_commit_op}, reply op={})",
retry_reply.header().op
);
// The payload committed exactly once.
let committed = sim.replicas[usize::from(new_primary_idx)].shards[0]
.plane
.partitions()
.get_by_ns(&ns)
.expect("partition must exist on the new primary")
.stats
.current_offset();
assert_eq!(
committed, offset_after_original,
"duplicate must not append a second copy"
);
}
/// With a positive crash probability
/// the driver crashes followers (never the primary) but never below the
/// survivor floor, while the per-tick invariants stay green and the
/// surviving quorum keeps committing.
#[test]
fn crash_injection_spares_primary_and_keeps_quorum() {
use crate::workload::{
self, Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 5;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0xC0FF_EE00,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
network_opts,
);
let client = client::SimClient::new(client_id);
let ns_a = server_common::sharding::IggyNamespace::new(1, 1, 0);
sim.init_partition(ns_a);
sim.register_client_with_primary(&client);
let mut options = WorkloadOptions::new(0xC0FF_EE00, replica_count, vec![ns_a]);
options.weights = ActionWeights::new(&[(Action::SendMessages, 100)]);
options.crash_per_tick_ratio = 0.05;
options.min_survivors = 3; // quorum of 5
let mut wl = Workload::new(options);
let clients = [client];
// run() asserts the per-tick invariants every tick under injected crashes.
let replies = workload::run(&mut sim, &mut wl, &clients, 3_000, u64::MAX);
let crashed = sim.crashed.len();
assert!(
crashed >= 1,
"expected at least one crash injected over the run"
);
assert!(
!sim.is_crashed(0),
"primary (replica 0) must never be crashed"
);
assert!(
usize::from(replica_count) - crashed >= 3,
"must keep at least min_survivors=3 live (crashed={crashed})"
);
assert!(
replies > 0,
"surviving quorum must keep committing under follower crashes"
);
}
/// Committed metadata prepare timestamps for `seed`: register plus two
/// stream creates, read back from replica 0's metadata journal.
fn metadata_prepare_timestamps(seed: u64) -> Vec<u64> {
use consensus::MetadataHandle;
use journal::{Journal, JournalHandle};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
for name in ["clock-a", "clock-b"] {
let msg = client.create_stream(name);
sim.submit_request(client_id, 0, msg.into_generic());
let mut got_reply = false;
for _ in 0..100 {
if !sim.step().is_empty() {
got_reply = true;
break;
}
}
assert!(got_reply, "create_stream({name}) must commit");
}
let shard = &sim.replicas[0].shards[0];
let journal = shard
.plane
.metadata()
.journal
.as_ref()
.expect("shard 0 owns the metadata journal");
// Ops 1..=3: Register, then the two creates.
(1..=3)
.map(|op| {
journal
.handle()
.header(op)
.expect("committed op must have a journal header")
.timestamp
})
.collect()
}
/// Schedule hash for `seed` after stepping the consensus plane with no
/// client traffic, with the dispatch shell on or off.
fn consensus_schedule_hash(seed: u64, shell: bool) -> u64 {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let network_opts = packet::PacketSimulatorOptions {
node_count: 3,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
};
let mut sim = if shell {
Simulator::with_shards_shell(3, 1, std::iter::once(1u128), network_opts)
} else {
Simulator::with_shards(3, 1, std::iter::once(1u128), network_opts)
};
for _ in 0..20 {
sim.step();
}
sim.schedule_hash()
}
/// With the injected [`SimClock`], primary-stamped prepare timestamps
/// are a pure function of the seed: identical across same-seed runs,
/// anchored at the synthetic sim epoch (not 1970, not wall clock),
/// and strictly monotonic per the clamp in
/// `next_monotonic_timestamp`.
#[test]
fn prepare_timestamps_replay_with_seed() {
let first = metadata_prepare_timestamps(0xC10C_0001);
let second = metadata_prepare_timestamps(0xC10C_0001);
assert_eq!(
first, second,
"prepare timestamps diverged across same-seed runs"
);
for timestamp in &first {
assert!(
*timestamp >= deps::SIM_EPOCH_MICROS,
"timestamp {timestamp} predates the sim epoch; wall clock leaked"
);
// Sim runs complete in well under a simulated day; a wall-clock
// leak would stamp 2026-07+ values far past this bound.
assert!(
*timestamp < deps::SIM_EPOCH_MICROS + 86_400_000_000,
"timestamp {timestamp} beyond epoch + 1 day; wall clock leaked"
);
}
assert!(
first.windows(2).all(|pair| pair[0] < pair[1]),
"prepare timestamps must be strictly monotonic: {first:?}"
);
}
/// Turning the dispatch shell on wires the server's real deferred
/// handlers on every shard. With no client traffic none of them is
/// reached, so the consensus plane both replays deterministically and
/// matches the shell-off schedule: the toggle is genuinely off the
/// consensus path. Also guards that shell construction does not panic.
#[test]
fn shell_on_consensus_schedule_matches_shell_off() {
let seed = 0x5CED_0001;
assert_eq!(
consensus_schedule_hash(seed, true),
consensus_schedule_hash(seed, true),
"shell-on schedule diverged at same seed"
);
assert_eq!(
consensus_schedule_hash(seed, true),
consensus_schedule_hash(seed, false),
"shell perturbed the consensus schedule despite no client traffic"
);
assert_ne!(
consensus_schedule_hash(0x5CED_0001, true),
consensus_schedule_hash(0x5CED_0002, true),
"different seeds produced identical shell-on schedule"
);
}
/// Multi-shard replay: the same seed reproduces both the reply trace
/// and the executor schedule; a different seed diverges in both.
#[test]
fn multi_shard_replay_is_deterministic() {
let (replies_a, schedule_a) = workload_hash(0xD0D0_0001, 4);
let (replies_b, schedule_b) = workload_hash(0xD0D0_0001, 4);
assert_eq!(replies_a, replies_b, "reply trace diverged at same seed");
assert_eq!(schedule_a, schedule_b, "schedule diverged at same seed");
let (replies_c, schedule_c) = workload_hash(0xD0D0_0002, 4);
assert_ne!(replies_a, replies_c, "different seeds, identical replies");
assert_ne!(
schedule_a, schedule_c,
"different seeds, identical schedule"
);
}
/// A full fault run replays byte-identically from its seed.
///
/// The other determinism tests drive the workload inline, so nothing covered
/// `run_with_faults`, `FaultInjector` or `resubmit_due`: an injector drawing from
/// `rand::random` passed the whole suite. Crash and restart counts are compared
/// alongside the traces because a trace can match while the faults behind it
/// differ.
#[test]
fn fault_runs_replay_from_their_seed() {
use crate::workload::{
FaultInjector, Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
run_with_faults,
};
// `(replies, schedule_hash, crashes, restarts)` for one run.
fn fault_run(seed: u64) -> (u64, u64, u64, u64) {
let replica_count: u8 = 3;
let client_id: u128 = 1;
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
packet_loss_probability: 0.02,
..packet::PacketSimulatorOptions::default()
},
);
let client = SimClient::new(client_id);
let ns = server_common::sharding::IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.register_client_with_primary(&client);
let mut options = WorkloadOptions::new(seed, replica_count, vec![ns]);
options.crash_per_tick_ratio = 0.02;
options.restart_per_tick_ratio = 0.08;
options.spare_primary = false;
options.weights = ActionWeights::new(&[
(Action::CreateStream, 30),
(Action::SendMessages, 50),
(Action::StoreConsumerOffset, 20),
]);
let mut workload = Workload::new(options);
let mut injector = FaultInjector::new(seed, replica_count);
let clients = [client];
let replies = run_with_faults(
&mut sim,
&mut workload,
&clients,
3_000,
u64::MAX,
&mut injector,
);
(
replies,
sim.schedule_hash(),
injector.crashes(),
injector.restarts(),
)
}
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let first = fault_run(0xFA17_0001);
assert_eq!(first, fault_run(0xFA17_0001), "a fault run did not replay");
assert!(first.0 > 0, "the run produced no replies");
assert!(
first.2 > 0 && first.3 > 0,
"no crash or restart was injected, so this compares a fault-free run: \
got {} crashes and {} restarts",
first.2,
first.3,
);
assert_ne!(
first,
fault_run(0xFA17_0002),
"two seeds produced the same trace, so the seed is not reaching the \
injector"
);
}
/// Committed streams as `(slab id, name)` on one replica.
fn committed_stream_slabs(sim: &Simulator, replica_idx: usize) -> Vec<(usize, String)> {
use metadata::impls::metadata::StreamsFrontend;
sim.replicas[replica_idx].shards[0]
.plane
.metadata()
.mux_stm
.streams()
.read(|inner| {
inner
.items
.iter()
.map(|(id, stream)| (id, stream.name.to_string()))
.collect()
})
}
/// A restarted replica assigns the same slab ids as a peer that never restarted.
///
/// `CreateStream::apply` takes `vacant_key()`, so slab ids follow insertion
/// order, and a restart used to seed the fillers after replaying the WAL rather
/// than before. The committed log matched either way, so nothing comparing
/// headers noticed, but partition ops address a namespace by slab id.
#[test]
fn a_restarted_replica_keeps_its_peers_slab_ids() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0xC4E0_0005,
..packet::PacketSimulatorOptions::default()
},
);
let client = SimClient::new(client_id);
let ns = server_common::sharding::IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.register_client_with_primary(&client);
for sequence in 0..4u32 {
let msg = client.create_stream(&format!("wl-slab-{sequence}"));
sim.submit_request(client_id, 0, msg.into_generic());
for _ in 0..40 {
sim.step();
}
}
let healthy = committed_stream_slabs(&sim, 0);
assert!(
healthy.iter().any(|(_, name)| name.starts_with("wl-")),
"no workload stream committed, so the slab order below is only the seed"
);
let rejoining = 1u8;
sim.replica_crash(rejoining);
sim.replica_restart(rejoining);
for _ in 0..2_000 {
sim.step();
}
assert_eq!(
committed_stream_slabs(&sim, usize::from(rejoining)),
healthy,
"the restarted replica assigned different slab ids than a peer holding \
the same committed log, so a namespace names different streams on each"
);
}
/// A solo replica that checkpointed recovers the state the checkpoint absorbed.
///
/// The case with no second opinion: a clustered replica repairs a botched local
/// recovery from a peer, so what boot reconstructs here IS the state. The client
/// table is asserted too, being folded in separately by `persist_snapshot`.
#[test]
fn solo_replica_recovers_the_state_its_checkpoint_absorbed() {
let (mut sim, client_id, _root) = checkpointing_cluster(1, 0xC4E0_0002, "solo", 40);
let before = committed_stream_names(&sim, 0);
let watermark_before = client_watermark(&sim, 0, client_id);
assert!(
snapshot_floor(&sim, 0) > 0,
"the solo replica never checkpointed, so this proves nothing about \
snapshot recovery"
);
sim.replica_crash(0);
sim.replica_restart(0);
for _ in 0..2_000 {
sim.step();
}
assert_eq!(
committed_stream_names(&sim, 0),
before,
"the restarted solo replica lost committed streams the checkpoint \
drained out of the WAL"
);
assert_eq!(
client_watermark(&sim, 0, client_id),
watermark_before,
"the checkpoint's folded client table did not come back, so a session \
below the snapshot floor lost its watermark"
);
}
/// A clustered replica that took its own checkpoint rejoins holding the same
/// committed metadata as a healthy peer.
///
/// Against a peer, not a recorded snapshot of itself: a replica that dropped the
/// drained prefix still reports a plausible commit point, and only the peer
/// comparison shows the state behind it is wrong.
#[test]
fn a_checkpointed_replica_rejoins_agreeing_with_a_healthy_peer() {
let (mut sim, _client_id, _root) = checkpointing_cluster(3, 0xC4E0_0003, "peer", 40);
// A backup, so the restart does not also trigger a view change: the subject
// here is local recovery, not election.
let rejoining = 1u8;
assert!(
snapshot_floor(&sim, usize::from(rejoining)) > 0,
"replica {rejoining} never checkpointed, so its restart exercises no \
snapshot recovery"
);
let healthy = committed_stream_names(&sim, 0);
assert!(
healthy.len() > 1,
"the peer holds no workload streams to compare"
);
sim.replica_crash(rejoining);
sim.replica_restart(rejoining);
for _ in 0..8_000 {
sim.step();
}
assert_eq!(
committed_stream_names(&sim, usize::from(rejoining)),
healthy,
"the rejoined replica disagrees with a healthy peer on committed \
metadata: local recovery dropped the prefix its checkpoint drained"
);
}
/// A namespace still live in committed metadata but hosted by nobody is a
/// convergence FAILURE, not a converged cluster.
///
/// Settlement and the leader-relative offset check both skip such a namespace,
/// which is right for a deleted stream and used to be the only word on the
/// subject. Seeds the metadata half without the partition half, the state a total
/// loss of instances leaves.
#[test]
#[should_panic(expected = "no live replica hosts it at quiesce")]
fn a_live_namespace_with_no_host_fails_convergence() {
use crate::workload::{Workload, options::WorkloadOptions, oracle};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let seed = 0xC4E0_0004;
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
},
);
let client = SimClient::new(client_id);
let ns = server_common::sharding::IggyNamespace::new(1, 1, 0);
// Metadata only: the namespace is committed-visible, but no replica ever
// materialises the group (`init_partition` is deliberately not called).
sim.seed_stream_topic_partition(ns);
sim.register_client_with_primary(&client);
for _ in 0..200 {
sim.step();
}
let mut workload = Workload::new(WorkloadOptions::new(seed, replica_count, vec![ns]));
oracle::assert_converged(&sim, &mut workload);
}
/// A replica that checkpoints serves a real state transfer: the rejoining peer
/// fetches the snapshot in chunks rather than stalling at the handshake.
///
/// Companion to
/// [`repair_below_the_snapshot_floor_escalates_to_state_transfer`], which stamps a
/// watermark without snapshot bytes and so reaches only `StateTransferTarget`.
/// Here the coordinator is armed with a data directory and the journal is bounded,
/// so the cluster checkpoints for real. Both were needed and neither was present:
/// no directory meant no `SnapshotCoordinator`, and an unbounded journal never
/// fired `should_checkpoint`.
#[test]
fn checkpointing_cluster_serves_a_chunked_state_transfer() {
use iggy_binary_protocol::Command;
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let root = tempfile::tempdir().expect("temp dir for the simulator's snapshots");
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0xC4E0_0001,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_checkpoints(
usize::from(replica_count),
std::iter::once(client_id),
network_opts,
false,
root.path(),
);
// Small enough that the ops below cross the margin; the coordinator forces
// a checkpoint once free slots fall to its margin (64 by default).
sim.set_metadata_journal_slots(80);
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
let lagging = 2u8;
sim.replica_crash(lagging);
// Commit past the checkpoint margin while the lagging replica is down, so
// the survivors checkpoint and compact the prefix it is missing.
for sequence in 0..40u32 {
let msg = client.create_stream(&format!("wl-checkpoint-{sequence}"));
sim.submit_request(client_id, 0, msg.into_generic());
for _ in 0..40 {
sim.step();
}
}
let snapshot = root
.path()
.join("replica-0")
.join(metadata::impls::METADATA_DIR)
.join(metadata::impls::SNAPSHOT_FILE_NAME);
assert!(
snapshot.exists(),
"the primary never checkpointed, so there is no snapshot to transfer: \
raise the op count or lower the journal slot count"
);
sim.replica_restart(lagging);
for _ in 0..8_000 {
sim.step();
}
assert!(
sim.network.delivered_any(Command::RequestStateChunk),
"the rejoining replica never asked for a chunk, so the transfer \
stalled at the handshake exactly as it does without a checkpoint"
);
assert!(
sim.network.delivered_any(Command::StateChunk),
"no chunk was served: the peer offered a transfer it could not fulfil"
);
// Traffic is not recovery: everything above passes when the chunks arrive and
// the install then fails. Each assertion below closes one of those ways.
let recovered = &sim.replicas[usize::from(lagging)].shards[0];
let consensus = recovered
.plane
.metadata()
.consensus
.as_ref()
.expect("shard 0 owns metadata consensus");
assert_eq!(
consensus.status(),
consensus::Status::Normal,
"the rejoining replica never returned to Normal"
);
let transferred_floor = snapshot_floor(&sim, 0);
assert!(
transferred_floor > 0,
"the serving peer has no snapshot to transfer"
);
assert!(
consensus.commit_min() >= transferred_floor,
"the rejoining replica commits through {} but was served a snapshot \
covering {transferred_floor}: the install did not land",
consensus.commit_min(),
);
assert!(
consensus.commit_max() >= consensus.recovery_barrier(),
"a recovery barrier at {} still gates the rejoining replica (commit_max {})",
consensus.recovery_barrier(),
consensus.commit_max(),
);
assert_eq!(
committed_stream_names(&sim, usize::from(lagging)),
committed_stream_names(&sim, 0),
"the transfer left the rejoining replica holding different committed \
metadata than the peer that served it"
);
assert_eq!(
client_watermark(&sim, usize::from(lagging), client_id),
client_watermark(&sim, 0, client_id),
"the transferred client table did not install: the session below the \
snapshot floor came back without its watermark"
);
}
/// A client that dials a BACKUP still gets a working session, and the login
/// travels as a forwarded consensus proposal rather than a redirect.
///
/// Register forwarding exists so a client need not find the primary itself: the
/// backup verifies the credentials locally, sends only the proposal on as
/// `ForwardRegister`, parks the login until `ForwardRegisterResult` returns, then
/// answers on the connection it owns. The subsystem landed with no deterministic
/// coverage and could have none while the harness only dialed the primary.
///
/// Asserts the four frames were delivered rather than inferring them from a working
/// session: dialing a backup would also "work" under a silent redirect, which is
/// the design this replaced.
#[test]
fn login_via_backup_forwards_the_register_to_the_primary() {
use iggy_binary_protocol::Command;
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0xF02D_0001,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards_shell(
usize::from(replica_count),
1,
std::iter::once(client_id),
network_opts,
);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.seed_stream_topic_partition(ns);
// Replica 0 leads both planes at view 0, so replica 1 is a backup and the
// login has to be forwarded.
let client = SimClient::new(client_id);
sim.shell_login_via(&client, 1);
assert!(
sim.network.delivered_any(Command::ForwardRegister),
"no ForwardRegister crossed the wire: the backup answered the login \
itself, so this covers nothing"
);
assert!(
sim.network.delivered_any(Command::ForwardRegisterResult),
"the forwarded register was never answered, so the login below \
succeeded by some other route"
);
// Log out on the SAME backup: covers the other half of forwarding and proves
// the session was real, only a bound session being torn down, with the
// teardown replicating through the primary as the register did. A logout, not
// a data request, because the session belongs to the connection and a backup
// refuses a partition write for routing reasons (`TransientNotAccepted`) that
// say nothing about the session.
let logout = client.logout();
let request = logout.header().request;
sim.submit_request(client_id, 1, logout.into_generic());
let mut answered = false;
for _ in 0..400 {
if let Some(reply) = sim
.step()
.into_iter()
.find(|reply| reply.header().request == request)
{
assert_eq!(
reply.header().status,
0,
"logout on the forwarded session was refused (status {})",
reply.header().status,
);
answered = true;
break;
}
}
assert!(answered, "no reply to the logout issued on the backup");
assert!(
sim.network.delivered_any(Command::ForwardLogout),
"the backup committed the logout without asking the primary"
);
assert!(
sim.network.delivered_any(Command::ForwardLogoutResult),
"the forwarded logout was never answered"
);
}
/// The workload drains and converges when every request goes through the
/// server's real dispatch handlers rather than the raw `on_message` path.
///
/// Its own test because the shell path is where authorization, session binding and
/// the pre-commit deny replies live; the raw path has no deny site. Running the
/// workload here is the only thing that exercises them, and it surfaced that the
/// workload had never modelled a denial: nonzero `ReplyHeader::status` means an
/// empty body, and the decoder read a result section off it and called the reply
/// corrupt.
///
/// Asserts denials were observed, so this cannot pass on a path where nothing is
/// denied.
#[test]
fn shell_workload_drains_and_converges() {
use crate::workload::{
self, Workload,
options::{ActionWeights, WorkloadOptions},
oracle,
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let seed = 0x5E11_0001;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards_shell(
usize::from(replica_count),
1,
std::iter::once(client_id),
network_opts,
);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
// The dispatch path resolves a partition request's namespace against
// committed metadata, so the stream and topic have to exist too.
sim.seed_stream_topic_partition(ns);
let client = SimClient::new(client_id);
// Log in rather than bare-register: dispatch admits a request only from a
// bound session.
sim.shell_login(&client);
let mut options = WorkloadOptions::new(seed, replica_count, vec![ns]);
options.weights = ActionWeights::uniform();
let mut wl = Workload::new(options);
let clients = [client];
let replies = workload::run(&mut sim, &mut wl, &clients, 4_000, u64::MAX);
assert!(replies > 0, "shell workload produced no replies");
let stats = wl.auditor.stats();
assert!(
stats.commits_per_action.iter().sum::<u64>() > 0,
"shell workload committed nothing, so the dispatch path never got past \
admission"
);
assert!(
stats.denials > 0,
"no request was denied, so the pre-commit deny path this test exists to \
cover never ran"
);
assert!(
oracle::drive_to_quiesce(&mut sim, &mut wl, 20_000),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
oracle::assert_converged(&sim, &mut wl);
}
/// A result-framed transport rejection is not a committed result.
///
/// Dispatch refuses a request it cannot place (not the primary, transferring,
/// queue full, a view change canceled the pending prepare) and answers with the
/// reason in the reply's RESULT section, under the request's own operation, leaving
/// `status` at 0. That reaches `on_reply` shaped exactly like a commit while
/// carrying a code no op's result enum declares, so the classifier called it a
/// server bug and every fault-injected shell run died on the first one.
///
/// `TransientNotCommitted` also leaves the outcome UNKNOWN, so the request is held
/// outstanding for a replay, and the quiesce below proves that replay settles
/// rather than stalling the drain.
///
/// Asserts a transient was seen, so this cannot pass on a path that never produces
/// one.
#[test]
fn shell_workload_survives_result_framed_transient_rejections() {
use crate::workload::{
FaultInjector, Workload,
options::{ActionWeights, WorkloadOptions},
oracle, run_with_faults,
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
// Hand-picked: of seeds 1..=40 under these options, ten reached a transient.
// Crashing the primary under a lossy network is necessary but not sufficient,
// so the seed cannot be arbitrary. Re-scan if the PRNG streams are remapped.
let seed = 2;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
packet_loss_probability: 0.10,
replay_probability: 0.03,
one_way_delay_mean: 8,
partition_probability: 0.02,
unpartition_probability: 0.02,
partition_stability: 50,
unpartition_stability: 50,
partition_mode: packet::PartitionMode::UniformSize,
partition_symmetry: packet::PartitionSymmetry::Asymmetric,
path_clog_probability: 0.01,
path_clog_duration_mean: 25,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards_shell(
usize::from(replica_count),
1,
std::iter::once(client_id),
network_opts,
);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.seed_stream_topic_partition(ns);
let client = SimClient::new(client_id);
sim.shell_login(&client);
let mut options = WorkloadOptions::new(seed, replica_count, vec![ns]);
options.weights = ActionWeights::uniform();
// Crashing the primary is what puts a live request on a replica that
// cannot place it, which is where the transient comes from.
options.crash_per_tick_ratio = 0.05;
options.restart_per_tick_ratio = 0.08;
options.spare_primary = false;
let mut wl = Workload::new(options);
let clients = [client];
let mut injector = FaultInjector::new(seed, replica_count);
run_with_faults(&mut sim, &mut wl, &clients, 1_500, u64::MAX, &mut injector);
assert!(
wl.auditor.stats().transient_rejections > 0,
"no request was answered with a result-framed transient rejection, so the \
path this test exists to cover never ran"
);
assert!(
oracle::drive_to_quiesce(&mut sim, &mut wl, 50_000),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
}
/// The cross-replica equality check actually compares replicas against each
/// other, and holds over a metadata workload with crashes and restarts.
///
/// Non-vacuity is the point of the chain assertions. An equality oracle that never
/// finds two replicas at the same op passes in silence, so `ops_compared` counts
/// only ops witnessed on more than one replica, the subset that exercised the
/// property.
#[test]
fn committed_metadata_agrees_across_replicas() {
use crate::workload::{
self, FaultInjector, Workload,
invariants::Invariants,
options::{ActionWeights, WorkloadOptions},
oracle,
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 5;
let client_id: u128 = 1;
let seed = 0x57A7_E000;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.register_client_with_primary(&client);
// Metadata ops, since the committed chain this checks is the metadata WAL.
// Crash and restart so replicas rejoin and repair, which is when a
// divergence would be introduced if one could be.
let mut options = WorkloadOptions::new(seed, replica_count, vec![ns]);
options.weights = ActionWeights::metadata_only();
options.crash_per_tick_ratio = 0.01;
options.restart_per_tick_ratio = 0.02;
let mut wl = Workload::new(options);
let clients = [client];
let mut injector = FaultInjector::new(seed, replica_count);
let mut invariants = Invariants::new();
// Driven here rather than through `workload::run` so the accumulated
// chain is readable afterwards; `run` builds its own `Invariants`.
for _ in 0..4_000u32 {
wl.tick();
injector.step(&mut sim, &wl);
workload::resubmit_due(&mut sim, &mut wl);
if let Some((target, msg)) = wl.build_request(&clients[0]) {
sim.submit_request(clients[0].client_id(), target, msg.into_generic());
}
for reply in sim.step() {
let cmds = wl.on_reply(&reply);
workload::apply_sim_commands(&mut sim, &cmds);
}
invariants.check(&sim, &wl);
}
assert!(
injector.restarts() > 0,
"no replica restarted, so rejoin and repair never ran"
);
let chain = invariants.state_checker();
assert!(
chain.chain_len() > 0,
"the canonical commit chain is empty: nothing was ever recorded"
);
assert!(
chain.ops_compared() > 0,
"no committed op was witnessed on two replicas, so the equality check \
never actually compared anything and would pass on a diverged cluster"
);
assert!(
oracle::drive_to_quiesce(&mut sim, &mut wl, 50_000),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
assert!(
oracle::settle_to_stable_view(&mut sim, &mut wl, 50_000),
"metadata views never converged after the drain"
);
oracle::assert_converged(&sim, &mut wl);
}
/// A lost `PrepareOk` does not wedge the metadata plane: once the acks flow
/// again the primary reaches its commit quorum without client involvement.
///
/// The retransmit is the whole mechanism, and it depends on the duplicate being
/// admitted rather than dropped as a gap: `on_replicate`'s "journal already holds
/// prepare" branch re-forwards it down the chain and re-acks, regenerating the ack
/// the primary lost. Fails if a duplicate ever starts falling through to the gap
/// check instead.
///
/// Drops acks rather than crashing anyone, so the property is about lost acks in
/// general, not restart recovery.
#[test]
fn lost_prepare_ok_is_recovered_by_retransmit() {
use iggy_binary_protocol::Command;
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0x5EED_0077,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
let committed_before = metadata_commit(&sim, 0);
// Drop only PrepareOk on both backup links. Everything else still flows,
// so the backups receive and journal the prepare; only the primary's
// evidence of that is lost.
for backup in 1..replica_count {
sim.network
.link_filter_mut(ProcessId::Replica(backup), ProcessId::Replica(0))
.remove(Command::PrepareOk);
}
let msg = client.create_stream("wl-lost-ack");
sim.submit_request(client_id, 0, msg.into_generic());
// Long enough for the prepare to reach and be journaled by both backups
// while the primary sees no acks.
for _ in 0..200 {
sim.step();
}
assert_eq!(
metadata_commit(&sim, 0),
committed_before,
"the primary must not commit while every backup ack is dropped"
);
for backup in 1..replica_count {
assert!(
metadata_op(&sim, usize::from(backup)) > committed_before,
"backup {backup} must have journaled the prepare, else this test \
proves nothing about a LOST ack"
);
}
// Restore the acks. From here the primary's retransmit is the only route
// to a commit, which is exactly the mechanism under test.
for backup in 1..replica_count {
sim.network
.link_filter_mut(ProcessId::Replica(backup), ProcessId::Replica(0))
.insert(Command::PrepareOk);
}
for _ in 0..5_000 {
sim.step();
if metadata_commit(&sim, 0) > committed_before {
return;
}
}
panic!(
"metadata commit stuck at {} after 5000 ticks with healthy links: a lost \
PrepareOk is no longer recovered, so the backup's gap check is now \
swallowing the primary's retransmit",
metadata_commit(&sim, 0),
);
}
/// A prepare that every backup journaled but never acked still commits after
/// those backups restart.
///
/// The rejoin path recovers it: a restarted replica comes back with `current_op`
/// at N from its own WAL, rejoins as a probing backup (`Status::Recovering`, see
/// `new_shard`), and its probe draws a targeted `StartView` that returns it to
/// `Normal` and gets the tail acked. The retransmit cannot do it while the backup
/// is still probing: `replicate_preflight` refuses any prepare outside
/// `Status::Normal`.
///
/// Pinned because the fuzzer finds runs where this does NOT happen: two live
/// backups hold op 45 with the primary at commit 44, both logging the gap drop for
/// the whole drain. Covering the case that works narrows where that one diverges.
#[test]
fn unacked_prepare_commits_after_the_backup_restarts() {
use iggy_binary_protocol::Command;
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0x5EED_0078,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
usize::from(replica_count),
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
let committed_before = metadata_commit(&sim, 0);
// Lose every backup ack, so the prepare is journaled cluster-wide while
// the primary stays one short of its commit quorum.
for backup in 1..replica_count {
sim.network
.link_filter_mut(ProcessId::Replica(backup), ProcessId::Replica(0))
.remove(Command::PrepareOk);
}
let msg = client.create_stream("wl-unacked");
sim.submit_request(client_id, 0, msg.into_generic());
for _ in 0..200 {
sim.step();
}
for backup in 1..replica_count {
assert!(
metadata_op(&sim, usize::from(backup)) > committed_before,
"backup {backup} must hold the prepare before it is restarted"
);
}
assert_eq!(
metadata_commit(&sim, 0),
committed_before,
"the primary must not have committed while its acks were dropped"
);
// Restart every backup. Each recovers the unacked op from its own WAL and
// rejoins as a probing backup, which is the state the primary's
// retransmit cannot get an ack out of.
for backup in 1..replica_count {
sim.replica_crash(backup);
for _ in 0..50 {
sim.tick();
}
sim.replica_restart(backup);
}
// Healthy links from here: nothing but the protocol stands between the
// primary and its quorum.
for backup in 1..replica_count {
sim.network
.link_filter_mut(ProcessId::Replica(backup), ProcessId::Replica(0))
.insert(Command::PrepareOk);
}
for _ in 0..10_000 {
sim.step();
if metadata_commit(&sim, 0) > committed_before {
return;
}
}
panic!(
"metadata commit stuck at {} after 10000 ticks with healthy links and \
every replica holding op {}: the restarted backups never re-acked the \
prepare they recovered from their own WALs",
metadata_commit(&sim, 0),
metadata_op(&sim, 1),
);
}
/// Committed metadata op on a replica's shard 0.
fn metadata_commit(sim: &Simulator, replica_idx: usize) -> u64 {
sim.replicas[replica_idx].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.expect("shard 0 owns metadata consensus")
.commit_min()
}
/// Highest metadata op a replica has journaled.
fn metadata_op(sim: &Simulator, replica_idx: usize) -> u64 {
sim.replicas[replica_idx]
.metadata_journal
.last_op()
.unwrap_or(0)
}
/// IGGY-66 acceptance: per-partition consensus independence. Blocking `ns_a`'s
/// `PrepareOk` acks and filling its pipeline to `PIPELINE_PREPARE_QUEUE_MAX`
/// wedges it for want of quorum, `ns_b` still commits, and lifting the block
/// drains `ns_a` completely.
#[test]
fn per_partition_consensus_independence() {
use consensus::PIPELINE_PREPARE_QUEUE_MAX;
use iggy_binary_protocol::{Command, PrepareOkHeader};
use packet::Packet;
use std::sync::atomic::{AtomicU64, Ordering};
// The link predicate is a plain fn pointer (no captures), so the
// namespace under blockade travels through a static. Owned by
// this test alone; other tests never install drop predicates.
static BLOCKED_NS: AtomicU64 = AtomicU64::new(0);
fn drop_blocked_prepare_ok(packet: &Packet) -> bool {
if packet.message.header().command != Command::PrepareOk {
return false;
}
let header: &PrepareOkHeader = bytemuck::checked::from_bytes(
&packet.message.as_slice()[..std::mem::size_of::<PrepareOkHeader>()],
);
header.group == BLOCKED_NS.load(Ordering::Relaxed)
}
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0x5EED_0066,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
let ns_a = IggyNamespace::new(1, 1, 0);
let ns_b = IggyNamespace::new(1, 1, 1);
sim.init_partition(ns_a);
sim.init_partition(ns_b);
sim.register_client_with_primary(&client);
BLOCKED_NS.store(ns_a.inner(), Ordering::Relaxed);
// Block every backup's PrepareOk for ns_a toward the primary: the
// primary's self-ack alone is 1 < quorum 2, so ns_a can prepare
// and replicate but never commit.
for backup in 1..replica_count {
*sim.network
.link_drop_packet_fn(ProcessId::Replica(backup), ProcessId::Replica(0)) =
Some(drop_blocked_prepare_ok);
}
// Fill ns_a's pipeline exactly to the cap (one more would be
// rejected at preflight and generate a reply, muddying the
// no-replies assertion below).
for sequence in 0..PIPELINE_PREPARE_QUEUE_MAX {
let msg = client.send_messages(ns_a, &[Bytes::from(format!("wedged-{sequence}"))]);
sim.submit_request(client_id, 0, msg.into_generic());
}
for _ in 0..100 {
assert!(
sim.step().is_empty(),
"ns_a must not commit while its PrepareOk acks are blocked"
);
}
// ns_b shares the client, the replicas, and the shard, but has its
// own consensus group: it must commit while ns_a stays wedged.
let msg = client.send_messages(ns_b, &[Bytes::from_static(b"independent")]);
// Replies no longer carry a group id; correlate by the request id
// this send was stamped with.
let ns_b_request = msg.header().request;
sim.submit_request(client_id, 0, msg.into_generic());
let mut independent_replies = 0usize;
for _ in 0..100 {
for reply in sim.step() {
assert_eq!(
reply.header().request,
ns_b_request,
"only ns_b may commit while ns_a's acks are blocked"
);
independent_replies += 1;
}
}
assert_eq!(
independent_replies, 1,
"ns_b request must commit while ns_a's pipeline is full"
);
// Lift the blockade: retransmitted acks land and ns_a drains.
for backup in 1..replica_count {
*sim.network
.link_drop_packet_fn(ProcessId::Replica(backup), ProcessId::Replica(0)) = None;
}
let mut drained_replies = 0usize;
for _ in 0..800 {
for reply in sim.step() {
// Only ns_a replies are outstanding once ns_b committed
// above, so every reply counts toward the drain.
if reply.header().request != ns_b_request {
drained_replies += 1;
}
}
if drained_replies == PIPELINE_PREPARE_QUEUE_MAX {
break;
}
}
assert_eq!(
drained_replies, PIPELINE_PREPARE_QUEUE_MAX,
"every wedged ns_a send must commit once acks flow again"
);
}
/// `SendMessages` workload at 3 replicas x 4 shards drains, converges
/// against the oracle, and drops no inter-shard frames.
#[test]
fn multi_shard_workload_converges() {
use crate::workload::{
self, Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
oracle,
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
seed: 0xC0FF_EE04,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards(
replica_count as usize,
4,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
sim.register_client_with_primary(&client);
let mut options = WorkloadOptions::new(0xC0FF_EE04, replica_count, vec![ns]);
options.weights = ActionWeights::new(&[(Action::SendMessages, 100)]);
let mut wl = Workload::new(options);
let clients = [client];
let replies = workload::run(&mut sim, &mut wl, &clients, 2_000, u64::MAX);
assert!(replies > 0, "workload produced no replies");
assert!(
oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000),
"system did not drain within the tick budget"
);
oracle::assert_converged(&sim, &mut wl);
assert_no_frame_drops(&sim);
}
}
#[cfg(test)]
mod view_change_data_loss_tests {
//! A committed, client-acknowledged op must survive a view change even when
//! the replica that becomes primary is the one missing it.
//!
//! Without the sender's log suffix on the `DoViewChange`, the new primary adopts
//! the winner's op NUMBER, rebuilds its pipeline from its OWN journal, hits the
//! hole and truncates the range as "decided lost", discarding an op journaled on a
//! quorum and already replied to. The next client op reuses that number and
//! collides with the stale entry on the up-to-date backup.
//!
//! The hole is punched at the commit point, so the regression is caught by "the op
//! came back" rather than "the head did not regress": with nothing uncommitted
//! there is no pipeline rebuild to truncate. The `dvc_merge` unit tests cover the
//! sequencer-truncation path directly.
use super::*;
use crate::executor::yield_once;
use consensus::{Sequencer, Status};
use journal::Journal;
use message_bus::MessageBus;
/// A client submit landing inside the new primary's view-start superblock
/// persist must not corrupt the pipeline.
///
/// `start_pending_view` flips the replica into a Normal primary
/// synchronously and defers the rebuild of the inherited uncommitted
/// suffix; the persist then suspends the pump. A register admitted in that
/// window used to mint the next op into the still-empty pipeline, and the
/// deferred rebuild panicked pushing the inherited op beneath it
/// ("sequence must be sequential"); the same empty pipeline also blinded
/// the register dedup, admitting an inherited in-flight register twice.
/// The suspension is real on disk-backed stores (an fsync) and is restored
/// here with `set_yield_writes`.
#[test]
fn given_a_register_inside_the_view_start_persist_when_the_pipeline_rebuilds_should_commit_once()
{
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let settled_client: u128 = 1;
let straggler_client: u128 = 2;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 2,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
[settled_client, straggler_client].into_iter(),
network_opts,
);
let client = SimClient::new(settled_client);
sim.register_client_with_primary(&client);
for _ in 0..100 {
sim.step();
}
let (baseline_head, baseline_commit) = metadata_progress(&sim, 1);
assert_eq!(
baseline_head, baseline_commit,
"the cluster must be quiescent before the straggler is staged"
);
// Stage the inherited suffix: the straggler's register reaches the
// next primary's journal, then the old primary dies before the commit
// makes it back.
let straggler = SimClient::new(straggler_client);
sim.submit_request(straggler_client, 0, straggler.register().into_generic());
let mut staged = None;
for _ in 0..200 {
sim.step();
let (head, commit_max) = metadata_progress(&sim, 1);
if head > baseline_head && commit_max < head {
staged = Some(head);
break;
}
}
let staged =
staged.expect("the register must reach the next primary's journal before it commits");
// Both survivors' next persists suspend once, opening the window a
// real fsync has.
sim.replicas[1].superblock.set_yield_writes();
sim.replicas[2].superblock.set_yield_writes();
sim.replica_crash(0);
// The straggler's retry loop, as the server runs it: `dispatch` spawns
// the in-process submit on its own task, which is what can interleave
// with the parked pump. The sim's wire path processes requests inside
// the pump itself, so the window is only reachable from a spawned
// task. A plain once-per-step retry is never ready inside the drain
// where the pump flips to primary and suspends on the persist, so
// each tick wake spends a small budget of yield-separated attempts:
// the yields land the retry between the pump's polls, one of which is
// the suspended view-start persist.
let registered = std::rc::Rc::new(std::cell::Cell::new(false));
let submit_shard = std::rc::Rc::clone(&sim.replicas[1].shards[0]);
let submit_flag = std::rc::Rc::clone(&registered);
sim.executor.spawn(async move {
loop {
for _ in 0..32 {
match submit_shard
.plane
.metadata()
.submit_register_in_process(straggler_client, 0)
.await
{
Ok(_) => {
submit_flag.set(true);
return;
}
Err(error) if error.is_transient() => yield_once().await,
Err(_) => return,
}
}
submit_shard
.bus
.sleep(std::time::Duration::from_millis(10))
.await;
}
});
for _ in 0..1500 {
sim.step();
if registered.get() {
break;
}
}
assert!(
registered.get(),
"the straggler's login must complete after the failover"
);
let primary = (1..replica_count)
.find(|&replica| is_new_metadata_primary(&sim, replica))
.expect("a metadata primary must be elected after the old one crashes");
let (_, commit_max) = metadata_progress(&sim, primary);
assert!(
commit_max >= staged,
"the inherited op ({staged}) must commit under the new primary \
(commit_max = {commit_max})"
);
}
/// Whether a replica's shard-0 metadata consensus is a settled primary in a
/// view past the one that crashed.
fn is_new_metadata_primary(sim: &Simulator, replica: u8) -> bool {
sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.is_some_and(|consensus| {
consensus.view() > 0
&& consensus.status() == Status::Normal
&& consensus.is_primary()
})
}
/// `(head op, commit_max)` of a replica's shard-0 metadata consensus.
fn metadata_progress(sim: &Simulator, replica: u8) -> (u64, u64) {
let consensus = sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.expect("shard 0 owns metadata consensus");
(
consensus.sequencer().current_sequence(),
consensus.commit_max(),
)
}
/// Whether a replica's metadata journal holds `op`.
fn metadata_holds(sim: &Simulator, replica: u8, op: u64) -> bool {
let journal = sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.journal
.as_ref()
.expect("shard 0 owns the metadata journal");
let slot = usize::try_from(op).expect("op fits usize");
Journal::header(journal.as_ref(), slot).is_some()
}
/// Drop `op` from a replica's metadata journal, leaving a hole.
fn metadata_forget(sim: &Simulator, replica: u8, op: u64) -> bool {
sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.journal
.as_ref()
.expect("shard 0 owns the metadata journal")
.forget_op(op)
}
#[test]
fn given_committed_op_missing_on_next_primary_when_primary_crashes_should_survive_view_change()
{
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
// Commit some metadata ops so there is a log to lose. Registering binds
// a session, and seeding a stream/topic/partition commits several more.
sim.register_client_with_primary(&client);
sim.seed_stream_topic_partition(IggyNamespace::new(1, 1, 0));
for _ in 0..200 {
sim.step();
}
// Replica 0 is primary for view 0, so replica 1 is primary-elect for view 1
// (view % replica_count): the replica whose hole decides the outcome.
let next_primary: u8 = 1;
let (_, committed) = metadata_progress(&sim, next_primary);
assert!(
committed > 0,
"the test needs committed metadata ops to be able to lose one"
);
// Every replica must hold the op: the point is that it IS recoverable, and
// only the incoming primary lacks it.
for replica in 0..replica_count {
assert!(
metadata_holds(&sim, replica, committed),
"replica {replica} must hold op {committed} before the hole is punched"
);
}
// Punch the hole: the incoming primary forgets an op its peers still hold.
assert!(
metadata_forget(&sim, next_primary, committed),
"op {committed} must have been present to forget"
);
sim.replica_crash(0);
for _ in 0..1500 {
sim.step();
}
// A primary must emerge among the survivors.
let primary = (1..replica_count)
.find(|&replica| is_new_metadata_primary(&sim, replica))
.expect("a metadata primary must be elected after the old one crashes");
let (head, commit_max) = metadata_progress(&sim, primary);
// The committed op must not have been discarded.
assert!(
head >= committed,
"the new primary's head ({head}) regressed below the committed op ({committed}); \
a committed, acknowledged op was discarded by the view change"
);
assert!(
commit_max >= committed,
"commit_max ({commit_max}) regressed below the committed op ({committed})"
);
// And back in the new primary's journal: the view change repaired the hole
// from a peer that offered the body, rather than declaring the op lost.
assert!(
metadata_holds(&sim, primary, committed),
"op {committed} must be repaired back into the new primary's journal"
);
}
}
/// Whether a setup-handshake reply is a result-framed transport rejection rather
/// than an answer. `build_result_rejection_reply` carries the reason in the result
/// section under the request's own operation with `status` left at 0, so nothing in
/// the header distinguishes it from a commit and the code is the only signal.
pub(crate) fn setup_reply_is_transient(reply: &Message<ReplyHeader>) -> bool {
let header = reply.header();
let Some(body) = reply
.as_slice()
.get(size_of::<ReplyHeader>()..header.size as usize)
else {
return false;
};
iggy_binary_protocol::result_code(body)
.and_then(workload::TransientRejection::from_code)
.is_some()
}
#[cfg(test)]
mod repair_frontier_tests {
//! Journal repair moves durable coverage, not the head of the hash chain.
//!
//! A replica that rejoins behind the group adopts the primary's head and
//! then backfills the ops it missed. Those land BELOW that head, so the
//! pair `(sequencer, last_prepare_checksum)` has to keep describing one
//! and the same entry: the pair is exactly what the next projected prepare
//! stamps as `(op, parent)`. Carrying the repaired frame's own checksum
//! instead rewinds the parent, and the next prepare then chains past the
//! entry that actually precedes it -- every entry individually well
//! sealed, the chain broken, and a WAL that refuses to boot as soon as a
//! rewrite (checkpoint drain or uncommitted-suffix truncation) puts the
//! two entries side by side.
use super::*;
use consensus::Sequencer;
use journal::Journal;
use std::collections::BTreeMap;
/// Replica 0 is primary for view 0, so this one stays a backup for the
/// whole run: only the repair ingest is under test, not an election.
const LAGGING: u8 = 1;
/// Committed ops the lagging replica misses and has to repair back.
const OPS_MISSED: usize = 20;
/// Steps allowed for the rejoin, the adoption, and the repair stream.
const REPAIR_STEPS: usize = 4000;
/// Highest op the journal probe walks. The workload stays far below it.
const OP_PROBE_CEILING: u64 = 512;
/// `(sequencer, last_prepare_checksum)` of a replica's metadata consensus:
/// the `(op, parent)` its next projected prepare would stamp.
fn metadata_chain_head(sim: &Simulator, replica: u8) -> (u64, u128) {
let consensus = sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.consensus
.as_ref()
.expect("shard 0 owns metadata consensus");
(
consensus.sequencer().current_sequence(),
consensus.last_prepare_checksum(),
)
}
/// Every op a replica's metadata journal holds, with its checksum.
fn metadata_journal_checksums(sim: &Simulator, replica: u8) -> BTreeMap<u64, u128> {
let journal = sim.replicas[replica as usize].shards[0]
.plane
.metadata()
.journal
.as_ref()
.expect("shard 0 owns the metadata journal");
(1..=OP_PROBE_CEILING)
.filter_map(|op| {
let slot = usize::try_from(op).expect("op fits usize");
Journal::header(journal.as_ref(), slot).map(|header| (op, header.checksum))
})
.collect()
}
#[test]
fn given_repair_below_the_head_when_backfilling_should_not_rewind_the_parent() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_id: u128 = 1;
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: 1,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
std::iter::once(client_id),
network_opts,
);
let client = SimClient::new(client_id);
sim.register_client_with_primary(&client);
for _ in 0..50 {
sim.step();
}
sim.replica_crash(LAGGING);
for index in 0..OPS_MISSED {
let msg = client.create_stream(&format!("gap-{index}"));
sim.submit_request(client_id, 0, msg.into_generic());
for _ in 0..30 {
sim.step();
}
}
sim.replica_restart(LAGGING);
// Everything the restart recovered locally is the baseline; anything
// that appears from here arrived over the wire.
let mut journaled = metadata_journal_checksums(&sim, LAGGING);
let mut backfills_below_head = 0usize;
for _ in 0..REPAIR_STEPS {
sim.step();
let (head, parent) = metadata_chain_head(&sim, LAGGING);
let current = metadata_journal_checksums(&sim, LAGGING);
for (&op, &checksum) in &current {
// A live replicated op IS the head, and the pair moves with it.
// Only entries that landed below the head are repair backfill.
if journaled.contains_key(&op) || op >= head {
continue;
}
backfills_below_head += 1;
assert_ne!(
parent,
checksum,
"repairing op {op} rewound the parent of the next prepare: the \
sequencer sits at op {head} but last_prepare_checksum now \
describes op {op}, so the next projected prepare would be op \
{} parented past op {head}",
head + 1
);
}
journaled = current;
}
// The contract itself, not just the absence of a rewind: after the
// repair stream the pair has to describe one and the same entry, or
// the next projected prepare parents on something that is not its
// predecessor.
let (head, parent) = metadata_chain_head(&sim, LAGGING);
let journaled = metadata_journal_checksums(&sim, LAGGING);
assert_eq!(
journaled.get(&head).copied(),
Some(parent),
"the sequencer sits at op {head} but last_prepare_checksum describes \
op {:?}, so the next projected prepare would parent past op {head}",
journaled
.iter()
.find(|&(_, &checksum)| checksum == parent)
.map(|(&op, _)| op)
);
assert!(
backfills_below_head > 0,
"the rejoined replica never repaired an op below its own head, so \
nothing about the repair frontier was exercised"
);
}
}