feat(#2772): make a dependency edge a set of accepted outcomes
`DepWhen` was two named cases, so every new combination wanted a new
variant. It is now a set over the terminal outcomes: a `u8` bitset
newtype, no dependency, with `AFTER_OK` / `AFTER_ANY` kept as the two
constants the templates actually use. "Run regardless" is all outcomes,
"anything that isn't a failure" is `{Done, Cancelled}`, a compensation
branch is `{Failed}` — closed under combination, so it never needs
another variant.
`TerminalState` is its own type rather than a subset of `State`, so an
edge cannot name `Pending` / `Running` / `Finishing`. Those are
meaningless in a dependency and are better unrepresentable than
validated against. The empty set is the one thing that can't be typed
away — nothing satisfies it, so `validate` rejects it next to the cycle
check.
Two consequences worth calling out:
- `cascade_cancel` collapses to one rule: a pending node is doomed once
any edge it names can no longer be satisfied. The hardcoded `AfterOk`
special case is gone, and a weak-edged node survives its dependency's
cancellation because of its own edge rather than by exemption.
- The cascade now runs on **any** terminal outcome, `Done` included.
With sets, success rules dependents out just as failure does — a
`{Failed}` branch is unsatisfiable the moment its dependency succeeds,
and leaving it `Pending` would wedge the subtree non-terminal forever.
That is a hang, not a wrong answer, so it is the load-bearing half of
this commit.
Edges are conjunctive, so "any of these N failed" is not directly
sayable. The composition that works is in the tests: the success branch
depends `AFTER_OK` on every root, so it is itself cancelled the moment
one of them doesn't succeed, and the failure branch hangs off *that*
with `{Cancelled}`. Exactly one of the two runs.
Also deletes hive-c0re's duplicate `DepWhen` enum and the
`to_crate_when` translation beside it. The copy bought nothing and had
to be widened in lockstep with the crate's edge model — it is the
in-between layer #2772 exists to remove, and it is what broke the build
when the crate's spelling changed.
All 34 jobq tests pass, including the four new ones covering both
directions of a failure-only branch, weak-edge survival of a cancelled
dependency, and the aggregator composition.
This commit is contained in:
parent
7110a25cf6
commit
affedecaa5
6 changed files with 303 additions and 82 deletions
|
|
@ -32,7 +32,7 @@ use std::collections::HashMap;
|
|||
use std::hash::Hash;
|
||||
|
||||
use crate::resources::ResourceTable;
|
||||
use crate::{Dep, DepWhen, Graph, GraphError, NodeId, State};
|
||||
use crate::{Dep, Graph, GraphError, NodeId, State};
|
||||
|
||||
/// The result of a node's own execution, reported to [`Scheduler::complete`].
|
||||
///
|
||||
|
|
@ -244,6 +244,12 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
|
|||
/// Transition a node whose own logic just *succeeded* to its resulting state:
|
||||
/// [`State::Finishing`] while any child is still non-terminal, else `Failed`
|
||||
/// if a child failed, else `Done`. A node with no children skips `Finishing`.
|
||||
///
|
||||
/// Cascades on **any** terminal outcome, `Done` included. Since an edge names
|
||||
/// the set of outcomes it accepts, success can rule a dependent out just as
|
||||
/// failure can — a `{Failed}` compensation branch is unsatisfiable the moment
|
||||
/// its dependency succeeds, and leaving it `Pending` would wedge the subtree
|
||||
/// non-terminal forever.
|
||||
fn settle_terminal(&mut self, id: NodeId) {
|
||||
let state = if !self.all_children_terminal(id) {
|
||||
State::Finishing
|
||||
|
|
@ -253,7 +259,7 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
|
|||
State::Done
|
||||
};
|
||||
self.graph.set_state(id, state);
|
||||
if state == State::Failed {
|
||||
if state.is_terminal() {
|
||||
self.cascade_cancel(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -276,14 +282,13 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
|
|||
State::Done
|
||||
};
|
||||
self.graph.set_state(a, state);
|
||||
if state == State::Failed {
|
||||
self.cascade_cancel(a);
|
||||
}
|
||||
// Any terminal outcome can rule a dependent out — see `settle_terminal`.
|
||||
self.cascade_cancel(a);
|
||||
cur = self.graph.node(a).and_then(|n| n.parent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a still-*pending* node (and cascade to its `AfterOk` dependents):
|
||||
/// Cancel a still-*pending* node (and cascade to the dependents it rules out):
|
||||
/// mark it [`State::Cancelled`] and report whether it was cancellable. A
|
||||
/// node that has already started (`Running`) or finished is left untouched —
|
||||
/// an in-flight node's work is not interruptible. A pending node holds no
|
||||
|
|
@ -316,16 +321,26 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Propagate cancellation out from a just-failed/cancelled `origin`: every
|
||||
/// Propagate cancellation out from a just-terminal `origin`: every
|
||||
/// still-`Pending` node that can no longer run gets marked `Cancelled`,
|
||||
/// transitively. Two edges carry it: (a) an `AfterOk` dep on a cancelled node
|
||||
/// (a strong dependency failed), and (b) being a *child* of one (its parent
|
||||
/// will never reach `Finishing`, so it was gated from ever starting — and
|
||||
/// leaving it pending would wedge the subtree non-terminal). Cancelled nodes
|
||||
/// were `Pending`, so they hold no resources.
|
||||
/// transitively. Cancelled nodes were `Pending`, so they hold no resources.
|
||||
///
|
||||
/// One rule decides it: **a node is doomed once any edge it names can never
|
||||
/// be satisfied** — the dep settled on an outcome that edge does not accept.
|
||||
/// `AFTER_OK` on a `Failed` dep dooms (a strong dependency failed);
|
||||
/// `AFTER_ANY` never dooms, which is what lets a tail node survive the
|
||||
/// cancellation of the work it reports on. That falls out of the edge's own
|
||||
/// set rather than being a special case for particular node kinds.
|
||||
///
|
||||
/// Plus the structural edge: being a *child* of a doomed node. Its parent
|
||||
/// will never reach `Finishing`, so it was gated from ever starting, and
|
||||
/// leaving it pending would wedge the subtree non-terminal.
|
||||
fn cascade_cancel(&mut self, origin: NodeId) {
|
||||
let mut stack = vec![origin];
|
||||
while let Some(cur) = stack.pop() {
|
||||
let Some(outcome) = self.graph.node(cur).and_then(|n| n.state.terminal()) else {
|
||||
continue;
|
||||
};
|
||||
let doomed: Vec<NodeId> = self
|
||||
.graph
|
||||
.nodes()
|
||||
|
|
@ -333,7 +348,7 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
|
|||
n.state == State::Pending
|
||||
&& (n.parent == Some(cur)
|
||||
|| n.deps.iter().any(|d| {
|
||||
matches!(d, Dep::Node { id, when: DepWhen::AfterOk } if *id == cur)
|
||||
matches!(d, Dep::Node { id, when } if *id == cur && !when.accepts(outcome))
|
||||
}))
|
||||
})
|
||||
.map(|n| n.id)
|
||||
|
|
@ -413,6 +428,7 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DepWhen, TerminalState};
|
||||
|
||||
fn res(name: &str) -> String {
|
||||
name.to_owned()
|
||||
|
|
@ -436,7 +452,7 @@ mod tests {
|
|||
fn after_ok(on: NodeId) -> Dep<String> {
|
||||
Dep::Node {
|
||||
id: on,
|
||||
when: DepWhen::AfterOk,
|
||||
when: DepWhen::AFTER_OK,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -727,7 +743,7 @@ mod tests {
|
|||
"weak",
|
||||
vec![Dep::Node {
|
||||
id: root,
|
||||
when: DepWhen::AfterAny,
|
||||
when: DepWhen::AFTER_ANY,
|
||||
}],
|
||||
None,
|
||||
)
|
||||
|
|
@ -739,6 +755,134 @@ mod tests {
|
|||
assert_eq!(s.settle(), vec![weak]);
|
||||
}
|
||||
|
||||
/// The direction only a *set* edge can express: a branch that runs solely on
|
||||
/// failure. Success has to rule it out, which means the cascade must fire on
|
||||
/// `Done` too — otherwise it sits `Pending` forever and wedges the graph.
|
||||
#[test]
|
||||
fn success_cancels_a_failure_only_branch() {
|
||||
let mut s: Scheduler<&str, String> = Scheduler::new(Graph::new(), ResourceTable::new());
|
||||
let root = s.append("root", vec![], None).expect("root");
|
||||
let on_fail = s
|
||||
.append(
|
||||
"compensate",
|
||||
vec![Dep::Node {
|
||||
id: root,
|
||||
when: DepWhen::of(&[TerminalState::Failed]),
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.expect("compensate");
|
||||
assert_eq!(s.settle(), vec![root]);
|
||||
s.complete(root, Outcome::Done);
|
||||
assert_eq!(
|
||||
s.graph().node(on_fail).unwrap().state,
|
||||
State::Cancelled,
|
||||
"a Failed-only branch is unsatisfiable once its dep succeeds"
|
||||
);
|
||||
assert!(s.settle().is_empty(), "and nothing is left runnable");
|
||||
}
|
||||
|
||||
/// The mirror: the same branch is exactly what *does* run on failure, while
|
||||
/// an `AFTER_OK` sibling is cancelled. One edge set, both directions.
|
||||
#[test]
|
||||
fn failure_runs_the_failure_only_branch_and_cancels_the_ok_one() {
|
||||
let mut s: Scheduler<&str, String> = Scheduler::new(Graph::new(), ResourceTable::new());
|
||||
let root = s.append("root", vec![], None).expect("root");
|
||||
let on_ok = s
|
||||
.append("on_ok", vec![after_ok(root)], None)
|
||||
.expect("on_ok");
|
||||
let on_fail = s
|
||||
.append(
|
||||
"on_fail",
|
||||
vec![Dep::Node {
|
||||
id: root,
|
||||
when: DepWhen::of(&[TerminalState::Failed]),
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.expect("on_fail");
|
||||
assert_eq!(s.settle(), vec![root]);
|
||||
s.complete(root, Outcome::Failed("boom".to_owned()));
|
||||
assert_eq!(s.graph().node(on_ok).unwrap().state, State::Cancelled);
|
||||
assert_eq!(s.settle(), vec![on_fail]);
|
||||
}
|
||||
|
||||
/// A weak edge accepts cancellation, so a tail survives the cancellation of
|
||||
/// the work it reports on — the property hive-c0re's approval tails rely on,
|
||||
/// held here by the edge itself rather than by any node-kind special case.
|
||||
#[test]
|
||||
fn cancelled_dep_still_satisfies_a_weak_edge() {
|
||||
let mut s: Scheduler<&str, String> = Scheduler::new(Graph::new(), ResourceTable::new());
|
||||
let root = s.append("root", vec![], None).expect("root");
|
||||
let mid = s.append("mid", vec![after_ok(root)], None).expect("mid");
|
||||
let tail = s
|
||||
.append(
|
||||
"tail",
|
||||
vec![Dep::Node {
|
||||
id: mid,
|
||||
when: DepWhen::AFTER_ANY,
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.expect("tail");
|
||||
assert_eq!(s.settle(), vec![root]);
|
||||
s.complete(root, Outcome::Failed("boom".to_owned()));
|
||||
assert_eq!(s.graph().node(mid).unwrap().state, State::Cancelled);
|
||||
assert_eq!(
|
||||
s.settle(),
|
||||
vec![tail],
|
||||
"the tail runs off a cancelled dependency"
|
||||
);
|
||||
}
|
||||
|
||||
/// Edges are **conjunctive**, so "any of these several nodes failed" is not
|
||||
/// directly expressible — a `{Failed}` edge on each would mean *all* failed.
|
||||
/// The composition that does work: the success branch depends `AFTER_OK` on
|
||||
/// every node (so it runs only if all succeeded, and is cancelled the moment
|
||||
/// one doesn't), and the failure branch hangs off *it* with `{Cancelled}`.
|
||||
/// The success branch becomes the aggregator, and exactly one of the two runs.
|
||||
#[test]
|
||||
fn ok_branch_aggregates_and_failure_branch_hangs_off_its_cancellation() {
|
||||
let build = || {
|
||||
let mut s: Scheduler<&str, String> = Scheduler::new(Graph::new(), ResourceTable::new());
|
||||
let a = s.append("a", vec![], None).expect("a");
|
||||
let b = s.append("b", vec![], None).expect("b");
|
||||
let on_ok = s
|
||||
.append("on_ok", vec![after_ok(a), after_ok(b)], None)
|
||||
.expect("on_ok");
|
||||
let on_fail = s
|
||||
.append(
|
||||
"on_fail",
|
||||
vec![Dep::Node {
|
||||
id: on_ok,
|
||||
when: DepWhen::of(&[TerminalState::Cancelled]),
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.expect("on_fail");
|
||||
(s, a, b, on_ok, on_fail)
|
||||
};
|
||||
|
||||
// Everything succeeds: the ok branch runs, the failure branch is ruled out.
|
||||
let (mut s, a, b, on_ok, on_fail) = build();
|
||||
assert_eq!(s.settle(), vec![a, b], "both roots start; neither tail can");
|
||||
s.complete(a, Outcome::Done);
|
||||
s.complete(b, Outcome::Done);
|
||||
assert_eq!(s.settle(), vec![on_ok]);
|
||||
s.complete(on_ok, Outcome::Done);
|
||||
assert_eq!(s.graph().node(on_fail).unwrap().state, State::Cancelled);
|
||||
assert!(s.settle().is_empty());
|
||||
|
||||
// One of them fails: the ok branch is cancelled, which is precisely the
|
||||
// signal the failure branch waits on.
|
||||
let (mut s, a, b, on_ok, on_fail) = build();
|
||||
assert_eq!(s.settle(), vec![a, b]);
|
||||
s.complete(a, Outcome::Failed("boom".to_owned()));
|
||||
s.complete(b, Outcome::Done);
|
||||
assert_eq!(s.graph().node(on_ok).unwrap().state, State::Cancelled);
|
||||
assert_eq!(s.settle(), vec![on_fail]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_parent_cancels_its_pending_children() {
|
||||
// A failed group node cancels its sub-nodes (they were gated from ever
|
||||
|
|
|
|||
Loading…
Reference in a new issue