jobq-wire: move the generic graph projection into its own crate
The wire types were in hive-host-sock, which is the host *socket* crate — so anything living there is core-shaped by construction, and the projection had quietly grown two core dependencies to match: it selected roots by matching NodeKind::Dag, and rendered payloads through free functions in hive-c0re that nothing obliged a second host to write. hive-jobq is the wrong home too. That crate is the scheduler — logic — and folding presentation in means every consumer of it carries a JSON vocabulary it may never serve. So: a new hive-jobq-wire. A host implements WireNode for its payload N and WireResource for its resource name R; GraphWire::wire_snapshot is blanket-implemented for Graph<N, R> when both hold, and for nothing else. A payload that has never said how it displays has no way onto the wire. wire_snapshot takes the roots to serve rather than reading Graph::roots itself. Nothing is ever removed from a Graph, so retention is a policy only the host can hold; hive-c0re passes visible_roots(), which is the existing MAX_HISTORY_DAGS bound selected structurally (a root is a node with no parent) instead of by node kind.
This commit is contained in:
parent
f357f98867
commit
7966d5eb66
14 changed files with 564 additions and 297 deletions
|
|
@ -1,192 +0,0 @@
|
|||
//! 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,7 +16,6 @@ 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