From 24cd7f6d652973d0ac7631558e446f4d5ac8a07d Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 3 Aug 2026 13:31:36 +0200 Subject: [PATCH 1/2] jobq: stamp created_at on every node, beside started_at/finished_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Node` carried two of the three lifecycle timestamps; the third lived on hive-c0re's `NodeKind::Dag` container payload, a core-specific wrapper the graph knows nothing about. Give it its real home so the container's copy becomes redundant rather than load-bearing. Not an `Option` like its neighbours: starting and finishing are events that may never happen, but a node that exists was created. Modelling it as optional would encode a state the graph cannot be in. `hive-jobq-wire::GraphNode` gains the field in the same commit — it already carries the other two, and without this one the value cannot reach a viewer when the container's copy is deleted. --- hive-jobq-wire/src/lib.rs | 5 +++++ hive-jobq/src/lib.rs | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/hive-jobq-wire/src/lib.rs b/hive-jobq-wire/src/lib.rs index 19ef493b..437782ea 100644 --- a/hive-jobq-wire/src/lib.rs +++ b/hive-jobq-wire/src/lib.rs @@ -152,6 +152,9 @@ pub struct GraphNode { /// What must hold before this node runs. Omitted when empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub deps: Vec, + /// When the node was inserted into the graph. Always present — a node that + /// exists was created, so unlike the two below this is not an `Option`. + pub created_at: DateTime, /// When the node entered `Running`. `None` until it starts; a node that /// never ran keeps `None`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -254,6 +257,7 @@ fn wire_node(node: &hive_jobq::Node) -> Grap parent: node.parent.map(NodeId::get), state: node.state, deps: node.deps.iter().map(wire_dep).collect(), + created_at: node.created_at, started_at: node.started_at, finished_at: node.finished_at, error: node.error.clone(), @@ -443,6 +447,7 @@ mod tests { parent, state, deps, + created_at: chrono::DateTime::::default(), started_at: None, finished_at: None, error: None, diff --git a/hive-jobq/src/lib.rs b/hive-jobq/src/lib.rs index cccd55a1..0e899dc7 100644 --- a/hive-jobq/src/lib.rs +++ b/hive-jobq/src/lib.rs @@ -255,9 +255,9 @@ impl State { } /// Wall-clock UTC now — the source for node lifecycle timestamps -/// ([`Node::started_at`] / [`Node::finished_at`]). The graph stamps its own -/// timestamps rather than threading a clock through every call, so a node's -/// timing is self-contained. Derived from `SystemTime` (the workspace `chrono` +/// ([`Node::created_at`] / [`Node::started_at`] / [`Node::finished_at`]). The +/// graph stamps its own timestamps rather than threading a clock through every +/// call, so a node's timing is self-contained. Derived from `SystemTime` (the workspace `chrono` /// carries no `clock` feature, matching `hive_sh4re::wire_time`), truncated to /// whole seconds; a pre-epoch or out-of-range clock clamps to the epoch. fn now_utc() -> DateTime { @@ -291,6 +291,14 @@ pub struct Node { pub deps: Vec>, /// Lifecycle state. pub state: State, + /// UTC instant the node was inserted into the graph. Stamped by the graph. + /// + /// Not an `Option`, unlike its two siblings below: starting and finishing + /// are events that may never happen, but a node that exists was created. + /// Together the three are the node's whole lifecycle — a caller asking + /// "how long did this sit before it ran" needs this end of the interval, + /// and previously had to get it from a wrapper the graph knows nothing about. + pub created_at: DateTime, /// UTC instant the node entered [`State::Running`] (`None` until it starts; /// a cancelled node never ran, so it stays `None`). Stamped by the graph. pub started_at: Option>, @@ -481,6 +489,7 @@ impl Graph { payload, deps, state: State::Pending, + created_at: now_utc(), started_at: None, finished_at: None, error: None, @@ -934,6 +943,7 @@ mod tests { when: DepWhen::AFTER_OK, }], state: State::Pending, + created_at: now_utc(), started_at: None, finished_at: None, error: None, @@ -954,6 +964,7 @@ mod tests { payload: "x", deps: vec![], state: State::Pending, + created_at: now_utc(), started_at: None, finished_at: None, error: None, From 55705fd617ba1390a03295e38ebb5413f74262c0 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 3 Aug 2026 15:38:51 +0200 Subject: [PATCH 2/2] jobq: drop the Dag payload's created_at, read it off the node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container node now carries its own `created_at` like every other node, so the payload copy recorded the same instant a second time — and only the payload one was reachable to a viewer that doesn't know what a Dag is. `dag_view` reads `node.created_at` off the container instead. `DagView` keeps the field on the wire: hivectl's dag_progress uses it as the elapsed fallback for a DAG that hasn't started yet. It just has one source now. Removing the field left chrono entirely unused in model.rs, which is the compiler confirming the payload had no other use for a timestamp. --- hive-c0re/src/job_queue/mod.rs | 26 +++++++++----------------- hive-c0re/src/job_queue/model.rs | 12 ++++++------ 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 32beb2c5..6b2411df 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -217,15 +217,7 @@ impl JobQueue { ) -> anyhow::Result { let mut inner = self.lock(); let container = inner - .append( - NodeKind::Dag { - source, - reason, - created_at: Utc::now(), - }, - Vec::new(), - None, - ) + .append(NodeKind::Dag { source, reason }, Vec::new(), None) .map_err(|e| anyhow::anyhow!("job_queue: container insert failed: {e}"))?; insert_group(&mut inner, declare, Some(container))?; drop(inner); @@ -407,15 +399,15 @@ fn container(sched: &Sched, dag_id: u64) -> Option { /// DAG drops out of the snapshot entirely (a `Failed` one lingers until /// aged out). fn dag_view(sched: &Sched, container: NodeId) -> Option { - // Read straight off the container's payload: the three fields have a + // Read straight off the container's payload: the domain fields have a // single home there, so an intermediate owned copy of them was a second // type describing the same data rather than a grouping side-table. - let NodeKind::Dag { - source, - reason, - created_at, - } = &sched.graph().node(container)?.payload - else { + // + // `created_at` is NOT among them — it comes off the container node itself + // below, where the graph stamps it for every node. Keeping a payload copy + // would record one instant in two places. + let node = sched.graph().node(container)?; + let NodeKind::Dag { source, reason } = &node.payload else { return None; }; let all: Vec<_> = sched.graph().descendants(container).collect(); @@ -494,7 +486,7 @@ fn dag_view(sched: &Sched, container: NodeId) -> Option { id: container.get(), source: *source, reason: reason.clone(), - created_at: *created_at, + created_at: node.created_at, started_at: started.into_iter().min(), finished_at: is_terminal.then(|| finished.into_iter().max()).flatten(), nodes, diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index e783c05c..3b8aaf4b 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -12,7 +12,6 @@ //! DAG can span agents). See `docs/coordinator.md::Job queue` for the //! full design. -use chrono::{DateTime, Utc}; pub use hive_host_sock::jobs::{DagView, PermPayload, Source, State}; use serde::Serialize; @@ -276,11 +275,12 @@ pub enum NodeKind { /// the DAG state. Pure grouping — lease- and /// build-slot-exempt; the executor instant-completes it (`Done`) so it /// reaches `Finishing` and its children start. - Dag { - source: Source, - reason: String, - created_at: DateTime, - }, + /// + /// No `created_at` here: the graph stamps [`hive_jobq::Node::created_at`] on + /// every node at insert, so the container already has one. A second copy in + /// the payload would be the same instant recorded twice, with only this + /// variant's version reachable to a generic viewer. + Dag { source: Source, reason: String }, } /// How a hive-c0re node describes itself to a generic graph viewer.