vike
ConceptsThe command journal

Frames

The [len][check][payload] frame codec, the one frame walk every reader layers over, and the three torn-tail causes it tells apart — because a benign post-crash tear and a segment written by a different build deserve opposite readings.

A segment is a 16-byte header — the magic VJL1, the format version, and the first record seq — followed by frames laid end to end. crates/vike-core/src/journal/frame.rs owns the codec: [len: u32 LE][check: u32 FNV-1a32(payload)][payload: serde_json(record)]. The check is fnv1a32, a 32-bit FNV-1a over the payload bytes and nothing else. The payload is one record.

How a frame is written

CommandJournal::write_framed in crates/vike-core/src/journal/writer.rs writes the check, then the payload, and the length last — so a reader can never see a length that points at an unwritten payload. If the frame would not fit, the journal rolls to a fresh segment first. Every flush_every records (256 at the default) it issues one flush_async, never per record: that syscall on every append would blow the latency gate. The blocking flushes are a separate story, told on Sync policy.

One walk, three readers

walk_frames is the single frame walk in the module. The resume scan that finds the write cursor on open (scan_written), the full read that feeds replay (read_all) and the per-segment seq bounds the prune computes (scan_segment_seqs) all layer their own aggregation over it. That single-siting is the point: the torn-tail decision is made in exactly one place, and the three former copies cannot drift apart. The walk starts at an offset, hands every successfully decoded record to a callback, and returns where it stopped together with why — a WalkEnd.

WalkEnd::Clean is the ordinary end of a segment's written data: a zero length (segments are zero-filled at creation) or the buffer's end. It is not corruption, and a caller that spans segments crosses to the next one. WalkEnd::Corrupt means the walk stopped at the last valid record and never advanced into the suspect bytes, and it carries which of three conditions fired.

The three causes

CorruptCause exists because the walk already knows which check failed and used to throw that away, leaving a reader unable to tell an expected post-crash truncation from a payload written by a different build. Each cause has a stable log token and a one-line operator reading:

causetokenwhat it means
OobLenoob_lenthe length prefix claims more bytes than the segment holds — a torn length write, the process died between the length and the payload. Expected and benign after a crash; every record before it is intact
CheckMismatchcheck_mismatchthe FNV check over the payload does not match the stored one — a torn payload write (benign after a crash), or bytes that rotted on disk (not benign)
ParseFailedparse_failedthe payload passed its check, so nothing tore, and still did not deserialize as a JournalRecord — a schema mismatch: these bytes came from a different build, and the version header did not catch it

The order of the checks is what makes the third distinguishable at all: the check gates the parse, so by the time deserialization is attempted the bytes are known to be exactly what was written.

The cause is diagnostic only; the halt is the same for all three

Carrying the cause out changed no caller's behaviour. read_all halts journal-wide at the first corrupt frame — nothing at or after that offset is replayed, in that segment or any later one — while the single-segment callers simply end their fold. An out-of-bounds length is deliberately classified as corrupt rather than clean so that a multi-segment reader stops there instead of crossing into a later segment past a corruption gap. The cross-segment test in this file exists precisely because every other corruption test is single-segment, and a per-file break would pass all of them.

Where the halt is reported

The seam that acts on the halt is CommandJournal::read_all_reporting, which logs once per call — naming the segment, the halt offset, the cause token, how many records were kept, and the reading — with the message journal read halted at a corrupt frame. Before that warning existed the decision was taken in silence; a reader of a live node's logs could not tell that replay had stopped early, let alone why. Nothing on this path runs on the fold thread: read_all is an offline and startup reader.

The same cause reaches the determinism fence. A truncated read makes the last readable Snap a mid-session checkpoint rather than the session's exit Snap, so re-folding the tail past it cannot reproduce the recorded hash. Replay reports that as ReplayError::Truncated carrying the CorruptCause, instead of the HashMismatch it used to surface as — "the engine diverged" being a misreading of the ordinary consequence of a crash when the cause is oob_len, and the right reading when it is parse_failed.

What the header gates before any frame is read

A file without the magic is skipped as foreign. A file with the magic is one of ours, and its version is gated by check_version before a single frame is walked: a supported older version reads normally — it simply contains none of the variants added since — while an unsupported one, older than the floor or newer than this build, is an explicit InvalidData error rather than a best-effort parse or a silent drop as a torn tail. That gate and ParseFailed are complementary: the header catches a declared version step, and the parse failure catches a frame whose build did not declare one.

On this page