Skip to content

Support Binary Versioning - #191

Open
ZwFink wants to merge 11 commits into
developfrom
worktree-binary-version-oo
Open

Support Binary Versioning#191
ZwFink wants to merge 11 commits into
developfrom
worktree-binary-version-oo

Conversation

@ZwFink

@ZwFink ZwFink commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

We are evolving Mneme's binary format in different ways (#188) is one example. At the same time, we are building persistent recording databases and adding new features to Mneme. This portends binary compatibility hell, which this branch diverts us from.

We create an interface for reading/writing Mneme's binary formats, with versioned readers.

The bytes and diff writers each re-typed the global-variable and blob
record prefixes, so a layout change had to be made in four places at
once. Move each prefix into a small value type with write/read/size and
route every writer, reader and size estimator through it.

The metadata size formula moves next to metadata::serialize for the same
reason. Metadata itself stays outside BlobHeader: the bytes layout puts
it after the blob data and the diff layout puts it before the range
count, so one contiguous codec cannot cover both without changing a
layout.

No on-disk change; the bytes written are identical.
Which format a snapshot file is, and what that implies, was re-derived by
hand in six unrelated places: two magic sniffs, two reader entry points
whose names promised a format the code did not enforce, a hard-coded
13-byte string in the unit test, and a Python-side rule that an epilogue
always needs a base prologue.

SnapshotHeader::parse is now the only code that knows any magic string,
including both legacy layouts. It returns the kind, the version and the
offset the payload starts at, so a reader never has to know which prefix
it skipped. SnapshotFormatRegistry maps that pair onto one reader per
(kind, version) through a table; an unknown pair is one fatal that names
what it found.

Self-containment is now a property of the reader rather than a guess at
the call site. A prologue goes through BaseSnapshotSource::load, which
rejects a diff by name instead of failing later on an empty base path,
and a bytes epilogue no longer needs a base at all.

SnapshotHeader::write still emits the legacy prefixes, so this writes
byte-identical files.
The record side branched on EpilogueSnapshotType inside the epilogue
lambda, over three static writers with three mismatched signatures, and
"best" mode compared them through computeMnemeBytesSnapshotSize, a
by-hand copy of the bytes layout that had to be edited in lockstep with
the writer.

SnapshotInput collapses the three signatures into one argument.
FormatWriter owns the shape every format shares, a header followed by a
payload, so a format supplies only header() and writePayload(). "Best" is
now a composite writer that measures its two members and delegates to the
smaller, rather than a third policy that re-derives both sizes.

BytesWriter::measure stays analytic, because the default counting pass
would copy every blob device-to-host just to size it. ValidateMeasure in
the unit test now pins both writers to the size they actually produced,
which is the check the hand-maintained estimate never had.

makeEpilogueWriter is the only remaining switch on EpilogueSnapshotType.
MnemeSnapshot is gone.

No on-disk change.
A bytes snapshot had no header at all, so it was recognised only as
"anything that is not a diff" and could never be versioned. Every new
file now begins with the 16-byte SnapshotHeader, bytes and diff alike,
which is the whole point of having a header: the next layout change is a
version bump and one reader subclass rather than a new magic string.

Only SnapshotHeader::write changes. No reader or registry row is needed,
because the version names the payload layout, which is unchanged, and
whether the container prefix is present is already handled by parse
returning a different offset.

Recordings made before this keep loading through the two aliases in
parse; verified by reading legacy-prefix copies of both formats back
through the registry. Old Mneme builds cannot read files written after
this, which is the intended break.

Two test-side parsers of the bytes layout, tests/record/read-record.py
and python/tests/test_record_annotations.py, learn to skip the prefix.
They keep the offset-0 fallback, so they still read older recordings.
The only caller asks whether a file can serve as a base, which it cannot
if it depends on a base of its own. Naming that dependency directly reads
better than negating "self-contained" at every use, and matches the Base
parameter of read() and BaseSnapshotSource.
Nothing modified the argument, so the non-const reference only forced the
bytes reader to copy the name into a mutable local before constructing.
Same fix as the std::string constructor; the name is only copied.
Add globalVarHeader and readGlobalFromDevice so the bytes and diff
writers share one device-to-host path for globals, and drop the stray
std::cout debugging from BytesWriter. Replace countChangedRanges with a
scratch-buffer pass so ranges are scanned once, shared through
emitRanges. Collapse the diff reader's count checks into expectCount and
use StringRef::starts_with for the magic comparisons.
Each reader now owns its (kind, version) pair in a Layout member. The
registry table and the paired writer's header() reference that member,
so bumping a format means typing the version number in exactly one place.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant