Skip to main content

repolith_core/
plan.rs

1//! Build plan — immutable DAG snapshot with staleness reasons.
2//!
3//! `Plan::compute` traverses the action graph topologically (Kahn layers)
4//! and decides which actions are stale by comparing their current input hash
5//! against the last recorded `BuildEvent` in the [cache](crate::cache::Cache).
6//! Stale-ness cascades: an action whose hash didn't change but which depends
7//! on a stale ancestor is marked `ChangeReason::UpstreamMoved`.
8//!
9//! The resulting `Plan` is owned, cloneable, and can be replayed any number
10//! of times by the orchestrator (with `--dry-run`, `--explain`, etc.).
11
12use crate::action::Action;
13use crate::cache::Cache;
14use crate::types::{ActionId, BuildError, BuildEvent, Ctx, Sha256};
15use futures::future::{Either, join_all, select};
16use std::collections::{HashMap, HashSet};
17use std::pin::pin;
18use thiserror::Error;
19
20/// Immutable, topologically-layered execution plan.
21///
22/// Layer `n` holds only actions whose dependencies are all in layers `0..n`,
23/// so layers can be executed in order; actions within a single layer are
24/// independent and may run in parallel.
25#[derive(Clone, Debug)]
26pub struct Plan {
27    layers: Vec<Vec<ActionId>>,
28    reasons: HashMap<ActionId, ChangeReason>,
29    /// Input hash captured at `Plan::compute` time, keyed by action id.
30    /// Stored so the orchestrator can reuse it without re-running every
31    /// action's `input_hash` (which may shell out to git/cargo).
32    input_hashes: HashMap<ActionId, Sha256>,
33}
34
35/// Why a given action is considered stale and needs to re-run.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum ChangeReason {
38    /// The cache has no prior build event for this action.
39    NoCachedBuild,
40    /// The action was previously built, but its current input hash differs.
41    InputHashChanged {
42        /// Hash recorded in the last successful build.
43        from: Sha256,
44        /// Hash computed for the current run.
45        to: Sha256,
46    },
47    /// The action's own hash is unchanged, but a transitive dependency is stale.
48    UpstreamMoved {
49        /// First stale dependency encountered (deterministic via deps order).
50        dep: ActionId,
51    },
52    /// Inputs are unchanged and the cache holds a `Success`, but the
53    /// artifact it describes is gone from this machine — deleted by hand,
54    /// or never here in the first place because another machine wrote the
55    /// entry into a shared cache backend.
56    OutputMissing,
57    /// The last recorded run of this action failed. Carries the rendered
58    /// error so `status` can say what went wrong instead of pretending the
59    /// action was never run.
60    ///
61    /// Holds a `String` rather than a [`BuildError`]:
62    /// that type is deliberately not `PartialEq`, which this enum needs.
63    PreviousFailure {
64        /// `BuildError`'s `Display` output, as recorded in the cache.
65        error: String,
66    },
67}
68
69impl std::fmt::Display for ChangeReason {
70    /// One short line per reason, suitable for a table cell. `Debug` stays
71    /// derived for tests and for anyone who wants the full hashes.
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::NoCachedBuild => write!(f, "never built"),
75            Self::InputHashChanged { from, to } => {
76                write!(f, "inputs changed ({} -> {})", from.short(), to.short())
77            }
78            Self::UpstreamMoved { dep } => write!(f, "upstream {dep} is stale"),
79            Self::OutputMissing => write!(f, "artifact missing"),
80            Self::PreviousFailure { error } => {
81                // Cargo and git put the message on the first line and the
82                // context below; a table cell only has room for the former.
83                let first = error.lines().next().unwrap_or(error);
84                let trimmed: String = first.chars().take(72).collect();
85                if trimmed.len() < first.len() {
86                    write!(f, "previous run failed: {trimmed}...")
87                } else {
88                    write!(f, "previous run failed: {trimmed}")
89                }
90            }
91        }
92    }
93}
94
95/// Errors raised while computing a [`Plan`].
96#[derive(Debug, Error)]
97pub enum PlanError {
98    /// The action graph contains a cycle. Carries the unsettled node ids.
99    #[error("cycle detected: {0:?}")]
100    Cycle(Vec<ActionId>),
101    /// An action declares a dependency on an id not present in the action set.
102    #[error("action {from} depends on {to}, which is not registered")]
103    MissingDep {
104        /// The action that declared the missing dep.
105        from: ActionId,
106        /// The id that was not found.
107        to: ActionId,
108    },
109    /// Underlying [`Action::input_hash`] failure.
110    #[error(transparent)]
111    Build(#[from] BuildError),
112}
113
114impl Plan {
115    /// Topological layers, each layer's actions independent of each other.
116    #[must_use]
117    pub fn layers(&self) -> &[Vec<ActionId>] {
118        &self.layers
119    }
120
121    /// Map of stale action id → reason. Actions absent from the map are up-to-date.
122    #[must_use]
123    pub fn reasons(&self) -> &HashMap<ActionId, ChangeReason> {
124        &self.reasons
125    }
126
127    /// Iterator over the ids of all stale actions (any reason).
128    pub fn stale(&self) -> impl Iterator<Item = &ActionId> {
129        self.reasons.keys()
130    }
131
132    /// Iterator over all action ids in topological order, flattening layers.
133    pub fn flat_topo(&self) -> impl Iterator<Item = &ActionId> {
134        self.layers.iter().flatten()
135    }
136
137    /// Build a plan from a set of actions and a cache snapshot.
138    ///
139    /// # Errors
140    /// - [`PlanError::MissingDep`] when an action declares a dep on an
141    ///   unknown id.
142    /// - [`PlanError::Cycle`] when no topological order exists.
143    /// - [`PlanError::Build`] when [`Action::input_hash`] returns an error.
144    pub async fn compute(
145        actions: &[Box<dyn Action>],
146        cache: &dyn Cache,
147        ctx: &Ctx,
148    ) -> std::result::Result<Self, PlanError> {
149        // 1. Build inbound + dependents maps.
150        let ids: HashSet<ActionId> = actions.iter().map(|a| a.id()).collect();
151        let mut inbound: HashMap<ActionId, usize> = ids.iter().map(|i| (i.clone(), 0)).collect();
152        let mut dependents: HashMap<ActionId, Vec<ActionId>> = HashMap::new();
153
154        for a in actions {
155            for dep in a.deps() {
156                if !ids.contains(&dep) {
157                    return Err(PlanError::MissingDep {
158                        from: a.id(),
159                        to: dep,
160                    });
161                }
162                if let Some(c) = inbound.get_mut(&a.id()) {
163                    *c += 1;
164                }
165                dependents.entry(dep).or_default().push(a.id());
166            }
167        }
168
169        // 2. Layer-by-layer extraction (Kahn).
170        let mut layers: Vec<Vec<ActionId>> = Vec::new();
171        loop {
172            let mut layer: Vec<ActionId> = inbound
173                .iter()
174                .filter(|(_, c)| **c == 0)
175                .map(|(id, _)| id.clone())
176                .collect();
177            if layer.is_empty() {
178                break;
179            }
180            layer.sort_by(|a, b| a.0.cmp(&b.0)); // deterministic ordering within a layer
181            for id in &layer {
182                inbound.remove(id);
183                if let Some(children) = dependents.get(id) {
184                    for child in children.clone() {
185                        if let Some(c) = inbound.get_mut(&child) {
186                            *c -= 1;
187                        }
188                    }
189                }
190            }
191            layers.push(layer);
192        }
193        if !inbound.is_empty() {
194            let mut remaining: Vec<ActionId> = inbound.into_keys().collect();
195            remaining.sort_by(|a, b| a.0.cmp(&b.0));
196            return Err(PlanError::Cycle(remaining));
197        }
198
199        // 3. Compute ChangeReason per action, walking layers in order so a
200        //    stale ancestor is already marked when we look at its children.
201        //
202        //    Within each layer the per-action `input_hash` and
203        //    `cache.last_build` calls are independent — they may shell out
204        //    (`git ls-remote`, `cargo --version`) so running them
205        //    concurrently turns wall-clock from `sum(latency)` into
206        //    `max(latency)`. The cascade pass that uses the results is
207        //    sequential because layer N reads `stale` populated by layer
208        //    N-1.
209        let by_id: HashMap<ActionId, &dyn Action> =
210            actions.iter().map(|a| (a.id(), a.as_ref())).collect();
211        let mut reasons: HashMap<ActionId, ChangeReason> = HashMap::new();
212        let mut stale: HashSet<ActionId> = HashSet::new();
213        let mut input_hashes: HashMap<ActionId, Sha256> = HashMap::new();
214
215        for layer in &layers {
216            // Honor cancel between layers — the parallel probes inside
217            // a layer (network, subprocess) can take seconds, so polling
218            // here means a Ctrl-C during plan computation aborts before
219            // the next layer fires its `git ls-remote` storm.
220            if ctx.cancel.is_cancelled() {
221                return Err(PlanError::Build(BuildError::Cancelled));
222            }
223            let LayerProbes {
224                hashes_now,
225                cached_map,
226                presence_map,
227            } = probe_layer(layer, &by_id, cache, ctx).await?;
228
229            // Sequential cascade — order matters because `UpstreamMoved`
230            // depends on the `stale` set populated by earlier ids.
231            for id in layer {
232                let now = hashes_now[id];
233                input_hashes.insert(id.clone(), now);
234                let last = cached_map.get(id).and_then(Option::as_ref);
235                let present = presence_map.get(id).copied().unwrap_or(true);
236                if let Some(reason) = classify(id, by_id[id], now, last, &stale, present) {
237                    reasons.insert(id.clone(), reason);
238                    stale.insert(id.clone());
239                }
240            }
241        }
242
243        Ok(Self {
244            layers,
245            reasons,
246            input_hashes,
247        })
248    }
249
250    /// Pre-computed input hash for `id`, captured during [`Self::compute`].
251    /// Returns `None` for ids not in the plan (caller error).
252    #[must_use]
253    pub fn input_hash(&self, id: &ActionId) -> Option<Sha256> {
254        self.input_hashes.get(id).copied()
255    }
256}
257
258/// Everything one layer's concurrent probes produced, keyed by action id.
259struct LayerProbes {
260    hashes_now: HashMap<ActionId, Sha256>,
261    cached_map: HashMap<ActionId, Option<BuildEvent>>,
262    presence_map: HashMap<ActionId, bool>,
263}
264
265/// Run all three per-action probes for one layer concurrently.
266///
267/// The probes are independent and may shell out (`git ls-remote`,
268/// `cargo --version`, `docker image inspect`), so fanning them out turns
269/// wall-clock from `sum(latency)` into `max(latency)`. Extracted from
270/// [`Plan::compute`] to keep that function under the workspace's
271/// `clippy::too_many_lines` threshold.
272async fn probe_layer(
273    layer: &[ActionId],
274    by_id: &HashMap<ActionId, &dyn Action>,
275    cache: &dyn Cache,
276    ctx: &Ctx,
277) -> std::result::Result<LayerProbes, PlanError> {
278    // Pre-materialize (id, action) tuples so each future owns its own
279    // pieces — capturing `by_id` in the closure hits the FnMut "moved
280    // value" borrow checker because `.map()` reuses it across iterations.
281    let hash_probes: Vec<(ActionId, &dyn Action)> =
282        layer.iter().map(|id| (id.clone(), by_id[id])).collect();
283    // Inner cancel: race each probe against `ctx.cancel`. The layer-edge
284    // check in `compute` only catches cancel *between* layers; this
285    // catches it *during* a fan-out (50 nodes mid-`git ls-remote`, Ctrl-C).
286    let hash_results: Vec<(ActionId, Result<Sha256, BuildError>)> =
287        join_all(hash_probes.into_iter().map(|(id, action)| async move {
288            let probe = pin!(action.input_hash(ctx));
289            let cancel = pin!(ctx.cancel.cancelled());
290            let result = match select(probe, cancel).await {
291                Either::Left((r, _)) => r,
292                Either::Right(((), _)) => Err(BuildError::Cancelled),
293            };
294            (id, result)
295        }))
296        .await;
297
298    // Is the artifact still on this machine? Cheap by contract (a stat, at
299    // most one subprocess), so it rides along in the same fan-out for free.
300    let presence_probes: Vec<(ActionId, &dyn Action)> =
301        layer.iter().map(|id| (id.clone(), by_id[id])).collect();
302    let presence: Vec<(ActionId, bool)> = join_all(
303        presence_probes
304            .into_iter()
305            .map(|(id, action)| async move { (id, action.output_present(ctx).await) }),
306    )
307    .await;
308
309    let cached: Vec<(ActionId, Option<BuildEvent>)> = join_all(layer.iter().map(|id| {
310        let id = id.clone();
311        async move { (id.clone(), cache.last_build(&id).await) }
312    }))
313    .await;
314
315    let mut hashes_now: HashMap<ActionId, Sha256> = HashMap::new();
316    for (id, result) in hash_results {
317        hashes_now.insert(id, result?);
318    }
319    Ok(LayerProbes {
320        hashes_now,
321        cached_map: cached.into_iter().collect(),
322        presence_map: presence.into_iter().collect(),
323    })
324}
325
326/// Decide whether `id` is stale, given its freshly-computed input hash,
327/// its last cached build event, and the set of already-stale ids in
328/// earlier positions of the same layer (or earlier layers).
329///
330/// Returns `Some(reason)` when stale, `None` when up-to-date. Pure — no
331/// side effects, no async — extracted from `Plan::compute` so the latter
332/// stays under the workspace's `clippy::too_many_lines` threshold.
333fn classify(
334    id: &ActionId,
335    action: &dyn Action,
336    now: Sha256,
337    last: Option<&BuildEvent>,
338    stale: &HashSet<ActionId>,
339    output_present: bool,
340) -> Option<ChangeReason> {
341    let _ = id; // referenced only for trace context in future revisions
342    // No cache entry at all means `NoCachedBuild`; a recorded failure gets
343    // its own reason below. Either way the action re-runs.
344    let Some(event) = last else {
345        return Some(ChangeReason::NoCachedBuild);
346    };
347    match event {
348        // Still stale, still re-runs — only the label differs from
349        // `NoCachedBuild`, so `status` can distinguish "never ran" from
350        // "ran and blew up", which the cache has always known.
351        BuildEvent::Failed { error, .. } => Some(ChangeReason::PreviousFailure {
352            error: error.to_string(),
353        }),
354        BuildEvent::Success { input, .. } if *input != now => {
355            Some(ChangeReason::InputHashChanged {
356                from: *input,
357                to: now,
358            })
359        }
360        // Inputs match, so the cache *claims* this is done. Only trust it
361        // if the artifact is actually here — the entry may have been
362        // written by another machine through a shared cache backend.
363        BuildEvent::Success { .. } if !output_present => Some(ChangeReason::OutputMissing),
364        BuildEvent::Success { .. } => action
365            .deps()
366            .into_iter()
367            .find(|d| stale.contains(d))
368            .map(|dep| ChangeReason::UpstreamMoved { dep }),
369    }
370}