1use 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
36const SLICE_SIZE: usize = 8 * 1024 * 1024;
38
39pub struct ArchiveEntry {
41 pub relative_path: String,
42 pub data: Vec<u8>,
43 pub pkg_type: Option<i8>,
47 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
63pub 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
88pub 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
102pub 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
113pub fn compress_stream_with_sink_and_policy(
117 output: &PathBuf,
118 policy: SkipPolicy,
119 sink_factory: Option<znippy_common::MetaSinkFactory>,
120) -> Result<StreamCompressor> {
121 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#[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
150struct ChunkLabel {
153 file_index: u64,
154 fdata_offset: u64,
155 chunk_seq: u32,
156 skip: bool,
157}
158
159struct ChunkInput {
161 data: Arc<Vec<u8>>,
162 start: usize,
163 len: usize,
164}
165
166enum OutPayload {
168 Buf(Vec<u8>),
170 Skip { data: Arc<Vec<u8>>, start: usize, len: usize },
173}
174
175struct 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 static COMPRESS_TLS: RefCell<Option<(CompressCtx, Vec<u8>)>> =
193 const { RefCell::new(None) };
194}
195
196struct 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
207struct 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 let cap = num_workers * 4;
260 let level = CONFIG.compression_level;
261
262 let registry: Arc<Mutex<Registry>> = Arc::new(Mutex::new(Registry::default()));
266
267 let producer = {
269 let registry = Arc::clone(®istry);
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 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 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 }
332 Err(_) => return None, }
334 }
335 }
336 };
337
338 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(); 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 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 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 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 #[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; 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 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 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; 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 files_failed: 0,
498 })
499}