hive-host-sock: a jobq graph wire type that doesn't know what a node is
`jobs::NodeView` can only ever display hive-c0re's queue. Five of its fields are domain knowledge: `approval_id` is only ever on a `DeployWindow`, `inputs` only on a `MetaLock`, `build_log_id` only on the nix-heavy kinds, `agent` is derived from the payload, and `kind` is a payload tag consumers branch on. A component built against that shape cannot render a second jobq. `graph::GraphNode` is `hive_jobq::Node` with both generics erased: the crate's own field set, with everything domain-specific in one opaque `payload.data` slot the consumer renders without branching on. That is the crate boundary made visible — hive-jobq owns structure, its host owns meaning — and it is the same split #2957 drew inside the code. Two details that are easy to get wrong and are pinned by tests: `GraphDep::Node` carries `accepts` as the **set** of terminal outcomes, not a strong/weak flag. A template emits its tails as a pair edged on the same upstream node, and the only thing telling them apart is which outcomes each accepts; collapsing that renders two structurally different nodes identically. There is **no roll-up field**. A group root ships as an ordinary node with `parent: None`, and its own `state` is its subtree's answer — `Finishing` means "own logic done, children still running", the terminal states are the rolled-up outcome. A separate field would be a lossier copy: `DagView::rollup_state` flattens `Running` and `Finishing` into one, which is exactly the distinction a viewer wants. `State` and `TerminalState` are re-exported from `hive-jobq` rather than redeclared, so they cannot drift from the scheduler that produces them.
This commit is contained in:
parent
7fba2d6919
commit
792d7cb304
4 changed files with 195 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1727,6 +1727,7 @@ dependencies = [
|
|||
"hive-sh4re",
|
||||
"hive-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ hive-jobq.workspace = true
|
|||
hive-sh4re.workspace = true
|
||||
hive-types.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
192
hive-host-sock/src/graph.rs
Normal file
192
hive-host-sock/src/graph.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! Generic jobq graph wire types — a `hive_jobq` graph serialised without
|
||||
//! knowing what its nodes mean.
|
||||
//!
|
||||
//! [`super::jobs`]'s `DagView` / `NodeView` are hive-c0re's *domain*
|
||||
//! projection: they carry `approval_id`, `inputs`, `build_log_id` and an
|
||||
//! `agent`, each meaningful for a subset of one specific node kind. Anything
|
||||
//! built on those can only ever display hive-c0re's queue.
|
||||
//!
|
||||
//! These types carry what `hive_jobq::Node` itself carries — identity, the
|
||||
//! parent tree, dependency edges, lifecycle — and push everything
|
||||
//! domain-specific into one opaque [`NodePayload::data`] slot the consumer
|
||||
//! renders without branching on. That is the crate boundary made visible:
|
||||
//! `hive-jobq` owns structure, its host owns meaning.
|
||||
//!
|
||||
//! There is deliberately **no roll-up field**. A group root's own [`State`]
|
||||
//! *is* its subtree's answer: `Finishing` means "own logic done, children
|
||||
//! still running", and the terminal states are the rolled-up outcome. A
|
||||
//! separate field would be a lossier copy of a value already on the wire —
|
||||
//! lossier because it would have to flatten `Running` and `Finishing` together.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use hive_jobq::{State, TerminalState};
|
||||
|
||||
/// Node id, carried verbatim from `hive_jobq::NodeId`: globally unique across
|
||||
/// the whole graph, not per group. Opaque to consumers — they group by
|
||||
/// [`GraphNode::parent`] and match dependency edges, nothing more.
|
||||
pub type NodeId = u64;
|
||||
|
||||
/// One node, serialised near-raw from `hive_jobq::Node`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphNode {
|
||||
pub id: NodeId,
|
||||
/// Structural parent, or `None` for a group root.
|
||||
///
|
||||
/// A group root is an **ordinary node here** — nothing is hidden, so a
|
||||
/// consumer needs no special case for "the container", and the root's
|
||||
/// own `state` answers "how is this whole group doing".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<NodeId>,
|
||||
pub state: State,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub deps: Vec<GraphDep>,
|
||||
/// When the node entered `Running`. `None` until it starts; a node that
|
||||
/// never ran keeps `None`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
/// When the node reached a terminal state. `None` while non-terminal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
/// Failure reason, set only when this node's *own* logic failed — a node
|
||||
/// that rolled up `Failed` from a child carries none of its own.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub payload: NodePayload,
|
||||
}
|
||||
|
||||
/// What a node *is*, in terms the graph layer does not interpret.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodePayload {
|
||||
/// Short tag the consumer displays verbatim. **Not** for matching on: a
|
||||
/// generic viewer that branches on this has stopped being generic.
|
||||
pub label: String,
|
||||
/// Domain data, rendered generically (as key/value, a details pane, a
|
||||
/// tooltip — the consumer's choice). Everything a specific host wants to
|
||||
/// say about a node beyond its label lives here, so adding a field costs
|
||||
/// the wire type nothing.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// What must hold before a node runs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum GraphDep {
|
||||
/// Depend on another node finishing acceptably.
|
||||
Node {
|
||||
/// The node depended on. May name a node the consumer has not been
|
||||
/// sent (a filtered view); treat an absent target as satisfied.
|
||||
id: NodeId,
|
||||
/// **The outcomes that satisfy this edge, as a set** — not a
|
||||
/// strong/weak flag.
|
||||
///
|
||||
/// A template routinely emits several tails edged on the *same*
|
||||
/// upstream node, distinguished only by which outcomes each accepts
|
||||
/// (one for `Done`, one for `Failed`/`Cancelled`, …). Collapsing this
|
||||
/// to a boolean renders those as identical nodes.
|
||||
accepts: Vec<TerminalState>,
|
||||
},
|
||||
/// Need `count` units of a named resource. Named, not typed: the resource
|
||||
/// vocabulary belongs to the host, so it rides as a string.
|
||||
Resource { name: String, count: u32 },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GraphDep, GraphNode, NodePayload, State, TerminalState};
|
||||
|
||||
fn node(id: u64, parent: Option<u64>, state: State, deps: Vec<GraphDep>) -> GraphNode {
|
||||
GraphNode {
|
||||
id,
|
||||
parent,
|
||||
state,
|
||||
deps,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
error: None,
|
||||
payload: NodePayload {
|
||||
label: "reconcile".to_owned(),
|
||||
data: serde_json::Value::Null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_round_trips_through_json() {
|
||||
let before = node(
|
||||
7,
|
||||
Some(3),
|
||||
State::Running,
|
||||
vec![
|
||||
GraphDep::Node {
|
||||
id: 3,
|
||||
accepts: vec![TerminalState::Done],
|
||||
},
|
||||
GraphDep::Resource {
|
||||
name: "build-slot".to_owned(),
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
let json = serde_json::to_string(&before).expect("serialises");
|
||||
let after: GraphNode = serde_json::from_str(&json).expect("round trips");
|
||||
assert_eq!(format!("{after:?}"), format!("{before:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tails_on_one_upstream_are_distinguishable_by_their_accepted_set() {
|
||||
// The reason `accepts` is a set and not a strong/weak bool: these two
|
||||
// are the same shape and the same upstream, and *only* the outcome
|
||||
// set tells them apart.
|
||||
let ok_tail = node(
|
||||
10,
|
||||
None,
|
||||
State::Pending,
|
||||
vec![GraphDep::Node {
|
||||
id: 4,
|
||||
accepts: vec![TerminalState::Done],
|
||||
}],
|
||||
);
|
||||
let fail_tail = node(
|
||||
11,
|
||||
None,
|
||||
State::Pending,
|
||||
vec![GraphDep::Node {
|
||||
id: 4,
|
||||
accepts: vec![TerminalState::Failed, TerminalState::Cancelled],
|
||||
}],
|
||||
);
|
||||
let render = |n: &GraphNode| match n.deps.first() {
|
||||
Some(GraphDep::Node { accepts, .. }) => format!("{accepts:?}"),
|
||||
_ => "none".to_owned(),
|
||||
};
|
||||
assert_ne!(render(&ok_tail), render(&fail_tail));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_root_is_an_ordinary_node_and_carries_the_groups_answer() {
|
||||
// No roll-up field: `Finishing` on the root says "own logic done,
|
||||
// children still running", and a terminal root state is the roll-up.
|
||||
let root = node(1, None, State::Finishing, Vec::new());
|
||||
assert!(root.parent.is_none(), "a group root is just parentless");
|
||||
assert_eq!(root.state, State::Finishing);
|
||||
|
||||
let done_root = node(2, None, State::Failed, Vec::new());
|
||||
assert_eq!(
|
||||
done_root.state,
|
||||
State::Failed,
|
||||
"the root's own state is the subtree's rolled-up outcome"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_payload_slot_is_omitted_from_the_wire() {
|
||||
// The opaque slot costs nothing when a host has nothing to say.
|
||||
let json =
|
||||
serde_json::to_string(&node(1, None, State::Pending, Vec::new())).expect("serialises");
|
||||
assert!(!json.contains("\"data\""), "null data is skipped: {json}");
|
||||
assert!(json.contains("\"label\""), "the label always rides: {json}");
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ use hive_sh4re::{AgentStatusRow, Approval};
|
|||
use hive_types::Ident;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod graph;
|
||||
pub mod jobs;
|
||||
|
||||
// ── Shared hive layout facts ──────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue