Skip to main content

znippy_common/
hot.rs

1//! `HotArchive` — the **dynamic half** of a znippy archive: append blobs and
2//! index rows at O(1) per append, and pay the static seal exactly once.
3//!
4//! # The defect this exists to remove, measured
5//!
6//! [`append_files`](crate::append_files) re-seals the *whole* metadata tail on
7//! every call: [`ArrowIpcSinkAppend::open_existing`](crate::ArrowIpcSinkAppend::open_existing)
8//! decodes every pre-existing row out of the lookup section, `finish()` re-emits
9//! them as a data sub-index, re-sorts all of them, rebuilds the fst trie over all
10//! of them, and rewrites manifest and footer. So one append costs O(rows already
11//! in the archive) and N appends cost **O(N²)**.
12//!
13//! That is not a theory. **MEASURED on oden 2026-08-04**, loadavg 14–17, 32
14//! cores / 512 GB, release build, archive on **md1** (`/dev/md1`, not tmpfs — so
15//! the two fsyncs per append are real), one archive, two entries appended per
16//! call (a `pack-*.pack` and its `.idx`, ~1.5 KiB together),
17//! `SkipPolicy::already_compressed()`. Same fixture, same rungs, A/B in one
18//! binary:
19//!
20//! | appends | CPU/append, re-seal | CPU/append, hot | wall total, re-seal | wall total, hot |
21//! |---|---|---|---|---|
22//! | 800 | 1.88 ms | — | | |
23//! | 2 000 | 2.66 ms | 0.075 ms | | |
24//! | 4 000 | 5.38 ms | 0.075 ms | | |
25//! | 8 000 | **10.18 ms** | **0.060 ms** | **61.16 s** | **2.29 s** |
26//! | 32 000 | (not run) | 0.061 ms | | 5.62 s |
27//!
28//! Re-seal's per-append cost doubles every time N doubles — a straight line
29//! through the origin, the signature of a quadratic total. The hot path's is
30//! **flat from 2 000 to 32 000 appends**, which is the claim. At 8 000 appends
31//! that is **26.7× on wall clock and 170× on CPU per append**, and both ratios
32//! grow with N because only one side of them grows.
33//!
34//! INFERRED, not measured: extrapolating re-seal's line, the 100 000th append
35//! costs ~127 ms and getting there costs ~1.8 h of CPU. The hot path's 100 000th
36//! append costs what its 2 000th did.
37//!
38//! The seal is the once-only price: **0.08 s of CPU for 64 000 rows**. At 8 000
39//! appends the two paths land on the same archive — 15 929 871 B sealed from hot
40//! against 15 932 909 B re-sealed, the 3 038 B being the seed entry
41//! `append_files` needs and the hot path does not (it can start from nothing).
42//!
43//! # The two-phase shape
44//!
45//! ```text
46//!   HOT                                        COLD
47//!   foo.znippy        blobs only, pure append   foo.znippy   [blobs][index][lookup][trie][manifest][footer]
48//!   foo.znippy.hot.NNNNN   Arrow IPC journal    (journal removed)
49//! ```
50//!
51//! * [`HotArchive::append`] writes the blob bytes past the blob cursor and
52//!   *one* Arrow IPC RecordBatch of the new index rows to the journal. It never
53//!   reads, sorts or rewrites a row it wrote earlier, so its cost depends only on
54//!   what is being appended. **O(1) in the archive's size.**
55//! * [`HotArchive::seal`] pays the static cost once: one sorted lookup, one fst
56//!   trie, one manifest, one footer.
57//!
58//! This is znippy's own `znippy-iceberg` architecture — dynamic metadata store,
59//! then `seal()` freezing it into the static `.znippy` with the blobs copied
60//! verbatim — with the metadata store made local and cheap instead of an Iceberg
61//! warehouse. Iceberg's `fast_append` rewrites its manifest list on every commit,
62//! which is the same O(N²) in a different format.
63//!
64//! # Why the sealed output is byte-identical to `create_archive`
65//!
66//! The blobs are written in the same order at the same offsets, and the seal
67//! pushes exactly one data sub-index carrying the rows in insertion order —
68//! literally the same call [`create_archive`](crate::create_archive) makes. So
69//! *appending N files one at a time and sealing* produces the **same bytes** as
70//! *creating the archive from all N at once*. `seal_matches_create_archive_byte_for_byte`
71//! asserts that on real archives rather than asserting a row count, because a
72//! seal that silently dropped the trie would still be able to report the right
73//! number of rows.
74//!
75//! # Journal segments, and why there is more than one file
76//!
77//! An Arrow IPC *stream* begins with a schema message, so appending batches to
78//! one across process restarts is not possible without re-emitting the schema —
79//! 424 B of flatbuffer metadata on a four-row batch, paid per push. Instead each
80//! **process** that opens the archive for append starts a new segment
81//! `foo.znippy.hot.NNNNN`; the schema is written once per segment, and a push
82//! costs one RecordBatch message. Segment count grows with restarts, not with
83//! pushes.
84//!
85//! A torn segment (killed mid-write) is read up to its last complete message and
86//! the rest is discarded, which is what a log reader must do; the blob bytes
87//! whose rows were lost stay in the file as dead payload the seal drops, exactly
88//! like [`AppendReport::rows_replaced`](crate::AppendReport::rows_replaced).
89//!
90//! # Ordering, so a row never points at a blob that is not there
91//!
92//! `append` fsyncs the blob bytes **before** the journal batch that references
93//! them. A crash between the two loses the append and nothing else; a crash the
94//! other way round would leave an index row pointing into a hole.
95
96use std::collections::HashMap;
97use std::fs::File;
98use std::os::unix::fs::FileExt;
99use std::path::{Path, PathBuf};
100use std::sync::Arc;
101
102use anyhow::{Result, anyhow, bail};
103use arrow::ipc::reader::StreamReader;
104use arrow::ipc::writer::StreamWriter;
105
106use crate::archive::ZnippyReader;
107use crate::codec::CompressCtx;
108use crate::index::{ChunkLoc, data_subindex_schema};
109use crate::meta_sink::{ArchiveMetaSink, ArrowIpcSink, GroupKey, ReservedSectionBuilder};
110use crate::meta_sink_append::{base_batch_from_rows, decode_base_rows, write_blobs};
111
112/// Suffix of a journal segment: `<archive>.hot.<NNNNN>`.
113const JOURNAL_INFIX: &str = ".hot.";
114
115/// What one [`HotArchive::append`] did. Deliberately the same three counts
116/// [`AppendReport`](crate::AppendReport) reports, so a caller can swap paths
117/// without re-reading its own accounting.
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub struct HotAppendReport {
120    /// Index rows written by this append (one per chunk).
121    pub rows_added: u64,
122    /// Rows already in the journal that this append superseded, by
123    /// `relative_path`. Their blob bytes stay in the file as dead payload.
124    pub rows_replaced: u64,
125    /// Byte offset the appended blob region started at.
126    pub blob_append_offset: u64,
127    /// Blob payload bytes written (compressed or stored raw).
128    pub blob_bytes_added: u64,
129    /// Bytes the journal batch cost — the index-side price of this append.
130    pub journal_bytes_added: u64,
131}
132
133/// What one [`HotArchive::seal`] did.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct HotSealReport {
136    pub archive: PathBuf,
137    /// Rows carried out of the journal into the static index.
138    pub rows_sealed: u64,
139    /// Journal segments removed.
140    pub segments_removed: usize,
141    /// Journal bytes reclaimed.
142    pub journal_bytes_reclaimed: u64,
143    /// Final size of the sealed archive.
144    pub sealed_total_bytes: u64,
145}
146
147/// A znippy archive in its **hot** phase: blobs appended in place, index rows in
148/// a journal beside it.
149///
150/// [`open`](Self::open) reads the journal once (O(rows)); every [`append`](Self::append)
151/// after that is O(what is appended). Holding the rows in memory is what buys
152/// that, and it is the same set of rows the old path decoded *per append*.
153pub struct HotArchive {
154    archive: PathBuf,
155    blobs: Arc<File>,
156    blob_end: u64,
157    /// Every live row, in insertion order. The seal emits exactly this.
158    paths: Vec<String>,
159    locs: Vec<ChunkLoc>,
160    /// `relative_path` → index into `paths`/`locs`, for replace semantics.
161    by_path: HashMap<String, usize>,
162    /// The segment this process appends to, schema already written.
163    journal: StreamWriter<File>,
164    journal_path: PathBuf,
165    journal_len: u64,
166    /// Long-lived, per Design Law 2 — a codec context built per push would put
167    /// the setup cost back into the hot path this module exists to empty. Behind
168    /// a `Mutex` only because `ZnippyReader` is `Sync` and the raw OpenZL context
169    /// is not; `append` takes `&mut self`, so the lock is never contended.
170    ctx: std::sync::Mutex<CompressCtx>,
171    policy: crate::SkipPolicy,
172}
173
174impl HotArchive {
175    /// Open `archive` for hot appends, creating it if absent.
176    ///
177    /// Three entry states, all handled here so no caller has to know which it is:
178    ///
179    /// * **already hot** — journal segments exist; their rows are loaded and the
180    ///   blob cursor is taken from the last one.
181    /// * **sealed** — a static `.znippy`; its rows are recovered into a fresh
182    ///   segment and the (rebuildable) metadata tail is dropped. O(rows), once.
183    /// * **absent** — a new archive at blob offset 0.
184    pub fn open(archive: &Path, level: i32, policy: crate::SkipPolicy) -> Result<Self> {
185        let archive = archive.to_path_buf();
186        let segments = journal_segments(&archive)?;
187
188        let (mut paths, mut locs) = (Vec::new(), Vec::new());
189        let mut reheated = false;
190
191        if !segments.is_empty() {
192            for (_, seg) in &segments {
193                let (p, l) = read_segment(seg)?;
194                paths.extend(p);
195                locs.extend(l);
196            }
197        } else if archive.is_file() && crate::index::read_znippy_full_manifest(&archive).is_ok() {
198            // Sealed → hot. Recover the rows out of the static index, then drop
199            // the tail. The journal is made durable BEFORE the truncation, so a
200            // crash in between leaves the rows recoverable and the stale tail is
201            // simply overwritten by the next append (and `set_len` below).
202            let (entries, _) = crate::index::read_znippy_full_manifest(&archive)?;
203            let (p, l) = crate::meta_sink_append::recover_rows(&archive, &entries)?;
204            paths = p;
205            locs = l;
206            reheated = true;
207        }
208
209        // Dedup to live rows: last writer for a path wins, insertion order kept.
210        let (paths, locs) = live_rows(paths, locs);
211
212        // The blob cursor is the end of the last live blob — NOT the file length,
213        // which on the sealed path still includes the metadata tail and after a
214        // torn append may include payload no row references.
215        let blob_end = locs
216            .iter()
217            .map(|l| l.blob_offset + l.blob_size)
218            .max()
219            .unwrap_or(0);
220
221        let next = segments.last().map(|(n, _)| n + 1).unwrap_or(0);
222        let journal_path = segment_path(&archive, next);
223        let mut journal = new_segment(&journal_path)?;
224        if reheated {
225            // Segment 0 of a re-heated archive carries the recovered rows, so the
226            // journal alone is a complete statement of the archive's index.
227            let batch = base_batch_from_rows(&paths, &locs)?;
228            journal
229                .write(&batch)
230                .map_err(|e| anyhow!("hot: seeding journal from sealed index: {e}"))?;
231            journal.flush().map_err(|e| anyhow!("hot: journal flush: {e}"))?;
232            journal
233                .get_ref()
234                .sync_all()
235                .map_err(|e| anyhow!("hot: journal fsync: {e}"))?;
236            sync_parent_dir(&journal_path);
237        }
238        let journal_len = std::fs::metadata(&journal_path).map(|m| m.len()).unwrap_or(0);
239
240        let blobs = Arc::new(
241            std::fs::OpenOptions::new()
242                .read(true)
243                .write(true)
244                .create(true)
245                .truncate(false)
246                .open(&archive)
247                .map_err(|e| anyhow!("hot: open {} : {e}", archive.display()))?,
248        );
249        // Drop anything past the live blob region — a stale metadata tail from
250        // the sealed path, or a torn append's orphan payload. Idempotent.
251        if blobs.metadata()?.len() > blob_end {
252            blobs.set_len(blob_end)?;
253            blobs.sync_all()?;
254        }
255
256        let mut by_path = HashMap::with_capacity(paths.len());
257        for (i, p) in paths.iter().enumerate() {
258            by_path.insert(p.clone(), i);
259        }
260
261        Ok(Self {
262            archive,
263            blobs,
264            blob_end,
265            paths,
266            locs,
267            by_path,
268            journal,
269            journal_path,
270            journal_len,
271            ctx: std::sync::Mutex::new(CompressCtx::new(level)?),
272            policy,
273        })
274    }
275
276    /// Whether `archive` currently has a journal — i.e. is in its hot phase.
277    pub fn is_hot(archive: &Path) -> bool {
278        journal_segments(archive).map(|s| !s.is_empty()).unwrap_or(false)
279    }
280
281    /// The archive path.
282    pub fn archive(&self) -> &Path {
283        &self.archive
284    }
285
286    /// Live index rows.
287    pub fn rows(&self) -> u64 {
288        self.paths.len() as u64
289    }
290
291    /// Byte offset one past the last blob.
292    pub fn blob_end(&self) -> u64 {
293        self.blob_end
294    }
295
296    /// Append `files`, paying only for `files`.
297    ///
298    /// Replace semantics match [`append_files`](crate::append_files): a
299    /// `relative_path` already present is superseded and its old blob bytes
300    /// become dead payload.
301    pub fn append(&mut self, files: &[(String, Vec<u8>)]) -> Result<HotAppendReport> {
302        if files.is_empty() {
303            return Ok(HotAppendReport {
304                blob_append_offset: self.blob_end,
305                ..Default::default()
306            });
307        }
308        let blob_append_offset = self.blob_end;
309
310        // Blobs first, and durable before any row points at them.
311        let (new_paths, new_locs, cursor) =
312            write_blobs(&self.blobs, self.blob_end, files, &mut self.ctx.lock().unwrap(), self.policy)?;
313        let blob_bytes_added = cursor - blob_append_offset;
314        self.blobs.sync_all()?;
315        self.blob_end = cursor;
316
317        // One RecordBatch, one message on the journal. No re-sort, no trie, no
318        // manifest — that is the entire difference from `append_files`.
319        let batch = base_batch_from_rows(&new_paths, &new_locs)?;
320        self.journal
321            .write(&batch)
322            .map_err(|e| anyhow!("hot: journal write: {e}"))?;
323        self.journal.flush().map_err(|e| anyhow!("hot: journal flush: {e}"))?;
324        self.journal
325            .get_ref()
326            .sync_all()
327            .map_err(|e| anyhow!("hot: journal fsync: {e}"))?;
328        let journal_len = std::fs::metadata(&self.journal_path)?.len();
329        let journal_bytes_added = journal_len - self.journal_len;
330        self.journal_len = journal_len;
331
332        let mut rows_replaced = 0u64;
333        for (p, l) in new_paths.into_iter().zip(new_locs) {
334            match self.by_path.get(&p).copied() {
335                Some(i) => {
336                    rows_replaced += 1;
337                    self.locs[i] = l;
338                }
339                None => {
340                    self.by_path.insert(p.clone(), self.paths.len());
341                    self.paths.push(p);
342                    self.locs.push(l);
343                }
344            }
345        }
346
347        Ok(HotAppendReport {
348            rows_added: batch.num_rows() as u64,
349            rows_replaced,
350            blob_append_offset,
351            blob_bytes_added,
352            journal_bytes_added,
353        })
354    }
355
356    /// Freeze the hot archive into a static `.znippy` and drop the journal.
357    ///
358    /// The blobs are already in place and are not touched: the seal writes the
359    /// metadata tail only. `reserved` is the same hook
360    /// [`ArrowIpcSink::with_reserved_builder`](crate::ArrowIpcSink::with_reserved_builder)
361    /// offers, so a caller with its own sections (gunnar's `__gunnar_refs__`)
362    /// seals them here rather than needing a second seal.
363    pub fn seal(self, reserved: Option<ReservedSectionBuilder>) -> Result<HotSealReport> {
364        let Self {
365            archive,
366            blobs,
367            blob_end,
368            paths,
369            locs,
370            journal,
371            journal_path,
372            ..
373        } = self;
374        drop(journal);
375
376        // The ORIGINAL sink, not the append clone: the seal has every row in
377        // hand, so it needs none of the resume machinery — and this is the sink
378        // that already carries the reserved-section hook gunnar's
379        // `__gunnar_refs__` needs. The two are proven byte-identical on the fresh
380        // path by `clone_fresh_path_is_byte_identical_to_original`.
381        let rows_sealed = paths.len() as u64;
382        let mut sink = ArrowIpcSink::new(blobs, blob_end);
383        if let Some(b) = reserved {
384            sink = sink.with_reserved_builder(b);
385        }
386        if !paths.is_empty() {
387            let batch = base_batch_from_rows(&paths, &locs)?;
388            let schema = data_subindex_schema();
389            sink.push_subindex(schema.as_ref(), &[batch], GroupKey {
390                pkg_type: 0,
391                repo: String::new(),
392                module_name: String::new(),
393            })?;
394        }
395        let sealed_total_bytes = Box::new(sink).finish()?;
396
397        // The static archive is durable; only now is the journal redundant.
398        let segments = journal_segments(&archive)?;
399        let mut journal_bytes_reclaimed = 0u64;
400        for (_, seg) in &segments {
401            journal_bytes_reclaimed += std::fs::metadata(seg).map(|m| m.len()).unwrap_or(0);
402            std::fs::remove_file(seg)
403                .map_err(|e| anyhow!("hot: removing journal {}: {e}", seg.display()))?;
404        }
405        sync_parent_dir(&journal_path);
406
407        Ok(HotSealReport {
408            archive,
409            rows_sealed,
410            segments_removed: segments.len(),
411            journal_bytes_reclaimed,
412            sealed_total_bytes,
413        })
414    }
415}
416
417/// The hot archive reads without being sealed — that is the other half of "worth
418/// having". Same trait the static [`ZnippyArchive`](crate::ZnippyArchive)
419/// implements, so a caller does not branch on phase.
420impl ZnippyReader for HotArchive {
421    fn list_files(&self) -> Result<Vec<String>> {
422        Ok(self.paths.clone())
423    }
424
425    fn contains(&self, relative_path: &str) -> bool {
426        self.by_path.contains_key(relative_path)
427    }
428
429    fn file_size(&self, relative_path: &str) -> Option<u64> {
430        self.by_path
431            .get(relative_path)
432            .map(|&i| self.locs[i].uncompressed_size)
433    }
434
435    fn extract_file(&self, relative_path: &str) -> Result<Vec<u8>> {
436        let &i = self
437            .by_path
438            .get(relative_path)
439            .ok_or_else(|| anyhow!("file not found in archive: {relative_path}"))?;
440        let loc = &self.locs[i];
441        let mut blob = vec![0u8; loc.blob_size as usize];
442        self.blobs.read_exact_at(&mut blob, loc.blob_offset)?;
443        if loc.compressed {
444            let mut out = Vec::new();
445            crate::codec::decompress_into(&blob, &mut out)?;
446            Ok(out)
447        } else {
448            Ok(blob)
449        }
450    }
451}
452
453/// Keep the last row written for each `relative_path`, in first-insertion order —
454/// the same last-writer-wins the re-sealing path gets from `drop_carried_paths`.
455fn live_rows(paths: Vec<String>, locs: Vec<ChunkLoc>) -> (Vec<String>, Vec<ChunkLoc>) {
456    let mut at: HashMap<&str, usize> = HashMap::with_capacity(paths.len());
457    let mut order: Vec<usize> = Vec::with_capacity(paths.len());
458    for (i, p) in paths.iter().enumerate() {
459        match at.get(p.as_str()).copied() {
460            Some(slot) => order[slot] = i,
461            None => {
462                at.insert(p.as_str(), order.len());
463                order.push(i);
464            }
465        }
466    }
467    let order: Vec<usize> = order;
468    let mut out_p = Vec::with_capacity(order.len());
469    let mut out_l = Vec::with_capacity(order.len());
470    for &i in &order {
471        out_p.push(paths[i].clone());
472        out_l.push(locs[i].clone());
473    }
474    (out_p, out_l)
475}
476
477fn segment_path(archive: &Path, n: u32) -> PathBuf {
478    let mut s = archive.as_os_str().to_os_string();
479    s.push(format!("{JOURNAL_INFIX}{n:05}"));
480    PathBuf::from(s)
481}
482
483/// Every journal segment for `archive`, sorted by sequence number.
484fn journal_segments(archive: &Path) -> Result<Vec<(u32, PathBuf)>> {
485    let dir = archive.parent().filter(|p| !p.as_os_str().is_empty());
486    let dir = dir.map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
487    let stem = archive
488        .file_name()
489        .and_then(|n| n.to_str())
490        .ok_or_else(|| anyhow!("hot: {} has no usable file name", archive.display()))?;
491    let prefix = format!("{stem}{JOURNAL_INFIX}");
492    let mut out = Vec::new();
493    let rd = match std::fs::read_dir(&dir) {
494        Ok(rd) => rd,
495        Err(_) => return Ok(out),
496    };
497    for e in rd.flatten() {
498        let name = e.file_name();
499        let Some(name) = name.to_str() else { continue };
500        let Some(tail) = name.strip_prefix(&prefix) else { continue };
501        let Ok(n) = tail.parse::<u32>() else { continue };
502        out.push((n, e.path()));
503    }
504    out.sort_by_key(|(n, _)| *n);
505    Ok(out)
506}
507
508/// Buffer alignment for the journal, in bytes.
509///
510/// **8, not arrow-rs's default 64.** The 64 is arrow-rs's choice, not Arrow's —
511/// arrow-cpp and pyarrow have always written 8 — so this is what the C++
512/// reference implementation does and not a bent format. It matters here and
513/// almost nowhere else, because a journal batch is *small*: the base schema has
514/// eight columns and a two-row batch therefore carries a dozen-plus buffers that
515/// are each rounded up. MEASURED on oden, 2026-08-04, two rows per append
516/// (a `pack-*.pack` and its `.idx`, 40-hex names): **1 664 B/append at 64-byte
517/// alignment, 824 B at 8** — 2.02×, for byte-identical rows. Flat at 824 B all
518/// the way to 32 000 appends, so it is a per-append constant and not a curve.
519///
520/// The **sealed** archive is untouched by this: it is written by `ArrowIpcSink`
521/// with arrow-rs's default, so every `.znippy` on disk keeps the bytes its
522/// readers already expect. This is the transient half only.
523const JOURNAL_ALIGNMENT: usize = 8;
524
525fn new_segment(path: &Path) -> Result<StreamWriter<File>> {
526    let f = File::create(path)
527        .map_err(|e| anyhow!("hot: create journal segment {}: {e}", path.display()))?;
528    let schema = data_subindex_schema();
529    let opts = arrow::ipc::writer::IpcWriteOptions::try_new(
530        JOURNAL_ALIGNMENT,
531        false,
532        arrow::ipc::MetadataVersion::V5,
533    )
534    .map_err(|e| anyhow!("hot: journal write options: {e}"))?;
535    let w = StreamWriter::try_new_with_options(f, schema.as_ref(), opts)
536        .map_err(|e| anyhow!("hot: journal schema: {e}"))?;
537    sync_parent_dir(path);
538    Ok(w)
539}
540
541/// Read one segment, tolerating a torn tail.
542///
543/// A killed process leaves a partial final message. Every complete message
544/// before it is real and is kept; the partial one is dropped, which is what makes
545/// the journal a log rather than a document. `decode_base_rows` cannot be reused
546/// here for exactly that reason — it fails the whole stream on the first bad
547/// batch, which is right for a sealed section and wrong for a log.
548fn read_segment(path: &Path) -> Result<(Vec<String>, Vec<ChunkLoc>)> {
549    let bytes = std::fs::read(path)
550        .map_err(|e| anyhow!("hot: reading journal {}: {e}", path.display()))?;
551    if bytes.is_empty() {
552        return Ok((Vec::new(), Vec::new()));
553    }
554    let reader = match StreamReader::try_new(std::io::Cursor::new(&bytes), None) {
555        Ok(r) => r,
556        // A segment whose schema message never landed carries nothing.
557        Err(_) => return Ok((Vec::new(), Vec::new())),
558    };
559    let mut paths = Vec::new();
560    let mut locs = Vec::new();
561    let mut complete: Vec<arrow::record_batch::RecordBatch> = Vec::new();
562    for batch in reader {
563        match batch {
564            Ok(b) => complete.push(b),
565            Err(_) => break, // torn tail
566        }
567    }
568    for b in &complete {
569        let mut ipc: Vec<u8> = Vec::new();
570        {
571            let mut w = StreamWriter::try_new(&mut ipc, b.schema().as_ref())
572                .map_err(|e| anyhow!("hot: re-encode: {e}"))?;
573            w.write(b).map_err(|e| anyhow!("hot: re-encode write: {e}"))?;
574            w.finish().map_err(|e| anyhow!("hot: re-encode finish: {e}"))?;
575        }
576        let (p, l) = decode_base_rows(&ipc)?;
577        paths.extend(p);
578        locs.extend(l);
579    }
580    Ok((paths, locs))
581}
582
583/// A newly-created or removed file is only durable once its directory entry is.
584fn sync_parent_dir(path: &Path) {
585    let parent = match path.parent() {
586        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
587        _ => PathBuf::from("."),
588    };
589    if let Ok(f) = File::open(parent) {
590        let _ = f.sync_all();
591    }
592}
593
594/// `HotArchive::seal` as a free function, for a caller that has a path and not a
595/// handle. Refuses an archive with no journal rather than silently producing an
596/// empty one.
597pub fn seal_hot(archive: &Path, level: i32, policy: crate::SkipPolicy) -> Result<HotSealReport> {
598    if !HotArchive::is_hot(archive) {
599        bail!("{} has no journal; it is not hot", archive.display());
600    }
601    HotArchive::open(archive, level, policy)?.seal(None)
602}
603
604// ── tests ────────────────────────────────────────────────────────────────────
605// Every test here seals a real archive through a `CompressCtx`, so all of them
606// need the codec — the same gate `meta_sink_append::tests` carries.
607#[cfg(all(test, feature = "openzl"))]
608mod tests {
609    use super::*;
610    use crate::{ZnippyArchive, create_archive};
611    use std::time::{SystemTime, UNIX_EPOCH};
612
613    fn unique_dir(tag: &str) -> PathBuf {
614        let ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
615        let d = std::env::temp_dir()
616            .join(format!("znippy_hot_{tag}_{ns}_{:?}", std::thread::current().id()));
617        std::fs::create_dir_all(&d).unwrap();
618        d
619    }
620
621    /// Deterministic, lexicographically spread, and *compressible* — so the codec
622    /// really runs and a byte-identity claim is about compressed output, not about
623    /// two stored-raw copies of the same input.
624    fn synth(n: usize, salt: u64) -> Vec<(String, Vec<u8>)> {
625        (0..n)
626            .map(|i| {
627                let g = (i.wrapping_mul(2_654_435_761) ^ salt as usize) % 997;
628                let p = format!("repo/grp{g:03}/file{i:08}_{salt}.bin");
629                let body = format!("payload {i} salt {salt} {}\n", "z".repeat(8 + (i % 40)));
630                (p, body.into_bytes())
631            })
632            .collect()
633    }
634
635    /// **The correctness claim the whole module rests on.** Appending N files one
636    /// at a time through the hot journal and then sealing must produce the SAME
637    /// BYTES as creating the archive from all N at once — same blob offsets, same
638    /// data sub-index, same sorted lookup, same fst trie, same manifest, same
639    /// footer.
640    ///
641    /// This is an applied-output assertion, not a round trip: a seal that dropped
642    /// the trie, or emitted rows in journal-segment order instead of insertion
643    /// order, would still list N files and still read every one of them back
644    /// correctly. Only the bytes catch it.
645    ///
646    /// Seen RED by emitting the rows sorted instead of in insertion order in
647    /// `HotArchive::seal` — the data sub-index differs and the assertion fires
648    /// with a first-difference offset inside it.
649    #[test]
650    fn seal_matches_create_archive_byte_for_byte() {
651        let dir = unique_dir("identity");
652        let files = synth(400, 11);
653
654        let reference = dir.join("ref.znippy");
655        create_archive(&reference, &files, 3).unwrap();
656
657        let hot = dir.join("hot.znippy");
658        {
659            let mut h = HotArchive::open(&hot, 3, crate::SkipPolicy::resolve()).unwrap();
660            for f in &files {
661                h.append(std::slice::from_ref(f)).unwrap();
662            }
663            let report = h.seal(None).unwrap();
664            assert_eq!(report.rows_sealed, files.len() as u64);
665            assert_eq!(report.segments_removed, 1, "one process, one segment");
666        }
667
668        let a = std::fs::read(&reference).unwrap();
669        let b = std::fs::read(&hot).unwrap();
670        assert_eq!(
671            a.len(),
672            b.len(),
673            "sealed length differs: create_archive {} vs hot-then-seal {}",
674            a.len(),
675            b.len()
676        );
677        let first_diff = a.iter().zip(&b).position(|(x, y)| x != y);
678        assert!(
679            first_diff.is_none(),
680            "hot-then-seal is NOT byte-identical to create_archive; first difference at byte {:?}",
681            first_diff
682        );
683
684        // …and the journal is gone, so the archive is genuinely cold.
685        assert!(!HotArchive::is_hot(&hot), "seal must remove the journal");
686        let _ = std::fs::remove_dir_all(&dir);
687    }
688
689    /// The hot archive is READABLE before it is sealed — otherwise a two-phase
690    /// tier would be write-only until compaction, which is not "worth having".
691    #[test]
692    fn a_hot_archive_serves_reads_before_the_seal() {
693        let dir = unique_dir("read");
694        let path = dir.join("a.znippy");
695        let files = synth(120, 3);
696        let mut h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
697        for f in &files {
698            h.append(std::slice::from_ref(f)).unwrap();
699        }
700        for (p, bytes) in &files {
701            assert!(h.contains(p), "hot archive must contain {p}");
702            assert_eq!(h.file_size(p), Some(bytes.len() as u64));
703            assert_eq!(&h.extract_file(p).unwrap(), bytes, "hot read mismatch for {p}");
704        }
705        assert_eq!(h.list_files().unwrap().len(), files.len());
706
707        // And after the seal the ORDINARY static reader answers the same bytes.
708        h.seal(None).unwrap();
709        let ar = ZnippyArchive::open(&path).unwrap();
710        for (p, bytes) in &files {
711            assert_eq!(&ar.extract_file(p).unwrap(), bytes, "sealed read mismatch for {p}");
712        }
713        // Random access through the rebuilt trie+lookup.
714        assert!(!crate::locate_file(&path, &files[77].0).unwrap().is_empty());
715        let _ = std::fs::remove_dir_all(&dir);
716    }
717
718    /// A restart must not lose the journal, and must not corrupt the blob cursor.
719    /// Three processes' worth of appends (three segments), then one seal.
720    #[test]
721    fn reopening_recovers_every_segment() {
722        let dir = unique_dir("reopen");
723        let path = dir.join("a.znippy");
724        let mut all: Vec<(String, Vec<u8>)> = Vec::new();
725        for round in 0..3u64 {
726            let files = synth(40, round + 1);
727            let mut h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
728            assert_eq!(h.rows(), all.len() as u64, "reopen must recover prior rows");
729            for f in &files {
730                h.append(std::slice::from_ref(f)).unwrap();
731            }
732            all.extend(files);
733        }
734        let h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
735        assert_eq!(h.rows(), all.len() as u64);
736        let report = h.seal(None).unwrap();
737        assert_eq!(report.segments_removed, 4, "three appending opens + the final one");
738
739        let ar = ZnippyArchive::open(&path).unwrap();
740        for (p, bytes) in &all {
741            assert_eq!(&ar.extract_file(p).unwrap(), bytes, "byte mismatch after restarts for {p}");
742        }
743        let _ = std::fs::remove_dir_all(&dir);
744    }
745
746    /// A SEALED archive goes hot again without losing anything: the static rows
747    /// are recovered into segment 0 and the rebuildable tail is dropped. This is
748    /// the path gunnar's cold tier takes after a `repack()` fold.
749    #[test]
750    fn a_sealed_archive_reheats_and_keeps_its_rows() {
751        let dir = unique_dir("reheat");
752        let path = dir.join("a.znippy");
753        let sealed = synth(60, 5);
754        create_archive(&path, &sealed, 3).unwrap();
755        let cold_len = std::fs::metadata(&path).unwrap().len();
756
757        let mut h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
758        assert_eq!(h.rows(), sealed.len() as u64, "re-heat must recover the sealed rows");
759        assert!(
760            h.blob_end() < cold_len,
761            "the metadata tail must be dropped: blob_end {} vs sealed length {cold_len}",
762            h.blob_end()
763        );
764        let extra = synth(20, 6);
765        for f in &extra {
766            h.append(std::slice::from_ref(f)).unwrap();
767        }
768        h.seal(None).unwrap();
769
770        let ar = ZnippyArchive::open(&path).unwrap();
771        for (p, bytes) in sealed.iter().chain(extra.iter()) {
772            assert_eq!(&ar.extract_file(p).unwrap(), bytes, "byte mismatch after re-heat for {p}");
773        }
774        let mut listed = ar.list_files().unwrap();
775        listed.sort();
776        let mut want: Vec<String> =
777            sealed.iter().chain(extra.iter()).map(|(p, _)| p.clone()).collect();
778        want.sort();
779        assert_eq!(listed, want);
780        let _ = std::fs::remove_dir_all(&dir);
781    }
782
783    /// Last writer wins, exactly as `append_files` does — one live row per path,
784    /// and the bytes read back are the NEW ones. The old blob stays in the file as
785    /// dead payload, which is the documented cost, so the assertion is on the row
786    /// count and the bytes, not on the file size.
787    #[test]
788    fn re_appending_a_path_replaces_it() {
789        let dir = unique_dir("replace");
790        let path = dir.join("a.znippy");
791        let mut h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
792        h.append(&[("x.bin".into(), b"first version".to_vec())]).unwrap();
793        h.append(&[("y.bin".into(), b"other".to_vec())]).unwrap();
794        let r = h.append(&[("x.bin".into(), b"SECOND version, longer".to_vec())]).unwrap();
795        assert_eq!(r.rows_replaced, 1, "re-appending x.bin must replace, not duplicate");
796        assert_eq!(h.rows(), 2);
797        assert_eq!(h.extract_file("x.bin").unwrap(), b"SECOND version, longer");
798        h.seal(None).unwrap();
799
800        let ar = ZnippyArchive::open(&path).unwrap();
801        assert_eq!(ar.list_files().unwrap().len(), 2, "the seal must carry ONE row per path");
802        assert_eq!(ar.extract_file("x.bin").unwrap(), b"SECOND version, longer");
803        assert_eq!(ar.extract_file("y.bin").unwrap(), b"other");
804        let _ = std::fs::remove_dir_all(&dir);
805    }
806
807    /// A killed process leaves a partial final message. Everything before it is
808    /// real and must survive; the partial one must be dropped rather than failing
809    /// the open. Simulated by truncating the segment inside its last message.
810    #[test]
811    fn a_torn_journal_tail_is_dropped_not_fatal() {
812        let dir = unique_dir("torn");
813        let path = dir.join("a.znippy");
814        let files = synth(30, 2);
815        {
816            let mut h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
817            for f in &files {
818                h.append(std::slice::from_ref(f)).unwrap();
819            }
820        }
821        let seg = segment_path(&path, 0);
822        let len = std::fs::metadata(&seg).unwrap().len();
823        // Cut 40 bytes: inside the last batch message, past many complete ones.
824        std::fs::OpenOptions::new().write(true).open(&seg).unwrap().set_len(len - 40).unwrap();
825
826        let h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
827        let rows = h.rows();
828        assert!(
829            rows > 0 && rows < files.len() as u64,
830            "a torn tail must cost SOME rows and not all of them; got {rows} of {}",
831            files.len()
832        );
833        // Every surviving row still reads back byte-exact.
834        for p in h.list_files().unwrap() {
835            let want = &files.iter().find(|(q, _)| *q == p).unwrap().1;
836            assert_eq!(&h.extract_file(&p).unwrap(), want, "torn-journal read mismatch for {p}");
837        }
838        let _ = std::fs::remove_dir_all(&dir);
839    }
840
841    /// **The O(1) claim, as an applied output.** The index-side cost of an append
842    /// is the journal bytes it writes. If that is flat across a 100× growth in
843    /// archive size, the append is not paying for the archive; if the old
844    /// re-sealing path were behind this API the number would grow with N.
845    ///
846    /// Bytes, not CPU: instructions and wall clock are unusable as absolutes on a
847    /// contended box, and a byte count is exact.
848    #[test]
849    fn the_index_cost_of_an_append_does_not_grow_with_the_archive() {
850        let dir = unique_dir("flat");
851        let path = dir.join("a.znippy");
852        let mut h = HotArchive::open(&path, 3, crate::SkipPolicy::resolve()).unwrap();
853        let mut early = 0u64;
854        let mut late = 0u64;
855        for i in 0..2_000usize {
856            // Constant-shape rows so the comparison is about N, not about payload.
857            let f = vec![(format!("pack-{i:040x}.pack"), vec![b'q'; 64])];
858            let r = h.append(&f).unwrap();
859            if i == 10 {
860                early = r.journal_bytes_added;
861            }
862            if i == 1_999 {
863                late = r.journal_bytes_added;
864            }
865        }
866        assert!(early > 0, "an append must cost SOME journal bytes; the probe read 0");
867        assert_eq!(
868            early, late,
869            "the index cost of an append grew from {early} B at row 10 to {late} B at row 2000 — \
870             the append is paying for the archive again"
871        );
872        let _ = std::fs::remove_dir_all(&dir);
873    }
874}