Skip to main content

shipper_core/engine/parallel/
mod.rs

1//! Wave-based parallel publishing engine.
2//!
3//! Schedules independent crates into concurrent publish waves based on the
4//! dependency graph produced by `shipper_plan::ReleasePlan::group_by_levels`.
5//!
6//! Absorbed from the standalone `shipper-engine-parallel` crate. See
7//! `CLAUDE.md` alongside this module for module-level guidance.
8
9use std::collections::VecDeque;
10use std::path::Path;
11use std::sync::{Arc, Mutex};
12use std::thread;
13use std::time::{Duration, Instant};
14
15use anyhow::Result;
16
17use crate::plan::PlannedWorkspace;
18use crate::state::events;
19use shipper_registry::HttpRegistryClient as RegistryClient;
20use shipper_types::{
21    ExecutionState, PackageEvidence, PackageReceipt, PackageState, RuntimeOptions,
22};
23
24mod policy;
25mod publish;
26mod readiness;
27mod reconcile;
28mod webhook;
29
30/// Re-exported for parallel publish wave planning.
31pub use crate::plan::chunking::chunk_by_max_concurrent;
32
33use publish::run_publish_level;
34use webhook::WebhookEvent;
35#[cfg(test)]
36use webhook::maybe_send_event;
37
38/// Reporter interface shared with the host crate. Parallel publish forwards
39/// status updates and warnings through this trait.
40pub trait Reporter {
41    fn info(&mut self, msg: &str);
42    fn warn(&mut self, msg: &str);
43    fn error(&mut self, msg: &str);
44
45    #[allow(clippy::too_many_arguments)]
46    fn retry_wait(
47        &mut self,
48        pkg_name: &str,
49        pkg_version: &str,
50        attempt: u32,
51        max_attempts: u32,
52        delay: Duration,
53        reason: shipper_types::ErrorClass,
54        message: &str,
55    ) {
56        self.warn(&format!(
57            "{}@{}: {} ({:?}); next attempt in {} (attempt {}/{})",
58            pkg_name,
59            pkg_version,
60            message,
61            reason,
62            humantime::format_duration(delay),
63            attempt.saturating_add(1),
64            max_attempts,
65        ));
66        thread::sleep(delay);
67    }
68}
69
70/// Adapter that bridges the host crate's `crate::engine::Reporter` trait into
71/// this module's local `Reporter` trait. Allows callers inside `shipper` to
72/// pass their existing reporters without any wrapping at the call site.
73struct HostReporterAdapter<'a> {
74    inner: &'a mut dyn crate::engine::Reporter,
75}
76
77impl<'a> Reporter for HostReporterAdapter<'a> {
78    fn info(&mut self, msg: &str) {
79        self.inner.info(msg);
80    }
81    fn warn(&mut self, msg: &str) {
82        self.inner.warn(msg);
83    }
84    fn error(&mut self, msg: &str) {
85        self.inner.error(msg);
86    }
87
88    fn retry_wait(
89        &mut self,
90        pkg_name: &str,
91        pkg_version: &str,
92        attempt: u32,
93        max_attempts: u32,
94        delay: Duration,
95        reason: shipper_types::ErrorClass,
96        message: &str,
97    ) {
98        self.inner.retry_wait(
99            pkg_name,
100            pkg_version,
101            attempt,
102            max_attempts,
103            delay,
104            reason,
105            message,
106        );
107    }
108}
109
110pub(super) struct RetryWaitNotice {
111    pub(super) pkg_name: String,
112    pub(super) pkg_version: String,
113    pub(super) attempt: u32,
114    pub(super) max_attempts: u32,
115    pub(super) delay: Duration,
116    pub(super) reason: shipper_types::ErrorClass,
117    pub(super) message: String,
118    pub(super) started_at: Instant,
119}
120
121#[derive(Default)]
122pub(super) struct SendReporter {
123    infos: Mutex<Vec<String>>,
124    warns: Mutex<Vec<String>>,
125    errors: Mutex<Vec<String>>,
126    retry_waits: Mutex<VecDeque<RetryWaitNotice>>,
127}
128
129impl SendReporter {
130    pub(super) fn info(&self, msg: &str) {
131        self.infos.lock().unwrap().push(msg.to_string());
132    }
133
134    pub(super) fn warn(&self, msg: &str) {
135        self.warns.lock().unwrap().push(msg.to_string());
136    }
137
138    pub(super) fn error(&self, msg: &str) {
139        self.errors.lock().unwrap().push(msg.to_string());
140    }
141
142    #[allow(clippy::too_many_arguments)]
143    pub(super) fn retry_wait(
144        &self,
145        pkg_name: &str,
146        pkg_version: &str,
147        attempt: u32,
148        max_attempts: u32,
149        delay: Duration,
150        reason: shipper_types::ErrorClass,
151        message: &str,
152    ) {
153        self.retry_waits.lock().unwrap().push_back(RetryWaitNotice {
154            pkg_name: pkg_name.to_string(),
155            pkg_version: pkg_version.to_string(),
156            attempt,
157            max_attempts,
158            delay,
159            reason,
160            message: message.to_string(),
161            started_at: Instant::now(),
162        });
163        thread::sleep(delay);
164    }
165
166    fn drain_infos(&self) -> Vec<String> {
167        std::mem::take(&mut *self.infos.lock().unwrap())
168    }
169
170    fn drain_warns(&self) -> Vec<String> {
171        std::mem::take(&mut *self.warns.lock().unwrap())
172    }
173
174    fn drain_errors(&self) -> Vec<String> {
175        std::mem::take(&mut *self.errors.lock().unwrap())
176    }
177
178    fn drain_retry_waits(&self) -> Vec<RetryWaitNotice> {
179        self.retry_waits.lock().unwrap().drain(..).collect()
180    }
181}
182
183fn replay_buffered_messages(reporter: &mut dyn Reporter, send_reporter: &SendReporter) {
184    for msg in send_reporter.drain_infos() {
185        reporter.info(&msg);
186    }
187    for msg in send_reporter.drain_warns() {
188        reporter.warn(&msg);
189    }
190    for msg in send_reporter.drain_errors() {
191        reporter.error(&msg);
192    }
193}
194
195pub(super) fn drain_retry_waits(reporter: &mut dyn Reporter, send_reporter: &SendReporter) {
196    for notice in send_reporter.drain_retry_waits() {
197        let remaining = notice.delay.saturating_sub(notice.started_at.elapsed());
198        reporter.retry_wait(
199            &notice.pkg_name,
200            &notice.pkg_version,
201            notice.attempt,
202            notice.max_attempts,
203            remaining,
204            notice.reason,
205            &notice.message,
206        );
207    }
208}
209
210/// Run publish in parallel mode using `shipper`'s wrapped `RegistryClient`.
211///
212/// This is the entry point called by `engine::run_publish`. It adapts the
213/// host crate's types (`crate::registry::RegistryClient`, `crate::engine::Reporter`)
214/// into the inner ones expected by the parallel engine.
215///
216/// Constructs a fresh `shipper_registry::RegistryClient` from the host
217/// registry's configuration so the call works regardless of which `registry`
218/// impl variant is active (micro wrapper vs. in-tree legacy).
219pub fn run_publish_parallel(
220    ws: &crate::plan::PlannedWorkspace,
221    opts: &RuntimeOptions,
222    st: &mut ExecutionState,
223    state_dir: &Path,
224    reg: &crate::registry::RegistryClient,
225    reporter: &mut dyn crate::engine::Reporter,
226) -> Result<Vec<PackageReceipt>> {
227    let api_base = reg.registry().api_base.trim_end_matches('/');
228    let reg_inner = shipper_registry::HttpRegistryClient::new(api_base);
229    let mut adapter = HostReporterAdapter { inner: reporter };
230    run_publish_parallel_inner(ws, opts, st, state_dir, &reg_inner, &mut adapter)
231}
232
233/// Inner entry point operating on `shipper_registry::RegistryClient` and the
234/// local `Reporter` trait. Kept `pub` for tests inside this module.
235pub(crate) fn run_publish_parallel_inner(
236    ws: &PlannedWorkspace,
237    opts: &RuntimeOptions,
238    st: &mut ExecutionState,
239    state_dir: &Path,
240    reg: &RegistryClient,
241    reporter: &mut dyn Reporter,
242) -> Result<Vec<PackageReceipt>> {
243    let levels = ws.plan.group_by_levels();
244
245    reporter.info(&format!(
246        "parallel publish: {} levels, {} packages total",
247        levels.len(),
248        ws.plan.packages.len()
249    ));
250
251    // Send webhook notification: publish started
252    webhook::maybe_send_event(
253        &opts.webhook,
254        WebhookEvent::PublishStarted {
255            plan_id: ws.plan.plan_id.clone(),
256            package_count: ws.plan.packages.len(),
257            registry: ws.plan.registry.name.clone(),
258        },
259    );
260
261    // Initialize event log
262    let events_path = events::events_path(state_dir);
263    let event_log = Arc::new(Mutex::new(events::EventLog::new()));
264
265    // Wrap state and reporter in Arc<Mutex<>> for thread safety
266    let st_arc = Arc::new(Mutex::new(st.clone()));
267
268    let send_reporter = Arc::new(SendReporter {
269        infos: Mutex::new(Vec::new()),
270        warns: Mutex::new(Vec::new()),
271        errors: Mutex::new(Vec::new()),
272        retry_waits: Mutex::new(VecDeque::new()),
273    });
274
275    let mut all_receipts: Vec<PackageReceipt> = Vec::new();
276
277    // Track if we've reached the resume point if one was specified
278    let mut reached_resume_point = opts.resume_from.is_none();
279
280    for level in &levels {
281        // If we haven't reached the resume point, check if it's in this level
282        if !reached_resume_point {
283            if level
284                .packages
285                .iter()
286                .any(|p| Some(&p.name) == opts.resume_from.as_ref())
287            {
288                reached_resume_point = true;
289            } else {
290                // Check if all packages in this level are already done in state
291                // If so, we can "skip" it silently (as already done).
292                // If NOT done, we skip it with a warning because of resume_from.
293                let mut level_done = true;
294                {
295                    let st_guard = st_arc.lock().unwrap();
296                    for p in &level.packages {
297                        let key = crate::runtime::execution::pkg_key(&p.name, &p.version);
298                        if let Some(progress) = st_guard.packages.get(&key) {
299                            if !matches!(
300                                progress.state,
301                                PackageState::Published | PackageState::Skipped { .. }
302                            ) {
303                                level_done = false;
304                                break;
305                            }
306                        } else {
307                            level_done = false;
308                            break;
309                        }
310                    }
311                }
312
313                if level_done {
314                    reporter.info(&format!(
315                        "Level {}: already complete (skipping)",
316                        level.level
317                    ));
318                } else {
319                    reporter.warn(&format!(
320                        "Level {}: skipping (before resume point {})",
321                        level.level,
322                        opts.resume_from.as_ref().unwrap()
323                    ));
324                }
325
326                // Still need to "collect" receipts for these skipped packages so they appear in final receipt
327                for p in &level.packages {
328                    let key = crate::runtime::execution::pkg_key(&p.name, &p.version);
329                    let st_guard = st_arc.lock().unwrap();
330                    if let Some(progress) = st_guard.packages.get(&key) {
331                        all_receipts.push(PackageReceipt {
332                            name: p.name.clone(),
333                            version: p.version.clone(),
334                            attempts: progress.attempts,
335                            state: progress.state.clone(),
336                            started_at: chrono::Utc::now(),
337                            finished_at: chrono::Utc::now(),
338                            duration_ms: 0,
339                            evidence: PackageEvidence {
340                                attempts: vec![],
341                                readiness_checks: vec![],
342                            },
343                            compromised_at: None,
344                            compromised_by: None,
345                            superseded_by: None,
346                        });
347                    }
348                }
349                continue;
350            }
351        }
352
353        let level_receipts = run_publish_level(
354            level,
355            ws,
356            opts,
357            reg,
358            &st_arc,
359            state_dir,
360            &event_log,
361            &events_path,
362            reporter,
363            &send_reporter,
364        )?;
365        all_receipts.extend(level_receipts);
366        replay_buffered_messages(reporter, send_reporter.as_ref());
367    }
368
369    replay_buffered_messages(reporter, send_reporter.as_ref());
370
371    // Copy updated state back
372    let updated_st = st_arc.lock().unwrap();
373    *st = updated_st.clone();
374
375    Ok(all_receipts)
376}
377
378#[cfg(test)]
379mod property_tests {
380    use proptest::prelude::*;
381
382    use super::chunk_by_max_concurrent;
383
384    fn names() -> impl Strategy<Value = Vec<String>> {
385        prop::collection::vec("[a-z]{1,8}", 0..64)
386    }
387
388    proptest! {
389        #[test]
390        fn chunking_preserves_order_and_limits_size(items in names(), limit in 0usize..64) {
391            let chunks = chunk_by_max_concurrent(&items, limit);
392            let flattened: Vec<String> = chunks.iter().flatten().cloned().collect();
393
394            prop_assert_eq!(flattened.as_slice(), items.as_slice());
395
396            let max_size = limit.max(1);
397            for chunk in &chunks {
398                prop_assert!(chunk.len() <= max_size);
399            }
400
401            if !flattened.is_empty() {
402                if max_size == 1 {
403                    prop_assert!(chunks.iter().all(|chunk| chunk.len() <= 1));
404                } else {
405                    prop_assert!(chunks.iter().all(|chunk| !chunk.is_empty() && chunk.len() <= max_size));
406                }
407            }
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests;