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}