refactor(#2815): derive the transient snapshot, don't mirror it

mara on !2910: "why is set_transient still a thing if it completely
derives from nodes?"

It was still a thing because the scheduler mirrored the derived set into
a stored map that every consumer read — derived state computed once and
then cached, with the reconciliation loop existing only to keep the cache
honest. `transient_snapshot()` now derives: `running_transients()` off the
live graph, with the handful of entries that have no node behind them
(destroy, migration) overlaid on top. There is no cached copy left to go
stale or disagree with what is running.

`set_transient` / `clear_transient` split by what they actually do:
`set_manual_transient` / `clear_manual_transient` own the stored map for
the no-node callers, and `emit_transient_set` / `emit_transient_cleared`
publish the edges both paths need.

Two things had to survive, and both are edges rather than state:

- The dashboard's `TransientSet` / `TransientCleared` events. The
  scheduler carries the previous derived value and emits the diff.
- The crash watcher's grace window. `recent_transient_within` answers
  "was a transient cleared just now?", which is what stops a deliberate
  stop from reading as a crash on the next 10s poll — a derived read of
  current state cannot answer it, so the clear still stamps. The
  scheduler keeps `deliberate_stop` alongside the label precisely so it
  is available at clear time: the node it came from is, by definition, no
  longer running to be asked.

`TransientState::since` becomes wall-clock and, for derived entries, is
the node's own `started_at` — the true start of the operation rather than
the moment a watcher first noticed it, which is what the old
guard-creation timestamp actually measured.

`running_transients` returns a named `RunningTransient` rather than a
4-tuple; two of its fields are strings and one is a bool whose meaning is
not guessable at a call site.

Note for anyone reaching for a timestamp here: chrono is vendored with
`default-features = false`, so there is no `Utc::now()`. The workspace
convention is `wire_time::now_unix()` / `from_secs()`.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
This commit is contained in:
atlas 2026-08-01 17:47:08 +02:00
commit 884e39ba63
4 changed files with 142 additions and 55 deletions

View file

@ -59,6 +59,27 @@ const MAX_HISTORY_DAGS: usize = 50;
/// Cap on stored node error strings.
const MAX_ERROR_LEN: usize = 2_000;
/// One live transient pill, derived from a running node.
///
/// A named struct rather than a tuple because three of its four fields are
/// easy to confuse at a call site: two are strings and two answer questions
/// nobody should have to guess at ("is this the agent or the label?", "does
/// this bool mean deliberate or running?").
#[derive(Debug, Clone)]
pub struct RunningTransient {
/// The agent whose lease the node declared.
pub agent: String,
/// The node's own wire tag, rendered as the pill.
pub label: String,
/// Whether this operation is expected to take the container down — the
/// crash watcher's input. See [`NodeKind::takes_container_down`].
pub takes_container_down: bool,
/// When the node started running, so the dashboard can tick elapsed
/// seconds. Taken from the node itself, which is the true start of the
/// operation rather than the moment a watcher noticed it.
pub since: DateTime<Utc>,
}
/// A node claimed for execution — everything the executor needs, snapshotted at
/// claim time.
#[derive(Debug, Clone)]
@ -422,7 +443,7 @@ impl JobQueue {
/// the resources-where-constructed work, not this function. An agent's lease
/// is cap-1, so at most one entry per agent.
#[must_use]
pub fn running_transients(&self) -> Vec<(String, String, bool)> {
pub fn running_transients(&self) -> Vec<RunningTransient> {
let inner = self.lock();
inner
.sched
@ -441,11 +462,17 @@ impl JobQueue {
} => Some(a),
_ => None,
})?;
Some((
Some(RunningTransient {
agent,
n.payload.as_str().to_owned(),
n.payload.takes_container_down(),
))
label: n.payload.as_str().to_owned(),
takes_container_down: n.payload.takes_container_down(),
// `started_at` is set when a node enters `Running`, and this
// only sees `Running` nodes — the fallback is unreachable in
// practice, and "just now" is the honest answer if it isn't.
since: n
.started_at
.unwrap_or_else(|| hive_sh4re::wire_time::from_secs(now_unix())),
})
})
.collect()
}

View file

@ -59,7 +59,7 @@ pub async fn run_worker(coord: Arc<Coordinator>) {
// it exists to spot transitions, since the dashboard wants edges
// (`TransientSet` / `TransientCleared`) and the crash watcher wants the
// moment of the clear. Nothing owns a pill; nothing can leak one.
let mut transients: HashMap<String, String> = HashMap::new();
let mut transients: HashMap<String, (String, bool)> = HashMap::new();
loop {
// Checked every iteration, not just in the `select!` below — a
// continuous stream of ready claims never reaches the `select!`, so
@ -174,25 +174,29 @@ fn handle_completion(coord: &Arc<Coordinator>, done: NodeDone) {
/// starting* is a real crash that must keep reporting as one.
///
/// [`NodeKind::takes_container_down`]: super::NodeKind::takes_container_down
fn reconcile_transients(coord: &Arc<Coordinator>, prev: &mut HashMap<String, String>) {
fn reconcile_transients(coord: &Arc<Coordinator>, prev: &mut HashMap<String, (String, bool)>) {
let running = coord.job_queue.running_transients();
// Cleared: in `prev`, gone (or relabelled) now. Emitted before the sets
// below so a same-agent label change reads as clear-then-set rather than
// two overlapping pills.
prev.retain(|agent, label| {
let still = running.iter().any(|(a, l, _)| a == agent && l == label);
// two overlapping pills. `deliberate_stop` is carried in `prev` precisely
// so it is still available *here* — the node it came from is, by
// definition, no longer running to be asked.
prev.retain(|agent, (label, deliberate)| {
let still = running
.iter()
.any(|t| &t.agent == agent && &t.label == label);
if !still {
coord.clear_transient(agent);
coord.emit_transient_cleared(agent, *deliberate);
}
still
});
for (agent, label, takes_down) in running {
if prev.get(&agent) == Some(&label) {
for t in running {
if prev.get(&t.agent).map(|(l, _)| l) == Some(&t.label) {
continue;
}
coord.set_transient(&agent, label.clone(), takes_down);
prev.insert(agent, label);
coord.emit_transient_set(&t.agent, t.label.clone());
prev.insert(t.agent, (t.label, t.takes_container_down));
}
}