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 /// The registered actions, in registration order.
118 ///
119 /// Read-only, for consumers that need to describe the graph a `Plan`
120 /// was computed from — `repolith status <filter>` reports each action's
121 /// dependencies and whether its artifact is on disk. Rebuilding the
122 /// action set from the manifest a second time would risk describing a
123 /// *different* graph than the one that was planned.
124 #[must_use]
125 pub fn actions(&self) -> &[Box<dyn Action>] {
126 &self.actions
127 }
128
129 /// The cache this orchestrator plans against.
130 ///
131 /// Read-only: the query methods ([`Cache::last_record`]) are the point;
132 /// writes stay the orchestrator's business.
133 #[must_use]
134 pub fn cache(&self) -> &dyn Cache {
135 self.cache.as_ref()
136 }
137
138 /// The context every action is executed with.
139 #[must_use]
140 pub fn base_ctx(&self) -> &Ctx {
141 &self.base_ctx
142 }
143
144 /// Execute every stale action in the plan, layer by layer.
145 ///
146 /// Returns the chronological list of `BuildEvent`s recorded across all
147 /// executed layers. Cache writes happen **after** each layer settles, so
148 /// a crash mid-layer leaves the prior layers persisted.
149 ///
150 /// # Errors
151 /// - [`ExecError::LayerFailed`] when any action in a layer errors out
152 /// (further layers are not started, regardless of `mode`).
153 /// - [`ExecError::Cache`] when persisting an event to the cache fails.
154 pub async fn execute_plan(
155 &mut self,
156 plan: &Plan,
157 mode: ExecMode,
158 ) -> Result<Vec<BuildEvent>, ExecError> {
159 let sem = self
160 .shared_semaphore
161 .clone()
162 .unwrap_or_else(|| Arc::new(Semaphore::new(self.max_parallelism)));
163 let mut all_events: Vec<BuildEvent> = Vec::new();
164
165 for layer in plan.layers() {
166 // Honor cancel between layers — without this, a cancel
167 // fired after layer N completes still triggers layer N+1's
168 // child token to be created from the already-cancelled
169 // parent, which makes every action in N+1 instantly fail
170 // with `Cancelled` and pollutes the cache with bogus
171 // `BuildEvent::Failed` entries.
172 if self.base_ctx.cancel.is_cancelled() {
173 return Err(ExecError::LayerFailed { events: all_events });
174 }
175
176 let stale: Vec<ActionId> = layer
177 .iter()
178 .filter(|id| plan.reasons().contains_key(*id))
179 .cloned()
180 .collect();
181 if stale.is_empty() {
182 continue;
183 }
184
185 let layer_events = self.execute_layer(&stale, plan, mode, &sem).await;
186
187 // Persist the whole layer's events atomically — a crash mid-batch
188 // shouldn't leave half the layer marked Success and the other
189 // half un-persisted (and thus replayed on the next sync).
190 self.cache.record_batch(layer_events.clone()).await?;
191 all_events.extend(layer_events.iter().cloned());
192
193 if layer_events
194 .iter()
195 .any(|e| matches!(e, BuildEvent::Failed { .. }))
196 {
197 return Err(ExecError::LayerFailed { events: all_events });
198 }
199 }
200
201 Ok(all_events)
202 }
203
204 /// Run one layer's actions concurrently, capped by `sem`. Never panics
205 /// on errors — they're folded into [`BuildEvent::Failed`] entries.
206 async fn execute_layer(
207 &self,
208 stale_ids: &[ActionId],
209 plan: &Plan,
210 mode: ExecMode,
211 sem: &Arc<Semaphore>,
212 ) -> Vec<BuildEvent> {
213 // Fresh child token per layer — failing layer N doesn't poison layer N+1.
214 let cancel = self.base_ctx.cancel.child_token();
215 let layer_ctx = Ctx {
216 cancel: cancel.clone(),
217 workdir: self.base_ctx.workdir.clone(),
218 env: self.base_ctx.env.clone(),
219 };
220
221 let by_id: HashMap<ActionId, &Box<dyn Action>> =
222 self.actions.iter().map(|a| (a.id(), a)).collect();
223
224 let mut pending: FuturesUnordered<_> = stale_ids
225 .iter()
226 .filter_map(|id| by_id.get(id).map(|a| (id.clone(), *a)))
227 .map(|(id, action)| {
228 let sem = Arc::clone(sem);
229 let layer_ctx = layer_ctx.clone();
230 // Reuse the input hash captured during `Plan::compute` —
231 // saves a second `git ls-remote` / `cargo --version` per
232 // action and means a network failure here surfaces as a
233 // typed `BuildEvent::Failed` rather than a silent
234 // `Sha256([0; 32])` poison value (was masking input_hash
235 // errors entirely).
236 let input_hash = plan
237 .input_hash(&id)
238 .expect("stale id must have been planned with an input_hash");
239 let coordinator = action.is_coordinator();
240 async move {
241 // Acquire concurrency permit — yields when the layer is
242 // wider than the limit. Coordinators (federation) are
243 // exempt: their children draw from the same shared pool,
244 // so charging the coordinator too would deadlock at
245 // --jobs 1 (it would hold the only permit while its
246 // children wait for it).
247 let _permit = if coordinator {
248 None
249 } else {
250 Some(sem.acquire().await.expect("semaphore never closed"))
251 };
252 let started = Instant::now();
253 let result = action.execute(&layer_ctx).await;
254 (id, started.elapsed(), input_hash, result)
255 }
256 })
257 .collect();
258
259 let mut events: Vec<BuildEvent> = Vec::new();
260
261 while let Some((id, dur, input_hash, result)) = pending.next().await {
262 let ms = u64::try_from(dur.as_millis()).unwrap_or(u64::MAX);
263 match result {
264 Ok(out) => events.push(BuildEvent::Success {
265 id,
266 input: input_hash,
267 output: out.output_hash,
268 ms,
269 }),
270 Err(e) => {
271 events.push(BuildEvent::Failed {
272 id,
273 input: input_hash,
274 error: e,
275 ms,
276 });
277 if mode == ExecMode::FailFast {
278 // Signal in-flight peers to short-circuit on their next .await poll.
279 cancel.cancel();
280 }
281 }
282 }
283 }
284
285 events
286 }
287}
288
289impl Builder {
290 /// Set the cache backend. **Required** — `build()` errors otherwise.
291 #[must_use]
292 pub fn cache<C: Cache + 'static>(mut self, c: C) -> Self {
293 self.cache = Some(Box::new(c));
294 self
295 }
296
297 /// Set an already-boxed cache backend. Useful when the concrete type
298 /// is only known at runtime (federation hooks return `Box<dyn Cache>`).
299 #[must_use]
300 pub fn cache_boxed(mut self, c: Box<dyn Cache>) -> Self {
301 self.cache = Some(c);
302 self
303 }
304
305 /// Attach the parsed manifest snapshot (optional, for downstream consumers).
306 #[must_use]
307 pub fn manifest(mut self, m: Manifest) -> Self {
308 self.manifest = Some(m);
309 self
310 }
311
312 /// Cap on concurrent in-flight actions. Floored to `1` (zero is meaningless).
313 #[must_use]
314 pub fn max_parallelism(mut self, n: usize) -> Self {
315 self.max_parallelism = n.max(1);
316 self
317 }
318
319 /// Draw concurrency permits from an externally-owned pool instead of a
320 /// private one. Used by federation so `--jobs N` bounds the **whole
321 /// tree** of orchestrators, not N per level. Overrides
322 /// [`Self::max_parallelism`] when set.
323 #[must_use]
324 pub fn shared_semaphore(mut self, sem: Arc<Semaphore>) -> Self {
325 self.shared_semaphore = Some(sem);
326 self
327 }
328
329 /// Override the base [`Ctx`] (cancel token, workdir, env).
330 #[must_use]
331 pub fn base_ctx(mut self, ctx: Ctx) -> Self {
332 self.base_ctx = ctx;
333 self
334 }
335
336 /// Register one action. May be called many times.
337 #[must_use]
338 pub fn register<A: Action + 'static>(mut self, a: A) -> Self {
339 self.actions.push(Box::new(a));
340 self
341 }
342
343 /// Register an already-boxed action. Useful when the concrete type is
344 /// only known at runtime (e.g. when assembled from a manifest factory).
345 #[must_use]
346 pub fn register_boxed(mut self, a: Box<dyn Action>) -> Self {
347 self.actions.push(a);
348 self
349 }
350
351 /// Finalize.
352 ///
353 /// # Errors
354 /// [`BuilderError::MissingCache`] when no cache was set.
355 pub fn build(self) -> Result<Orchestrator, BuilderError> {
356 let cache = self.cache.ok_or(BuilderError::MissingCache)?;
357 Ok(Orchestrator {
358 actions: self.actions,
359 cache,
360 manifest: self.manifest,
361 max_parallelism: self.max_parallelism,
362 shared_semaphore: self.shared_semaphore,
363 base_ctx: self.base_ctx,
364 })
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use repolith_core::cache::Result as CacheResult;
372
373 struct StubCache;
374 #[async_trait::async_trait]
375 impl Cache for StubCache {
376 async fn last_build(&self, _id: &ActionId) -> Option<BuildEvent> {
377 None
378 }
379 async fn record(&mut self, _event: BuildEvent) -> CacheResult<()> {
380 Ok(())
381 }
382 }
383
384 /// CWE-200 — `Orchestrator::builder()` default `base_ctx.env` must be
385 /// empty. Library consumers that forget `.base_ctx(...)` can't inherit
386 /// `GITHUB_TOKEN` / `AWS_SECRET_ACCESS_KEY` from the parent process
387 /// into `Ctx`. The CLI overrides via `filtered_env()`.
388 #[test]
389 fn builder_default_env_is_empty() {
390 let orch = Orchestrator::builder()
391 .cache(StubCache)
392 .build()
393 .expect("build");
394 assert!(
395 orch.base_ctx.env.is_empty(),
396 "default builder env must be empty, got keys: {:?}",
397 orch.base_ctx.env.keys().collect::<Vec<_>>()
398 );
399 }
400}