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

nordisk/znippy

.nornir/random-access.md

Random-access file lookup

How znippy answers "give me the bytes of this path" — the question Holger asks when it wants one artifact out of an archive of millions. There are now three ways to get a row, each a different trade between open cost, memory, and who is asking (the znippy reader, or an outside SQL engine).

What's on disk

Every archive ends with the v0.7 multi-index footer:

[ blob_0 … blob_N ]
[ data sub-index_0 ] … [ data sub-index_M ]   ← one Arrow IPC stream per (pkg_type, repo)
[ lookup sub-index ]                           ← NEW: base columns, sorted by (relative_path, chunk_seq)
[ trie blob ]                                  ← NEW: fst::Map<relative_path → first lookup row>
[ manifest Arrow IPC ]                         ← one row per section: pkg_type, repo, module_name, offset, len, row_count
[ 8-byte MAGIC "ZNPYMIDX" ][ 8-byte LE u64 manifest_offset ]

The base index columns (the same in every archive) are:

relative_path · chunk_seq · fdata_offset · compressed
uncompressed_size · blob_offset · blob_size · checksum(32-byte blake3)

A file larger than file_split_block_size (10 MB) spans several rows — one per chunk — sharing a relative_path and ordered by chunk_seq. To return a file you must gather all of its chunk rows, decompress each, and place it at its fdata_offset.

The lookup sub-index and trie are reserved manifest entries

Both new sections are registered in the ordinary manifest, but with a reserved discriminant so no existing reader trips over them:

read_znippy_manifest filters these out and the index-merge loop skips them, so list, decompress, the iceberg sink, and the manifest-count tests see exactly the data sub-indexes they always did. The 16-byte footer is unchanged — archives written before this layer simply lack the two entries and still open.

The lookup sub-index is not new data: it is the base columns of the main index re-sorted by (relative_path, chunk_seq) and stripped of plugin columns. Sorting buys two things at once — a file's chunks become contiguous, and paths land in byte-lexicographic order (which is exactly what fst requires to build). The main index keeps its original chunk_seq write order, so Law 3 (deterministic checksums bound to chunk_seq) is untouched — the lookup is a second, sorted copy of the locating columns, never a reordering of the source.

The trie is an fst::Map from relative_path to the index of that file's first row in the (sorted) lookup. Exact lookup is O(key length); because the keys are stored sorted, prefix/range scans (com/google/…) fall out for free.

The three ways to get a row

1. ZnippyArchive::open + extract_file — eager full-index map

znippy-common/src/archive.rs. open() reads the whole index and builds a HashMap<String, FileEntry> up front. After that, every extract_file / contains / file_size is O(1).

2. get_file / znippy get — lean, no full-index load

znippy-common/src/decompress.rs::get_file → index.rs::locate_file. This never materializes the whole index. It resolves one path through a three-tier fallback, fastest first:

  1. trie (fst::Map) → first lookup row — O(key length).
  2. binary search of the sorted lookup's relative_path column — O(log n) (used when the trie section is absent).
  3. linear scan of the merged main index — O(n) (only for archives written before the lookup layer existed — full backward compatibility).

Having found one matching row, it expands to the contiguous run sharing that path (the sort guarantees they're adjacent), then for each chunk: pread the blob, decompress_into (or take the raw bytes on the skip path), blake3-verify against the per-chunk checksum, and copy into the output at fdata_offset.

znippy get -i archive.znippy -p com/google/guava/guava/33.2.0/guava-33.2.0.jar -o guava.jar
znippy get -i archive.znippy -p pom.xml          # no -o ⇒ bytes to stdout

3. External SQL over the lookup sub-index — no znippy at all

The lookup sub-index is a valid Arrow IPC stream sorted by relative_path. Slice it out by the manifest's index_offset / index_len and hand the bytes to DuckDB / Polars / DataFusion:

SELECT blob_offset, blob_size, uncompressed_size, compressed, chunk_seq, checksum
FROM   '<lookup-slice>.arrow'
WHERE  relative_path = 'com/google/guava/guava/33.2.0/guava-33.2.0.jar'
ORDER  BY chunk_seq;

Browsing metadata, not bytes — get_all_files_meta

The three ways above each return bytes. Holger (and other consumers) often want the opposite: the list of artifacts and their sizes, without reading any file data — to render a tree, total a subtree, or decide what to fetch next. Two library calls cover that, both reading only the lookup sub-index:

// Every file, one slim row each, sorted by relative_path.
let all: Vec<ArtifactMeta> = znippy_common::get_all_files_meta(&archive)?;

// …or just a subtree — "maybe you don't want all files".
let sub = znippy_common::get_files_meta_with_prefix(&archive, "com/google/guava/")?;
pub struct ArtifactMeta {
    pub relative_path: String,
    pub uncompressed_size: u64, // summed across the file's chunks
    pub chunk_count: u32,
    pub compressed: bool,       // false on the stored-raw skip path
}

The prefix form is where the trie earns its keep a second time: an fst::Map is an ordered set, so map.range().ge(prefix) seeks straight to the first key ≥ the prefix in O(prefix length), and matching files — being sorted — form one contiguous run that we walk until a key stops matching. No full scan, no bytes read. The fallbacks mirror get_file: no trie ⇒ binary-search the sorted lookup for the prefix's lower bound; no lookup layer ⇒ aggregate the legacy main index into per-file rows. Each file's chunks are aggregated into one ArtifactMeta (sizes summed, compressed OR-ed), so the result is a clean per-artifact listing rather than per-chunk rows.

This is the cheap browse primitive behind "show me what's under this group/path" — the same access shape as way 3 (external SQL SELECT … WHERE relative_path LIKE 'prefix%'), but in-process and without an analytics engine.

Why both a sorted table and a trie?

They serve different readers and that's the point of carrying both. The sorted Arrow table is what an outside engine can query (DuckDB can't read an fst blob); the trie is what the native reader uses to skip the binary search entirely and to answer prefix queries. Same rows, two access shapes, built once in meta_sink.rs::write_lookup_and_trie during finalize.