jobq: complete is the completion, and failure grows nothing

Two review findings from the previous round, re-checked against the
actual tree rather than against my notes.

`Scheduler::complete` was still a public wrapper whose entire body was
`self.finish(id, outcome)`. Its docstring argued the split was not a
redirect because both completion forms shared `finish` -- but sharing a
private helper is not a reason for two public names. `finish`'s body now
lives in `complete`, and `complete_growing` calls it. Same sharing, one
name, no redirect.

Growth on a failed node is now dropped by `complete_growing` instead of
by the host loop. Failure cancel-cascades to every pending child of the
completing node, and grown work is inserted as its children, so anything
appended here is Skipped by the next statement -- the insert is not
wrong, it is provably pointless. That is a consequence of this crate's
cascade rule, so this crate should be the one enforcing it; a host that
has to remember it can forget it. Behaviour is unchanged: hive-c0re
already dropped growth before calling, and now no longer has to.

`Scheduler::new_job` is left alone but documented for what it is: the
hole in `JobBuilder::new`'s pub(crate) wall, with no non-test caller
since claim_next mints a builder per running node. Closing it is a venue
question rather than a rename, so it stays for now.
This commit is contained in:
atlas 2026-08-02 19:12:10 +02:00 committed by mara
commit 8459cc66bd
4 changed files with 53 additions and 52 deletions

View file

@ -324,7 +324,18 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
/// propagates up the parent chain. Call [`Scheduler::settle`] again afterwards
/// to start newly-unblocked work.
pub fn complete(&mut self, id: NodeId, outcome: Outcome) {
self.finish(id, outcome);
match outcome {
Outcome::Failed(error) => {
// Record the reason before the terminal transition so it's set
// by the time `set_state` stamps `finished_at`.
self.graph.set_error(id, error);
self.graph.set_state(id, State::Failed);
self.cascade_cancel(id);
}
Outcome::Done => self.settle_terminal(id),
}
self.roll_up_ancestors(id);
self.release_ready();
}
/// A fresh builder for a **running** node to declare more work into.
@ -335,9 +346,13 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
/// [`NodeId`]s only at insert), so it can be filled in freely and handed
/// back to [`Scheduler::complete_growing`], which inserts it under the lock.
///
/// This is the only way to get one — [`JobBuilder::new`] is `pub(crate)` and
/// there is no `Default` impl — so a caller can declare work but never
/// insert it itself.
/// ⚠️ **This has no non-test caller left, and it is the hole in
/// [`JobBuilder::new`]'s `pub(crate)` wall** — it hands out exactly the
/// builder that fn withholds. [`Scheduler::claim_next`] mints one per
/// running node itself, so production never asks. Kept only so the host's
/// graph-growth tests can still declare work by hand; the fix is a venue
/// question (move those tests here vs. a closure-form completion), not a
/// rename.
#[must_use]
pub fn new_job(&self) -> JobBuilder<N, R> {
JobBuilder::new()
@ -353,6 +368,13 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
/// outright, which is the overwhelmingly common case (most nodes grow no
/// work at all).
///
/// **A failed node grows nothing**, whatever it declared. Failure
/// cancel-cascades to every pending child of `id`, so work inserted here
/// would be `Skipped` by the very next statement — the insert is not wrong,
/// it is provably pointless. This lives here rather than in the caller
/// because it is a consequence of *this crate's* cascade rule; a host that
/// had to remember it could forget it.
///
/// # Errors
/// [`BuildError`] if `grown` is malformed — **and the node is still
/// completed**. Its own work already happened; refusing to complete it
@ -371,7 +393,10 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
// dangling `parent` edge rather than being rejected. The host used to
// carry this guard itself, as a lookup before a separate append call;
// it belongs here, where the graph is and where it cannot be skipped.
let grew = if grown.is_empty() || self.graph.node(id).is_none() {
let grew = if grown.is_empty()
|| matches!(outcome, Outcome::Failed(_))
|| self.graph.node(id).is_none()
{
Ok(())
} else {
let graph = &mut self.graph;
@ -381,29 +406,10 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
})
.map(|_ids| ())
};
self.finish(id, outcome);
self.complete(id, outcome);
grew
}
/// The completion half, shared by [`Scheduler::complete`] and
/// [`Scheduler::complete_growing`] so neither is a redirect through the
/// other: the growing form must insert *before* this runs, and the plain
/// form must not pay for an empty job.
fn finish(&mut self, id: NodeId, outcome: Outcome) {
match outcome {
Outcome::Failed(error) => {
// Record the reason before the terminal transition so it's set
// by the time `set_state` stamps `finished_at`.
self.graph.set_error(id, error);
self.graph.set_state(id, State::Failed);
self.cascade_cancel(id);
}
Outcome::Done => self.settle_terminal(id),
}
self.roll_up_ancestors(id);
self.release_ready();
}
/// Whether every direct child of `id` is terminal.
fn all_children_terminal(&self, id: NodeId) -> bool {
self.graph