blob: 64aecc440a241057dcd73c1ba6f8812c81b40758 [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 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::{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};
use rand::RngExt;
use rand_xoshiro::Xoshiro256Plus;
use rand_xoshiro::rand_core::SeedableRng;
use replica::{Replica, SIM_INBOX_CAPACITY, new_shard};
use server_common::Message;
use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
use shard::CONSENSUS_TICK_INTERVAL;
use shard::shards_table::{ShardsTable, calculate_shard_assignment};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::rc::Rc;
/// Poll budget per [`DetExecutor::run_until_stalled`] call. The pumps are
/// event-driven, so hitting this means a task is spin-waking: always a bug,
/// surfaced as a panic carrying the seed.
const POLL_BUDGET: u32 = 100_000;
/// Salt for the entry-shard PRNG stream (sibling of the workload fault salt
/// and [`executor::EXECUTOR_SEED_SALT`]).
///
/// In production the shard-0 coordinator round-robins inbound connections
/// across shards, so the shard that receives a peer's bytes is unrelated to
/// the shard owning the target consensus group. The sim models that homing
/// with a seeded uniform pick per delivered packet; an independent stream
/// keeps those draws from perturbing network or workload traces.
pub const ENTRY_SHARD_SEED_SALT: u64 = 0x5A1A_F0E5_FACE_0003;
/// One simulated replica: its shards plus the executor bookkeeping needed
/// to crash it. One entry per shard in `shards`/`pump_tasks` (a single
/// shard until multi-shard lands).
pub struct SimReplica {
/// Shards of this replica, indexed by shard id.
pub shards: Vec<Rc<Replica>>,
/// Shard 0's durable superblock, held here rather than inside the shard so its
/// bytes survive the shard being dropped and rebuilt across a restart.
pub superblock: Rc<SimSuperblock>,
/// Shard 0's metadata WAL, held here for the same reason as the superblock: the
/// bytes and index survive a restart, so a rebuilt replica recovers its
/// op/commit and committed metadata from its own disk. Only shard 0 owns
/// metadata consensus, so this is the single retained journal.
pub metadata_journal: Rc<SimJournal<MemStorage>>,
/// Shard 0's metadata consensus incarnation nonce. Harness-owned: a seed-derived
/// value bumped by one on each restart, so successive incarnations are distinct
/// yet the run stays byte-identical on replay. See
/// `VsrConsensus::set_incarnation`.
pub metadata_incarnation: u128,
/// One durable superblock per partition group this replica has materialised,
/// harness-owned for the same reason as the metadata one: the bytes survive
/// the shards being dropped and rebuilt, so a re-materialised group recovers
/// the `(view, log_view)` it recorded instead of re-entering view 0. Without
/// a store the persist gate marks every view durable without writing, which
/// leaves the gate, its write-failure fence, and view recovery all
/// unexercised.
pub partition_superblocks: RefCell<HashMap<IggyNamespace, Rc<SimSuperblock>>>,
/// Keeps each pump's stop channel alive; dropping one would end that
/// pump gracefully, which is reserved for future shutdown/restart
/// tests (crash uses `DetExecutor::abort` instead).
_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
/// Panics if the shard count does not fit `u32` (impossible: 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)]
}
}
pub struct Simulator {
/// All replicas, indexed by replica id. Always fully populated — crashed
/// replicas are kept alive but skipped during dispatch.
pub replicas: Vec<SimReplica>,
/// Per-replica outbox, indexed by replica id. Shared with consensus inside
/// each replica via [`SharedSimOutbox`](bus::SharedSimOutbox).
pub outboxes: Vec<Rc<SimOutbox>>,
/// Set of replica ids that are currently crashed. Dispatch and outbox drain
/// are skipped for these ids.
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 network seed, so the schedule replays with it.
executor: DetExecutor,
/// Picks which shard of a replica receives each inbound packet,
/// modeling the coordinator's connection homing (see
/// [`ENTRY_SHARD_SEED_SALT`]).
entry_rng: Xoshiro256Plus,
/// Network seed, kept for livelock diagnostics.
seed: u64,
/// Dispatch-shell mode: when set, inbound client packets are delivered
/// through the real `on_client_request` handler (see
/// [`shard::IggyShard::deliver_client_request`]) instead of the raw
/// `dispatch` routing. Chosen at construction via
/// [`Simulator::with_shards_shell`].
shell: bool,
}
impl Simulator {
/// New simulator with per-replica outboxes routed through a [`Network`].
///
/// # Panics
/// Panics if `clients` yields duplicate `client_id`s. The auditor
/// keys in-flight entries by `(client_id, request)` and the network
/// indexes packet routes by `client_id`; duplicates would 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 on every replica,
/// meshed exactly like server-ng bootstrap: metadata plane on shard 0,
/// partitions hash-assigned, one pump task per shard.
///
/// # Panics
/// 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 deterministic dispatch shell on:
/// every shard wires server-ng'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
/// 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,
)
}
#[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 {
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: prepare timestamps
// become a pure function of the seed instead of 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 they outlive a replica restart, which drops and
// rebuilds the shards: the superblock's VSR state and the metadata WAL
// both survive.
let superblock = Rc::new(SimSuperblock::default());
let metadata_journal = Rc::new(SimJournal::<MemStorage>::default());
// Initial incarnation, spread across the 128-bit space per replica so the
// restart increments in `replica_restart` never collide across replicas.
// Non-zero.
let metadata_incarnation = 1 + (u128::from(id) << 64);
// One crossfire mesh per replica; every shard gets a clone of
// the canonical senders vec and exclusively takes its inbox.
let (senders, mut inboxes) =
shard::shard_mesh_channels(shards_per_replica, 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 (mirrors server-ng bootstrap): shard 0
// builds the writable STM and mints a factory bundle; every peer
// shard rebuilds a reader-mode mirror from it and sees committed
// metadata through the shared read handle. Shards are built in index
// order, so shard 0's bundle exists before any peer needs it.
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");
// Only shard 0 owns metadata consensus, so only it carries the
// superblock. Peer shards 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,
consensus_clock.clone(),
shell,
metadata_bundle.clone(),
shard_superblock,
shard_journal,
None, // fresh boot: no recovered VSR state
metadata_incarnation,
);
if shard_idx == 0 {
metadata_bundle = Some(
writer_bundle
.expect("shard 0 returns the metadata factory bundle for peers"),
);
}
// Same wiring as server-ng bootstrap: 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);
pump_tasks.push(executor.spawn(async move {
pump_shard.run_message_pump(stop_rx).await;
}));
stop_txs.push(stop_tx);
shards.push(shard);
}
replicas.push(SimReplica {
shards,
superblock,
metadata_journal,
metadata_incarnation,
partition_superblocks: RefCell::new(HashMap::new()),
_stop_txs: stop_txs,
pump_tasks,
});
outboxes.push(outbox);
}
Self {
replicas,
outboxes,
crashed: HashSet::new(),
network,
replica_count: rc,
client_ids,
executor,
entry_rng: Xoshiro256Plus::seed_from_u64(seed ^ ENTRY_SHARD_SEED_SALT),
seed,
shell,
}
}
/// Init a partition with its own consensus group on every live replica.
///
/// Mirrors 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 of the replica gets the routing row
/// stamped with the committed `created_revision` (production seeds rows
/// through `ReconcileOp::{InsertOwned,InsertRouted}`).
///
/// # Panics
/// Panics if a replica's shard count does not fit `u32` (impossible:
/// mesh construction caps it at `u16`).
#[allow(clippy::cast_possible_truncation)]
pub fn init_partition(&mut self, namespace: IggyNamespace) {
for (i, replica) in self.replicas.iter().enumerate() {
if self.crashed.contains(&(i as u8)) {
continue;
}
materialise_partition(replica, namespace);
}
}
/// Seed the metadata `Streams` STM on each live replica's shard-0 writer
/// so a poll's namespace resolution (`resolve_partition_namespace`)
/// succeeds for `namespace`, which the partition-plane-only
/// [`Self::init_partition`] does not populate. Peer shards observe the
/// seed through their shared left-right read handle, so only shard 0 is
/// seeded (a direct seed on a reader-mode peer STM would panic). The
/// simulator does not wire the reconciler, so this bypasses it the same
/// way `init_partition` bypasses it for the partition plane. Pair it with
/// `init_partition` for the same namespace on the dispatch-shell poll path.
///
#[allow(clippy::cast_possible_truncation)]
pub fn seed_stream_topic_partition(&self, namespace: IggyNamespace) {
for (i, replica) in self.replicas.iter().enumerate() {
if self.crashed.contains(&(i as u8)) {
continue;
}
// Shard 0 is the sole metadata writer; peers share its read handle
// and see the seed through the left-right publish, so seeding a
// peer's reader-mode STM directly would panic.
replica.shards[0]
.plane
.metadata()
.mux_stm
.streams()
.seed_namespace(namespace, namespace.inner());
}
}
/// 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 on the client. Requires the shell on and
/// the root user seeded (see [`new_shard`]); target is the primary
/// (replica 0), as [`Self::register_client_with_primary`] does.
///
/// # Panics
/// If no login reply arrives within 200 steps or it carries no session.
pub fn shell_login(&mut self, client: &SimClient) {
// Register the client's 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 session 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,
));
}
let msg = client.login(replica::SHELL_ROOT_USERNAME, replica::SHELL_ROOT_PASSWORD);
self.submit_request(client.client_id(), 0, msg.into_generic());
let mut session = 0u64;
let mut got_reply = false;
for _ in 0..200 {
if let Some(reply) = self.step().first() {
// The login reply carries the assigned session in `op`
// (`build_reply_with_body` maps the session field to `op`).
session = reply.header().op;
got_reply = true;
break;
}
}
assert!(got_reply, "shell_login: no login reply within 200 steps");
assert!(session > 0, "shell_login: login reply carried no session");
client.bind_session(session);
}
/// Advance the simulation by one tick. Returns client replies delivered.
///
/// Every shard runs its real message pump as an executor task, so a
/// step is: fire the virtual consensus tick, let the pumps run to
/// quiescence, feed network packets into the shard routers, let the
/// pumps process the resulting frames, then exchange outboxes with the
/// network. Interleaving between pumps is a seeded executor pick;
/// everything a step produces lands on the wire before network time
/// advances, matching the pre-executor phase semantics.
///
/// # Panics
/// If a client-addressed packet cannot be decoded as `ReplyHeader`, or
/// if a pump livelocks (poll budget exhausted).
#[allow(clippy::cast_possible_truncation)]
pub fn step(&mut self) -> Vec<Message<ReplyHeader>> {
let mut client_replies = Vec::new();
// 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 it does 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: every client request enters through the
// real `on_client_request` handler (as the client-fd
// listener does in production), so it drains as a task
// the executor interleaves with the pump. Partition
// writes now use the same legacy `SendMessages` wire
// shape the real SDK sends, so
// `resolve_partition_request_namespace` decodes them
// on this path. Consensus frames (replica-sourced)
// always 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(_) => {
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
}
/// 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 this is a
/// spin-waking task, i.e. a livelock bug. The seed reproduces it.
///
/// 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-wake tripwire. At executor quiescence every live pump must have
/// drained its inbox: a non-empty inbox on a non-crashed replica means a
/// frame reached the channel without waking the target pump. Because
/// every pump holds a standing `CONSENSUS_TICK_INTERVAL` timer, the next
/// `advance_time` would re-poll and silently drain it, masking the exact
/// wake-loss class this harness exists to catch, so trip here instead.
///
/// Incomplete by construction: it catches a lost wakeup only while the
/// un-woken frame is still queued at quiescence. A later frame that does
/// wake the pump drains the whole inbox (the recv loop pulls every queued
/// frame), so a lost wake masked by a subsequent drain slips through. The
/// direction is safe: 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 any
/// frame stranded in their inbox 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(),
);
}
}
}
/// Submit a client request into the simulated network. Equivalent to 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,
);
}
/// 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 100 steps.
#[allow(clippy::cast_possible_truncation)]
pub fn register_client_with_primary(&mut self, client: &SimClient) {
let msg = client.register();
self.submit_request(client.client_id(), 0, msg.into_generic());
let mut session = 0u64;
let mut got_reply = false;
for _ in 0..100 {
let replies = self.step();
if !replies.is_empty() {
let header = replies[0].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,
);
session = header.commit;
got_reply = true;
break;
}
}
assert!(
got_reply,
"register_client_with_primary: no reply within 100 steps"
);
client.bind_session(session);
// Partition has no `client_table`: at-least-once, no per-client
// dedup. Consumers dedup via message id / content / producer-id+seq.
// Sessions/dedup/eviction live on metadata only (IggyMetadata).
}
/// Crash a replica: abort its pump tasks, disable its network links, and discard
/// its outbox. The replica object stays alive but receives no messages; a
/// following [`Self::replica_restart`] drops and rebuilds it from the durable
/// superblock, which is when 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 the pumps: 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);
}
// Tear down any detached dispatch tasks this replica's bus spawned
// (off-pump poll IO, request drains), so a crash leaves no orphaned
// tasks running against the 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 them against the retained
/// superblock, recovering `(view, log_view)` from disk exactly as production's
/// `restore_metadata_consensus` does. The superblock and outbox are
/// harness-owned, so they survive the drop; a fresh inter-shard mesh and pump
/// tasks are wired, and the network is re-enabled.
///
/// # Panics
/// If the replica is not crashed, or if its shard count does not fit `u16`,
/// impossible since 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 its bytes and index survive the
// drop: the rebuilt shard 0 recovers op/commit and committed state from it,
// not from an empty journal.
let metadata_journal = Rc::clone(&self.replicas[idx].metadata_journal);
// Bump the incarnation on restart, so a StartView addressed to the previous
// incarnation and still in flight is ignored. Deterministic, so replay stays
// byte-identical.
let metadata_incarnation = self.replicas[idx].metadata_incarnation + 1;
// Partition superblocks carry forward too: a group re-materialised after
// the restart must recover its recorded view from the same store, exactly
// as a rebooted server-ng partition reads the record in its directory.
let partition_superblocks =
std::mem::take(&mut *self.replicas[idx].partition_superblocks.borrow_mut());
// Recover the 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());
let consensus_clock = ConsensusClock::new(Rc::new(SimClock::new(self.executor.timer())));
let outbox = Rc::clone(&self.outboxes[idx]);
let (senders, mut inboxes) =
shard::shard_mesh_channels(shards_per_replica, 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 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,
consensus_clock.clone(),
self.shell,
metadata_bundle.clone(),
shard_superblock,
shard_journal,
recovered_state,
metadata_incarnation,
);
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);
pump_tasks.push(self.executor.spawn(async move {
pump_shard.run_message_pump(stop_rx).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 forward.
self.replicas[idx] = SimReplica {
shards,
superblock,
metadata_journal,
metadata_incarnation,
partition_superblocks: RefCell::new(partition_superblocks),
_stop_txs: stop_txs,
pump_tasks,
};
// Re-materialise every group this replica had before the crash, as a
// rebooted server-ng re-opens every partition directory it owns. This
// is what makes the carried-forward superblock load-bearing: the group
// recovers the `(view, log_view)` it recorded instead of re-entering
// view 0.
// SORTED: `HashMap` iteration order is seeded per process, and
// materialisation order is observable (shard init order, routing-row
// stamps), so replay would stop being byte-identical.
let mut materialised: Vec<IggyNamespace> = self.replicas[idx]
.partition_superblocks
.borrow()
.keys()
.copied()
.collect();
materialised.sort_unstable_by_key(IggyNamespace::inner);
for namespace in materialised {
materialise_partition(&self.replicas[idx], namespace);
}
// Reconnect to the network and mark the replica live again.
self.network
.process_enable(ProcessId::Replica(replica_index));
self.crashed.remove(&replica_index);
}
/// Advance consensus timeouts on every live replica without a full
/// step cycle: fires the pumps' virtual tick timers and runs 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 sim's partitions are in-memory (no `partition_dir`), so the plan
// serves only the resident journal tier; `execute` performs no disk IO.
//
// This is the one `block_on` allowed to stay, and only because
// `plan.execute()` cannot suspend here: the sim's partitions are
// in-memory, so the plan serves the resident journal tier with no disk
// IO and no `bus.sleep`. If it ever grew a suspending await it would
// fail two ways -- on the virtual clock it would hang this thread
// forever (the clock only advances through `advance_time`, which does
// not run during `block_on`), and on the retry path it would panic on
// the compio timer outside a compio runtime. Safe today only because
// it runs between `run_pumps` calls, when the executor is quiescent and
// no pump can hold 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}"
)));
};
// The simulator drives partitions directly, so it never replicates a
// poll's auto-commit (that is the serving shard's job in the real
// server); the surfaced offset is discarded here.
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 the
/// namespace is not present on that replica.
#[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()))
}
/// 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. Sound only while the primary is static, which holds today: the
/// driver spares primaries from crashes, so no crash-triggered view change
/// runs mid-test. Once primary-crash injection lands, views can diverge and
/// this may name a stale or crashed primary (see
/// `workload::oracle::assert_converged` for the consequence and fix).
#[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-ng re-opens every partition directory it owns, so the sim has to
/// re-materialise too, otherwise 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) {
let shard_count = u32::try_from(replica.shards.len()).expect("shard count fits u32");
let owner = calculate_shard_assignment(&namespace, shard_count);
// One store per group, minted on first materialisation and reused on every
// later one, 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());
replica.shards[usize::from(owner)].init_partition(namespace, Some(superblock), recovered_state);
// Commit the namespace before stamping the rows: a partition the metadata
// plane never heard of is a shape production cannot produce, and the shard
// refuses to serve 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());
let epoch = streams
.created_revision_for_namespace(namespace)
.expect("namespace committed by the seed above");
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::MemoryPoolConfigOther {
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 metadata replica that advanced its view, persisted it through the superblock
/// gate, then crashed recovers that same view from its own disk on restart, not a
/// fresh 0. The split-brain guarantee: a replica never forgets a view it acted in.
/// Impossible before the superblock, since a rebuilt consensus starts at view 0.
#[test]
fn given_advanced_view_when_metadata_replica_restarts_should_recover_view_from_superblock() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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"
);
// This run takes zero traffic, so every WAL is empty: the recovered view came
// from the superblock alone. Pin that, since it is what makes the restart 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 must
// rejoin as a probing backup. It is still primary-by-index for the recovered
// view, so resuming primaryship here 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_committed_metadata_when_solo_replica_restarts_should_recover_from_own_wal() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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::MemoryPoolConfigOther {
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"
);
}
#[test]
fn given_superblock_write_fails_when_primary_crashes_should_withhold_votes_and_not_elect() {
// The split-brain gate under test: a view-scoped send (SVC, DVC, StartView)
// must not go out until the new view is durable. Fail one survivor's
// superblock writes so its persist gate returns false, advancing its view
// in-memory while withholding every view-scoped send. In a 3-replica cluster,
// quorum 2, crashing the primary leaves one working survivor whose lone vote
// cannot reach quorum, so NO new primary is elected. Were the gate to send
// before persisting, the withheld votes would reach the peer and elect a
// primary that has no durable record of the new view, reintroducing
// split-brain on its restart. The positive control is
// `given_advanced_view_when_metadata_replica_restarts_...`, which elects a new
// primary from the same crash with healthy superblocks.
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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 the crash and is the 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"
);
// The retry is bounded. Without a backoff the 10 ms consensus tick would run a
// full `atomic_replace` (create, write, fsync, rename, dir fsync) on every tick
// for as long as the disk stays broken, on the executor that also serves
// partition traffic.
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)"
);
}
/// At-least-once failover: `SendMessages` retry on a new primary
/// re-executes. Retry reply carries a HIGHER `commit` op (re-execution
/// proof, not dedup). Duplicate payload lives at two offsets; consumers
/// dedup if they need at-most-once-per-payload.
#[test]
fn failover_retry_re_executes_under_at_least_once() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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;
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 SAME request to new primary. No dedup -> re-execution.
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; new primary re-commits as \
fresh prepare (at-least-once)",
);
// At-least-once: same request id (correlation), HIGHER commit op
// (re-execution). No dedup absorbs the retry.
assert_eq!(
retry_reply.header().request,
original_request_id,
"retry's reply must correlate to the request id"
);
assert!(
retry_reply.header().commit > original_commit_op,
"retry must re-execute (commit op > original={original_commit_op}, got {})",
retry_reply.header().commit
);
assert_eq!(
retry_reply.header().client,
client_id,
"retry must echo original client_id"
);
}
/// Regression: a behind backup (`commit_min < commit_max`) becoming
/// primary must not panic during the `CommitMessage` heartbeat timeout.
/// `handle_commit_message_timeout` used to assert `commit_min == commit_max`.
#[test]
fn view_change_behind_backup_becomes_primary() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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);
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 several messages so primary commits ahead of backups.
// Backups receive prepares but may lag on commit (`commit_max` <
// primary's `commit_min`): commit point only propagates via later
// Prepare headers or Commit heartbeats.
for i in 0..3 {
let msg = client.send_messages(ns, &[Bytes::from(format!("msg-{i}"))]);
sim.submit_request(client_id, 0, msg.into_generic());
// Few steps: enough for replication, not enough for backups
// to fully learn the commit point.
for _ in 0..10 {
sim.step();
}
}
// Crash the primary immediately. Backups may have commit_min < commit_max.
sim.replica_crash(0);
// Run view change. This must not panic in handle_commit_message_timeout.
for _ in 0..800 {
sim.step();
}
// Verify a new primary was elected and is functional.
let mut new_primary_found = false;
for idx in 1..replica_count {
let c = sim.replicas[idx as usize].shards[0]
.plane
.partitions()
.get_by_ns(&ns)
.expect("partition must exist on every live replica")
.consensus();
if c.view() > 0 && c.status() == Status::Normal && c.is_primary() {
new_primary_found = true;
}
}
assert!(new_primary_found, "expected a new primary");
}
/// 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::MemoryPoolConfigOther {
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 whenever reply shape, partition commit values, or
// PRNG draw order change. Draw order is sensitive to `pick_outcome`: adding
// an outcome to an op sampled in this seed's window, or a weight bump,
// shifts the trace. Re-lock on intentional changes; expect re-locks until
// error discriminants and reply bodies stabilize the wire format.
//
// Re-locked when the sim adopted METADATA_CONSENSUS_NAMESPACE (1<<63)
// for metadata requests and the metadata consensus group, replacing
// the sim-only 0: reply headers and the per-group timeout-jitter seed
// (replica_id ^ namespace) both changed. The old 0 only ever routed
// correctly because `hash % 1 == 0` at one shard per replica.
assert_eq!(
h1, 0x530D_499C_5DBE_A2BE,
"workload reply hash drifted from locked baseline"
);
}
/// Drive workload with uniform weights across all 25 `Action` variants.
/// Assert it runs without panic and observes at least one reply.
/// Per-op coverage not asserted: some ops can starve the in-flight slot
/// at single-client / 1-slot pipeline limits.
#[test]
fn uniform_weights_runs_clean() {
use crate::workload::{
Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
};
use strum::{EnumCount, IntoEnumIterator};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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);
let ns_b = server_common::sharding::IggyNamespace::new(1, 1, 1);
sim.init_partition(ns_a);
sim.init_partition(ns_b);
sim.register_client_with_primary(&client);
// 25 variants × 4 = 100.
assert_eq!(Action::COUNT, 25, "Action::COUNT changed; adjust weights");
let entries: Vec<(Action, u8)> = Action::iter().map(|a| (a, 4)).collect();
let weights = ActionWeights::new(&entries);
let mut options = WorkloadOptions::new(0xC0FF_EE00, replica_count, vec![ns_a, ns_b]);
options.weights = weights;
let mut wl = Workload::new(options);
let mut replies_seen = 0u64;
for _tick in 0..2_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 cmds = wl.on_reply(&reply);
apply_sim_commands(&mut sim, &cmds);
replies_seen += 1;
}
}
assert!(
replies_seen > 0,
"uniform-weight workload produced no replies; sampling or dispatch broken"
);
}
/// The cheap per-tick invariants run inside `workload::run` and
/// stay green over a uniform 25-op single-client workload. A second pass
/// with a fresh `Invariants` confirms the checks observed live state
/// (non-vacuous): `commit_offset` is tracked for every (replica, namespace)
/// pair, not silently skipped.
#[test]
fn uniform_weights_invariants_hold() {
use crate::workload::{
self, Workload,
actions::Action,
invariants::Invariants,
options::{ActionWeights, WorkloadOptions},
};
use strum::{EnumCount, IntoEnumIterator};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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(
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);
let ns_b = server_common::sharding::IggyNamespace::new(1, 1, 1);
sim.init_partition(ns_a);
sim.init_partition(ns_b);
sim.register_client_with_primary(&client);
assert_eq!(Action::COUNT, 25, "Action::COUNT changed; adjust weights");
let entries: Vec<(Action, u8)> = Action::iter().map(|a| (a, 4)).collect();
let mut options = WorkloadOptions::new(0xC0FF_EE00, replica_count, vec![ns_a, ns_b]);
options.weights = ActionWeights::new(&entries);
let mut wl = Workload::new(options);
// `run` asserts the invariants every tick; any regression panics
// here, replayable from the seed above.
let clients = [client];
let replies = workload::run(&mut sim, &mut wl, &clients, 2_000, u64::MAX);
assert!(
replies > 0,
"uniform-weight workload produced no replies; invariants never exercised"
);
// Non-vacuity: the checks must have read live state for every pair.
let mut probe = Invariants::new();
probe.check(&sim, &wl);
assert_eq!(
probe.tracked_pairs(),
usize::from(replica_count) * 2,
"expected commit_offset tracked for every (replica, namespace) pair"
);
}
/// 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::MemoryPoolConfigOther {
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"
);
}
/// After a mixed metadata + partition run drains, the shadow's
/// predicted streams equal the metadata committed on the leader (the
/// entity-oracle payoff of the name-keyed shadow).
///
/// Interleaves creates, deletes, and partition sends from a single client.
/// A send between two metadata ops once consumed a metadata request number
/// and gapped the next create into a permanent `RequestGap`; the `SimClient`
/// per-plane numbering fix keeps the metadata sequence contiguous, so the
/// mix now drains. The entity oracle still compares only against the leader:
/// a quorum-excluded backup has no idle catch-up yet, 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::MemoryPoolConfigOther {
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]);
// Interleave metadata creates/deletes with partition sends: 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 entity
// 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, &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::MemoryPoolConfigOther {
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-plane workload under crashes: SendMessages replies and the
// partition plane converges across survivors. Stays partition-only by
// design to isolate partition-offset convergence from metadata loss to a
// crashed primary; the metadata entity oracle runs in the no-crash test.
// Crash one follower (keep 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, &wl);
}
/// Drive Create-heavy then Delete-heavy workload; assert shadow tracks
/// live streams:
///
/// - At least one `CreateStream` commits.
/// - At least one `DeleteStream` commits, proving sample picked a live
/// name (without shadow tracking, sample would return `None`).
/// - Shadow stream count matches net `creates - deletes`.
#[test]
fn shadow_tracks_live_streams() {
use crate::workload::{
Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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_0002,
..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);
// Phase 1: Create-heavy to populate the shadow.
let mut options = WorkloadOptions::new(0x5EED_0002, replica_count, vec![ns_a]);
options.weights = ActionWeights::new(&[(Action::CreateStream, 100)]);
let mut wl = Workload::new(options);
for _tick in 0..3_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 cmds = wl.on_reply(&reply);
apply_sim_commands(&mut sim, &cmds);
}
}
let created = wl.auditor.stats().commits_per_action[Action::CreateStream as usize];
assert!(created > 0, "Create-only workload produced no commits");
assert_eq!(
wl.shadow.stream_names.len() as u64,
created,
"shadow stream count diverged from CreateStream commits"
);
// Phase 2: Create/Delete mix. DeleteStream sample succeeds only if
// shadow.pick_stream_name returns Some (the wiring under test).
wl.options.weights =
ActionWeights::new(&[(Action::CreateStream, 30), (Action::DeleteStream, 70)]);
for _tick in 0..3_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 cmds = wl.on_reply(&reply);
apply_sim_commands(&mut sim, &cmds);
}
}
let deleted = wl.auditor.stats().commits_per_action[Action::DeleteStream as usize];
let created_total = wl.auditor.stats().commits_per_action[Action::CreateStream as usize];
assert!(
deleted > 0,
"DeleteStream never committed; shadow-driven sampling is broken \
(sample would return None unless pick_stream_name found a live name)"
);
let expected_live = created_total.saturating_sub(deleted);
assert_eq!(
wl.shadow.stream_names.len() as u64,
expected_live,
"shadow.stream_names.len() ({}) != creates ({}) - deletes ({}) = {}",
wl.shadow.stream_names.len(),
created_total,
deleted,
expected_live,
);
}
/// Drive workload with 4 concurrent clients over two namespaces; assert:
///
/// - Every client observes at least one commit (no starvation).
/// - Per-(client, namespace) commit-monotonic invariant holds.
/// - Commits interleave across clients.
#[test]
fn multi_client_interleaves_commits() {
use crate::workload::{
Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let replica_count: u8 = 3;
let client_ids: Vec<u128> = (1..=4).collect();
let network_opts = packet::PacketSimulatorOptions {
node_count: replica_count,
client_count: u8::try_from(client_ids.len()).expect("fits"),
seed: 0x5EED_0005,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::new(
replica_count as usize,
client_ids.iter().copied(),
network_opts,
);
let clients: Vec<client::SimClient> = client_ids
.iter()
.map(|&id| client::SimClient::new(id))
.collect();
let ns_a = server_common::sharding::IggyNamespace::new(1, 1, 0);
let ns_b = server_common::sharding::IggyNamespace::new(1, 1, 1);
sim.init_partition(ns_a);
sim.init_partition(ns_b);
for c in &clients {
sim.register_client_with_primary(c);
}
let mut options = WorkloadOptions::new(0x5EED_0005, replica_count, vec![ns_a, ns_b]);
options.client_count = u8::try_from(clients.len()).expect("fits");
options.weights = ActionWeights::new(&[
(Action::CreateStream, 5),
(Action::SendMessages, 70),
(Action::StoreConsumerOffset2, 25),
]);
let mut wl = Workload::new(options);
let mut commits_per_client: std::collections::HashMap<u128, u64> =
std::collections::HashMap::new();
let mut replies_seen = 0u64;
for _tick in 0..4_000u32 {
for c in &clients {
if let Some((target, msg)) = wl.build_request(c) {
sim.submit_request(c.client_id(), target, msg.into_generic());
}
}
for reply in sim.step() {
let client_id = reply.header().client;
let cmds = wl.on_reply(&reply);
apply_sim_commands(&mut sim, &cmds);
*commits_per_client.entry(client_id).or_insert(0) += 1;
replies_seen += 1;
}
}
assert!(
replies_seen > 0,
"multi-client workload produced no replies"
);
for &id in &client_ids {
let count = commits_per_client.get(&id).copied().unwrap_or(0);
assert!(
count > 0,
"client {id} observed no commits; multi-client routing is starving \
(counts: {commits_per_client:?})",
);
}
let distinct = commits_per_client.values().filter(|&&c| c > 0).count();
assert!(
distinct >= 2,
"commits concentrated on a single client ({commits_per_client:?}); \
no interleaving observed"
);
}
/// Outcome-first generation: with a single client the strict equality oracle
/// is on, so any targeted-vs-committed mismatch panics in `on_reply`. Populate
/// streams, then drive a Create/Delete mix targeting error outcomes
/// (`NameAlreadyExists` by reusing a live name, `StreamNotFound` by fabricating
/// an absent one). Assert the server committed rejections, proving the error
/// paths are generated and verified end-to-end.
#[test]
fn outcome_first_generation_commits_targeted_rejections() {
use crate::workload::{
Workload,
actions::Action,
options::{ActionWeights, WorkloadOptions},
};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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_0E4C,
..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);
// Phase 1: populate streams so `NameAlreadyExists` has live targets.
let mut options = WorkloadOptions::new(0x5EED_0E4C, replica_count, vec![ns_a]);
options.weights = ActionWeights::new(&[(Action::CreateStream, 100)]);
let mut wl = Workload::new(options);
for _tick in 0..2_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 cmds = wl.on_reply(&reply);
apply_sim_commands(&mut sim, &cmds);
}
}
// Phase 2: keep creating (now hitting `NameAlreadyExists` on live names)
// and deleting (hitting `StreamNotFound` on fabricated names).
wl.options.weights =
ActionWeights::new(&[(Action::CreateStream, 50), (Action::DeleteStream, 50)]);
for _tick in 0..4_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 cmds = wl.on_reply(&reply);
apply_sim_commands(&mut sim, &cmds);
}
}
assert!(
wl.auditor.stats().committed_rejections > 0,
"outcome-first generation produced no committed rejections; error \
outcomes are not being targeted, or the server is not rejecting them"
);
}
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::StoreConsumerOffset2, 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, not just end-of-run aggregates.
// The reply cap lives inside the per-reply loop so a multi-reply
// tick at replies_seen=49 cannot leak a 50th+ reply 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.namespace,
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())
}
/// Assert no shard of any replica dropped an inter-shard frame. Runs
/// without injected loss must keep the counters at zero; a non-zero
/// value means an inbox silently shed a frame (undersized capacity or
/// a routing bug), which would otherwise hide behind VSR retransmit.
///
/// `park_overflow` is deliberately NOT excluded. The simulator never wires
/// the partition reconciler (`init_partition` mirrors its outcome directly),
/// so nothing here ever drains the park buffer: a parked frame is never
/// re-dispatched and never swept. A non-zero `park_overflow` in the simulator
/// therefore means frames were shed for a namespace that will never
/// materialise -- which is the very fault class this assert exists to catch,
/// not the back-pressure it would be in production.
///
/// For the same reason the park buffer must be empty at quiescence: a frame
/// still parked here has no drainer, so it will neither be 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 nothing will deliver or answer them"
);
}
}
}
/// A partition materialises only on its murmur3 owner shard; every
/// shard of a replica carries an identical routing row; metadata
/// consensus/journal state exists only on shard 0.
#[test]
fn multi_shard_partition_and_metadata_placement() {
use consensus::MetadataHandle;
use shard::shards_table::{ShardsTable, calculate_shard_assignment};
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let shards_per_replica: u16 = 4;
let network_opts = packet::PacketSimulatorOptions {
node_count: 3,
client_count: 1,
seed: 0x5EED_5AAD,
..packet::PacketSimulatorOptions::default()
};
let mut sim =
Simulator::with_shards(3, shards_per_replica, std::iter::once(1), network_opts);
let ns = IggyNamespace::new(1, 1, 0);
sim.init_partition(ns);
let owner = calculate_shard_assignment(&ns, u32::from(shards_per_replica));
for replica in &sim.replicas {
for (shard_idx, shard) in replica.shards.iter().enumerate() {
assert_eq!(
shard.plane.partitions().contains(&ns),
shard_idx == usize::from(owner),
"partition must live exactly on its hash owner (owner={owner})"
);
assert_eq!(
shard.shards_table().shard_for(ns),
Some(owner),
"every shard must carry the same routing row"
);
assert_eq!(
shard.plane.metadata().consensus.is_some(),
shard_idx == 0,
"metadata consensus must exist only on shard 0"
);
}
}
}
/// Single-writer metadata: a peer shard resolves a namespace against shard
/// 0's metadata through the shared left-right read handle. Before this,
/// every shard carried an independent writable STM, so a write that reached
/// only shard 0 (as a metadata consensus commit does) was invisible to
/// peers and a partition op homing on a peer shard failed to resolve its
/// namespace. Seeding only shard 0 and reading it back from every peer is
/// the direct proof of the read-handle propagation.
#[test]
fn peer_shard_resolves_namespace_via_shard0_read_handle() {
use iggy_binary_protocol::WireIdentifier;
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
enabled: false,
size: iggy_common::IggyByteSize::from(0u64),
bucket_capacity: 1,
});
let network_opts = packet::PacketSimulatorOptions {
node_count: 1,
client_count: 1,
seed: 0x5EED_B00C,
..packet::PacketSimulatorOptions::default()
};
// One replica, four shards: shard 0 writes metadata, shards 1..4 read it.
let sim = Simulator::with_shards(1, 4, std::iter::once(1), network_opts);
// Seeds only shard 0's writable STM (see `seed_stream_topic_partition`).
let ns = IggyNamespace::new(0, 0, 0);
sim.seed_stream_topic_partition(ns);
let resolve = |shard: &Rc<Replica>| {
shard
.plane
.metadata()
.mux_stm
.streams()
.namespace_from_partition(
&WireIdentifier::numeric(0),
&WireIdentifier::numeric(0),
0,
)
};
let shards = &sim.replicas[0].shards;
let writer_resolved = resolve(&shards[0]);
assert_eq!(
writer_resolved,
Some(ns),
"shard 0 (metadata writer) must resolve the seeded namespace"
);
for (shard_idx, peer) in shards.iter().enumerate().skip(1) {
assert_eq!(
resolve(peer),
writer_resolved,
"peer shard {shard_idx} must resolve via shard 0's shared read handle, \
not an independent STM"
);
}
}
/// View change at 5 replicas x 3 shards: crash the primary replica,
/// survivors elect a new primary for the partition group, and a
/// post-change send commits through the mesh.
#[test]
fn multi_shard_view_change_after_primary_crash() {
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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: 0x5EED_5C0C,
..packet::PacketSimulatorOptions::default()
};
let mut sim = Simulator::with_shards(
replica_count as usize,
3,
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 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..200 {
if !sim.step().is_empty() {
got_reply = true;
break;
}
}
assert!(got_reply, "expected reply before crash");
sim.replica_crash(0);
for _ in 0..800 {
sim.step();
}
let mut new_primary_found = false;
for replica_idx in 1..replica_count {
let consensus = sim.replicas[replica_idx as usize]
.partition_shard(ns)
.plane
.partitions()
.get_by_ns(&ns)
.expect("partition must exist on every live replica's owner shard")
.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 crash");
let live = sim.replicas[1].partition_shard(ns);
let live_consensus = live
.plane
.partitions()
.get_by_ns(&ns)
.expect("partition must exist on replica 1")
.consensus();
let new_primary_idx = live_consensus.primary_index(live_consensus.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");
}
/// 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"
);
}
/// 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::MemoryPoolConfigOther {
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()
}
/// Turning the dispatch shell on wires server-ng'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"
);
}
/// 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::MemoryPoolConfigOther {
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` now emits the legacy
// `SendMessagesHeader` wire shape the real SDK sends, so
// `resolve_partition_request_namespace` decodes it on the
// `handle_client_request` path. Both the write and the poll below now
// exercise the real dispatch layer.
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`), which runs as a task the
/// executor interleaves with the pump, and the whole login/produce/poll
/// round-trip replays byte-for-byte under 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 the reply bytes differ run-to-run; the executor schedule is seeded
// and must replay. This mirrors the workload tests, which hash reply
// headers rather than message bodies for the same reason.
assert_eq!(
schedule_a, schedule_b,
"shell schedule diverged at same seed"
);
}
/// 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)]` borrow tripwire. The correct `with_partition`
/// read drops the borrow before the suspension, so the same interleaving is
/// sound. Debug-only: the tripwire (and this detector) compile out in
/// release, exactly like the `BorrowGuard` they ride on.
///
/// The fault is injected via the synthetic `hold_borrow_across_await`, not
/// the real read, on purpose: the production read has no borrow-holding
/// suspension to seed. The partition journal read is synchronous (a pure
/// memory copy that never awaits) and `with_partition` returns an owned
/// `PollPlan` before the only awaits (disk read, offset persist) run off the
/// borrow in `spawn_poll_io`, so the real read is sound by construction.
///
/// TODO: once the simulator models storage faults, the disk-tier read
/// (`PollPlan::execute` -> `read_disk`) becomes a real, seedable await in the
/// read path. A regression holding a partition borrow across it, run against
/// a concurrent reconcile `InsertOwned` reallocation, would then trip this
/// detector end-to-end through the real handler, retiring 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::MemoryPoolConfigOther {
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
// task runs while it is parked (borrow live) -> the tripwire fires.
// `catch_unwind` builds the executor inline so unwinding drops the
// parked read's guard, restoring 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"
);
// Contiguity: the tripwire fires BEFORE `remove` touches the vec (the
// assert is its 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 very mutation the bad read's tripwire aborted now applies
// cleanly (no borrow was live across the suspension): `ns_b` is gone,
// `ns_a` intact -- 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"
);
}
/// 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::MemoryPoolConfigOther {
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()
}
/// 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:?}"
);
}
/// IGGY-66 acceptance: per-partition consensus independence. Block
/// `ns_a`'s `PrepareOk` acks at the network layer and fill its
/// pipeline to `PIPELINE_PREPARE_QUEUE_MAX`; a request on `ns_b`
/// still commits while `ns_a` is wedged (no quorum without backup
/// acks); lifting the block drains `ns_a` completely.
#[test]
fn per_partition_consensus_independence() {
use consensus::PIPELINE_PREPARE_QUEUE_MAX;
use iggy_binary_protocol::{Command2, 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 != Command2::PrepareOk {
return false;
}
let header: &PrepareOkHeader = bytemuck::checked::from_bytes(
&packet.message.as_slice()[..std::mem::size_of::<PrepareOkHeader>()],
);
header.namespace == BLOCKED_NS.load(Ordering::Relaxed)
}
server_common::MemoryPool::init_pool(&server_common::MemoryPoolConfigOther {
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")]);
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().namespace,
ns_b.inner(),
"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() {
if reply.header().namespace == ns_a.inner() {
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::MemoryPoolConfigOther {
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, &wl);
assert_no_frame_drops(&sim);
}
}