Deterministic Simulation Testing for Distributed Systems

AI Evaluation & Safety advanced Building with AI ~35 min read

Overview

Distributed systems fail in combinations that ordinary unit, integration, and staging tests rarely cover. A database replica can crash after accepting a write but before acknowledging it. A network link can become asymmetric, allowing one node to send messages but not receive them. A disk can return corrupt data without an error. These are not exotic academic cases for consensus systems, storage engines, schedulers, and AI infrastructure services that rely on replicated state.

Deterministic simulation testing is a testing architecture that runs the real system logic inside a controlled simulator. The simulator replaces clocks, network I/O, storage I/O, process crashes, and request arrival with deterministic event sources driven from a seed. When a randomized run finds a bug, the same seed replays the same schedule, turning a rare distributed failure into a reproducible test case.

FoundationDB popularized this approach by building a deterministic database simulator before building much of the database itself. TigerBeetle applies the same family of ideas through its VOPR simulator: a single-process virtual cluster that injects network, process, and storage faults while checking strict serializability, replica convergence, and liveness. The transferable lesson is not the product-specific simulator; it is the pattern of designing production code so that nondeterminism can be intercepted, randomized, replayed, and checked continuously.

Core Concepts

Deterministic Event Sources

The simulator owns every source of nondeterminism. Time advances in logical ticks instead of wall-clock milliseconds. Network sends become scheduled message deliveries. Disk reads and writes go through a virtual device that can delay, corrupt, drop, or reorder operations. Random choices come from a seeded generator. The production code still thinks it is using clocks, sockets, and disks, but those dependencies are interfaces supplied by the simulation harness.

Fault Injection Over Real Implementations

Model checking verifies an abstract model; deterministic simulation executes the implementation. That distinction matters because many severe bugs are not in the textbook protocol. They live in buffer ownership, retry logic, request IDs, persistence ordering, compaction scheduling, clock assumptions, and error handling. A simulator that runs the real state machine can find mismatches between the intended algorithm and the shipped code.

Seeded Reproducibility

Randomized testing is most useful when failures are reproducible. A deterministic simulator records the initial seed, configuration, and sometimes a compact event trace. Developers can rerun the failing seed locally, add logging, single-step through the event schedule, and confirm the fix by keeping the seed as a regression case.

Key Concept: Simulated Time Dilation

A simulator can advance logical time much faster than real time because it does not sleep on real network, disk, or timer delays. TigerBeetle describes VOPR runs where seconds of simulation cover many minutes of real-world behavior, and longer runs cover months or years of equivalent cluster time. That time dilation lets CI and continuous fuzzing explore schedules that staging clusters would rarely reach.

Key Concept: Safety and Liveness Need Different Scenarios

Safety checks ask whether the system ever returns an impossible result, loses committed data, or lets replicas diverge. Liveness checks ask whether the system keeps making progress when a healthy quorum exists. TigerBeetle split its simulator into safety and liveness modes after observing that temporary random faults could miss a livelock caused by a permanently disruptive non-core replica.

How It Works

Build a Simulator Boundary

Production logic must depend on an environment abstraction rather than calling the operating system directly. The abstraction should include time, random numbers, network send/receive, storage operations, process lifecycle events, and timers. The real deployment supplies an operating-system-backed environment; tests supply a deterministic simulator.

type Environment interface {
    Now() Tick
    After(delay Tick, callback func())
    Send(from NodeID, to NodeID, msg Message)
    ReadBlock(node NodeID, block BlockID, callback func(ReadResult))
    WriteBlock(node NodeID, block BlockID, data []byte, callback func(WriteResult))
    Random() *Rand
}

type Simulator struct {
    seed   uint64
    rand   *Rand
    tick   Tick
    events PriorityQueue[Event]
    net    NetworkModel
    disk   StorageModel
    checks []InvariantChecker
}

func (s *Simulator) Run(maxTicks Tick) Failure {
    for s.tick < maxTicks && !s.events.Empty() {
        event := s.events.Pop()
        s.tick = event.At
        event.Apply(s)

        for _, check := range s.checks {
            if err := check.Validate(s); err != nil {
                return Failure{Seed: s.seed, Tick: s.tick, Err: err}
            }
        }
    }
    return Failure{}
}

Inject Faults as Scheduled Events

Faults should be explicit events in the same deterministic queue as normal work. A network message can be delayed, duplicated, dropped, or delivered across a partition. A replica can crash before a callback runs and restart later from persisted state. A disk operation can return an error, return stale data, or return corrupt bytes. Because all choices are seed-driven, the simulator can rerun the exact same sequence.

func (n *NetworkModel) Deliver(s *Simulator, msg Envelope) {
    if n.Partitioned(msg.From, msg.To) {
        return
    }
    if s.rand.Float() < n.DropProbability {
        return
    }
    delay := n.DelayDistribution.Sample(s.rand)
    s.events.Push(Event{
        At: s.tick + delay,
        Apply: func(s *Simulator) {
            s.Cluster[msg.To].Receive(msg)
        },
    })
    if s.rand.Float() < n.DuplicateProbability {
        n.Deliver(s, msg)
    }
}

Check Invariants Continuously

A simulator without strong invariant checkers becomes a random demo. The harness should verify properties after every event or after bounded event batches: committed operations form a single total order, acknowledged writes are durable, responses are linearizable, replicas never apply conflicting commands at the same log index, storage checksums match, and available quorums eventually process requests under liveness scenarios.

Practical Applications

Consensus and Replication

Replicated logs, leader election, quorum reads, failover, and membership changes are natural targets. A deterministic simulator can explore message reordering, delayed heartbeats, leader crashes, stale terms, asymmetric partitions, duplicate requests, and restart sequences. This is especially useful for systems that implement Raft, Multi-Raft, Viewstamped Replication, Paxos-family protocols, or custom metadata replication.

Storage Engines

Storage engines can simulate torn writes, read corruption, slow disks, flush reordering, compaction races, manifest loss, and recovery after crash. TigerBeetle uses storage-fault simulation as a first-class part of its cluster testing because consensus correctness alone does not protect a database from faulty media or incorrect recovery paths.

AI Infrastructure Control Planes

AI engineering systems increasingly include distributed schedulers, model-serving fleets, cache invalidation protocols, vector-index update pipelines, and agent queues. Deterministic simulation can test whether queued jobs are lost during failover, whether model placement converges after node churn, whether rate-limited evaluators preserve accounting, or whether a vector index update stream remains monotonic across retries.

Implementation Patterns

Start with a Narrow State Machine

Do not begin by simulating the entire production platform. Start with a state machine that has a clear contract: a replicated log, a shard coordinator, a task queue, or a storage manifest. Put all side effects behind interfaces, then write a simulator that drives that state machine with generated workloads and generated faults.

type Workload struct {
    Clients int
    Keys    []Key
    MaxOps  int
}

func GenerateWorkload(r *Rand, cfg Workload) []ClientOp {
    ops := make([]ClientOp, 0, cfg.MaxOps)
    for i := 0; i < cfg.MaxOps; i++ {
        client := r.Intn(cfg.Clients)
        key := cfg.Keys[r.Intn(len(cfg.Keys))]
        if r.Float() < 0.7 {
            ops = append(ops, Put{Client: client, Key: key, Value: r.Bytes(32)})
        } else {
            ops = append(ops, Get{Client: client, Key: key})
        }
    }
    return ops
}

Use Swarm Testing for Coverage

Fixed probabilities repeatedly explore the same region of the state space. Swarm testing randomizes the configuration itself: number of replicas, number of clients, partition style, delay distribution, crash frequency, write corruption probability, workload mix, and request concurrency. Some runs become calm, some become brutal, and some emphasize one fault class enough to reveal a hidden assumption.

Promote Failing Seeds into Regression Tests

When a seed fails, minimize the configuration if possible, keep the seed, and add it to a deterministic regression suite. Random exploration finds the bug; the fixed seed prevents recurrence. This also makes simulator work acceptable in code review because reviewers can rerun the exact failure rather than trusting an anecdotal fuzzing result.

Performance Considerations

Common Pitfalls

Further Reading