use anyhow::{Result, anyhow};
use crossbeam_channel::{Receiver, Sender, bounded};
use std::cell::RefCell;
use std::fs::File;
use std::os::unix::fs::FileExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use znippy_zoomies::gatling::ordered::{OrderedSink, run_ordered_sink};
use znippy_common::CompressionReport;
use znippy_common::codec::CompressCtx;
use znippy_common::common_config::CONFIG;
use znippy_common::index::{
build_arrow_metadata_for_config, build_metadata_batch,
compose_index_schema, should_skip_compression,
};
use znippy_common::meta::{BlobMeta, ChunkMeta};
use znippy_common::{ArchiveMetaSink, ArrowIpcSink, GroupKey};
const SLICE_SIZE: usize = 8 * 1024 * 1024;
pub struct ArchiveEntry {
pub relative_path: String,
pub data: Vec<u8>,
pub pkg_type: Option<i8>,
pub repo: Option<String>,
}
impl ArchiveEntry {
pub fn new(relative_path: impl Into<String>, data: Vec<u8>) -> Self {
Self { relative_path: relative_path.into(), data, pkg_type: None, repo: None }
}
}
impl Default for ArchiveEntry {
fn default() -> Self {
Self { relative_path: String::new(), data: Vec::new(), pkg_type: None, repo: None }
}
}
pub struct StreamCompressor {
tx: Option<Sender<ArchiveEntry>>,
join_handle: Option<thread::JoinHandle<Result<CompressionReport>>>,
}
impl StreamCompressor {
pub fn sender(&self) -> &Sender<ArchiveEntry> {
self.tx.as_ref().expect("sender already consumed")
}
pub fn finish(mut self) -> Result<CompressionReport> {
drop(self.tx.take());
self.join_handle
.take()
.expect("already finished")
.join()
.map_err(|e| anyhow!("Compression thread panicked: {:?}", e))?
}
}
pub fn compress_stream(output: &PathBuf, no_skip: bool) -> Result<StreamCompressor> {
compress_stream_with_sink(output, no_skip, None)
}
pub fn compress_stream_with_sink(
output: &PathBuf,
no_skip: bool,
sink_factory: Option<znippy_common::MetaSinkFactory>,
) -> Result<StreamCompressor> {
let num_workers = CONFIG.max_core_in_flight.max(1);
let (tx_entry, rx_entry): (Sender<ArchiveEntry>, Receiver<ArchiveEntry>) =
bounded(num_workers * 4);
let output = output.clone();
let join_handle = thread::spawn(move || -> Result<CompressionReport> {
run_pipeline(rx_entry, &output, no_skip, sink_factory)
});
Ok(StreamCompressor { tx: Some(tx_entry), join_handle: Some(join_handle) })
}
#[derive(Default)]
struct Registry {
paths: Vec<String>,
pkg_types: Vec<Option<i8>>,
repos: Vec<Option<String>>,
uf: u64,
ub: u64,
cf: u64,
cb: u64,
}
struct ChunkLabel {
file_index: u64,
fdata_offset: u64,
chunk_seq: u32,
skip: bool,
}
struct ChunkInput {
data: Arc<Vec<u8>>,
start: usize,
len: usize,
}
enum OutPayload {
Buf(Vec<u8>),
Skip { data: Arc<Vec<u8>>, start: usize, len: usize },
}
struct ChunkOut {
payload: OutPayload,
on_disk_len: usize,
file_index: u64,
fdata_offset: u64,
chunk_seq: u32,
checksum: [u8; 32],
compressed: bool,
uncompressed_size: u64,
}
thread_local! {
static COMPRESS_TLS: RefCell<Option<(CompressCtx, Vec<u8>)>> =
const { RefCell::new(None) };
}
struct CurEntry {
data: Arc<Vec<u8>>,
total: usize,
off: usize,
seq: u32,
skip: bool,
file_index: u64,
small: bool,
}
struct ArchiveSink {
file: Arc<File>,
cursor: u64,
blobs: Vec<BlobMeta>,
}
impl OrderedSink<Result<ChunkOut>> for ArchiveSink {
fn emit(&mut self, _seq: u64, output: Result<ChunkOut>) -> Result<()> {
let job = output?;
let off = self.cursor;
self.cursor += job.on_disk_len as u64;
match job.payload {
OutPayload::Buf(buf) => {
self.file.write_all_at(&buf[..job.on_disk_len], off)?;
}
OutPayload::Skip { data, start, len } => {
self.file.write_all_at(&data[start..start + len], off)?;
}
}
self.blobs.push(BlobMeta {
chunk_meta: ChunkMeta {
fdata_offset: job.fdata_offset,
file_index: job.file_index,
chunk_seq: job.chunk_seq,
checksum: job.checksum,
compressed: job.compressed,
uncompressed_size: job.uncompressed_size,
compressed_size: job.on_disk_len as u64,
},
blob_offset: off,
blob_size: job.on_disk_len as u64,
});
Ok(())
}
}
fn run_pipeline(
rx_entry: Receiver<ArchiveEntry>,
output: &PathBuf,
no_skip: bool,
sink_factory: Option<znippy_common::MetaSinkFactory>,
) -> Result<CompressionReport> {
let output_path = output.with_extension("znippy");
let file = Arc::new(File::create(&output_path)?);
let num_workers = CONFIG.max_core_in_flight.max(1);
let cap = num_workers * 4;
let level = CONFIG.compression_level;
let registry: Arc<Mutex<Registry>> = Arc::new(Mutex::new(Registry::default()));
let producer = {
let registry = Arc::clone(®istry);
let mut cur: Option<CurEntry> = None;
move || -> Option<(ChunkLabel, ChunkInput)> {
loop {
if let Some(c) = cur.as_mut() {
if c.off < c.total {
let len = if c.small { c.total } else { SLICE_SIZE.min(c.total - c.off) };
let label = ChunkLabel {
file_index: c.file_index,
fdata_offset: c.off as u64,
chunk_seq: c.seq,
skip: c.skip,
};
let input = ChunkInput { data: Arc::clone(&c.data), start: c.off, len };
c.off += len;
c.seq += 1;
return Some((label, input));
}
cur = None;
}
match rx_entry.recv() {
Ok(entry) => {
let skip =
!no_skip && should_skip_compression(Path::new(&entry.relative_path));
let data_len = entry.data.len() as u64;
let file_index;
{
let mut reg = registry.lock().expect("registry lock");
file_index = reg.paths.len() as u64;
if skip {
reg.uf += 1;
reg.ub += data_len;
} else {
reg.cf += 1;
reg.cb += data_len;
}
reg.pkg_types.push(entry.pkg_type);
reg.repos.push(entry.repo);
reg.paths.push(entry.relative_path);
}
let data = Arc::new(entry.data);
let total = data.len();
if total == 0 {
return Some((
ChunkLabel { file_index, fdata_offset: 0, chunk_seq: 0, skip },
ChunkInput { data, start: 0, len: 0 },
));
}
let small = total <= SLICE_SIZE;
cur = Some(CurEntry { data, total, off: 0, seq: 0, skip, file_index, small });
}
Err(_) => return None, }
}
}
};
let map = move |label: ChunkLabel, input: ChunkInput| -> Result<ChunkOut> {
let src = &input.data[input.start..input.start + input.len];
let checksum = *blake3::hash(src).as_bytes(); let uncompressed_size = input.len as u64;
let (payload, on_disk_len, compressed) = if label.skip {
(
OutPayload::Skip {
data: Arc::clone(&input.data),
start: input.start,
len: input.len,
},
input.len,
false,
)
} else {
COMPRESS_TLS.with(|cell| -> Result<(OutPayload, usize, bool)> {
let mut guard = cell.borrow_mut();
if guard.is_none() {
*guard = Some((CompressCtx::new(level)?, Vec::new()));
}
let (cctx, scratch) = guard.as_mut().unwrap();
let n = cctx.compress_into(src, scratch)?;
if n >= input.len {
Ok((
OutPayload::Skip {
data: Arc::clone(&input.data),
start: input.start,
len: input.len,
},
input.len,
false,
))
} else {
Ok((OutPayload::Buf(std::mem::take(scratch)), n, true))
}
})?
};
Ok(ChunkOut {
payload,
on_disk_len,
file_index: label.file_index,
fdata_offset: label.fdata_offset,
chunk_seq: label.chunk_seq,
checksum,
compressed,
uncompressed_size,
})
};
let mut sink = ArchiveSink { file: Arc::clone(&file), cursor: 0, blobs: Vec::new() };
let sink_result = run_ordered_sink(producer, num_workers, cap, map, &mut sink);
#[cfg(feature = "testmatrix")]
crate::functional_status(
"znippy-compress/stream_packer",
"run_ordered_sink",
sink_result.is_ok(),
&format!(
"workers={num_workers} cap={cap} blobs={} ok={}",
sink.blobs.len(),
sink_result.is_ok()
),
);
sink_result?;
let mut all_blobs = sink.blobs;
let blob_bytes = sink.cursor; all_blobs.sort_by_key(|b| (b.chunk_meta.file_index, b.chunk_meta.chunk_seq));
let total_chunks = all_blobs.len() as u64;
let reg = std::mem::take(&mut *registry.lock().expect("registry lock"));
let (uf, ub, cf, cb) = (reg.uf, reg.ub, reg.cf, reg.cb);
let file_keys: Vec<(i8, String)> = reg
.pkg_types
.iter()
.zip(reg.repos.iter())
.map(|(p, r)| (p.unwrap_or(0), r.clone().unwrap_or_default()))
.collect();
let mut groups: std::collections::BTreeMap<(i8, String), Vec<usize>> =
std::collections::BTreeMap::new();
for (i, blob) in all_blobs.iter().enumerate() {
let key = file_keys[blob.chunk_meta.file_index as usize].clone();
groups.entry(key).or_default().push(i);
}
let meta_map = build_arrow_metadata_for_config(&CONFIG);
let mut sink: Box<dyn ArchiveMetaSink> = match sink_factory {
Some(make) => make(Arc::clone(&file), blob_bytes),
None => Box::new(ArrowIpcSink::new(Arc::clone(&file), blob_bytes)),
};
let group_count = groups.len();
for ((pkg_type, repo), blob_indices) in &groups {
let group_blobs: Vec<_> = blob_indices.iter().map(|&i| all_blobs[i].clone()).collect();
let batch = build_metadata_batch(&group_blobs, |fi| reg.paths[fi as usize].clone(), &[], &[])
.map_err(|e| anyhow!("build sub-index batch: {e}"))?;
let schema_with_meta = arrow::datatypes::Schema::new_with_metadata(
compose_index_schema(&[]).fields().to_vec(),
meta_map.clone(),
);
sink.push_subindex(
&schema_with_meta,
std::slice::from_ref(&batch),
GroupKey {
pkg_type: *pkg_type,
repo: repo.clone(),
module_name: String::new(),
},
)?;
}
let manifest_offset = blob_bytes; let total_bytes_out = sink.finish()?;
let total_files = uf + cf;
log::info!(
"[stream] gatling archive: {} group(s), {} blob bytes, manifest at {}",
group_count,
blob_bytes,
manifest_offset
);
Ok(CompressionReport {
total_files,
compressed_files: cf,
uncompressed_files: uf,
chunks: total_chunks,
total_dirs: 0,
total_bytes_in: cb + ub,
total_bytes_out,
compressed_bytes: cb,
uncompressed_bytes: ub,
compression_ratio: if cb > 0 && total_bytes_out > ub {
(cb as f32 / (total_bytes_out - ub) as f32) * 100.0
} else {
0.0
},
})
}