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.
This commit is contained in:
atlas 2026-08-02 15:45:27 +02:00
commit a2edad715f
3 changed files with 54 additions and 13 deletions

View file

@ -419,11 +419,18 @@ impl<N, R> Graph<N, R> {
/// 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 fn insert(
pub(crate) fn insert(
&mut self,
payload: N,
deps: Vec<Dep<R>>,
@ -444,6 +451,25 @@ impl<N, R> Graph<N, R> {
}
}
}
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,
@ -455,7 +481,7 @@ impl<N, R> Graph<N, R> {
finished_at: None,
error: None,
});
Ok(id)
id
}
/// Borrow a node by id.