Examples
Examples are small enough to read quickly, but they exercise the real Marionette APIs. Each example should become useful input for deterministic replay tests. The example set is intentionally small while the API is experimental.
The examples module root is examples/root.zig.
Add new examples there so zig build test picks them up without hard-coding
each example in build.zig.
Retry Queue
Source: examples/retry_queue.zig
The retry queue is the README-facing bug demo. It models a single leased job:
- Worker 1 leases the job.
- The lease times out.
- Worker 2 leases the same job.
- A late completion from worker 1 arrives after worker 2 owns the lease.
The correct scenario rejects the stale completion and then accepts worker 2's
completion. The deliberately buggy scenario accepts both completions, and a
named mar.StateCheck catches the duplicate completion:
var report = try retry_queue.runBuggyScenarioReport(allocator, 0xC0FFEE);
defer report.deinit();
The useful trace shape is:
queue.lease job=7 worker=1 deadline_ns=5000000
queue.timeout job=7 worker=1
queue.lease job=7 worker=2
queue.complete job=7 worker=1 accepted=true reason=stale_ack_bug completions=1
queue.complete job=7 worker=2 accepted=true reason=current_lease completions=2
queue.invariant_violation job=7 completions=2
This example does not require disk or cooperative tasks. It shows the smaller simulation loop directly: seeded choices, simulated time, trace-visible behavior, and a named checker that preserves the failure context.
KV Store
Source: examples/kv_store.zig
The KV store is the first disk-backed example. It is intentionally tiny: a
fixed-size append-only WAL where each sector is one record with a magic value,
key, value, and checksum. The store itself is production-shaped: it accepts
std.Io, a root std.Io.Dir, and a narrow mar.Recorder, then uses
std.Io.File positional read/write and sync. The scenario runs under
mar.SimCase(KVStore), so application state lives at case.app while
Marionette's disk fault authority is available through case.control().disk.
The correct scenario:
- Writes and syncs one committed record.
- Writes a second unsynced record.
- Crashes the disk so the unsynced record is lost.
- Restarts the disk.
- Injects scripted corruption into the second sector.
- Recovers by scanning records and validating checksums.
- Checks that the synced record is recovered exactly once and the unsynced record is rejected.
Run it with:
zig build run-example -- kv-store --seed 12648430 --summary
The deliberately buggy scenario accepts any record with the right magic value, ignoring the checksum. A torn write leaves enough bytes for the magic and key to look plausible, and the named checker catches that the unsynced record was recovered:
zig build run-example -- kv-store-bug --seed 12648430 --expect-failure
Useful trace events include:
disk.crash_write op=4 path=kv.wal offset=16 len=16 result=torn
kv.recover.record offset=16 key=2 value=0 mode=buggy_accept_magic_only
kv.invariant_violation reason=unsynced_record_recovered
The KV store is also the worked case for Marionette's recovery-window
vocabulary (see the Recovery Windows section of the
disk fault model). The probabilistic scenarios crash
with 25% lost-write and 25% torn-write rates, so the unsynced record's fate
varies by seed, and the recovered state is within the recovery window
checker asserts the window rather than one outcome: the synced record must
recover exactly, and the unsynced record may be absent or exact but never
damaged. A fuzz test holds the window across seeds, and a seed search finds
the planted magic-only recovery accepting a torn record as
DamagedRecordAccepted.
KV Compatibility Validation
Validation source:
validation/kv_compat.zig
The KV compatibility validation is a local surrogate for the storage-facing
slice of an external KV engine. It uses only std.Io file and directory calls:
append fixed WAL records, sync commits, recover by replaying the WAL, compact
the in-memory table through a tmp-file rename, directory-sync the rename, then
delete and recreate the WAL.
The scenario matrix drives every compaction crash point:
- before the tmp file is renamed,
- after rename but before directory sync,
- after a torn tmp write,
- after WAL delete/recreate but before the final directory sync,
- across seed-varying crash points with aligned and misaligned sectors.
The oracle is either-incarnation recovery. If pending metadata is lost, the old table plus full WAL must recover the durable truth. If pending metadata survives, the compacted table plus old or empty WAL must converge to the same state. Recovery deliberately rejects damaged compacted table records but accepts a damaged WAL tail as inside the recovery window. The validation omits directory deletion, permissions, symlinks, and richer directory APIs; those remain deferred until a compatibility target forces them.
Idempotency Bug
Source: examples/idempotency_bug.zig
The idempotency bug is a small seed-sensitive replay demo. It models two account-local deposits. The service has a subtle bug: it dedupes request IDs globally, even though request IDs are only required to be unique per account.
Most seeds choose distinct request IDs and pass. Seeds that reuse the same request ID across two accounts suppress the second deposit, and the checker catches the lost update.
Run a passing seed:
zig build run-example -- idempotency-bug --seed 12648430 --summary
Replay a failing seed:
zig build run-example -- idempotency-bug --seed 13 --expect-failure
Use --trace with the failing seed to print the same failure trace each time.
Useful trace events include:
buggify hook=reuse_request_id_across_accounts
idempotency.requests alice_id=... bob_id=... reused=true
idempotency.deposit account=bob ... accepted=false reason=global_duplicate
idempotency.invariant_violation
Replicated Register
Source: examples/replicated_register.zig
The replicated register is the first VOPR-inspired showcase. It is not a real consensus protocol and does not copy TigerBeetle internals. It demonstrates the portable shapes Marionette needs:
- A small cluster model with three replicas.
- Seeded message drops and delivery latency.
world.simulate(.{ .network = ... })producing typedmar.Endpoint(MessagePayload)node endpoints backed by fixed-topology per-link queues ordered by(deliver_at, packet_id).- A partition scenario that drops queued packets through directed link filters.
- Runtime network fault configuration through focused
control.networkhelpers such assetLossiness(...),setLatency(...),setClogs(...), andsetPartitionDynamics(...). runSimCase/expectSimPass/expectSimFuzz/expectSimFailurefor scenario runs.- Trace events for sends, drops, deliveries, accepts, commits, and checks.
- A named
mar.StateCheckthat inspects structuredSimCasestate. - Rejection of conflicting same-version proposals.
The normal scenario writes one value to a quorum, commits it, and checks that committed replicas agree and that committed values were accepted by a quorum:
const trace = try replicated_register.runScenario(allocator, 0xC0FFEE);
defer allocator.free(trace);
The trace starts with the run name and records network fault configuration as explicit control events, so the seed is not the only context available when a failure is replayed.
The example also includes a deliberately buggy scenario used by tests to prove the checker path catches divergent committed state:
var report = try replicated_register.runBuggyScenarioReport(allocator, 0xC0FFEE);
defer report.deinit();
The partition scenario isolates one replica from the client and majority, then heals the network and replays the same value so the previously isolated replica commits too:
const trace = try replicated_register.runPartitionScenario(allocator, 0xC0FFEE);
defer allocator.free(trace);
There is also a same-version conflict scenario used by tests to prove the register rejects conflicting values instead of overwriting accepted state.
This is intentionally tiny. Its job is to keep the endpoint network and invariant APIs concrete and regression-tested.
std.Io.net KV
SUT source:
examples/std_io_net_kv.zig
Harness source:
validation/std_io_net_kv.zig
This external-style validation keeps Marionette out of the application module.
The SUT uses only std.Io.net to implement a fixed-frame PUT/GET protocol. The
harness owns cooperative tasks, network latency, partition/heal control, the
trace, and an exact retry-idempotency oracle.
The correct server caches responses by request ID. The harness partitions the
link after the first PUT response is queued, observes error.Timeout, heals,
and retries the same request. The server returns the cached response and keeps
revision == 1.
The planted buggy server reapplies the retry. The value remains 41, but the revision and application count become 2, so the checker catches the duplicate mutation.
zig build validate-std-io-net-kv
zig build run-example -- std-io-net-kv --seed 12648430 --trace
zig build run-example -- std-io-net-kv-bug \
--seed 12648430 --trace --expect-failure
See Testing std.Io.net Code Deterministically for the trace and supported stream boundary.
Toy DB
Source: examples/toy_sql_db.zig
The toy database is a tiny protocol-adapter example. Its wire format is just a
one-byte tag plus an optional little-endian i64. Its purpose is not SQL
coverage; it shows a user-owned protocol encoding and decoding at the
experimental mar.Endpoint(WireMessage) edge. WireMessage owns a fixed byte
array rather than borrowing a slice, so the example demonstrates the safe
value-only payload shape while database code sees typed Request and
Response values.
The scenario drives a client and server over simulated message endpoints. It
tests the codec's logical protocol boundary, not a production wire path; use
the std.Io.net example when framing and partial I/O must run unchanged.
Durable Broadcast
Source: examples/durable_broadcast.zig
Durable broadcast is the first example that combines disk and network in one harness. It models a service that writes one operation to a local WAL, syncs it, then broadcasts the operation to three replicas and waits for a quorum of acknowledgements.
The example is deliberately narrow: one fixed-size WAL record, one operation, and scripted crash/restart. The roadmap tracks follow-ups for extracting the duplicated WAL framing helper, adding a probabilistic buggy fuzz/search variant, splitting happy-path and crash-recovery scenarios, and growing this into a multi-record recovery case.
The checker asserts the cross-subsystem invariant:
- if a quorum acknowledged an operation, that operation must be recoverable from local durable storage after crash/restart;
- if any replica accepted an operation, it must match the recovered durable operation.
Run the correct scenario:
zig build run-example -- durable-broadcast --seed 12648430 --summary
The deliberately buggy scenario broadcasts before syncing. The replicas can acknowledge the operation, then a crash loses the pending WAL write. The checker catches that the network-visible operation was not durable:
zig build run-example -- durable-broadcast-bug --seed 12648430 --expect-failure
Useful trace events include:
durable.broadcast.quorum op=1 value=99 acks=3
disk.crash_write op=0 path=durable_broadcast.wal offset=0 len=24 result=lost
durable.invariant_violation reason=quorum_without_durable
Memtable Pressure
Source: examples/memtable_pressure.zig
The memtable makes allocation failure a real modeled branch. Every put
copies its value through env.allocator(), so a deterministic OOM injected
via control.allocation must be rejected without mutating table state:
- Put key 1, then set
fail_after = 0so every later allocation fails. - Put key 2, which is rejected cleanly.
- Heal the faults and put key 3.
The checker asserts the committed count matches the stored entries exactly. The planted bug counts an insert as committed before its allocations succeed, so the injected OOM leaves a phantom commit:
memtable.put key=1 accepted=true committed=1
allocation.alloc op=4 len=7 align=1 status=fail reason=fail_after roll=none ...
memtable.put key=2 accepted=false reason=allocation_rejected committed=2
memtable.invariant_violation reason=phantom_commit committed=3 entries=2
A third scenario fuzzes buggify_rate allocation faults across seeds and
asserts the table never records a commit it did not store.
Example Rules
- Keep examples focused and readable.
- Prefer one clear service behavior over a broad feature tour.
- Route time and randomness through the environment's
std.Io. - Return or expose traces so tests can compare replay behavior.
- Avoid independent host I/O backends, raw OS calls, unseeded randomness, and host threads in simulated example code.