jobq: one insertion entry point, and make it atomic

Three findings from the operator's review, all correct.

1. Two insert_job's. Graph::insert_job had no caller outside hive-jobq's
   own tests -- production only ever went through Scheduler::insert_job.
   It existed because the graph-level one got written first. Deleted; the
   tests moved onto a Scheduler, which is where insertion belongs anyway.

2. insert_job was not atomic, and the previous commit made that worse: a
   forward edge or forward parent surfaced mid-loop, leaving the nodes
   before it in the graph, and resolve_wanted ran after every insert, so
   an unknown handle failed once the whole job was already committed.
   The module documented this under "Partial insertion" instead of fixing
   it -- prose describing a hole is not a design.

   All three are decidable from what the builder holds, so
   check_declaration_order now runs before the first insert and the loop
   indexes ids directly. A malformed job leaves the graph untouched.
   What remains mid-insert is the graph's own rejection (out-of-group
   dep, empty DepWhen); closing that needs a dry-run validate on Graph,
   which is a separate change.

3. DagSpec no longer boxes its recipe: it is generic over the closure,
   which travels from the template that built it straight into submit.
   The box bought type inference, and paying for it costs annotations --
   `|b: &Job|` at each declaration site (the field needs an HRTB, and an
   unannotated closure binds one lifetime) and `+ use<>` on each
   returning signature (or the opaque type captures the caller's borrows).
   Erasure is still needed where several recipe shapes share one type:
   the boxed Declare stays for the executor's append_subgraph, and a test
   table uses an erase() helper.
This commit is contained in:
atlas 2026-08-02 14:39:37 +02:00 committed by mara
commit f035b63b9a
8 changed files with 211 additions and 176 deletions

View file

@ -179,7 +179,7 @@ impl Default for JobQueue {
/// Propagates a crate graph-insert error (malformed dep/parent / dep-scope).
fn insert_group(
inner: &mut QueueInner,
declare: Declare,
declare: impl FnOnce(&Job),
group_parent: Option<NodeId>,
) -> anyhow::Result<()> {
inner
@ -216,15 +216,19 @@ impl JobQueue {
self.inner.lock().expect("job_queue mutex poisoned")
}
/// Submit a DAG. Validates the spec, inserts a [`NodeKind::Dag`] **container
/// node** carrying the group's metadata, then inserts the template's nodes as
/// its subtree (their roots re-parented to the container). Returns the
/// container's id as the DAG id — its rolled-up state is the DAG state.
/// Submit a DAG: insert a [`NodeKind::Dag`] **container node** carrying the
/// group's metadata, then insert the template's nodes as its subtree (their
/// roots re-parented to the container). Returns the container's id as the
/// DAG id — its rolled-up state is the DAG state.
///
/// Takes the spec's recipe by generic, not as a boxed [`Declare`]: a spec
/// travels from the template that built it directly into this call, so
/// there is nothing to allocate for.
///
/// # Errors
/// Propagates the spec-validation error (empty / cyclic / bad parent) or a
/// graph-insert error (dependencies that aren't dependency-topological).
pub fn submit(&self, spec: DagSpec) -> anyhow::Result<u64> {
/// Propagates a graph-insert error (dependencies that aren't
/// dependency-topological).
pub fn submit<F: FnOnce(&Job)>(&self, spec: DagSpec<F>) -> anyhow::Result<u64> {
let mut inner = self.lock();
let container = inner
.sched