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

nordisk/znippy

legacy/backlog.md

⚠ SUPERSEDED (2026-06-09). Frozen history (v0.4–v0.8 cycles). The live backlog is now in /plan.md. Do not add to this file.

Znippy Backlog

Done (v0.4.0)

Done (v0.5.0)

Done (v0.7.0)

Done (v0.8.0) — Iceberg support

Arrow IPC Serialization Bottleneck — Technical Analysis

The numbers

Write strategy mixed_repo (530MB, uncompressed) Bottleneck
Direct NVMe write (write_all()) 2775 MB/s NVMe bandwidth
Arrow IPC Stream (current v0.5) 777 MB/s extend_from_slice

Ratio: 3.6x slower due to Arrow IPC serialization copy.

Root cause

arrow-ipc-57.3.0/src/writer.rs line 2083:

fn write_buffer(arrow_data: &mut Vec<u8>, buffer: &Buffer, ...) {
    arrow_data.extend_from_slice(buffer);  // ← mandatory memcpy
}

Every buffer in every RecordBatch is copied into a contiguous Vec<u8> before being written to disk. No scatter-gather / write_vectored support exists.

What we tried (eliminated, made no difference)

  1. LargeBinaryBuilder::append_value() → replaced with direct Buffer::from(vec) construction
  2. Direct OffsetBuffer + Buffer assembly (avoids builder's internal realloc)
  3. Result: 799 → 777 MB/s (noise). The IPC writer copy dominates.

Why direct NVMe write is 3.6x faster

Direct:  ring_buffer → write_all(data) → NVMe    (1 syscall, 0 userspace copies)
Arrow:   ring_buffer → Vec values → Buffer → extend_from_slice → write() → NVMe
                        copy #1              copy #2 (IPC writer)

Path to fix (upstream)

Decision: we keep Arrow IPC (v0.5)

TODO

Performance

P4: Small-file coalescing — pack multiple small files into one chunk before compression

Problem (observed in benchmark):

Idea: Reader accumulates small files (below a threshold, e.g. file_split_block_size / 4) into a single revolver chunk, recording individual fdata_offset boundaries in the chunk metadata. A single chunk is sent to the compressor for the whole group. Decompressor splits it back using the stored offsets.

Trade-offs:

Relevant commit: e77d9a4 (zero-copy Blob pipeline — shows current per-chunk cost baseline)

P1: mixed_repo compress regression (-68% vs v0.3) ✅ FIXED

Solved by hybrid format (v2.1): raw data section + Arrow IPC Stream for metadata. Write path now does zero userspace copies — chunk bytes go directly to file. mixed_repo recovered from 901 → 2775 MB/s (v0.3 was 2850).

P2: decompress regression for incompressible data (-34%) ✅ FIXED

Solved by hybrid format: reader now seeks directly in raw section (sequential I/O). No Arrow IPC parsing overhead for data bytes. random decompress: 3125 MB/s (was 2110, +48% improvement).

P3: Investigate Arrow IPC compression (body-level)

Arrow IPC supports LZ4 Frame and ZSTD body compression (per-batch). This could reduce file size for the metadata columns without affecting zdata (which is already compressed).

Features

F1: Selective decompression by repo

znippy extract archive.znippy --repo libs-release --out ./libs/ Filter rows by repo column during decompression.

F2: Multi-batch streaming for very large archives

Current writer still buffers up to 64MB before flush. For extreme archives (>100GB), could reduce to smaller batches or adaptive threshold.

F3: Refactor compress_dir to use compress_stream internally
pub fn compress_dir(input: &Path, output: &Path, no_skip: bool) -> Result<CompressionReport> {
    let handle = compress_stream(output, no_skip)?;
    for entry in WalkDir::new(input)... {
        handle.sender().send(ArchiveEntry { relative_path, data: fs::read(path)? })?;
    }
    handle.finish()
}
F4: Plugin metadata on chunk_seq=0

Extension DenseUnion column currently always null. Wire up plugin metadata injection for the first chunk of each file.

F5: Self-contained package-handler modules (znippy as a thin host)

Vision: znippy core knows nothing about any specific package type. A "module" (= package handler, native or WASM) is fully self-contained:

znippy must never hardcode a plugin's parameters. Adding a new package type (maven, cargo, npm, docker, …) requires zero changes to znippy core — you register the module and it brings its full CLI surface with it.

Why: otherwise znippy would have to know every present and future plugin's flags (--plugin-type-id, resolver depth, etc.). That doesn't scale and couples the host to every handler. Self-description inverts it: the module owns its parameters.

Design sketch (extends the ArchiveTypePlugin interface):

Module owns its Arrow schema (currently violated): Today pkg_type + the 5 maven_* columns are hardcoded in ZNIPPY_INDEX_SCHEMA (znippy-common/src/index.rs) and filled by maven-specific code in build_metadata_batch. That's the host-knows-about-maven coupling to remove. Target:

Archive model: one .znippy is a multi-type container. A single archive holds multiple package types, each with multiple repos: { maven: [repo1, repo2], cargo: [repo1, repo2, repo3], pip: [...] }.

create/decompress are FORMAT-level (shared), not per-module — important. The container format (blobs + Arrow IPC index + footer) is universal, so modules must NOT each reimplement the compressor/decompressor. A module plugs into:

CLI surface:

Open questions:

F6: Spatial archive — znippy as a geo-indexed coordinate store

Motivation: katana-osm needs to store ~6.8 billion node coordinates (id, lat, lon) and look them up by ID during way geometry resolution. Current approach is a 100+ GB flat file sorted by node ID, with Ragnar's STree for tree-routed mmap access. Problem: nodes referenced by the same way are spatially close but may be far apart in ID-sorted order → random 4K page faults across the entire file.

Idea: Use znippy as a spatial archive where each "file" is a geographic tile (S2 cell, geohash bucket, or Hilbert curve segment) stored as a compressed Arrow IPC chunk (columns: id, lat, lon). Ragnar's STree indexes node_id → tile, then znippy's per-file random decompression gives you just that tile's data.

spatial_nodes.znippy:
  ├── tile_0x3a1f.arrow   (compressed Arrow IPC — ~1M nodes)
  ├── tile_0x3a20.arrow
  ├── ...  (~4096 tiles for planet)
  └── [znippy Arrow IPC index with STree overlay]

Why this wins:

Two-level index:

  1. STree (Ragnar's tree): node_id → tile_index (in-RAM, ~2 GB for planet)
  2. Within tile: binary search or secondary STree on the id column

Simpler alternative — Hilbert-sorted flat file: Sort nodes by Hilbert curve of (lat, lon) instead of by ID. Then nodes that are spatially close ARE file-adjacent. Same flat file + STree architecture, but the mmap access pattern becomes nearly sequential for way lookups. No znippy compression, but massively better page-cache locality. Could be a stepping stone.

Open questions:

F7: Transfer/resurrect VTD into znippy-zoomies as generic parallel XML parser

The VTD scanner (xml_vtd in katana-osm) finds XML element boundaries fast using memchr. Combined with gatling's worker pool, it gives parallel XML parsing with zero allocation for the index itself (streaming callback pattern). This is useful as a generic parallel XML parser module — not OSM-specific. The element-boundary finding + safe-split logic (find_safe_slot_end) is reusable for any large XML.

Transfer the core scanning primitives (build_elem_index_slice, find_safe_slot_end, attribute parsing) into znippy-zoomies as a vtd module. The OSM-specific record building stays in katana-osm; the generic XML scanner moves to znippy-zoomies alongside gatling (which already orchestrates the parallel pipeline around it).

Performance Baseline (v0.5.0 Arrow IPC, 8-core T14s laptop, release)

Test Compress MB/s Decompress MB/s Ratio
text_500mb 1707 3049 4471x
binary_pattern_500mb 2370 2809 2354x
random_500mb 152 1312 1.0x
100k_small_files_10kb 3150 751 67x
mixed_repo_530mb 777 1241 1.0x
single_file_2gb 3442 3089 4673x

Note: mixed_repo limited by Arrow IPC serialization copy (apache/arrow-rs#9835). Direct NVMe write achieves 2775 MB/s for same workload.

Performance Baseline (v0.6.0, zero-copy Blob pipeline, 8-core T14s laptop, release)

Test In MB Out MB Ratio Comp MB/s Dec MB/s Comp ms Dec ms Chunks Skipped
text_500mb 500 0.12 4092x 2415 2128 207 235 500 0
binary_pattern_500mb 500 0.22 2245x 1880 2304 266 217 500 0
random_500mb 500 500 1.00x 33 2283 15322 219 500 0
100k_small_files_10kb 977 17 56x 1578 627 619 1557 100000 0
mixed_repo_530mb 530 530 1.00x 2325 2227 228 238 55 4
single_file_2gb 2048 0.49 4141x 2338 2698 876 759 2048 0
rust_crates (real, 215 MB) 215 215 1.00x 1209 2562 178 84 1219 1218
rust_deps_real (real, 988 MB) 988 137 7.21x 18 1642 54686 602 41501 103

Key changes vs v0.5.0: