vike
Tutorials

Replay a journal and pass the fence

Turn on the write-ahead command journal, re-fold a recorded session through the real core, and read the state-hash fence that proves it reproduced bit for bit.

The command journal's payoff is not that you can read it. It is that a recorded session can be re-derived through the same single-writer runtime and proven bit-identical — which gates the journal format itself, because any nondeterminism in the fold surfaces as a mismatch. crates/vike-core/src/replay.rs is that proof, and this tutorial runs it.

Turn the journal on

Journaling is off by default, so the ordinary desktop and headless paths stay zero-overhead and byte-identical. journal_config_from_env describes exactly two ways in, in precedence order:

  1. VIKE_RUN_PROFILE=<run.toml> — load that run profile and use its [sinks].journal mapping. The profile is authoritative: it decides even when that means "no journal", so a profile can never be silently overridden. A set-but-broken profile disables journaling and logs a warning rather than falling through.
  2. VIKE_JOURNAL_DIR=<dir> — a single-knob quick opt-in at the default cadence, with VIKE_JOURNAL_SNAPSHOT_EVERY=<n> optionally overriding the snapshot cadence. Consulted only when VIKE_RUN_PROFILE is unset.

Neither set means no journal. The same directory is reachable from settings as config.journal_dir, with VIKE_JOURNAL_DIR as its environment spelling — see environment variables.

Run a session — paper is fine, and is the right place to start — then stop it cleanly so the shutdown snapshot is written.

Replay it

The replay_journal binary (its reference page is replay-journal) takes the journal directory and nothing else. It prints four things:

journal records  : <total records in the SOURCE journal, commands and snapshots>
snaps verified   : <n>
final state hash : <16 hex digits>
engine <venue>/<symbol>: <n> open orders, <n> positions, balance <x>

The first line is labelled honestly in the source: records is the total source journal record count, not the length of the tail that was re-folded. A failure prints REPLAY FAILED: with the error and exits non-zero.

What it actually does

Two entry points share one engine. replay_offline — the one the binary calls — restores from the journal's first Snap, re-folds the whole tail, and fences the reproduced final hash against the source's last Snap hash. restore_from_journal is the crash-restart twin: it restores from the latest snapshot, re-folds the usually-empty crash tail, and deliberately has no fence, because it is a restore rather than a proof.

Restoring from the first checkpoint is the load-bearing choice. Restoring from the latest would leave nothing to replay on a clean run — its exit snapshot already reflects everything — which is a worthless fence. Cadence snapshots are taken after the fold, so every command before the first snapshot is already folded into it and every command after is re-folded forward, re-deriving the whole post-checkpoint history.

The replay core is the real one, with three substitutions:

  • a pure no-op ReplayClient, because the original venue events are already journaled as Ingest::Event records and get re-pumped — a client that also emitted events would double the state;
  • a QueueClock replaying the tail's recorded now_ms, so timestamp-derived state such as an order's creation time matches;
  • a temporary journal whose snapshot cadence is effectively infinite, so only the exit snapshot fires.

The fence is final-hash equality, not per-record comparison: the replay core mints its own sequence numbers, so a per-sequence comparison is impossible, and re-folding from the first checkpoint reproducing the exact final state is the essential guarantee.

What the fence covers now

It used to check exactly one thing — state_hash(&engines) — while the restore hands a restart three more values nothing looked at. A fold bug that resurrected a consumed conditional arm, dropped a live one, mis-scoped a mass cancel or rewound a counter reproduced a bit-identical engine hash and passed. replay_offline now adds two fences over the same oracle:

  • the resting conditional books, through conditionals_hash against the ones the source's last snapshot recorded — on a v8-or-later journal only, since Snap.conditionals was defaulted in at v8 and an older snapshot reads its books back empty;
  • the coid_seq / arm_seq counters, as floors rather than equalities (fence_floor). An overshoot is legal and deliberately produced — a mark-refused tail trailing arm, or a pre-v7 base snapshot — while an undercount is the id-reuse bug worth catching.

Four exclusions remain, each argued rather than overlooked: now_ms and the account snapshot's marks, which non-journaled market data mutates on every message; a trailing arm's ratcheted extreme, the books' analogue of those marks; the GTD sweep's fire-once suppression set, which cannot move the hash in either direction; and the publish/cadence bookkeeping — dirty, journaled_since_snap and the bounded recent-events ring — which decides only when a snapshot or GUI publish fires, never what it contains, and is not restored at all, so a fence would have nothing to compare.

Reading a failure

ReplayError's variants are a triage table, and two distinctions matter more than the rest.

Truncated is not HashMismatch

A journal whose read stopped at a torn frame reports Truncated { cause }, returned instead of HashMismatch and only where that would have been returned. The records the fences see are then a prefix of what the session wrote, so the fence target is not the exit snapshot but whichever mid-session checkpoint survived — and re-folding past a mid-session checkpoint necessarily produces a different hash. HashMismatch reads as "the engine diverged, this journal cannot be trusted"; for a torn tail that is a misreading of the ordinary consequence of a crash. A truncated journal whose fences all pass still succeeds exactly as before.

RestoreMismatch { field, expected, got } is the other split: the engine state matched, but something else the restore hands back did not — a book or a counter. Different cause, completely different triage path. Unsupported covers the two shapes this replay does not handle: a multi-engine base snapshot, and a tail carrying watchdog-tick records, which the submit-acknowledgement timeout and both dead-man switches still arm and which are not deterministic functions of the journal. Empty means no snapshot at all — a truncated or absent journal, since a clean shutdown always writes one. Io is a real disk fault, kept distinct so a determinism tool never folds one into Unsupported.

Upgrading between sessions

Segments stamp a format version, and this build reads and resumes anything in the accepted range rather than rejecting it, so upgrading a binary never costs an operator their live journal. The rule that makes the range safe is that a version step may only add a record variant or a defaulted field. Determinism is unaffected by reading an older journal: a v4 journal replayed by a newer binary yields exactly the record sequence a v4 binary saw, because the added variants are ones v4 never wrote, and the additive records that do exist are replay-neutral observations the tail extraction ignores. What an older journal costs is fence COVERAGE rather than determinism: below v8 the conditional-books fence above is skipped, while the hash fence and the counter floors still run.

Next: the command journal for the format and the sync policy, replay for the reference page, and stop, liquidate, restart for what a restart replays and what it does not.

On this page