Skip to main content

znippy_compress/
stream_packer.rs

1//! Streaming compressor (`compress_stream`) on the canonical no-barrier Gatling
2//! engine: [`znippy_zoomies::gatling::ordered::run_ordered_sink`].
3//!
4//! The caller stays the **producer**: entries arrive in memory via a channel; the
5//! producer pulls one entry at a time and splits it into rounds (small entry = one
6//! whole round; big entry = slice-size rounds), referencing the entry's
7//! `Arc<Vec<u8>>` — zero-copy, no slot buffers needed since the data is already in
8//! RAM. The engine fans rounds across N **map** workers that stamp BLAKE3 over the
9//! original bytes and compress (or store raw). The **sink** receives outputs in
10//! strict producer order and pwrites each at the next archive offset, recording its
11//! `BlobMeta` incrementally — no full-archive RAM buffering. The finalizer groups
12//! by (pkg_type, repo) into a v0.7 multi-index — arrow-ipc from metadata only.
13
14use anyhow::{Result, anyhow};
15use crossbeam_channel::{Receiver, Sender, bounded};
16use std::cell::RefCell;
17use std::fs::File;
18use std::os::unix::fs::FileExt;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::thread;
22
23use znippy_zoomies::gatling::ordered::{OrderedSink, run_ordered_sink};
24
25use znippy_common::CompressionReport;
26use znippy_common::codec::CompressCtx;
27use znippy_common::common_config::CONFIG;
28use znippy_common::index::{
29    build_arrow_metadata_for_config, build_metadata_batch,
30    compose_index_schema,
31};
32use znippy_common::meta::{BlobMeta, ChunkMeta};
33use znippy_common::precompressed::{SNIFF_PREFIX_LEN, SkipPolicy};
34use znippy_common::{ArchiveMetaSink, ArrowIpcSink, GroupKey};
35
36/// Entries bigger than this are cut into slice-size rounds; smaller stay whole.
37const SLICE_SIZE: usize = 8 * 1024 * 1024;
38
39/// An entry to be compressed into the archive.
40pub struct ArchiveEntry {
41    pub relative_path: String,
42    pub data: Vec<u8>,
43    /// Package type discriminator. None means "untyped / default group".
44    /// When all entries share the same (pkg_type, repo), the archive is written
45    /// in v0.6 format. Multiple distinct pairs produce a v0.7 multi-index archive.
46    pub pkg_type: Option<i8>,
47    /// Repository label for this entry. None is treated as "".
48    pub repo: Option<String>,
49}
50
51impl ArchiveEntry {
52    pub fn new(relative_path: impl Into<String>, data: Vec<u8>) -> Self {
53        Self { relative_path: relative_path.into(), data, pkg_type: None, repo: None }
54    }
55}
56
57impl Default for ArchiveEntry {
58    fn default() -> Self {
59        Self { relative_path: String::new(), data: Vec::new(), pkg_type: None, repo: None }
60    }
61}
62
63/// A handle to the streaming compressor.
64pub struct StreamCompressor {
65    tx: Option<Sender<ArchiveEntry>>,
66    join_handle: Option<thread::JoinHandle<Result<CompressionReport>>>,
67}
68
69impl StreamCompressor {
70    pub fn sender(&self) -> &Sender<ArchiveEntry> {
71        self.tx.as_ref().expect("sender already consumed")
72    }
73
74    pub fn finish(mut self) -> Result<CompressionReport> {
75        drop(self.tx.take());
76        self.join_handle
77            .take()
78            .expect("already finished")
79            .join()
80            .map_err(|e| anyhow!("Compression thread panicked: {:?}", e))?
81    }
82}
83
84pub fn compress_stream(output: &PathBuf, no_skip: bool) -> Result<StreamCompressor> {
85    compress_stream_with_sink(output, no_skip, None)
86}
87
88/// [`compress_stream`] with an explicit [`SkipPolicy`] instead of a bare
89/// `no_skip` flag.
90///
91/// The policy covers the whole stream, because that is how a caller's knowledge
92/// arrives — gunnar sealing a repository's packfiles knows it for the whole seal,
93/// not entry by entry. [`SkipPolicy::already_compressed`] stores every entry raw
94/// without inspecting a byte.
95pub fn compress_stream_with_policy(
96    output: &PathBuf,
97    policy: SkipPolicy,
98) -> Result<StreamCompressor> {
99    compress_stream_with_sink_and_policy(output, policy, None)
100}
101
102/// Like [`compress_stream`] but lets the caller choose the metadata backend via a
103/// [`MetaSinkFactory`] (e.g. an Iceberg sink). `None` uses the default inline
104/// `ArrowIpcSink`. Mirrors `compress_dir`'s sink injection for the in-memory path.
105pub fn compress_stream_with_sink(
106    output: &PathBuf,
107    no_skip: bool,
108    sink_factory: Option<znippy_common::MetaSinkFactory>,
109) -> Result<StreamCompressor> {
110    compress_stream_with_sink_and_policy(output, SkipPolicy::from_no_skip(no_skip), sink_factory)
111}
112
113/// The one implementation behind [`compress_stream`],
114/// [`compress_stream_with_sink`] and [`compress_stream_with_policy`] — both a
115/// caller-chosen metadata backend and a caller-supplied [`SkipPolicy`].
116pub fn compress_stream_with_sink_and_policy(
117    output: &PathBuf,
118    policy: SkipPolicy,
119    sink_factory: Option<znippy_common::MetaSinkFactory>,
120) -> Result<StreamCompressor> {
121    // PERF: bounded entry channel caps in-flight `ArchiveEntry { data: Vec<u8> }`
122    // payloads so producers get backpressure instead of the unbounded queue
123    // ballooning memory when the reader/compressors fall behind.
124    let num_workers = CONFIG.max_core_in_flight.max(1);
125    let (tx_entry, rx_entry): (Sender<ArchiveEntry>, Receiver<ArchiveEntry>) =
126        bounded(num_workers * 4);
127    let output = output.clone();
128
129    let join_handle = thread::spawn(move || -> Result<CompressionReport> {
130        run_pipeline(rx_entry, &output, policy, sink_factory)
131    });
132
133    Ok(StreamCompressor { tx: Some(tx_entry), join_handle: Some(join_handle) })
134}
135
136/// Per-file metadata + byte accounting collected by the producer, consumed by the
137/// finalizer. Small (path strings + per-file discriminators) — the file *data*
138/// streams through the rounds and is freed incrementally, never accumulated here.
139#[derive(Default)]
140struct Registry {
141    paths: Vec<String>,
142    pkg_types: Vec<Option<i8>>,
143    repos: Vec<Option<String>>,
144    uf: u64,
145    ub: u64,
146    cf: u64,
147    cb: u64,
148}
149
150/// The gatling **label**: out-of-band metadata for one chunk (everything the sink
151/// needs to place it and record its `ChunkMeta`, carried alongside the bytes).
152struct ChunkLabel {
153    file_index: u64,
154    fdata_offset: u64,
155    chunk_seq: u32,
156    skip: bool,
157}
158
159/// The gatling **input** for one chunk: a zero-copy slice of the entry's `Arc` data.
160struct ChunkInput {
161    data: Arc<Vec<u8>>,
162    start: usize,
163    len: usize,
164}
165
166/// What the sink pwrites for one chunk.
167enum OutPayload {
168    /// Compressed output owned by the map worker.
169    Buf(Vec<u8>),
170    /// Skip / incompressible path: zero-copy straight from the entry's data (the
171    /// `Arc` keeps it alive until the sink has pwritten it).
172    Skip { data: Arc<Vec<u8>>, start: usize, len: usize },
173}
174
175/// The gatling **output** for one chunk: payload to pwrite + the `ChunkMeta` fields.
176struct ChunkOut {
177    payload: OutPayload,
178    on_disk_len: usize,
179    file_index: u64,
180    fdata_offset: u64,
181    chunk_seq: u32,
182    checksum: [u8; 32],
183    compressed: bool,
184    uncompressed_size: u64,
185}
186
187thread_local! {
188    /// One OpenZL context + one reusable compress-scratch buffer **per map worker
189    /// thread** (the gatling workers are persistent, so this is created once per
190    /// worker and reused across every chunk it handles). Keeps the hot path
191    /// allocation-free — exactly what the recycled free-buffer pool did before.
192    static COMPRESS_TLS: RefCell<Option<(CompressCtx, Vec<u8>)>> =
193        const { RefCell::new(None) };
194}
195
196/// One chunk's in-flight cursor while the producer splits an entry into rounds.
197struct CurEntry {
198    data: Arc<Vec<u8>>,
199    total: usize,
200    off: usize,
201    seq: u32,
202    skip: bool,
203    file_index: u64,
204    small: bool,
205}
206
207/// The ordered streaming sink: pwrites each in-order chunk at the next archive
208/// offset and records its `BlobMeta`. Runs on the pipeline (calling) thread, so it
209/// owns its mutable archive state directly — no `Send`/lock dance.
210struct ArchiveSink {
211    file: Arc<File>,
212    cursor: u64,
213    blobs: Vec<BlobMeta>,
214}
215
216impl OrderedSink<Result<ChunkOut>> for ArchiveSink {
217    fn emit(&mut self, _seq: u64, output: Result<ChunkOut>) -> Result<()> {
218        let job = output?;
219        let off = self.cursor;
220        self.cursor += job.on_disk_len as u64;
221        match job.payload {
222            OutPayload::Buf(buf) => {
223                self.file.write_all_at(&buf[..job.on_disk_len], off)?;
224            }
225            OutPayload::Skip { data, start, len } => {
226                self.file.write_all_at(&data[start..start + len], off)?;
227            }
228        }
229        self.blobs.push(BlobMeta {
230            chunk_meta: ChunkMeta {
231                fdata_offset: job.fdata_offset,
232                file_index: job.file_index,
233                chunk_seq: job.chunk_seq,
234                checksum: job.checksum,
235                compressed: job.compressed,
236                uncompressed_size: job.uncompressed_size,
237                compressed_size: job.on_disk_len as u64,
238            },
239            blob_offset: off,
240            blob_size: job.on_disk_len as u64,
241        });
242        Ok(())
243    }
244}
245
246fn run_pipeline(
247    rx_entry: Receiver<ArchiveEntry>,
248    output: &PathBuf,
249    policy: SkipPolicy,
250    sink_factory: Option<znippy_common::MetaSinkFactory>,
251) -> Result<CompressionReport> {
252    let output_path = output.with_extension("znippy");
253    let file = Arc::new(File::create(&output_path)?);
254
255    let num_workers = CONFIG.max_core_in_flight.max(1);
256    // In-flight / reorder-buffer bound — matches the old bounded-channel depth so
257    // the producer is back-pressured the same way (memory bounded by `cap`, not by
258    // stream length). The entry channel above already bounds the upstream sender.
259    let cap = num_workers * 4;
260    let level = CONFIG.compression_level;
261
262    // Shared per-file registry: the producer appends one record per entry (a single
263    // coarse-grained lock per entry, not per round); the finalizer reads it back
264    // after the engine has joined every thread.
265    let registry: Arc<Mutex<Registry>> = Arc::new(Mutex::new(Registry::default()));
266
267    // ── PRODUCER: pull entries, split into rounds, yield (label, bytes) lazily ──
268    let producer = {
269        let registry = Arc::clone(&registry);
270        let mut cur: Option<CurEntry> = None;
271        move || -> Option<(ChunkLabel, ChunkInput)> {
272            loop {
273                if let Some(c) = cur.as_mut() {
274                    if c.off < c.total {
275                        let len = if c.small { c.total } else { SLICE_SIZE.min(c.total - c.off) };
276                        let label = ChunkLabel {
277                            file_index: c.file_index,
278                            fdata_offset: c.off as u64,
279                            chunk_seq: c.seq,
280                            skip: c.skip,
281                        };
282                        let input = ChunkInput { data: Arc::clone(&c.data), start: c.off, len };
283                        c.off += len;
284                        c.seq += 1;
285                        return Some((label, input));
286                    }
287                    cur = None;
288                }
289
290                match rx_entry.recv() {
291                    Ok(entry) => {
292                        // Path fast path first (it covers the whole entry, every
293                        // chunk of it), then a magic-byte probe over the head of
294                        // the real bytes — which is the only thing that can reach
295                        // an entry carrying no extension, or one whose name lies.
296                        let path = Path::new(&entry.relative_path);
297                        let skip = policy.skip_by_path(path)
298                            || policy.skip_by_bytes(
299                                &entry.data[..entry.data.len().min(SNIFF_PREFIX_LEN)],
300                            );
301                        let data_len = entry.data.len() as u64;
302                        let file_index;
303                        {
304                            let mut reg = registry.lock().expect("registry lock");
305                            file_index = reg.paths.len() as u64;
306                            if skip {
307                                reg.uf += 1;
308                                reg.ub += data_len;
309                            } else {
310                                reg.cf += 1;
311                                reg.cb += data_len;
312                            }
313                            reg.pkg_types.push(entry.pkg_type);
314                            reg.repos.push(entry.repo);
315                            reg.paths.push(entry.relative_path);
316                        }
317
318                        let data = Arc::new(entry.data);
319                        let total = data.len();
320                        if total == 0 {
321                            // Empty entry → one zero-length round so it appears in
322                            // the index. (No further rounds for this entry.)
323                            return Some((
324                                ChunkLabel { file_index, fdata_offset: 0, chunk_seq: 0, skip },
325                                ChunkInput { data, start: 0, len: 0 },
326                            ));
327                        }
328                        let small = total <= SLICE_SIZE;
329                        cur = Some(CurEntry { data, total, off: 0, seq: 0, skip, file_index, small });
330                        // Loop to emit this entry's first round.
331                    }
332                    Err(_) => return None, // sender hung up: end of stream
333                }
334            }
335        }
336    };
337
338    // ── MAP: BLAKE3 over the ORIGINAL bytes, then compress (or store raw) ────────
339    let map = move |label: ChunkLabel, input: ChunkInput| -> Result<ChunkOut> {
340        let src = &input.data[input.start..input.start + input.len];
341        let checksum = *blake3::hash(src).as_bytes(); // ORIGINAL bytes, pre-compression
342        let uncompressed_size = input.len as u64;
343
344        let (payload, on_disk_len, compressed) = if label.skip {
345            (
346                OutPayload::Skip {
347                    data: Arc::clone(&input.data),
348                    start: input.start,
349                    len: input.len,
350                },
351                input.len,
352                false,
353            )
354        } else {
355            COMPRESS_TLS.with(|cell| -> Result<(OutPayload, usize, bool)> {
356                let mut guard = cell.borrow_mut();
357                if guard.is_none() {
358                    *guard = Some((CompressCtx::new(level)?, Vec::new()));
359                }
360                let (cctx, scratch) = guard.as_mut().unwrap();
361                let n = cctx.compress_into(src, scratch)?;
362                if n >= input.len {
363                    // Incompressible: storing raw is no bigger and skips the decode
364                    // cost. Zero-copy straight from the entry's `Arc` — the scratch
365                    // buffer keeps its capacity for the next chunk.
366                    Ok((
367                        OutPayload::Skip {
368                            data: Arc::clone(&input.data),
369                            start: input.start,
370                            len: input.len,
371                        },
372                        input.len,
373                        false,
374                    ))
375                } else {
376                    // ZERO-ALLOC: hand the already-filled scratch buffer to the
377                    // sink via `take` — `compress_into` truncated it to exactly `n`,
378                    // so no alloc-a-new-buffer-and-memcpy. The next chunk on this
379                    // worker gets a fresh scratch (the CompressCtx, the costly part,
380                    // is retained in TLS).
381                    Ok((OutPayload::Buf(std::mem::take(scratch)), n, true))
382                }
383            })?
384        };
385
386        Ok(ChunkOut {
387            payload,
388            on_disk_len,
389            file_index: label.file_index,
390            fdata_offset: label.fdata_offset,
391            chunk_seq: label.chunk_seq,
392            checksum,
393            compressed,
394            uncompressed_size,
395        })
396    };
397
398    // ── SINK: ordered, streaming pwrite + incremental BlobMeta ───────────────────
399    let mut sink = ArchiveSink { file: Arc::clone(&file), cursor: 0, blobs: Vec::new() };
400
401    let sink_result = run_ordered_sink(producer, num_workers, cap, map, &mut sink);
402
403    // test-matrix emit: HONEST verdict of the streaming big-file ordered-sink run.
404    #[cfg(feature = "testmatrix")]
405    crate::functional_status(
406        "znippy-compress/stream_packer",
407        "run_ordered_sink",
408        sink_result.is_ok(),
409        &format!(
410            "workers={num_workers} cap={cap} blobs={} ok={}",
411            sink.blobs.len(),
412            sink_result.is_ok()
413        ),
414    );
415    sink_result?;
416
417    let mut all_blobs = sink.blobs;
418    let blob_bytes = sink.cursor; // blob region starts at 0
419    all_blobs.sort_by_key(|b| (b.chunk_meta.file_index, b.chunk_meta.chunk_seq));
420    let total_chunks = all_blobs.len() as u64;
421
422    // Recover the registry (the producer has been joined, so we are the sole owner).
423    let reg = std::mem::take(&mut *registry.lock().expect("registry lock"));
424    let (uf, ub, cf, cb) = (reg.uf, reg.ub, reg.cf, reg.cb);
425
426    // ── FINALIZER: group by (pkg_type, repo) into a v0.7 multi-index ─────────────
427    let file_keys: Vec<(i8, String)> = reg
428        .pkg_types
429        .iter()
430        .zip(reg.repos.iter())
431        .map(|(p, r)| (p.unwrap_or(0), r.clone().unwrap_or_default()))
432        .collect();
433    let mut groups: std::collections::BTreeMap<(i8, String), Vec<usize>> =
434        std::collections::BTreeMap::new();
435    for (i, blob) in all_blobs.iter().enumerate() {
436        let key = file_keys[blob.chunk_meta.file_index as usize].clone();
437        groups.entry(key).or_default().push(i);
438    }
439
440    let meta_map = build_arrow_metadata_for_config(&CONFIG);
441    let mut sink: Box<dyn ArchiveMetaSink> = match sink_factory {
442        Some(make) => make(Arc::clone(&file), blob_bytes),
443        None => Box::new(ArrowIpcSink::new(Arc::clone(&file), blob_bytes)),
444    };
445    let group_count = groups.len();
446
447    for ((pkg_type, repo), blob_indices) in &groups {
448        let group_blobs: Vec<_> = blob_indices.iter().map(|&i| all_blobs[i].clone()).collect();
449
450        let batch = build_metadata_batch(&group_blobs, |fi| reg.paths[fi as usize].clone(), &[], &[])
451            .map_err(|e| anyhow!("build sub-index batch: {e}"))?;
452        let schema_with_meta = arrow::datatypes::Schema::new_with_metadata(
453            compose_index_schema(&[]).fields().to_vec(),
454            meta_map.clone(),
455        );
456
457        sink.push_subindex(
458            &schema_with_meta,
459            std::slice::from_ref(&batch),
460            GroupKey {
461                pkg_type: *pkg_type,
462                repo: repo.clone(),
463                module_name: String::new(),
464            },
465        )?;
466    }
467
468    let manifest_offset = blob_bytes; // logging only; sink owns real placement
469    let total_bytes_out = sink.finish()?;
470    let total_files = uf + cf;
471
472    log::info!(
473        "[stream] gatling archive: {} group(s), {} blob bytes, manifest at {}",
474        group_count,
475        blob_bytes,
476        manifest_offset
477    );
478
479    Ok(CompressionReport {
480        total_files,
481        compressed_files: cf,
482        uncompressed_files: uf,
483        chunks: total_chunks,
484        total_dirs: 0,
485        total_bytes_in: cb + ub,
486        total_bytes_out,
487        compressed_bytes: cb,
488        uncompressed_bytes: ub,
489        compression_ratio: if cb > 0 && total_bytes_out > ub {
490            (cb as f32 / (total_bytes_out - ub) as f32) * 100.0
491        } else {
492            0.0
493        },
494        // The stream API counts uf/cf only for entries actually received with
495        // their bytes in hand (the caller has already read the file), and sets
496        // `total_files = uf + cf`, so no enumerated-but-dropped files exist here.
497        files_failed: 0,
498    })
499}