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