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:
atlas 2026-07-27 16:00:18 +02:00 committed by mara
commit affedecaa5
6 changed files with 303 additions and 82 deletions

View file

@ -56,29 +56,105 @@ impl NodeId {
}
}
/// When a [`Dep::Node`] edge is satisfied — the strong/weak distinction the
/// current queue carries as `DepWhen`, load-bearing for failure safety.
/// How a node finished. The terminal subset of [`State`], as its own type so an
/// edge condition cannot name `Pending` / `Running` / `Finishing` — those are
/// meaningless in a dependency and are better unrepresentable than rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DepWhen {
/// The dependency must reach [`State::Done`]. This is the default chain
/// edge: if the dependency *fails*, the dependent must not run and is
/// cancelled ([`State::Cancelled`]) down the chain — e.g. a failed
/// `Prebuild` must not let `StopForUpdate` stop a healthy container.
AfterOk,
/// The dependency need only be terminal — success or failure both satisfy
/// it. For steps that must converge regardless, e.g. `Reconcile` running
/// even when the preceding `Swap` failed.
AfterAny,
#[serde(rename_all = "snake_case")]
pub enum TerminalState {
/// Own logic succeeded and every sub-node did too.
Done,
/// Own logic failed, or a sub-node did.
Failed,
/// Never ran — an edge it depended on became unsatisfiable.
Cancelled,
}
impl TerminalState {
/// Bit for this outcome in a [`DepWhen`] set.
const fn bit(self) -> u8 {
match self {
TerminalState::Done => 1,
TerminalState::Failed => 1 << 1,
TerminalState::Cancelled => 1 << 2,
}
}
}
impl State {
/// This state as a [`TerminalState`], or `None` while the node is still
/// in flight.
#[must_use]
pub fn terminal(self) -> Option<TerminalState> {
match self {
State::Done => Some(TerminalState::Done),
State::Failed => Some(TerminalState::Failed),
State::Cancelled => Some(TerminalState::Cancelled),
State::Pending | State::Running | State::Finishing => None,
}
}
}
/// Which outcomes of a dependency satisfy a [`Dep::Node`] edge — **a set**, not
/// a fixed set of named cases.
///
/// Naming the cases (`AfterOk` / `AfterFail` / …) means a new variant every time
/// a combination is wanted. A set is closed under combination: "run regardless"
/// (systemd's `After=`) is all three; "anything that isn't a failure" is
/// `{Done, Cancelled}`; a compensating branch is `{Failed}`. [`AFTER_OK`] and
/// [`AFTER_ANY`] stay as named constants because they're the two the templates
/// overwhelmingly use.
///
/// The empty set satisfies nothing, so a node carrying one could never run;
/// [`Graph::validate`] rejects it.
///
/// [`AFTER_OK`]: DepWhen::AFTER_OK
/// [`AFTER_ANY`]: DepWhen::AFTER_ANY
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DepWhen(u8);
impl DepWhen {
/// Whether a dependency in `dep_state` satisfies this edge.
/// The dependency must reach [`TerminalState::Done`]. The default chain
/// edge: if the dependency fails, the dependent must not run and is
/// cancelled down the chain — e.g. a failed `Prebuild` must not let
/// `StopForUpdate` stop a healthy container.
pub const AFTER_OK: Self = Self(TerminalState::Done.bit());
/// The dependency need only be terminal — any outcome satisfies. For steps
/// that must converge regardless, e.g. `Reconcile` running even when the
/// preceding `Swap` failed, or a tail node that reports how the work ended.
pub const AFTER_ANY: Self = Self(
TerminalState::Done.bit() | TerminalState::Failed.bit() | TerminalState::Cancelled.bit(),
);
/// An edge satisfied by exactly the listed outcomes.
#[must_use]
pub const fn of(outcomes: &[TerminalState]) -> Self {
let mut bits = 0u8;
let mut i = 0;
while i < outcomes.len() {
bits |= outcomes[i].bit();
i += 1;
}
Self(bits)
}
/// Whether `outcome` satisfies this edge.
#[must_use]
pub const fn accepts(self, outcome: TerminalState) -> bool {
self.0 & outcome.bit() != 0
}
/// An edge no outcome can satisfy — rejected at [`Graph::validate`].
#[must_use]
pub const fn is_empty(self) -> bool {
self.0 == 0
}
/// Whether a dependency in `dep_state` satisfies this edge. A non-terminal
/// dependency never does.
#[must_use]
pub fn satisfied_by(self, dep_state: State) -> bool {
match self {
DepWhen::AfterOk => dep_state == State::Done,
DepWhen::AfterAny => dep_state.is_terminal(),
}
dep_state.terminal().is_some_and(|t| self.accepts(t))
}
}
@ -207,6 +283,16 @@ pub enum GraphError {
/// A node's `parent` named an id not present in the graph.
#[error("parent references unknown node {0:?}")]
UnknownParent(NodeId),
/// A [`Dep::Node`] edge carries an empty [`DepWhen`] set, which no outcome
/// can satisfy — the node could never run. Rejected at insert rather than
/// left to wedge its subtree non-terminal at runtime.
#[error("node {node:?} has an unsatisfiable dependency on {dep:?}: empty outcome set")]
UnsatisfiableDep {
/// The node that could never run.
node: NodeId,
/// The dependency whose edge accepts nothing.
dep: NodeId,
},
/// A node's [`Dep::Node`] edge points outside its own parent group — the
/// target must be a proper descendant of the depender's `parent` (a sibling
/// or a sibling's sub-node), never the parent itself or a node in another
@ -427,10 +513,16 @@ impl<N, R> Graph<N, R> {
return Err(GraphError::UnknownParent(p));
}
for dep in &node.deps {
if let Dep::Node { id, .. } = dep {
if let Dep::Node { id, when } = dep {
if self.node(*id).is_none() {
return Err(GraphError::UnknownDep(*id));
}
if when.is_empty() {
return Err(GraphError::UnsatisfiableDep {
node: node.id,
dep: *id,
});
}
if !self.dep_target_in_group(node.parent, *id) {
return Err(GraphError::DepOutsideParent {
dep: *id,
@ -465,7 +557,7 @@ mod tests {
"update",
vec![Dep::Node {
id: a,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}],
None,
)
@ -494,19 +586,19 @@ mod tests {
fn after_ok_needs_success_after_any_needs_terminal() {
// AfterOk: only Done satisfies; a Failed/Cancelled dep does NOT (the
// dependent must be cancelled, not run).
assert!(DepWhen::AfterOk.satisfied_by(State::Done));
assert!(!DepWhen::AfterOk.satisfied_by(State::Failed));
assert!(!DepWhen::AfterOk.satisfied_by(State::Cancelled));
assert!(!DepWhen::AfterOk.satisfied_by(State::Running));
assert!(DepWhen::AFTER_OK.satisfied_by(State::Done));
assert!(!DepWhen::AFTER_OK.satisfied_by(State::Failed));
assert!(!DepWhen::AFTER_OK.satisfied_by(State::Cancelled));
assert!(!DepWhen::AFTER_OK.satisfied_by(State::Running));
// AfterAny: any terminal state satisfies.
assert!(DepWhen::AfterAny.satisfied_by(State::Done));
assert!(DepWhen::AfterAny.satisfied_by(State::Failed));
assert!(DepWhen::AfterAny.satisfied_by(State::Cancelled));
assert!(!DepWhen::AfterAny.satisfied_by(State::Pending));
assert!(DepWhen::AFTER_ANY.satisfied_by(State::Done));
assert!(DepWhen::AFTER_ANY.satisfied_by(State::Failed));
assert!(DepWhen::AFTER_ANY.satisfied_by(State::Cancelled));
assert!(!DepWhen::AFTER_ANY.satisfied_by(State::Pending));
// Finishing satisfies neither — a dependent waits until the node rolls
// up to a terminal state (all its sub-nodes done).
assert!(!DepWhen::AfterOk.satisfied_by(State::Finishing));
assert!(!DepWhen::AfterAny.satisfied_by(State::Finishing));
assert!(!DepWhen::AFTER_OK.satisfied_by(State::Finishing));
assert!(!DepWhen::AFTER_ANY.satisfied_by(State::Finishing));
}
#[test]
@ -515,7 +607,7 @@ mod tests {
let bogus = NodeId(42);
let deps = vec![Dep::Node {
id: bogus,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}];
assert_eq!(
g.insert("x", deps, None).unwrap_err(),
@ -547,7 +639,7 @@ mod tests {
"b".to_owned(),
vec![Dep::Node {
id: a,
when: DepWhen::AfterAny,
when: DepWhen::AFTER_ANY,
}],
None,
)
@ -568,7 +660,7 @@ mod tests {
// roll-up model — the parent stays `Finishing` awaiting its children).
let on_parent = vec![Dep::Node {
id: root,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}];
assert_eq!(
g.insert("child", on_parent, Some(root)).unwrap_err(),
@ -598,7 +690,7 @@ mod tests {
fn after_ok_dep(on: NodeId) -> Dep<String> {
Dep::Node {
id: on,
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}
}
@ -613,7 +705,7 @@ mod tests {
payload: "x".to_owned(),
deps: vec![Dep::Node {
id: NodeId(99),
when: DepWhen::AfterOk,
when: DepWhen::AFTER_OK,
}],
state: State::Pending,
started_at: None,