A deterministic harness that runs an Iggy cluster inside a single thread. The replicas are the real shard::IggyShard, running the real message pump, VSR consensus, metadata state machine and partitions. Only the environment under them is swapped: clock, journal storage, superblock, bus and network are in-memory doubles, and every task runs on a cooperative executor whose poll order is drawn from the run's seed.
A run is a pure function of that seed. Failures print it, and --seed <value> replays the same task interleaving, the same dropped packets, the same crashes, down to the prepare timestamps recorded in the log. Three production consensus bugs have been found this way, each of them reproducible from one seed and a handful of flags.
The crate is publish = false: a tool to run against the workspace, not a dependency.
# 10k ticks of partition-plane traffic, 3 replicas, perfect network cargo run -p simulator --bin workload-fuzz # metadata plane against a hostile network with crashes and restarts cargo run -p simulator --bin workload-fuzz -- \ --seed 48 --ticks 4000 --plane metadata --faults heavy \ --crash-prob 0.02 --restart-prob 0.08 --crash-primary # the properties below, pinned as regression tests cargo test -p simulator # scripted demo: send, create and delete a stream, crash a follower, poll cargo run -p simulator --bin simulator-ui
Nothing here needs Docker, a server binary, or a network stack. core/integration is where real processes are spawned.
| Layer | What runs |
|---|---|
| Shard runtime, router, dispatch | shard::IggyShard, one run_message_pump task per shard |
| Consensus | consensus::VsrConsensus, one group for the metadata plane and one per partition namespace |
| Metadata, partitions | the metadata STM, single writer on shard 0 with reader mirrors on the peers; partitions on the hash-owning shard |
| Client requests | real wire messages built by SimClient; with --shell, the server's on_client_request path including login and session binding |
| Clock | SimClock over executor virtual time, epoch pinned at 2026-01-01 |
| Journal, storage | SimJournal<MemStorage>, held by the harness so a WAL outlives the replica that wrote it |
| Superblock | SimSuperblock; RAM has no torn writes, so it carries none of PingPongSuperblock's framing or checksums |
| Bus and network | SimOutbox per replica, drained into PacketSimulator |
| Scheduling | DetExecutor, uniform seeded pick among ready tasks |
| Snapshots | the real SnapshotCoordinator writing real files, only under Simulator::with_checkpoints |
The simulator feature on shard, metadata, partitions and server_common exposes four seams that only this crate uses: init_partition (bypasses the reconciler), seed_single_partition (bypasses metadata consensus), hold_borrow_across_await (feeds the borrow detector a known-bad case), and a fixed-salt password hash so replicated user metadata replays. None has a production caller, and a -p iggy-server build excludes all four.
SimSeeds::derive(seed) splits the run's seed into one PRNG stream per consumer:
| Stream | Drives | Why it is separate |
|---|---|---|
network | delays, drops, replays, partitions, clogs | a workload change must not move the packet trace |
workload | which action, which entity, which argument | it shared a stream with network once, which is why children are drawn from a parent rather than salted off the seed |
executor | task-poll and timer-fire order | shaking out order dependence must not perturb traffic |
entry_shard | which shard receives each inbound packet | one draw per delivered packet, the highest-rate stream |
faults | crash and restart scheduling | both probabilities at zero draws nothing, so a fault-free run replays bit-identically |
swarm | --faults swarm's parameter draw | correlating the loss probability with the loss events would collapse the explored space to a diagonal |
Children are drawn from a parent PRNG in field order, so fields are appended to SimSeeds, never inserted: an insertion moves every child after it and re-locks every seeded baseline.
Time never flows on its own. DetExecutor::advance_time is the only thing that moves it, sleeps anchor their deadline at creation (matching compio::time::sleep, which the pump's re-arm relies on), and timers fire in (deadline, seq) order so equal deadlines resolve FIFO. run_pumps gives run_until_stalled a 100k poll budget; exhausting it means a task is spin-waking, and the panic names the seed and the schedule hash.
Simulator::step() returns the client replies delivered during it, in four phases:
CONSENSUS_TICK_INTERVAL: view change, retransmits) and run every pump to quiescence.--shell a client packet enters through deliver_client_request instead of raw dispatch.Everything a step produces is on the wire before network time moves, so a reply cannot chain into another delivery inside the same tick.
workload::run_with_faults is the loop the fuzzer and most tests share. Per tick it advances the workload clock, steps the fault injector, then resends timed-out requests and handshakes before sampling anything new, since a timed-out request still holds its client's slot. It then samples at most one request per idle client, steps the simulator, classifies the replies, recovers evicted clients, and asserts the per-tick invariants.
Each workload action has a module under workload/ops/, 23 of them, one per Action variant, exposing the same seven items: Input, Outcome, OUTCOMES, sample, build_message, classify_reply, predicted_effect. Dispatch runs through the op_dispatch! macro, so an action without a module is a compile error rather than a silently unsampled op. Variant order in Action is part of the determinism contract: append, never insert or reorder.
A resend reuses the original encoded message verbatim. Rebuilding it would draw a fresh request id, and the metadata client table dedups on that id: a renumbered retry commits a second time instead of returning the cached reply. Resends move to the next replica, so a client whose primary died finds the new one. Each client holds one request in flight (CLIENT_REQUEST_QUEUE_MAX).
| Check | When | Catches |
|---|---|---|
workload::invariants | every tick | commit_offset or consensus view regressing on a live (replica, namespace), in-flight requests over the per-client bound |
workload::state_checker | every tick | two replicas holding different history at an op they both committed, both directions of (commit_a == commit_b) == (checksum_a == checksum_b), and a break in the parent == checksum hash link |
Simulator::assert_inboxes_drained | every executor quiescence | a frame sitting in an inbox nobody was woken for, which the next advance_time would otherwise drain silently |
workload::oracle | after the drain | a live replica ahead of the leader, disagreement on any committed op two replicas share, a namespace in committed metadata with no host or more than one primary, and on a serial run a predicted Shadow that differs from the metadata committed on the leader |
Agreement is asserted over the committed prefix, not over equal heads. A replica that missed the last commit broadcast, or rejoined a moment ago, is allowed to trail; what it may not do is hold different history at an op it did commit.
The oracle reports what it actually compared (ops_compared, replicas_compared, namespaces_checked) because a check that ran against an empty chain passes silently.
let seed = 0xC0FF_EE00; let client_id: u128 = 1; let network = PacketSimulatorOptions { node_count: 3, client_count: 1, seed, ..PacketSimulatorOptions::default() }; let mut sim = Simulator::new(3, std::iter::once(client_id), network); 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(seed, 3, vec![ns]); options.weights = ActionWeights::new(&[ (Action::CreateStream, 50), (Action::DeleteStream, 25), (Action::SendMessages, 25), ]); let mut wl = Workload::new(options); let replies = workload::run(&mut sim, &mut wl, &[client], 2_000, u64::MAX); assert!(replies > 0); assert!(oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000)); oracle::assert_converged(&sim, &mut wl);
MemoryPool::init_pool has to run first with pooling disabled, since PooledBuffer::from panics on an uninitialised pool. For the dispatch path use Simulator::with_shards_shell, where shell_login and seed_stream_topic_partition replace register_client_with_primary. To assert what was injected rather than trusting the probabilities to have fired, pass a caller-owned FaultInjector to run_with_faults; replica_crash and replica_restart drive faults directly.
workload-fuzz: seed=1 ticks=500 clients=1 replicas=3 plane=Partition faults=None shell=false crash_prob=0 quiesce=true network: loss=0 replay=0 delay=1..3 partition=None/Symmetric p_partition=0 p_unpartition=0 stability=0/0 clog=0 clog_ticks=0 link_capacity=64 ran 500 ticks; 50 replies; crashes=0 restarts=0 still down: 0 coverage: replies_seen=50 replies_unknown=0 committed_rejections=0 samples_none=0 resends=0 denials=2 transients=0 evictions=0 SendMessages: 28 commits, 0 denied (last status 0), 0 transient (last code 0) StoreConsumerOffset: 16 commits, 0 denied (last status 0), 0 transient (last code 0) DeleteConsumerOffset: 4 commits, 2 denied (last status 3021), 0 transient (last code 0) quiesced and converged (leader-relative; entity oracle: held; evictions=0; ops_compared=1 replicas_compared=2 namespaces_checked=1) coverage: replies_seen=51 replies_unknown=0 committed_rejections=0 samples_none=0 resends=0 denials=2 transients=0 evictions=0 commands delivered: Request=52 Prepare=80 PrepareOk=78 Reply=52 Commit=56 RequestPrepares=3 RepairPrepare=4 RepairDone=3 commands never delivered: Ping Pong PingClient PongClient StartViewChange DoViewChange StartView Eviction ... workload-fuzz: OK (seed=1)
The two banner lines are the run's identity: every network parameter is printed, not the interesting subset, because under --faults swarm those values are the run. Coverage prints twice, before the quiesce assert and after the drain, so a failed drain still reports what the run managed to do. The command coverage lines answer “did this path get exercised?” by counting at delivery, so a command listed as delivered was seen on the wire rather than inferred from the source.
An invalid network configuration exits 2 before the simulator starts. Every other failure is a panic, and the hook prints reproduce with --seed N ahead of it.
| Group | Flags |
|---|---|
| Shape | --replicas (3), --clients (1), --ticks (10000), --plane, --ack-quorum-ratio (0.5), --shell |
| Faults | --faults, --crash-prob, --restart-prob, --crash-primary, --min-survivors (a commit quorum) |
| Network overrides | --packet-loss-prob, --replay-prob, --one-way-delay-min, --one-way-delay-mean, --link-capacity, --partition-mode, --partition-symmetry, --partition-prob, --unpartition-prob, --partition-stability, --unpartition-stability, --clog-prob, --clog-duration-mean |
| Checkpointing | --journal-slots, --data-dir, --reuse-data-dir |
| Quiesce | --no-quiesce, --heal-before-quiesce |
| Gates | --min-commits (1), --min-ops-compared (1), --require-entity-oracle, --require-faults |
| Durability study | --restore-partition-frontier |
--plane picks the op mix: partition (writes and consumer offsets), metadata (replicated metadata mutations), mixed (stream creates over a write-heavy base), uniform (every action equally likely, the widest per-tick coverage).
--faults picks a whole network profile and the individual network flags override single fields of it. Severity costs progress, every lost frame paying a resend timeout: on one namespace with one client, a 3-replica cluster drains roughly 440 replies in 5000 ticks on a perfect network, 240 under light, 40 under heavy. Budget ticks accordingly instead of reading a low reply count as a stall. --faults swarm derives every network parameter from the seed, which is what a campaign wants: none, light and heavy are three fixed points, so a thousand seeds against heavy is the same network a thousand times.
The gates exist because a run that commits nothing compares empty against empty and reports success. --min-commits and --min-ops-compared default to 1 for that reason; --require-entity-oracle additionally fails a run whose entity oracle was disarmed by an eviction and never re-armed.
--heal-before-quiesce restarts every crashed replica and stops the network drawing faults before the drain. It is off by default: a drain that fails with a replica still down is how a wedge shows up, and healing first resolves the wedge instead of reporting it.
A checkpoint is forced by the metadata WAL running low on slots, so --journal-slots N is what makes one happen. Without it the journal is unbounded and WAL drain, snapshot_op movement, RangeEvicted and metadata state transfer are all unreachable however hard the cluster is driven. N must exceed SnapshotCoordinator::CHECKPOINT_MARGIN, or every commit checkpoints and the run measures that rather than the workload.
Snapshots are written to a real directory, per process, whose path is printed. It refuses an existing --data-dir unless --reuse-data-dir says otherwise, because a restart now recovers from snapshot.bin and the directory would be input to the new run.
RUST_BACKTRACE=1 RUST_LOG=shard=debug,consensus=debug \ cargo run -p simulator --bin workload-fuzz -- --seed 13 --plane metadata \ --crash-prob 0.01 --restart-prob 0.05 --ticks 30000
Omitting --seed draws one and prints it on the banner, so an exploratory run stays replayable. The panic hook replaces the default one, so RUST_BACKTRACE is honoured explicitly and only when set; a campaign would otherwise be buried in backtraces. RUST_LOG selects the server‘s own tracing, which is the only record of a request dropped after logging, the shape that wedges a client’s in-flight slot.
A failed drain prints every outstanding request with its action, target and attempt count, then one line per replica carrying its commit range, repair barrier, transfer state, primary claim and armed repair session, which usually names the failure without a debugger:
replica 2: live | metadata ... commit=1407..1414 barrier=1359 transferring=false primary=false repair=..1407@0
The suite lives at the end of src/lib.rs, with unit tests next to the modules they cover. Names follow given_X_when_Y_should_Z where there is a meaningful given; older behavioural names remain where there is not. Every test that touches replies initialises MemoryPool first, as above.
What the suite pins, by example: workload_replay_is_deterministic and multi_shard_replay_is_deterministic (the seed contract), committed_metadata_agrees_across_replicas, given_advanced_view_when_metadata_replica_restarts_should_recover_view_from_superblock, given_superblock_write_fails_when_primary_crashes_should_withhold_votes_and_not_elect, checkpointing_cluster_serves_a_chunked_state_transfer, shell_detects_partition_borrow_held_across_await.
Constructors: Simulator::new (one shard per replica), with_shards (metadata on shard 0, partitions hash-assigned across all shards), with_shards_shell (the same plus the real dispatch handlers), with_checkpoints. Multi-shard and shell-on-multi-shard are library-only; workload-fuzz always builds one shard per replica.
SimSuperblock::set_fail_writes and set_yield_writes inject a persistent write fault and an fsync-wide suspension point; MemStorage under the journal never fails and never tears a write. Partition superblocks are storeless, which leaves partition view recovery untested.RetainedPartitionState across the rebuild.load_partition restores the view alone, so a restarted replica rejoins at op 0, invisible to quorum. --restore-partition-frontier looks past that at a system more durable than Iggy is.io_uring, no sockets, no wall clock.src/ ├── lib.rs Simulator: cluster construction, step(), crash/restart, partition seeding, tests ├── executor/ DetExecutor (seeded cooperative scheduler) + virtual clock ├── packet.rs PacketSimulator: delay, loss, replay, partitions, clogs, link capacity ├── network.rs Network: passthrough over PacketSimulator, submit/step/heal ├── bus.rs SimOutbox: per-replica staging that consensus sends into ├── replica.rs Replica type alias and new_shard() wiring, mirroring server bootstrap ├── deps.rs SimClock, MemStorage, SimJournal, SimSuperblock ├── client.rs SimClient: builds real wire requests ├── seeds.rs SimSeeds: one PRNG stream per consumer ├── ready_queue.rs min-heap with reservoir-sampled random ready removal ├── workload/ driver, op modules, shadow, auditor, invariants, state checker, oracle └── bin/ ├── workload-fuzz.rs the fuzzer └── simulator-ui.rs scripted demo run
A change under core/simulator/** marks the rust-simulator component in .github/config/components.yml, which scopes the Rust test tasks to it. The component is split out of rust-cluster so simulator-only changes do not trigger the foreign SDK suites. No fuzzing campaign runs in CI today; workload-fuzz is driven by hand, and a seed that fails should arrive as a test or as a finding write-up.
Commits touching this crate take the simulator scope: fix(simulator): ....