Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy

.nornir/meta-index-design.md

Searchable archive metadata — the __znippy_meta__ sub-index

Status: implemented (2026-07-22, oden) Question it answers: which entries in this archive carry key X, and what are their values — read from an index, never by streaming the payload. First consumer: dwarves' build thing (a wasm module describing how to build an app, embedded in artifacts and pulled from a registry). Finding one inside an archive without extracting the archive is the same problem as reading it from an ELF section without executing the binary.


Why this is a sub-index and not a new format

A sealed v0.7 .znippy already ends:

[ blob_0 … blob_N ][ data sub-idx(es) ][ lookup ][ trie ][ manifest ][ ZNPYMIDX ][off]

The 16-byte footer gives the manifest offset with one seek; the manifest frames every section by (offset, len). A metadata region that is independently readable at O(index) cost is therefore already a solved problem in this format — it only needed a module. META_MODULE = "__znippy_meta__" is registered exactly like LOOKUP_MODULE and TRIE_MODULE, written through the existing ArchiveMetaSink::push_subindex, and read back through the existing reserved- section reader.

There is deliberately no second directory. The metadata rows key on relative_path — the same identity the data sub-indexes and the lookup already use — and a hit is resolved to bytes through the existing locate_file / ArchiveReader. Nothing here duplicates the index, so nothing here can drift out of agreement with it. (This was an explicit constraint: twinned sources of truth bit this constellation three times in one session.)


On-disk shape

Arrow IPC, one RecordBatch, schema metadata carrying znippy_format_version (so a section from a newer znippy is refused rather than mis-parsed — the same guard every other sub-index gets).

column type null meaning
relative_path Utf8 yes the entry this fact is about; NULL = archive-level
key Utf8 no the search key
value_type Int8 no 0 Str · 1 I64 · 2 F64 · 3 Bool · 4 Bytes
v_str Utf8 yes exactly one of the five is non-null,
v_i64 Int64 yes chosen by value_type
v_f64 Float64 yes
v_bool Boolean yes
v_bytes Binary yes

Columnar rather than a serialized blob so the section stays directly queryable by DuckDB/Polars — SELECT relative_path FROM … WHERE key = 'build-thing' — which is the same property the rest of znippy's index layer sells.

Rows are stored sorted by (key, relative_path). Key-major, so find_by_key and find_by_prefix are partition_point ranges (O(log n) + O(hits)), not scans, and archive-level rows for a key sort ahead of its entry-level rows (NULL first).

value_type and the value columns are cross-checked on read: a row that declares a string with a NULL string column, or an unknown tag, is an error. Nothing defaults. The section is attacker-reachable input.


API

// write
let mut t = MetaTable::new();
t.insert("app/main.wasm", "build-thing", MetaValue::Bytes(wasm))
 .insert("app/main.wasm", "build-thing.abi", "wasi-p2")
 .insert_archive("producer", "znippy");

create_archive_with_meta(path, &files, 3, Some(t))?;      // fresh
append_files_with_meta(path, &more, 3, Some(extra))?;     // merge into existing
ArrowIpcSink::new(f, b).with_meta(t)                      // compress path, via MetaSinkFactory

// read
match read_archive_meta(path)? {
    ArchiveMeta::NoMetadata => …,                 // nothing was searched
    ArchiveMeta::Index(ix) => {
        ix.find_by_key("build-thing");            // &[MetaEntry] — entry refs
        ix.find_by_prefix("build-thing");         // sweeps build-thing.abi too
        ix.archive_value("producer");
        ix.keys(); ix.iter(); ix.len();
    }
}
// or, keeping the distinction at the call site:
meta.find_by_key("build-thing").hits()            // Option<&[MetaEntry]>

Queries return entry refs, so the caller reads the index, decides, and only then extracts the one entry it wants:

for hit in meta.find_by_key("build-thing").hits().unwrap_or(&[]) {
    let bytes = ArchiveReader::open(path)?.read_file(hit.path().unwrap())?;
}

CLI

znippy append -i a.znippy -a dir \
      --meta 'app/main.wasm=build-thing=@build.wasm' \
      --meta 'app/main.wasm=build-thing.abi=wasi-p2' \
      --meta-archive 'producer=znippy'

znippy meta -i a.znippy -k build-thing          # exit 0 — index exists, hits
znippy meta -i a.znippy -p build-thing --paths-only
znippy meta -i a.znippy -k absent               # exit 1 — searched, no match
znippy meta -i old.znippy -k anything           # exit 2 — NO index; nothing searched

Values are typed by shape, narrowly and predictably: @file → raw bytes, true/false → bool, integer → i64, decimal → f64, anything else → string (so 1.0.2 stays a string rather than half-parsing).


NoMetadata is not an empty index

This is the part with a scar on it. An archive written before this module has no __znippy_meta__ entry; a reader that turns that into an empty result set will answer "searched, found nothing" about an archive it never searched.

So:

The re-seal trap, closed

ArrowIpcSinkAppend::open_existing truncates the entire metadata tail and rebuilds it. Without carrying the metadata section forward, every append would silently erase it — and the reader would then honestly report NoMetadata for an archive that used to have some, which is the worst possible version of this bug because it looks correct. open_existing therefore recovers the section into the sink, and a plain append_files (which says nothing about metadata) preserves it. Asserted in meta_sink_append::tests::metadata_is_searchable_survives_a_reseal_and_absence_is_reported_as_absence.

One more trap, closed

push_subindex accumulates every pushed batch into the random-access lookup. Its guard was != LOOKUP && != TRIE; the metadata sub-index is Arrow IPC and does go through push_subindex, so its key/value rows would have been folded into the lookup as bogus chunk locations. The guard is now !is_reserved_module(..). The test asserts the lookup and the data index still contain exactly the file rows.


Cost — measured, not asserted

Two independent forms of evidence, because a timing bench on a shared box can be contaminated and a structural fact cannot.

Structural (always-on test). The bytes a search reads are the footer, the manifest and the metadata section. a_search_reads_only_the_index_and_its_size_is_independent_of_the_payload builds two archives with identical metadata and payloads 128× apart, and asserts those byte counts are equal: 129 952 B on a 0.6 MB archive and 129 952 B on a 13.7 MB one — 0.95 % of the larger archive. This runs in the ordinary suite, so a regression that made the search touch the payload fails CI.

Timing (--ignored benches). Metadata held constant at 512 keyed entries (1538 rows); only the payload grows. The verify all column is the control — it reads and hashes every chunk, so it is O(payload) by construction, and the bench refuses to call a flat search number evidence unless the control actually moved. (An earlier draft used single-entry extraction as the control; the numbers showed it stays flat too, because znippy's random access is also O(1) in payload. It is now reported but not used as the control.)

oden · 32 cores — non-heavy

entries bytes/entry archive on disk search build-thing hits extract 1 entry CONTROL: verify all
1024 8 192 1.3 MB 224.9 µs 512 264.0 µs 1 003.4 µs
1024 131 072 13.7 MB 223.8 µs 512 368.3 µs 5 110.3 µs
1024 1 048 576 106.4 MB 182.5 µs 512 951.6 µs 35 010.8 µs

payload 82.4× · search 0.81× · control 34.9×

oden · 32 cores — HEAVY

entries bytes/entry archive on disk search build-thing hits extract 1 entry CONTROL: verify all
4096 32 768 14.2 MB 206.1 µs 512 931.9 µs 5 917.7 µs
4096 524 288 204.4 MB 188.9 µs 512 1 185.4 µs 72 712.3 µs
4096 2 097 152 813.3 MB 190.1 µs 512 2 275.2 µs 265 935.5 µs

payload 57.1× · search 0.92× · control 44.9×

Reported figure is the minimum of 25 repeats — the least contaminated estimator on a box with other agents on it. New host row (oden, 32 physical cores, SMT off, 499 GB). Never merge with the loki · 12 cores tables.

Run them:

cargo test -p znippy-tests --release --test meta_search_bench -- --ignored --nocapture
ZNIPPY_BENCH_HEAVY=1 cargo test -p znippy-tests --release --test meta_search_bench \
    -- --ignored --nocapture meta_search_heavy

Not done