vike
ConceptsThe command journal

Replay

A recorded session re-folded through the real single-writer core from its first checkpoint and fenced against the recorded final state hash — what replays, what is deliberately replay-neutral, the three fences, and the crash-restart restore that shares the engine.

crates/vike-core/src/replay.rs is the payoff of the journal: a recorded session re-derived through the same runtime it was recorded on, and proven bit-identical. Its header says what that buys twice over — it gates the journal format, because any nondeterminism in the fold surfaces as a mismatch, and it is the substrate the crash restart restores from. Two entry points share one engine, replay_from. replay_offline restores from the first Snap, re-folds the whole tail, and fences the reproduced final hash against the source journal's last Snap. restore_from_journal restores from the latest Snap, re-folds only the crash tail after it — empty on a clean shutdown — and applies no fence, because it is a restore.

The mechanism

Restoring from the first checkpoint is what makes the fence worth anything: restoring from the latest would leave nothing to replay on a clean run, since the exit Snap already reflects everything. Cadence Snaps are taken after the fold, so every command before the first one is already folded into it and every command after it is re-folded forward. From that engine replay_from spawns a real core with three substitutions. ReplayClient is a pure no-op ExecutionClient: it suppresses every venue side effect and synthesizes no events — the original venue events are already in the journal as Ingest::Event records and are re-pumped, so a client that also emitted them would double the state and correctly break the fence. QueueClock replays the tail's recorded now_ms stamps in order, so timestamp-derived state such as an order's created_ms matches. The temp journal is opened with snapshot_every = u64::MAX, so only the exit Snap fires and carries the reproduced hash. The tail is pumped through the handle in journal order — events and commands feed one FIFO queue, so dispatch order equals record order — then the core is shut down and joined, and its exit Snap is read back. The replay core runs no strategy hook: only a strategy's already-decided orders need to reproduce, and those are on disk. When the base Snap came from a mounted-strategy session, replay_from does mount an InertStrategy — every hook at its no-op default, and never invoked, since the tail pump sends no market data — solely so spawn_core's mount-presence derivation of collect_applied_fills, a field the hash does cover, matches the source rather than diverging on a perfectly faithful fold.

What replays and what is neutral

The tail extraction is an exhaustive match with no catch-all — a new record variant fails to compile until someone decides. Four variants re-apply: Cmd, StrategySubmit (as an order command through the same apply_intent site), and ConditionalFire and MarginCallLiquidate, each as a submit whose coid is still empty, so the replay core re-mints the identical id from the restored generator. The trigger and the breach are never re-evaluated; the replay core pumps no market data, so its equity never moves. Everything else is replay-neutral at any position: Snap is the base, MintedSubmit's mint is re-derived, PortfolioSnap carries no engine state, the arm and disarm records only resolve terms, and GtdExpire and ScheduleFire are decision markers whose effects arrive as their own records — re-issuing them would double a cancel or a set of submits. Market data is never journaled, and neither is what a strategy's hooks saw; what is journaled is what they decided.

The three fences

The engine fence is vike_exec::state_hash, defined in crates/vike-exec/src/engine_snapshot.rs: an FNV-1a64 over the canonical JSON of the engine snapshots, with two fields cleared before hashing — the top-level now_ms, which is stamped by every ingest message including non-journaled market ticks, and the account's mark-price cache, which mark ticks mutate. Neither is reproducible by re-folding the exec lane alone, so hashing them would fail the fence on a perfectly valid journal; everything else stays hashed. The fence verifies the recoverable order and account state, which is the point. A mismatch is ReplayError::HashMismatch.

That covered one thing while the restore handed back three more, so replay_offline adds two fences over the same oracle, the source journal's last Snap. conditionals_hash compares the reproduced resting conditional books with the recorded ones, clearing a trailing arm's ratcheted extreme, which moves on non-journaled market data as the marks do; it is gated on version 8 or later through latest_segment_version, because an older Snap reads back empty books whether or not the live session had arms. And the two id counters are fenced as floors by fence_floor: an overshoot only wastes ids, while an undercount means a restored session re-mints an id the pre-crash run already spent — and an arm id is the disarm key, so a duplicate lets a stale disarm remove a fresh stop. Both surface as ReplayError::RestoreMismatch naming the field, distinct from the engine mismatch on purpose: the two have different triage paths.

A truncated journal is reported as truncated, not as a divergence

When the frame walk halts at a torn frame, the last readable Snap is a mid-session checkpoint, so the honestly re-folded tail cannot match it. That is ReplayError::Truncated carrying the CorruptCause from Frames: a torn length prefix is the ordinary consequence of a crash, a checksum-valid frame that does not parse came from a different build. A truncated journal whose fences all pass — a tear strictly after the exit Snap — still succeeds. Two shapes are Unsupported: a multi-engine base, whose extra engines' seed cash is not in their snapshots, and a tail carrying the wall-clock waker — journaled only when the stuck-order watchdog or either dead-man's switch is enabled, precisely so such a session refuses. A journal with no Snap is Empty.

The restore

restore_from_journal returns a RestoredState: the engines, the coid session and counter to resume so post-restart ids never collide with a still-open prior id, the arm counter, the resting conditional books, the OTO/OCO contingency book, the per-mount attribution ledgers, and the coid-to-mount origin map. Membership of the conditional books is a pure fold of the journal's records (fold_conditionals), never the replay core's own book — that core neither fires nor arms a trailing stop faithfully, the records do. The contingency book is read from the replay core's exit Snap instead, because it is driven purely by journaled fills. The origin map is folded from the StrategySubmit and MintedSubmit provenance already on disk (fold_coid_mounts), which is what lets an order still resting across the restart book its eventual fill into the mount that placed it. The caller pattern on its doc: rebuild the engine from the snapshot with the real client, spawn the core with the restored session and books, point the journal at the same directory so appends resume, and let venue reconciliation repair whatever the venue saw while the process was down. Two residuals are declared: a trailing arm re-arms at its last-snapshot extreme, slightly looser than it died, and re-tightens as live data resumes; and on an unclean crash the attribution ledgers are the base Snap's value, the gap showing in the published residual row rather than vanishing.

The tool

crates/vike-core/src/bin/replay_journal.rs is replay_offline at a command line:

cargo run -p vike-core --bin replay_journal -- <journal-dir>

It prints the source journal's total record count, the number of snapshots verified (one — the final hash), the reproduced hash, and one line per engine with open orders, positions and balance; a failure prints REPLAY FAILED with the error and exits 1. The replay core's own journal is a fresh directory under the system temp directory, removed on every path including a panic. The binary page and the determinism tutorial run it end to end.

On this page