Skip to content
About
Main content

Replay determinism

Break it on purposeNeeds Python

Problem

Exactly-once processing in stream engines is from a : after a crash, restore the state saved at the last checkpoint and reprocess the events after it. The final state equals a crash-free run only if reprocessing an event produces exactly what the first processing did. A computation that reads a clock or a random number produces something different on replay, and nothing in the engine detects it. On the output side, writes repeated by the replay are harmless only if the is , for example an keyed by event id.

Write a tiny exactly-once pipeline: a loop that reads a list of events, updates a dictionary, and "checkpoints" by saving the dictionary and the position every 500 events. Crash it at a random position and restart from the checkpoint, 50 times. First make the computation pure. Then add random.random() to it and repeat. Then make the output an upsert keyed by event ID.

The question. Does the final state match a crash-free run after each crash, and what do replayed writes do to an upsert sink?


Predict

With random.random() inside the computation, in how many of 50 crash-and-replay runs does the final state differ from a crash-free run?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    replay-determinism.py59 linesDownload
    View the code
    replay-determinism.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Replay determinism.
    
    A tiny exactly-once pipeline: a loop over 10,000 events updates a dictionary
    and checkpoints (state + position) every 500 events. It is crashed at a
    random position and restarted from the checkpoint, 50 times, first with a
    pure computation and then with one that reads random.random(). Each final
    state is compared with a crash-free run. Then the impure output is written as
    an upsert keyed by event id, and replayed events are counted.
    """
    import argparse, copy, json, random
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--events", type=int, default=10_000, help="Events.")
    p.add_argument("--every", type=int, default=500, help="Checkpoint interval.")
    p.add_argument("--trials", type=int, default=50, help="Crash-and-restart trials.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    rng = random.Random(args.seed)
    events = [{"id": i, "user": f"u{rng.randrange(50)}", "amount": rng.randrange(1, 100)} for i in range(args.events)]
    def step(state, e, impure):
        bonus = random.random() if impure else 0.0                             # the hidden non-determinism
        state[e["user"]] = state.get(e["user"], 0) + e["amount"] + bonus
    def run(impure, crash_at=None, sink=None):
        ckpt = ({}, 0); state, pos = {}, 0; replayed = 0
        while pos < len(events):
            e = events[pos]; step(state, e, impure)
            if sink is not None:
                if e["id"] in sink: replayed += 1
                sink[e["id"]] = round(state[e["user"]], 6)                     # upsert keyed by event id
            pos += 1
            if pos % args.every == 0: ckpt = (copy.deepcopy(state), pos)
            if crash_at is not None and pos == crash_at:
                state, pos = copy.deepcopy(ckpt[0]), ckpt[1]; crash_at = None   # crash: restart from the checkpoint
        return state, replayed
    rows = []
    for impure in (False, True):
        random.seed(1); base, _ = run(impure)
        differ = 0
        for t in range(args.trials):
            crash = rng.randrange(1, args.events); random.seed(1)               # same seed as the crash-free run: only the replay differs
            st, _ = run(impure, crash)
            if any(abs(st.get(k, 0) - v) > 1e-9 for k, v in base.items()): differ += 1
        rows.append({"computation": "random.random() inside" if impure else "pure", "trials": args.trials, "final_state_differs": differ})
    sink = {}; random.seed(7); _, replayed = run(True, crash_at=args.events // 2 + 123, sink=sink)
    summary = {"sink_rows": len(sink), "events": args.events, "replayed_writes": replayed}
    for r in rows: print(f"{r['computation']:<24} final state differs from the crash-free run in {r['final_state_differs']} of {r['trials']} crashes (no error raised)")
    print(f"upsert keyed by event id: {replayed} replayed writes, {len(sink):,} rows for {args.events:,} events")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"computations": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run replay-determinism.py
    uv run replay-determinism.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--events10,000 Events.
--every500 Checkpoint interval.
--trials50 Crash-and-restart trials.
--seed0 Random seed.

Our run

50 crashes and restarts

50 crashes and restarts
ComputationFinal state differsOf
pure050
random.random() inside5050
Reference runApple M2 · 8 cores · 8 GB
Output · uv run replay-determinism.py
pure                     final state differs from the crash-free run in 0 of 50 crashes (no error raised)
random.random() inside   final state differs from the crash-free run in 50 of 50 crashes (no error raised)
upsert keyed by event id: 123 replayed writes, 10,000 rows for 10,000 events
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

The pure computation matched the crash-free run after every crash (0 of 50 differed). With random.random() inside, 50 of 50 runs ended in a different state, and nothing raised an error. On the output side, the replay wrote 123 events a second time, and the upsert sink still held exactly 10K rows for 10K events.


Read it

Exactly-once is a property of the computation as much as of the engine: the engine guarantees each event's effect is applied once, from state that was correct, and your code must make that effect the same every time. Push non-determinism into the input (an event's own timestamp, a seed stored with the event) and make sinks idempotent.