shep_core/config/graph.rs
1//! Boot ordering: names and edges in, stages out.
2//!
3//! Pure. No I/O and no runtime, so the dog positioning rule below is
4//! testable without a daemon. The daemon's driver runs what this produces;
5//! it decides nothing about order itself.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9/// What a node is, which decides its default position.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum NodeKind {
12 /// A managed user process. Sorted purely by its edges.
13 Sheep,
14 /// A plugin process the daemon supervises.
15 Dog {
16 /// Named in `[daemon] boot_first_dogs`, so it runs before every
17 /// sheep.
18 boot_first: bool,
19 },
20}
21
22/// One node of the boot graph.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct BootNode {
25 /// The sheep or dog name.
26 pub name: String,
27 /// Names this node waits for. Always empty for a dog, since `dog_app`
28 /// builds a dog's config from `AppConfig::minimal`.
29 pub depends_on: Vec<String>,
30 /// Sheep or dog.
31 pub kind: NodeKind,
32}
33
34/// An edge that pointed at a name the flock does not have.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Unresolved {
37 /// The node whose list holds the edge.
38 pub dependent: String,
39 /// The name nothing answers to.
40 pub missing: String,
41}
42
43/// The order to start a flock in, and what was wrong with the graph.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct BootPlan {
46 /// Stages in start order. Every stage is non-empty and its names are
47 /// sorted, so one flock always plans the same way.
48 pub stages: Vec<Vec<String>>,
49 /// Edges dropped because nothing answers to the name.
50 pub unresolved: Vec<Unresolved>,
51 /// One cycle for every knot in the graph, as a path with the first name
52 /// not repeated at the end. [`render_cycle`] closes it for display.
53 /// Several cycles can run through the same knot; the one named here is
54 /// a representative, and breaking it is what an operator does about it.
55 pub cycles: Vec<Vec<String>>,
56 /// Every member of each knot, index-aligned with [`BootPlan::cycles`].
57 ///
58 /// The representative path names only the nodes one cycle runs through,
59 /// so a knot of three reached by two edges leaves one of them off it. A
60 /// caller asking whether a name is stuck asks this; a caller printing
61 /// what to break prints the path.
62 pub knots: Vec<BTreeSet<String>>,
63}
64
65/// Borrowed names, owned. The call sites below all convert one of this
66/// module's `&str` sets or paths into an owned `Vec<String>` or
67/// `BTreeSet<String>` for a `BootPlan` field; this is the one place that
68/// spells the conversion.
69fn owned<'a, C: FromIterator<String>>(names: impl IntoIterator<Item = &'a str>) -> C {
70 names.into_iter().map(str::to_string).collect()
71}
72
73/// Renders one cycle as `a -> b -> c -> a`.
74///
75/// The closing repeat is what makes it readable as a cycle rather than as a
76/// list of names that happen to be involved in one.
77#[must_use]
78pub fn render_cycle(cycle: &[String]) -> String {
79 let mut path: Vec<&str> = cycle.iter().map(String::as_str).collect();
80 if let Some(first) = cycle.first() {
81 path.push(first.as_str());
82 }
83 path.join(" -> ")
84}
85
86/// Sorts `nodes` into stages.
87///
88/// Edges to a name outside `nodes` are dropped and recorded in
89/// [`BootPlan::unresolved`], because a dependency on an app whose Flockfile
90/// lives in another repository is legitimate.
91///
92/// Nodes in a cycle are lifted out of the sort into one unordered stage, and
93/// one cycle through each knot they form is recorded in
94/// [`BootPlan::cycles`]. Refusing here would strand an unattended boot; the
95/// caller decides whether to refuse, and only the operator-facing callers
96/// do. Anything depending on a knot, directly or through a chain, follows
97/// that stage rather than preceding it, for the reason argued on
98/// `depends_on_a_cycle`.
99///
100/// Dogs default to a stage after every sheep, so an existing install keeps
101/// the order `boot.rs` argues for. Two things move one: `boot_first`, which
102/// puts it in the first stage unless it depends on a knot, in which case it
103/// plans after the cyclic stage like anything else that does; and anything
104/// depending on it, which gives it an ordinary graph position. A dog runs
105/// at the earliest stage anything asks for.
106///
107/// The stages therefore run: `boot_first` dogs, the ordinary sort, the
108/// cyclic stage, the nodes that depend on the cycle in their own edge order,
109/// then dogs nothing depends on. Those come last unconditionally, since a
110/// dog placed before a knot would answer for a flock still coming up.
111///
112/// That paragraph describes the plan this function returns, and not even
113/// shep's boot honours all of it. `shep-daemon`'s `boot` spawns dogs in two
114/// groups,
115/// the promoted ones before the restore and every other one after the last
116/// stage, so a dog's plan position between those two points is not read: a
117/// sheep depending on a dog is warned about and started anyway. The driver
118/// decides, not this plan.
119#[must_use]
120pub fn plan(nodes: &[BootNode]) -> BootPlan {
121 let names: BTreeSet<&str> = nodes.iter().map(|n| n.name.as_str()).collect();
122
123 let mut unresolved = Vec::new();
124 let mut edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
125 for node in nodes {
126 let deps = edges.entry(node.name.as_str()).or_default();
127 for target in &node.depends_on {
128 if names.contains(target.as_str()) {
129 deps.insert(target.as_str());
130 } else {
131 unresolved.push(Unresolved {
132 dependent: node.name.clone(),
133 missing: target.clone(),
134 });
135 }
136 }
137 }
138
139 let knots = knots(&edges);
140 let in_a_cycle: BTreeSet<&str> = knots.iter().flatten().copied().collect();
141 let cycles: Vec<Vec<String>> = knots
142 .iter()
143 .map(|members| representative_cycle(&edges, members))
144 .collect();
145 let members: Vec<BTreeSet<String>> = knots
146 .iter()
147 .map(|knot| owned(knot.iter().copied()))
148 .collect();
149
150 let after_cycle = depends_on_a_cycle(&edges, &in_a_cycle);
151
152 let depended_on: BTreeSet<&str> = edges.values().flatten().copied().collect();
153 let mut first = Vec::new();
154 let mut last = Vec::new();
155 let mut ordered: BTreeSet<&str> = BTreeSet::new();
156 for node in nodes {
157 let name = node.name.as_str();
158 if in_a_cycle.contains(name) || after_cycle.contains(name) {
159 continue;
160 }
161 match node.kind {
162 NodeKind::Dog { boot_first: true } => first.push(name),
163 NodeKind::Dog { boot_first: false } if !depended_on.contains(name) => last.push(name),
164 _ => {
165 ordered.insert(name);
166 }
167 }
168 }
169 first.sort_unstable();
170 last.sort_unstable();
171
172 let mut stages: Vec<Vec<String>> = Vec::new();
173 if !first.is_empty() {
174 stages.push(owned(first.iter().copied()));
175 }
176 stages.extend(kahn(&ordered, &edges, &first));
177 if !in_a_cycle.is_empty() {
178 // One stage for every cyclic node, never one per reported cycle: a
179 // node several cycles run through is still started once.
180 stages.push(owned(in_a_cycle.iter().copied()));
181 }
182 // Whatever hangs off the knot, in its own edge order. Every dependency
183 // outside this set is already placed, which is what `kahn` reads a
184 // missing name as.
185 stages.extend(kahn(&after_cycle, &edges, &[]));
186 // Dead last, after the knot and everything hanging off it. Placing these
187 // before the cyclic stage would leave a metrics dog answering for a flock
188 // whose knot has not started, which is the case the dogs-last default
189 // exists to prevent, so "last" has to mean last even when the graph is
190 // degraded.
191 if !last.is_empty() {
192 stages.push(owned(last.iter().copied()));
193 }
194
195 BootPlan {
196 stages,
197 unresolved,
198 cycles,
199 knots: members,
200 }
201}
202
203/// Every node outside `in_a_cycle` with a path to one of its members.
204///
205/// The surprising part: shep does not refuse a boot over a cycle, it warns
206/// and brings the flock up with the knot last. A dependent of the knot can
207/// never have its dependency satisfied, so it starts anyway, and the only
208/// question left is where. A plain topological sort answers "first", because
209/// the edge points at a name the sort no longer holds and an absent
210/// dependency reads as a met one. That is the worst of the available orders,
211/// so these nodes are held out of the sort too and replanted after the
212/// cyclic stage.
213fn depends_on_a_cycle<'a>(
214 edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
215 in_a_cycle: &BTreeSet<&'a str>,
216) -> BTreeSet<&'a str> {
217 let mut found: BTreeSet<&'a str> = BTreeSet::new();
218 loop {
219 let grown: Vec<&'a str> = edges
220 .iter()
221 .filter(|(name, _)| !in_a_cycle.contains(*name) && !found.contains(*name))
222 .filter(|(_, deps)| {
223 deps.iter()
224 .any(|dep| in_a_cycle.contains(dep) || found.contains(dep))
225 })
226 .map(|(name, _)| *name)
227 .collect();
228 if grown.is_empty() {
229 return found;
230 }
231 found.extend(grown);
232 }
233}
234
235/// Kahn's algorithm over `ordered`, taking every node whose remaining edges
236/// are satisfied as one stage. `already` names nodes placed in an earlier
237/// stage, whose edges are therefore met.
238fn kahn(
239 ordered: &BTreeSet<&str>,
240 edges: &BTreeMap<&str, BTreeSet<&str>>,
241 already: &[&str],
242) -> Vec<Vec<String>> {
243 let mut placed: BTreeSet<&str> = already.iter().copied().collect();
244 let mut left: BTreeSet<&str> = ordered.clone();
245 let mut stages = Vec::new();
246 while !left.is_empty() {
247 let ready: Vec<&str> = left
248 .iter()
249 .copied()
250 .filter(|name| {
251 edges
252 .get(name)
253 .is_none_or(|deps| deps.iter().all(|d| placed.contains(d) || !left.contains(d)))
254 })
255 .collect();
256 // Cycles were lifted out before this ran, so a stall is impossible.
257 // Breaking rather than looping forever is the safe arm regardless.
258 if ready.is_empty() {
259 break;
260 }
261 for name in &ready {
262 left.remove(name);
263 placed.insert(name);
264 }
265 stages.push(owned(ready.iter().copied()));
266 }
267 stages
268}
269
270/// Every knot in `edges`: a strongly connected component of two or more
271/// nodes, or a lone node that depends on itself. Each component's members,
272/// sorted, and the components themselves ordered by their first member.
273///
274/// Tarjan's algorithm rather than a search for a back edge. A back-edge walk
275/// answers "does this one walk close on itself", and marking a node explored
276/// the first time it is reached is what makes that walk finite: a second path
277/// arriving at the same node turns back without retraversing it, so of two
278/// cycles sharing a node only one is ever seen. The question this module asks
279/// is which nodes sit in a component larger than themselves, and only a
280/// component algorithm answers it.
281///
282/// Recursive, since the depth is bounded by the size of one flock.
283fn knots<'a>(edges: &BTreeMap<&'a str, BTreeSet<&'a str>>) -> Vec<BTreeSet<&'a str>> {
284 let mut tarjan = Tarjan {
285 index: BTreeMap::new(),
286 low: BTreeMap::new(),
287 stack: Vec::new(),
288 on_stack: BTreeSet::new(),
289 next: 0,
290 components: Vec::new(),
291 };
292 for name in edges.keys().copied() {
293 if !tarjan.index.contains_key(name) {
294 tarjan.connect(name, edges);
295 }
296 }
297 let mut found: Vec<BTreeSet<&str>> = tarjan
298 .components
299 .into_iter()
300 .filter(|members| {
301 members.len() > 1
302 || members
303 .iter()
304 .next()
305 .is_some_and(|only| edges.get(only).is_some_and(|deps| deps.contains(only)))
306 })
307 .collect();
308 found.sort();
309 found
310}
311
312/// Tarjan's bookkeeping: the visit number each node was reached at, the
313/// lowest number it can reach, and the nodes whose component is still open.
314struct Tarjan<'a> {
315 index: BTreeMap<&'a str, usize>,
316 low: BTreeMap<&'a str, usize>,
317 stack: Vec<&'a str>,
318 on_stack: BTreeSet<&'a str>,
319 next: usize,
320 components: Vec<BTreeSet<&'a str>>,
321}
322
323impl<'a> Tarjan<'a> {
324 fn connect(&mut self, name: &'a str, edges: &BTreeMap<&'a str, BTreeSet<&'a str>>) {
325 self.index.insert(name, self.next);
326 self.low.insert(name, self.next);
327 self.next += 1;
328 self.stack.push(name);
329 self.on_stack.insert(name);
330
331 if let Some(deps) = edges.get(name) {
332 for dep in deps.iter().copied() {
333 let reachable = if self.index.contains_key(dep) {
334 // An edge into a closed component says nothing about
335 // this one, so only a node still on the stack counts.
336 self.on_stack.contains(dep).then(|| self.index[dep])
337 } else {
338 self.connect(dep, edges);
339 Some(self.low[dep])
340 };
341 if let Some(reachable) = reachable {
342 let low = self.low.entry(name).or_insert(reachable);
343 *low = (*low).min(reachable);
344 }
345 }
346 }
347
348 if self.low[name] == self.index[name] {
349 let mut members = BTreeSet::new();
350 while let Some(member) = self.stack.pop() {
351 self.on_stack.remove(member);
352 members.insert(member);
353 if member == name {
354 break;
355 }
356 }
357 self.components.push(members);
358 }
359 }
360}
361
362/// One cycle through `members`, as the path a walk closed on.
363///
364/// [`BootPlan::cycles`] names a path rather than a set because
365/// [`render_cycle`] prints `a -> b -> c -> a`, and a bare set of the names
366/// involved is not something an operator can act on.
367fn representative_cycle<'a>(
368 edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
369 members: &BTreeSet<&'a str>,
370) -> Vec<String> {
371 let Some(start) = members.iter().copied().next() else {
372 return Vec::new();
373 };
374 let mut path = vec![start];
375 let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
376 if !close_on(start, start, edges, members, &mut path, &mut seen) {
377 // Every member of a knot has a path back to every other, so the
378 // walk closes. Naming the node alone is the honest fallback.
379 return vec![start.to_string()];
380 }
381 owned(path.iter().copied())
382}
383
384/// Walks from `at`, inside `members` only, until it finds an edge back to
385/// `start`. `path` holds the walk so far and is the cycle when this answers
386/// `true`.
387fn close_on<'a>(
388 at: &'a str,
389 start: &'a str,
390 edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
391 members: &BTreeSet<&'a str>,
392 path: &mut Vec<&'a str>,
393 seen: &mut BTreeSet<&'a str>,
394) -> bool {
395 let Some(deps) = edges.get(at) else {
396 return false;
397 };
398 for dep in deps.iter().copied().filter(|dep| members.contains(dep)) {
399 if dep == start {
400 return true;
401 }
402 if seen.insert(dep) {
403 path.push(dep);
404 if close_on(dep, start, edges, members, path, seen) {
405 return true;
406 }
407 path.pop();
408 }
409 }
410 false
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 fn sheep(name: &str, deps: &[&str]) -> BootNode {
418 BootNode {
419 name: name.to_string(),
420 depends_on: deps.iter().map(|d| (*d).to_string()).collect(),
421 kind: NodeKind::Sheep,
422 }
423 }
424
425 fn dog(name: &str, boot_first: bool) -> BootNode {
426 BootNode {
427 name: name.to_string(),
428 depends_on: Vec::new(),
429 kind: NodeKind::Dog { boot_first },
430 }
431 }
432
433 #[test]
434 fn a_chain_becomes_one_stage_per_link() {
435 // fails if the sort collapses or reorders the chain
436 let out = plan(&[
437 sheep("web", &["api"]),
438 sheep("api", &["db"]),
439 sheep("db", &[]),
440 ]);
441 assert_eq!(out.stages, vec![vec!["db"], vec!["api"], vec!["web"]]);
442 }
443
444 #[test]
445 fn independent_nodes_share_a_stage_sorted_by_name() {
446 // fails if a stage's order depends on input order, which would make
447 // the boot plan nondeterministic
448 let out = plan(&[
449 sheep("cache", &[]),
450 sheep("db", &[]),
451 sheep("api", &["db", "cache"]),
452 ]);
453 assert_eq!(out.stages, vec![vec!["cache", "db"], vec!["api"]]);
454 }
455
456 #[test]
457 fn a_cycle_is_named_as_a_path_and_its_nodes_run_last() {
458 // fails if the cycle is merely detected, or if a cycle sinks the
459 // whole plan rather than being isolated into a final stage
460 let out = plan(&[
461 sheep("a", &["c"]),
462 sheep("b", &["a"]),
463 sheep("c", &["b"]),
464 sheep("lone", &[]),
465 ]);
466 assert_eq!(out.cycles.len(), 1, "one cycle expected: {:?}", out.cycles);
467 let rendered = render_cycle(&out.cycles[0]);
468 assert!(
469 rendered.starts_with("a -> ")
470 || rendered.starts_with("b -> ")
471 || rendered.starts_with("c -> ")
472 );
473 assert_eq!(
474 rendered.matches(" -> ").count(),
475 3,
476 "the path must close: {rendered}"
477 );
478 assert_eq!(out.stages.last().unwrap(), &vec!["a", "b", "c"]);
479 }
480
481 #[test]
482 fn an_edge_to_a_name_nobody_has_is_recorded_and_dropped() {
483 // fails if an unknown name refuses the plan or silently vanishes
484 let out = plan(&[sheep("api", &["nope"])]);
485 assert_eq!(out.stages, vec![vec!["api"]]);
486 assert_eq!(
487 out.unresolved,
488 vec![Unresolved {
489 dependent: "api".to_string(),
490 missing: "nope".to_string()
491 }]
492 );
493 }
494
495 #[test]
496 fn a_dog_nobody_depends_on_runs_last() {
497 // fails if dogs join the ordinary sort, which would move every
498 // existing install's boot order
499 let out = plan(&[
500 dog("metrics", false),
501 sheep("db", &[]),
502 sheep("api", &["db"]),
503 ]);
504 assert_eq!(out.stages, vec![vec!["db"], vec!["api"], vec!["metrics"]]);
505 }
506
507 #[test]
508 fn a_dog_still_runs_last_when_the_flock_holds_a_cycle() {
509 // fails if the undepended-on dogs are placed before the cyclic stage
510 // rather than after everything: a metrics dog would then answer for a
511 // flock whose knot has not started, which is the exact case the
512 // dogs-last default exists to prevent.
513 let out = plan(&[
514 dog("metrics", false),
515 sheep("a", &["b"]),
516 sheep("b", &["a"]),
517 sheep("tail", &["a"]),
518 sheep("plain", &[]),
519 ]);
520 let metrics = out
521 .stages
522 .iter()
523 .position(|stage| stage.iter().any(|n| n == "metrics"))
524 .expect("the dog is planned");
525 assert_eq!(
526 metrics,
527 out.stages.len() - 1,
528 "the dog must be the last stage: {:?}",
529 out.stages
530 );
531 }
532
533 #[test]
534 fn a_boot_first_dog_runs_before_every_sheep() {
535 // fails if boot_first is ignored, which is the log-rotate case
536 let out = plan(&[
537 dog("log-rotate", true),
538 sheep("db", &[]),
539 dog("metrics", false),
540 ]);
541 assert_eq!(
542 out.stages,
543 vec![vec!["log-rotate"], vec!["db"], vec!["metrics"]]
544 );
545 }
546
547 #[test]
548 fn a_dog_something_depends_on_takes_its_graph_position() {
549 // fails if the dogs-last default outranks an explicit edge
550 let out = plan(&[
551 dog("sidecar", false),
552 sheep("db", &[]),
553 sheep("api", &["db", "sidecar"]),
554 dog("metrics", false),
555 ]);
556 assert_eq!(
557 out.stages,
558 vec![vec!["db", "sidecar"], vec!["api"], vec!["metrics"]]
559 );
560 }
561
562 #[test]
563 fn an_empty_flock_plans_no_stages() {
564 // fails if the sort emits an empty stage, which the driver would
565 // then wait on
566 assert!(plan(&[]).stages.is_empty());
567 }
568
569 #[test]
570 fn two_cycles_sharing_a_node_put_every_member_in_the_last_stage() {
571 // fails if the cycle search marks a node done the first time it is
572 // reached, which leaves the second path through it unwalked: "c" is
573 // as cyclic as "b" and used to plan into the first stage, ahead of
574 // the "d" it depends on
575 let out = plan(&[
576 sheep("a", &["b", "c"]),
577 sheep("b", &["d"]),
578 sheep("c", &["d"]),
579 sheep("d", &["a"]),
580 ]);
581 assert_eq!(
582 out.cycles.len(),
583 1,
584 "one component expected: {:?}",
585 out.cycles
586 );
587 assert_eq!(out.stages, vec![vec!["a", "b", "c", "d"]]);
588 }
589
590 #[test]
591 fn a_knot_reports_every_member_even_when_its_path_names_two() {
592 // fails if `knots` is derived from the reported path: the path runs
593 // through one cycle and "c" is off it, so a caller asking whether a
594 // name is stuck would read "c" as free
595 let out = plan(&[
596 sheep("a", &["b", "c"]),
597 sheep("b", &["a"]),
598 sheep("c", &["a"]),
599 ]);
600 assert_eq!(out.knots.len(), out.cycles.len(), "one set per path");
601 assert_eq!(
602 out.knots[0],
603 ["a", "b", "c"]
604 .iter()
605 .map(|n| (*n).to_string())
606 .collect::<BTreeSet<String>>()
607 );
608 assert!(
609 !out.cycles[0].contains(&"c".to_string()),
610 "the representative path is still a path: {:?}",
611 out.cycles[0]
612 );
613 }
614
615 #[test]
616 fn a_node_two_cycles_run_through_is_planned_into_one_stage() {
617 // fails if the final stage is built once per reported cycle rather
618 // than once from the set of cyclic nodes, which starts the shared
619 // node twice
620 let out = plan(&[
621 sheep("a", &["b", "c"]),
622 sheep("b", &["a"]),
623 sheep("c", &["a"]),
624 ]);
625 assert_eq!(
626 out.cycles.len(),
627 1,
628 "one component expected: {:?}",
629 out.cycles
630 );
631 assert_eq!(out.stages, vec![vec!["a", "b", "c"]]);
632 }
633
634 #[test]
635 fn a_node_depending_on_a_cycle_starts_after_it_not_before() {
636 // fails if an edge into a knot reads as satisfied because the knot
637 // was lifted out of the sort, which put "x" in the first stage,
638 // ahead of the "a" it depends on
639 let out = plan(&[
640 sheep("x", &["a"]),
641 sheep("a", &["b"]),
642 sheep("b", &["a"]),
643 sheep("y", &["x"]),
644 ]);
645 assert_eq!(out.stages, vec![vec!["a", "b"], vec!["x"], vec!["y"]]);
646 }
647
648 /// Every stage is non-empty and internally sorted, the shape both
649 /// proptests below check before going on to their own invariant.
650 ///
651 /// Returns rather than asserts: `prop_assert!` expands to an early
652 /// return of a `TestCaseError`, which is what lets a failure shrink.
653 fn stages_are_well_formed(
654 stages: &[Vec<String>],
655 ) -> Result<(), proptest::test_runner::TestCaseError> {
656 for stage in stages {
657 proptest::prop_assert!(!stage.is_empty(), "an empty stage: {:?}", stages);
658 let mut sorted = stage.clone();
659 sorted.sort();
660 proptest::prop_assert_eq!(stage, &sorted, "an unsorted stage: {:?}", stages);
661 }
662 Ok(())
663 }
664
665 proptest::proptest! {
666 #[test]
667 fn every_edge_is_respected_in_the_planned_order(
668 edges in proptest::collection::vec((0usize..8, 0usize..8), 0..24)
669 ) {
670 // fails if the sort violates edge order anywhere on an acyclic
671 // graph. An edge only ever points at a lower index by
672 // construction, so no input here can be cyclic.
673 let mut deps: Vec<Vec<String>> = vec![Vec::new(); 8];
674 for (from, to) in edges {
675 if to < from {
676 deps[from].push(format!("n{to}"));
677 }
678 }
679 let nodes: Vec<BootNode> = (0..8)
680 .map(|i| BootNode {
681 name: format!("n{i}"),
682 depends_on: deps[i].clone(),
683 kind: NodeKind::Sheep,
684 })
685 .collect();
686 let out = plan(&nodes);
687 proptest::prop_assert!(out.cycles.is_empty());
688 stages_are_well_formed(&out.stages)?;
689 let mut stage_of = std::collections::BTreeMap::new();
690 for (index, stage) in out.stages.iter().enumerate() {
691 for name in stage {
692 stage_of.insert(name.clone(), index);
693 }
694 }
695 for node in &nodes {
696 for dep in &node.depends_on {
697 proptest::prop_assert!(stage_of[dep] < stage_of[&node.name]);
698 }
699 }
700 }
701
702 #[test]
703 fn every_cyclic_node_is_reported_and_no_node_is_planned_twice(
704 edges in proptest::collection::vec((0usize..6, 0usize..6), 0..18),
705 shuffle_keys in proptest::collection::vec(0u32.., 6)
706 ) {
707 // fails if a node is dropped or duplicated, a cyclic node is
708 // missed or misplaced, an edge out of an acyclic node is not
709 // respected, a stage is empty or unsorted, two reported cycles
710 // share a node, or the plan changes when the same nodes are
711 // planned in a different order. Arbitrary edges, so most draws
712 // are cyclic: the DAG-by-construction case above gives the
713 // cyclic path no coverage at all. Ground truth is a transitive
714 // closure computed here, not anything the module does, so the
715 // test cannot agree with a bug by sharing its logic.
716 const N: usize = 6;
717 let mut adjacent = [[false; N]; N];
718 for (from, to) in edges {
719 adjacent[from][to] = true;
720 }
721 let mut reaches = adjacent;
722 for k in 0..N {
723 for i in 0..N {
724 for j in 0..N {
725 if reaches[i][k] && reaches[k][j] {
726 reaches[i][j] = true;
727 }
728 }
729 }
730 }
731 let nodes: Vec<BootNode> = (0..N)
732 .map(|i| BootNode {
733 name: format!("n{i}"),
734 depends_on: (0..N)
735 .filter(|j| adjacent[i][*j])
736 .map(|j| format!("n{j}"))
737 .collect(),
738 kind: NodeKind::Sheep,
739 })
740 .collect();
741 let out = plan(&nodes);
742
743 stages_are_well_formed(&out.stages)?;
744
745 // Determinism against input order: planning the same nodes in a
746 // different order must produce the identical plan. The reorder
747 // is a sort key drawn independently of the edges, not a
748 // transform derived from the module under test.
749 let mut reordered: Vec<(u32, BootNode)> =
750 shuffle_keys.into_iter().zip(nodes.iter().cloned()).collect();
751 reordered.sort_by_key(|(key, _)| *key);
752 let shuffled_nodes: Vec<BootNode> = reordered.into_iter().map(|(_, n)| n).collect();
753 let out_shuffled = plan(&shuffled_nodes);
754 proptest::prop_assert_eq!(
755 &out_shuffled,
756 &out,
757 "the same nodes in a different order planned differently"
758 );
759
760 let planned: Vec<String> = out.stages.iter().flatten().cloned().collect();
761 let mut once = planned.clone();
762 once.sort();
763 once.dedup();
764 proptest::prop_assert_eq!(
765 once.len(),
766 planned.len(),
767 "a node is planned twice: {:?}",
768 out.stages
769 );
770 proptest::prop_assert_eq!(once.len(), N, "a node is planned nowhere: {:?}", out.stages);
771
772 // A node is truly cyclic when it reaches itself, and every one
773 // of them belongs in one stage together. That stage is not the
774 // last one: whatever depends on the knot follows it.
775 let stage_of = |i: usize| {
776 out.stages
777 .iter()
778 .position(|stage| stage.contains(&format!("n{i}")))
779 .expect("every node is planned")
780 };
781 let cyclic: Vec<usize> = (0..N).filter(|i| reaches[*i][*i]).collect();
782
783 // An edge out of an acyclic node has to land strictly earlier,
784 // the same claim the DAG test above makes, just without the
785 // guarantee that every node here qualifies: a node inside a
786 // knot has no such promise, since its own dependency can sit in
787 // the same stage or after it.
788 for i in (0..N).filter(|i| !reaches[*i][*i]) {
789 for j in (0..N).filter(|j| adjacent[i][*j]) {
790 proptest::prop_assert!(
791 stage_of(j) < stage_of(i),
792 "n{} depends on n{} but n{} is not strictly earlier: {:?}",
793 i,
794 j,
795 j,
796 out.stages
797 );
798 }
799 }
800
801 if let Some(first) = cyclic.first().copied() {
802 let knot = stage_of(first);
803 for i in cyclic.iter().copied() {
804 proptest::prop_assert_eq!(
805 stage_of(i),
806 knot,
807 "n{} is cyclic and is planned elsewhere: {:?}",
808 i,
809 out.stages
810 );
811 }
812 // A dependent of the knot can never have its dependency
813 // satisfied, so it starts anyway, but never first; an
814 // acyclic node that is NOT a dependent has no business in
815 // the knot's stage or after it.
816 for i in (0..N).filter(|i| !reaches[*i][*i]) {
817 let is_dependent = cyclic.iter().any(|c| reaches[i][*c]);
818 // `prop_assert!` with a comparison rather than
819 // `prop_assert_ne!`: that macro expands to an unqualified
820 // `prop_assert!` in proptest 1.0.0, so a path-qualified
821 // call to it fails to compile under the minimal-versions
822 // job even though it is fine on a current proptest.
823 proptest::prop_assert!(
824 stage_of(i) != knot,
825 "n{} is acyclic but planned into the knot's own stage: {:?}",
826 i,
827 out.stages
828 );
829 proptest::prop_assert_eq!(
830 stage_of(i) > knot,
831 is_dependent,
832 "n{} depends on the knot: {}, but its stage relative to the knot disagrees: {:?}",
833 i,
834 is_dependent,
835 out.stages
836 );
837 }
838 }
839
840 // One reported path per cyclic component, and every reported
841 // path is a real cycle rather than a set of involved names.
842 let mut components: Vec<Vec<usize>> = Vec::new();
843 for (i, reached) in reaches.iter().enumerate() {
844 if reached[i] && !components.iter().any(|c| c.contains(&i)) {
845 components.push((0..N).filter(|j| reached[*j] && reaches[*j][i]).collect());
846 }
847 }
848 proptest::prop_assert_eq!(
849 out.cycles.len(),
850 components.len(),
851 "reported {:?} for components {:?}",
852 out.cycles,
853 components
854 );
855 for cycle in &out.cycles {
856 for (at, name) in cycle.iter().enumerate() {
857 let from: usize = name[1..].parse().unwrap();
858 let to: usize = cycle[(at + 1) % cycle.len()][1..].parse().unwrap();
859 proptest::prop_assert!(
860 adjacent[from][to],
861 "{} is not a path anything can walk",
862 render_cycle(cycle)
863 );
864 }
865 }
866
867 // Two reported cycles never share a node: a component is a set
868 // of nodes, so two distinct components are disjoint, and this
869 // checks the reported paths against each other rather than
870 // against `components` above, since a bug that miscomputed both
871 // the same way would slip past a check that used one to verify
872 // the other.
873 for (i, a) in out.cycles.iter().enumerate() {
874 for b in &out.cycles[i + 1..] {
875 let a_names: BTreeSet<&str> = a.iter().map(String::as_str).collect();
876 let b_names: BTreeSet<&str> = b.iter().map(String::as_str).collect();
877 proptest::prop_assert!(
878 a_names.is_disjoint(&b_names),
879 "two reported cycles share a node: {} and {}",
880 render_cycle(a),
881 render_cycle(b)
882 );
883 }
884 }
885 }
886 }
887}