ADR-059: Compressed Snapshot Storage Envelope¶
Date: 2026-08-10
Status: Accepted — implemented for core snapshot I/O, dump CLI,
compare/scan --against/Python API/service layer, the internal snapshot
cache, actions/baseline (snapshot-compression input, stale-file cleanup
across all three canonical suffixes, build_manifest.py reading through
canonical snapshot I/O), the root composite Action's dump-mode
snapshot-compression input, abi-baseline: latest-release/<tag>
recognizing compressed release assets, and publish-baseline.yml/
update-main-baseline.yml's snapshot-compression input.
actions/resolve-baseline needed no code change at all — it already reads a
compressed manifest-referenced snapshot transparently via magic-byte
detection, confirmed by a dedicated end-to-end test. Baseline-set manifest
v2, a deterministic .tar.zst packager, and the wider documentation sweep
remain deferred — see "What this ADR does not (yet) close".
Decision maker: Nikolay Petrov
Context¶
A real-world audit of two oneDAL libraries found:
| Library | Raw snapshot | Functions | Types |
|---|---|---|---|
daal |
149.45 MB | 23,006 | 1,044 |
oneapi::dal |
114.98 MB | 5,976 | 912 |
Each snapshot's embedded L5 header-declaration graph (build_source's
inline pack, see abicheck/buildsource/CLAUDE.md) accounts for roughly
57–59 MB, ~38k graph nodes, and ~92k graph edges. A release bundling both
libraries as raw JSON is ~264 MB; gzipped, ~7.7 MB (daal ~3.4 MB,
oneapi::dal ~4.3 MB). The content is highly repetitive — JSON keys,
namespace paths, header paths, producer strings, and graph facts recur
constantly — so this is a storage-format problem, not an evidence-bloat
problem: the fix is compressed storage, not deleting graph/evidence content
(explicitly out of scope — see "Non-goals").
Before this ADR, abicheck/serialization.py's load_snapshot/
save_snapshot always did plain open(..., encoding="utf-8") — no
gzip/zstd support anywhere in the snapshot I/O path (confirmed by a full
producer/consumer audit — see "Audit" below). zstandard was already a
dependency, but scoped to the validation extra and used only by the
conda-forge .conda-archive test harness, never for snapshot I/O.
Audit (producers/consumers of snapshot I/O, before this ADR)¶
| Call site | plain | gzip | zstd | notes |
|---|---|---|---|---|
serialization.load_snapshot/save_snapshot |
yes | no | no | the only snapshot file I/O choke point |
service.resolve_input (sniff_text_format) |
yes | no | no | magic-byte-blind; a compressed file sniffed as "unknown" |
cli_resolve._sniff_text_format |
yes | no | no | second copy of the same heuristic (compare's per-side metadata classification) |
snapshot_cache.py (lookup_key/store_key) |
yes | no | no | up to 100 raw JSON entries, <key>.json |
cli.py dump write path |
yes | no | no | snapshot_to_json() → fold_dump_provenance_into_json() (a second full json.loads/json.dumps round trip) → Path.write_text |
buildsource/baseline_set.py (_snapshot_digest_issue) |
yes | no | no | reads raw dict directly (bypasses AbiSnapshot) for content-hash validation |
actions/baseline/build_manifest.py |
yes | no | no | reads .abicheck.json as a raw dict by design (schema-decoupled) |
action/run.sh (abi-baseline: latest-release) |
yes | no | no | globs only *.abicheck.json |
pyproject.toml |
n/a | n/a | validation extra only |
zstandard not reachable from snapshot I/O at all |
Decision¶
1. Logical schema vs. storage envelope¶
The logical snapshot stays exactly what it has always been: a JSON
object with a top-level schema_version (serialization.SCHEMA_VERSION,
currently 20), produced by snapshot_to_dict/consumed by
snapshot_from_dict. Compression is a storage/transport envelope
around that same JSON payload, decided per-file, and never a new snapshot
schema:
- it does not change
schema_version, the extraction contract (AbiSnapshot.contract), profile/scope fingerprints, evidence depth,build_source, graph nodes/edges, verdict, or field ordering; - a decoded compressed snapshot's bytes are byte-identical to what the
plain writer produces for the same logical snapshot (P0 deliberately
keeps compression a pure envelope, not a new compact JSON dialect —
verified by
test_decompressed_bytes_match_plain_writer_bytes); snapshot_from_dict(json.loads(decoded_bytes))yields the identicalAbiSnapshotregardless of which encoding produceddecoded_bytes.
Vocabulary used consistently across code/docs: logical snapshot /
decoded snapshot (the JSON payload), stored snapshot / storage
envelope (the on-disk bytes, possibly compressed), logical content hash
(hash of the stable decoded dict — baseline_set.compute_snapshot_content_
hash, unaffected by storage encoding), stored-file hash (sha256 of the
literal on-disk bytes, encoding-dependent), decoded size, stored size,
compression algorithm.
A single .json.zst file is a snapshot storage envelope, never called
an "archive" in code or docs — a baseline-set .tar.zst (multiple
libraries/binaries bundled) is the thing this repo calls an archive/
container; the two are architecturally distinct and must not be conflated.
2. Supported algorithms¶
One project-wide enum, abicheck.snapshot_io.SnapshotCompression:
- plain (
none) —*.abicheck.json/*.abi.json/ any other.json. Best for debugging and small, Git-reviewable snapshots. - gzip —
*.abicheck.json.gz/*.abi.json.gz. Universally available, good for interoperability with tooling that only speaks gzip. - zstd —
*.abicheck.json.zst/*.abi.json.zst. Preferred for baseline/release/cache storage: a better compression ratio than gzip at every level measured (see "Deterministic compression" below) — the baseline/release level (19) trades write speed for that ratio, while the separate, faster cache level (3) is chosen for write speed instead.
bz2/xz/lz4 are explicitly out of scope for this pass (P0) — not
because they're bad, but because supporting a fourth codec doubles the
detection/determinism/limit-testing surface for no acceptance-criteria gain.
3. Detection: magic bytes, not suffix¶
abicheck.snapshot_io.detect_snapshot_compression/
detect_compression_from_bytes classify a stored snapshot from its first 4
bytes — 1f 8b (gzip), 28 b5 2f fd (zstd), else plain — never from the
filename alone. suffix_compression/resolve_write_compression separately
answer what a canonical suffix implies, and the two are cross-checked on
read: a canonical-suffix file whose magic bytes disagree with the suffix is
a hard SnapshotError (never a silent guess in either direction).
bounded_decoded_prefix reads a small bounded decoded prefix (never a full
decompression) so input classification (service.sniff_text_format,
cli_resolve._sniff_text_format) can tell a compressed snapshot (decoded
prefix starts with {) from an unrelated compressed archive (a
baseline-set .tar.zst, which doesn't) without misrouting either one.
4. Canonical snapshot I/O layer¶
abicheck/snapshot_io.py is the one module that knows how a snapshot is
stored — a dependency-free leaf module (no import of the rest of
abicheck) so it can be imported from serialization.py,
snapshot_cache.py, and CLI/service code without growing the existing
CLI-registration import-cycle SCC (IMPORT_CYCLE_ALLOWLIST). It exposes:
SnapshotCompression,SnapshotWriteResult(path, compression, decoded/ stored size, stored sha256,.ratio),SnapshotStorageInfo;detect_snapshot_compression,detect_compression_from_bytes,suffix_compression,resolve_write_compression,bounded_decoded_prefix,read_snapshot_storage_info;read_snapshot_bytes/read_snapshot_text(transparent decode, with a decoded-size limit — see "Decompression limits");write_snapshot_bytes/write_snapshot_text(atomic, deterministic, compression-aware write).
abicheck/serialization.py stays the public compatibility surface:
load_snapshot(path) transparently reads plain/gzip/zstd; save_snapshot
(snap, path, *, compression="auto") keeps its historical two-positional-
argument calling convention (compression is keyword-only, defaulting to
"auto", so every existing save_snapshot(snap, path) call is unchanged);
write_snapshot(snap, path, *, compression="auto", zstd_level=None) ->
SnapshotWriteResult is the new richer entry point for callers that want
the write summary. snapshot_to_json() remains the in-memory string helper
for callers/tests that want a JSON string without touching a file.
5. zstandard promoted to a core dependency¶
.json.zst is a first-class format, so it must work after a plain pip
install abicheck on every supported Python (3.10–3.14 have no stdlib
zstd) and OS, without depending on a system zstd binary. zstandard moved
from the validation extra into [project.dependencies]. The
validation extra was initially kept (now empty) rather than removed, so an
existing pip install "abicheck[validation]" invocation wouldn't fail on an
unknown extra; it has since been deleted outright in the pre-release
historical-surface cleanup, since no first-party script, CI job, or doc ever
referenced it. The validation/ harness itself is unaffected -- it never
needed an extra of its own once zstandard became core.
6. Deterministic compression¶
Byte-for-byte reproducible for identical logical payload + settings, on Linux/macOS/Windows:
- gzip: fixed
compresslevel=9,mtime=0, no embedded filename (gzip.compress(data, compresslevel=9, mtime=0)— the stdlib module never writes a filename when compressing from bytes rather than a real file). Verified:test_gzip_header_has_no_embedded_filename_or_mtime,test_deterministic_gzip_bytes. - zstd: one
zstandard.ZstdCompressorbackend everywhere (no systemzstd),write_checksum=False,write_content_size=True, and two fixed, project-owned levels rather than a user knob (a P0 requirement — no--compression-levelflag, to avoid a second profile/ storage drift axis):
| Use | Level | Why |
|---|---|---|
Baseline/release (ZSTD_LEVEL_BASELINE) |
19 | written rarely (a CI publish job), read often — take the slow/best-ratio end |
Internal cache (ZSTD_LEVEL_CACHE) |
3 | written on nearly every dump/compare invocation — take the fast end |
Chosen from a measured trade-off on an ~18.5 MB graph-heavy synthetic snapshot (38k-node/92k-edge shape, matching the real oneDAL L5 graph section's scale):
| Level | Stored size | Ratio | Compress time |
|---|---|---|---|
| 3 | 2.90 MB | 15.6% | 0.07s |
| 6 | 2.64 MB | 14.2% | 0.18s |
| 10 | 2.27 MB | 12.3% | 0.37s |
| 15 | 1.93 MB | 10.4% | 1.85s |
| 19 | 1.40 MB | 7.6% | 12.96s |
| gzip -9 | 3.28 MB | 17.7% | 0.80s |
Extrapolated to a real ~150 MB snapshot, level 19 costs roughly 100–110s of compression time — acceptable for a periodic baseline- publish CI job, not for a per-invocation cache write.
P0 does not attempt a custom compact JSON dialect alongside compression — see "Non-goals".
7. Atomic, safe writes¶
Every canonical write (write_snapshot_bytes/write_snapshot_text, and
therefore every writer built on it — dump, the snapshot cache, save_
snapshot/write_snapshot) is atomic: a temp file in the same directory
(tempfile.mkstemp), full payload write, flush(), best-effort fsync(),
os.replace() onto the final path, with the temp file removed on any
failure and the destination left untouched. Verified:
test_atomic_write_leaves_no_temp_file, test_failed_write_preserves_
existing_destination (injects an os.replace failure and asserts the
pre-existing destination bytes are unchanged and no stray temp file is
left).
The dump write path (see "dump pipeline" below) never writes an
intermediate raw snapshot next to the compressed one — the compressor
consumes the already-built JSON text directly.
8. Decompression limits (bomb defence)¶
DEFAULT_MAX_DECODED_BYTES = 1 GiB — comfortably above the real oneDAL
snapshots' ~150 MB decoded size, private-override-only via
_ABICHECK_SNAPSHOT_MAX_DECODED_BYTES for tests (no public CLI flag, per
the "no new knobs without product need" rule). Both gzip and zstd decode in
bounded chunks, raising SnapshotError the moment cumulative decoded bytes
exceed the limit — the same fail-closed behavior either way. zstd
additionally bounds the decompressor's window size (max_window_size = 1 <<
31, a 2 GiB ceiling) independent of the decoded-byte limit, so a hostile
frame can't force an oversized window allocation regardless of what it
claims to produce. A frame that decompresses cleanly but short of its own
declared content_size (a truncation shape that a naive streaming read
loop can silently swallow as "just fewer bytes than expected" — confirmed
empirically) is cross-checked against that declared size and raised as
corrupt, not accepted as a short read.
9. Logical vs. stored digests; dump pipeline¶
dump's write path used to be:
AbiSnapshot -> snapshot_to_json() [full JSON string]
-> fold_dump_provenance_into_json() [json.loads + mutate + json.dumps -- a SECOND full parse/encode]
-> Path.write_text()
Now:
AbiSnapshot -> snapshot_to_dict() [one payload dict]
-> fold_dump_provenance_into_dict() [mutate the dict in place]
-> json.dumps() [one encode]
-> write_snapshot_text() [atomic, optionally compressed]
fold_dump_provenance_into_dict() (new) does the same augmentation as
before directly on the dict; fold_dump_provenance_into_json() (the
former sole entry point, dump_provenance is JSON-only payload
augmentation, not an AbiSnapshot field — unchanged by this ADR) is now a
thin backward-compatible wrapper over it for existing callers/tests. dump
prints a compact storage summary to stderr (never stdout, which stays pure
JSON when no -o/--output is given):
Snapshot written to foo.abicheck.json.zst
Storage: zstd, 19,169 -> 3,191 bytes (16.6%)
Resolved evidence depth: headers
--compression (auto default) on abicheck dump resolves from
-o/--output's suffix; an explicit value that contradicts a canonical
output suffix is a hard UsageError, never a silent rename/override.
--compression <non-none> with no -o/--output is also a hard
UsageError (stdout always stays plain JSON). dump --dry-run shows the
resolved compression (and that no file will be created) without invoking
the compressor — moot in practice today since --dry-run and -o/--output
are already mutually exclusive at the CLI level, but implemented for
forward compatibility and exercised via unit tests on the underlying
resolver.
Not attempted in this pass, and explicitly deferred (see "Non-goals"):
replacing dataclasses.asdict() (snapshot_to_dict's own base) with a
hand-rolled incremental/streaming JSON encoder. The two full-payload passes
this ADR removes (snapshot_to_json + fold_dump_provenance_into_json's
re-parse) were the concrete, measured problem; asdict() itself was not
separately profiled as a bottleneck in this pass.
10. Snapshot cache (snapshot_cache.py)¶
New entries are written zstd-compressed at ZSTD_LEVEL_CACHE
(<key>.json.zst), through the canonical atomic writer. lookup_key
checks the compressed entry first, then falls back to a legacy plain
<key>.json entry so an upgrade doesn't discard a warm cache wholesale.
store_key removes a stale legacy plain entry for the same key after
writing the compressed one, so a lookup never prefers stale content over a
freshly stored entry. _evict_if_needed's LRU glob covers both suffixes.
A corrupt/truncated compressed cache entry is a cache miss (the existing
"any read problem here is cache-safe, never a crash" stance), not a
caller-visible failure. _SNAPSHOT_CACHE_VERSION is not bumped: the
cache key is a function of the dump inputs, not the storage encoding, and
existing entries stay valid and readable — only new writes changed shape.
11. What this ADR does not (yet) close¶
Deferred, tracked as follow-up work rather than attempted as a drive-by extension of this pass (each is a separately-scoped project of its own):
- Baseline-set manifest v2 (compressed member paths, a
storageblock per artifact recording compression/stored-sha256/stored/decoded size alongside the existing logicalsha256, v1 backward compatibility). Notebuild_manifest.pyalready records a per-artifactcompressionfield (none/gzip/zstd) informationally as of the follow-up below — that is not the same as a versioned, backward-compatible manifest schema bump, which is what's actually deferred here. - The root composite Action's
dump-mode default output path/outputs (a.abicheck.json.zstdefault output path rather than plain.abicheck.json, and dedicatedsnapshot-path/snapshot-compression/ size outputs). Thesnapshot-compressioninput itself, andabi-baseline: latest-release/<tag>recognizing a compressed release asset, are both implemented — see the Status line above. - A shared deterministic
.tar.zstpackager forpublish-baseline.yml/update-main-baseline.yml, to replacetar --zstd, which does not guarantee deterministic tar metadata across runners. Both workflows' ownsnapshot-compressioninput (forwarded toactions/baseline) is implemented — see the Status line above. - The wider documentation sweep listed in the originating task
(
docs/use/github-action*.md,docs/reference/*-baseline.mdbeyondpublish-baseline.md,docs/start/*, the upgrade guide,README.md,mkdocs.ymlnav) beyond this ADR,docs/reference/snapshot-format.md,docs/use/baseline-storage.md,docs/reference/publish-baseline.md, and ADR-015's cross-reference below, all of which are done. BuildSourcePackexternalization/deduplication (aninline | referenced | autopackaging mode, content-addressed dedup of a shared pack betweendaal/oneapi::dal-shaped sibling libraries) — the architectural question is real (see the originating task's "internal/ out-of-band buildsource packs" section) but is its own scoped design, not something this ADR's storage-envelope change should half-implement.- Graph wire-schema compaction — the L5 node/edge JSON shape itself is unchanged; this ADR closes the storage problem it causes without touching its representation, deliberately (a schema change needs its own versioned design and backward-compat tests, per this repo's existing ADR-046/048 graph-identity conventions).
None of the above blocks what is implemented: dump, compare, scan
--against, the typed Python API, and the internal snapshot cache all
already transparently read/write plain, gzip, and zstd snapshots today —
see the acceptance table in the PR/CHANGELOG entry for this ADR.
12. Postmortem: the max_window_size unit bug (and what it changed about this ADR's tests)¶
A real-world .json.zst baseline (window_size 8 MiB, content_size ~150 MB —
exactly the scale this ADR's own "Real-world audit" table above cites)
could not be read back after this ADR shipped: ZstdDecompressor(
max_window_size=...) was being called with an intended-byte value silently
divided by 1024 (a docstring-literal reading of python-zstandard's
max_window_size parameter, which claims kibibytes; the underlying
implementation — both backends — passes the value straight through to
ZSTD_DCtx_setMaxWindowSize(), and that libzstd API takes raw bytes). The
effective accepted window shrank to ~2 MiB against an intended 2 GiB, so any
snapshot compressed with a real multi-megabyte window failed to decode with
ZstdError: Frame requires too much memory for decoding — surfaced to users
as a misleading Cannot detect format of '...', since the sniffing path
(bounded_decoded_prefix/_try_decode_prefix) swallows the underlying
decompression exception into "unrecognized format" by design (see
"Detection" above).
Root cause, generalized beyond this one bug: every existing test validated shape, not the actual external-library contract:
- A unit test asserted the fixed ceiling's own internal arithmetic
(
_ZSTD_MAX_WINDOW_SIZE_KIB * 1024 == intended_byte_ceiling) — a tautology against the bug's own (wrong) formula, not a check againstpython-zstandard's actual runtime behavior. - A second test did attempt to exercise a real "at the ceiling" frame
(
window_log=31), but with a small, highly-compressible fixture (b"a" * (1 << 20)). zstd's single-segment framing collapses a frame's recordedwindow_sizedown to itscontent_sizewhenever content fits inside the nominal window — so that fixture's real required window was ~1 MiB, comfortably under the buggy ~2 MiB effective ceiling too. The test exercised none of the failure mode it was written to catch, and passed identically before and after the bug. - No test exercised the storage layer's actual public contract — write
through the real production path (
ZSTD_LEVEL_BASELINE, no manualCompressionParametersoverride) at a large-enough scale to force zstd's real auto-selected window, then read it back. Every zstd test either compressed a tiny in-memory fixture or hand-built aCompressionParametersobject, none of which is whatwrite_snapshot/dumpactually do.
The general lesson, not specific to zstd: a test that only checks a
value's internal arithmetic, or that exercises a toy-shaped boundary
condition (small/no-op input at a small-number-but-large-magnitude nominal
parameter), can pass on both sides of a real regression. When a module's
job is "honor an external library's storage contract" (this ADR's whole
premise), at least one test per algorithm must go through the actual
public write path, at a content scale realistic enough to trigger the
condition being defended against (here: content large enough that zstd's
auto window-sizing stops collapsing to a toy value) — never only a
hand-constructed shortcut into the library's lower-level API. snapshot_io.py's
test suite (tests/test_snapshot_compression.py) now has three such tests
built around a payload sized to force a real 8 MiB window (matching the
ZSTD_LEVEL_BASELINE auto-selection observed against a real ~150 MB
snapshot): one exercising _decompress_zstd directly, one exercising
bounded_decoded_prefix's sniffing path, and one exercising
sniff_text_format end to end — plus a full production-write-path
round-trip test (test_zstd_round_trip_at_production_scale_and_level) that
never constructs a CompressionParameters at all, only calls
write_snapshot_bytes/read_snapshot_bytes the way dump/compare
actually do. All four fail against the pre-fix code with the same
Frame requires too much memory for decoding symptom the field report hit,
and pass with it.
A related, independently-found gap in the same fix (Codex review): the
ceiling itself (1 << 31) is only valid on a 64-bit libzstd build —
ZSTD_DCtx_setMaxWindowSize() bound-checks its argument against the
backend's own reported [windowLogMin, windowLogMax] range and errors
(not just declines an oversized frame) if exceeded, and a 32-bit build's
ZSTD_WINDOWLOG_MAX_32 is 30. _zstd_max_window_size_bytes() now clamps
to min(31, zstandard.WINDOWLOG_MAX) — the backend's own runtime-reported
ceiling — rather than a value this module asserts unconditionally, with a
unit test simulating a lower WINDOWLOG_MAX to pin the clamp directly
rather than relying on access to an actual 32-bit build.
Non-goals (explicit)¶
- DWARF/scan extraction memory (RSS) is out of scope. This ADR touches
only the serialization/write stage (removing redundant parse/encode
passes, adding atomic + streaming-friendly compression) — it does not
touch
DwarfSession, DIE caches, basic/advanced DWARF extraction, scan memory limits, runner swap, scan/baseline timeout budgets, extraction concurrency, or reachability/filtering algorithms. A separate, concurrent effort owns DWARF/scan memory work; this ADR does not claim to solve it and did not modify any of the files that effort owns. - No new compact/binary JSON dialect (see "Compression levels" above).
- No
bz2/xz/lz4support. - No user-facing
--compression-levelflag. - No new
snapshot inspect-shaped root CLI command (the storage summarydumpalready prints, plus the typedSnapshotWriteResult/SnapshotStorageInfoPython API, cover the needed observability without a new command clearing ADR-054's admission bar).
Backward compatibility¶
- Every pre-existing plain
.abi.json/.abicheck.jsonsnapshot loads unchanged (test_pre_compression_fixture_still_loads). save_snapshot(snap, path)— the historical two-positional-argument call — is unchanged;compressionis a new keyword-only parameter defaulting to"auto"(test_save_snapshot_legacy_positional_signature_ unchanged).- A pre-this-ADR reader (an already-released abicheck) has no code path
that recognizes gzip/zstd magic bytes at all and will fail attempting to
json.loads()a compressed file — this is the expected, documented migration boundary (see the CHANGELOG/migration-guide entry for this ADR): a compressed snapshot requires an abicheck build containing this ADR's reader to open. Noschema_versionbump was needed or made, since the logical schema is unchanged — see "Logical schema vs. storage envelope" above.
Related¶
- ADR-015 (Snapshot Serialization and Schema Versioning) — this ADR is additive to it: the schema-versioning contract ADR-015 established is unchanged; this ADR adds a storage envelope around the same schema.
- ADR-028/031 (single-artifact UX, inline
BuildSourcePack) — the L5 graph section this ADR's compression benefits most is the one those ADRs chose to embed inline; see "What this ADR does not (yet) close" for the deferred externalization question. - ADR-050 (comparability contract) — unaffected:
ExtractionContractfingerprints are computed from decoded snapshot content, identical across encodings.