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