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}
53
54/// Errors raised while computing a [`Plan`].
55#[derive(Debug, Error)]
56pub enum PlanError {
57    /// The action graph contains a cycle. Carries the unsettled node ids.
58    #[error("cycle detected: {0:?}")]
59    Cycle(Vec<ActionId>),
60    /// An action declares a dependency on an id not present in the action set.
61    #[error("action {from} depends on {to}, which is not registered")]
62    MissingDep {
63        /// The action that declared the missing dep.
64        from: ActionId,
65        /// The id that was not found.
66        to: ActionId,
67    },
68    /// Underlying [`Action::input_hash`] failure.
69    #[error(transparent)]
70    Build(#[from] BuildError),
71}
72
73impl Plan {
74    /// Topological layers, each layer's actions independent of each other.
75    #[must_use]
76    pub fn layers(&self) -> &[Vec<ActionId>] {
77        &self.layers
78    }
79
80    /// Map of stale action id → reason. Actions absent from the map are up-to-date.
81    #[must_use]
82    pub fn reasons(&self) -> &HashMap<ActionId, ChangeReason> {
83        &self.reasons
84    }
85
86    /// Iterator over the ids of all stale actions (any reason).
87    pub fn stale(&self) -> impl Iterator<Item = &ActionId> {
88        self.reasons.keys()
89    }
90
91    /// Iterator over all action ids in topological order, flattening layers.
92    pub fn flat_topo(&self) -> impl Iterator<Item = &ActionId> {
93        self.layers.iter().flatten()
94    }
95
96    /// Build a plan from a set of actions and a cache snapshot.
97    ///
98    /// # Errors
99    /// - [`PlanError::MissingDep`] when an action declares a dep on an
100    ///   unknown id.
101    /// - [`PlanError::Cycle`] when no topological order exists.
102    /// - [`PlanError::Build`] when [`Action::input_hash`] returns an error.
103    pub async fn compute(
104        actions: &[Box<dyn Action>],
105        cache: &dyn Cache,
106        ctx: &Ctx,
107    ) -> std::result::Result<Self, PlanError> {
108        // 1. Build inbound + dependents maps.
109        let ids: HashSet<ActionId> = actions.iter().map(|a| a.id()).collect();
110        let mut inbound: HashMap<ActionId, usize> = ids.iter().map(|i| (i.clone(), 0)).collect();
111        let mut dependents: HashMap<ActionId, Vec<ActionId>> = HashMap::new();
112
113        for a in actions {
114            for dep in a.deps() {
115                if !ids.contains(&dep) {
116                    return Err(PlanError::MissingDep {
117                        from: a.id(),
118                        to: dep,
119                    });
120                }
121                if let Some(c) = inbound.get_mut(&a.id()) {
122                    *c += 1;
123                }
124                dependents.entry(dep).or_default().push(a.id());
125            }
126        }
127
128        // 2. Layer-by-layer extraction (Kahn).
129        let mut layers: Vec<Vec<ActionId>> = Vec::new();
130        loop {
131            let mut layer: Vec<ActionId> = inbound
132                .iter()
133                .filter(|(_, c)| **c == 0)
134                .map(|(id, _)| id.clone())
135                .collect();
136            if layer.is_empty() {
137                break;
138            }
139            layer.sort_by(|a, b| a.0.cmp(&b.0)); // deterministic ordering within a layer
140            for id in &layer {
141                inbound.remove(id);
142                if let Some(children) = dependents.get(id) {
143                    for child in children.clone() {
144                        if let Some(c) = inbound.get_mut(&child) {
145                            *c -= 1;
146                        }
147                    }
148                }
149            }
150            layers.push(layer);
151        }
152        if !inbound.is_empty() {
153            let mut remaining: Vec<ActionId> = inbound.into_keys().collect();
154            remaining.sort_by(|a, b| a.0.cmp(&b.0));
155            return Err(PlanError::Cycle(remaining));
156        }
157
158        // 3. Compute ChangeReason per action, walking layers in order so a
159        //    stale ancestor is already marked when we look at its children.
160        //
161        //    Within each layer the per-action `input_hash` and
162        //    `cache.last_build` calls are independent — they may shell out
163        //    (`git ls-remote`, `cargo --version`) so running them
164        //    concurrently turns wall-clock from `sum(latency)` into
165        //    `max(latency)`. The cascade pass that uses the results is
166        //    sequential because layer N reads `stale` populated by layer
167        //    N-1.
168        let by_id: HashMap<ActionId, &Box<dyn Action>> =
169            actions.iter().map(|a| (a.id(), a)).collect();
170        let mut reasons: HashMap<ActionId, ChangeReason> = HashMap::new();
171        let mut stale: HashSet<ActionId> = HashSet::new();
172        let mut input_hashes: HashMap<ActionId, Sha256> = HashMap::new();
173
174        for layer in &layers {
175            // Honor cancel between layers — the parallel probes inside
176            // a layer (network, subprocess) can take seconds, so polling
177            // here means a Ctrl-C during plan computation aborts before
178            // the next layer fires its `git ls-remote` storm.
179            if ctx.cancel.is_cancelled() {
180                return Err(PlanError::Build(BuildError::Cancelled));
181            }
182            // Spawn all per-action probes concurrently. Pre-materialize
183            // (id, action) and (id, ()) tuples so each future owns its own
184            // pieces — the alternative (capturing `by_id` / `cache` in the
185            // closure) hits the FnMut "moved value" borrow checker because
186            // `.map()` reuses the closure across iterations.
187            let action_probes: Vec<(ActionId, &dyn Action)> = layer
188                .iter()
189                .map(|id| (id.clone(), by_id[id].as_ref()))
190                .collect();
191            // Inner cancel: race each probe future against `ctx.cancel`. The
192            // outer layer-edge check above only catches cancel *between*
193            // layers; this catches it *during* a layer's fan-out (e.g. when
194            // 50 nodes are mid-`git ls-remote` and Ctrl-C is pressed).
195            let hash_results: Vec<(ActionId, Result<Sha256, BuildError>)> =
196                join_all(action_probes.into_iter().map(|(id, action)| async move {
197                    let probe = pin!(action.input_hash(ctx));
198                    let cancel = pin!(ctx.cancel.cancelled());
199                    let result = match select(probe, cancel).await {
200                        Either::Left((r, _)) => r,
201                        Either::Right(((), _)) => Err(BuildError::Cancelled),
202                    };
203                    (id, result)
204                }))
205                .await;
206            let cached: Vec<(ActionId, Option<BuildEvent>)> = join_all(layer.iter().map(|id| {
207                let id = id.clone();
208                async move { (id.clone(), cache.last_build(&id).await) }
209            }))
210            .await;
211
212            // Materialize results into maps so we can match them up by id
213            // in the deterministic cascade pass below.
214            let mut hashes_now: HashMap<ActionId, Sha256> = HashMap::new();
215            for (id, result) in hash_results {
216                let now = result?;
217                hashes_now.insert(id, now);
218            }
219            let cached_map: HashMap<ActionId, Option<BuildEvent>> = cached.into_iter().collect();
220
221            // Sequential cascade — order matters because `UpstreamMoved`
222            // depends on the `stale` set populated by earlier ids.
223            for id in layer {
224                let now = hashes_now[id];
225                input_hashes.insert(id.clone(), now);
226                let last = cached_map.get(id).and_then(Option::as_ref);
227                if let Some(reason) = classify(id, by_id[id].as_ref(), now, last, &stale) {
228                    reasons.insert(id.clone(), reason);
229                    stale.insert(id.clone());
230                }
231            }
232        }
233
234        Ok(Self {
235            layers,
236            reasons,
237            input_hashes,
238        })
239    }
240
241    /// Pre-computed input hash for `id`, captured during [`Self::compute`].
242    /// Returns `None` for ids not in the plan (caller error).
243    #[must_use]
244    pub fn input_hash(&self, id: &ActionId) -> Option<Sha256> {
245        self.input_hashes.get(id).copied()
246    }
247}
248
249/// Decide whether `id` is stale, given its freshly-computed input hash,
250/// its last cached build event, and the set of already-stale ids in
251/// earlier positions of the same layer (or earlier layers).
252///
253/// Returns `Some(reason)` when stale, `None` when up-to-date. Pure — no
254/// side effects, no async — extracted from `Plan::compute` so the latter
255/// stays under the workspace's `clippy::too_many_lines` threshold.
256fn classify(
257    id: &ActionId,
258    action: &dyn Action,
259    now: Sha256,
260    last: Option<&BuildEvent>,
261    stale: &HashSet<ActionId>,
262) -> Option<ChangeReason> {
263    let _ = id; // referenced only for trace context in future revisions
264    // Both `None` and a prior `Failed` collapse to `NoCachedBuild`:
265    // we don't carry a "last failure" signal in `ChangeReason`, so a
266    // failed prior build always re-runs.
267    let Some(event) = last else {
268        return Some(ChangeReason::NoCachedBuild);
269    };
270    match event {
271        BuildEvent::Failed { .. } => Some(ChangeReason::NoCachedBuild),
272        BuildEvent::Success { input, .. } if *input != now => {
273            Some(ChangeReason::InputHashChanged {
274                from: *input,
275                to: now,
276            })
277        }
278        BuildEvent::Success { .. } => action
279            .deps()
280            .into_iter()
281            .find(|d| stale.contains(d))
282            .map(|dep| ChangeReason::UpstreamMoved { dep }),
283    }
284}