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