jobq-wire: name every outcome, keep the enum spellings, document the schema

Three things, all from review:

Accepted outcomes were built from a hand-listed [Done, Failed, Cancelled,
Skipped] array. Exhaustive today, silently short the day someone adds a
variant — the new outcome would vanish from every edge that accepts it.
BitFlags::ALL asks the type instead.

TerminalState carried rename_all = "snake_case" while its sibling State did
not, so one enum shipped "done" and the other "Done". A rename is a second
spelling of a name that then has to be kept in agreement by hand; both now
serialise their variant names verbatim. Nothing else reads TerminalState off
a wire, so no consumer moves. GraphDep's tag values likewise.

The endpoint documented its body as serde_json::Value, which tells a spec
reader nothing. hive-jobq-wire now derives ToSchema. State and TerminalState
are foreign types here and utoipa stays out of the scheduler crate, so the
schema points at local mirror enums. A mirror that drifts is worse than none:
the conversions are exhaustive (a new upstream variant fails the build) and a
test asserts each documented name equals the serialised one, since an
exhaustive match still compiles when only the spellings diverge.
This commit is contained in:
atlas 2026-08-03 00:14:37 +02:00 committed by mara
commit fe0906c043
5 changed files with 148 additions and 18 deletions

2
Cargo.lock generated
View file

@ -1747,9 +1747,11 @@ name = "hive-jobq-wire"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"enumflags2",
"hive-jobq", "hive-jobq",
"serde", "serde",
"serde_json", "serde_json",
"utoipa",
] ]
[[package]] [[package]]

View file

@ -660,9 +660,8 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
dependency edges with their accepted-outcome sets, lifecycle, and \ dependency edges with their accepted-outcome sets, lifecycle, and \
one opaque per-node payload. Group roots ride as ordinary nodes \ one opaque per-node payload. Group roots ride as ordinary nodes \
(`parent: null`) and `Done` nodes are not filtered a consumer \ (`parent: null`) and `Done` nodes are not filtered a consumer \
renders the graph without knowing what any node means. Each element \ renders the graph without knowing what any node means.",
is a `hive_jobq_wire::GraphNode`", body = Vec<hive_jobq_wire::GraphNode>),
body = serde_json::Value),
), ),
tag = "state_snapshot" tag = "state_snapshot"
)] )]

View file

@ -9,6 +9,10 @@ workspace = true
[dependencies] [dependencies]
chrono = { workspace = true } chrono = { workspace = true }
enumflags2 = { workspace = true }
hive-jobq = { workspace = true } hive-jobq = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
# `chrono` for the lifecycle timestamps' schema; the workspace default carries
# only `axum_extras`, and features are additive so this affects nothing else.
utoipa = { workspace = true, features = ["chrono"] }

View file

@ -28,8 +28,74 @@
//! [`Node`]: hive_jobq::Node //! [`Node`]: hive_jobq::Node
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use enumflags2::BitFlags;
use hive_jobq::{Dep, Graph, NodeId, State, TerminalState}; use hive_jobq::{Dep, Graph, NodeId, State, TerminalState};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// `OpenAPI` mirror of [`hive_jobq::State`].
///
/// Exists **only** so the generated spec can enumerate the states: `State` is
/// a foreign type, so neither `ToSchema` nor a newtype around it can be
/// implemented here, and `utoipa` is deliberately not a dependency of the
/// scheduler crate. Referenced via `#[schema(value_type = …)]`; nothing is ever
/// serialised through it, so it cannot change the wire.
///
/// [`StateSchema::of`] is what keeps it honest — an exhaustive match, so adding
/// a variant upstream **fails to compile here** instead of quietly leaving the
/// documented enum short.
#[derive(Debug, Clone, Copy, ToSchema)]
pub enum StateSchema {
Pending,
Running,
Finishing,
Done,
Failed,
Cancelled,
Skipped,
}
impl StateSchema {
/// The mirror variant for `state`. Public because the mirror type is: a
/// consumer that renders the documented enum needs the same mapping, and a
/// constructor nobody outside can call is a schema nobody can check.
#[must_use]
pub fn of(state: State) -> Self {
match state {
State::Pending => Self::Pending,
State::Running => Self::Running,
State::Finishing => Self::Finishing,
State::Done => Self::Done,
State::Failed => Self::Failed,
State::Cancelled => Self::Cancelled,
State::Skipped => Self::Skipped,
}
}
}
/// `OpenAPI` mirror of [`hive_jobq::TerminalState`] — same rationale and same
/// exhaustive-match guard as [`StateSchema`].
#[derive(Debug, Clone, Copy, ToSchema)]
pub enum TerminalStateSchema {
Done,
Failed,
Cancelled,
Skipped,
}
impl TerminalStateSchema {
/// The mirror variant for `outcome`. Public for the same reason as
/// [`StateSchema::of`].
#[must_use]
pub fn of(outcome: TerminalState) -> Self {
match outcome {
TerminalState::Done => Self::Done,
TerminalState::Failed => Self::Failed,
TerminalState::Cancelled => Self::Cancelled,
TerminalState::Skipped => Self::Skipped,
}
}
}
/// Node id, carried verbatim from [`hive_jobq::NodeId`]: globally unique across /// Node id, carried verbatim from [`hive_jobq::NodeId`]: globally unique across
/// the whole graph, not per group. Opaque to consumers — they group by /// the whole graph, not per group. Opaque to consumers — they group by
@ -67,8 +133,9 @@ pub trait WireResource {
} }
/// One node, serialised near-raw from [`hive_jobq::Node`]. /// One node, serialised near-raw from [`hive_jobq::Node`].
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct GraphNode { pub struct GraphNode {
/// Unique across the whole graph, not per group.
pub id: WireId, pub id: WireId,
/// Structural parent, or `None` for a group root. /// Structural parent, or `None` for a group root.
/// ///
@ -77,7 +144,12 @@ pub struct GraphNode {
/// `state` answers "how is this whole group doing". /// `state` answers "how is this whole group doing".
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<WireId>, pub parent: Option<WireId>,
/// Lifecycle state. On a group root this is also the subtree's answer:
/// `Finishing` = own logic done, children still running; a terminal value
/// is the rolled-up outcome.
#[schema(value_type = StateSchema)]
pub state: State, pub state: State,
/// What must hold before this node runs. Omitted when empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deps: Vec<GraphDep>, pub deps: Vec<GraphDep>,
/// When the node entered `Running`. `None` until it starts; a node that /// When the node entered `Running`. `None` until it starts; a node that
@ -95,7 +167,7 @@ pub struct GraphNode {
} }
/// What a node *is*, in terms the graph layer does not interpret. /// What a node *is*, in terms the graph layer does not interpret.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct NodePayload { pub struct NodePayload {
/// From [`WireNode::label`]. /// From [`WireNode::label`].
pub label: String, pub label: String,
@ -107,8 +179,12 @@ pub struct NodePayload {
} }
/// What must hold before a node runs. /// What must hold before a node runs.
#[derive(Debug, Clone, Serialize, Deserialize)] ///
#[serde(rename_all = "snake_case", tag = "kind")] /// Externally tagged on `kind`, whose values are the **variant names verbatim**
/// (`"Node"`, `"Resource"`) — no `rename_all`. A rename is a second spelling of
/// the same name that has to be kept in agreement with the Rust one by hand.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "kind")]
pub enum GraphDep { pub enum GraphDep {
/// Depend on another node finishing acceptably. /// Depend on another node finishing acceptably.
Node { Node {
@ -122,6 +198,10 @@ pub enum GraphDep {
/// node, distinguished only by which outcomes each accepts (one for /// node, distinguished only by which outcomes each accepts (one for
/// `Done`, one for `Failed`/`Cancelled`, …). Collapsing this to a /// `Done`, one for `Failed`/`Cancelled`, …). Collapsing this to a
/// boolean renders those as identical nodes. /// boolean renders those as identical nodes.
///
/// Every accepted outcome is **named**, so a client never shifts bits
/// to read an edge.
#[schema(value_type = Vec<TerminalStateSchema>)]
accepts: Vec<TerminalState>, accepts: Vec<TerminalState>,
}, },
/// Need `count` units of a named resource, per [`WireResource::name`]. /// Need `count` units of a named resource, per [`WireResource::name`].
@ -188,15 +268,14 @@ fn wire_dep<R: WireResource>(dep: &Dep<R>) -> GraphDep {
match dep { match dep {
Dep::Node { id, when } => GraphDep::Node { Dep::Node { id, when } => GraphDep::Node {
id: id.get(), id: id.get(),
accepts: [ // Every outcome the type knows about, not a hand-listed set: a
TerminalState::Done, // variant added to `TerminalState` is then named on the wire
TerminalState::Failed, // automatically, where a literal array would have silently
TerminalState::Cancelled, // dropped it from every edge that accepts it.
TerminalState::Skipped, accepts: BitFlags::<TerminalState>::ALL
] .iter()
.into_iter() .filter(|outcome| when.accepts(*outcome))
.filter(|outcome| when.accepts(*outcome)) .collect(),
.collect(),
}, },
Dep::Resource { name, count } => GraphDep::Resource { Dep::Resource { name, count } => GraphDep::Resource {
name: name.name(), name: name.name(),
@ -212,9 +291,51 @@ mod tests {
use hive_jobq::scheduler::Scheduler; use hive_jobq::scheduler::Scheduler;
use super::{ use super::{
GraphDep, GraphNode, GraphWire, NodePayload, State, TerminalState, WireId, WireNode, BitFlags, GraphDep, GraphNode, GraphWire, NodePayload, State, StateSchema, TerminalState,
TerminalStateSchema, WireId, WireNode,
}; };
/// The mirrors document what the wire actually says — a mirror that
/// disagreed would be worse than none, since the spec is all a consumer
/// has.
///
/// `StateSchema::of` is the *compile-time* half (exhaustive, so a new
/// upstream variant breaks the build). This is the *value* half: the
/// documented variant name has to equal the serialised one. Both are
/// needed — an exhaustive match still compiles if the names diverge, which
/// is exactly what a stray `rename_all` upstream would do.
#[test]
fn the_openapi_mirrors_name_states_exactly_as_the_wire_does() {
let states = [
State::Pending,
State::Running,
State::Finishing,
State::Done,
State::Failed,
State::Cancelled,
State::Skipped,
];
for state in states {
let on_the_wire = serde_json::to_string(&state).expect("serialises");
let documented = format!("{:?}", StateSchema::of(state));
assert_eq!(
on_the_wire,
format!("\"{documented}\""),
"{state:?} is documented as {documented} but serialises as {on_the_wire}"
);
}
for outcome in BitFlags::<TerminalState>::ALL {
let on_the_wire = serde_json::to_string(&outcome).expect("serialises");
let documented = format!("{:?}", TerminalStateSchema::of(outcome));
assert_eq!(
on_the_wire,
format!("\"{documented}\""),
"{outcome:?} is documented as {documented} but serialises as {on_the_wire}"
);
}
}
/// A scheduler over the test payloads, built through the same public API a /// A scheduler over the test payloads, built through the same public API a
/// host uses. /// host uses.
/// ///

View file

@ -62,10 +62,14 @@ impl NodeId {
/// How a node finished. The terminal subset of [`State`], as its own type so an /// How a node finished. The terminal subset of [`State`], as its own type so an
/// edge condition cannot name `Pending` / `Running` / `Finishing` — those are /// edge condition cannot name `Pending` / `Running` / `Finishing` — those are
/// meaningless in a dependency and are better unrepresentable than rejected. /// meaningless in a dependency and are better unrepresentable than rejected.
///
/// Serialises as its **variant name verbatim** (`"Done"`, `"Cancelled"`), with
/// no `rename_all` transformation — matching [`State`], whose names the
/// dashboard already matches on. A rename is a second spelling of the same
/// value that has to be kept in agreement by hand.
#[enumflags2::bitflags] #[enumflags2::bitflags]
#[repr(u8)] #[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TerminalState { pub enum TerminalState {
/// Own logic succeeded and every sub-node did too. /// Own logic succeeded and every sub-node did too.
Done, Done,