jobq: delete DagView/NodeView, the second projection of one graph
Two views of the same graph existed: the typed `DagView`/`NodeView` (`/api/state.rebuild_queue`, the `QueueDag` socket request, and the `RebuildQueueChanged` payload) and `hive-jobq-wire`'s generic `GraphNode` (`/api/jobq/graph`, `QueueNodes`). Every consumer has moved to the generic one, so the typed pair is deleted rather than kept in agreement with it. What that removes, beyond the types: the `QueueDag` request and `HostResponse::dags`; `Queue::snapshot`; `dag_view`, `visible_dags`, `shown_on_wire`, `dag_finished_at` and `containers`; and the `rebuild_queue` field on `/api/state`. `RebuildQueueChanged` keeps its seq and loses its payload — nothing read it, and shipping the graph both on an event and on an endpoint is the duplication this issue is about. It stays an event rather than becoming a poll because push-on-change is what every other live surface here does. Two behaviours came out simpler for a structural reason. `await_dags` needed two rules — settled means "gone from the snapshot" *or* "present with every node terminal" — because the typed view evicted finished groups; the generic view doesn't, so pending is just "some node isn't terminal". And `state_of` in the tests no longer derives a roll-up at all: a group root's own state is the scheduler's answer. That second one found a bug. `cancelled_dag_still_runs_its_approval tail` asserted the group reads `Cancelled` while the tail it exists to protect was still pending — `rollup_state` flattened the surviving child away and called the group settled. The root reads `Finishing`, which is what the scheduler documents: own logic done, children still running. The test now asserts that, with the reasoning inline so it doesn't get "fixed" back. Kept: `Source`, `State`, `PermPayload` and the `NodeId` alias in `hive-host-sock::jobs` — shared vocabulary, still used by hivectl.
This commit is contained in:
parent
4ec1c61d52
commit
f707c60f90
10 changed files with 131 additions and 615 deletions
|
|
@ -1,12 +1,15 @@
|
|||
//! Wire shapes of hive-c0re's job-DAG queue: what a queued job looks
|
||||
//! like on the dashboard SSE channel (`rebuild_queue_changed`), the
|
||||
//! `/api/state.rebuild_queue` snapshot, and the host admin socket's
|
||||
//! `QueueDag` polling surface (`hivectl`'s wait/progress loop). The
|
||||
//! queue *internals* — node kinds, dependency edges, scheduling state —
|
||||
//! live in `hive-c0re::job_queue`; these are the serialized views it
|
||||
//! produces. Semantics: `docs/coordinator.md::Job queue`.
|
||||
//! Vocabulary hive-c0re's job queue shares with its clients: where a job
|
||||
//! came from ([`Source`]), what a permission change carries
|
||||
//! ([`PermPayload`]), and the scheduler's lifecycle [`State`].
|
||||
//!
|
||||
//! **The typed `DagView`/`NodeView` projection that used to live here is
|
||||
//! gone.** One graph is served one way now — `hive_jobq_wire`'s generic
|
||||
//! `GraphNode`, over the `QueueNodes` socket request and
|
||||
//! `GET /api/jobq/graph` — so there is no second shape to keep in
|
||||
//! agreement with the first. Queue internals (node kinds, edges,
|
||||
//! scheduling) live in `hive-c0re::job_queue`.
|
||||
//! Semantics: `docs/coordinator.md::Job queue`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Where the submit request originated — drives the "why" chip on the
|
||||
|
|
@ -64,248 +67,3 @@ pub enum PermPayload {
|
|||
/// just within one. Consumers treat it opaquely (grouping + dep matching),
|
||||
/// so the widening from the old dag-local `u32` is transparent.
|
||||
pub type NodeId = u64;
|
||||
|
||||
/// One node of a queued DAG, serialized near-raw from the scheduler
|
||||
/// graph. Lifecycle (`state` / `started_at` / `finished_at` / `error`)
|
||||
/// comes straight off the `hive_jobq::Node`. The client derives DAG-level
|
||||
/// roll-ups (label, state, timestamps) from the node set — nothing is
|
||||
/// rolled up host-side. Build logs are fetched on demand by node id
|
||||
/// (`GET /api/build-log/<id>`), not carried inline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeView {
|
||||
pub id: NodeId,
|
||||
/// The agent whose container (or meta repo, for `hyperhive` meta-level
|
||||
/// nodes) this node operates on. Agent is per-node — a single DAG can
|
||||
/// span multiple agents (e.g. a hive-wide restart), so there is no
|
||||
/// DAG-level agent field; consumers group by this.
|
||||
pub agent: String,
|
||||
/// Node primitive tag: `"prebuild"`, `"stop_for_update"`,
|
||||
/// `"swap"`, `"create"`, `"meta_lock"`, `"reconcile"`, `"signal"`,
|
||||
/// `"drain"`, `"write_dropin"`, `"write_perm_file"`,
|
||||
/// `"approval_deploy"`.
|
||||
pub kind: String,
|
||||
/// Ids of the nodes this one waits for. May reference an already-`Done`
|
||||
/// node that's been filtered out of the wire — the client treats a dep
|
||||
/// on an absent node as satisfied.
|
||||
#[serde(default)]
|
||||
pub deps: Vec<NodeId>,
|
||||
pub state: State,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// Approval-queue row id — present only on the `approval_deploy` node.
|
||||
/// The client links a DAG to its pending approval through this (it is
|
||||
/// not derivable from the graph, so it rides the node that owns it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approval_id: Option<i64>,
|
||||
/// Meta-flake inputs being bumped — present only on the `meta_lock`
|
||||
/// node. Display-only payload, not derivable from the graph.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub inputs: Vec<String>,
|
||||
/// The build-log history row id, when this node has a captured build
|
||||
/// log fetchable at `GET /api/build-log/<node id>` and deep-linkable to
|
||||
/// `/builds.html?id=<this>#buildlogs`. Only the nix-heavy nodes that
|
||||
/// stream build output set one; the client gates its log link on
|
||||
/// `.is_some()` so lock / noop / store-only nodes don't render a link
|
||||
/// that 404s.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub build_log_id: Option<i64>,
|
||||
/// Structural parent in the jobq tree — `None` for top-level nodes
|
||||
/// (direct children of the DAG container). Sub-nodes carry the id of
|
||||
/// their containing parent node. The client uses this to render the
|
||||
/// recursive tree rather than inferring structure from `deps` alone.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// A queued / running / failed DAG — a thin projection of one container
|
||||
/// node plus its (non-`Done`) subtree from the scheduler graph. Only
|
||||
/// non-derivable facts live here: `id`, `source`, `reason`, `created_at`,
|
||||
/// and the node set. The client derives the card label, roll-up state, and
|
||||
/// DAG timestamps from `nodes` (per-node `kind` + lifecycle) — nothing is
|
||||
/// rolled up host-side. There is no DAG-level `agent`: a DAG can span
|
||||
/// agents, so agent is per-[`NodeView`]; consumers group by `NodeView::agent`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DagView {
|
||||
pub id: u64,
|
||||
pub source: Source,
|
||||
pub reason: String,
|
||||
/// When the DAG was enqueued.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When the DAG's first node started (min over *all* its nodes) — computed
|
||||
/// host-side, **not** derived on the client: `Done` nodes are excluded from
|
||||
/// `nodes` below, so the earliest-started node is usually absent from the
|
||||
/// wire and the client can't take the min itself. `None` until a node runs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
/// When the DAG finished (max `finished_at` over all its nodes), set only
|
||||
/// once the DAG has settled terminal. Host-computed for the same reason as
|
||||
/// `started_at`. `None` while the DAG is still live.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
/// Nodes of this DAG with `Done` ones excluded. `Skipped` nodes are
|
||||
/// carried so the dashboard can show which branches weren't taken, but
|
||||
/// they don't keep a DAG alive: one whose nodes are all `Done` or
|
||||
/// `Skipped` is omitted from the snapshot entirely, and its absence is
|
||||
/// what signals completion. A `Failed` DAG lingers until aged out by the
|
||||
/// history cap.
|
||||
pub nodes: Vec<NodeView>,
|
||||
}
|
||||
|
||||
impl DagView {
|
||||
/// Roll-up state derived from the node set — the shared derivation every
|
||||
/// Rust consumer (hivectl, the wait loops, tests) uses so the dashboard's
|
||||
/// JS render and the host agree: `Failed` if any node failed, else
|
||||
/// **`Cancelled` if any cancelled**, else `Running` if any running, else
|
||||
/// `Pending` if any pending, else `Done`. `Done` nodes are excluded
|
||||
/// from the wire, so a DAG that is *entirely* done isn't sent at all —
|
||||
/// its absence from the snapshot is what signals completion.
|
||||
///
|
||||
/// `Cancelled` outranks both `Running` and `Pending` because a cancelled DAG
|
||||
/// still has its weak-edged tail node to run (it reports the cancellation),
|
||||
/// so `Pending`-then-`Running` would flicker back at the operator who just
|
||||
/// cancelled it and read as "the cancel didn't take". Outside that window
|
||||
/// the states barely co-occur: a cancel *cascade* originates at a `Failed`
|
||||
/// node, which returns early above.
|
||||
///
|
||||
/// `Finishing` counts as running: the node's own work is done but its
|
||||
/// sub-nodes are still going, so the DAG is still in flight.
|
||||
///
|
||||
/// `Skipped` contributes nothing: a not-taken branch is an expected part of
|
||||
/// a healthy run, so counting it would make every successful DAG roll up
|
||||
/// non-`Done`.
|
||||
///
|
||||
/// This ordering matches `frontend/packages/dashboard/src/builds.js`'s
|
||||
/// `rollupState`. The two implementations must be edited together — they
|
||||
/// have silently disagreed before.
|
||||
#[must_use]
|
||||
pub fn rollup_state(&self) -> State {
|
||||
let mut any_running = false;
|
||||
let mut any_pending = false;
|
||||
let mut any_cancelled = false;
|
||||
for n in &self.nodes {
|
||||
match n.state {
|
||||
State::Failed => return State::Failed,
|
||||
State::Running | State::Finishing => any_running = true,
|
||||
State::Pending => any_pending = true,
|
||||
State::Cancelled => any_cancelled = true,
|
||||
State::Done | State::Skipped => {}
|
||||
}
|
||||
}
|
||||
if any_cancelled {
|
||||
State::Cancelled
|
||||
} else if any_running {
|
||||
State::Running
|
||||
} else if any_pending {
|
||||
State::Pending
|
||||
} else {
|
||||
State::Done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use super::{DagView, NodeView, Source, State};
|
||||
|
||||
/// A node set carrying nothing but the states — the only input
|
||||
/// `rollup_state` reads.
|
||||
fn dag(states: &[State]) -> DagView {
|
||||
DagView {
|
||||
id: 1,
|
||||
source: Source::Manual,
|
||||
reason: "test".to_owned(),
|
||||
created_at: Utc::now(),
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
nodes: states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &state)| NodeView {
|
||||
id: i as u64,
|
||||
agent: "a".to_owned(),
|
||||
kind: "reconcile".to_owned(),
|
||||
deps: Vec::new(),
|
||||
state,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
error: None,
|
||||
approval_id: None,
|
||||
inputs: Vec::new(),
|
||||
build_log_id: None,
|
||||
parent: None,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_outranks_everything_and_skipped_counts_for_nothing() {
|
||||
// The case this replaces used to be arranged in hive-c0re by running a
|
||||
// rebuild until its Prebuild failed. Only the states ever mattered.
|
||||
assert_eq!(
|
||||
dag(&[State::Done, State::Failed, State::Skipped]).rollup_state(),
|
||||
State::Failed
|
||||
);
|
||||
// A failure wins even against a node still going — the DAG's verdict
|
||||
// is already decided.
|
||||
assert_eq!(
|
||||
dag(&[State::Running, State::Failed]).rollup_state(),
|
||||
State::Failed
|
||||
);
|
||||
// Skipped is an expected part of a healthy run: an outcome-branched
|
||||
// DAG always leaves one branch untaken, so counting it would make
|
||||
// every successful DAG roll up non-Done.
|
||||
assert_eq!(
|
||||
dag(&[State::Done, State::Skipped]).rollup_state(),
|
||||
State::Done
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_outranks_running_and_pending() {
|
||||
// A cancelled DAG still has its weak-edged tail node to run, so
|
||||
// Pending-then-Running would flicker back at the operator who just
|
||||
// cancelled it and read as "the cancel didn't take".
|
||||
assert_eq!(
|
||||
dag(&[State::Cancelled, State::Pending]).rollup_state(),
|
||||
State::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
dag(&[State::Cancelled, State::Running]).rollup_state(),
|
||||
State::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_still_counts_as_running() {
|
||||
// The node's own work is done but its sub-nodes are still going, so
|
||||
// the DAG is in flight. A parent parked in Finishing is the normal
|
||||
// shape of a subtree mid-run, not an edge case.
|
||||
assert_eq!(
|
||||
dag(&[State::Finishing, State::Pending]).rollup_state(),
|
||||
State::Running
|
||||
);
|
||||
assert_eq!(
|
||||
dag(&[State::Running, State::Pending]).rollup_state(),
|
||||
State::Running
|
||||
);
|
||||
assert_eq!(
|
||||
dag(&[State::Done, State::Pending]).rollup_state(),
|
||||
State::Pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_node_set_reads_done() {
|
||||
// Every node Done means every node is filtered off the wire, so this
|
||||
// is what a finished DAG actually looks like to a consumer that has
|
||||
// one in hand at all.
|
||||
assert_eq!(dag(&[]).rollup_state(), State::Done);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,15 +210,9 @@ pub enum HostRequest {
|
|||
/// matrix GUI disabled). Backs `hivectl open` + the federation
|
||||
/// peer-config block (which reads the bare `domain`).
|
||||
Urls,
|
||||
/// Fetch one job-queue DAG by id — the polling surface behind
|
||||
/// `hivectl`'s wait/progress loop. A multi-step op is a single DAG
|
||||
/// (its whole graph in `nodes`). Result: [`HostResponse::dags`].
|
||||
QueueDag { id: u64 },
|
||||
/// Fetch one or more job-queue nodes plus their live subtrees, as
|
||||
/// generic `hive-jobq-wire` nodes — `hivectl`'s wait/progress loop.
|
||||
/// Sibling of [`Self::QueueDag`]: same graph, through the generic
|
||||
/// projection instead of the typed `DagView`/`NodeView` (kept for
|
||||
/// `QueueDag`'s other consumer, `/api/state.rebuild_queue`). No
|
||||
/// generic `hive-jobq-wire` nodes — `hivectl`'s wait/progress loop,
|
||||
/// and the only way the queue is served. No
|
||||
/// assumption that an id names a DAG container or root — whatever
|
||||
/// node has that id, the backend hands back its subtree as-is. A
|
||||
/// batch op that submits several independent roots (e.g. one per
|
||||
|
|
@ -542,23 +536,16 @@ pub struct HostResponse {
|
|||
/// different question".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_exists: Option<bool>,
|
||||
/// Ids of the job-queue DAGs this request submitted (rebuild /
|
||||
/// Ids of the job-queue roots this request submitted (rebuild /
|
||||
/// restart / power ops). Clients poll them via
|
||||
/// [`HostRequest::QueueDag`]; `None` for non-submitting requests.
|
||||
/// [`HostRequest::QueueNodes`]; `None` for non-submitting requests.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub queued_dags: Option<Vec<u64>>,
|
||||
/// `QueueDag` result — the requested DAG followed by its live
|
||||
/// fan-out children ([`jobs::DagView`]). Empty when the DAG has
|
||||
/// been evicted from the queue's history tail.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dags: Option<Vec<jobs::DagView>>,
|
||||
/// `QueueNodes` result — the requested nodes plus their live subtrees,
|
||||
/// as generic `hive-jobq-wire` nodes, all roots' subtrees combined in
|
||||
/// one flat list. `None` for every other request kind. An id with no
|
||||
/// live node in the graph is silently dropped rather than erroring
|
||||
/// the whole batch — see `JobQueue::node_subtrees`'s doc comment for
|
||||
/// why a *completed* DAG's nodes don't vanish the same way
|
||||
/// `QueueDag`'s do; callers should read each root node's `state` for
|
||||
/// the whole batch — callers should read each root node's `state` for
|
||||
/// terminality, not absence.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>,
|
||||
|
|
@ -652,16 +639,6 @@ impl HostResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/// `QueueDag` result — the polled DAG + its live children.
|
||||
#[must_use]
|
||||
pub fn dags(dags: Vec<jobs::DagView>) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
dags: Some(dags),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `QueueNodes` result — the polled node + its subtree, as generic
|
||||
/// wire nodes.
|
||||
#[must_use]
|
||||
|
|
|
|||
Loading…
Reference in a new issue