Autumn ships a deterministic simulation testing (DST) harness: a #[sim_test]
attribute that hands your test a seeded [Sim] handle, a paused deterministic
executor, and a virtual clock, so your whole app's concurrency — retries,
scheduled ticks, background jobs — runs identically on every machine and every
run. A failure prints a copy-pasteable line that reproduces it exactly, and a
seed-sweep runner (sweep_proptest, see below) lets you drive your own
workload against many seeds looking for rare-interleaving bugs a single-seed
test would never stumble into. This sweeping is opt-in, not automatic:
autumn's own CI runs a built-in sim-sweep job over a few hundred seeds, and
sweeps one example app each night (see "The reddit-clone nightly"). It
sweeps a toy scenario (a smoke check of the harness) and three framework
scenarios: jobs, scheduler and lock across replicas (see "Multiple
replicas"). It does not explore your application's interleavings. Wiring
sweep_proptest against your own scenario, in your own CI, is how you get
that coverage for your app; see "Property-based op-driving + the seed sweep"
below. For a complete app sweep to copy, see "The reddit-clone nightly"
below: 4,000,000 seeds each night.
┌──────────────────┐
│ #[sim_test] │ seed → deterministic Sim
└─────────┬─────────┘
│
┌───────▼────────┐ paused, current-thread ┌──────────────────┐
│ Sim::advance / │ ─────────────────────────► │ your app's real │
│ run_to_idle │ virtual clock │ jobs/scheduler/ │
└───────┬─────────┘ │ request path │
│ panic (always! violated) └──────────────────┘
┌───────▼────────────────────────────┐
│ AUTUMN_SIM_SEED=0x… cargo test … │ ← replay line, prints on failure
└─────────────────────────────────────┘
Why simulation testing
The nastiest production bugs are not logic errors in a single handler — they are interleaving bugs: a retry storm when a downstream stalls, a deadlock between a request and a scheduled tick, a timeout that fires in the wrong order. These bugs need many actors racing at the same instant to trigger, so a real-clock test would need to get impossibly lucky to reproduce one — and even luckier to rerun it to prove a fix. A deterministic simulation makes "many actors racing at the same instant" the cheap, constructible default: pause the clock, drive everything from one seed, and the same instant is trivial to manufacture on purpose.
Quick start
#[sim_test] needs no extra feature flag — it's part of the default autumn-web
surface.
use autumn_web::sim::Sim;
use autumn_web::sim_test;
#[sim_test]
async fn deterministic(mut sim: Sim) {
// The seed comes from `AUTUMN_SIM_SEED` (hex `0x..` or decimal), default 0.
// `sim`'s own clock and RNG (`sim.rng()`) are pure functions of this seed.
// An app mounted with `sim.build(...)` gets the virtual clock and an
// entropy source seeded from it, so its ids replay too.
assert_eq!(sim.seed, 0);
}
Reproduce a failing run by copying the replay line printed on panic:
AUTUMN_SIM_SEED=0x9f3a cargo test -p my-crate deterministic
Mounting a real app
use autumn_web::sim::Sim;
use autumn_web::sim_test;
use autumn_web::test::TestApp;
#[sim_test]
async fn app_boots_under_the_sim(mut sim: Sim) {
sim.build(
TestApp::new()
.routes(routes![index])
.jobs(jobs![send_receipt])
.tasks(tasks![nightly_digest]),
);
let response = sim.client().get("/").send().await;
response.assert_ok();
}
sim.build mounts an [autumn_web::app::AppBuilder]-configured app on the
sim's paused runtime. It wires in the virtual clock, an entropy source seeded
from sim.seed (see "Deterministic identifiers" below), and the
fault-injection hooks below if you configured any. Register #[job]s with
TestApp::jobs and #[scheduled] tasks with TestApp::tasks, or through a
plugin, exactly as on AppBuilder.
Virtual time
sim.advance(std::time::Duration::from_secs(24 * 3600)).await; // 24h, zero wall-clock sleep
sim.run_to_idle().await; // drain everything the advance released
- [
Sim::advance] steps the injected [autumn_web::time::Clock] and tokio's paused timer wheel together, so a#[job]'s exponential backoff or a#[scheduled]tick fires the instant virtual time crosses its deadline — with zero real waiting.sim.buildstarts the app's scheduled tasks, so anevery = "1m"task first ticks one virtual minute afterbuild, and acrontask at its next occurrence after the sim epoch. - [
Sim::run_to_idle] drains everything already-ready — the job worker, due scheduler ticks, durable repository commit hooks — until the runtime is quiescent. It does not fast-forward to a future timer; pair it withadvancefor "jump to the next interesting instant, then settle what fired." The drain runs 1024 rounds, then stops at 64 quiet rounds in a row. If it sees no such quiet run within 2048 rounds (for example, a job that enqueues itself again),run_to_idlepanics with asim drain stallmessage and the seed.Sim::try_run_to_idlereturns theSimStallinstead. - [
Sim::advance_to] / [Sim::advance_to_local] jump to a specific (optionally timezone-zoned, DST-aware) instant instead of a raw duration — useful for business-calendar / SLA tests that must cross a spring-forward or fall-back boundary deterministically.
Deterministic identifiers
Framework-minted IDs (job IDs, request IDs, idempotency keys, session tokens)
draw from an injected [autumn_web::entropy::Entropy] source. Reach a seeded
one from a handler via the [autumn_web::entropy::Rng] extractor, or from the
sim directly via sim.rng().
Sim::build seeds the mounted app's entropy from sim.seed, so a job's retry
jitter, a minted UUID and anything else reading state.entropy() replay from
AUTUMN_SIM_SEED with no extra call. The default is the same source as
with_entropy(SeededEntropy::new(sim.seed)). To use a different source, pass
your own; an explicit with_entropy always wins:
use autumn_web::entropy::SeededEntropy;
sim.build(
TestApp::new()
.routes(routes![index])
.with_entropy(SeededEntropy::new(0xfeed)),
);
An app mounted again after [Sim::kill] (a restart) gets a new stream derived
from the seed, so the restarted process does not repeat the crashed one's ids
and the run still replays.
Before 0.8.0 the default was the OS entropy source, and a test had to pass
with_entropy(SeededEntropy::new(sim.seed)) itself. That call still works and
now changes nothing.
Fault injection
Chaos injects faults probabilistically — you give it a rate and the seed
decides which operations get hit:
use autumn_web::sim::Chaos;
sim.chaos(
Chaos::default()
.db_transient_errors(0.05) // 5% of connection checkouts fail
.job_duplicate_delivery(0.10) // occasional at-least-once double-delivery
.clock_skew(std::time::Duration::from_millis(250)),
);
Every fault decision is drawn from the seed, so enabling chaos never breaks
reproducibility — the same seed replays the same fault schedule. See
[autumn_web::sim::Chaos] for the full catalog, including SMTP transport
faults. Two related tools sit outside Chaos: sim::llm's seeded LLM stub
for agent retry paths, and [Sim::kill] / [Sim::restart] for
crash-recovery proofs. A kill drops the app between two drains, after its
transactions commit, not in the middle of one. When you want to name a fault rather than sample
one — "fail the 3rd database checkout" — reach for FaultPlan in the next
subsection instead.
Authored fault scenarios (FaultPlan)
Chaos is a rate: "5% of checkouts fail", with the seed choosing which ones.
That is the right shape for a sweep hunting rare interleavings, and the wrong
shape for a regression test — because the sentence a post-mortem actually
produces is "the third connection checkout failed while the second
send_invoice execution was retrying", and no probability reproduces that on
purpose. [autumn_web::sim::FaultPlan] lets you author that scenario directly,
by ordinal rather than by rate, and hands back a serializable record of what
happened that a test can assert on and CI can replay byte-for-byte.
use autumn_web::sim::{FaultPlan, Sim};
use autumn_web::sim_test;
use autumn_web::test::TestApp;
#[sim_test]
async fn the_third_checkout_and_the_second_invoice_fail(mut sim: Sim) {
let plan = FaultPlan::from_seed(sim.seed)
.fail_db_checkout(3) // 3rd checkout on any pool (ordinals are 1-based)
.fail_job("send_invoice", 2); // 2nd execution of that job by name
sim.build(
TestApp::new()
.routes(routes![checkout])
.plugin(InvoiceJobPlugin)
.with_fault_plan(plan),
);
for _ in 0..5 {
sim.client().post("/checkout").send().await;
}
sim.run_to_idle().await; // drains the job worker through the fault seam
let outcome = sim.client().fault_outcome().await;
assert_eq!(outcome.fired.len(), 2);
assert_eq!(outcome.fired[0].ordinal, 3); // the checkout that failed
assert_eq!(outcome.server_errors[0].status, 503); // captured through reporting
assert_eq!(outcome.final_state.db_checkouts, 5); // every checkout, fired or not
// Canonical, byte-identical on every replay of this seed — commit it as a
// fixture and this scenario becomes a CI regression test.
assert_eq!(outcome.to_json_string(), include_str!("fixtures/invoice_scenario.json").trim_end());
}
Ordinals are 1-based, and 0 matches nothing (the same convention as
Chaos::smtp_faults). fail_db_checkout(n) and fail_job_execution(n) count
across every pool and every job name; fail_db_checkout_on("replica", 2) and
fail_job("send_invoice", 2) count on that target's own counter. Duplicate
entries collapse, so a plan is a set — plan.planned() returns the whole
schedule sorted and plan.describe() prints it one fault per line, both before
you run anything. A plan is pure data: clone it onto two apps and you get two
independent ledgers.
Faults can be confined to a window of virtual time, driven by the injected clock rather than wall time:
let plan = FaultPlan::from_seed(sim.seed)
.fail_job_execution(2)
.only_between(Duration::from_secs(5), Duration::from_secs(10));
The window is half-open (from <= elapsed < to) and elapsed is measured on
the app's injected monotonic clock since the app started — so sim.advance
moves it and no real time ever does. Outside the window the effect still
consumes its ordinal, and the near-miss is recorded in outcome.suppressed
rather than vanishing: a scenario that stopped firing because your timings
shifted is visible in the outcome instead of silently passing.
The seed earns its keep in the random_* lane, which spreads faults across a
range instead of naming each one:
let plan = FaultPlan::from_seed(0x5EED).random_db_checkout_faults(2, 1..=8);
That picks 2 distinct checkout ordinals from 1..=8 using the plan's seed, and
resolves them into explicit entries at builder-call time — so planned()
still describes the schedule completely, a different seed picks different
checkouts, and a plan built only from explicit fail_* calls draws no entropy
at all.
What the outcome record holds
| Field | Contents |
|---|---|
seed | the plan's seed, echoed so an outcome identifies its own replay |
fired | Vec<FiredFault> in fire order: effect, target (pool or job name), the global ordinal, the target_ordinal, at (injected wall clock) and elapsed_ms (injected monotonic) |
suppressed | matched an ordinal but fell outside only_between |
unfired | planned faults the run never reached, sorted — an empty list (together with an empty suppressed) is the proof your scenario actually exercised what it authored; a near-miss outside the window counts as reached, so check suppressed too |
server_errors | 5xx captured through reporting.rs, in report order: status, method, route, message, problem_type. Deliberately no request ID — it is entropy-minted, and the record has to stay comparable |
final_state | seam totals: db_checkouts, job_executions, job_executions_failed, job_executions_succeeded |
to_json_string() is canonical (declaration-order fields, no maps, no floats),
fingerprint() is an FNV-1a 64 over that string, and FaultOutcome::from_json_str
round-trips it. TestClient::fault_outcome() is async because autumn
dispatches error reports on a detached task: it settles those with bounded
cooperative yields before snapshotting, and never advances the virtual clock
while doing so. TestClient::fault_ledger() returns the same handle for an
un-settled outcome() snapshot, and None when no plan was attached.
The determinism contract
The byte-identical-replay guarantee holds inside a specific box, and build
enforces the parts it can:
- A paused, current-thread runtime —
#[sim_test], or#[tokio::test(start_paused = true)]. On a multi-threaded runtime concurrent executions race for their ordinals and the ordering is not reproducible. - The injected clock. Window checks and every
at/elapsed_msread the app'sClockSource, so no wall-clock read leaks into the record. - Seeded entropy. Attaching a plan defaults the app's entropy to
SeededEntropyderived from the plan's seed, so retry jitter and minted IDs replay from it too. Unlike the rest of the sim this one is automatic — an explicitwith_entropystill wins. - One job worker (
jobs.workers = 1), reporting atsample_rate = 1.0, and failure capture off (the default).buildasserts all three when a plan is attached, rather than letting a second worker, a sampled-out 5xx, or a capsule write that reporting awaits before any reporter runs quietly break replay.
What it does not cover
- Only two effect classes: database connection checkout and job execution.
Mail, outbound HTTP and channels have no
FaultPlanlane — for SMTP useChaos::smtp_faults, which already takes an explicit 1-based schedule. - Test-only.
FaultPlanattaches to aTestApp. For a running staging app, use[fault_injection]. It is refused inprodby default. perform_enqueued_jobsbypasses it.TestClient::perform_enqueued_jobsinvokes handlers directly and never crosses theintercept_executeseam, so job faults never fire under it. Drain withsim.run_to_idle()— plussim.advanceto cross a retry backoff — instead.
autumn/tests/integration/sim_fault_plan.rs carries the worked example, in the
same before/after shape as the retry-storm bug above: a charge_card job and a
plan that fails its first execution. With max_attempts = 1 the charge is never
recorded and the scenario's resilience assertion fails; with max_attempts = 3
the retry lands it exactly once and the same plan passes — flip that one
attribute back to reproduce the failure. See issue
#1680 for the design.
always! / sometimes!
use autumn_web::{always, sometimes};
always!(total_balance == 0, "ledger must stay zero-sum (seed={:#x})", sim.seed);
sometimes!(response.status() == 409, "a transfer was rejected (insufficient funds)");
always!is a hard invariant — it panics the instant its condition is false, exactly likeassert!, but the panic flows through#[sim_test]'s replay-line printing.sometimes!is a reachability target — it records whether the labeled condition was ever true. A single run doesn't fail just because asometimes!was never satisfied (call [autumn_web::sim::assert_all_sometimes_satisfied] for an explicit single-run check); the seed-sweep runner below fails the sweep if a label was observed but never satisfied by any seed in the range — so a green sweep is provably non-vacuous, never accidentally testing nothing.
Property-based op-driving + the seed sweep
Behind the sim-testing feature (dep:proptest), sim::op and sim::sweep
add a proptest-driven workload generator and a batch seed runner:
use autumn_web::sim::{Sim, sweep_proptest, SweepOutcome};
let strategy = proptest::collection::vec(any::<Op>(), 1..32);
match sweep_proptest(0..1000, &strategy, |sim, ops| apply_ops(sim, ops)) {
SweepOutcome::Passed { seeds_run } => println!("{seeds_run} seeds, non-vacuous"),
SweepOutcome::Failed { failure, .. } => panic!("{failure}"), // shrunk to a minimal op-sequence
SweepOutcome::Vacuous { unsatisfied, .. } => panic!("never satisfied: {unsatisfied:?}"),
SweepOutcome::Empty => panic!("swept zero seeds"),
}
sweep_proptest runs [Sim::run_proptest] sequentially across every seed in
the range, stopping at the first failure and reporting its seed plus a
proptest-shrunk minimal op-sequence.
This is a library function you call from your own [[bin]] or test, driven
against your own Strategy/body for your own app's properties — nothing
runs it for you automatically. Autumn's own repository ships one example of
wiring it up: autumn/src/bin/sim_sweep.rs, a small [[bin]] that sweeps a
fixed, deliberately-correct toy account scenario as a smoke check that the
sweep mechanism itself works (sim_sweep_driver's DoD test proves it catches
a real invariant break using a deliberately-buggy variant of the same toy
scenario). Autumn's own CI runs that binary as its own job (Sim sweep,
structured like the loom job, 512 seeds) on every push and PR. To get this
coverage for your own app, write a [[bin]] following the same shape against
your own scenario and wire it into your own CI.
Built with --features sqlite, the binary also sweeps three framework
scenarios. Each one mounts two or three replicas on one
database and injects seeded faults:
| Scenario | What runs | Faults | Invariant |
|---|---|---|---|
jobs | the durable SQLite job queue, with some jobs longer than the visibility timeout | crash and restart, session loss, mid-query errors and commit ambiguity on autumn_jobs, clock skew | no job runs on two replicas at once; every job finishes |
scheduler | the SQLite lease scheduler, one cron and one fixed-delay task | late boots, clock offset, drift and steps, crash and restart, session loss, faults on the lease table | each tick runs at most once |
lock | the SQLite Lock | session loss (also aimed at the holder), commit ambiguity on autumn_locks | two holders overlap only after the first one lost its session long enough for its lease to lapse |
The first seeds also run twice, and their traces must match (see "Same-seed trace check").
The sweep found one contract limit on its first run: each replica reaps
SQLite scheduler leases with its own clock. If one clock is ahead by more than
scheduler.lease_ttl_secs, that replica reaps a live lease, and a second
replica runs the tick again. Processes on one host use one clock. Only
processes with their own clocks can have this skew. The scenario keeps its
skew below the TTL. See docs/guide/scheduled-multi-replica.md.
AUTUMN_SIM_SEEDS=1000 cargo run -p autumn-web --release --features "sim-testing,sqlite" --bin sim-sweep
AUTUMN_SIM_SEED_STARTsets the first seed (default0).AUTUMN_SIM_SCENARIOSselects scenarios, for examplejobs,lock(default: all that the build has).AUTUMN_SIM_TRACE_CHECKSsets how many of the first seeds run twice (default16).
On a failure the binary prints a replay command for only the failing seed and
scenario, for example
AUTUMN_SIM_SCENARIOS=lock AUTUMN_SIM_SEED_START=300 AUTUMN_SIM_SEEDS=1 cargo run ….
Catching a deadlock
A deadlock parks every task with no timer that could wake one, so by default
a deadlocked #[sim_test] hangs. Set AUTUMN_SIM_LIVENESS_BUDGET_SECS to arm
a watchdog: a virtual-time timeout around the test body. Because the paused
runtime advances straight to the next timer when every task is parked, a
deadlock reaches the watchdog in microseconds of real time, and the test fails
with a sim liveness panic and its replay line.
AUTUMN_SIM_LIVENESS_BUDGET_SECS=31536000 cargo test -p my-crate sim_ -- --test-threads=1
The watchdog is off by default because of how the paused clock works: it also
advances while a task waits on something outside the runtime, such as a lock
that a test on another thread holds, or real I/O. So arm it only where sim
tests run one at a time (--test-threads=1) and do no real I/O. A busy loop
that never parks keeps the runtime from advancing, so the watchdog does not
detect it; an always! invariant is the tool for that. Autumn's own CI arms
the watchdog on its single-threaded sim_ step.
Crash at any await
crash_at(index, op) runs op and drops it at its index-th suspension
point (an await that returns Pending). A re-poll with no new wake does not
count, so the index is the same inside Sim::interleave. The work before that
point stays
done. The work after it never runs. Then call sim.kill() and
sim.restart(app) to model the process dying there.
use autumn_web::sim::{crash_at, CrashOutcome};
let point = sim.crash_point().unwrap(); // seeded
let outcome = crash_at(point.await_index, sim.client().post("/transfer").send()).await;
if outcome.is_crashed() {
sim.crash_and_restart(app_on_the_same_db());
sim.run_to_idle().await; // recovery runs here
}
To test every crash point, increase index from 0 until the outcome is
CrashOutcome::Completed.
Interleaving shuffler
Tokio polls ready tasks in a fixed order. The shuffler changes that order from the seed, so a sweep explores task interleavings, not only timer and fault orders.
let client = sim.client();
let ops = vec![client.post("/reserve").send(), client.post("/reserve").send()];
let responses = sim.interleave(ops).await; // seeded poll order
let handle = sim.spawn(async { /* … */ }); // seeded yields
Sim::interleavepolls the futures in a seeded order each round, and can hold one back for a round. A request's handler runs inside its future, so handler awaits are reordered too.Sim::spawnspawns a task that can yield before a poll, which moves it behind the other ready tasks.
The same seed replays the same interleaving. Tasks that the framework spawns itself (job workers, scheduled loops) keep tokio's order.
Simulated network
SimNet replaces the real network for outbound calls through
http_client::Client:
use autumn_web::sim::SimNet;
let net = SimNet::new()
.host("payments", payments_router()) // an in-process axum::Router
.latency(Duration::from_millis(5), Duration::from_millis(80))
.drop_rate(0.1);
sim.net(net.clone());
sim.build(TestApp::new().routes(routes![checkout]));
net.partition("payments"); // calls to http://payments/… fail at once
net.heal("payments");
let log = net.events(); // one NetEvent per attempt
- Latency is seeded, in virtual time. A drop fails the attempt after its
latency. The client's retry policy applies: attempts, jittered backoff,
Retry-Afteron 429 and 503, 502-504 retries andrequest_timeout. - A host with no router falls back to the app's
http_mocks. A host with neither is an error. No call reaches the real network. - The same seed records the same
events().
Only a Client built from the app state (the Client extractor or
Client::from_state) uses the network, and only after sim.net(..). It needs
the http-client feature. Like http mocks, a sim call skips the process-global
circuit breaker, the SSRF checks, pin_to and redirect following.
Multiple replicas
A Sim can run several apps on one clock. Each replica is its
own app: its own state, job runtime and scheduled tasks. Give each replica its
own pool on one SQLite database, so they share data as a fleet does:
use autumn_web::sim::substrate::SqliteSubstrate;
use autumn_web::sim::{Replica, Sim};
#[sim_test]
async fn two_replicas(mut sim: Sim) {
let substrate = SqliteSubstrate::new().unwrap();
for spec in [Replica::named("a"), Replica::named("b").clock_behind(Duration::from_secs(2))] {
let name = spec.name().to_owned();
let pool = substrate.replica_pool(&sim.db_link(&name)).unwrap();
sim.mount_replica(spec, TestApp::new().config(config(&name)).with_db(pool).jobs(jobs![work]));
}
sim.run_for(Duration::from_secs(60)).await;
sim.replica("a").get("/status").send().await.assert_ok();
}
sim.replica(name)returns the replica's client.sim.replica_names()lists the live replicas.sim.kill_replica(name)stops the replica's tasks and drops the app, as a process crash does. A job handler that was running stops and does not settle its claim.sim.restart_replica(name, app)mounts it again with the same clock.- With
sim.net(..), each replica is also aSimNethost under its name:http://b/reaches the replicab. A killed replica is unreachable. - Process-wide state is not per replica. The cache, the event bus and the
global job client belong to the replica mounted last. Enqueue through the
replica's own
JobClientextension, notjob::enqueue.
Advance time with run_for
Use sim.run_for(duration) with replicas on SQLite. Tokio moves its paused
clock to the next timer only when all tasks wait and no blocking work is
queued, such as a SQLite query. So database work ends before time moves on,
and a seed replays the same order. sim.advance and sim.run_to_idle move or
drain immediately. They do this also while a query runs, so they do not fix
the order of database work.
#[sim_test] runs on sim::runtime(): a paused current-thread runtime with
one blocking thread. A blocking task that waits for a second blocking task
therefore never ends. Do not nest blocking work in a sim test.
Clocks per replica
Each replica reads its own clock: the sim's elapsed time, scaled by a drift rate, plus an offset.
| Call | Effect |
|---|---|
Replica::named("b").clock_ahead(d) / .clock_behind(d) | a fixed offset |
.clock_drift_ppm(ppm) | the clock runs ppm parts per million fast (negative: slow), from the mount |
.seeded_clock(max_offset, max_drift_ppm) | offset and drift drawn from the seed and the name |
sim.step_replica_clock("b", TimeDelta::seconds(5)) | an NTP step: the wall clock jumps, the monotonic clock does not |
sim.replica_clock("b") | the offset (with steps) and drift in use |
The drift changes what the replica's clock reads, not its tokio timers: a replica that sleeps 60 s wakes 60 s of sim time later.
Database faults per replica
sim.db_link(name) is the fault switch between one replica and the shared
database. The replica's pool must come from
substrate.replica_pool(&sim.db_link(name)).
| Call | Effect |
|---|---|
link.lose_session() / restore_session() | the replica loses its session: its idle connection closes and each checkout fails. A lease it holds cannot renew. |
link.mid_query_errors("autumn_jobs", p) | a write to the table fails part way, with probability p per row. It does not apply. |
link.commit_ambiguity("autumn_jobs", p) | a write applies, then the caller gets an error |
link.clear_faults() | remove the table faults |
link.events() | each fault decision, in order |
The faults hit only this replica. The write faults use TEMP triggers on the
replica's own connection: RAISE(ABORT) before the write, RAISE(FAIL)
after it. So only writes fault, not reads. Inside an explicit transaction,
the caller rolls back on the error, so an ambiguous fault acts as a mid-query
error. Each link draws from its own seeded stream.
Regression tests for the P0 bugs
autumn/tests/sim_replicas.rs holds the two-replica regressions for #3050:
sim_cron_tick_runs_once_when_a_replica_clock_steps_forwardandsim_fixed_delay_tick_runs_once_across_staggered_replicas. With the lease held only for its TTL (the code before the fix) both fail: the late replica runs the tick again.sim_long_job_runs_once_across_two_replicas: a 6 s job with a 2 s visibility timeout. Before the claim heartbeat it ran five times. Now it runs once.sim_lock_session_loss_past_the_lease_lets_the_peer_in: theSQLiteLockhas no loss signal, so a session loss longer than the lease lets a second holder in. The test proves the harness finds that overlap.
Same-seed trace check
sim::trace::capture(future) records every framework tracing event that a
run logs, down to TRACE, with its sim time. Run one seed twice and compare:
let (_, first) = autumn_web::sim::trace::capture(scenario(Sim::from_seed(7))).await;
let (_, second) = autumn_web::sim::trace::capture(scenario(Sim::from_seed(7))).await;
assert_eq!(first.diff(&second), None);
A difference is nondeterminism in the app, the framework or the harness. Use
capture_targets(&["autumn_web", "my_app"], ..) to record your own crate too.
tracing caches callsite interest for the whole process, so a test on another
thread can hide events. Run trace checks in their own test binary.
Fuzzing the shared scenario
Autumn's sim-sweep bin and its sim_ops cargo-fuzz target drive one Op
vocabulary. Random seeds and coverage-guided search then look at the same
state space. An op sequence moves between them: encode a shrunk sweep failure
as a fuzz input, and decode a fuzz crash into ops for a test.
cargo +nightly fuzz run sim_ops
The built-in scenario is correct by design, so both are smoke checks of the
harness. For your own app, use the same shape: one Op type, a proptest
strategy for the sweep, and a byte decoder for a fuzz target.
Worked example: a real retry-storm bug
The harness's value isn't hypothetical — it caught a genuine bug in autumn's
own job runtime. The local job runtime's retry backoff computed a pure
exponential delay, initial_backoff_ms * 2^(attempt - 1), with no jitter.
That delay depends only on a job's configuration, not its identity — so when
several jobs in the same queue fail at the same instant (a downstream
dependency blips and takes every in-flight job down with it), every one of
them computes the identical delay and retries at the identical instant: a
synchronized "thundering herd" that immediately re-floods the dependency it
just backed off from.
A real-clock integration test can't reproduce this on purpose — it would need N real jobs to fail within the same millisecond of wall time, a coincidence no ordinary test schedule can force. Under the sim's virtual clock it's the opposite: "N jobs fail at the same instant" is the deterministic, trivial condition, because the paused runtime never lets real time elapse between enqueuing them.
#[sim_test]
async fn retries_are_not_synchronized_under_load(mut sim: Sim) {
// The retry jitter reads `state.entropy()`, which `sim.build` seeds
// from `sim.seed` (see "Deterministic identifiers" above).
sim.build(TestApp::new().plugin(StormProbeJobPlugin));
for id in 0..STORM_SIZE {
StormProbeJob::enqueue(StormArgs { id }).await.unwrap();
}
sim.run_to_idle().await; // every job fails, "at once"
// Step through the backoff window in bounded checkpoints, recording
// which checkpoint each retry lands in as it drains. A single big
// `sim.advance(Duration::from_millis(1_500))` looks tempting here, but
// both the injected `ClockSource` and Tokio's own paused clock jump
// straight to their target instant in one step *before* any timer
// fires — so every retry woken by that one big advance would read the
// *same* post-advance `now()` regardless of its individual delay,
// silently hiding a real spread (or a real herd) behind a single
// observed instant. Checkpointing avoids that: each drain only sees
// the retries whose delay fell inside that specific step.
for step_ms in [550, 100, 100, 100, 100, 550] {
sim.advance(Duration::from_millis(step_ms)).await;
checkpoint += 1;
sim.run_to_idle().await; // records `checkpoint` on each retry that lands here
}
let distinct_checkpoints = /* … collected from the drained retries … */;
always!(
distinct_checkpoints > 1,
"all retries landed in the same checkpoint of the backoff window — a thundering herd"
);
}
Before the fix, this always! fired on every seed: every retry landed in the
same checkpoint. The fix draws a full-jitter delay, a random value in
[0, min(cap, base_delay)], from the framework's injected Entropy seam. The
herd spreads out with real OS entropy in production, and replays bit for bit
under a fixed sim seed. Issue #3054 moved the helper to autumn_web::backoff
and applied it to every job backend and to the HTTP client. See
autumn/tests/integration/sim_retry_storm.rs and
autumn/tests/integration/sim_retry_storm_http.rs for the tests, and
job_retry_delay_ms in autumn/src/job.rs for the job side.
A worked example in an app
examples/reddit-clone/tests/sim_hot_rank.rs is the smallest complete shape of
an application #[sim_test]: it mounts a route on the sim's paused runtime,
walks 48 virtual hours in checkpoints, and asserts the app's hot-rank decay
curve through the ordinary [Clock] extractor rather than around it. It uses
always! for the hard invariants and sometimes! for reachability, and it
arranges two deliberately-separated input bands so that
assert_all_sometimes_satisfied
holds at every seed — the pattern to copy when you want a single-run
non-vacuity check rather than a sweep.
The reddit-clone nightly
examples/reddit-clone/tests/sim_nightly.rs is a seed sweep over the whole
reddit-clone app. The sim-nightly.yml workflow runs it each night.
What one seed does
Each seed mounts the app on a new sim runtime and runs these phases:
| Phase | DST API |
|---|---|
| Setup: the plan, the faults and the network | rng, gen_ops, gen_ops_with, Chaos, FaultPlan, SimNet, SeededLlm |
| Sign-ups; the welcome mail retries with full jitter | Chaos::smtp_faults, smtp_transient_errors, backoff::*, advance |
| Posts enqueue a rank job, an auto-mod job and a webhook job | Chaos::job_duplicate_delivery, the FaultPlan job faults |
Votes race through the real vote rule (src/vote_core.rs) | interleave, record_broadcasts |
The real PostPolicy and the feed experiment | plain requests |
| Posts age; the scheduled rank sweep runs; the fault ledger | advance, advance_to, advance_to_local, try_run_to_idle, time::ambient_*, fault_outcome |
| Replicas with skewed clocks, and a vote worker task | mount_replica, Replica, step_replica_clock, run_for, spawn, kill_replica, restart_replica |
| A crash at a seeded await, then a restart on the same store | crash_point, crash_at, crash_and_restart, kill, restart |
| End checks | SimNet::events, SeededLlm::calls |
An in-memory store is the database of all apps of one seed. It lives longer than a crashed app. Each step that is a database round trip in the real app yields a seeded number of times, so the races of the real app can occur. The checks include:
- Subscribers never get an older vote score after a newer one, per process.
- The score of a post is the sum of its votes.
- Ranks are finite, in order, and fall as a post ages.
- Each post is ranked, has its verdict and has its webhook at the end, after the faults, the partition and the crash.
The guards
- The planted bug.
sim_nightly_catches_unordered_publishruns the vote rule withoutPublishOrder. The sweep must fail, and the failing seed must fail again alone. - The trace check. The first
AUTUMN_SIM_TRACE_CHECKSseeds of each process (2 by default, 8 in the nightly) run twice. The two traces must be the same. - Not vacuous. Each
sometimes!label that a seed reaches must be true in at least one seed of the same process. A window of fewer than 32 seeds, such as a one-seed replay, skips this check. A failure also prints a second replay that runs the earlier seeds of the process too. - No leak. Each 1,000 seeds, the sweep reads the memory of its process. If the process grew by more than 256 MiB since the first 1,000 seeds, the sweep fails with a memory-leak message. Without this check, a leak fills the runner, and the runner stops with no report.
- The API surface gate.
dst_api_surface_is_fully_exercisedreads the publicfnitems of the sim files that do not needsqlite. The nightly must call each one, orEXCLUDEDlists it with a reason. The check is textual: it shows that a call exists, not what the call asserts. TheSQLiteparts (Sim::db_link,DbLink, the substrate, the fleet scenarios) are left out. reddit-clone cannot enablesqlite, becausebackend_select!then compiles the whole app forSQLite. Thesim-sweepjob covers them.
Cost and scale
One process runs one seed at a time: the framework keeps the job runtime and
the event bus per process. A release build runs about 45 seeds/s on one core,
and about 150 seeds/s with 4 processes on a 4-vCPU runner. So 4,000,000 seeds
take about 7.5 runner-hours: about 25 minutes on 20 runners. Each night sweeps
a new window (start = UTC day number × seeds), so each night tests new
seeds.
# The default: 8 seeds and the guard tests. CI runs this on each PR, in debug.
cargo test -p reddit-clone --release --test sim_nightly
# One shard of a large window, with a JSON report.
AUTUMN_SIM_SEED_START=0 AUTUMN_SIM_SEEDS=4000000 AUTUMN_SIM_SHARD=3/64 \
AUTUMN_SIM_REPORT=report.json \
cargo test -p reddit-clone --release --test sim_nightly -- --nocapture sim_nightly_sweep
A failure prints the seed and a replay command for only that seed.
What it found
On its first sweep the nightly found a bug in reddit-clone. A replica clock
that was more than 2 hours slow saw a new post "from the future". The rank
formula score / (age + 2)^1.5 then gave NaN. Postgres raised an error on the
same POWER in the scheduled rank UPDATE. Thus one such post stopped the
rank update for all posts. calculate_hot_rank and its SQL now keep the age at
0 or more.
What's virtualized (and what isn't)
| Source | Sim treatment |
|---|---|
Wall-clock time (Utc::now() via the Clock extractor) | Virtual, driven by Sim::advance |
Async timers (tokio::time::sleep, job backoff, scheduler ticks) | Virtual, via a paused current-thread Tokio runtime |
Elapsed / monotonic time (state.monotonic(), the Clock extractor's .monotonic()) | Virtual, driven by Sim::advance — but a raw std::time::Instant is not (see below) |
| Scheduling of autumn's own background work (jobs, scheduler, commit hooks) | Deterministic, drained by Sim::run_to_idle |
| Framework-minted IDs (job IDs, request IDs, idempotency keys, sessions) | Seeded from sim.seed via the Entropy seam |
| Database | Boundary — real in-process SQLite, fault-injected at the connection level via Chaos (by probability) or FaultPlan (by checkout ordinal), and per replica via DbLink (session loss, mid-query errors, commit ambiguity), not simulated at the SQL-dialect level |
| Database work order | Fixed: one blocking thread, and a gate that runs each query only while every task waits |
| Several replicas | Sim::mount_replica: one clock, a clock per replica (offset, drift, steps), one database, SimNet between them |
| Redis, Postgres | Not simulated. Redis and Postgres lanes need Docker; LeaseLock and the Postgres tick table use the database clock |
| Framework code with no clock in scope | Virtual, through the ambient clock (time::ambient_now and its siblings) |
Outbound HTTP through http_client::Client | Simulated by SimNet after sim.net(..): in-process hosts, seeded latency and drops, partitions |
| Task-poll order | Tokio's order, or seeded with Sim::interleave / Sim::spawn |
| Other third-party network (SMTP, LLM calls) | Boundary — mocked/fault-injected via Chaos/sim::llm, not simulated |
Keeping your own code deterministic
Tokio's paused runtime virtualizes tokio::time::Instant — not
std::time::Instant. So a handler or job that measures how long something took
with std::time::Instant::now() reads the real machine clock even inside a
#[sim_test], and two runs of the same seed disagree. Read time through the
seams instead:
| Instead of | Use |
|---|---|
chrono::Utc::now() | state.clock().now(), or the Clock extractor in a handler |
std::time::Instant::now() (measuring elapsed) | clock.monotonic() for the start (the extractor snapshots at request start) and state.monotonic() for the closing read, then MonotonicInstant::saturating_duration_since |
std::time::Instant::now() (a deadline whose counterparty is tokio::time::sleep) | tokio::time::Instant::now() — already virtual under the paused runtime |
std::time::SystemTime::now() | autumn_web::time::clock_unix_secs(clock) / clock_unix_duration(clock) |
uuid::Uuid::new_v4() | state.entropy().uuid_v4(), or the Rng extractor in a handler |
| Any of the time reads above, with no clock in scope | autumn_web::time::ambient_now(), ambient_monotonic(), ambient_instant(), ambient_system_time() — these read the running Sim's clock on this thread, else the system clock |
Each Sim has its own elapsed-time timeline, and the system clock is another
one. ambient_monotonic() readings are comparable only within one timeline.
ambient_instant() joins the timelines one after the other: a Sim starts its
instants at the latest instant already given out in the process. So state that
outlives one Sim sees the next one's instants as later, and its TTLs and idle
ages run on. After a nested Sim ends, the outer one's instants go on from
where the nested one stopped, so on one thread an ambient instant never goes
back. Instants from code outside the sim, or from a Sim on another thread, are
not ordered this way: do not share state that stores ambient instants (a cache,
a presence map) with those.
Three more rules hold for code that a sim drives:
- Use
biased;intokio::select!. Without it, tokio selects the branch order at random. A stable build cannot set the seed for this order. Two branches that are ready at once then run in a different order per run. - Do not use the database's
random(). Draw fromstate.entropy()and pass the value in. - Do not drop a
SQLitequery in flight. diesel-async panics when it drops one on a current-thread runtime, such as the sim's. Aselect!drops the futures it owns when another branch wins. Pin the query outside theselect!and poll it by reference. If another branch wins, spawn the query. The job worker still drops a handler on a lost claim or a timeout, so a handler query in flight at that time panics in a sim.
The sim-sweep found all three in the framework's own job, scheduler, lock
and heartbeat code; they now follow these rules.
If you write a custom impl ClockSource whose now() is virtual, you must
also override monotonic(). The trait ships a default body that reads the real
process-monotonic clock — that keeps every pre-existing implementation compiling
and behaving exactly as before, but it means a virtual clock that forgets to
override it silently reports real elapsed time.
The framework holds itself to the same rule on the modules listed in
scripts/check-determinism-gate.sh, where a clippy deny-lint enforces it; see
CONTRIBUTING.md's "Determinism seam gate" for the enforced subset and the parts
of the framework that are not on the seam yet.
A sim-only green run proves your orchestration, timing, ordering, and identity logic — it is not a substitute for testcontainer integration tests against real Postgres, and it does not simulate SQL-dialect or isolation-level behavior. See issue #1797 for the full design rationale and determinism boundary.