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}