repolith_engine/orchestrator.rs
1//! Orchestrator — drives a `Plan` to completion.
2//!
3//! `Orchestrator` owns the actions, the cache, and the execution context;
4//! `execute_plan` walks the plan layer by layer, fanning the stale actions
5//! out into a `FuturesUnordered` capped by a `tokio::sync::Semaphore`.
6//! On the first error in `ExecMode::FailFast` mode, a shared
7//! `CancellationToken` is fired so in-flight peers can short-circuit.
8//! `ExecMode::KeepGoing` lets the current layer settle but still halts
9//! before the next layer is started.
10//!
11//! **Implementation invariant**: this module never uses
12//! `futures::future::join_all` — it would defeat `FailFast` because
13//! `join_all` ignores cancellation and waits for every future to finish.
14
15use futures::stream::{FuturesUnordered, StreamExt};
16use repolith_core::action::Action;
17use repolith_core::cache::{Cache, CacheError};
18use repolith_core::manifest::Manifest;
19use repolith_core::plan::{Plan, PlanError};
20use repolith_core::types::{ActionId, BuildEvent, Ctx, ExecMode};
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Instant;
24use thiserror::Error;
25use tokio::sync::Semaphore;
26use tokio_util::sync::CancellationToken;
27
28/// Top-level orchestrator. Construct via [`Orchestrator::builder`].
29pub struct Orchestrator {
30 actions: Vec<Box<dyn Action>>,
31 cache: Box<dyn Cache>,
32 /// Optional manifest snapshot — kept for downstream consumers (CLI, telemetry).
33 pub manifest: Option<Manifest>,
34 max_parallelism: usize,
35 /// Externally-provided concurrency pool. When set (federation), this
36 /// orchestrator draws permits from the shared pool instead of creating
37 /// its own — `--jobs N` stays a **global** bound across the whole
38 /// federation tree, never N per level.
39 shared_semaphore: Option<Arc<Semaphore>>,
40 base_ctx: Ctx,
41}
42
43/// Errors raised while executing a `Plan`.
44#[derive(Debug, Error)]
45pub enum ExecError {
46 /// At least one action in a layer reported an error. The vector contains
47 /// every event recorded **so far** (across all completed layers + the
48 /// current failing layer), so callers can render a full report.
49 #[error("layer failed: {} event(s) recorded", events.len())]
50 LayerFailed {
51 /// Every event recorded up to and including the failing layer.
52 events: Vec<BuildEvent>,
53 },
54 /// Underlying `Plan::compute` failure surfaced through the orchestrator.
55 #[error(transparent)]
56 Plan(#[from] PlanError),
57 /// Cache write failure while persisting an event.
58 #[error(transparent)]
59 Cache(#[from] CacheError),
60}
61
62/// Errors raised while validating a [`Builder`] configuration.
63#[derive(Debug, Error)]
64pub enum BuilderError {
65 /// `Builder::cache(...)` was never called.
66 #[error("orchestrator builder requires a cache")]
67 MissingCache,
68}
69
70/// Builder for [`Orchestrator`]. Use [`Orchestrator::builder`] to obtain one.
71pub struct Builder {
72 actions: Vec<Box<dyn Action>>,
73 cache: Option<Box<dyn Cache>>,
74 manifest: Option<Manifest>,
75 max_parallelism: usize,
76 shared_semaphore: Option<Arc<Semaphore>>,
77 base_ctx: Ctx,
78}
79
80impl Orchestrator {
81 /// Start a fresh [`Builder`].
82 ///
83 /// The default `max_parallelism` is `num_cpus::get()`. The default
84 /// `base_ctx` uses a fresh [`CancellationToken`], `current_dir()` for
85 /// `workdir`, and an **empty** env map. SDK/library consumers must
86 /// explicitly call [`Builder::base_ctx`] with an allowlisted env (the
87 /// CLI does this via `filtered_env()`) — defaulting to a snapshot of
88 /// the parent process env would leak secrets like `GITHUB_TOKEN` /
89 /// `AWS_SECRET_ACCESS_KEY` through any future `tracing::debug!(?ctx)`
90 /// or panic dump (CWE-200). This is the engine-layer counterpart to
91 /// the CLI's existing env allowlist.
92 #[must_use]
93 pub fn builder() -> Builder {
94 Builder {
95 actions: vec![],
96 cache: None,
97 manifest: None,
98 max_parallelism: num_cpus::get().max(1),
99 shared_semaphore: None,
100 base_ctx: Ctx {
101 cancel: CancellationToken::new(),
102 workdir: std::env::current_dir().unwrap_or_default(),
103 env: std::collections::HashMap::new(),
104 },
105 }
106 }
107
108 /// Compute the `Plan` for the registered actions against the current
109 /// cache state. Pure: no side effects.
110 ///
111 /// # Errors
112 /// Forwarded from `Plan::compute`.
113 pub async fn compute_plan(&self) -> Result<Plan, PlanError> {
114 Plan::compute(&self.actions, self.cache.as_ref(), &self.base_ctx).await
115 }
116
117 /// Execute every stale action in the plan, layer by layer.
118 ///
119 /// Returns the chronological list of `BuildEvent`s recorded across all
120 /// executed layers. Cache writes happen **after** each layer settles, so
121 /// a crash mid-layer leaves the prior layers persisted.
122 ///
123 /// # Errors
124 /// - [`ExecError::LayerFailed`] when any action in a layer errors out
125 /// (further layers are not started, regardless of `mode`).
126 /// - [`ExecError::Cache`] when persisting an event to the cache fails.
127 pub async fn execute_plan(
128 &mut self,
129 plan: &Plan,
130 mode: ExecMode,
131 ) -> Result<Vec<BuildEvent>, ExecError> {
132 let sem = self
133 .shared_semaphore
134 .clone()
135 .unwrap_or_else(|| Arc::new(Semaphore::new(self.max_parallelism)));
136 let mut all_events: Vec<BuildEvent> = Vec::new();
137
138 for layer in plan.layers() {
139 // Honor cancel between layers — without this, a cancel
140 // fired after layer N completes still triggers layer N+1's
141 // child token to be created from the already-cancelled
142 // parent, which makes every action in N+1 instantly fail
143 // with `Cancelled` and pollutes the cache with bogus
144 // `BuildEvent::Failed` entries.
145 if self.base_ctx.cancel.is_cancelled() {
146 return Err(ExecError::LayerFailed { events: all_events });
147 }
148
149 let stale: Vec<ActionId> = layer
150 .iter()
151 .filter(|id| plan.reasons().contains_key(*id))
152 .cloned()
153 .collect();
154 if stale.is_empty() {
155 continue;
156 }
157
158 let layer_events = self.execute_layer(&stale, plan, mode, &sem).await;
159
160 // Persist the whole layer's events atomically — a crash mid-batch
161 // shouldn't leave half the layer marked Success and the other
162 // half un-persisted (and thus replayed on the next sync).
163 self.cache.record_batch(layer_events.clone()).await?;
164 all_events.extend(layer_events.iter().cloned());
165
166 if layer_events
167 .iter()
168 .any(|e| matches!(e, BuildEvent::Failed { .. }))
169 {
170 return Err(ExecError::LayerFailed { events: all_events });
171 }
172 }
173
174 Ok(all_events)
175 }
176
177 /// Run one layer's actions concurrently, capped by `sem`. Never panics
178 /// on errors — they're folded into [`BuildEvent::Failed`] entries.
179 async fn execute_layer(
180 &self,
181 stale_ids: &[ActionId],
182 plan: &Plan,
183 mode: ExecMode,
184 sem: &Arc<Semaphore>,
185 ) -> Vec<BuildEvent> {
186 // Fresh child token per layer — failing layer N doesn't poison layer N+1.
187 let cancel = self.base_ctx.cancel.child_token();
188 let layer_ctx = Ctx {
189 cancel: cancel.clone(),
190 workdir: self.base_ctx.workdir.clone(),
191 env: self.base_ctx.env.clone(),
192 };
193
194 let by_id: HashMap<ActionId, &Box<dyn Action>> =
195 self.actions.iter().map(|a| (a.id(), a)).collect();
196
197 let mut pending: FuturesUnordered<_> = stale_ids
198 .iter()
199 .filter_map(|id| by_id.get(id).map(|a| (id.clone(), *a)))
200 .map(|(id, action)| {
201 let sem = Arc::clone(sem);
202 let layer_ctx = layer_ctx.clone();
203 // Reuse the input hash captured during `Plan::compute` —
204 // saves a second `git ls-remote` / `cargo --version` per
205 // action and means a network failure here surfaces as a
206 // typed `BuildEvent::Failed` rather than a silent
207 // `Sha256([0; 32])` poison value (was masking input_hash
208 // errors entirely).
209 let input_hash = plan
210 .input_hash(&id)
211 .expect("stale id must have been planned with an input_hash");
212 let coordinator = action.is_coordinator();
213 async move {
214 // Acquire concurrency permit — yields when the layer is
215 // wider than the limit. Coordinators (federation) are
216 // exempt: their children draw from the same shared pool,
217 // so charging the coordinator too would deadlock at
218 // --jobs 1 (it would hold the only permit while its
219 // children wait for it).
220 let _permit = if coordinator {
221 None
222 } else {
223 Some(sem.acquire().await.expect("semaphore never closed"))
224 };
225 let started = Instant::now();
226 let result = action.execute(&layer_ctx).await;
227 (id, started.elapsed(), input_hash, result)
228 }
229 })
230 .collect();
231
232 let mut events: Vec<BuildEvent> = Vec::new();
233
234 while let Some((id, dur, input_hash, result)) = pending.next().await {
235 let ms = u64::try_from(dur.as_millis()).unwrap_or(u64::MAX);
236 match result {
237 Ok(out) => events.push(BuildEvent::Success {
238 id,
239 input: input_hash,
240 output: out.output_hash,
241 ms,
242 }),
243 Err(e) => {
244 events.push(BuildEvent::Failed {
245 id,
246 input: input_hash,
247 error: e,
248 ms,
249 });
250 if mode == ExecMode::FailFast {
251 // Signal in-flight peers to short-circuit on their next .await poll.
252 cancel.cancel();
253 }
254 }
255 }
256 }
257
258 events
259 }
260}
261
262impl Builder {
263 /// Set the cache backend. **Required** — `build()` errors otherwise.
264 #[must_use]
265 pub fn cache<C: Cache + 'static>(mut self, c: C) -> Self {
266 self.cache = Some(Box::new(c));
267 self
268 }
269
270 /// Set an already-boxed cache backend. Useful when the concrete type
271 /// is only known at runtime (federation hooks return `Box<dyn Cache>`).
272 #[must_use]
273 pub fn cache_boxed(mut self, c: Box<dyn Cache>) -> Self {
274 self.cache = Some(c);
275 self
276 }
277
278 /// Attach the parsed manifest snapshot (optional, for downstream consumers).
279 #[must_use]
280 pub fn manifest(mut self, m: Manifest) -> Self {
281 self.manifest = Some(m);
282 self
283 }
284
285 /// Cap on concurrent in-flight actions. Floored to `1` (zero is meaningless).
286 #[must_use]
287 pub fn max_parallelism(mut self, n: usize) -> Self {
288 self.max_parallelism = n.max(1);
289 self
290 }
291
292 /// Draw concurrency permits from an externally-owned pool instead of a
293 /// private one. Used by federation so `--jobs N` bounds the **whole
294 /// tree** of orchestrators, not N per level. Overrides
295 /// [`Self::max_parallelism`] when set.
296 #[must_use]
297 pub fn shared_semaphore(mut self, sem: Arc<Semaphore>) -> Self {
298 self.shared_semaphore = Some(sem);
299 self
300 }
301
302 /// Override the base [`Ctx`] (cancel token, workdir, env).
303 #[must_use]
304 pub fn base_ctx(mut self, ctx: Ctx) -> Self {
305 self.base_ctx = ctx;
306 self
307 }
308
309 /// Register one action. May be called many times.
310 #[must_use]
311 pub fn register<A: Action + 'static>(mut self, a: A) -> Self {
312 self.actions.push(Box::new(a));
313 self
314 }
315
316 /// Register an already-boxed action. Useful when the concrete type is
317 /// only known at runtime (e.g. when assembled from a manifest factory).
318 #[must_use]
319 pub fn register_boxed(mut self, a: Box<dyn Action>) -> Self {
320 self.actions.push(a);
321 self
322 }
323
324 /// Finalize.
325 ///
326 /// # Errors
327 /// [`BuilderError::MissingCache`] when no cache was set.
328 pub fn build(self) -> Result<Orchestrator, BuilderError> {
329 let cache = self.cache.ok_or(BuilderError::MissingCache)?;
330 Ok(Orchestrator {
331 actions: self.actions,
332 cache,
333 manifest: self.manifest,
334 max_parallelism: self.max_parallelism,
335 shared_semaphore: self.shared_semaphore,
336 base_ctx: self.base_ctx,
337 })
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use repolith_core::cache::Result as CacheResult;
345
346 struct StubCache;
347 #[async_trait::async_trait]
348 impl Cache for StubCache {
349 async fn last_build(&self, _id: &ActionId) -> Option<BuildEvent> {
350 None
351 }
352 async fn record(&mut self, _event: BuildEvent) -> CacheResult<()> {
353 Ok(())
354 }
355 }
356
357 /// CWE-200 — `Orchestrator::builder()` default `base_ctx.env` must be
358 /// empty. Library consumers that forget `.base_ctx(...)` can't inherit
359 /// `GITHUB_TOKEN` / `AWS_SECRET_ACCESS_KEY` from the parent process
360 /// into `Ctx`. The CLI overrides via `filtered_env()`.
361 #[test]
362 fn builder_default_env_is_empty() {
363 let orch = Orchestrator::builder()
364 .cache(StubCache)
365 .build()
366 .expect("build");
367 assert!(
368 orch.base_ctx.env.is_empty(),
369 "default builder env must be empty, got keys: {:?}",
370 orch.base_ctx.env.keys().collect::<Vec<_>>()
371 );
372 }
373}