hyperhive/hive-jobq/src/lib.rs
atlas a2edad715f jobq: make the builder the only way nodes enter a graph
`Graph::insert` was public and validating, and the builder's insert loop
called it with `?`. That made job-level atomicity an accident: the loop
mutates as it goes, so a rejection at node `i` left `0..i` already in the
graph — the error fired loudly *after* the corruption, not instead of it.
It held only because `check_job_shape` happens to be exhaustive, with
nothing in the types saying so.

Make the guarantee structural instead. `Graph::insert` becomes
`pub(crate)`; the builder drains into a new infallible
`insert_unchecked` (via `Scheduler::append_unchecked`), so
`insert_with`'s sink returns a bare `NodeId` and a half-built job is no
longer expressible. Re-checking at the sink cannot add safety anyway — it
can only report after the mutation it was meant to prevent.

Drop `BuildError::Graph`: nothing in the builder path can produce a
`GraphError` any more. Clippy could not see this (an unreachable variant
of a `pub` enum is still constructible from outside the crate).

`Scheduler::append` stays public and validating — hive-c0re inserts a
DAG's container node through it. Folding that away means removing the
container/DagView indirection, which is out of scope here.
2026-08-02 15:45:27 +02:00

967 lines
39 KiB
Rust

//! `hive-jobq` — a persistent job-DAG scheduler, extracted from hive-c0re's
//! in-tree `job_queue` as a domain-agnostic library.
//!
//! # Model (v2)
//!
//! One **persistent graph** for the whole system, not a DAG per job. Enqueuing
//! inserts a self-contained sub-DAG of nodes and returns their ids; the
//! scheduler runs a continuous loop, starting every node whose [`Dep`]s are
//! satisfied:
//!
//! - **Resource** deps are named counting semaphores over a caller-chosen
//! type `R`: `build-slot` (cap N), `agent/<name>` (cap 1), or any name
//! (cap 1, created on use). A node acquires *all* its resource deps
//! atomically at start (all-or-nothing) — no hold-and-wait, no deadlock.
//! - **Node** deps wait on another node per [`DepWhen`]: `AfterOk` needs
//! success (a failed dep cancels the dependent), `AfterAny` only terminal.
//!
//! A node carries two independent axes: its [`Dep`]s (ordering + resource
//! needs) and its [`Node::parent`] (structural grouping) — the parent chain,
//! not the [`Dep::Node`] edges, is what the [`scheduler`] consults for resource
//! re-entrancy. A [`NodeId`] is opaque, stable, and monotonic (persisted). The
//! payload `N` is generic so the library stays container-agnostic.
//!
//! A resource unit is held for the acquiring node + its whole [`Node::parent`]
//! subtree; a node needing a resource an ancestor holds re-uses that grant (a
//! re-entrant borrow, one branch at a time). Single-threaded — the scheduler
//! owns the resource table and mutates it directly. See [`scheduler`].
pub mod builder;
pub mod resources;
pub mod scheduler;
pub use builder::{BuildError, JobBuilder, NodeGuid, NodeRef};
use chrono::{DateTime, Utc};
/// Opaque, stable, monotonic node identifier.
///
/// Assigned by the [`Graph`] on insert and persisted, so it is stable across
/// restarts.
///
/// The inner field is crate-private: an id can only originate from the graph's
/// monotonic counter (or deserialization of a persisted graph), never be
/// fabricated by a caller — that is what makes it opaque.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct NodeId(pub(crate) u64);
impl NodeId {
/// The underlying monotonic value, for carrying this id across a boundary
/// that cannot hold the opaque `NodeId` type — e.g. serializing it onto a
/// wire protocol. The inverse (fabricating a `NodeId` from a raw value)
/// stays impossible by construction: an id only ever originates from the
/// graph's counter, which is what makes it opaque.
#[must_use]
pub fn get(self) -> u64 {
self.0
}
}
/// 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.
#[enumflags2::bitflags]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[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 because the work was **dropped** before it could start — the
/// caller cancelled the whole group while it was still queued. Counts as
/// not-success when a parent rolls up.
Cancelled,
/// Never ran because its own **edges ruled it out**: a dependency settled on
/// an outcome the edge doesn't accept. Expected, not a problem — the failure
/// branch of a run that succeeded is `Skipped`.
///
/// A parent's roll-up **ignores** `Skipped` children entirely. Without that,
/// branching on outcome would be self-defeating: exactly one branch is always
/// ruled out, so every group containing one would roll up failed.
Skipped,
}
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::Skipped => Some(TerminalState::Skipped),
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)]
pub struct DepWhen(enumflags2::BitFlags<TerminalState>);
/// Serialised as the list of outcomes it accepts (`["done","failed"]`) rather
/// than the underlying bitmask, so the wire form stays readable and survives the
/// bits being renumbered.
impl serde::Serialize for DepWhen {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
serde::Serialize::serialize(&self.0.iter().collect::<Vec<_>>(), ser)
}
}
impl<'de> serde::Deserialize<'de> for DepWhen {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let outcomes = Vec::<TerminalState>::deserialize(de)?;
Ok(Self(outcomes.into_iter().collect()))
}
}
impl DepWhen {
/// 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(enumflags2::make_bitflags!(TerminalState::{Done}));
/// Anything **except the work being dropped** — `Done`, `Failed` or
/// `Skipped`. For steps that must converge regardless of how the run went,
/// e.g. `Reconcile` bringing a container back up even when the preceding
/// `Swap` failed *or* was itself ruled out by a failed `MetaSync`.
///
/// Deliberately excludes [`TerminalState::Cancelled`]: if the group never
/// started at all there is nothing to converge, and running the recovery
/// step anyway would act on work that provably never happened. A node that
/// must report a cancellation names `Cancelled` explicitly.
pub const AFTER_ANY: Self =
Self(enumflags2::make_bitflags!(TerminalState::{Done | Failed | Skipped}));
/// An edge satisfied by exactly the listed outcomes.
#[must_use]
pub fn of(outcomes: &[TerminalState]) -> Self {
Self(outcomes.iter().copied().collect())
}
/// Whether `outcome` satisfies this edge.
#[must_use]
pub fn accepts(self, outcome: TerminalState) -> bool {
self.0.contains(outcome)
}
/// An edge no outcome can satisfy — rejected at [`Graph::validate`].
#[must_use]
pub fn is_empty(self) -> bool {
self.0.is_empty()
}
/// 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 {
dep_state.terminal().is_some_and(|t| self.accepts(t))
}
}
/// One dependency of a node. A node becomes runnable once every [`Dep::Node`]
/// edge it names is satisfied (per its [`DepWhen`]) *and* every [`Dep::Resource`]
/// it names can be acquired (all of them, atomically).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Dep<R> {
/// Depend on another node. Whether a *failed* dependency satisfies the edge
/// is decided by `when`: `AfterOk` requires success (and cancels this node
/// if the dep fails), `AfterAny` only requires the dep to be terminal.
Node {
/// The node depended on.
id: NodeId,
/// Strong (`AfterOk`) vs weak (`AfterAny`).
when: DepWhen,
},
/// Need `count` units of a named resource to run. Declared on every node
/// that needs it, even when a [`Node::parent`]-ancestor already holds it.
/// Acquired atomically with the node's other resource deps at start; the
/// acquired unit is held for the acquirer's whole subtree (released only
/// once the acquirer and all its sub-nodes are terminal). A node whose
/// parent-ancestor already holds this resource re-uses that grant (a
/// re-entrant borrow) instead of taking a fresh unit.
Resource {
/// The resource to acquire.
name: R,
/// How many units to hold (usually 1).
count: u32,
},
}
/// A node's lifecycle state.
///
/// This type *is* the wire representation — `hive-host-sock` hands it to
/// clients verbatim rather than mapping it through a parallel enum — so the
/// serialised names (`"Pending"`, `"Running"`, …) are what every consumer,
/// including the dashboard's JS, matches on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum State {
/// Waiting on dependencies (node or resource).
Pending,
/// Dependencies satisfied, resources held, currently executing its own logic.
Running,
/// Own logic finished successfully, but the node is *not yet terminal*: it
/// waits here until all its sub-nodes ([`Node::parent`] children) are
/// terminal, then rolls up — `Failed` if any child failed, else `Cancelled`
/// if any was dropped, else `Done`. A node with no children never rests
/// here — it goes straight to a terminal state.
Finishing,
/// Completed successfully — own logic done *and* every sub-node `Done`.
Done,
/// Completed unsuccessfully — own logic failed, or a sub-node did.
Failed,
/// Never ran: the work was dropped while still queued. See
/// [`TerminalState::Cancelled`].
Cancelled,
/// Never ran: its own edges ruled it out. See [`TerminalState::Skipped`] —
/// notably, a parent's roll-up ignores these.
Skipped,
}
impl State {
/// A node is *terminal* once it has finished — successfully, unsuccessfully,
/// dropped, or ruled out — which is when its resources are released and
/// dependents are re-evaluated.
#[must_use]
pub fn is_terminal(self) -> bool {
matches!(
self,
State::Done | State::Failed | State::Cancelled | State::Skipped
)
}
}
/// Wall-clock UTC now — the source for node lifecycle timestamps
/// ([`Node::started_at`] / [`Node::finished_at`]). The graph stamps its own
/// timestamps rather than threading a clock through every call, so a node's
/// timing is self-contained. Derived from `SystemTime` (the workspace `chrono`
/// carries no `clock` feature, matching `hive_sh4re::wire_time`), truncated to
/// whole seconds; a pre-epoch or out-of-range clock clamps to the epoch.
fn now_utc() -> DateTime<Utc> {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
DateTime::<Utc>::from_timestamp(secs, 0).unwrap_or_default()
}
/// A node in the graph, carrying a caller-defined payload `N`.
///
/// The library schedules over `Node`s and resources without interpreting the
/// payload; the caller supplies `N` (its own node kind) and a runner to execute
/// a claimed node. A node carries two independent axes: its [`Dep`]s (ordering +
/// resource needs) and its [`Node::parent`] (structural grouping), both set by
/// the caller/submit layer.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Node<N, R> {
/// Stable identity, assigned on insert.
pub id: NodeId,
/// Structural grouping: the node this one is a sub-node of, or `None` for a
/// group root. Independent of [`Node::deps`] — grouping is *not* ordering.
/// The [`scheduler`] uses the parent chain to decide resource re-entrancy: a
/// node needing a resource a parent-ancestor holds re-uses that grant rather
/// than acquiring a fresh unit, and a held unit stays reserved for the
/// acquirer's whole subtree. A node's sub-nodes run *after* its own logic.
pub parent: Option<NodeId>,
/// Caller-defined payload (the node's kind / work description).
pub payload: N,
/// What must hold before this node runs (other nodes + resources).
pub deps: Vec<Dep<R>>,
/// Lifecycle state.
pub state: State,
/// UTC instant the node entered [`State::Running`] (`None` until it starts;
/// a cancelled node never ran, so it stays `None`). Stamped by the graph.
pub started_at: Option<DateTime<Utc>>,
/// UTC instant the node reached a terminal state (`Done` / `Failed` /
/// `Cancelled`). `None` while non-terminal. Stamped by the graph.
pub finished_at: Option<DateTime<Utc>>,
/// Failure reason for a `Failed` node, supplied by the runner via
/// [`scheduler::Outcome::Failed`]. `None` unless this node's own logic
/// failed (a node that rolled up `Failed` from a child, or was cancelled,
/// carries no error of its own).
pub error: Option<String>,
}
/// An error from inserting into or loading a [`Graph`] with a dangling id.
///
/// A [`NodeId`] is only meaningful against the graph that minted it, so both
/// entry points — [`Graph::insert`] and deserialization — reject references to
/// nodes the graph does not contain. That is what lets internal iteration trust
/// every id the graph holds.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GraphError {
/// A node's dependency named an id not present in the graph.
#[error("dependency references unknown node {0:?}")]
UnknownDep(NodeId),
/// 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
/// group. Top-level nodes may only depend on top-level nodes.
#[error("dependency {dep:?} is outside the depender's parent group {parent:?}")]
DepOutsideParent {
/// The out-of-group dependency target.
dep: NodeId,
/// The depender's parent (the group the target had to be inside).
parent: Option<NodeId>,
},
/// A loaded graph's `next_id` counter is not past the largest existing id,
/// so the next minted id would collide with one already in the graph.
#[error("next_id {next_id} must exceed the largest existing node id {max_id}")]
NextIdTooSmall {
/// The persisted counter value.
next_id: u64,
/// The largest id already present.
max_id: u64,
},
}
/// The single persistent graph of all nodes.
///
/// New jobs are inserted as sub-DAGs of nodes; the scheduler walks this graph
/// filling open slots. Completed nodes are retained (no pruning in v1).
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(
try_from = "GraphData<N, R>",
bound(
serialize = "N: serde::Serialize, R: serde::Serialize",
deserialize = "N: serde::Deserialize<'de>, R: serde::Deserialize<'de>"
)
)]
pub struct Graph<N, R> {
nodes: Vec<Node<N, R>>,
next_id: u64,
}
// Deserialization target: the raw fields, turned into a `Graph` by the `TryFrom`
// below — which runs [`Graph::validate`], so a loaded graph can never carry a
// dangling id reference (Serialize does not validate; Deserialize always does).
#[derive(serde::Deserialize)]
#[serde(bound(deserialize = "N: serde::Deserialize<'de>, R: serde::Deserialize<'de>"))]
struct GraphData<N, R> {
nodes: Vec<Node<N, R>>,
next_id: u64,
}
impl<N, R> TryFrom<GraphData<N, R>> for Graph<N, R> {
type Error = GraphError;
fn try_from(data: GraphData<N, R>) -> Result<Self, Self::Error> {
let graph = Graph {
nodes: data.nodes,
next_id: data.next_id,
};
graph.validate()?;
Ok(graph)
}
}
// A `derive(Default)` would wrongly require `N: Default` (an empty graph holds
// no payload); an empty `Vec<Node<N>>` needs no such bound, so impl it directly.
impl<N, R> Default for Graph<N, R> {
fn default() -> Self {
Self::new()
}
}
impl<N, R> Graph<N, R> {
/// An empty graph.
#[must_use]
pub fn new() -> Self {
Self {
nodes: Vec::new(),
next_id: 0,
}
}
/// Mint the next stable node id.
fn mint_id(&mut self) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
id
}
/// Insert a node with the given payload, deps, and `parent`, returning its
/// freshly-minted id. The node starts [`State::Pending`].
///
/// Every [`Dep::Node`] id and the `parent` id (when `Some`) must already
/// resolve to a node in the graph — an id is only meaningful against the
/// graph that minted it, so a dangling reference is rejected here rather than
/// surfacing as a broken edge later.
///
/// **Crate-private on purpose.** Nodes enter a graph only through
/// [`crate::builder::JobBuilder`] (via
/// [`crate::scheduler::Scheduler::insert_job`]) or
/// [`crate::scheduler::Scheduler::append`]. A second public way in would be
/// a second place validation has to agree with the builder's, and the two
/// would drift.
///
/// # Errors
/// Returns [`GraphError::UnknownDep`] / [`GraphError::UnknownParent`] for a
/// dangling dependency or parent id, or [`GraphError::DepOutsideParent`] if a
/// `Dep::Node` edge points outside the node's own parent group.
pub(crate) fn insert(
&mut self,
payload: N,
deps: Vec<Dep<R>>,
parent: Option<NodeId>,
) -> Result<NodeId, GraphError> {
if let Some(p) = parent
&& self.node(p).is_none()
{
return Err(GraphError::UnknownParent(p));
}
for dep in &deps {
if let Dep::Node { id, .. } = dep {
if self.node(*id).is_none() {
return Err(GraphError::UnknownDep(*id));
}
if !self.dep_target_in_group(parent, *id) {
return Err(GraphError::DepOutsideParent { dep: *id, parent });
}
}
}
Ok(self.insert_unchecked(payload, deps, parent))
}
/// Insert without re-validating — **only** for a node the builder has
/// already proved well-formed.
///
/// [`crate::builder::check_job_shape`] decides every rejection
/// [`Graph::insert`] could raise, before the first node lands. Re-checking
/// here would not add safety: the insert loop mutates as it goes, so a
/// rejection at node `i` would leave `0..i` in the graph — a loud error
/// *after* the corruption rather than instead of it. Making the sink
/// infallible is what turns the builder's atomicity from an accident of the
/// pre-pass being exhaustive into a property of the types.
pub(crate) fn insert_unchecked(
&mut self,
payload: N,
deps: Vec<Dep<R>>,
parent: Option<NodeId>,
) -> NodeId {
let id = self.mint_id();
self.nodes.push(Node {
id,
parent,
payload,
deps,
state: State::Pending,
started_at: None,
finished_at: None,
error: None,
});
id
}
/// Borrow a node by id.
#[must_use]
pub fn node(&self, id: NodeId) -> Option<&Node<N, R>> {
self.nodes.iter().find(|n| n.id == id)
}
/// Resolve a raw value back to the opaque [`NodeId`] it names — the inverse
/// of [`NodeId::get`], and the only way to perform that direction. A caller
/// holding a value that crossed a wire cannot fabricate an id from it (that
/// impossibility is the point of the type), so it has to be matched against
/// the graph, which is what makes this a search rather than a cast.
///
/// `None` when no node carries that value, which covers both a value that
/// was never an id and one whose node has since been reaped.
#[must_use]
pub fn resolve_id(&self, raw: u64) -> Option<NodeId> {
self.nodes
.iter()
.find_map(|n| (n.id.0 == raw).then_some(n.id))
}
/// Every node in the graph, in insertion order. The scheduler iterates
/// this to find runnable pending nodes.
pub fn nodes(&self) -> impl Iterator<Item = &Node<N, R>> {
self.nodes.iter()
}
/// Top of `id`'s [`Node::parent`] chain — the group root whose subtree `id`
/// lives in. Returns `id` itself when `id` is already a root, and `None`
/// only when `id` isn't in the graph.
#[must_use]
pub fn root_of(&self, id: NodeId) -> Option<NodeId> {
let mut cur = id;
loop {
match self.node(cur)?.parent {
Some(p) => cur = p,
None => return Some(cur),
}
}
}
/// Every node in `id`'s subtree, excluding `id` itself, in insertion order.
pub fn descendants(&self, id: NodeId) -> impl Iterator<Item = &Node<N, R>> {
self.nodes
.iter()
.filter(move |n| self.is_descendant(n.id, id))
}
/// Every group root — the nodes with no parent.
pub fn roots(&self) -> impl Iterator<Item = &Node<N, R>> {
self.nodes.iter().filter(|n| n.parent.is_none())
}
/// Whether `id` has settled, or `None` when there is no such node. A group
/// root's state is its subtree's roll-up, so for a root this answers "is
/// everything under it finished" — which is why callers don't scan the
/// subtree themselves.
///
/// `None` rather than `false` for an unknown id: "this node is not finished"
/// and "there is no such node" are different answers, and a caller that
/// conflates them keeps polling an id that will never settle.
#[must_use]
pub fn is_settled(&self, id: NodeId) -> Option<bool> {
self.node(id).map(|n| n.state.is_terminal())
}
/// Why `id`'s subtree failed: the error of the first `Failed` descendant
/// that carries one, in insertion order.
///
/// Skipping the ones without an error is the point, not an optimisation. A
/// node that rolled up `Failed` from a child holds no error of its own, and
/// such a node can sort before the child that actually broke — stopping at
/// the first `Failed` node would report `None` while the real reason sits
/// further down the subtree.
#[must_use]
pub fn first_error(&self, id: NodeId) -> Option<&str> {
self.descendants(id)
.filter(|n| matches!(n.state, State::Failed))
.find_map(|n| n.error.as_deref())
}
/// Whether `ancestor` lies on `node`'s [`Node::parent`] chain (i.e. `node` is
/// in `ancestor`'s subtree). `node` is not its own ancestor.
fn is_descendant(&self, node: NodeId, ancestor: NodeId) -> bool {
let mut cur = self.node(node).and_then(|n| n.parent);
while let Some(p) = cur {
if p == ancestor {
return true;
}
cur = self.node(p).and_then(|n| n.parent);
}
false
}
/// Whether a node whose parent is `node_parent` may depend on `target` — the
/// grouping rule: a [`Dep::Node`] edge must stay inside the depender's own
/// parent group. `target` must be a proper descendant of `node_parent` (a
/// sibling or a sibling's sub-node), never the parent itself (which would
/// deadlock: the parent stays [`State::Finishing`] until its children finish,
/// so a child that waited on the parent could never run). Top-level nodes
/// (`parent == None`) may only depend on other top-level nodes.
fn dep_target_in_group(&self, node_parent: Option<NodeId>, target: NodeId) -> bool {
match node_parent {
Some(p) => self.is_descendant(target, p),
None => self.node(target).is_some_and(|n| n.parent.is_none()),
}
}
/// Set a node's lifecycle state, returning `false` for an unknown id. The
/// scheduler drives every state transition — nothing else mutates state,
/// which is what keeps the resource guards + terminality in sync. This is
/// also where the node's lifecycle timestamps are stamped: `started_at` on
/// the first transition to [`State::Running`], `finished_at` on the first
/// transition to a terminal state (`Done` / `Failed` / `Cancelled`).
pub(crate) fn set_state(&mut self, id: NodeId, state: State) -> bool {
if let Some(node) = self.nodes.iter_mut().find(|n| n.id == id) {
node.state = state;
if state == State::Running {
if node.started_at.is_none() {
node.started_at = Some(now_utc());
}
} else if state.is_terminal() && node.finished_at.is_none() {
node.finished_at = Some(now_utc());
}
true
} else {
false
}
}
/// Record a node's failure reason ([`Node::error`]). No-op for an unknown
/// id. Called by the scheduler on an [`scheduler::Outcome::Failed`] before
/// the terminal state transition.
pub(crate) fn set_error(&mut self, id: NodeId, error: String) {
if let Some(node) = self.nodes.iter_mut().find(|n| n.id == id) {
node.error = Some(error);
}
}
/// Check that every id the graph holds resolves: every [`Dep::Node`] id
/// names a node present in the graph, and `next_id` is past the largest
/// existing id. Deserialization runs this, so a loaded graph is internally
/// consistent and internal iteration can trust its ids.
///
/// # Errors
/// Returns [`GraphError`] on a dangling dependency reference, or a `next_id`
/// that would remint an id already in the graph.
pub fn validate(&self) -> Result<(), GraphError> {
for node in &self.nodes {
if let Some(p) = node.parent
&& self.node(p).is_none()
{
return Err(GraphError::UnknownParent(p));
}
for dep in &node.deps {
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,
parent: node.parent,
});
}
}
}
}
if let Some(max_id) = self.nodes.iter().map(|n| n.id.0).max()
&& self.next_id <= max_id
{
return Err(GraphError::NextIdTooSmall {
next_id: self.next_id,
max_id,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_mints_stable_monotonic_ids() {
let mut g: Graph<&str, String> = Graph::new();
let a = g.insert("sweep", vec![], None).unwrap();
let b = g
.insert(
"update",
vec![Dep::Node {
id: a,
when: DepWhen::AFTER_OK,
}],
None,
)
.unwrap();
assert_eq!(a, NodeId(0));
assert_eq!(b, NodeId(1));
// The dep edge references the earlier node; ids are stable + monotonic.
assert!(matches!(
g.node(b).unwrap().deps.first(),
Some(Dep::Node { id, .. }) if *id == a
));
}
/// Borrow a node mutably so a test can force its lifecycle state. Looks the
/// node up by id rather than indexing, so it doesn't quietly depend on ids
/// and positions coinciding.
fn node_mut<'g>(
g: &'g mut Graph<&'static str, String>,
id: NodeId,
) -> &'g mut Node<&'static str, String> {
g.nodes
.iter_mut()
.find(|n| n.id == id)
.expect("node in graph")
}
/// `root_of` walks to the top of the parent chain; `descendants` is its
/// inverse and excludes the node itself.
#[test]
fn root_of_and_descendants_span_the_subtree() {
let mut g: Graph<&str, String> = Graph::new();
let root = g.insert("root", vec![], None).unwrap();
let mid = g.insert("mid", vec![], Some(root)).unwrap();
let leaf = g.insert("leaf", vec![], Some(mid)).unwrap();
let other = g.insert("other-root", vec![], None).unwrap();
assert_eq!(g.root_of(leaf), Some(root), "walks the whole chain");
assert_eq!(g.root_of(root), Some(root), "a root is its own root");
assert_eq!(g.root_of(NodeId(99)), None, "unknown id");
let mut under_root: Vec<NodeId> = g.descendants(root).map(|n| n.id).collect();
under_root.sort_unstable();
assert_eq!(under_root, vec![mid, leaf], "excludes the node itself");
assert_eq!(g.descendants(other).count(), 0);
let roots: Vec<NodeId> = g.roots().map(|n| n.id).collect();
assert_eq!(roots, vec![root, other]);
}
/// The reason `first_error` looks for the first failed descendant **that
/// carries an error** rather than simply the first failed one: a node that
/// rolled its `Failed` up from a child holds no error of its own, and it
/// sorts *before* that child. Stopping at the first `Failed` node would
/// report `None` and lose the real reason.
#[test]
fn first_error_skips_a_rolled_up_failure_carrying_no_error() {
let mut g: Graph<&str, String> = Graph::new();
let container = g.insert("dag", vec![], None).unwrap();
let rolled_up = g.insert("prebuild", vec![], Some(container)).unwrap();
let broke = g.insert("swap", vec![], Some(rolled_up)).unwrap();
node_mut(&mut g, rolled_up).state = State::Failed;
let broken = node_mut(&mut g, broke);
broken.state = State::Failed;
broken.error = Some("nix build exploded".to_owned());
assert_eq!(g.first_error(container), Some("nix build exploded"));
}
#[test]
fn is_settled_tracks_node_state() {
let mut g: Graph<&str, String> = Graph::new();
let n = g.insert("n", vec![], None).unwrap();
assert_eq!(g.is_settled(n), Some(false), "Pending is not settled");
node_mut(&mut g, n).state = State::Finishing;
assert_eq!(
g.is_settled(n),
Some(false),
"Finishing still has children running"
);
node_mut(&mut g, n).state = State::Done;
assert_eq!(g.is_settled(n), Some(true));
assert_eq!(
g.is_settled(NodeId(99)),
None,
"an unknown id is not the same answer as `not settled`"
);
}
#[test]
fn resolve_id_inverts_get_and_rejects_a_value_that_was_never_an_id() {
let mut g: Graph<&str, String> = Graph::new();
let a = g.insert("a", vec![], None).unwrap();
let b = g.insert("b", vec![], None).unwrap();
// Round-trips every id the graph handed out: this is the only way back
// from a raw value, since NodeId can't be constructed from one.
assert_eq!(g.resolve_id(a.get()), Some(a));
assert_eq!(g.resolve_id(b.get()), Some(b));
// A value that was never an id resolves to nothing, so a caller can't
// reach a node by guessing a number off the wire.
assert_eq!(g.resolve_id(u64::MAX), None);
}
#[test]
fn state_terminality() {
assert!(State::Done.is_terminal());
assert!(State::Failed.is_terminal());
assert!(State::Cancelled.is_terminal());
assert!(!State::Pending.is_terminal());
assert!(!State::Running.is_terminal());
// Finishing (logic done, children still running) is NOT terminal.
assert!(!State::Finishing.is_terminal());
}
#[test]
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::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));
assert!(!DepWhen::AFTER_OK.satisfied_by(State::Skipped));
// AfterAny: the dep reached a terminal state *some other way than being
// dropped* — success, failure, or ruled out by its own edges.
assert!(DepWhen::AFTER_ANY.satisfied_by(State::Done));
assert!(DepWhen::AFTER_ANY.satisfied_by(State::Failed));
assert!(DepWhen::AFTER_ANY.satisfied_by(State::Skipped));
assert!(
!DepWhen::AFTER_ANY.satisfied_by(State::Cancelled),
"a dropped dep does not converge a weak dependent — nothing ever ran"
);
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::AFTER_OK.satisfied_by(State::Finishing));
assert!(!DepWhen::AFTER_ANY.satisfied_by(State::Finishing));
}
#[test]
fn insert_rejects_unknown_dep() {
let mut g: Graph<&str, String> = Graph::new();
let bogus = NodeId(42);
let deps = vec![Dep::Node {
id: bogus,
when: DepWhen::AFTER_OK,
}];
assert_eq!(
g.insert("x", deps, None).unwrap_err(),
GraphError::UnknownDep(bogus)
);
}
#[test]
fn insert_rejects_unknown_parent() {
let mut g: Graph<&str, String> = Graph::new();
let bogus = NodeId(7);
assert_eq!(
g.insert("x", vec![], Some(bogus)).unwrap_err(),
GraphError::UnknownParent(bogus)
);
// A resolvable parent is accepted and recorded.
let a = g.insert("a", vec![], None).unwrap();
let b = g.insert("b", vec![], Some(a)).unwrap();
assert_eq!(g.node(b).unwrap().parent, Some(a));
}
#[test]
fn valid_graph_round_trips_through_serde() {
let mut g: Graph<String, String> = Graph::new();
// `a` (top-level) and `b` (top-level, depends on its sibling `a`), plus
// `c` — a sub-node of `a` (grouping, no dep on its parent).
let a = g.insert("a".to_owned(), vec![], None).unwrap();
g.insert(
"b".to_owned(),
vec![Dep::Node {
id: a,
when: DepWhen::AFTER_ANY,
}],
None,
)
.unwrap();
let c = g.insert("c".to_owned(), vec![], Some(a)).unwrap();
let json = serde_json::to_string(&g).unwrap();
let back: Graph<String, String> = serde_json::from_str(&json).unwrap();
assert!(back.validate().is_ok());
assert_eq!(back.node(a).unwrap().payload, "a");
assert_eq!(back.node(c).unwrap().parent, Some(a));
}
#[test]
fn insert_rejects_dep_on_parent_and_cross_group() {
let mut g: Graph<&str, String> = Graph::new();
let root = g.insert("root", vec![], None).unwrap();
// A child cannot depend on its own parent (would deadlock under the
// roll-up model — the parent stays `Finishing` awaiting its children).
let on_parent = vec![Dep::Node {
id: root,
when: DepWhen::AFTER_OK,
}];
assert_eq!(
g.insert("child", on_parent, Some(root)).unwrap_err(),
GraphError::DepOutsideParent {
dep: root,
parent: Some(root),
}
);
// A sibling dep IS allowed: two children of `root`, the second on the first.
let c1 = g.insert("c1", vec![], Some(root)).unwrap();
let c2 = g
.insert("c2", vec![after_ok_dep(c1)], Some(root))
.expect("sibling dep is in-group");
assert_eq!(g.node(c2).unwrap().parent, Some(root));
// But a node in another group cannot be depended on across the boundary.
let other = g.insert("other", vec![], None).unwrap();
assert_eq!(
g.insert("x", vec![after_ok_dep(other)], Some(root))
.unwrap_err(),
GraphError::DepOutsideParent {
dep: other,
parent: Some(root),
}
);
}
fn after_ok_dep(on: NodeId) -> Dep<String> {
Dep::Node {
id: on,
when: DepWhen::AFTER_OK,
}
}
#[test]
fn deserialize_rejects_a_dangling_dependency() {
// Build a graph whose only node depends on a non-existent id, serialize
// it (Serialize does not validate), and confirm deserialize rejects it.
let bad = Graph::<String, String> {
nodes: vec![Node {
id: NodeId(0),
parent: None,
payload: "x".to_owned(),
deps: vec![Dep::Node {
id: NodeId(99),
when: DepWhen::AFTER_OK,
}],
state: State::Pending,
started_at: None,
finished_at: None,
error: None,
}],
next_id: 1,
};
let json = serde_json::to_string(&bad).unwrap();
let err = serde_json::from_str::<Graph<String, String>>(&json).unwrap_err();
assert!(err.to_string().contains("unknown node"));
}
#[test]
fn validate_rejects_next_id_that_would_remint() {
let bad = Graph::<&str, String> {
nodes: vec![Node {
id: NodeId(5),
parent: None,
payload: "x",
deps: vec![],
state: State::Pending,
started_at: None,
finished_at: None,
error: None,
}],
next_id: 3,
};
assert_eq!(
bad.validate().unwrap_err(),
GraphError::NextIdTooSmall {
next_id: 3,
max_id: 5,
}
);
}
}