Skip to main content

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}