Skip to main content

gatling/
parwrite.rs

1//! Parallel-writer gatling — the shared extract/decode/**write** fan-out.
2//!
3//! The streaming [`crate::gatling`] engine and the fork-join
4//! [`crate::gatling_forkjoin`] both funnel their decoded output through a
5//! **single ordered sink** (one collector thread draining an mpsc, one writer).
6//! For codecs whose output units are *independent files or independent regions
7//! of one pre-sized stream*, that single writer is the last un-parallelised
8//! stage — the serial drain that leaves cores idle while one thread copies and
9//! writes gigabytes.
10//!
11//! This module removes it. It is the parallel-writer sibling of
12//! [`gatling_for_each`](crate::gatling_forkjoin::gatling_for_each): the same
13//! no-barrier, self-dispatching (atomic-cursor) worker pool built on
14//! `std::thread::scope`, but each worker also **writes its own output** — there
15//! is no collector and no writer thread on the critical path.
16//!
17//! Two shapes, both format-agnostic (the caller plugs in the decode):
18//!
19//! - [`extract_entries_unordered`] — N workers self-dispatch **independent
20//!   entries** (ZIP/JAR members). Each worker `pread`s its entry's compressed
21//!   bytes from the shared input (thread-safe positional reads, no shared file
22//!   offset), decodes, and writes its **own output file**. Entries are
23//!   independent, so the file writes never conflict and no ordering is needed.
24//!
25//! - [`write_segments_positional`] — N workers self-dispatch **independent
26//!   segments of one output stream** (concatenated gzip members). The caller
27//!   pre-computes each segment's byte offset (a prefix-sum of known output
28//!   sizes) and pre-sizes the output file; each worker decodes its segment and
29//!   `pwrite`s it at the segment's offset. Exact byte order is preserved with
30//!   no ordered collector and no serial concatenation.
31//!
32//! Both reuse each worker's scratch buffers across the units it claims
33//! (zero-alloc hot loop after warm-up), never copy input into the pool (the
34//! decode reads directly from the shared source), and are rayon-free: pure
35//! `std::thread::scope` + one `AtomicUsize` cursor — the gatling soul.
36
37use std::fs::{self, File};
38use std::io::Write;
39use std::os::unix::fs::FileExt;
40use std::path::Path;
41use std::sync::atomic::{AtomicUsize, Ordering};
42
43/// Outcome of a parallel-writer run: how many units were written and how many
44/// failed (a failed unit is logged to stderr and skipped, never aborting the
45/// siblings).
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub struct WriteStats {
48    /// Units decoded and written successfully.
49    pub ok: usize,
50    /// Units that failed to read/decode/write (skipped, logged).
51    pub failed: usize,
52}
53
54/// One independent extraction unit for [`extract_entries_unordered`]: read
55/// `comp_len` compressed bytes at `data_offset` from the shared input, decode,
56/// and write the result to `out_dir.join(name)`.
57pub struct EntryJob {
58    /// Byte offset of the entry's compressed payload in the shared input file.
59    pub data_offset: u64,
60    /// Compressed payload length in bytes.
61    pub comp_len: usize,
62    /// Uncompressed size (exact, from the archive central directory) — sizes the
63    /// decode output buffer so the hot loop never reallocates.
64    pub out_size: usize,
65    /// Method-0 STORE entry: payload is copied verbatim (no decode).
66    pub is_store: bool,
67    /// Relative output path (joined onto `out_dir`).
68    pub name: String,
69}
70
71/// Extract independent entries across a no-barrier, self-dispatching worker pool
72/// — **each worker decodes AND writes its own output file**, with no ordered
73/// collector and no single writer thread.
74///
75/// `input` is the shared archive file; workers read each entry's compressed
76/// bytes with `read_exact_at` (`pread`), which is thread-safe and uses no shared
77/// file offset, so N workers read disjoint regions concurrently. `decode` turns
78/// one entry's compressed bytes into its uncompressed bytes: it is handed the
79/// compressed slice, the entry's exact `out_size`, and a **reused** output
80/// buffer to fill (cleared first). STORE entries (`is_store`) bypass `decode`
81/// and are copied verbatim.
82///
83/// Returns the [`WriteStats`]. A unit that fails to read/decode/write is logged
84/// to stderr and skipped; its siblings keep running.
85pub fn extract_entries_unordered<D>(
86    input: &File,
87    jobs: &[EntryJob],
88    out_dir: &Path,
89    n_workers: usize,
90    decode: D,
91) -> WriteStats
92where
93    D: Fn(&[u8], usize, &mut Vec<u8>) -> Result<(), String> + Sync,
94{
95    let n = jobs.len();
96    if n == 0 {
97        return WriteStats::default();
98    }
99    let workers = n_workers.max(1).min(n);
100
101    let next = AtomicUsize::new(0);
102    let ok = AtomicUsize::new(0);
103    let failed = AtomicUsize::new(0);
104
105    let next_ref = &next;
106    let ok_ref = &ok;
107    let failed_ref = &failed;
108    let decode_ref = &decode;
109
110    std::thread::scope(|s| {
111        for _ in 0..workers {
112            s.spawn(move || {
113                // Per-worker scratch, reused across every entry this worker
114                // claims — zero-alloc after the first (largest) entry.
115                let mut comp: Vec<u8> = Vec::new();
116                let mut out: Vec<u8> = Vec::new();
117                loop {
118                    let i = next_ref.fetch_add(1, Ordering::Relaxed);
119                    if i >= n {
120                        break;
121                    }
122                    let job = &jobs[i];
123
124                    // Read the compressed payload via pread (no shared offset).
125                    if comp.len() < job.comp_len {
126                        comp.resize(job.comp_len, 0);
127                    }
128                    if let Err(e) = input.read_exact_at(&mut comp[..job.comp_len], job.data_offset) {
129                        eprintln!("parwrite: read {}: {e}", job.name);
130                        failed_ref.fetch_add(1, Ordering::Relaxed);
131                        continue;
132                    }
133                    let comp_slice = &comp[..job.comp_len];
134
135                    // Decode (or copy for STORE) into the reused output buffer.
136                    out.clear();
137                    if job.is_store {
138                        out.extend_from_slice(comp_slice);
139                    } else if let Err(e) = decode_ref(comp_slice, job.out_size, &mut out) {
140                        eprintln!("parwrite: decode {}: {e}", job.name);
141                        failed_ref.fetch_add(1, Ordering::Relaxed);
142                        continue;
143                    }
144
145                    // Write our own output file (independent entry → no ordering).
146                    let dest = out_dir.join(&job.name);
147                    if let Some(parent) = dest.parent() {
148                        if let Err(e) = fs::create_dir_all(parent) {
149                            eprintln!("parwrite: mkdir {}: {e}", parent.display());
150                            failed_ref.fetch_add(1, Ordering::Relaxed);
151                            continue;
152                        }
153                    }
154                    match File::create(&dest).and_then(|mut f| f.write_all(&out)) {
155                        Ok(()) => {
156                            ok_ref.fetch_add(1, Ordering::Relaxed);
157                        }
158                        Err(e) => {
159                            eprintln!("parwrite: write {}: {e}", dest.display());
160                            failed_ref.fetch_add(1, Ordering::Relaxed);
161                        }
162                    }
163                }
164            });
165        }
166    });
167
168    WriteStats {
169        ok: ok.load(Ordering::Relaxed),
170        failed: failed.load(Ordering::Relaxed),
171    }
172}
173
174/// Write independent segments of **one** output stream at pre-computed offsets,
175/// across a no-barrier, self-dispatching worker pool — **each worker decodes
176/// AND `pwrite`s its own segment**, with no ordered collector and no serial
177/// concatenation.
178///
179/// The caller has already laid out the stream: `offsets[i]` is the byte offset
180/// in `out` where segment `i`'s decoded bytes belong (a prefix-sum of the
181/// segments' known output sizes), and `out` has been pre-sized to the total
182/// (e.g. via `set_len`). Each worker claims the next segment index, calls
183/// `decode(i, &mut buf)` to fill its reused buffer, and `write_all_at`
184/// (`pwrite`) writes it at `offsets[i]` — positional writes to disjoint regions
185/// never conflict, so exact stream byte-order is preserved without any ordering
186/// on the critical path.
187///
188/// `decode` should verify the decoded length matches what the caller assumed
189/// for `offsets` (returning `Err` on mismatch); a failed segment is logged and
190/// skipped, and the caller can detect `failed > 0` to fall back to a safe path.
191pub fn write_segments_positional<D>(
192    out: &File,
193    n_segments: usize,
194    offsets: &[u64],
195    n_workers: usize,
196    decode: D,
197) -> WriteStats
198where
199    D: Fn(usize, &mut Vec<u8>) -> Result<(), String> + Sync,
200{
201    assert_eq!(offsets.len(), n_segments, "offsets must have one entry per segment");
202    if n_segments == 0 {
203        return WriteStats::default();
204    }
205    let workers = n_workers.max(1).min(n_segments);
206
207    let next = AtomicUsize::new(0);
208    let ok = AtomicUsize::new(0);
209    let failed = AtomicUsize::new(0);
210
211    let next_ref = &next;
212    let ok_ref = &ok;
213    let failed_ref = &failed;
214    let decode_ref = &decode;
215
216    std::thread::scope(|s| {
217        for _ in 0..workers {
218            s.spawn(move || {
219                let mut buf: Vec<u8> = Vec::new();
220                loop {
221                    let i = next_ref.fetch_add(1, Ordering::Relaxed);
222                    if i >= n_segments {
223                        break;
224                    }
225                    buf.clear();
226                    if let Err(e) = decode_ref(i, &mut buf) {
227                        eprintln!("parwrite: segment {i} decode: {e}");
228                        failed_ref.fetch_add(1, Ordering::Relaxed);
229                        continue;
230                    }
231                    match out.write_all_at(&buf, offsets[i]) {
232                        Ok(()) => {
233                            ok_ref.fetch_add(1, Ordering::Relaxed);
234                        }
235                        Err(e) => {
236                            eprintln!("parwrite: segment {i} pwrite: {e}");
237                            failed_ref.fetch_add(1, Ordering::Relaxed);
238                        }
239                    }
240                }
241            });
242        }
243    });
244
245    WriteStats {
246        ok: ok.load(Ordering::Relaxed),
247        failed: failed.load(Ordering::Relaxed),
248    }
249}
250
251/// Write already-decoded slices of **one** output stream at pre-computed offsets,
252/// across a no-barrier, self-dispatching worker pool — **zero-copy parallel
253/// `pwrite`**, no ordered collector and no serial concatenation.
254///
255/// The two-phase sibling of [`write_segments_positional`]: use it when the
256/// segments are already materialised (e.g. members decoded in parallel by a
257/// prior [`gatling_for_each`](crate::gatling_forkjoin::gatling_for_each) pass)
258/// and their exact sizes — hence `offsets` — are known. `out` must be pre-sized
259/// to the total (`set_len`). Each worker claims the next slice index and
260/// `write_all_at` (`pwrite`)s `slices[i]` at `offsets[i]`; the slices are never
261/// copied through the pool, and positional writes to disjoint regions preserve
262/// exact stream byte-order with no ordering on the critical path.
263pub fn write_slices_positional(
264    out: &File,
265    slices: &[&[u8]],
266    offsets: &[u64],
267    n_workers: usize,
268) -> WriteStats {
269    assert_eq!(offsets.len(), slices.len(), "offsets must have one entry per slice");
270    let n = slices.len();
271    if n == 0 {
272        return WriteStats::default();
273    }
274    let workers = n_workers.max(1).min(n);
275
276    let next = AtomicUsize::new(0);
277    let ok = AtomicUsize::new(0);
278    let failed = AtomicUsize::new(0);
279
280    let next_ref = &next;
281    let ok_ref = &ok;
282    let failed_ref = &failed;
283
284    std::thread::scope(|s| {
285        for _ in 0..workers {
286            s.spawn(move || loop {
287                let i = next_ref.fetch_add(1, Ordering::Relaxed);
288                if i >= n {
289                    break;
290                }
291                match out.write_all_at(slices[i], offsets[i]) {
292                    Ok(()) => {
293                        ok_ref.fetch_add(1, Ordering::Relaxed);
294                    }
295                    Err(e) => {
296                        eprintln!("parwrite: slice {i} pwrite: {e}");
297                        failed_ref.fetch_add(1, Ordering::Relaxed);
298                    }
299                }
300            });
301        }
302    });
303
304    WriteStats {
305        ok: ok.load(Ordering::Relaxed),
306        failed: failed.load(Ordering::Relaxed),
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::io::Read;
314
315    #[test]
316    fn extract_writes_every_entry_concurrently() {
317        // Build an "archive": three payloads concatenated in one temp file.
318        let dir = std::env::temp_dir().join(format!("parwrite-test-{}", std::process::id()));
319        let _ = fs::remove_dir_all(&dir);
320        fs::create_dir_all(&dir).unwrap();
321        let arc_path = dir.join("archive.bin");
322
323        let payloads: Vec<Vec<u8>> = (0..3)
324            .map(|k| (0..1000).map(|i| (i as u8).wrapping_add(k as u8)).collect())
325            .collect();
326        let mut arc = Vec::new();
327        let mut jobs = Vec::new();
328        for (k, p) in payloads.iter().enumerate() {
329            jobs.push(EntryJob {
330                data_offset: arc.len() as u64,
331                comp_len: p.len(),
332                out_size: p.len(),
333                is_store: true, // verbatim copy path
334                name: format!("out_{k}.bin"),
335            });
336            arc.extend_from_slice(p);
337        }
338        fs::write(&arc_path, &arc).unwrap();
339        let f = File::open(&arc_path).unwrap();
340
341        let out_dir = dir.join("out");
342        let stats = extract_entries_unordered(&f, &jobs, &out_dir, 4, |_c, _s, _o| Ok(()));
343        assert_eq!(stats.ok, 3);
344        assert_eq!(stats.failed, 0);
345        for (k, p) in payloads.iter().enumerate() {
346            let got = fs::read(out_dir.join(format!("out_{k}.bin"))).unwrap();
347            assert_eq!(&got, p, "entry {k} bytes must round-trip");
348        }
349        let _ = fs::remove_dir_all(&dir);
350    }
351
352    #[test]
353    fn positional_writes_reassemble_in_order() {
354        let dir = std::env::temp_dir().join(format!("parwrite-pos-{}", std::process::id()));
355        let _ = fs::remove_dir_all(&dir);
356        fs::create_dir_all(&dir).unwrap();
357        let out_path = dir.join("stream.bin");
358
359        // Four segments of differing sizes; workers may finish out of order but
360        // the positional writes must reassemble the exact concatenation.
361        let segs: Vec<Vec<u8>> = vec![
362            vec![1u8; 100],
363            vec![2u8; 250],
364            vec![3u8; 50],
365            vec![4u8; 400],
366        ];
367        let mut offsets = Vec::new();
368        let mut total = 0u64;
369        for s in &segs {
370            offsets.push(total);
371            total += s.len() as u64;
372        }
373        let out = File::create(&out_path).unwrap();
374        out.set_len(total).unwrap();
375
376        let stats = write_segments_positional(&out, segs.len(), &offsets, 4, |i, buf| {
377            buf.extend_from_slice(&segs[i]);
378            Ok(())
379        });
380        assert_eq!(stats.ok, segs.len());
381        assert_eq!(stats.failed, 0);
382        drop(out);
383
384        let mut got = Vec::new();
385        File::open(&out_path).unwrap().read_to_end(&mut got).unwrap();
386        let mut want = Vec::new();
387        for s in &segs {
388            want.extend_from_slice(s);
389        }
390        assert_eq!(got, want, "positional segments must reassemble in stream order");
391        let _ = fs::remove_dir_all(&dir);
392    }
393}