job_queue: four more template tests read the graph

perm_change and the graceful rebuild chain walked their whole DAG to
collect node kinds in order; both now assert declared_shape. The
graceful one gets a sharper claim out of it -- signal and drain go
between the build and the stop, and nothing else changes -- which is
what distinguishes it from the non-graceful chain.

reparent_bulk needed the node's payload rather than its wiring, so
payload_of() reads it off the graph. The assertion is unchanged: one
node carries every move, because bulk atomicity is why a single node was
chosen.

resubmit_while_running_is_new_dag no longer claims a node to stage the
"while running" part. submit appends a container and inserts the
declared group; it never consults the state of any existing node, so a
running earlier DAG cannot change the outcome. The property is no dedup,
covered by identical_resubmit_is_a_distinct_dag -- this one keeps the
named scenario because a config bump mid-build is what people actually
worry about.
This commit is contained in:
atlas 2026-08-02 20:43:44 +02:00 committed by mara
commit eab6bce813

View file

@ -239,6 +239,14 @@ fn node_of(q: &JobQueue, dag: u64, kind: &str) -> hive_jobq::NodeId {
found.pop().expect("checked above")
}
/// The payload of the one node of `kind` under `dag`, for assertions about what
/// a node *carries* rather than how it is wired.
fn payload_of(q: &JobQueue, dag: u64, kind: &str) -> NodeKind {
let id = node_of(q, dag, kind);
let sched = q.sched().lock().expect("job_queue mutex poisoned");
sched.graph().node(id).expect("node exists").payload.clone()
}
/// Kinds of every node under `dag` still `Pending` — the nodes that could yet
/// run. Stronger than asking the scheduler what is *ready right now*: a node
/// blocked on a dep is not ready but is very much still alive.
@ -379,11 +387,17 @@ fn distinct_submits_never_collapse() {
#[test]
fn resubmit_while_running_is_new_dag() {
// The "while running" is not load-bearing and used to be staged by claiming
// a node first. `submit` appends a container and inserts the declared
// group; it never consults the state of any existing node, so whether an
// earlier DAG is running cannot change the outcome. What is actually being
// asserted — no dedup, ever — is `identical_resubmit_is_a_distinct_dag`.
//
// Kept as the *named* case because "a config bump mid-build must not be
// swallowed" is the scenario people worry about, and a reader looking for
// it should find it.
let q = JobQueue::new(1);
let a = submit(&q, rebuild("agent-a", "first"));
let claim = claim_one(&q); // Prebuild running
assert_eq!(claim.dag_id, a);
// While the original runs, re-submit is legitimate new work.
let again = submit(&q, rebuild("agent-a", "config bumped during build"));
assert_ne!(a, again);
assert_eq!(q.snapshot().len(), 2);
@ -484,26 +498,25 @@ fn graceful_rebuild_chain_drains_before_stopping() {
}),
},
);
for expected in [
"meta_sync",
"prebuild",
"signal",
"drain",
"stop_for_update",
"swap",
"post_swap",
"reconcile",
] {
let c = claim_one(&q);
assert_eq!(c.dag_id, id);
assert_eq!(c.kind.as_str(), expected);
assert!(
q.claim_ready().is_empty(),
"chain must serialize: nothing ready while {expected} runs"
);
q.complete_node(c.node_id, Ok(()));
}
assert_eq!(state_of(&q, id), State::Done);
assert_eq!(
declared_shape(&q, id)
.iter()
.map(|d| d.kind)
.collect::<Vec<_>>(),
vec![
"meta_sync",
"prebuild",
// The graceful window goes between the build and the stop: the
// agent gets its turn to finish before the container goes down.
"signal",
"drain",
"stop_for_update",
"swap",
"post_swap",
"reconcile",
],
"graceful inserts signal + drain ahead of the stop, and nothing else"
);
}
/// The non-graceful shape is the default everywhere except the boot sweep:
@ -1648,21 +1661,25 @@ fn perm_change_shape_prefixes_rebuild_chain() {
},
),
);
for expected in [
"write_perm_file",
"meta_sync",
"prebuild",
"stop_for_update",
"swap",
"post_swap",
"reconcile",
] {
let c = claim_one(&q);
assert_eq!(c.kind.as_str(), expected);
q.complete_node(c.node_id, Ok(()));
}
settle_rebuild_tail(&q, "agent-a", true);
assert_eq!(state_of(&q, id), State::Done);
assert_eq!(
declared_shape(&q, id)
.iter()
.map(|d| d.kind)
.collect::<Vec<_>>(),
vec![
"write_perm_file",
"meta_sync",
"prebuild",
"stop_for_update",
"swap",
"post_swap",
"reconcile",
// the ok / !ok tail pair
"emit_rebuilt",
"emit_rebuilt",
],
"the perm write prefixes an otherwise ordinary rebuild chain"
);
}
#[test]
@ -1705,12 +1722,13 @@ fn reparent_bulk_shape_carries_every_move_on_one_node() {
&q,
templates::reparent(moves.clone(), Source::Manual, "set-parent-bulk".to_owned()),
);
let c = claim_one(&q);
assert_eq!(c.kind.as_str(), "reparent");
let NodeKind::Reparent { moves: got } = &c.kind else {
panic!("expected a Reparent node, got {:?}", c.kind);
assert_eq!(
declared_shape(&q, id),
vec![row("reparent", None, &[])],
"one node for the whole request, not one per move"
);
let NodeKind::Reparent { moves: got } = payload_of(&q, id, "reparent") else {
panic!("expected a Reparent node");
};
assert_eq!(got, &moves);
q.complete_node(c.node_id, Ok(()));
assert_eq!(state_of(&q, id), State::Done);
assert_eq!(got, moves, "every move rides the single node");
}