Replay
In H.G. Wells' The Time Machine, ,} the traveler moves freely through time, witnessing the rise and fall of civilizations. Our event log grants us a similar power---not over physical time, but over the time of our system.
Given a complete history of events, we can reconstruct the state at any moment. We can travel backward and forward. We can watch the story unfold.
Deriving State from Events
The fundamental operation of replay is simple:
replay(events, up_to_index):
state = initial_state
for each event in events[0..up_to_index]:
state = apply(state, event)
return stateOr more declaratively:
state_at(n) = reduce(events[0..n], apply, initial_state)To see move 15, we replay the first 15 events. To see the current state, we replay all events.
Time Travel
With replay, we gain extraordinary powers:
Go to any moment:
state_at_move_10 = replay(events, up_to: 10)
state_at_move_50 = replay(events, up_to: 50)
state_at_start = replay(events, up_to: 0) // = initial_stateUndo is just "go to the previous moment":
current_move = 23
undo = replay(events, up_to: current_move - 1)Redo is "go forward again":
redo = replay(events, up_to: current_move) // back to where we wereTwo Roads to Time Travel
Let's compare how mutable and immutable approaches handle time travel:
Immutable (Event Sourcing):
// State is derived from events
events = [e1, e2, e3, e4, e5]
current_state = replay(events)
// Undo: just replay fewer events
previous_state = replay(events[0..4])
// Go to any point: replay to that point
state_at_3 = replay(events[0..3])Mutable (Command Pattern):
// State is modified in place
// Each operation must record how to reverse itself
history = [
{do: "set age to 30", undo: "set age to 25"},
{do: "set name to Bob", undo: "set name to Alice"},
]
// Undo: apply the inverse operation
undo():
last = history.pop()
apply(last.undo)The trade-offs are illuminating:
| Immutable/Replay | Mutable/Inverse | |
|---|---|---|
| Undo complexity | Trivial (replay less) | Must track inverses |
| Random access | Replay from start | Only sequential undo |
| Storage | All events | State + undo stack |
| Computation | Re-derive state | Direct mutation |
The mutable approach can be optimized. For example, you could compact the history:
// Original history
history = [
"age = 10",
"age = 20",
"age = 30"
]
// Compacted: only the net change matters
compacted = ["age: was 10, now 30"]
// Undo 3 steps? Just restore to 10.This trades computation (compacting) for memory (shorter history). Real systems often use hybrid approaches---event logs with periodic snapshots, or undo stacks with intelligent merging.
The Power of Determinism
Replay only works if our system is deterministic. Given the same events in the same order, we must always arrive at the same state.
// Deterministic: always gives same result
apply(state, event) = new_state // pure function
// Non-deterministic: might give different results!
apply(state, event) =
new_state with timestamp: now() // depends on external time If apply consults external data (current time, random numbers, network calls), replay will produce different results each time. The time machine breaks.
Solutions:
- Store everything needed in the event itself (capture time when event is created)
- Use deterministic pseudo-random numbers with stored seeds
- Isolate non-determinism at system boundaries
Watching the Story Unfold
Replay isn't just for computation---it's for understanding.
// Animate a chess game
for move_number in 1..total_moves:
state = replay(events, up_to: move_number)
display(state)
pause(1 second)We can also analyze:
// Find when the queen was captured
for move_number in 1..total_moves:
state = replay(events, up_to: move_number)
if not has_queen(state, white):
return move_number // Find all positions where black was in check
check_positions =
[1..total_moves]
|> filter((n) => is_in_check(replay(events, n), black))The Cost of Replay
Replay is powerful but not free. To reach move 1000, we must process 1000 events.
// Slow: replay from beginning every time
state_at_1000 = replay(events, 1000) // processes 1000 events
state_at_1001 = replay(events, 1001) // processes 1001 events!If we access recent states often, this is wasteful. We keep re-doing the same work.
Caching the Journey
We can optimize by keeping intermediate states:
// Cache recent state
cached_state = replay(events, 1000)
cached_index = 1000
// To get state 1001: continue from cache
state_at_1001 = apply(cached_state, events[1001])Or cache at regular intervals:
snapshots:
at_move_0: initial_state
at_move_100: replay(events, 100)
at_move_200: replay(events, 200)
...
// To reach move 237: start from snapshot 200, replay 37 events
state_at_237 = replay(events[201..237], starting_from: snapshots[200])This hybrid approach---events plus periodic snapshots---gives us the best of both worlds: full history and fast access.
Debugging with Replay
When something goes wrong, replay lets us investigate:
// Bug report: "Game crashed at move 47"
// Replay to just before the crash
state_before = replay(events, 46)
// Examine the state
inspect(state_before)
// Try the problematic event
problematic_event = events[47]
// Step through slowly, watching what happens
debug_apply(state_before, problematic_event)We can reproduce bugs exactly. We can step through the history. We can find the moment things went wrong.
We've seen two approaches: storing events (and replaying to get state) versus storing state directly. Each has trade-offs. In the next chapter, we compare them systematically: snapshots versus stories.