Skip to main content

znippy_common/
meta_sink_append.rs

1//! `ArrowIpcSinkAppend` — the **append/resume-capable v2 clone** of
2//! [`ArrowIpcSink`](crate::ArrowIpcSink).
3//!
4//! ## Why a clone and not a refactor
5//! The original [`ArrowIpcSink`] is the A baseline of the arrow-ipc write/seal
6//! A/B audit — its hot path (`push_subindex` + `finish`) must stay byte- and
7//! perf-identical. So this is a *near-identical copy* that adds the two new
8//! capabilities **without touching the original**:
9//!
10//!  * **fresh path** — [`ArrowIpcSinkAppend::new`] + `push_subindex` + `finish`
11//!    is a line-for-line clone of `ArrowIpcSink`. It writes the **same v0.7
12//!    on-disk bytes** (same sub-index serialisation, same sorted lookup, same
13//!    fst trie, same manifest, same `ZNPYMIDX` footer). The A/B parity test
14//!    proves this clone is a zero-cost superset on the normal write path.
15//!
16//!  * **resume/append path** — [`ArrowIpcSinkAppend::open_existing`] reopens an
17//!    already-sealed `.znippy`, recovers its existing data rows, repositions the
18//!    cursor at the **end of the blob region** (truncating the old metadata
19//!    tail), and lets the caller `push_subindex` more blobs' rows. `finish()`
20//!    then re-seals the merged old+new row set — the **first-class native blob
21//!    append** that the iceberg lifecycle test (#23) previously had to do by
22//!    hand.
23//!
24//! ### The resume mechanism, in bytes
25//! A sealed v0.7 archive is:
26//! ```text
27//! [ blob_0 … blob_N ][ data sub-idx(es) ][ lookup sub-idx ][ trie ][ manifest ][ ZNPYMIDX ][off]
28//! ^0                 ^blob_end           (the whole metadata tail is rebuildable)
29//! ```
30//! To append we must:
31//!   1. read the full manifest (incl. reserved lookup/trie entries),
32//!   2. find `blob_end` = the lowest `index_offset` over all sub-index entries
33//!      (where the blob region stops and the rebuildable tail begins),
34//!   3. recover the existing **data** rows from the sorted lookup sub-index (one
35//!      cheap read of the already-sorted reserved section — no per-sub-index
36//!      re-scan), seeding the lookup accumulator,
37//!   4. set the cursor to `blob_end` so the caller's new blob bytes + new data
38//!      sub-index overwrite the old (now-stale) metadata tail,
39//!   5. on `finish()`, re-sort the merged rows and re-emit lookup + trie +
40//!      manifest + footer.
41//!
42//! The new blob bytes are written by the caller (the compress pipeline / a
43//! library append entrypoint such as [`append_files`]) to the file at
44//! `blob_end()` exactly as a fresh archive
45//! writes blobs at offset 0; this sink owns only the metadata tail, identical to
46//! the original's contract.
47
48use std::fs::File;
49use std::os::unix::fs::FileExt;
50use std::path::Path;
51use std::sync::Arc;
52
53use anyhow::{Result, anyhow};
54use arrow::array::{
55    BooleanArray, BooleanBuilder, FixedSizeBinaryArray, FixedSizeBinaryBuilder, StringArray,
56    StringBuilder, UInt32Array, UInt32Builder, UInt64Array, UInt64Builder,
57};
58use arrow::datatypes::Schema;
59use arrow::ipc::writer::StreamWriter;
60use arrow::record_batch::RecordBatch;
61
62use crate::index::{
63    ChunkLoc, LOOKUP_MODULE, MULTI_INDEX_MAGIC, ManifestEntry, RESERVED_PKG_TYPE, TRIE_MODULE,
64    data_subindex_schema, is_reserved_module, lookup_schema, read_znippy_full_manifest,
65    write_manifest_bytes,
66};
67use crate::index::{
68    CARRIED_RESERVED_MODULES, META_MODULE, ZNIPPY_DELTA_MODULE, read_reserved_section_bytes,
69};
70use crate::meta_index::{
71    MetaTable, build_meta_batch, decode_meta_section, meta_schema,
72};
73use crate::meta_sink::{ArchiveMetaSink, GroupKey};
74
75/// Append/resume-capable v2 clone of [`ArrowIpcSink`](crate::ArrowIpcSink).
76///
77/// Field-for-field identical to the original; the only added surface is the
78/// [`open_existing`](Self::open_existing) constructor and the [`blob_end`](Self::blob_end)
79/// accessor. The fresh-write path is byte-identical to the original.
80pub struct ArrowIpcSinkAppend {
81    file: Arc<File>,
82    cursor: u64,
83    entries: Vec<ManifestEntry>,
84    lookup_paths: Vec<String>,
85    lookup_locs: Vec<ChunkLoc>,
86    /// On resume, the recovered pre-existing data rows. They are re-emitted as a
87    /// data sub-index in `finish()` (so the ordinary reader, which reads data
88    /// sub-indexes, still sees them) AND merged into the rebuilt lookup. Empty
89    /// for a fresh sink. Kept separate from `lookup_*` so they aren't
90    /// double-counted before the re-emit.
91    carried: Vec<(String, ChunkLoc)>,
92    /// Searchable metadata to seal as the `META_MODULE` sub-index.
93    ///
94    /// `None` means "emit no section", which is what a reader later reports as
95    /// `ArchiveMeta::NoMetadata`; `Some(empty)` means "emit a present, empty
96    /// index". The two are different archives on disk and different answers on
97    /// read, and this field is the only place that decision is made.
98    ///
99    /// On resume this is **seeded from the archive's existing section**, because
100    /// `open_existing` truncates the whole metadata tail — without carrying it,
101    /// every append would silently erase the metadata the archive already had.
102    meta: Option<MetaTable>,
103    /// Reserved sections carried forward verbatim across an append, as
104    /// `(module_name, raw bytes)`.
105    ///
106    /// The same failure `meta` above fixes, for the reserved sections that are
107    /// **independent logs** rather than derivations — see
108    /// [`CARRIED_RESERVED_MODULES`]. MEASURED 2026-08-04: an object-carrying
109    /// push dropped `__gunnar_refs__` (144 928 -> 146 396 bytes, section gone),
110    /// because this sink re-supplies no reserved section it was not handed and
111    /// nothing hands it one.
112    ///
113    /// Derived sections are deliberately NOT carried: when the objects change,
114    /// `__gunnar_graph__` / `__gunnar_reach__` / `__gunnar_oid__` are wrong, and
115    /// carrying a stale one forward is worse than dropping it.
116    carried_reserved: Vec<(String, Vec<u8>)>,
117    /// The delta map, as rows: `(relative_path, chunk_seq, base_path)`.
118    ///
119    /// Decoded rather than carried raw, because an append may ADD rows to it and
120    /// the manifest cannot hold two sections under one module name. Same reason
121    /// `meta` travels as a `MetaTable`.
122    delta_map: Vec<(String, u32, String)>,
123}
124
125impl ArrowIpcSinkAppend {
126    /// Fresh archive: identical to [`ArrowIpcSink::new`](crate::ArrowIpcSink::new).
127    /// `blob_end_offset` is the byte offset just past the last blob.
128    pub fn new(file: Arc<File>, blob_end_offset: u64) -> Self {
129        Self {
130            file,
131            cursor: blob_end_offset,
132            entries: Vec::new(),
133            lookup_paths: Vec::new(),
134            lookup_locs: Vec::new(),
135            carried: Vec::new(),
136            meta: None,
137            carried_reserved: Vec::new(),
138            delta_map: Vec::new(),
139        }
140    }
141
142    /// Seal `meta` as this archive's searchable metadata sub-index, replacing
143    /// anything carried from a resumed archive.
144    pub fn with_meta(mut self, meta: MetaTable) -> Self {
145        self.meta = Some(meta);
146        self
147    }
148
149    /// Add rows to the metadata to be sealed, keeping whatever a resume carried.
150    /// Creates the section if the archive had none.
151    pub fn merge_meta(&mut self, rows: impl IntoIterator<Item = crate::meta_index::MetaEntry>) {
152        self.meta.get_or_insert_with(MetaTable::new).extend(rows);
153    }
154
155    /// The metadata this sink will seal — `None` when it will emit no section.
156    pub fn meta(&self) -> Option<&MetaTable> {
157        self.meta.as_ref()
158    }
159
160    /// Reopen an **already-sealed** v0.7 `.znippy` for append/resume.
161    ///
162    /// Recovers the existing data rows from the sorted lookup sub-index, drops
163    /// the (rebuildable) metadata tail by positioning the cursor at the end of
164    /// the blob region, and returns a sink ready to accept more `push_subindex`
165    /// calls. The caller appends its new blob bytes to the same file starting at
166    /// [`blob_end`](Self::blob_end) before pushing the matching index rows.
167    ///
168    /// The file is opened read+write; nothing is mutated until `push_subindex` /
169    /// `finish` overwrite the old tail.
170    pub fn open_existing(path: &Path) -> Result<Self> {
171        let file = Arc::new(
172            std::fs::OpenOptions::new()
173                .read(true)
174                .write(true)
175                .open(path)
176                .map_err(|e| anyhow!("append: open {} for resume: {e}", path.display()))?,
177        );
178
179        // 1. Full manifest (incl. reserved lookup/trie entries).
180        let (entries, _manifest_offset) = read_znippy_full_manifest(path)?;
181        if entries.is_empty() {
182            return Err(anyhow!("append: archive {} has an empty manifest", path.display()));
183        }
184
185        // 2. blob_end = lowest index_offset over all sub-index/reserved sections.
186        //    Everything from there to EOF is the rebuildable metadata tail.
187        let blob_end = entries
188            .iter()
189            .map(|e| e.index_offset)
190            .min()
191            .ok_or_else(|| anyhow!("append: no sections in manifest"))?;
192
193        // 3. Recover existing DATA rows from the sorted lookup sub-index (one
194        //    read of the already-sorted reserved section). Fall back to scanning
195        //    the data sub-indexes if (unexpectedly) no lookup section is present.
196        let (paths, locs) = recover_rows(path, &entries)?;
197        let carried: Vec<(String, ChunkLoc)> = paths.into_iter().zip(locs).collect();
198
199        // 4. Carry the searchable metadata section forward. The tail we are about
200        //    to overwrite contains it, so a resume that did not recover it would
201        //    quietly turn an archive WITH metadata into one without — and the
202        //    reader would then honestly report `NoMetadata` about an archive that
203        //    used to have some. Absent stays absent; present-but-empty stays
204        //    present-but-empty.
205        let meta = match read_reserved_section_bytes(path, META_MODULE)? {
206            None => None,
207            Some(bytes) => Some(decode_meta_section(&bytes)?.to_table()),
208        };
209
210        // 5. Carry the INDEPENDENT reserved sections forward verbatim. The tail
211        //    about to be overwritten holds them, and nothing else in this process
212        //    can reproduce them: they are per-push logs, not derivations of the
213        //    blobs. Derived sections are left to be rebuilt.
214        let mut carried_reserved = Vec::new();
215        for module in CARRIED_RESERVED_MODULES {
216            if let Some(bytes) = read_reserved_section_bytes(path, module)? {
217                carried_reserved.push(((*module).to_string(), bytes));
218            }
219        }
220
221        // 6. The delta map, decoded. Dropping it would turn every delta chunk in
222        //    the archive back into a stored chunk on the next append, and the
223        //    reader would then hand a delta's instruction stream to a caller as
224        //    file content.
225        let delta_map = read_delta_map(path)?;
226
227        Ok(Self {
228            file,
229            cursor: blob_end,
230            entries: Vec::new(), // rebuilt fresh by push_subindex + finish
231            lookup_paths: Vec::new(),
232            lookup_locs: Vec::new(),
233            carried,
234            meta,
235            carried_reserved,
236            delta_map,
237        })
238    }
239
240    /// Byte offset where the blob region ends in a resumed archive — where the
241    /// caller writes its newly-appended blob bytes (and where the first new data
242    /// sub-index will be placed). For a fresh sink this is the `blob_end_offset`
243    /// passed to [`new`](Self::new) until the first `push_subindex`.
244    pub fn blob_end(&self) -> u64 {
245        self.cursor
246    }
247
248    /// Number of pre-existing data rows recovered on resume (0 for a fresh sink).
249    pub fn recovered_rows(&self) -> usize {
250        self.carried.len()
251    }
252
253    /// Drop every carried (pre-existing) row whose `relative_path` is about to be
254    /// re-written by this append, giving last-writer-wins **replace** semantics.
255    /// Returns the number of rows dropped.
256    ///
257    /// Without this, appending a path the archive already contains left TWO row
258    /// sets for it in the re-sealed index — the stale one and the new one — and
259    /// nothing downstream treated that as an error: the reader that concatenates
260    /// chunks returned both copies back to back at twice the real length, and the
261    /// reader that places chunks at `fdata_offset` wrote both to offset 0, so the
262    /// carried STALE copy (re-emitted last, in `finish`) won and `znippy get`
263    /// silently handed back the old file. Every chunk's blake3 is individually
264    /// correct in both cases, so even verified reads passed.
265    fn drop_carried_paths(&mut self, replacing: &std::collections::HashSet<&str>) -> usize {
266        if self.carried.is_empty() || replacing.is_empty() {
267            return 0;
268        }
269        let before = self.carried.len();
270        self.carried.retain(|(p, _)| !replacing.contains(p.as_str()));
271        before - self.carried.len()
272    }
273
274    /// Re-emit the carried (recovered) rows as a single data sub-index so the
275    /// ordinary reader — which reads data sub-indexes, not the lookup — still
276    /// lists them after the re-seal. `push_subindex` also folds them into the
277    /// rebuilt lookup accumulator. No-op for a fresh sink.
278    fn emit_carried(&mut self) -> Result<()> {
279        if self.carried.is_empty() {
280            return Ok(());
281        }
282        let carried = std::mem::take(&mut self.carried);
283        let (paths, locs): (Vec<String>, Vec<ChunkLoc>) = carried.into_iter().unzip();
284        let batch = base_batch_from_rows(&paths, &locs)?;
285        self.push_subindex(data_subindex_schema().as_ref(), &[batch], GroupKey {
286            pkg_type: 0,
287            repo: String::new(),
288            module_name: String::new(),
289        })
290    }
291
292    // ── below: a line-for-line clone of ArrowIpcSink's private machinery ──
293
294    fn accumulate_lookup(&mut self, batch: &RecordBatch) {
295        let cols = (|| {
296            Some((
297                batch.column_by_name("relative_path")?.as_any().downcast_ref::<StringArray>()?,
298                batch.column_by_name("chunk_seq")?.as_any().downcast_ref::<UInt32Array>()?,
299                batch.column_by_name("fdata_offset")?.as_any().downcast_ref::<UInt64Array>()?,
300                batch.column_by_name("compressed")?.as_any().downcast_ref::<BooleanArray>()?,
301                batch.column_by_name("uncompressed_size")?.as_any().downcast_ref::<UInt64Array>()?,
302                batch.column_by_name("blob_offset")?.as_any().downcast_ref::<UInt64Array>()?,
303                batch.column_by_name("blob_size")?.as_any().downcast_ref::<UInt64Array>()?,
304                batch.column_by_name("checksum")?.as_any().downcast_ref::<FixedSizeBinaryArray>()?,
305            ))
306        })();
307        let Some((paths, chunk_seq, fdata, compressed, usz, blob_off, blob_sz, checksum)) = cols
308        else { return; };
309        for i in 0..batch.num_rows() {
310            let mut ck = [0u8; 32];
311            ck.copy_from_slice(checksum.value(i));
312            self.lookup_paths.push(paths.value(i).to_string());
313            self.lookup_locs.push(ChunkLoc {
314                chunk_seq: chunk_seq.value(i),
315                fdata_offset: fdata.value(i),
316                blob_offset: blob_off.value(i),
317                blob_size: blob_sz.value(i),
318                uncompressed_size: usz.value(i),
319                compressed: compressed.value(i),
320                checksum: ck,
321            });
322        }
323    }
324
325    fn write_lookup_and_trie(&mut self) -> Result<()> {
326        let n = self.lookup_paths.len();
327        let mut order: Vec<usize> = (0..n).collect();
328        order.sort_by(|&a, &b| {
329            self.lookup_paths[a].cmp(&self.lookup_paths[b])
330                .then(self.lookup_locs[a].chunk_seq.cmp(&self.lookup_locs[b].chunk_seq))
331        });
332
333        let schema = lookup_schema();
334        let batch = base_batch_permuted(
335            schema.clone(),
336            &self.lookup_paths,
337            &self.lookup_locs,
338            &order,
339        )?;
340        self.push_subindex(&schema, &[batch], GroupKey {
341            pkg_type: RESERVED_PKG_TYPE,
342            repo: String::new(),
343            module_name: LOOKUP_MODULE.to_string(),
344        })?;
345
346        let mut builder = fst::MapBuilder::memory();
347        let mut prev: Option<&str> = None;
348        for (sorted_idx, &orig) in order.iter().enumerate() {
349            let p = self.lookup_paths[orig].as_str();
350            if prev != Some(p) {
351                builder.insert(p.as_bytes(), sorted_idx as u64)
352                    .map_err(|e| anyhow!("trie insert: {e}"))?;
353                prev = Some(p);
354            }
355        }
356        let trie_bytes = builder.into_inner().map_err(|e| anyhow!("trie finish: {e}"))?;
357        self.write_raw_section(&trie_bytes, GroupKey {
358            pkg_type: RESERVED_PKG_TYPE,
359            repo: String::new(),
360            module_name: TRIE_MODULE.to_string(),
361        })
362    }
363
364    /// Emit the searchable metadata sub-index, when there is one to emit.
365    ///
366    /// Reserved module, so the data readers skip it and an older znippy simply
367    /// ignores the entry. `None` writes NOTHING — that absence is exactly what
368    /// `ArchiveMeta::NoMetadata` reports, and it is why an archive sealed without
369    /// metadata stays byte-identical to one sealed before this module existed.
370    fn write_meta_subindex(&mut self) -> Result<()> {
371        let Some(table) = self.meta.take() else {
372            return Ok(());
373        };
374        let batch = build_meta_batch(&table)?;
375        let schema = meta_schema();
376        self.push_subindex(schema.as_ref(), &[batch], GroupKey {
377            pkg_type: RESERVED_PKG_TYPE,
378            repo: String::new(),
379            module_name: META_MODULE.to_string(),
380        })
381    }
382
383    /// Re-emit the reserved sections a resume carried, byte for byte.
384    ///
385    /// After `write_meta_subindex`, so a `__meta__` section this sink was handed
386    /// wins over anything else; the carried list never contains `__meta__`,
387    /// which travels as a decoded `MetaTable` instead.
388    fn write_carried_reserved(&mut self) -> Result<()> {
389        for (module, bytes) in std::mem::take(&mut self.carried_reserved) {
390            self.write_raw_section(&bytes, GroupKey {
391                pkg_type: RESERVED_PKG_TYPE,
392                repo: String::new(),
393                module_name: module,
394            })?;
395        }
396        Ok(())
397    }
398
399    /// Seal the delta map, if there is one.
400    fn write_delta_map(&mut self) -> Result<()> {
401        if self.delta_map.is_empty() {
402            return Ok(());
403        }
404        let rows = std::mem::take(&mut self.delta_map);
405        let paths = StringArray::from(rows.iter().map(|r| r.0.as_str()).collect::<Vec<_>>());
406        let seqs = UInt32Array::from(rows.iter().map(|r| r.1).collect::<Vec<_>>());
407        let bases = StringArray::from(rows.iter().map(|r| r.2.as_str()).collect::<Vec<_>>());
408        let schema = crate::index::delta_map_schema();
409        let batch = RecordBatch::try_new(
410            Arc::clone(&schema),
411            vec![Arc::new(paths), Arc::new(seqs), Arc::new(bases)],
412        )
413        .map_err(|e| anyhow!("delta map batch: {e}"))?;
414        self.push_subindex(schema.as_ref(), &[batch], GroupKey {
415            pkg_type: RESERVED_PKG_TYPE,
416            repo: String::new(),
417            module_name: ZNIPPY_DELTA_MODULE.to_string(),
418        })
419    }
420
421    /// The archive file, for a caller appending blob bytes at [`blob_end`](Self::blob_end).
422    pub fn file(&self) -> &Arc<File> {
423        &self.file
424    }
425
426    /// Move the blob cursor on after a caller wrote `n` bytes at `blob_end`.
427    pub fn advance_blob_end(&mut self, n: u64) {
428        self.cursor += n;
429    }
430
431    /// Replace every carried row of `path` with `locs`.
432    ///
433    /// Used to turn a stored entry into a delta entry in place: its old chunk
434    /// rows go, one delta row arrives. Rows of other paths are untouched.
435    pub fn replace_carried(&mut self, path: &str, locs: Vec<ChunkLoc>) {
436        self.carried.retain(|(p, _)| p != path);
437        for loc in locs {
438            self.carried.push((path.to_string(), loc));
439        }
440    }
441
442    /// Record that `(path, chunk_seq)` is a delta against `base`.
443    pub fn push_delta_map_row(&mut self, path: String, chunk_seq: u32, base: String) {
444        self.delta_map.retain(|(p, s, _)| !(p == &path && *s == chunk_seq));
445        self.delta_map.push((path, chunk_seq, base));
446    }
447
448    fn write_raw_section(&mut self, bytes: &[u8], key: GroupKey) -> Result<()> {
449        let start = self.cursor;
450        self.file.write_all_at(bytes, start)?;
451        self.cursor += bytes.len() as u64;
452        self.entries.push(ManifestEntry {
453            pkg_type: key.pkg_type,
454            repo: key.repo,
455            module_name: key.module_name,
456            index_offset: start,
457            index_len: bytes.len() as u64,
458            row_count: 0,
459        });
460        Ok(())
461    }
462}
463
464impl ArchiveMetaSink for ArrowIpcSinkAppend {
465    fn push_subindex(
466        &mut self,
467        schema: &Schema,
468        batches: &[RecordBatch],
469        key: GroupKey,
470    ) -> Result<()> {
471        let sub_start = self.cursor;
472        let mut sub_bytes: Vec<u8> = Vec::new();
473        let mut sw = StreamWriter::try_new(&mut sub_bytes, schema)
474            .map_err(|e| anyhow!("sub-index writer: {e}"))?;
475        let mut row_count = 0u64;
476        for batch in batches {
477            row_count += batch.num_rows() as u64;
478            sw.write(batch).map_err(|e| anyhow!("sub-index write: {e}"))?;
479        }
480        sw.finish().map_err(|e| anyhow!("sub-index finish: {e}"))?;
481
482        // Accumulate base columns for the lookup layer from DATA sub-indexes only.
483        // Widened from "not lookup, not trie" to "not reserved" when META_MODULE
484        // arrived: the metadata sub-index is Arrow IPC and does come through here,
485        // and its rows are key/value facts, not chunk locations — folding them
486        // into the lookup would corrupt random access. Behaviour for every
487        // pre-existing module is unchanged (sign sections are raw, never pushed).
488        if !is_reserved_module(&key.module_name) {
489            for batch in batches {
490                self.accumulate_lookup(batch);
491            }
492        }
493
494        let sub_len = sub_bytes.len() as u64;
495        self.file.write_all_at(&sub_bytes, sub_start)?;
496        self.cursor += sub_len;
497
498        self.entries.push(ManifestEntry {
499            pkg_type: key.pkg_type,
500            repo: key.repo,
501            module_name: key.module_name,
502            index_offset: sub_start,
503            index_len: sub_len,
504            row_count,
505        });
506        Ok(())
507    }
508
509    fn finish(mut self: Box<Self>) -> Result<u64> {
510        // Resume: re-emit recovered rows as a data sub-index (the ordinary reader
511        // reads data sub-indexes, not the lookup). No-op on the fresh path.
512        self.emit_carried()?;
513        self.write_lookup_and_trie()?;
514        self.write_meta_subindex()?;
515        self.write_carried_reserved()?;
516        self.write_delta_map()?;
517
518        let manifest_offset = self.cursor;
519        let manifest_bytes =
520            write_manifest_bytes(&self.entries).map_err(|e| anyhow!("manifest: {e}"))?;
521        self.file.write_all_at(&manifest_bytes, manifest_offset)?;
522
523        let after = manifest_offset + manifest_bytes.len() as u64;
524        self.file.write_all_at(&MULTI_INDEX_MAGIC, after)?;
525        self.file.write_all_at(
526            &manifest_offset.to_le_bytes(),
527            after + MULTI_INDEX_MAGIC.len() as u64,
528        )?;
529        // Resume overwrites the old (longer-or-shorter) tail in place; if the new
530        // tail is shorter than the old one, truncate so no stale footer lingers.
531        let final_len = after + MULTI_INDEX_MAGIC.len() as u64 + 8;
532        self.file.set_len(final_len)?;
533        self.file.sync_all()?;
534
535        Ok(final_len)
536    }
537}
538
539
540/// Read the delta map out of an archive, as rows. Empty when the section is
541/// absent, which is every archive that holds no delta chunk.
542///
543/// **Public because the chain is the caller's to bound.**
544/// [`supersede_as_delta`] takes `chain_depth` — how many links already sit
545/// behind the base — and refuses past [`MAX_GENERATION_CHAIN`]. A caller that
546/// supersedes one generation at a time (which is what a repository `gc` does)
547/// cannot supply that number from what it did in this process: the chain was
548/// built by earlier runs and the archive is the only record of it. Without this
549/// the caller must keep a sidecar counter, and a counter that drifts low walks
550/// the chain past the cap the writer exists to enforce.
551pub fn read_delta_map(path: &Path) -> Result<Vec<(String, u32, String)>> {
552    use arrow::ipc::reader::StreamReader;
553    let Some(bytes) = read_reserved_section_bytes(path, ZNIPPY_DELTA_MODULE)? else {
554        return Ok(Vec::new());
555    };
556    let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
557        .map_err(|e| anyhow!("delta map: {e}"))?;
558    let mut out = Vec::new();
559    for batch in reader {
560        let batch = batch.map_err(|e| anyhow!("delta map batch: {e}"))?;
561        let paths = batch
562            .column_by_name("relative_path")
563            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
564            .ok_or_else(|| anyhow!("delta map: missing relative_path"))?;
565        let seqs = batch
566            .column_by_name("chunk_seq")
567            .and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
568            .ok_or_else(|| anyhow!("delta map: missing chunk_seq"))?;
569        let bases = batch
570            .column_by_name("base_path")
571            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
572            .ok_or_else(|| anyhow!("delta map: missing base_path"))?;
573        for r in 0..batch.num_rows() {
574            out.push((paths.value(r).to_string(), seqs.value(r), bases.value(r).to_string()));
575        }
576    }
577    Ok(out)
578}
579
580
581/// **The writer: re-store a superseded entry as a delta against a live one.**
582///
583/// Given an archive holding generation N and generation N+1, this replaces N's
584/// stored bytes with a delta against N+1 and records the reference in
585/// [`ZNIPPY_DELTA_MODULE`]. N's old blob bytes become dead space in the file;
586/// they are reclaimed by a rewrite, not by this call.
587///
588/// # Why the OLD one is delta'd against the NEW one, and never the other way
589///
590/// The obvious direction is forward — store N+1 as a delta against N — and it is
591/// wrong for gunnar, for a measured reason that is not znippy's. The current
592/// generation is what `P-001`'s `write_pack_copy` copies entries out of on every
593/// clone and fetch; putting it behind a delta chain would move it onto the
594/// entry-copy path, measured at 18x the bytes and 221x the CPU of the path that
595/// avoids it.
596///
597/// Delta-ing backwards keeps the property that matters: **the live generation is
598/// always a whole entry, at chain depth zero**, whatever the archive's history.
599/// The k-th-oldest generation sits at depth k, and depth there is free because
600/// nothing on the serve path reads a superseded generation — the live pack holds
601/// every reachable object.
602///
603/// # The chain bound
604///
605/// A repository `gc`'d weekly reaches [`MAX_RECONSTRUCT_DEPTH`] in fifteen
606/// months, and the reader would then refuse the oldest generation rather than
607/// serve it slowly. So the writer bounds itself first: past
608/// [`MAX_GENERATION_CHAIN`] it declines and returns
609/// [`SupersedeOutcome::ChainTooLong`], and the caller keeps that generation
610/// whole. A whole generation is a keyframe: the chain restarts from it and every
611/// generation is reachable in at most `MAX_GENERATION_CHAIN` links, for ever.
612/// That is a policy decided here rather than a limit discovered in production.
613///
614/// `chain_depth` is how many links the base already sits behind a whole entry —
615/// the caller knows this because it wrote them. Zero for a base that is whole.
616pub fn supersede_as_delta(
617    archive: &Path,
618    superseded: &str,
619    base: &str,
620    chain_depth: usize,
621    compression_level: i32,
622) -> Result<SupersedeOutcome> {
623    if superseded == base {
624        return Err(anyhow!("an entry cannot be a delta against itself: {superseded}"));
625    }
626    if chain_depth + 1 > MAX_GENERATION_CHAIN {
627        return Ok(SupersedeOutcome::ChainTooLong);
628    }
629
630    let (old_bytes, base_bytes) = {
631        let ar = crate::ZnippyArchive::open(archive)?;
632        // VERIFIED reads: the bytes about to become a checksum and a delta base
633        // must be the bytes the archive claims, or the delta is correct against
634        // something nobody stored.
635        (
636            ar.extract_file_verified(superseded)?,
637            ar.extract_file_verified(base)?,
638        )
639    };
640
641    let delta = crate::archive::encode_delta_against(&base_bytes, &old_bytes);
642    // The same two cutoffs `plan_version` applies, on real bytes: a delta that
643    // does not clearly win is not worth a chain link that is paid on every later
644    // read of this entry.
645    if (delta.len() as f64) >= crate::archive::DELTA_SIZE_ALPHA * (old_bytes.len() as f64) {
646        return Ok(SupersedeOutcome::NotSmaller {
647            delta_bytes: delta.len() as u64,
648            stored_bytes: old_bytes.len() as u64,
649        });
650    }
651
652    let mut sink = ArrowIpcSinkAppend::open_existing(archive)?;
653    let at = sink.blob_end();
654    // A delta between two packs is not obviously compressible — pack bytes are
655    // already deflated, and a diff of them mostly is too. So the codec is
656    // consulted and its answer taken, exactly as the append path does, rather
657    // than a rule being asserted either way (`precompressed.rs`).
658    let mut ctx = crate::codec::CompressCtx::new(compression_level)?;
659    let frame = ctx.compress(&delta).ok();
660    let (on_disk, compressed): (&[u8], bool) = match frame.as_deref() {
661        Some(f) if f.len() < delta.len() => (f, true),
662        _ => (&delta, false),
663    };
664    sink.file().write_all_at(on_disk, at)?;
665    sink.advance_blob_end(on_disk.len() as u64);
666
667    // Drop every old row of the superseded entry, and put one delta row in.
668    sink.replace_carried(superseded, vec![ChunkLoc {
669        chunk_seq: 0,
670        fdata_offset: 0,
671        blob_offset: at,
672        blob_size: on_disk.len() as u64,
673        // What the entry reconstructs TO, which is what the reader sizes and
674        // what the chunk's blake3 is over.
675        uncompressed_size: old_bytes.len() as u64,
676        compressed,
677        checksum: *blake3::hash(&old_bytes).as_bytes(),
678    }]);
679    sink.push_delta_map_row(superseded.to_string(), 0, base.to_string());
680    Box::new(sink).finish()?;
681
682    Ok(SupersedeOutcome::Delta {
683        stored_bytes: old_bytes.len() as u64,
684        delta_bytes: on_disk.len() as u64,
685        chain_depth: chain_depth + 1,
686    })
687}
688
689/// What one [`compact_archive`] reclaimed.
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
691pub struct CompactReport {
692    pub bytes_before: u64,
693    pub bytes_after: u64,
694    /// Live data rows carried across. A compaction never changes this.
695    pub rows: u64,
696    /// Delta-map rows carried across. Also never changed.
697    pub delta_rows: u64,
698}
699
700/// **Rewrite a sealed archive keeping only the bytes something still points at.**
701///
702/// The other half of [`supersede_as_delta`], and without it that writer cannot
703/// deliver a byte. Superseding an entry writes the delta and drops the old
704/// entry's index rows, but the old *blob* stays where it was, unreferenced —
705/// MEASURED on oden 2026-08-05: an 8 056 624-byte archive went to **8 057 731**
706/// after a supersede replaced 4 000 000 bytes of live payload with 11. An archive
707/// fed one generation at a time therefore grows by a whole generation each time,
708/// which is exactly what storing them whole would have cost. The saving
709/// `supersede_as_delta` measures is in the archive's LIVE bytes; this is what
710/// makes it the archive's size.
711///
712/// # It copies, and that is the point
713///
714/// Every live chunk's on-disk bytes are copied **verbatim** to the new file — no
715/// decode, no re-encode, no delta re-computation, and the `compressed` flag,
716/// `uncompressed_size` and blake3 of each chunk are carried unchanged. A delta
717/// chunk stays a delta chunk at the same depth, and `__znippy_delta__` travels as
718/// its decoded rows exactly as an append carries it. So a compaction costs one
719/// pass over the live bytes and cannot change what any entry reads back as.
720///
721/// The obvious alternative — read every entry out and write a fresh archive —
722/// cannot work here at all, and that is worth recording because it is what a
723/// caller reaches for first: rebuilding stores every entry WHOLE, and superseding
724/// them again reproduces exactly the dead payload the rebuild was meant to
725/// remove.
726///
727/// Staged beside the destination and renamed over it, so an interruption at any
728/// byte leaves the original archive, dead payload and all, serving.
729pub fn compact_archive(archive: &Path) -> Result<CompactReport> {
730    let bytes_before = std::fs::metadata(archive)?.len();
731    let src = ArrowIpcSinkAppend::open_existing(archive)?;
732
733    let staged = {
734        let unique = std::time::SystemTime::now()
735            .duration_since(std::time::UNIX_EPOCH)
736            .map(|d| d.as_nanos())
737            .unwrap_or(0);
738        let mut p = archive.as_os_str().to_owned();
739        p.push(format!(".compact-{}-{unique}", std::process::id()));
740        std::path::PathBuf::from(p)
741    };
742    let out = Arc::new(
743        std::fs::OpenOptions::new()
744            .read(true)
745            .write(true)
746            .create(true)
747            .truncate(true)
748            .open(&staged)
749            .map_err(|e| anyhow!("compact: staging {}: {e}", staged.display()))?,
750    );
751
752    let mut sink = ArrowIpcSinkAppend::new(Arc::clone(&out), 0);
753    sink.meta = src.meta.clone();
754    sink.carried_reserved = src.carried_reserved.clone();
755    sink.delta_map = src.delta_map.clone();
756    let delta_rows = sink.delta_map.len() as u64;
757
758    let mut cursor = 0u64;
759    // Buffer reused across chunks: a repository's generations are packs, and
760    // allocating one per chunk is the kind of thing that turns a copy into a
761    // profile.
762    let mut buf: Vec<u8> = Vec::new();
763    for (path, loc) in &src.carried {
764        let n = loc.blob_size as usize;
765        buf.clear();
766        buf.resize(n, 0);
767        src.file
768            .read_exact_at(&mut buf, loc.blob_offset)
769            .map_err(|e| anyhow!("compact: reading {path} at {}: {e}", loc.blob_offset))?;
770        out.write_all_at(&buf, cursor)?;
771        let mut moved = loc.clone();
772        moved.blob_offset = cursor;
773        cursor += loc.blob_size;
774        sink.carried.push((path.clone(), moved));
775    }
776    let rows = sink.carried.len() as u64;
777    sink.cursor = cursor;
778    Box::new(sink).finish()?;
779
780    out.sync_all()?;
781    drop(out);
782    drop(src);
783    std::fs::rename(&staged, archive)?;
784    if let Some(parent) = archive.parent() {
785        if let Ok(f) = std::fs::File::open(parent) {
786            let _ = f.sync_all();
787        }
788    }
789
790    Ok(CompactReport {
791        bytes_before,
792        bytes_after: std::fs::metadata(archive)?.len(),
793        rows,
794        delta_rows,
795    })
796}
797
798/// What [`supersede_as_delta`] did, and why. Never silent: a caller that asked
799/// for a delta and got a whole entry has to be able to see which.
800#[derive(Debug, PartialEq, Eq)]
801pub enum SupersedeOutcome {
802    /// Stored as a delta. `delta_bytes` is what is on disk now.
803    Delta { stored_bytes: u64, delta_bytes: u64, chain_depth: usize },
804    /// The delta did not clear [`crate::archive::DELTA_SIZE_ALPHA`]; the entry is
805    /// untouched.
806    NotSmaller { delta_bytes: u64, stored_bytes: u64 },
807    /// The chain would exceed [`MAX_GENERATION_CHAIN`]; keep this one whole. It
808    /// becomes the keyframe the next chain is built against.
809    ChainTooLong,
810}
811
812/// How many generations may chain before one is kept whole as a keyframe.
813///
814/// **32**, half the reader's [`MAX_RECONSTRUCT_DEPTH`] of 64. The reader's bound
815/// is about a hostile index and is a refusal; this is a writer policy about cost,
816/// and it is set below the refusal so a legitimate archive never approaches it.
817///
818/// The cost it bounds is MEASURED on a real eight-generation `nornir` chain
819/// (oden, 2026-08-05, ~7.3-8.3 MB packs): reading the live generation is
820/// **12.8 ms** and every link behind it adds **18.2 ms** — 6.4 ms of delta
821/// application and decompression, 11.8 ms of blake3 over the reconstructed
822/// entry. Thirty-two links is therefore ~0.6 s to read the OLDEST generation in
823/// a full chain, and the reader's bound of 64 is ~1.2 s.
824///
825/// That is a cold archival read that nothing on a serve path makes: the live
826/// generation holds every reachable object and sits at depth 0, whole, for ever.
827/// Against it, each link saves a whole generation — measured **5.26x** over the
828/// chain as a whole, 62 350 306 bytes of packs down to 11 860 793.
829///
830/// **The per-link blake3 is kept, deliberately.** It is 56% of that cold read and
831/// it is the only thing that can catch a delta applied to the wrong base — which
832/// is exactly the mistake a generation chain invites, because every entry in it
833/// is a pack of the same repository at a similar size, and a base swapped for
834/// its neighbour would apply cleanly. Trading that for 80 ms on a read nobody
835/// makes is the wrong way round.
836pub const MAX_GENERATION_CHAIN: usize = 32;
837
838/// One base-schema data batch from `(path, ChunkLoc)` rows, in the order given.
839///
840/// The single builder for base-schema index rows. Four call sites used to carry
841/// a copy of this loop — `emit_carried`, `write_lookup_and_trie`, `ArrowIpcSink`'s
842/// two — and a column appended in one order in one of them and another order in
843/// the next is a silent index corruption no checksum catches, because every
844/// individual chunk still hashes correctly (LAW 5, by construction).
845/// `pub` rather than `pub(crate)` since 2026-08-08: `znippy-plugin-git`'s
846/// generation-0 seal needs exactly these columns in exactly this order for the
847/// verbatim packs it already holds on disk, and a second copy of this loop in
848/// that crate is the drift this function was extracted to prevent (LAW 5).
849pub fn base_batch_from_rows(paths: &[String], locs: &[ChunkLoc]) -> Result<RecordBatch> {
850    let order: Vec<usize> = (0..paths.len()).collect();
851    base_batch_permuted(data_subindex_schema(), paths, locs, &order)
852}
853
854/// [`base_batch_from_rows`] emitting rows in `order` under an explicit `schema` —
855/// the sorted-lookup case, which is the same columns in a different order.
856pub(crate) fn base_batch_permuted(
857    schema: Arc<Schema>,
858    paths: &[String],
859    locs: &[ChunkLoc],
860    order: &[usize],
861) -> Result<RecordBatch> {
862    let n = order.len();
863    let mut path_b = StringBuilder::with_capacity(n, n * 16);
864    let mut seq_b = UInt32Builder::with_capacity(n);
865    let mut fdata_b = UInt64Builder::with_capacity(n);
866    let mut comp_b = BooleanBuilder::with_capacity(n);
867    let mut usz_b = UInt64Builder::with_capacity(n);
868    let mut boff_b = UInt64Builder::with_capacity(n);
869    let mut bsz_b = UInt64Builder::with_capacity(n);
870    let mut ck_b = FixedSizeBinaryBuilder::with_capacity(n, 32);
871    for &i in order {
872        let loc = &locs[i];
873        path_b.append_value(&paths[i]);
874        seq_b.append_value(loc.chunk_seq);
875        fdata_b.append_value(loc.fdata_offset);
876        comp_b.append_value(loc.compressed);
877        usz_b.append_value(loc.uncompressed_size);
878        boff_b.append_value(loc.blob_offset);
879        bsz_b.append_value(loc.blob_size);
880        ck_b.append_value(loc.checksum).expect("checksum is 32 bytes");
881    }
882    Ok(RecordBatch::try_new(
883        schema,
884        vec![
885            Arc::new(path_b.finish()),
886            Arc::new(seq_b.finish()),
887            Arc::new(fdata_b.finish()),
888            Arc::new(comp_b.finish()),
889            Arc::new(usz_b.finish()),
890            Arc::new(boff_b.finish()),
891            Arc::new(bsz_b.finish()),
892            Arc::new(ck_b.finish()),
893        ],
894    )?)
895}
896
897/// Compress-or-store each file and write its blob at `cursor`, returning the
898/// index rows and the new cursor. **No metadata is touched** — this is the blob
899/// half alone, shared by the re-sealing [`append_files`] path and by the hot
900/// journal path, so the two cannot drift on the skip decision, the blake3 domain
901/// (original bytes) or the store-raw rule.
902pub(crate) fn write_blobs(
903    file: &File,
904    cursor: u64,
905    files: &[(String, Vec<u8>)],
906    ctx: &mut crate::codec::CompressCtx,
907    policy: crate::SkipPolicy,
908) -> Result<(Vec<String>, Vec<ChunkLoc>, u64)> {
909    let mut paths = Vec::with_capacity(files.len());
910    let mut locs = Vec::with_capacity(files.len());
911    let mut cursor = cursor;
912    for (rel, bytes) in files {
913        let checksum = *blake3::hash(bytes).as_bytes();
914        // The skip decision, which the append path did not make at all until
915        // 2026-08-03: every byte went to the codec and the frame was thrown away
916        // whenever it came out no smaller. For already-compressed input — a
917        // `.pack`, a `.jar`, a `.crate` — that is the entire codec cost paid to
918        // learn what the file's name already said.
919        //
920        // MEASURED on gunnar's cold tier (oden, 2026-08-03): appending a
921        // 34.1 MiB consolidated packfile cost 0.34 s of CPU compressing and
922        // 0.03 s skipping — 11.3x — for byte-identical output.
923        let skip = policy.skip_by_path(std::path::Path::new(rel.as_str()));
924        let frame = if skip { Vec::new() } else { ctx.compress(bytes)? };
925        let (on_disk, compressed): (&[u8], bool) = if !skip && frame.len() < bytes.len() {
926            (&frame, true)
927        } else {
928            (bytes, false)
929        };
930        let blob_offset = cursor;
931        file.write_all_at(on_disk, blob_offset)?;
932        cursor += on_disk.len() as u64;
933        paths.push(rel.clone());
934        locs.push(ChunkLoc {
935            chunk_seq: 0,
936            fdata_offset: 0,
937            blob_offset,
938            blob_size: on_disk.len() as u64,
939            uncompressed_size: bytes.len() as u64,
940            compressed,
941            checksum,
942        });
943    }
944    Ok((paths, locs, cursor))
945}
946
947/// Outcome of a native [`append_files`] call.
948#[derive(Debug, Clone)]
949pub struct AppendReport {
950    /// Data rows that existed in the archive before the append (recovered),
951    /// including any that the append then replaced.
952    pub rows_before: u64,
953    /// Pre-existing rows dropped because the append re-wrote the same
954    /// `relative_path` (replace semantics). Always 0 on the fresh-create path.
955    pub rows_replaced: u64,
956    /// New rows (one per appended file/chunk) written by the append.
957    pub rows_added: u64,
958    /// Byte offset where the appended blob region started (old blob_end).
959    pub blob_append_offset: u64,
960    /// Bytes of new blob payload appended (compressed/stored).
961    pub blob_bytes_added: u64,
962    /// Final size of the re-sealed archive.
963    pub sealed_total_bytes: u64,
964}
965
966/// First-class **native blob append** — the caller-facing library primitive that
967/// the iceberg lifecycle test (#23) previously had to perform by hand. (There is
968/// no `compress --append` CLI verb today; this is the in-process entry point.)
969///
970/// Opens an existing sealed v0.7 `.znippy`, compresses each `(relative_path,
971/// bytes)` in `new_files` with the znippy codec, appends the resulting blobs to
972/// the same file past the existing blob region, then re-seals (merged old+new
973/// lookup + trie + manifest + footer) via [`ArrowIpcSinkAppend`]. The original
974/// blob bytes and existing rows are reused verbatim — nothing is recompressed.
975///
976/// **Replace semantics:** a `relative_path` in `new_files` that the archive
977/// already contains REPLACES the existing entry — the pre-existing rows for that
978/// path are dropped from the re-sealed index (counted in
979/// [`AppendReport::rows_replaced`]) and its old blob bytes become unreferenced
980/// dead payload. Appending the same path twice never leaves two live copies.
981///
982/// Mirrors the real compress path's per-blob accounting: blake3 over the
983/// ORIGINAL bytes, store-raw when the codec frame is not smaller, one chunk per
984/// file (`chunk_seq = 0`). `compression_level` is the codec level (e.g. 3).
985pub fn append_files(
986    archive: &Path,
987    new_files: &[(String, Vec<u8>)],
988    compression_level: i32,
989) -> Result<AppendReport> {
990    append_files_with_meta(archive, new_files, compression_level, None)
991}
992
993/// [`append_files`] with an explicit [`SkipPolicy`](crate::SkipPolicy).
994///
995/// The batch-level form, for a caller that already knows what it is appending —
996/// `SkipPolicy::already_compressed()` stores every entry raw without inspecting
997/// a byte, which is exact, free, and better informed than any probe. gunnar's
998/// cold tier appends packfiles and uses it.
999pub fn append_files_with_policy(
1000    archive: &Path,
1001    new_files: &[(String, Vec<u8>)],
1002    compression_level: i32,
1003    policy: crate::SkipPolicy,
1004) -> Result<AppendReport> {
1005    let sink = ArrowIpcSinkAppend::open_existing(archive)?;
1006    let rows_before = sink.recovered_rows() as u64;
1007    let blob_append_offset = sink.blob_end();
1008    write_files_into_sink(
1009        sink,
1010        new_files,
1011        compression_level,
1012        rows_before,
1013        blob_append_offset,
1014        policy,
1015    )
1016}
1017
1018/// [`append_files`], additionally merging `meta` rows into the archive's
1019/// searchable metadata sub-index.
1020///
1021/// `None` leaves the metadata exactly as the archive had it — including having
1022/// none. `Some(rows)` merges into whatever was already there (the resume carries
1023/// the old section forward), creating the section if the archive had none.
1024pub fn append_files_with_meta(
1025    archive: &Path,
1026    new_files: &[(String, Vec<u8>)],
1027    compression_level: i32,
1028    meta: Option<MetaTable>,
1029) -> Result<AppendReport> {
1030    let mut sink = ArrowIpcSinkAppend::open_existing(archive)?;
1031    if let Some(table) = meta {
1032        sink.merge_meta(table.rows().to_vec());
1033    }
1034    let rows_before = sink.recovered_rows() as u64;
1035    let blob_append_offset = sink.blob_end();
1036    write_files_into_sink(
1037        sink,
1038        new_files,
1039        compression_level,
1040        rows_before,
1041        blob_append_offset,
1042        // Resolve per entry from its name, then from its bytes — the same
1043        // default `compress_dir` has. Deliberately NOT "always compress", which
1044        // is what this path did before and which is never the right answer for
1045        // an entry whose extension already says it is compressed.
1046        crate::SkipPolicy::resolve(),
1047    )
1048}
1049
1050/// Create a fresh `.znippy` archive from in-memory `files` — the bootstrap inverse
1051/// of [`append_files`], which requires an already-sealed archive (it rejects an
1052/// empty manifest). Seed a new writable archive with this, then grow it with
1053/// [`append_files`]. Overwrites `archive` if it already exists.
1054pub fn create_archive(
1055    archive: &Path,
1056    files: &[(String, Vec<u8>)],
1057    compression_level: i32,
1058) -> Result<AppendReport> {
1059    create_archive_with_meta(archive, files, compression_level, None)
1060}
1061
1062/// [`create_archive`], additionally sealing a searchable metadata sub-index.
1063///
1064/// `None` seals **no section** — the archive reads back as
1065/// `ArchiveMeta::NoMetadata` and is byte-identical to one from
1066/// [`create_archive`]. `Some(table)` seals the section even when the table is
1067/// empty, which reads back as a present-but-empty index: "searched, records
1068/// nothing", a different statement from "never had an index".
1069pub fn create_archive_with_meta(
1070    archive: &Path,
1071    files: &[(String, Vec<u8>)],
1072    compression_level: i32,
1073    meta: Option<MetaTable>,
1074) -> Result<AppendReport> {
1075    let blob_file = Arc::new(
1076        File::create(archive)
1077            .map_err(|e| anyhow!("create archive {}: {e}", archive.display()))?,
1078    );
1079    let mut sink = ArrowIpcSinkAppend::new(blob_file, 0);
1080    sink.meta = meta;
1081    write_files_into_sink(sink, files, compression_level, 0, 0, crate::SkipPolicy::resolve())
1082}
1083
1084/// Build a complete `.znippy` archive **entirely in memory** from in-memory
1085/// `files` and return its bytes — no staging directory, no named output file. The
1086/// sink needs a positioned-write fd, so this seals into an **anonymous temp file**
1087/// (`O_TMPFILE` where the OS supports it → never linked into the filesystem
1088/// namespace), then reads the sealed bytes straight back out. The returned `Vec`
1089/// is byte-identical to what [`create_archive`] would write to a path.
1090///
1091/// Use this for zero-disk pipelines: build a release/airgap archive from product
1092/// bytes held in RAM and stream it across the gap without ever touching disk on
1093/// the build side. Pair with [`append_files`] (path) or hold the bytes and re-seal.
1094pub fn create_archive_to_vec(
1095    files: &[(String, Vec<u8>)],
1096    compression_level: i32,
1097) -> Result<(Vec<u8>, AppendReport)> {
1098    let anon = Arc::new(
1099        tempfile::tempfile().map_err(|e| anyhow!("anonymous archive fd: {e}"))?,
1100    );
1101    let sink = ArrowIpcSinkAppend::new(anon.clone(), 0);
1102    let report =
1103        write_files_into_sink(sink, files, compression_level, 0, 0, crate::SkipPolicy::resolve())?;
1104    // The Arc keeps the anonymous fd alive past `finish()`; read the sealed bytes.
1105    let mut bytes = vec![0u8; report.sealed_total_bytes as usize];
1106    anon.read_exact_at(&mut bytes, 0)
1107        .map_err(|e| anyhow!("read back anonymous archive: {e}"))?;
1108    Ok((bytes, report))
1109}
1110
1111/// Shared core of [`append_files`] / [`create_archive`]: compress each file's bytes
1112/// (store-raw if not smaller), write the blobs at the sink's running blob cursor,
1113/// then push one base-schema data sub-index and seal. `sink` is either a fresh
1114/// [`ArrowIpcSinkAppend::new`] or an [`ArrowIpcSinkAppend::open_existing`].
1115fn write_files_into_sink(
1116    mut sink: ArrowIpcSinkAppend,
1117    new_files: &[(String, Vec<u8>)],
1118    compression_level: i32,
1119    rows_before: u64,
1120    blob_append_offset: u64,
1121    policy: crate::SkipPolicy,
1122) -> Result<AppendReport> {
1123    use crate::codec::CompressCtx;
1124
1125    // Replace, don't duplicate: a path being (re-)written now supersedes whatever
1126    // rows the archive already held for it. The old blob bytes stay in the file as
1127    // dead payload — they are simply no longer referenced by any index row.
1128    let incoming: std::collections::HashSet<&str> =
1129        new_files.iter().map(|(rel, _)| rel.as_str()).collect();
1130    let rows_replaced = sink.drop_carried_paths(&incoming) as u64;
1131
1132    // Append the new blob bytes to the file at the running blob cursor, mirroring
1133    // the compress pipeline (hash original bytes; store-raw if not smaller). One
1134    // writer, shared with the hot journal path.
1135    let blob_file = sink.file.clone();
1136    let mut ctx = CompressCtx::new(compression_level)?;
1137    let (paths, locs, cursor) =
1138        write_blobs(&blob_file, blob_append_offset, new_files, &mut ctx, policy)?;
1139    let blob_bytes_added = cursor - blob_append_offset;
1140    blob_file.sync_all()?;
1141
1142    // Advance the sink's cursor past the freshly-written blob region so the new
1143    // data sub-index lands after the appended blobs (not over them).
1144    sink.cursor = cursor;
1145
1146    // A DATA sub-index: seal it with the format-version-stamped schema. With the
1147    // bare `lookup_schema()` every archive this path writes — i.e. every archive
1148    // a writable holger repo or `cargo publish` produces — recorded no format
1149    // version, so the reader-side version pin had nothing to check.
1150    let batch = base_batch_from_rows(&paths, &locs)?;
1151    let schema = data_subindex_schema();
1152    let rows_added = batch.num_rows() as u64;
1153    sink.push_subindex(
1154        schema.as_ref(),
1155        &[batch],
1156        GroupKey { pkg_type: 0, repo: String::new(), module_name: String::new() },
1157    )?;
1158
1159    let sealed_total_bytes = Box::new(sink).finish()?;
1160
1161    Ok(AppendReport {
1162        rows_before,
1163        rows_replaced,
1164        rows_added,
1165        blob_append_offset,
1166        blob_bytes_added,
1167        sealed_total_bytes,
1168    })
1169}
1170
1171/// Recover existing DATA rows for the resume accumulator. Reads the sorted
1172/// lookup reserved section (the cheapest source — already the exact per-chunk
1173/// rows, sorted) when present; otherwise concatenates the data sub-indexes.
1174pub(crate) fn recover_rows(
1175    path: &Path,
1176    entries: &[ManifestEntry],
1177) -> Result<(Vec<String>, Vec<ChunkLoc>)> {
1178    use std::io::{Read, Seek, SeekFrom};
1179
1180    let mut file = File::open(path)?;
1181
1182    // Prefer the sorted lookup section: it is exactly the base-schema per-chunk
1183    // rows, one cheap stream decode.
1184    if let Some(lk) = entries.iter().find(|e| e.module_name == LOOKUP_MODULE) {
1185        file.seek(SeekFrom::Start(lk.index_offset))?;
1186        let mut bytes = vec![0u8; lk.index_len as usize];
1187        file.read_exact(&mut bytes)?;
1188        return decode_base_rows(&bytes);
1189    }
1190
1191    // Fallback: read every NON-reserved data sub-index and concatenate its rows.
1192    let mut paths = Vec::new();
1193    let mut locs = Vec::new();
1194    for e in entries {
1195        if is_reserved_module(&e.module_name) {
1196            continue;
1197        }
1198        file.seek(SeekFrom::Start(e.index_offset))?;
1199        let mut bytes = vec![0u8; e.index_len as usize];
1200        file.read_exact(&mut bytes)?;
1201        let (mut p, mut l) = decode_base_rows(&bytes)?;
1202        paths.append(&mut p);
1203        locs.append(&mut l);
1204    }
1205    Ok((paths, locs))
1206}
1207
1208// ── inject-assert tests (the "tests inject values, not just no-crash" LAW) ──
1209// Every test here seals a real archive through a `CompressCtx`, so all of them
1210// need the codec — see the note on `archive::tests`.
1211#[cfg(all(test, feature = "openzl"))]
1212mod tests {
1213    use super::*;
1214    use crate::codec::CompressCtx;
1215    use crate::meta::{BlobMeta, ChunkMeta};
1216    use crate::{ArrowIpcSink, ZnippyArchive, ZnippyReader};
1217    use std::time::{SystemTime, UNIX_EPOCH};
1218
1219    fn unique_dir(tag: &str) -> std::path::PathBuf {
1220        let ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
1221        let d = std::env::temp_dir().join(format!("znippy_append_{tag}_{ns}_{:?}", std::thread::current().id()));
1222        std::fs::create_dir_all(&d).unwrap();
1223        d
1224    }
1225
1226    /// **The append path takes the skip decision, and it did not used to.**
1227    ///
1228    /// Until 2026-08-03 `write_files_into_sink` ran `CompressCtx::compress` over
1229    /// every byte of every appended file and kept the frame only when it came
1230    /// out smaller. For already-compressed input that is the entire codec cost
1231    /// paid to learn nothing — `compress_dir` has consulted `SkipPolicy` since
1232    /// it existed, and this path silently did not.
1233    ///
1234    /// The observable is chosen so it cannot be faked. A *highly compressible*
1235    /// payload is appended under a `.pack` name, which the extension table
1236    /// declares already-compressed. If the policy is honoured the blob is stored
1237    /// RAW and `blob_bytes_added` equals the input length; if the codec runs, the
1238    /// frame is far smaller and the number collapses. Asserting on CPU time
1239    /// would have been the honest measure of the bug but is not a test; this is
1240    /// the same decision made visible in a byte count.
1241    ///
1242    /// Seen RED by restoring the unconditional `ctx.compress(bytes)`:
1243    /// `blob_bytes_added` drops to a few hundred bytes and the assertion fires.
1244    #[test]
1245    fn an_append_honours_the_skip_policy_instead_of_compressing_everything() {
1246        let dir = unique_dir("skip_policy");
1247        let archive = dir.join("a.znippy");
1248        create_archive(&archive, &[("seed.txt".into(), b"seed".to_vec())], 3).unwrap();
1249
1250        // 256 KiB of one byte: the codec would crush this to almost nothing.
1251        let squishy = vec![b'A'; 256 * 1024];
1252        let name = format!("pack-{}.pack", "0f".repeat(20));
1253        let report =
1254            append_files(&archive, &[(name.clone(), squishy.clone())], 3).unwrap();
1255
1256        assert_eq!(
1257            report.blob_bytes_added,
1258            squishy.len() as u64,
1259            "a `.pack` entry must be stored RAW. {} bytes were written for a {}-byte input, so \
1260             the codec ran over a file the extension table already said was compressed",
1261            report.blob_bytes_added,
1262            squishy.len()
1263        );
1264        // …and it still reads back byte-exact, which is what stored-raw has to mean.
1265        assert_eq!(crate::get_file(&archive, &name).unwrap(), squishy);
1266
1267        // The MIRROR, so the test above is not merely asserting that nothing is
1268        // ever compressed: the identical bytes under an ordinary name DO go
1269        // through the codec.
1270        let report2 =
1271            append_files(&archive, &[("plain.txt".into(), squishy.clone())], 3).unwrap();
1272        assert!(
1273            report2.blob_bytes_added < squishy.len() as u64 / 10,
1274            "an ordinary name must still be compressed; {} bytes for {}",
1275            report2.blob_bytes_added,
1276            squishy.len()
1277        );
1278        assert_eq!(crate::get_file(&archive, "plain.txt").unwrap(), squishy);
1279
1280        // And an explicit batch-level claim overrules the name entirely.
1281        let report3 = append_files_with_policy(
1282            &archive,
1283            &[("also-plain.txt".into(), squishy.clone())],
1284            3,
1285            crate::SkipPolicy::already_compressed(),
1286        )
1287        .unwrap();
1288        assert_eq!(
1289            report3.blob_bytes_added,
1290            squishy.len() as u64,
1291            "`already_compressed()` must store raw whatever the name says"
1292        );
1293
1294        std::fs::remove_dir_all(&dir).ok();
1295    }
1296
1297    /// Deterministic synthetic rows — distinct, lexicographically-spread paths
1298    /// (same flavour as the bench's `synth_blobs`, so the sort/fst do real work).
1299    fn synth(n: usize, salt: u64) -> Vec<(String, Vec<u8>)> {
1300        (0..n)
1301            .map(|i| {
1302                let g = (i.wrapping_mul(2_654_435_761) ^ salt as usize) % 1000;
1303                let p = format!("repo/grp{g:03}/file{:08}_{salt}.bin", i);
1304                let body = format!("payload {i} salt {salt} {}\n", "z".repeat(8 + (i % 40)));
1305                (p, body.into_bytes())
1306            })
1307            .collect()
1308    }
1309
1310    /// Write a fresh sealed archive with `sink` (codec-compressed blobs + one
1311    /// base-schema data sub-index + the seal). Returns the sealed length. Generic
1312    /// over a closure so we can drive BOTH ArrowIpcSink (A) and the clone (B)
1313    /// through the *identical* fresh path and compare bytes.
1314    fn write_fresh<S: ArchiveMetaSink + 'static>(
1315        path: &Path,
1316        files: &[(String, Vec<u8>)],
1317        make_sink: impl FnOnce(Arc<File>, u64) -> S,
1318    ) -> u64 {
1319        let file = Arc::new(File::create(path).unwrap());
1320        let mut ctx = CompressCtx::new(3).unwrap();
1321        let mut blobs = Vec::new();
1322        let mut paths = Vec::new();
1323        let mut cursor = 0u64;
1324        for (fi, (rel, bytes)) in files.iter().enumerate() {
1325            let checksum = *blake3::hash(bytes).as_bytes();
1326            let frame = ctx.compress(bytes).unwrap();
1327            let (on_disk, compressed): (&[u8], bool) =
1328                if frame.len() < bytes.len() { (&frame, true) } else { (bytes, false) };
1329            file.write_all_at(on_disk, cursor).unwrap();
1330            let blob_offset = cursor;
1331            cursor += on_disk.len() as u64;
1332            paths.push(rel.clone());
1333            blobs.push(BlobMeta {
1334                blob_offset,
1335                blob_size: on_disk.len() as u64,
1336                chunk_meta: ChunkMeta {
1337                    fdata_offset: 0,
1338                    file_index: fi as u64,
1339                    chunk_seq: 0,
1340                    checksum,
1341                    compressed,
1342                    uncompressed_size: bytes.len() as u64,
1343                    compressed_size: on_disk.len() as u64,
1344                },
1345            });
1346        }
1347        let resolver = { let p = paths.clone(); move |fi: u64| p[fi as usize].clone() };
1348        let batch = crate::build_metadata_batch(&blobs, resolver, &[], &[]).unwrap();
1349        // Same DATA sub-index schema `write_files_into_sink` seals with, so this
1350        // helper stays the byte-for-byte reference for `create_archive`.
1351        let schema = data_subindex_schema();
1352        let mut sink = make_sink(file.clone(), cursor);
1353        sink.push_subindex(
1354            schema.as_ref(),
1355            &[batch],
1356            GroupKey { pkg_type: 0, repo: String::new(), module_name: String::new() },
1357        )
1358        .unwrap();
1359        Box::new(sink).finish().unwrap()
1360    }
1361
1362    /// PARITY: the clone's fresh write+seal must produce a BYTE-IDENTICAL archive
1363    /// to the original `ArrowIpcSink` over the same rows. This is the structural
1364    /// proof that the clone is a zero-cost superset on the normal path (the
1365    /// throughput parity number lives in the bench; this asserts correctness).
1366    #[test]
1367    fn clone_fresh_path_is_byte_identical_to_original() {
1368        let dir = unique_dir("parity");
1369        let files = synth(2_000, 1);
1370
1371        let a = dir.join("a.znippy");
1372        let b = dir.join("b.znippy");
1373        let len_a = write_fresh(&a, &files, ArrowIpcSink::new);
1374        let len_b = write_fresh(&b, &files, ArrowIpcSinkAppend::new);
1375
1376        assert_eq!(len_a, len_b, "clone seal produced a different total length");
1377        let bytes_a = std::fs::read(&a).unwrap();
1378        let bytes_b = std::fs::read(&b).unwrap();
1379        assert_eq!(
1380            bytes_a, bytes_b,
1381            "clone's fresh write path is NOT byte-identical to ArrowIpcSink — parity broken"
1382        );
1383        let _ = std::fs::remove_dir_all(&dir);
1384    }
1385
1386    /// RESUME: native append → reopen with the ORDINARY arrow-ipc reader → both
1387    /// the original AND the appended files read back byte-exact, and the index
1388    /// lists exactly old+new. Inject real bytes, assert real bytes out.
1389    #[test]
1390    fn native_append_roundtrips_old_and_new_files() {
1391        let dir = unique_dir("resume");
1392        let archive = dir.join("store.znippy");
1393        let orig = synth(1_500, 7);
1394        write_fresh(&archive, &orig, ArrowIpcSink::new);
1395
1396        let added = synth(300, 99);
1397        let report = append_files(&archive, &added, 3).unwrap();
1398        assert_eq!(report.rows_before, orig.len() as u64, "must recover all original rows");
1399        assert_eq!(report.rows_added, added.len() as u64);
1400        assert!(report.blob_bytes_added > 0, "append must write new blob bytes");
1401        assert!(
1402            report.sealed_total_bytes > report.blob_append_offset,
1403            "re-sealed file must be larger than the old blob region"
1404        );
1405
1406        // Reopen with the plain reader (no append awareness) and verify EVERY
1407        // file — original and appended — comes back byte-exact.
1408        let ar = ZnippyArchive::open(&archive).unwrap();
1409        let mut listed = ar.list_files().unwrap();
1410        listed.sort();
1411        let mut expected: Vec<String> =
1412            orig.iter().chain(added.iter()).map(|(p, _)| p.clone()).collect();
1413        expected.sort();
1414        assert_eq!(listed, expected, "index must list exactly old+new files after append");
1415
1416        for (p, bytes) in orig.iter().chain(added.iter()) {
1417            let got = ar.extract_file(p).unwrap();
1418            assert_eq!(&got, bytes, "byte mismatch after append for {p}");
1419        }
1420
1421        // Random-access lookup of an appended file via the rebuilt trie+lookup.
1422        let probe = &added[123].0;
1423        let chunks = crate::locate_file(&archive, probe).unwrap();
1424        assert!(!chunks.is_empty(), "appended file must be locatable via the re-sealed lookup");
1425        let _ = std::fs::remove_dir_all(&dir);
1426    }
1427
1428    /// BOOTSTRAP: `create_archive` seeds a fresh archive that (a) is byte-identical
1429    /// to the proven fresh write path, (b) reads back byte-exact via the ordinary
1430    /// reader, and (c) can then be grown by `append_files` — the create→append flow
1431    /// holger's writable `put` relies on. Inject real bytes, assert real bytes out.
1432    #[test]
1433    fn create_archive_seeds_then_grows() {
1434        let dir = unique_dir("create");
1435        let seed = synth(40, 5);
1436
1437        // (a) create_archive == write_fresh(_, ArrowIpcSinkAppend::new) byte-for-byte.
1438        let made = dir.join("made.znippy");
1439        let report = create_archive(&made, &seed, 3).unwrap();
1440        assert_eq!(report.rows_before, 0, "fresh archive has no prior rows");
1441        assert_eq!(report.rows_added, seed.len() as u64);
1442
1443        let ref_path = dir.join("ref.znippy");
1444        write_fresh(&ref_path, &seed, ArrowIpcSinkAppend::new);
1445        assert_eq!(
1446            std::fs::read(&made).unwrap(),
1447            std::fs::read(&ref_path).unwrap(),
1448            "create_archive must be byte-identical to the proven fresh write path"
1449        );
1450
1451        // (b) seeded files read back byte-exact via the plain reader.
1452        let ar = ZnippyArchive::open(&made).unwrap();
1453        for (p, bytes) in &seed {
1454            assert_eq!(&ar.extract_file(p).unwrap(), bytes, "seed byte mismatch for {p}");
1455        }
1456
1457        // (c) append_files grows the bootstrapped archive; old+new read back exact.
1458        let added = synth(15, 88);
1459        let rep2 = append_files(&made, &added, 3).unwrap();
1460        assert_eq!(rep2.rows_before, seed.len() as u64, "append must recover seeded rows");
1461        assert_eq!(rep2.rows_added, added.len() as u64);
1462
1463        let ar2 = ZnippyArchive::open(&made).unwrap();
1464        for (p, bytes) in seed.iter().chain(added.iter()) {
1465            assert_eq!(&ar2.extract_file(p).unwrap(), bytes, "byte mismatch after grow for {p}");
1466        }
1467        let _ = std::fs::remove_dir_all(&dir);
1468    }
1469
1470    /// ZERO-COPY / no-filesystem: `create_archive_to_vec` builds a valid archive
1471    /// from in-memory bytes with NO staging tree and NO named output file (it seals
1472    /// into an anonymous fd). Assert the returned bytes are byte-identical to
1473    /// `create_archive`'s file output AND read back byte-exact through the reader.
1474    #[test]
1475    fn create_archive_to_vec_is_filesystem_free_and_round_trips() {
1476        let files = synth(24, 7);
1477        let (bytes, report) = create_archive_to_vec(&files, 3).unwrap();
1478        assert_eq!(report.rows_before, 0, "fresh in-memory archive has no prior rows");
1479        assert_eq!(report.rows_added, files.len() as u64);
1480        assert_eq!(bytes.len() as u64, report.sealed_total_bytes, "vec len == sealed size");
1481
1482        let dir = unique_dir("tovec");
1483        // (1) byte-identical to the proven path (`create_archive` → file).
1484        let ref_path = dir.join("ref.znippy");
1485        create_archive(&ref_path, &files, 3).unwrap();
1486        assert_eq!(
1487            bytes,
1488            std::fs::read(&ref_path).unwrap(),
1489            "in-memory archive must be byte-identical to create_archive's file output"
1490        );
1491        // (2) the in-memory bytes ARE a real archive: persist + read back byte-exact.
1492        let p = dir.join("from_mem.znippy");
1493        std::fs::write(&p, &bytes).unwrap();
1494        let ar = ZnippyArchive::open(&p).unwrap();
1495        for (name, content) in &files {
1496            assert_eq!(&ar.extract_file(name).unwrap(), content, "byte mismatch for {name}");
1497        }
1498        let _ = std::fs::remove_dir_all(&dir);
1499    }
1500
1501    /// Read an archive's recorded on-disk format version **exactly the way a
1502    /// reader does** — manifest, then the Arrow schema metadata of the FIRST
1503    /// non-reserved sub-index. This is byte-for-byte the same walk as
1504    /// `znippy_common::read_znippy_index`'s `check_format_version` call and as
1505    /// holger's independent `traits::recorded_format_version` pin, so what this
1506    /// helper returns is what those two see.
1507    fn recorded_format_version(path: &Path) -> Option<String> {
1508        use std::io::{Read, Seek, SeekFrom};
1509
1510        use arrow::ipc::reader::StreamReader;
1511
1512        let entries = crate::index::read_znippy_manifest(path).ok()?;
1513        let mut file = File::open(path).ok()?;
1514        for e in &entries {
1515            if is_reserved_module(&e.module_name) {
1516                continue;
1517            }
1518            file.seek(SeekFrom::Start(e.index_offset)).ok()?;
1519            let mut bytes = vec![0u8; e.index_len as usize];
1520            file.read_exact(&mut bytes).ok()?;
1521            let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None).ok()?;
1522            return reader
1523                .schema()
1524                .metadata()
1525                .get(crate::index::FORMAT_VERSION_KEY)
1526                .cloned();
1527        }
1528        None
1529    }
1530
1531    /// FORMAT VERSION: every archive the append/create path writes must RECORD
1532    /// its on-disk format version — on the fresh create, on the in-memory create,
1533    /// and still after a re-seal by `append_files` (which rebuilds the whole
1534    /// metadata tail, so a stamp that only survives the fresh path is no stamp).
1535    ///
1536    /// This is what makes a reader-side version pin mean anything for the
1537    /// archives this path produces — a writable holger repo and `cargo publish`
1538    /// both write through here. Sealing the data sub-index with the bare
1539    /// `lookup_schema()` (no schema metadata) recorded NO version at all, and the
1540    /// pin silently degraded to "undetermined → read as before" for exactly those
1541    /// archives.
1542    #[test]
1543    fn every_appended_archive_records_the_format_version() {
1544        let dir = unique_dir("fmtver");
1545        let want = crate::index::ZNIPPY_FORMAT_VERSION.to_string();
1546
1547        // (a) fresh create.
1548        let made = dir.join("made.znippy");
1549        create_archive(&made, &synth(12, 3), 3).unwrap();
1550        assert_eq!(
1551            recorded_format_version(&made).as_deref(),
1552            Some(want.as_str()),
1553            "create_archive must stamp the on-disk format version"
1554        );
1555
1556        // (b) after a re-seal: `append_files` rebuilds the metadata tail from
1557        // scratch, so the stamp has to be re-emitted, not merely inherited.
1558        append_files(&made, &synth(7, 91), 3).unwrap();
1559        assert_eq!(
1560            recorded_format_version(&made).as_deref(),
1561            Some(want.as_str()),
1562            "append_files must re-stamp the format version on the re-sealed archive"
1563        );
1564
1565        // (c) the in-memory create path seals through the same code.
1566        let (bytes, _) = create_archive_to_vec(&synth(9, 4), 3).unwrap();
1567        let mem = dir.join("mem.znippy");
1568        std::fs::write(&mem, &bytes).unwrap();
1569        assert_eq!(
1570            recorded_format_version(&mem).as_deref(),
1571            Some(want.as_str()),
1572            "create_archive_to_vec must stamp the on-disk format version"
1573        );
1574
1575        // The stamp did not cost readability: rows still read back byte-exact.
1576        let ar = ZnippyArchive::open(&mem).unwrap();
1577        for (p, body) in &synth(9, 4) {
1578            assert_eq!(&ar.extract_file(p).unwrap(), body, "byte mismatch for {p}");
1579        }
1580        let _ = std::fs::remove_dir_all(&dir);
1581    }
1582
1583    /// METADATA SEARCH, end to end through a real archive:
1584    ///  (a) an archive sealed WITHOUT metadata reads back as `NoMetadata` — the
1585    ///      backward-compatible case, since that is exactly what every archive
1586    ///      written before this module looks like;
1587    ///  (b) sealing metadata makes "which entries carry key X" answerable, and
1588    ///      the answer resolves to real bytes via the ordinary reader;
1589    ///  (c) a re-seal by `append_files` CARRIES the metadata forward and merges
1590    ///      new rows into it — without that, every append would silently erase it;
1591    ///  (d) the metadata rows never leak into the data index or the lookup.
1592    #[test]
1593    fn metadata_is_searchable_survives_a_reseal_and_absence_is_reported_as_absence() {
1594        use crate::meta_index::{ArchiveMeta, MetaSearch, MetaTable, MetaValue, read_archive_meta};
1595
1596        let dir = unique_dir("meta");
1597        let files = synth(30, 2);
1598
1599        // (a) BACKWARD COMPAT: no metadata sealed → NoMetadata, not "found nothing".
1600        let plain = dir.join("plain.znippy");
1601        create_archive(&plain, &files, 3).unwrap();
1602        let m = read_archive_meta(&plain).unwrap();
1603        assert_eq!(m, ArchiveMeta::NoMetadata, "an archive with no meta section must say so");
1604        assert!(!m.is_searchable());
1605        assert_eq!(m.find_by_key("build-thing"), MetaSearch::NoMetadata);
1606        assert!(
1607            m.find_by_key("build-thing").hits().is_none(),
1608            "absence must NOT present itself as an empty result set"
1609        );
1610
1611        // A present-but-EMPTY index is the other state, and it is distinguishable.
1612        let empty = dir.join("empty.znippy");
1613        create_archive_with_meta(&empty, &files, 3, Some(MetaTable::new())).unwrap();
1614        let me = read_archive_meta(&empty).unwrap();
1615        assert!(me.is_searchable(), "a sealed empty index WAS searched");
1616        assert!(me.index().is_some_and(|i| i.is_empty()));
1617        assert_eq!(me.find_by_key("build-thing"), MetaSearch::Hits(&[]));
1618
1619        // (b) SEARCHABLE: two entries carry a build-thing, one does not.
1620        let wasm = b"\0asm\x01\0\0\0".to_vec();
1621        let (p0, p1, p2) = (files[0].0.clone(), files[1].0.clone(), files[2].0.clone());
1622        let mut t = MetaTable::new();
1623        t.insert(p0.clone(), "build-thing", MetaValue::Bytes(wasm.clone()))
1624            .insert(p0.clone(), "build-thing.abi", "wasi-p2")
1625            .insert(p1.clone(), "build-thing", MetaValue::Bytes(wasm.clone()))
1626            .insert(p2.clone(), "coverage", 0.5f64)
1627            .insert_archive("producer", "znippy");
1628
1629        let ar = dir.join("meta.znippy");
1630        create_archive_with_meta(&ar, &files, 3, Some(t)).unwrap();
1631
1632        let m = read_archive_meta(&ar).unwrap();
1633        let idx = m.index().expect("sealed index is present");
1634        assert_eq!(idx.len(), 5);
1635        let hits = m.find_by_key("build-thing").hits().unwrap();
1636        assert_eq!(hits.len(), 2, "exactly the two entries that carry one");
1637        let mut got: Vec<&str> = hits.iter().filter_map(|h| h.path()).collect();
1638        got.sort();
1639        let mut want = vec![p0.as_str(), p1.as_str()];
1640        want.sort();
1641        assert_eq!(got, want, "the search names the right ENTRIES");
1642        assert_eq!(hits[0].value.as_bytes(), Some(&wasm[..]), "and the right VALUE");
1643        assert_eq!(idx.archive_value("producer").and_then(MetaValue::as_str), Some("znippy"));
1644        assert_eq!(
1645            idx.find_by_prefix("build-thing").len(),
1646            3,
1647            "prefix sweeps build-thing + build-thing.abi"
1648        );
1649
1650        // The hit resolves to real bytes without extracting anything else.
1651        let reader = ZnippyArchive::open(&ar).unwrap();
1652        let want_bytes = &files.iter().find(|(p, _)| *p == p0).unwrap().1;
1653        assert_eq!(&reader.extract_file(hits[0].path().unwrap()).unwrap(), want_bytes);
1654
1655        // (c) A RE-SEAL keeps it, and merges.
1656        let added = synth(6, 77);
1657        let mut more = MetaTable::new();
1658        more.insert(added[0].0.clone(), "build-thing", MetaValue::Bytes(wasm.clone()));
1659        append_files_with_meta(&ar, &added, 3, Some(more)).unwrap();
1660
1661        let m2 = read_archive_meta(&ar).unwrap();
1662        let hits2 = m2.find_by_key("build-thing").hits().unwrap();
1663        assert_eq!(hits2.len(), 3, "the append merged, it did not replace");
1664        assert_eq!(
1665            m2.index().unwrap().archive_value("producer").and_then(MetaValue::as_str),
1666            Some("znippy"),
1667            "the archive-level row survived the re-seal"
1668        );
1669
1670        // A plain `append_files` (no meta argument) must also preserve it.
1671        append_files(&ar, &synth(3, 91), 3).unwrap();
1672        assert_eq!(
1673            read_archive_meta(&ar).unwrap().find_by_key("build-thing").hits().unwrap().len(),
1674            3,
1675            "an append that says nothing about metadata must not erase it"
1676        );
1677
1678        // (d) The metadata rows are NOT file rows: the data index and the lookup
1679        //     see only the real entries.
1680        let ar2 = ZnippyArchive::open(&ar).unwrap();
1681        let n_files = files.len() + added.len() + 3;
1682        assert_eq!(
1683            ar2.file_count(),
1684            n_files,
1685            "metadata rows leaked into the data index"
1686        );
1687        let lookup_bytes = read_reserved_section_bytes(&ar, LOOKUP_MODULE).unwrap().unwrap();
1688        assert_eq!(
1689            decode_base_rows(&lookup_bytes).unwrap().0.len(),
1690            n_files,
1691            "metadata rows leaked into the random-access lookup"
1692        );
1693        for (p, bytes) in files.iter().chain(added.iter()) {
1694            assert_eq!(&ar2.extract_file(p).unwrap(), bytes, "byte mismatch for {p}");
1695        }
1696        let _ = std::fs::remove_dir_all(&dir);
1697    }
1698
1699    /// Cached `ArchiveReader` must return byte-for-byte the SAME chunks and
1700    /// per-file metadata as the re-reading free functions — for a present file,
1701    /// an absent file, and a prefix window. Proves idea (B) is a pure read-side
1702    /// cache with zero behavioural drift from `locate_file` /
1703    /// `get_files_meta_with_prefix`.
1704
1705    /// **An append kept the blobs and threw away the ref log.**
1706    ///
1707    /// MEASURED 2026-08-04 on gunnar: an object-carrying push took the archive
1708    /// from 144 928 to 146 396 bytes and `__gunnar_refs__` was **gone**.
1709    /// `open_existing` truncates the whole metadata tail and re-supplies no
1710    /// reserved section it was not handed, and nothing hands it one — so every
1711    /// push after the first erased the ref history, which is what blocks serving
1712    /// refs from the archive for most of a repository's life.
1713    ///
1714    /// The distinction the append path could not express: `__gunnar_graph__` /
1715    /// `__gunnar_reach__` / `__gunnar_oid__` are DERIVED from the objects and
1716    /// must go when the objects change; `__gunnar_refs__` / `__gunnar_secrets__`
1717    /// are INDEPENDENT logs and nothing else can reproduce them. Both halves are
1718    /// asserted here — carrying everything would be the opposite bug and would
1719    /// leave a stale commit graph behind.
1720    ///
1721    /// Seen RED by dropping the `write_carried_reserved()` call from `finish`:
1722    /// `__gunnar_refs__` reads back as `None` and the first assertion fires.
1723    #[test]
1724    fn an_append_carries_the_ref_log_forward_and_drops_the_derived_sections() {
1725        use crate::index::{
1726            GUNNAR_GRAPH_MODULE, GUNNAR_REFS_MODULE, GUNNAR_SECRETS_MODULE,
1727        };
1728        use crate::meta_sink::{ReservedSection, ReservedSectionBuilder};
1729
1730        let dir = unique_dir("carried_reserved");
1731        let archive = dir.join("a.znippy");
1732
1733        let refs_bytes = b"refs/heads/main 0123456789abcdef -- push 1".to_vec();
1734        let secrets_bytes = b"\x00ciphertext-only, never plaintext".to_vec();
1735        let graph_bytes = b"a commit graph derived from the objects".to_vec();
1736
1737        // Seal an archive carrying one independent log, one secrets log and one
1738        // DERIVED section, through the same builder gunnar's cold tier uses.
1739        {
1740            let f = Arc::new(File::create(&archive).unwrap());
1741            let mut cursor = 0u64;
1742            let mut blobs = Vec::new();
1743            for (i, (name, bytes)) in [("obj/a.bin", b"first".to_vec())].iter().enumerate() {
1744                use std::os::unix::fs::FileExt;
1745                f.write_all_at(bytes, cursor).unwrap();
1746                blobs.push(BlobMeta {
1747                    blob_offset: cursor,
1748                    blob_size: bytes.len() as u64,
1749                    chunk_meta: ChunkMeta {
1750                        fdata_offset: 0,
1751                        file_index: i as u64,
1752                        chunk_seq: 0,
1753                        checksum: *blake3::hash(bytes).as_bytes(),
1754                        compressed: false,
1755                        uncompressed_size: bytes.len() as u64,
1756                        compressed_size: bytes.len() as u64,
1757                    },
1758                });
1759                cursor += bytes.len() as u64;
1760                let _ = name;
1761            }
1762            let batch = crate::index::build_metadata_batch(
1763                &blobs,
1764                |_fi: u64| "obj/a.bin".to_string(),
1765                &[],
1766                &[],
1767            )
1768            .unwrap();
1769            // The sink starts AFTER the blob region, or it writes its index over
1770            // the bytes it is indexing.
1771            let mut sink = ArrowIpcSink::new(Arc::clone(&f), cursor);
1772            let (r, s, g) = (refs_bytes.clone(), secrets_bytes.clone(), graph_bytes.clone());
1773            let builder: ReservedSectionBuilder = Box::new(move |_lookup| {
1774                Ok(vec![
1775                    ReservedSection::raw(GUNNAR_REFS_MODULE, r),
1776                    ReservedSection::raw(GUNNAR_SECRETS_MODULE, s),
1777                    ReservedSection::raw(GUNNAR_GRAPH_MODULE, g),
1778                ])
1779            });
1780            sink = sink.with_reserved_builder(builder);
1781            sink.push_subindex(
1782                crate::index::lookup_schema().as_ref(),
1783                &[batch],
1784                GroupKey { pkg_type: 0, repo: String::new(), module_name: String::new() },
1785            )
1786            .unwrap();
1787            Box::new(sink).finish().unwrap();
1788        }
1789
1790        // Sanity: all three are there before the append, or this proves nothing.
1791        for m in [GUNNAR_REFS_MODULE, GUNNAR_SECRETS_MODULE, GUNNAR_GRAPH_MODULE] {
1792            assert!(
1793                read_reserved_section_bytes(&archive, m).unwrap().is_some(),
1794                "{m} must be present BEFORE the append"
1795            );
1796        }
1797
1798        // An object-carrying push.
1799        append_files(&archive, &[("obj/b.bin".to_string(), b"second".to_vec())], 3).unwrap();
1800
1801        // The independent logs survive, byte for byte.
1802        assert_eq!(
1803            read_reserved_section_bytes(&archive, GUNNAR_REFS_MODULE).unwrap(),
1804            Some(refs_bytes),
1805            "an object-carrying push must not erase the ref log — this is the 2026-08-04 bug"
1806        );
1807        assert_eq!(
1808            read_reserved_section_bytes(&archive, GUNNAR_SECRETS_MODULE).unwrap(),
1809            Some(secrets_bytes),
1810        );
1811        // The DERIVED section is gone, because the objects changed under it.
1812        assert_eq!(
1813            read_reserved_section_bytes(&archive, GUNNAR_GRAPH_MODULE).unwrap(),
1814            None,
1815            "a commit graph derived from the old object set must NOT be carried forward"
1816        );
1817
1818        // And the archive is still an archive: both entries read back.
1819        let reader = ZnippyArchive::open(&archive).unwrap();
1820        assert_eq!(reader.extract_file("obj/a.bin").unwrap(), b"first".to_vec());
1821        assert_eq!(reader.extract_file("obj/b.bin").unwrap(), b"second".to_vec());
1822
1823        let _ = std::fs::remove_dir_all(&dir);
1824    }
1825
1826
1827    /// **The writer, end to end: a superseded entry becomes a delta and still
1828    /// reads back byte-exact.**
1829    ///
1830    /// Two "generations" that share most of their bytes. After
1831    /// `supersede_as_delta`, generation N is a delta chunk against N+1 and N+1 is
1832    /// untouched — which is the direction that matters, because N+1 is the one a
1833    /// clone copies entries out of.
1834    #[test]
1835    fn a_superseded_entry_becomes_a_delta_and_reads_back_exactly() {
1836        let dir = unique_dir("supersede");
1837        let archive = dir.join("a.znippy");
1838
1839        // Incompressible base bytes, so the saving measured is the DELTA's and
1840        // not the codec's — the whole point of the exercise.
1841        let mut st = 0x243f_6a88_85a3_08d3u64;
1842        let gen_n: Vec<u8> = (0..400_000u32)
1843            .map(|_| {
1844                st = st.wrapping_add(0x9e37_79b9_7f4a_7c15);
1845                let mut z = st;
1846                z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1847                (z ^ (z >> 27)) as u8
1848            })
1849            .collect();
1850        // N+1 keeps N's bytes and appends a little, as a later pack does.
1851        let mut gen_n1 = gen_n.clone();
1852        gen_n1.extend_from_slice(&gen_n[..20_000]);
1853
1854        create_archive(
1855            &archive,
1856            &[
1857                ("pack-N.pack".to_string(), gen_n.clone()),
1858                ("pack-N1.pack".to_string(), gen_n1.clone()),
1859            ],
1860            3,
1861        )
1862        .unwrap();
1863        let before = std::fs::metadata(&archive).unwrap().len();
1864
1865        let out = supersede_as_delta(&archive, "pack-N.pack", "pack-N1.pack", 0, 3).unwrap();
1866        let (stored, delta_bytes) = match out {
1867            SupersedeOutcome::Delta { stored_bytes, delta_bytes, chain_depth } => {
1868                assert_eq!(chain_depth, 1);
1869                (stored_bytes, delta_bytes)
1870            }
1871            other => panic!("expected a delta, got {other:?}"),
1872        };
1873        assert_eq!(stored, gen_n.len() as u64);
1874        assert!(
1875            delta_bytes * 20 < stored,
1876            "N is a near-prefix of N+1, so the delta must be a small fraction of it; \
1877             got {delta_bytes} against {stored}"
1878        );
1879
1880        // THE ASSERTION THAT MATTERS: both entries still read back byte-exact,
1881        // and the delta'd one through the delta path.
1882        let ar = ZnippyArchive::open(&archive).unwrap();
1883        assert_eq!(ar.extract_file("pack-N.pack").unwrap(), gen_n, "the superseded generation");
1884        assert_eq!(ar.extract_file("pack-N1.pack").unwrap(), gen_n1, "the live generation");
1885        assert_eq!(ar.extract_file_verified("pack-N.pack").unwrap(), gen_n);
1886        assert_eq!(ar.file_size("pack-N.pack"), Some(gen_n.len() as u64));
1887
1888        // The live generation is still a WHOLE entry. If it were not, `P-001`'s
1889        // copy path would be reading through a delta chain, which is the one
1890        // outcome this direction exists to prevent.
1891        assert!(
1892            read_delta_map(&archive)
1893                .unwrap()
1894                .iter()
1895                .all(|(p, _, _)| p == "pack-N.pack"),
1896            "only the superseded generation may be a delta"
1897        );
1898
1899        let _ = before;
1900        let _ = std::fs::remove_dir_all(&dir);
1901    }
1902
1903    /// The two refusals, both stated rather than silent.
1904    #[test]
1905    fn the_writer_refuses_a_pointless_delta_and_an_over_long_chain() {
1906        let dir = unique_dir("supersede_refuse");
1907        let archive = dir.join("a.znippy");
1908        let mut st = 0x1234_5678_9abc_def0u64;
1909        let noise = |n: usize, st: &mut u64| -> Vec<u8> {
1910            (0..n)
1911                .map(|_| {
1912                    *st = st.wrapping_add(0x9e37_79b9_7f4a_7c15);
1913                    let mut z = *st;
1914                    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1915                    (z ^ (z >> 27)) as u8
1916                })
1917                .collect()
1918        };
1919        let a = noise(60_000, &mut st);
1920        let b = noise(60_000, &mut st); // unrelated: no delta can win
1921        create_archive(
1922            &archive,
1923            &[("a.pack".to_string(), a.clone()), ("b.pack".to_string(), b.clone())],
1924            3,
1925        )
1926        .unwrap();
1927
1928        match supersede_as_delta(&archive, "a.pack", "b.pack", 0, 3).unwrap() {
1929            SupersedeOutcome::NotSmaller { .. } => {}
1930            other => panic!("unrelated bytes must not produce a delta, got {other:?}"),
1931        }
1932        // Untouched, and still readable.
1933        let ar = ZnippyArchive::open(&archive).unwrap();
1934        assert_eq!(ar.extract_file("a.pack").unwrap(), a);
1935        assert!(read_delta_map(&archive).unwrap().is_empty());
1936
1937        // And the chain bound, which is a WRITER policy: it declines rather than
1938        // building a chain the reader would later refuse.
1939        assert_eq!(
1940            supersede_as_delta(&archive, "a.pack", "b.pack", MAX_GENERATION_CHAIN, 3).unwrap(),
1941            SupersedeOutcome::ChainTooLong
1942        );
1943        assert!(supersede_as_delta(&archive, "a.pack", "a.pack", 0, 3).is_err());
1944
1945        let _ = std::fs::remove_dir_all(&dir);
1946    }
1947
1948    /// **A chain of generations, and every one of them still exact.**
1949    ///
1950    /// Four generations, each superseded against its successor as it arrives, so
1951    /// the oldest sits at depth 3 and the newest is whole. This is the shape a
1952    /// repository `gc`'d repeatedly produces, and the property is that reading
1953    /// generation 0 walks three links and still returns the bytes that were
1954    /// stored.
1955    #[test]
1956    fn a_generation_chain_reads_every_generation_back_exactly() {
1957        let dir = unique_dir("genchain");
1958        let archive = dir.join("g.znippy");
1959        let mut st = 0x0f0f_0f0f_dead_beefu64;
1960        let mut gens: Vec<Vec<u8>> = Vec::new();
1961        let mut cur: Vec<u8> = (0..120_000u32)
1962            .map(|_| {
1963                st = st.wrapping_add(0x9e37_79b9_7f4a_7c15);
1964                let mut z = st;
1965                z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1966                (z ^ (z >> 27)) as u8
1967            })
1968            .collect();
1969        gens.push(cur.clone());
1970        for g in 1..4 {
1971            cur.extend_from_slice(format!("generation {g} tail ").repeat(200).as_bytes());
1972            gens.push(cur.clone());
1973        }
1974        let files: Vec<(String, Vec<u8>)> = gens
1975            .iter()
1976            .enumerate()
1977            .map(|(i, b)| (format!("pack-{i}.pack"), b.clone()))
1978            .collect();
1979        create_archive(&archive, &files, 3).unwrap();
1980
1981        // Supersede each older generation against the one after it, oldest last,
1982        // so each base is whole at the moment it is used.
1983        for i in (0..3).rev() {
1984            let out = supersede_as_delta(
1985                &archive,
1986                &format!("pack-{i}.pack"),
1987                &format!("pack-{}.pack", i + 1),
1988                3 - 1 - i,
1989                3,
1990            )
1991            .unwrap();
1992            assert!(matches!(out, SupersedeOutcome::Delta { .. }), "gen {i}: {out:?}");
1993        }
1994
1995        let ar = ZnippyArchive::open(&archive).unwrap();
1996        for (i, want) in gens.iter().enumerate() {
1997            assert_eq!(
1998                &ar.extract_file(&format!("pack-{i}.pack")).unwrap(),
1999                want,
2000                "generation {i} at chain depth {}",
2001                3 - i
2002            );
2003        }
2004        // The newest is whole: nothing in the map names it.
2005        assert!(
2006            read_delta_map(&archive)
2007                .unwrap()
2008                .iter()
2009                .all(|(p, _, _)| p != "pack-3.pack"),
2010            "the live generation must stay whole"
2011        );
2012        let _ = std::fs::remove_dir_all(&dir);
2013    }
2014
2015
2016    /// **What the writer costs and what it saves, on a REAL generation chain.**
2017    ///
2018    /// `#[ignore]`d: it wants real `gc` generations on disk. `ZNIPPY_CHAIN_DIR`
2019    /// names a directory of `pack-<i>.pack`, `i` ascending, oldest first.
2020    #[test]
2021    #[ignore]
2022    fn perf_real_generation_chain() {
2023        let Ok(d) = std::env::var("ZNIPPY_CHAIN_DIR") else { return };
2024        let dir = unique_dir("realchain");
2025        let archive = dir.join("g.znippy");
2026        let mut files: Vec<(String, Vec<u8>)> = Vec::new();
2027        for i in 0.. {
2028            let p = std::path::Path::new(&d).join(format!("pack-{i}.pack"));
2029            if !p.is_file() {
2030                break;
2031            }
2032            files.push((format!("pack-{i}.pack"), std::fs::read(&p).unwrap()));
2033        }
2034        let n = files.len();
2035        let raw: u64 = files.iter().map(|(_, b)| b.len() as u64).sum();
2036
2037        let t0 = std::time::Instant::now();
2038        create_archive(&archive, &files, 3).unwrap();
2039        let seal_ms = t0.elapsed().as_secs_f64() * 1e3;
2040        let sealed = std::fs::metadata(&archive).unwrap().len();
2041
2042        // Supersede oldest-last, so each base is whole when it is used.
2043        println!("gen,stored_b,delta_b,ratio_x,supersede_ms");
2044        let mut encode_ms_total = 0.0;
2045        let mut delta_total = 0u64;
2046        for i in (0..n - 1).rev() {
2047            let t = std::time::Instant::now();
2048            let out = supersede_as_delta(
2049                &archive,
2050                &format!("pack-{i}.pack"),
2051                &format!("pack-{}.pack", i + 1),
2052                n - 2 - i,
2053                3,
2054            )
2055            .unwrap();
2056            let ms = t.elapsed().as_secs_f64() * 1e3;
2057            encode_ms_total += ms;
2058            match out {
2059                SupersedeOutcome::Delta { stored_bytes, delta_bytes, .. } => {
2060                    delta_total += delta_bytes;
2061                    println!(
2062                        "{i},{stored_bytes},{delta_bytes},{:.2},{ms:.0}",
2063                        stored_bytes as f64 / delta_bytes as f64
2064                    );
2065                }
2066                other => println!("{i},-,-,-,{ms:.0} ({other:?})"),
2067            }
2068        }
2069
2070        // A rewrite reclaims the superseded blobs. Measured by rebuilding the
2071        // archive from what it now holds, which is what a compaction would do.
2072        let ar = ZnippyArchive::open(&archive).unwrap();
2073        let mut live: Vec<(String, Vec<u8>)> = Vec::new();
2074        let t = std::time::Instant::now();
2075        for i in 0..n {
2076            let name = format!("pack-{i}.pack");
2077            let got = ar.extract_file(&name).unwrap();
2078            assert_eq!(got, files[i].1, "generation {i} did not read back exactly");
2079            live.push((name, got));
2080        }
2081        let read_all_ms = t.elapsed().as_secs_f64() * 1e3;
2082
2083        // Per-generation read cost, by chain depth.
2084        println!("gen,depth,read_ms");
2085        for i in 0..n {
2086            let name = format!("pack-{i}.pack");
2087            let t = std::time::Instant::now();
2088            for _ in 0..5 {
2089                let _ = ar.extract_file(&name).unwrap();
2090            }
2091            println!("{i},{},{:.2}", n - 1 - i, t.elapsed().as_secs_f64() * 1e3 / 5.0);
2092        }
2093
2094        let compacted = dir.join("c.znippy");
2095        let mut packed: Vec<(String, Vec<u8>)> = Vec::new();
2096        for i in 0..n {
2097            packed.push(live[i].clone());
2098        }
2099        // The compacted size is what the format would cost steady-state: the live
2100        // generation whole plus one delta per superseded one, and nothing else.
2101        let overhead = sealed - raw;
2102        let steady = files[n - 1].1.len() as u64 + delta_total + overhead;
2103        println!(
2104            "SUMMARY generations={n} raw={raw} sealed={sealed} live_whole={} deltas={delta_total} \
2105             steady_state={steady} saving_x={:.2} seal_ms={seal_ms:.0} \
2106             supersede_ms_total={encode_ms_total:.0} read_all_ms={read_all_ms:.0}",
2107            files[n - 1].1.len(),
2108            raw as f64 / steady as f64
2109        );
2110        let _ = (compacted, packed);
2111        let _ = std::fs::remove_dir_all(&dir);
2112    }
2113
2114    #[test]
2115    fn archive_reader_matches_free_functions() {
2116        let dir = unique_dir("reader");
2117        let archive = dir.join("store.znippy");
2118        let files = synth(600, 13);
2119        write_fresh(&archive, &files, ArrowIpcSink::new);
2120
2121        let reader = crate::ArchiveReader::open(&archive).unwrap();
2122        assert_eq!(reader.row_count(), files.len(), "one chunk per synth file");
2123
2124        // Present files: cached locate == free-function locate.
2125        for (p, _) in files.iter().step_by(37) {
2126            let cached = reader.locate(p);
2127            let free = crate::locate_file(&archive, p).unwrap();
2128            assert!(!cached.is_empty(), "cached reader failed to locate {p}");
2129            assert_eq!(cached, free, "cached vs free locate diverged for {p}");
2130        }
2131
2132        // Absent file: both return empty.
2133        let missing = "repo/does/not/exist.bin";
2134        assert!(reader.locate(missing).is_empty());
2135        assert!(crate::locate_file(&archive, missing).unwrap().is_empty());
2136
2137        // Whole-archive metadata parity.
2138        assert_eq!(
2139            reader.files_meta(),
2140            crate::get_all_files_meta(&archive).unwrap(),
2141            "cached files_meta diverged from get_all_files_meta"
2142        );
2143
2144        // Prefix window parity (synth spreads paths across repo/grpNNN/).
2145        for prefix in ["repo/grp001/", "repo/grp0", "repo/", ""] {
2146            assert_eq!(
2147                reader.files_meta_with_prefix(prefix),
2148                crate::get_files_meta_with_prefix(&archive, prefix).unwrap(),
2149                "cached prefix meta diverged for {prefix:?}"
2150            );
2151        }
2152
2153        let _ = std::fs::remove_dir_all(&dir);
2154    }
2155
2156    /// **A supersede does not shrink the file, and this is what does.**
2157    ///
2158    /// The first assertion is the fact the writer's own numbers do not state:
2159    /// after `supersede_as_delta` the archive is no smaller — the superseded
2160    /// blob is still in it, unreferenced. The second is that `compact_archive`
2161    /// removes it and that every entry, delta chunks included, reads back exactly
2162    /// the same bytes at the same depth afterwards.
2163    ///
2164    /// Seen RED three ways, each restored:
2165    ///
2166    /// * `sink.delta_map = Vec::new()` instead of carrying it — the compacted
2167    ///   archive hands back generation 0's delta instruction stream (11 bytes)
2168    ///   as its content and the byte comparison fires.
2169    /// * copying `loc` unchanged instead of moving `blob_offset` — every entry
2170    ///   reads garbage from the wrong offset.
2171    /// * skipping the rename — the size assertion fires, because the original
2172    ///   file is still the one on disk.
2173    #[test]
2174    fn compaction_reclaims_the_superseded_blob_and_changes_no_entry() {
2175        let dir = unique_dir("compact");
2176        let archive = dir.join("c.znippy");
2177
2178        let mut st = 0x5151_2323_abcd_ef01u64;
2179        let base: Vec<u8> = (0..600_000u32)
2180            .map(|_| {
2181                st = st.wrapping_add(0x9e37_79b9_7f4a_7c15);
2182                let mut z = st;
2183                z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
2184                (z ^ (z >> 27)) as u8
2185            })
2186            .collect();
2187        let mut gens: Vec<Vec<u8>> = vec![base.clone()];
2188        for g in 1..4 {
2189            let mut next = gens[g - 1].clone();
2190            next.extend_from_slice(format!("generation {g} tail ").repeat(300).as_bytes());
2191            gens.push(next);
2192        }
2193        let files: Vec<(String, Vec<u8>)> = gens
2194            .iter()
2195            .enumerate()
2196            .map(|(i, b)| (format!("pack-{i}.pack"), b.clone()))
2197            .collect();
2198        create_archive(&archive, &files, 3).unwrap();
2199        let raw: u64 = gens.iter().map(|g| g.len() as u64).sum();
2200        let whole = std::fs::metadata(&archive).unwrap().len();
2201
2202        for i in (0..3).rev() {
2203            let out = supersede_as_delta(
2204                &archive,
2205                &format!("pack-{i}.pack"),
2206                &format!("pack-{}.pack", i + 1),
2207                3 - 1 - i,
2208                3,
2209            )
2210            .unwrap();
2211            assert!(matches!(out, SupersedeOutcome::Delta { .. }), "gen {i}: {out:?}");
2212        }
2213
2214        // The finding, as an assertion. Three of four generations are now
2215        // deltas of a few hundred bytes each, and the file is not smaller.
2216        let after_supersede = std::fs::metadata(&archive).unwrap().len();
2217        assert!(
2218            after_supersede >= whole,
2219            "supersede shrank the file ({whole} -> {after_supersede}); if that is now true, this \
2220             test and everything built on the dead-payload finding wants revisiting"
2221        );
2222
2223        let report = compact_archive(&archive).unwrap();
2224        let compacted = std::fs::metadata(&archive).unwrap().len();
2225        assert_eq!(report.bytes_after, compacted);
2226        assert_eq!(report.rows, 4, "a compaction must not change the row count");
2227        assert_eq!(report.delta_rows, 3, "the delta map must travel across it");
2228        assert!(
2229            compacted * 2 < raw,
2230            "the compacted archive is {compacted} bytes for {raw} bytes of generations — the dead \
2231             payload was not reclaimed"
2232        );
2233
2234        // Every generation, at every depth, byte for byte.
2235        let ar = ZnippyArchive::open(&archive).unwrap();
2236        for (i, want) in gens.iter().enumerate() {
2237            assert_eq!(
2238                &ar.extract_file(&format!("pack-{i}.pack")).unwrap(),
2239                want,
2240                "generation {i} did not survive the compaction"
2241            );
2242        }
2243        // The live generation is still whole, and the map still says so.
2244        let map = read_delta_map(&archive).unwrap();
2245        assert!(
2246            map.iter().all(|(p, _, _)| p != "pack-3.pack"),
2247            "compaction must not put the live generation behind a link: {map:?}"
2248        );
2249
2250        // Idempotent: a second compaction of a compact archive changes nothing
2251        // it should not, which is what a `gc` on a timer depends on.
2252        let again = compact_archive(&archive).unwrap();
2253        assert_eq!(again.rows, 4);
2254        assert_eq!(again.delta_rows, 3);
2255        let ar = ZnippyArchive::open(&archive).unwrap();
2256        for (i, want) in gens.iter().enumerate() {
2257            assert_eq!(&ar.extract_file(&format!("pack-{i}.pack")).unwrap(), want);
2258        }
2259
2260        let _ = std::fs::remove_dir_all(&dir);
2261    }
2262
2263    /// A compaction must carry the independent reserved logs too. gunnar's
2264    /// `__gunnar_refs__` is the section this archive format has already lost once
2265    /// (`25850cd`), and a rewrite is the same opportunity to lose it.
2266    #[test]
2267    fn compaction_carries_the_reserved_logs() {
2268        use crate::index::GUNNAR_REFS_MODULE;
2269        use crate::meta_sink::{ReservedSection, ReservedSectionBuilder};
2270
2271        let dir = unique_dir("compact_reserved");
2272        let archive = dir.join("r.znippy");
2273        let refs_bytes = b"refs/heads/main 0123456789abcdef".to_vec();
2274
2275        {
2276            use std::os::unix::fs::FileExt;
2277            let f = Arc::new(File::create(&archive).unwrap());
2278            let payload = b"an entry".to_vec();
2279            f.write_all_at(&payload, 0).unwrap();
2280            let blobs = vec![BlobMeta {
2281                blob_offset: 0,
2282                blob_size: payload.len() as u64,
2283                chunk_meta: ChunkMeta {
2284                    fdata_offset: 0,
2285                    file_index: 0,
2286                    chunk_seq: 0,
2287                    checksum: *blake3::hash(&payload).as_bytes(),
2288                    compressed: false,
2289                    uncompressed_size: payload.len() as u64,
2290                    compressed_size: payload.len() as u64,
2291                },
2292            }];
2293            let batch =
2294                crate::index::build_metadata_batch(&blobs, |_| "obj/a.bin".to_string(), &[], &[])
2295                    .unwrap();
2296            let mut sink = ArrowIpcSink::new(Arc::clone(&f), payload.len() as u64);
2297            let carried = refs_bytes.clone();
2298            let builder: ReservedSectionBuilder =
2299                Box::new(move |_lookup| Ok(vec![ReservedSection::raw(GUNNAR_REFS_MODULE, carried)]));
2300            sink = sink.with_reserved_builder(builder);
2301            sink.push_subindex(
2302                crate::index::lookup_schema().as_ref(),
2303                &[batch],
2304                GroupKey {
2305                    pkg_type: 0,
2306                    repo: String::new(),
2307                    module_name: String::new(),
2308                },
2309            )
2310            .unwrap();
2311            Box::new(sink).finish().unwrap();
2312        }
2313        assert_eq!(
2314            read_reserved_section_bytes(&archive, GUNNAR_REFS_MODULE).unwrap(),
2315            Some(refs_bytes.clone())
2316        );
2317
2318        compact_archive(&archive).unwrap();
2319        assert_eq!(
2320            read_reserved_section_bytes(&archive, GUNNAR_REFS_MODULE).unwrap(),
2321            Some(refs_bytes),
2322            "the compaction dropped __gunnar_refs__"
2323        );
2324        let _ = std::fs::remove_dir_all(&dir);
2325    }
2326}
2327
2328/// Decode an Arrow-IPC sub-index stream of the base schema into parallel
2329/// `(paths, locs)` vectors (mirrors `index::decode_lookup`, kept here so the
2330/// original stays untouched).
2331pub(crate) fn decode_base_rows(bytes: &[u8]) -> Result<(Vec<String>, Vec<ChunkLoc>)> {
2332    use arrow::ipc::reader::StreamReader;
2333
2334    let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
2335        .map_err(|e| anyhow!("append: lookup ipc reader: {e}"))?;
2336    let mut paths = Vec::new();
2337    let mut locs = Vec::new();
2338    for batch in reader {
2339        let batch = batch.map_err(|e| anyhow!("append: lookup batch decode: {e}"))?;
2340        let get = |n: &str| batch.column_by_name(n)
2341            .ok_or_else(|| anyhow!("append: lookup missing column {n}"));
2342        let p = get("relative_path")?.as_any().downcast_ref::<StringArray>()
2343            .ok_or_else(|| anyhow!("relative_path type"))?;
2344        let seq = get("chunk_seq")?.as_any().downcast_ref::<UInt32Array>()
2345            .ok_or_else(|| anyhow!("chunk_seq type"))?;
2346        let fdata = get("fdata_offset")?.as_any().downcast_ref::<UInt64Array>()
2347            .ok_or_else(|| anyhow!("fdata_offset type"))?;
2348        let comp = get("compressed")?.as_any().downcast_ref::<BooleanArray>()
2349            .ok_or_else(|| anyhow!("compressed type"))?;
2350        let usz = get("uncompressed_size")?.as_any().downcast_ref::<UInt64Array>()
2351            .ok_or_else(|| anyhow!("uncompressed_size type"))?;
2352        let boff = get("blob_offset")?.as_any().downcast_ref::<UInt64Array>()
2353            .ok_or_else(|| anyhow!("blob_offset type"))?;
2354        let bsz = get("blob_size")?.as_any().downcast_ref::<UInt64Array>()
2355            .ok_or_else(|| anyhow!("blob_size type"))?;
2356        let ck = get("checksum")?.as_any().downcast_ref::<FixedSizeBinaryArray>()
2357            .ok_or_else(|| anyhow!("checksum type"))?;
2358        for i in 0..batch.num_rows() {
2359            let mut c = [0u8; 32];
2360            c.copy_from_slice(ck.value(i));
2361            paths.push(p.value(i).to_string());
2362            locs.push(ChunkLoc {
2363                chunk_seq: seq.value(i),
2364                fdata_offset: fdata.value(i),
2365                blob_offset: boff.value(i),
2366                blob_size: bsz.value(i),
2367                uncompressed_size: usz.value(i),
2368                compressed: comp.value(i),
2369                checksum: c,
2370            });
2371        }
2372    }
2373    Ok((paths, locs))
2374}