Skip to main content

anodizer_core/
parallel.rs

1//! Shared bounded-parallelism helper used by stages that run one subprocess
2//! per sub-config (makeself, nfpm, snapcraft, flatpak, upx, …).
3//!
4//! The stages share the same Step 1 / Step 2 / Step 3 shape:
5//!
6//! 1. **Step 1** (serial, `&mut ctx`): render templates, stage files,
7//!    collect a `Vec<Job>` of fully-owned work units.
8//! 2. **Step 2** (parallel, bounded by `ctx.options.parallelism`): run one
9//!    subprocess per job in `std::thread::scope`.
10//! 3. **Step 3** (serial, `&mut ctx`): register the returned artifacts.
11//!
12//! Before this helper every stage hand-rolled the Step 2 loop —
13//! `for chunk in jobs.chunks(n) { thread::scope(|s| …) }` with its own
14//! join-unwrap-or-panic handling. The pattern is now shared here so new
15//! parallelized stages just write `run_job`.
16//!
17//! Semantics match the previous hand-rolled loops exactly:
18//!
19//! - **Bounded concurrency**: at most `parallelism` workers run at once,
20//!   enforced by chunking the job list and scoping threads per-chunk.
21//! - **Fail-fast within a chunk**: if any worker in a chunk fails, the whole
22//!   chunk still runs to completion (threads are already spawned), but the
23//!   caller receives the first error and processes no further chunks. The
24//!   completed siblings' work is still accounted for: additional failures
25//!   in the batch are logged, and a warn summarizes partial progress.
26//! - **Panic-safe**: a worker panic becomes an `anyhow::Error` annotated
27//!   with `stage_name`, so a panicked thread doesn't leave the pool
28//!   deadlocked or drop all other results on the floor.
29//! - **Order-preserving**: results are collected in job-submission order, so
30//!   downstream artifact registration remains deterministic.
31
32use anyhow::{Result, anyhow};
33
34use crate::log::StageLogger;
35use std::sync::{Mutex, MutexGuard};
36
37/// Acquire a `Mutex` guard, recovering from poison rather than panicking.
38///
39/// A poisoned lock means a sibling worker thread panicked while holding
40/// the guard. For the data shapes this helper is used on (counters,
41/// `Vec` accumulators), the inner state has no invariant a panic could
42/// have broken — the worst case is one partial write missing. Panicking
43/// the current worker too would abandon its already-completed network
44/// call without updating the count, silently inflating the operator's
45/// `failed` bucket.
46pub fn lock_recover<'a, T>(m: &'a Mutex<T>, log: &StageLogger, label: &str) -> MutexGuard<'a, T> {
47    match m.lock() {
48        Ok(g) => g,
49        Err(poisoned) => {
50            log.warn(&format!(
51                "{label} mutex poisoned by sibling thread panic; recovering state"
52            ));
53            poisoned.into_inner()
54        }
55    }
56}
57
58/// Translate a `thread::JoinHandle::join` result's panic payload into
59/// an `anyhow::Error` tagged with `label`. The two common panic
60/// payload shapes (`&'static str` / `String`) are downcast so the
61/// surfaced message is readable rather than the opaque `Any`
62/// placeholder.
63///
64/// Accepts `Result<T, Box<dyn Any + Send>>` rather than the handle
65/// itself so a single helper covers both [`std::thread::JoinHandle`]
66/// and [`std::thread::ScopedJoinHandle`] — both expose `.join()`
67/// returning the same `Result` shape.
68///
69/// Use when the worker returns `T` and the caller wants `Result<T>`
70/// so a panic doesn't propagate as a silently-lost result. For
71/// workers that already return `Result<T, anyhow::Error>`, prefer
72/// [`run_parallel_chunks`] which bakes this in.
73pub fn join_panic_to_err<T>(join_result: std::thread::Result<T>, label: &str) -> Result<T> {
74    join_result.map_err(|panic_payload| {
75        let msg = if let Some(s) = panic_payload.downcast_ref::<&'static str>() {
76            (*s).to_string()
77        } else if let Some(s) = panic_payload.downcast_ref::<String>() {
78            s.clone()
79        } else {
80            format!("{:?}", panic_payload)
81        };
82        anyhow!("{label} worker thread panicked: {msg}")
83    })
84}
85
86/// Run `run_job` across `jobs` with bounded parallelism. Returns the
87/// per-job results in submission order.
88///
89/// `stage_name` is embedded in the panic error message so a crash in one
90/// stage is attributable at a glance (`"nfpm worker thread panicked"` vs
91/// `"snapcraft worker thread panicked"`).
92///
93/// `parallelism` is clamped to `>= 1` internally, so callers can pass
94/// `ctx.options.parallelism` without pre-clamping.
95///
96/// On failure the FIRST error is returned and no further chunks run, but
97/// the failed chunk's completed siblings are never silently discarded:
98/// every additional failure in the chunk is logged as a warning (only the
99/// first error propagates), and a warn summarizes the partial progress —
100/// how many jobs in the batch succeeded before the failure and how many
101/// later jobs were never started.
102pub fn run_parallel_chunks<J, T, F>(
103    jobs: &[J],
104    parallelism: usize,
105    stage_name: &'static str,
106    log: &StageLogger,
107    run_job: F,
108) -> Result<Vec<T>>
109where
110    J: Sync,
111    T: Send,
112    F: Fn(&J) -> Result<T> + Sync,
113{
114    let parallelism = parallelism.max(1);
115    let mut results: Vec<T> = Vec::with_capacity(jobs.len());
116
117    // A worker thread starts outside every retry scope, so the caller's label
118    // is read here and re-entered inside each worker; without it the stage's
119    // parallel backoff would be filed as unattributed.
120    let retry_scope = crate::retry::current_scope();
121    for chunk in jobs.chunks(parallelism) {
122        let chunk_results: Vec<Result<T>> = std::thread::scope(|s| {
123            let handles: Vec<_> = chunk
124                .iter()
125                .map(|job| {
126                    s.spawn(|| {
127                        let _scope = crate::retry::RetryScope::inherit(retry_scope.clone());
128                        run_job(job)
129                    })
130                })
131                .collect();
132            handles
133                .into_iter()
134                .map(|h| {
135                    h.join()
136                        .unwrap_or_else(|_| Err(anyhow!("{} worker thread panicked", stage_name)))
137                })
138                .collect()
139        });
140
141        // The whole chunk already ran to completion (its threads were
142        // spawned together), so account for EVERY result before propagating:
143        // a bare `push(r?)` would silently drop the completed siblings'
144        // work and any second/third failure in the same batch.
145        let mut first_err: Option<anyhow::Error> = None;
146        let mut chunk_ok = 0usize;
147        let chunk_len = chunk.len();
148        for r in chunk_results {
149            match r {
150                Ok(t) => {
151                    chunk_ok += 1;
152                    results.push(t);
153                }
154                Err(e) => {
155                    if first_err.is_none() {
156                        first_err = Some(e);
157                    } else {
158                        // Warn with the root cause only: the full anyhow chain
159                        // can embed unredacted subprocess/HTTP detail (upload
160                        // URLs, response bodies) that the propagated error gets
161                        // caller-side redaction for but this path would not.
162                        let root = e.root_cause().to_string();
163                        let first = root.lines().next().unwrap_or("");
164                        let mut line: String = first.chars().take(200).collect();
165                        if first.chars().count() > 200 {
166                            line.push('…');
167                        }
168                        log.warn(&format!(
169                            "{stage_name}: additional failure in the same batch \
170                             (only the first is propagated): {line}"
171                        ));
172                        log.verbose(&format!("{stage_name}: additional failure detail: {e:#}"));
173                    }
174                }
175            }
176        }
177        if let Some(err) = first_err {
178            let not_started = jobs.len() - results.len() - (chunk_len - chunk_ok);
179            log.warn(&format!(
180                "{stage_name}: {chunk_ok} of {chunk_len} item(s) in this batch succeeded \
181                 before the failure ({completed} of {total} total completed, \
182                 {not_started} never started)",
183                completed = results.len(),
184                total = jobs.len(),
185            ));
186            return Err(err);
187        }
188    }
189    Ok(results)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::test_helpers::test_logger;
196    use std::sync::atomic::{AtomicUsize, Ordering};
197
198    #[test]
199    fn preserves_submission_order() {
200        // Even with multi-threaded execution, the returned Vec must mirror
201        // the input slice order so downstream artifact registration is
202        // deterministic across runs.
203        let jobs: Vec<u32> = (0..20).collect();
204        let out =
205            run_parallel_chunks(&jobs, 4, "test", test_logger(), |job| Ok(*job * 10)).unwrap();
206        assert_eq!(out, (0..20).map(|i| i * 10).collect::<Vec<_>>());
207    }
208
209    #[test]
210    fn bounded_concurrency() {
211        // With parallelism=2 across 10 jobs, no more than 2 workers should
212        // be in-flight at once. Observed via an AtomicUsize peak
213        // counter that each worker increments on entry and decrements on
214        // exit, with a small sleep to force overlap.
215        let jobs: Vec<u32> = (0..10).collect();
216        let in_flight = AtomicUsize::new(0);
217        let peak = AtomicUsize::new(0);
218
219        run_parallel_chunks(&jobs, 2, "test", test_logger(), |_| {
220            let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
221            peak.fetch_max(now, Ordering::SeqCst);
222            std::thread::sleep(std::time::Duration::from_millis(10));
223            in_flight.fetch_sub(1, Ordering::SeqCst);
224            Ok(())
225        })
226        .unwrap();
227
228        assert!(
229            peak.load(Ordering::SeqCst) <= 2,
230            "peak in-flight workers exceeded parallelism bound"
231        );
232    }
233
234    #[test]
235    fn propagates_first_error() {
236        // A single failing job should fail the batch. The job index returned
237        // in the error payload asserts the failing worker is the one the
238        // caller receives (not silently swallowed by a later success).
239        let jobs: Vec<u32> = (0..4).collect();
240        let result = run_parallel_chunks(&jobs, 2, "test", test_logger(), |job| {
241            if *job == 2 {
242                Err(anyhow!("job 2 failed"))
243            } else {
244                Ok(*job)
245            }
246        });
247        let err = result.unwrap_err();
248        assert!(
249            err.to_string().contains("job 2 failed"),
250            "unexpected error: {}",
251            err
252        );
253    }
254
255    /// A failed chunk must not silently swallow its completed siblings'
256    /// work or the batch's additional failures: every job in the chunk
257    /// still runs, the extra failure is logged, and a warn summarizes the
258    /// partial progress (succeeded-in-batch / total-completed / never-started).
259    #[test]
260    fn failed_chunk_reports_partial_progress_and_sibling_failures() {
261        let jobs: Vec<u32> = (0..8).collect();
262        let executed = AtomicUsize::new(0);
263        let (log, cap) = StageLogger::with_capture("test", crate::log::Verbosity::Quiet);
264
265        // parallelism=4 → chunk [0,1,2,3]: jobs 1 and 3 fail, 0 and 2 succeed;
266        // chunks [4..] must never start.
267        let result = run_parallel_chunks(&jobs, 4, "partial-stage", &log, |job| {
268            executed.fetch_add(1, Ordering::SeqCst);
269            if *job == 1 || *job == 3 {
270                Err(anyhow!("job {} failed", job)
271                    .context("POST https://uploads.example/secret-token failed"))
272            } else {
273                Ok(*job)
274            }
275        });
276
277        let err = result.unwrap_err();
278        assert!(
279            format!("{err:#}").contains("job 1 failed"),
280            "the FIRST error (submission order) must propagate: {err:#}"
281        );
282        assert_eq!(
283            executed.load(Ordering::SeqCst),
284            4,
285            "the whole failed chunk runs; later chunks never start"
286        );
287        let warns = cap.warn_messages();
288        assert!(
289            warns
290                .iter()
291                .any(|m| m.contains("job 3 failed") && m.contains("only the first is propagated")),
292            "the sibling failure must be logged, not dropped: {warns:?}"
293        );
294        assert!(
295            !warns.iter().any(|m| m.contains("uploads.example")),
296            "the sibling warn must carry the root cause only, never the \
297             unredacted context chain: {warns:?}"
298        );
299        let details: Vec<String> = cap
300            .all_messages()
301            .into_iter()
302            .filter(|(lvl, _)| *lvl == crate::log::LogLevel::Verbose)
303            .map(|(_, m)| m)
304            .collect();
305        assert!(
306            details
307                .iter()
308                .any(|m| m.contains("uploads.example") && m.contains("job 3 failed")),
309            "the full chain must still be available at verbose: {details:?}"
310        );
311        assert!(
312            warns.iter().any(|m| m.contains(
313                "partial-stage: 2 of 4 item(s) in this batch succeeded before the failure"
314            ) && m.contains("2 of 8 total completed")
315                && m.contains("4 never started")),
316            "partial-progress summary must be warned: {warns:?}"
317        );
318    }
319
320    /// A clean run must emit NO partial-progress warns — the summary is a
321    /// failure-path diagnostic, not routine chatter.
322    #[test]
323    fn successful_run_emits_no_warns() {
324        let jobs: Vec<u32> = (0..6).collect();
325        let (log, cap) = StageLogger::with_capture("test", crate::log::Verbosity::Quiet);
326        let out = run_parallel_chunks(&jobs, 3, "test", &log, |job| Ok(*job)).unwrap();
327        assert_eq!(out.len(), 6);
328        assert_eq!(cap.warn_count(), 0, "no warns on a clean run");
329    }
330
331    #[test]
332    fn fan_out_workers_attribute_backoff_to_the_callers_retry_scope() {
333        // A worker thread enters the pool holding no scope of its own, so
334        // without inheritance a stage's parallel backoff is filed as
335        // unattributed and the run summary can no longer name the slow remote.
336        let scope = "parallel-fan-out-attribution-b7c2";
337        let jobs: Vec<u64> = vec![3_000_000_000, 3_000_000_001];
338        {
339            let _guard = crate::retry::RetryScope::enter(scope);
340            run_parallel_chunks(&jobs, 2, "test", test_logger(), |job| {
341                crate::retry::record_retry_backoff(std::time::Duration::from_millis(*job));
342                Ok(*job)
343            })
344            .unwrap();
345        }
346        let recorded = crate::retry::retry_scope_breakdown()
347            .into_iter()
348            .find(|(k, _, _)| k == scope);
349        assert_eq!(
350            recorded.map(|(_, retries, backoff)| (retries, backoff)),
351            Some((2, std::time::Duration::from_millis(6_000_000_001))),
352            "both workers' backoff must land under the caller's scope"
353        );
354    }
355
356    #[test]
357    fn zero_parallelism_clamps_to_one() {
358        // `ctx.options.parallelism` can legitimately be 0 (unset) —
359        // callers must not need to pre-clamp. Verify the helper runs
360        // sequentially in that case rather than spawning 0 threads.
361        let jobs: Vec<u32> = (0..3).collect();
362        let out = run_parallel_chunks(&jobs, 0, "test", test_logger(), |job| Ok(*job + 1)).unwrap();
363        assert_eq!(out, vec![1, 2, 3]);
364    }
365
366    #[test]
367    fn empty_jobs_returns_empty() {
368        let out: Vec<u32> =
369            run_parallel_chunks::<u32, u32, _>(&[], 4, "test", test_logger(), |_| Ok(0)).unwrap();
370        assert!(out.is_empty());
371    }
372
373    #[test]
374    fn panic_in_worker_becomes_anyhow_error() {
375        // A panicking worker must not take down the whole thread::scope
376        // silently — the error must be attributable, carrying the stage name.
377        let jobs: Vec<u32> = vec![1, 2, 3];
378        let result = run_parallel_chunks(
379            &jobs,
380            2,
381            "explode-stage",
382            test_logger(),
383            |job| -> Result<u32> {
384                if *job == 2 {
385                    panic!("boom");
386                }
387                Ok(*job)
388            },
389        );
390        let err = result.unwrap_err();
391        assert!(
392            err.to_string()
393                .contains("explode-stage worker thread panicked"),
394            "unexpected error: {}",
395            err
396        );
397    }
398
399    // ---------- lock_recover ----------
400
401    #[test]
402    fn lock_recover_returns_inner_when_unpoisoned() {
403        // Happy path: an unpoisoned Mutex yields its guard, the helper
404        // adds no observable behavior over a bare `.lock().unwrap()`.
405        let log = test_logger();
406        let m = Mutex::new(0u32);
407        {
408            let mut g = lock_recover(&m, log, "test");
409            *g = 42;
410        }
411        assert_eq!(*m.lock().unwrap(), 42);
412    }
413
414    #[test]
415    fn lock_recover_recovers_from_poison() {
416        // A poisoned Mutex (sibling thread panicked while holding the
417        // guard) must yield the inner state rather than panicking the
418        // recovering thread too.
419        let log = test_logger();
420        let m = std::sync::Arc::new(Mutex::new(7u32));
421        let m_for_thread = std::sync::Arc::clone(&m);
422        let h = std::thread::spawn(move || {
423            let _g = m_for_thread.lock().unwrap();
424            panic!("poison the mutex");
425        });
426        let _ = h.join();
427        assert!(m.is_poisoned(), "test setup: mutex should be poisoned");
428        let g = lock_recover(&m, log, "test");
429        assert_eq!(*g, 7);
430    }
431
432    // ---------- join_panic_to_err ----------
433
434    #[test]
435    fn join_panic_to_err_passes_through_success() {
436        let h = std::thread::spawn(|| 42u32);
437        let r = join_panic_to_err(h.join(), "worker").unwrap();
438        assert_eq!(r, 42);
439    }
440
441    #[test]
442    fn join_panic_to_err_translates_str_panic() {
443        // The most common panic shape in this codebase is `panic!("msg")`
444        // which produces a `&'static str` payload — verify the message
445        // survives into the surfaced anyhow chain.
446        let h = std::thread::spawn(|| -> u32 {
447            panic!("kaboom");
448        });
449        let err = join_panic_to_err(h.join(), "worker").unwrap_err();
450        let s = err.to_string();
451        assert!(
452            s.contains("worker worker thread panicked") && s.contains("kaboom"),
453            "unexpected error: {}",
454            s
455        );
456    }
457
458    #[test]
459    fn join_panic_to_err_translates_string_panic() {
460        // The other common panic shape — `format!()`-derived `String`
461        // payloads — must also be downcast rather than printing as `Any`.
462        let h = std::thread::spawn(|| -> u32 {
463            panic!("{}", String::from("string-panic"));
464        });
465        let err = join_panic_to_err(h.join(), "worker").unwrap_err();
466        assert!(
467            err.to_string().contains("string-panic"),
468            "unexpected error: {}",
469            err
470        );
471    }
472
473    #[test]
474    fn join_panic_to_err_works_on_scoped_handle() {
475        // ScopedJoinHandle::join returns the same Result shape as
476        // JoinHandle::join — verify a single helper covers both so
477        // callers using `std::thread::scope` don't need a second variant.
478        let out: Result<u32> = std::thread::scope(|s| {
479            let h = s.spawn(|| 99u32);
480            join_panic_to_err(h.join(), "scoped")
481        });
482        assert_eq!(out.unwrap(), 99);
483    }
484}