job_queue: assert declared shape instead of driving the DAG
Three template tests ran a whole DAG -- claim, complete, repeat -- to observe an order that is fully determined the moment submit returns. They now read the graph directly: kinds, parent nesting, and dep edges with the outcome set each accepts. rebuild_chain_claims_in_dep_order is renamed, because the old name was wrong about the mechanism and reading it rather than the graph is how you stay wrong: only half that chain is dep edges. stop_for_update and swap declare no deps at all and are ordered by parent nesting -- a node's sub-nodes run after its own logic. Both axes are asserted now, since a template can break either independently. declared_shape spells out each edge's accepted outcomes rather than bucketing them into ok/any. Bucketing made spawn's three ResolveApproval tails -- which differ only in accepted outcome -- render as identical rows, which would have made the assertion a tautology. Checked by mutation against the code under test rather than the assertion: dropping .after_ok(signal) from the graceful-stop Drain fails graceful_stop_shape with a diff naming the one missing edge.
This commit is contained in:
parent
a59ad5ce3f
commit
d879d3e67a
1 changed files with 155 additions and 33 deletions
|
|
@ -167,6 +167,89 @@ impl CompleteNode for JobQueue {
|
|||
}
|
||||
}
|
||||
|
||||
/// One node's **declared** shape: what it is, what it hangs under, and what it
|
||||
/// waits for — all by kind, since ids are not stable across runs.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Declared {
|
||||
kind: &'static str,
|
||||
/// Parent kind, or `None` when the node hangs directly under the DAG
|
||||
/// container (i.e. it is a group root).
|
||||
parent: Option<&'static str>,
|
||||
/// Kinds this node declared a node-dep on, in declaration order, each with
|
||||
/// the outcome set that satisfies it.
|
||||
///
|
||||
/// The outcome set is **not** decoration: a template emits its tails as a
|
||||
/// pair edged on the same upstream nodes, and the *only* thing telling the
|
||||
/// ok-tail from the fail-tail is which outcomes each accepts. Without it
|
||||
/// two structurally different nodes read as identical.
|
||||
after: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
/// Render a dep's outcome set as the outcomes it actually accepts.
|
||||
///
|
||||
/// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other. The first
|
||||
/// version of this did bucket, and a template's three `ResolveApproval` tails —
|
||||
/// which differ ONLY in their accepted outcomes — all rendered as `"other"`.
|
||||
/// A helper that prints two structurally different nodes identically turns an
|
||||
/// assertion into a tautology.
|
||||
fn when_tag(when: hive_jobq::DepWhen) -> String {
|
||||
[
|
||||
(TerminalState::Done, "done"),
|
||||
(TerminalState::Failed, "failed"),
|
||||
(TerminalState::Cancelled, "cancelled"),
|
||||
(TerminalState::Skipped, "skipped"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(outcome, _)| when.accepts(*outcome))
|
||||
.map(|(_, name)| name)
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
}
|
||||
|
||||
/// Every work node under `dag`, in insertion order, as its declared shape.
|
||||
///
|
||||
/// **This is what the template tests are actually about.** A template's output
|
||||
/// is fully determined the moment `submit` returns: the kinds, the parent
|
||||
/// nesting and the dep edges are all sitting in the graph. Reading them here
|
||||
/// keeps the assertion on c0re's own product. Whether the scheduler then
|
||||
/// *honours* those edges — runs a chain serially, holds a grant across a
|
||||
/// subtree — is `hive_jobq`'s property and is tested in `hive_jobq`.
|
||||
fn declared_shape(q: &JobQueue, dag: u64) -> Vec<Declared> {
|
||||
let sched = q.sched().lock().expect("job_queue mutex poisoned");
|
||||
let graph = sched.graph();
|
||||
let root = graph.resolve_id(dag).expect("dag id is a real node id");
|
||||
let kind_of = |id: NodeId| graph.node(id).map(|n| n.payload.as_str());
|
||||
graph
|
||||
.nodes()
|
||||
.filter(|n| n.id != root && graph.root_of(n.id) == Some(root))
|
||||
.map(|n| Declared {
|
||||
kind: n.payload.as_str(),
|
||||
parent: n.parent.filter(|p| *p != root).and_then(kind_of),
|
||||
after: n
|
||||
.deps
|
||||
.iter()
|
||||
.filter_map(|dep| match dep {
|
||||
hive_jobq::Dep::Node { id, when } => kind_of(*id).map(|k| (k, when_tag(*when))),
|
||||
hive_jobq::Dep::Resource { .. } => None,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Shorthand for one expected row, so the tables below read as a shape.
|
||||
fn row(
|
||||
kind: &'static str,
|
||||
parent: Option<&'static str>,
|
||||
after: &[(&'static str, &str)],
|
||||
) -> Declared {
|
||||
Declared {
|
||||
kind,
|
||||
parent,
|
||||
after: after.iter().map(|(k, w)| (*k, (*w).to_owned())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim helper asserting exactly one node comes back.
|
||||
fn claim_one(q: &JobQueue) -> Claimed {
|
||||
let mut claims = q.claim_ready();
|
||||
|
|
@ -310,28 +393,52 @@ fn resubmit_while_running_is_new_dag() {
|
|||
// ---- dependency order within a DAG ----
|
||||
|
||||
#[test]
|
||||
fn rebuild_chain_claims_in_dep_order() {
|
||||
fn rebuild_chain_is_declared_serial() {
|
||||
// Was `rebuild_chain_claims_in_dep_order`, which drove the whole DAG to
|
||||
// observe an order that is fully declared the moment `submit` returns.
|
||||
//
|
||||
// ⚠️ The old name was also wrong about the mechanism, and reading it rather
|
||||
// than the graph is how you'd stay wrong: **only half this chain is dep
|
||||
// edges.** `stop_for_update` and `swap` declare no deps at all — they are
|
||||
// ordered by *parent nesting* ("a node's sub-nodes run after its own
|
||||
// logic"). Both axes are asserted below because a template can break either
|
||||
// one independently.
|
||||
let q = JobQueue::new(1);
|
||||
let id = submit(&q, rebuild("agent-a", "r"));
|
||||
for expected in [
|
||||
"meta_sync",
|
||||
"prebuild",
|
||||
"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(()));
|
||||
}
|
||||
settle_rebuild_tail(&q, "agent-a", true);
|
||||
assert_eq!(state_of(&q, id), State::Done);
|
||||
assert_eq!(
|
||||
declared_shape(&q, id),
|
||||
vec![
|
||||
row("meta_sync", None, &[]),
|
||||
row("prebuild", None, &[("meta_sync", "done")]),
|
||||
// No dep: ordered by hanging under `prebuild`.
|
||||
row("stop_for_update", Some("prebuild"), &[]),
|
||||
row("swap", Some("stop_for_update"), &[]),
|
||||
row("post_swap", Some("stop_for_update"), &[("swap", "done")]),
|
||||
row("reconcile", None, &[("prebuild", "done|failed|skipped")]),
|
||||
// The tail pair. The ok tail needs every root to succeed; the !ok
|
||||
// tail hangs off the ok tail's *elimination* (`skipped`), which is
|
||||
// what makes exactly one of them run.
|
||||
row(
|
||||
"emit_rebuilt",
|
||||
None,
|
||||
&[
|
||||
("meta_sync", "done"),
|
||||
("prebuild", "done"),
|
||||
("reconcile", "done"),
|
||||
],
|
||||
),
|
||||
row(
|
||||
"emit_rebuilt",
|
||||
None,
|
||||
&[
|
||||
("emit_rebuilt", "skipped"),
|
||||
("meta_sync", "done|failed|skipped"),
|
||||
("prebuild", "done|failed|skipped"),
|
||||
("reconcile", "done|failed|skipped"),
|
||||
],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The boot sweep's graceful shape: the agent gets `Signal` → `Drain` to
|
||||
|
|
@ -1722,12 +1829,17 @@ fn error_truncation_cuts_on_a_char_boundary() {
|
|||
fn graceful_stop_shape_signal_drain_reconcile() {
|
||||
let q = JobQueue::new(1);
|
||||
let id = submit(&q, stop_online(&["agent-a"], true, "graceful"));
|
||||
for expected in ["set_wanted", "signal", "drain", "reconcile"] {
|
||||
let c = claim_one(&q);
|
||||
assert_eq!(c.kind.as_str(), expected);
|
||||
q.complete_node(c.node_id, Ok(()));
|
||||
}
|
||||
assert_eq!(state_of(&q, id), State::Done);
|
||||
assert_eq!(
|
||||
declared_shape(&q, id),
|
||||
vec![
|
||||
// The whole stop hangs under `set_wanted`: the durable intent is
|
||||
// written first, and the mechanical steps are its sub-nodes.
|
||||
row("set_wanted", None, &[]),
|
||||
row("signal", Some("set_wanted"), &[]),
|
||||
row("drain", Some("set_wanted"), &[("signal", "done")]),
|
||||
row("reconcile", Some("set_wanted"), &[("drain", "done")]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1767,13 +1879,23 @@ fn spawn_shape_provision_create_dropin_reconcile() {
|
|||
&q,
|
||||
templates::spawn("newbie", 7, "approval #7 spawn".to_owned()),
|
||||
);
|
||||
for expected in ["provision", "create", "write_dropin", "reconcile"] {
|
||||
let c = claim_one(&q);
|
||||
assert_eq!(c.kind.as_str(), expected);
|
||||
q.complete_node(c.node_id, Ok(()));
|
||||
}
|
||||
settle_approval_tail(&q, 7, TerminalState::Done);
|
||||
assert_eq!(state_of(&q, id), State::Done);
|
||||
assert_eq!(
|
||||
declared_shape(&q, id),
|
||||
vec![
|
||||
row("provision", None, &[]),
|
||||
row("create", Some("provision"), &[]),
|
||||
row("write_dropin", Some("create"), &[]),
|
||||
row("reconcile", Some("create"), &[("write_dropin", "done")]),
|
||||
// One tail per outcome, each edged to accept only that one — so
|
||||
// *which* tail the graph lets run already is the answer, and
|
||||
// nothing branches at runtime. The three differ **only** in their
|
||||
// accepted outcome, which is why `declared_shape` spells the
|
||||
// outcome set out instead of bucketing it.
|
||||
row("resolve_approval", None, &[("provision", "done")]),
|
||||
row("resolve_approval", None, &[("provision", "failed")]),
|
||||
row("resolve_approval", None, &[("provision", "cancelled")]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue