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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue