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:
| cause | token | what it means |
|---|---|---|
OobLen | oob_len | the 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 |
CheckMismatch | check_mismatch | the 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) |
ParseFailed | parse_failed | the 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.
Records
The journal's serde schema — the externally-tagged JournalRecord enum every frame carries, its eleven variants and what replay does with each, and the compile-time contract that stops a new variant from being silently dropped at recovery.
Segments
How journal segment files are named, why a segment is built under a staging name before it goes live, what pre-allocation and page warming buy the append path, how a roll carries the sequence and the lock forward, and when whole segments are pruned.