| name | deterministic-simulation-testing |
| description | Use when building or testing distributed systems, consensus protocols, sync engines, replicated databases, or any system with network/disk/time non-determinism. Also use when tests are flaky due to concurrency, when debugging rare heisenbugs, or when asked about simulation testing, BUGGIFY, VOPR, or fault injection strategies. |
Deterministic Simulation Testing (DST)
Overview
DST collapses a multi-node distributed system into a single-threaded simulation where all non-determinism (network, disk, time, randomness) is controlled by a seeded PRNG. Same seed = same execution = perfect reproducibility.
Origin: FoundationDB — "We found all of the bugs in the database." Adopted by TigerBeetle, RisingWave, Dropbox, WarpStream, and others.
When to Use
- Building distributed systems, consensus protocols, sync engines, replicated state
- Code with network I/O, disk I/O, timers, or concurrency
- Flaky tests caused by race conditions or timing
- Need to find bugs that only manifest under rare failure combinations
- Want reproducible failure cases from randomized testing
When NOT to use:
- Pure computational code with no I/O or concurrency
- Simple CRUD apps without distributed state
- Performance benchmarking (simulation doesn't measure real performance)
The Three Pillars
1. I/O Abstraction
Every interaction with the outside world goes through an interface that can be swapped for simulation.
┌─────────────────────────────┐
│ Application Logic │
├─────────────────────────────┤
│ I/O Interface Layer │
├──────────┬──────────────────┤
│ Real I/O │ Simulated I/O │
│ (prod) │ (test) │
└──────────┴──────────────────┘
What to abstract: Network (TCP/UDP/RPC), Disk (reads/writes/fsync), Time (clocks/timers/sleep), Random (all PRNG sources).
Go example:
type Network interface {
Send(to NodeID, msg []byte) error
Recv() (from NodeID, msg []byte, err error)
}
type Clock interface {
Now() time.Time
Sleep(d time.Duration)
After(d time.Duration) <-chan time.Time
}
type Storage interface {
Write(key string, data []byte) error
Read(key string) ([]byte, error)
Sync() error
}
2. Single-Threaded Deterministic Execution
All "concurrent" nodes run cooperatively in one thread. A scheduler decides which node runs next, controlled by the seed.
State machine pattern (from sled):
fn receive(msg, at) -> [(msg, destination)] // Handle incoming message
fn tick(at) -> [(msg, destination)] // Advance time step
Simulation loop:
type Simulator struct {
rng *rand.Rand
clock *SimClock
network *SimNetwork
storage *SimStorage
events *PriorityQueue
nodes map[NodeID]*Node
}
func (s *Simulator) Run(seed int64, steps int) {
s.rng = rand.New(rand.NewSource(seed))
for i := 0; i < steps; i++ {
event := s.events.Pop()
s.clock.AdvanceTo(event.Time)
responses := event.Node.Handle(event.Msg)
for _, resp := range responses {
if s.shouldDeliver() {
delay := s.randomDelay()
s.events.Push(Event{
Time: s.clock.Now().Add(delay),
Node: resp.To,
Msg: resp.Msg,
})
}
}
s.checkInvariants()
}
}
3. BUGGIFY — White-Box Fault Injection
Black-box fault injection (network partitions, crashes) can't trigger higher-level distributed failures. BUGGIFY injects faults inside your own code to exercise rare paths.
Three rules:
- Simulation-only — never fires in production
- Deterministic per-run — each BUGGIFY site is enabled/disabled for the entire run (decided by seed)
- Default 25% probability — customizable per site
Go implementation:
var simulationMode bool
func Buggify(probability float64) bool {
if !simulationMode {
return false
}
return globalRng.Float64() < probability
}
func (r *Replica) sendHeartbeat() error {
if Buggify(0.1) {
simClock.Sleep(electionTimeout * 2)
}
if Buggify(0.05) {
return nil
}
return r.network.Send(r.leader, heartbeatMsg)
}
func (s *Storage) writeBlob(key string, data []byte) error {
if Buggify(0.02) {
data = data[:len(data)/2]
}
if Buggify(0.01) {
err := s.disk.Write(key, data)
if err != nil { return err }
return ErrSyncFailed
}
return s.disk.WriteAndSync(key, data)
}
Five BUGGIFY patterns:
| Pattern | What It Does | Example |
|---|
| Minimal work | Use minimum viable values | Quorum size = bare minimum |
| Forced errors | Trigger error handling paths | Return artificial I/O errors |
| Delay injection | Emphasize concurrency issues | Sleep before/after critical sections |
| Knob randomization | Sweep configuration space | Randomize buffer sizes, timeouts |
| Damage control | Disable after N seconds | Stop injecting faults to test recovery |
Framework Quick Reference
| Framework | Language | Approach | Best For |
|---|
| Madsim | Rust | Drop-in Tokio replacement via Cargo aliasing | Rust async systems, gRPC, etcd, Kafka |
| Turmoil | Rust | Lightweight single-thread multi-host sim | Tokio apps, Axum servers, simpler setups |
| Antithesis | Any | Commercial SaaS, autonomous bug discovery | Large teams, critical systems, any language |
| WASM approach | Go | Compile to WASM for single-threaded execution | Go systems (Polar Signals, WarpStream) |
| Custom | Any | Hand-built I/O abstraction + sim loop | Maximum control, specific requirements |
Madsim (Rust) — Key APIs
let mut rt = Runtime::with_seed_and_config(seed, config);
let node1 = rt.create_node().ip(addr1.ip()).build();
NetSim::current().clog_node(node_id);
NetSim::current().unclog_node(node_id);
Runtime::check_determinism(seed, config, || async { ... });
Turmoil (Rust) — Key APIs
let mut sim = Builder::new()
.simulation_duration(Duration::from_secs(60))
.min_message_latency(Duration::from_millis(1))
.max_message_latency(Duration::from_millis(100))
.fail_rate(0.05)
.build();
sim.host("server", || async { Ok(()) });
sim.client("client", async { Ok(()) });
sim.partition("a", "b");
sim.repair("a", "b");
sim.crash("server");
sim.bounce("server");
sim.run()?;
Go WASM Approach
GOOS=wasip1 GOARCH=wasm go test -run TestSimulation ./...
GORANDSEED=12345 go test ...
import "github.com/polarsignals/faketime"
clock := faketime.NewClock() // Controlled by simulator
Test Levels (from TigerBeetle VOPR)
Run simulations at escalating severity:
| Level | Description | Fault Rate |
|---|
| Normal | Perfect conditions, validate basic correctness | 0% faults |
| Adversarial | Network partitions, message reordering, delays | 5-15% fault rate |
| Hostile | Storage corruption, partial writes, crash-recovery | 20-30% fault rate |
TigerBeetle achieves 1000x time compression (3.3s simulation = 39min real-world) and runs on 1024 cores continuously.
Invariant Checking
The simulation is only as good as your invariants. Check after every step:
func (s *Simulator) checkInvariants() {
s.checkLinearizability()
for i, n1 := range s.nodes {
for j, n2 := range s.nodes {
if i >= j { continue }
assertConsistent(n1.CommittedState(), n2.CommittedState())
}
}
if !s.faultsActive && s.clock.Since(s.lastConvergence) > maxConvergenceTime {
panic("liveness violation: system did not converge")
}
}
CI/CD Integration
Run multiple seeds in parallel on every commit:
strategy:
matrix:
seed: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
steps:
- run: go test -run TestSimulation -seed=${{ matrix.seed }} -steps=100000
RisingWave runs 16 parallel seeds per CI run. WarpStream found bugs that evaded 10,000+ hours of traditional testing.
When a test fails: Log the seed. Reproduce with go test -run TestSimulation -seed=<FAILING_SEED>. Debug deterministically.
Structuring Code for DST
digraph dst_structure {
rankdir=TB;
"Application logic" [shape=box];
"I/O interface" [shape=box];
"Real I/O" [shape=box];
"Simulated I/O" [shape=box];
"Sim harness" [shape=box];
"Application logic" -> "I/O interface";
"I/O interface" -> "Real I/O" [label="production"];
"I/O interface" -> "Simulated I/O" [label="test"];
"Sim harness" -> "Simulated I/O" [label="controls"];
"Sim harness" -> "Application logic" [label="drives"];
}
Step-by-step:
- Define I/O interfaces for network, disk, time, random
- Implement real versions for production
- Implement simulated versions controlled by seeded PRNG
- Structure logic as state machines —
handle(msg) -> [actions]
- Write simulation harness with priority-queue event loop
- Add BUGGIFY sites at decision points in application code
- Define invariants — safety, liveness, linearizability
- Run many seeds — each seed explores a different path through the state space
Common Mistakes
| Mistake | Fix |
|---|
| Hidden non-determinism (system clock, goroutine scheduling, map iteration) | Audit ALL randomness sources. Use sort on map keys. Inject clock everywhere. |
| Testing only happy paths | Add BUGGIFY at every I/O site. Test corruption, not just partitions. |
| Too few seeds | More seeds = more state space coverage. Run hundreds in CI. |
| No invariant checks | Simulation without invariants just runs code. Define safety + liveness properties. |
| BUGGIFY in production | Guard ALL fault injection behind simulationMode flag. |
| Seed-dependent reproduction breaks on code changes | Expected. Log enough state to understand the failure, not just the seed. |
| Single test level | Escalate: normal → adversarial → hostile. Bugs hide at different severity levels. |
Real-World Results
- FoundationDB: "Found all of the bugs in the database"
- TigerBeetle: 1000x time compression, 3 severity levels, runs continuously on 1024 cores
- WarpStream: Found critical data race in 233 seconds that evaded 10,000+ hours of traditional testing
- Dropbox: Millions of nightly simulation runs for sync engine
- RisingWave: 16 parallel seeds per CI run, 2+ months of debugging phase found entire bug categories
- MongoDB (via Antithesis): 75+ severe bugs that all other testing missed