feat(jobq): hand the builder to a closure, and make a handle name its job

Two review findings from the operator, both about the builder being more
reachable than the design said.

**The builder must not leave the crate.** The module doc claimed "an
insertion API, not a spec factory — a builder is only ever handed to a
closure by the queue's insertion entry point", and then `new()` and
`insert_into` were public, so a caller could build one, carry it around
and insert it later. That is a spec factory with a builder's name on it.
`insert_job(root_parent, |b| …)` is now the whole API: the builder is
created inside the call, handed to the closure, and consumed there.
`new` / `insert_into` / `insert_with` are crate-private.

**A handle names the job that issued it.** `NodeGuid` was a per-builder
counter, so two jobs' first handles compared equal. `NodeRef` converts
into a bare `NodeGuid` — dropping the borrow that ties it to its
builder — so a handle carried into a second job (an inner closure
capturing an outer handle) would silently resolve to whatever that job's
first node happened to be. It is now `{ job, seq }` with a random `job`
half, so a foreign handle is a miss and the insert fails naming it. The
randomness comes from `RandomState`, which is collision-avoidance rather
than cryptography and needs no new dependency.

Tests moved onto the closure API rather than keeping their in-crate
access to the private constructor — a test that only passes because it
lives inside the crate is not testing the API a caller has. The two
forward-reference tests stopped asserting literal guid values (a random
half cannot be written down) and compare against the handles instead,
and a new test carries a handle between two jobs to pin the behaviour
that motivated the change.
This commit is contained in:
atlas 2026-08-02 13:29:44 +02:00 committed by mara
commit f7548e4535
3 changed files with 195 additions and 75 deletions

View file

@ -100,23 +100,31 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
self.graph.insert(payload, deps, parent)
}
/// Insert a whole job — every node a [`JobBuilder`] declared — under
/// `root_parent`, returning the id each handle's node was minted as.
/// Insert a whole job under `root_parent`, returning the id each handle's
/// node was minted as.
///
/// The scheduler-side counterpart of [`JobBuilder::insert_into`]: same
/// `declare` receives a fresh [`JobBuilder`] and names the job's nodes on
/// it; the builder never leaves this call. That is the whole insertion
/// API — a caller cannot construct a builder, hold one, or insert one
/// itself, so there is no way to end up with a job-shaped value being
/// passed around as a spec.
///
/// The scheduler-side counterpart of [`Graph::insert_job`]: same
/// resolution, but each node goes through [`Scheduler::append`], so a
/// caller building a job never has to reach past the scheduler at the graph
/// underneath. Call [`Scheduler::settle`] afterwards to start whatever
/// became runnable.
/// caller never has to reach past the scheduler at the graph underneath.
/// Call [`Scheduler::settle`] afterwards to start whatever became
/// runnable.
///
/// # Errors
/// Propagates [`BuildError`] — a forward reference in the job's own
/// declarations, or a graph rejection.
pub fn insert_job(
&mut self,
job: JobBuilder<N, R>,
root_parent: Option<NodeId>,
declare: impl FnOnce(&JobBuilder<N, R>),
) -> Result<HashMap<NodeGuid, NodeId>, BuildError> {
let job = JobBuilder::new();
declare(&job);
job.insert_with(root_parent, |payload, deps, parent| {
self.append(payload, deps, parent)
})