Skip to main content

lzip_parallel/
batch.rs

1//! Batch parallel ZIP extraction pipeline.
2//!
3//! Reads multiple ZIP files into a ~200 MB buffer, resolves all entries
4//! (Central Directory parse), then dispatches inflate work across N workers.
5//! While workers inflate batch N, the reader fills the buffer with batch N+1.
6//!
7//! This amortises I/O, Central Directory parsing, and rayon dispatch overhead
8//! across many ZIPs — achieving throughput that scales with core count.
9//!
10//! # Architecture
11//!
12//! ```text
13//! Reader thread                        Worker pool (8 cores)
14//! ────────────────                     ─────────────────────
15//! read ZIP₁..ZIPₖ into buf            inflate entries from prev buf
16//! parse CDs → entry list              write decompressed to disk
17//! partition entries across workers     ↓
18//!          ──── swap buffers ────→     receive new entry list
19//! ```
20
21use std::fs;
22use std::io::{Read, Write};
23use std::path::{Path, PathBuf};
24
25use crate::central_dir::{self, EntryLocation};
26use crate::entry::{self, ZipError};
27
28/// Maximum buffer size for batching ZIP file data.
29const CHUNK_BUFFER_SIZE: usize = 200 * 1024 * 1024; // 200 MB
30
31/// A resolved entry: knows where its compressed data lives in the batch buffer.
32#[derive(Clone)]
33struct ResolvedEntry {
34    /// Destination path relative to extraction root.
35    dest_rel: PathBuf,
36    /// Byte range in the batch buffer containing compressed data.
37    buf_offset: usize,
38    buf_len: usize,
39    /// ZIP compression method (0=stored, 8=deflate).
40    method: u16,
41    /// Expected decompressed size (from Central Directory).
42    uncompressed_size: u64,
43}
44
45/// Statistics returned after batch extraction.
46#[derive(Debug, Default)]
47pub struct BatchStats {
48    pub zips_processed: usize,
49    pub entries_extracted: usize,
50    pub compressed_bytes_read: u64,
51    pub decompressed_bytes_written: u64,
52    pub elapsed_secs: f64,
53}
54
55impl BatchStats {
56    pub fn throughput_mb_per_sec(&self) -> f64 {
57        let mb = self.decompressed_bytes_written as f64 / (1024.0 * 1024.0);
58        if self.elapsed_secs > 0.0 { mb / self.elapsed_secs } else { 0.0 }
59    }
60}
61
62/// Extract multiple ZIP files in parallel using a pipelined batch approach.
63///
64/// Reads ZIPs into a large buffer, resolves entries, then inflates in parallel.
65/// `dest` is the root output directory. Each ZIP's contents are extracted into
66/// a subdirectory named after the ZIP file (without extension).
67///
68/// Returns extraction statistics.
69pub fn extract_zips(zips: &[PathBuf], dest: &Path, flat: bool) -> Result<BatchStats, ZipError> {
70    let t0 = std::time::Instant::now();
71    let mut stats = BatchStats::default();
72
73    // Process ZIPs in batches that fit in the buffer.
74    let mut zip_idx = 0;
75    while zip_idx < zips.len() {
76        let (entries, buffer, zips_in_batch) = fill_buffer(&zips[zip_idx..], dest, flat)?;
77        zip_idx += zips_in_batch;
78        stats.zips_processed += zips_in_batch;
79
80        // Parallel inflate + write
81        let batch_stats = inflate_and_write(&entries, &buffer, dest)?;
82        stats.entries_extracted += batch_stats.0;
83        stats.decompressed_bytes_written += batch_stats.1;
84        stats.compressed_bytes_read += buffer.len() as u64;
85    }
86
87    stats.elapsed_secs = t0.elapsed().as_secs_f64();
88    Ok(stats)
89}
90
91/// Extract multiple ZIPs in-memory (no disk write). Returns all entries.
92///
93/// Used for benchmarking the decompression pipeline without I/O overhead.
94pub fn decompress_zips(zips: &[PathBuf]) -> Result<(Vec<(String, Vec<u8>)>, BatchStats), ZipError> {
95    let t0 = std::time::Instant::now();
96    let mut stats = BatchStats::default();
97    let mut all_entries = Vec::new();
98
99    let mut zip_idx = 0;
100    while zip_idx < zips.len() {
101        let (entries, buffer, zips_in_batch) = fill_buffer_no_dest(&zips[zip_idx..])?;
102        zip_idx += zips_in_batch;
103        stats.zips_processed += zips_in_batch;
104
105        // Parallel inflate (LPT / heaviest-first): weight each entry by its
106        // uncompressed size so a fat entry among tiny ones can't strand one
107        // core while the other 11 sit idle. Mirrors ljar's batch decode.
108        let results: Vec<Result<(String, Vec<u8>), ZipError>> =
109            gatling::gatling_forkjoin::gatling_for_each_balanced(
110                entries.len(),
111                0,
112                1,
113                |i| entries[i].uncompressed_size as u64,
114                |i| {
115                    let e = &entries[i];
116                    let compressed = &buffer[e.buf_offset..e.buf_offset + e.buf_len];
117                    let data = entry::decompress_entry_raw(compressed, e.method, e.uncompressed_size as usize)?;
118                    Ok((e.dest_rel.to_string_lossy().into_owned(), data))
119                },
120            );
121
122        for r in results {
123            let (name, data) = r?;
124            stats.decompressed_bytes_written += data.len() as u64;
125            all_entries.push((name, data));
126        }
127        stats.entries_extracted += entries.len();
128        stats.compressed_bytes_read += buffer.len() as u64;
129    }
130
131    stats.elapsed_secs = t0.elapsed().as_secs_f64();
132    Ok((all_entries, stats))
133}
134
135/// Fill the batch buffer with as many ZIPs as fit.
136/// Returns (resolved_entries, buffer, num_zips_consumed).
137fn fill_buffer(
138    zips: &[PathBuf],
139    _dest: &Path,
140    flat: bool,
141) -> Result<(Vec<ResolvedEntry>, Vec<u8>, usize), ZipError> {
142    let mut buffer = Vec::with_capacity(CHUNK_BUFFER_SIZE);
143    let mut entries = Vec::new();
144    let mut zips_consumed = 0;
145
146    for zip_path in zips {
147        let file_size = fs::metadata(zip_path)
148            .map_err(|_| ZipError("cannot stat ZIP file"))?
149            .len() as usize;
150
151        // If adding this ZIP would exceed buffer, stop (unless buffer is empty).
152        if !buffer.is_empty() && buffer.len() + file_size > CHUNK_BUFFER_SIZE {
153            break;
154        }
155
156        let buf_start = buffer.len();
157
158        // Read entire ZIP into buffer.
159        let mut f = fs::File::open(zip_path)
160            .map_err(|_| ZipError("cannot open ZIP file"))?;
161        buffer.resize(buf_start + file_size, 0);
162        f.read_exact(&mut buffer[buf_start..])
163            .map_err(|_| ZipError("cannot read ZIP file"))?;
164
165        // Parse Central Directory from the ZIP data in the buffer.
166        let zip_data = &buffer[buf_start..buf_start + file_size];
167        let locations = central_dir::read_central_directory(zip_data)?;
168
169        // Determine output subdirectory for this ZIP.
170        let zip_subdir = if flat {
171            PathBuf::new()
172        } else {
173            let stem = zip_path.file_stem()
174                .map(|s| s.to_string_lossy().into_owned())
175                .unwrap_or_else(|| "unknown".into());
176            PathBuf::from(stem)
177        };
178
179        // Resolve each entry.
180        for loc in &locations {
181            if loc.is_directory {
182                continue;
183            }
184            let compressed_range = resolve_compressed_range(zip_data, loc)?;
185            entries.push(ResolvedEntry {
186                dest_rel: zip_subdir.join(&loc.name),
187                buf_offset: buf_start + compressed_range.0,
188                buf_len: compressed_range.1,
189                method: loc.compression_method,
190                uncompressed_size: loc.uncompressed_size,
191            });
192        }
193
194        zips_consumed += 1;
195    }
196
197    Ok((entries, buffer, zips_consumed))
198}
199
200/// Fill buffer variant without destination paths (for in-memory benchmarks).
201fn fill_buffer_no_dest(
202    zips: &[PathBuf],
203) -> Result<(Vec<ResolvedEntry>, Vec<u8>, usize), ZipError> {
204    let mut buffer = Vec::with_capacity(CHUNK_BUFFER_SIZE);
205    let mut entries = Vec::new();
206    let mut zips_consumed = 0;
207
208    for zip_path in zips {
209        let file_size = fs::metadata(zip_path)
210            .map_err(|_| ZipError("cannot stat ZIP file"))?
211            .len() as usize;
212
213        if !buffer.is_empty() && buffer.len() + file_size > CHUNK_BUFFER_SIZE {
214            break;
215        }
216
217        let buf_start = buffer.len();
218        let mut f = fs::File::open(zip_path)
219            .map_err(|_| ZipError("cannot open ZIP file"))?;
220        buffer.resize(buf_start + file_size, 0);
221        f.read_exact(&mut buffer[buf_start..])
222            .map_err(|_| ZipError("cannot read ZIP file"))?;
223
224        let zip_data = &buffer[buf_start..buf_start + file_size];
225        let locations = central_dir::read_central_directory(zip_data)?;
226
227        let zip_name = zip_path.file_name()
228            .map(|s| s.to_string_lossy().into_owned())
229            .unwrap_or_else(|| "unknown".into());
230
231        for loc in &locations {
232            if loc.is_directory {
233                continue;
234            }
235            let compressed_range = resolve_compressed_range(zip_data, loc)?;
236            entries.push(ResolvedEntry {
237                dest_rel: PathBuf::from(format!("{}/{}", zip_name, loc.name)),
238                buf_offset: buf_start + compressed_range.0,
239                buf_len: compressed_range.1,
240                method: loc.compression_method,
241                uncompressed_size: loc.uncompressed_size,
242            });
243        }
244
245        zips_consumed += 1;
246    }
247
248    Ok((entries, buffer, zips_consumed))
249}
250
251/// Given a ZIP data slice and an EntryLocation, return (offset, len) of
252/// the compressed data within that slice.
253fn resolve_compressed_range(
254    zip_data: &[u8],
255    loc: &EntryLocation,
256) -> Result<(usize, usize), ZipError> {
257    let hdr = loc.local_header_offset as usize;
258
259    let sig = zip_data.get(hdr..hdr + 4)
260        .and_then(|b| b.try_into().ok())
261        .map(u32::from_le_bytes)
262        .ok_or(ZipError("local file header out of bounds"))?;
263    if sig != 0x04034b50 {
264        return Err(ZipError("invalid local file header signature"));
265    }
266
267    let name_len = zip_data.get(hdr + 26..hdr + 28)
268        .and_then(|b| b.try_into().ok())
269        .map(u16::from_le_bytes)
270        .ok_or(ZipError("local header truncated"))? as usize;
271
272    let extra_len = zip_data.get(hdr + 28..hdr + 30)
273        .and_then(|b| b.try_into().ok())
274        .map(u16::from_le_bytes)
275        .ok_or(ZipError("local header truncated"))? as usize;
276
277    let data_start = hdr + 30 + name_len + extra_len;
278    let data_len = loc.compressed_size as usize;
279
280    Ok((data_start, data_len))
281}
282
283/// Inflate all resolved entries in parallel and write to disk.
284/// Returns (entries_written, bytes_written).
285fn inflate_and_write(
286    entries: &[ResolvedEntry],
287    buffer: &[u8],
288    dest: &Path,
289) -> Result<(usize, u64), ZipError> {
290    // Pre-create all necessary directories.
291    let mut dirs: Vec<&Path> = entries.iter()
292        .filter_map(|e| e.dest_rel.parent())
293        .collect();
294    dirs.sort();
295    dirs.dedup();
296    for dir in dirs {
297        let full = dest.join(dir);
298        if !full.exists() {
299            fs::create_dir_all(&full)
300                .map_err(|_| ZipError("cannot create output directory"))?;
301        }
302    }
303
304    // Parallel inflate + write (LPT / heaviest-first): weight each entry by its
305    // uncompressed size so a fat entry among tiny ones can't strand one core.
306    let results: Vec<Result<u64, ZipError>> =
307        gatling::gatling_forkjoin::gatling_for_each_balanced(
308            entries.len(),
309            0,
310            1,
311            |i| entries[i].uncompressed_size as u64,
312            |i| {
313                let e = &entries[i];
314                let compressed = &buffer[e.buf_offset..e.buf_offset + e.buf_len];
315                // Cow-borrowing decode: STORE (method 0) writes the buffer slice
316                // directly — no to_vec round-trip for already-stored bytes (Law 1).
317                let data = entry::decompress_entry_raw_cow(compressed, e.method, e.uncompressed_size as usize)?;
318
319                let out_path = dest.join(&e.dest_rel);
320                let mut f = fs::File::create(&out_path)
321                    .map_err(|_| ZipError("cannot create output file"))?;
322                f.write_all(&data)
323                    .map_err(|_| ZipError("write failed"))?;
324
325                Ok(data.len() as u64)
326            },
327        );
328
329    let mut total_written = 0u64;
330    let mut count = 0usize;
331    for r in results {
332        total_written += r?;
333        count += 1;
334    }
335
336    Ok((count, total_written))
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::io::{Cursor, Write};
343
344    fn make_zip_file(items: &[(&str, &[u8])]) -> Vec<u8> {
345        use zip::write::SimpleFileOptions;
346        let mut buf = Vec::new();
347        let mut zw = zip::ZipWriter::new(Cursor::new(&mut buf));
348        let opts = SimpleFileOptions::default()
349            .compression_method(zip::CompressionMethod::Deflated);
350        for (name, data) in items {
351            zw.start_file(*name, opts).unwrap();
352            zw.write_all(data).unwrap();
353        }
354        zw.finish().unwrap();
355        buf
356    }
357
358    #[test]
359    fn batch_extract_two_zips() {
360        let tmp = tempfile::tempdir().unwrap();
361        let zip1_path = tmp.path().join("one.zip");
362        let zip2_path = tmp.path().join("two.zip");
363
364        fs::write(&zip1_path, make_zip_file(&[("a.txt", b"aaa")])).unwrap();
365        fs::write(&zip2_path, make_zip_file(&[("b.txt", b"bbb")])).unwrap();
366
367        let out_dir = tmp.path().join("out");
368        fs::create_dir_all(&out_dir).unwrap();
369
370        let stats = extract_zips(
371            &[zip1_path, zip2_path],
372            &out_dir,
373            false,
374        ).unwrap();
375
376        assert_eq!(stats.zips_processed, 2);
377        assert_eq!(stats.entries_extracted, 2);
378        assert_eq!(fs::read(out_dir.join("one/a.txt")).unwrap(), b"aaa");
379        assert_eq!(fs::read(out_dir.join("two/b.txt")).unwrap(), b"bbb");
380    }
381
382    #[test]
383    fn batch_decompress_in_memory() {
384        let tmp = tempfile::tempdir().unwrap();
385        let zip_path = tmp.path().join("test.zip");
386        fs::write(&zip_path, make_zip_file(&[
387            ("x.txt", b"xxx"),
388            ("y.txt", b"yyy"),
389        ])).unwrap();
390
391        let (entries, stats) = decompress_zips(&[zip_path]).unwrap();
392        assert_eq!(stats.zips_processed, 1);
393        assert_eq!(entries.len(), 2);
394    }
395}