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}