shipper_core/engine/parallel/
mod.rs1use 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
30pub 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
38pub 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
70struct 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 ¬ice.pkg_name,
200 ¬ice.pkg_version,
201 notice.attempt,
202 notice.max_attempts,
203 remaining,
204 notice.reason,
205 ¬ice.message,
206 );
207 }
208}
209
210pub 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, ®_inner, &mut adapter)
231}
232
233pub(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 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 let events_path = events::events_path(state_dir);
263 let event_log = Arc::new(Mutex::new(events::EventLog::new()));
264
265 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 let mut reached_resume_point = opts.resume_from.is_none();
279
280 for level in &levels {
281 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 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 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 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;