680 lines
30 KiB
Rust
680 lines
30 KiB
Rust
//! Generic job-DAG queue + desired-state reconciliation — the host-side
|
|
//! wrapper over the domain-agnostic [`hive_jobq`] scheduler. Jobs are nodes in
|
|
//! per-request DAGs (see [`templates`]); the special cases (graceful-stop
|
|
//! watcher, deferred-start follow-up, meta-update cascade) collapse into DAG
|
|
//! *shapes* over the shared node primitives ([`model::NodeKind`]).
|
|
//!
|
|
//! [`hive_jobq`] owns the graph, the two-class resource pool, and the roll-up
|
|
//! settle loop; this module maps hive-c0re's concepts onto it:
|
|
//! - [`model::NodeKind`] **is** the crate payload `N` directly — each variant
|
|
//! carries the agent it targets ([`NodeKind::agent`]); the two resource
|
|
//! classes are [`resource::Resource`] (`BuildSlot` node-held, `Agent` lease
|
|
//! subtree-held), declared per node at its construction site;
|
|
//! - a **DAG is a single container node** ([`NodeKind::Dag`], `parent = None`)
|
|
//! carrying the group's metadata, with the work nodes hung under it as
|
|
//! its subtree (the **parent axis** groups; `deps` order). So the container's
|
|
//! `NodeId` is the DAG id, its rolled-up state is the DAG state, and membership
|
|
//! is a graph walk — there are no host grouping side-tables. The lease is owned
|
|
//! by a subtree root and borrowed by its descendants (continuity);
|
|
//! - per-DAG terminal work is an ordinary **tail node**
|
|
//! ([`NodeKind::ResolveApproval`] / [`NodeKind::EmitRebuilt`]) that the builder
|
|
//! appends in [`templates`], edged onto the DAG's other group roots by the
|
|
//! outcome it reports. Templates emit one tail per outcome and the graph runs
|
|
//! exactly one, so nothing branches at runtime.
|
|
//!
|
|
//! The queue is runtime-only (no persistence): an empty graph on boot; desired
|
|
//! state is re-derived by the reconcile sweep. A single scheduler task
|
|
//! ([`scheduler::run_worker`]) drives it; concurrency comes from the build-slot
|
|
//! capacity, not multiple workers. Design: `docs/coordinator.md::Job queue`.
|
|
|
|
pub mod exec;
|
|
pub mod model;
|
|
pub mod resource;
|
|
pub mod scheduler;
|
|
pub mod submit;
|
|
pub mod templates;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use hive_host_sock::jobs::NodeView;
|
|
use hive_jobq::resources::ResourceTable;
|
|
use hive_jobq::scheduler::{Outcome, Scheduler};
|
|
use hive_jobq::{Dep, Graph, NodeId};
|
|
use hive_jobq_wire::{GraphNode, GraphWire};
|
|
use tokio::sync::Notify;
|
|
|
|
pub use hive_jobq::TerminalState;
|
|
pub use model::{DagView, NodeKind, PermPayload, Source, State};
|
|
use resource::Resource;
|
|
|
|
/// A job under construction: `hive_jobq`'s builder over this queue's payload
|
|
/// ([`NodeKind`]) and resource ([`Resource`]) types. Templates declare into a
|
|
/// borrowed one; only `hive_jobq` can make or insert it.
|
|
pub type JobBuilder = hive_jobq::JobBuilder<NodeKind, Resource>;
|
|
|
|
/// A handle to one node a template declared — where its edges, grouping and
|
|
/// resources are declared. `Copy`; naming a node as a dependency does not
|
|
/// consume the ability to name it again.
|
|
pub type Handle<'a> = hive_jobq::NodeRef<'a, NodeKind, Resource>;
|
|
|
|
/// How many terminal DAGs (`Done` / `Failed` / `Cancelled`) the snapshot
|
|
/// retains, newest first. A flat cap over the whole sorted list: the
|
|
/// dashboard renders one recent-builds list, so one number bounds it.
|
|
const MAX_HISTORY_DAGS: usize = 50;
|
|
|
|
/// Cap on stored node error strings.
|
|
const MAX_ERROR_LEN: usize = 2_000;
|
|
|
|
/// One live transient pill, derived from a running node.
|
|
///
|
|
/// A named struct rather than a tuple because three of its four fields are
|
|
/// easy to confuse at a call site: two are strings and two answer questions
|
|
/// nobody should have to guess at ("is this the agent or the label?", "does
|
|
/// this bool mean deliberate or running?").
|
|
#[derive(Debug, Clone)]
|
|
pub struct RunningTransient {
|
|
/// The agent whose lease the node declared.
|
|
pub agent: String,
|
|
/// The node's own wire tag, rendered as the pill.
|
|
pub label: String,
|
|
/// Whether this operation is expected to take the container down — the
|
|
/// crash watcher's input. See [`NodeKind::takes_container_down`].
|
|
pub takes_container_down: bool,
|
|
/// When the node started running, so the dashboard can tick elapsed
|
|
/// seconds. Taken from the node itself, which is the true start of the
|
|
/// operation rather than the moment a watcher noticed it.
|
|
pub since: DateTime<Utc>,
|
|
}
|
|
|
|
/// The crate scheduler, specialised to this host's node + resource types.
|
|
///
|
|
/// A **DAG is a single container node** ([`NodeKind::Dag`], `parent = None`)
|
|
/// whose subtree is the DAG's work — so the container's `NodeId` is the DAG id,
|
|
/// its rolled-up state is the DAG state, and there are no grouping side-tables:
|
|
/// membership + meta are graph queries ([`container`] + the `hive_jobq::Graph`
|
|
/// accessors, with the meta read straight off the container's payload). One
|
|
/// shared crate [`Graph`] holds every DAG.
|
|
///
|
|
/// There is deliberately **no wrapper struct and no per-node side map**. The
|
|
/// last map held the `build_logs` row id; that link now lives on the log row
|
|
/// itself (`build_logs.node_id`). With nothing else to guard, the mutex holds
|
|
/// the scheduler *directly* — which is what lets `hive_jobq` drive the run loop
|
|
/// (it takes `&Arc<Mutex<Scheduler<..>>>`, a type a host-side wrapper could not
|
|
/// satisfy).
|
|
type Sched = Scheduler<NodeKind, Resource>;
|
|
|
|
/// The queue. Lives on `Coordinator` (one per hive-c0re process); a single
|
|
/// scheduler task ([`scheduler::run_worker`]) drives it.
|
|
pub struct JobQueue {
|
|
/// The scheduler, held directly rather than behind a host-side wrapper —
|
|
/// `hive_jobq`'s run-loop seam takes `&Arc<Mutex<Scheduler<..>>>`, so this
|
|
/// *is* the type the crate drives.
|
|
sched: Arc<Mutex<Sched>>,
|
|
/// Wakes the scheduler when something new arrives or state changed.
|
|
pub(crate) notify: Notify,
|
|
}
|
|
|
|
impl std::fmt::Debug for JobQueue {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("JobQueue").finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl Default for JobQueue {
|
|
fn default() -> Self {
|
|
Self::new(1)
|
|
}
|
|
}
|
|
|
|
/// A node runner's `Result` as the scheduler's [`Outcome`].
|
|
///
|
|
/// The failure reason + `finished_at` are stamped onto the graph `Node` by the
|
|
/// scheduler (the reason rides `Outcome::Failed`); there is no host-side copy,
|
|
/// so nothing needs clearing on success.
|
|
fn outcome_of(result: Result<(), String>) -> Outcome {
|
|
match result {
|
|
Ok(()) => Outcome::Done,
|
|
Err(e) => Outcome::Failed(truncate_error(&e)),
|
|
}
|
|
}
|
|
|
|
/// Insert a declared `job` into the shared graph, returning the inserted ids.
|
|
///
|
|
/// A node that declared no parent hangs under `group_parent` — the DAG
|
|
/// container for a template, the emitting node for a runtime-appended
|
|
/// subgraph. Templates declare the parent axis + sibling ordering directly, so
|
|
/// there is no dep-on-root to drop and no lease to hoist: each node declares
|
|
/// its own resources, and the crate's borrow model keeps a resource continuous
|
|
/// across a subtree (a root owns it, descendants borrow it). Independent group
|
|
/// roots carry no cross-links, so a multi-agent DAG's per-agent subgraphs run
|
|
/// concurrently, each on its own lease.
|
|
///
|
|
/// # Errors
|
|
/// Propagates a crate graph-insert error (malformed dep/parent / dep-scope).
|
|
fn insert_group(
|
|
inner: &mut Sched,
|
|
declare: impl FnOnce(&JobBuilder),
|
|
group_parent: Option<NodeId>,
|
|
) -> anyhow::Result<()> {
|
|
inner
|
|
.insert_job(group_parent, |b| {
|
|
declare(b);
|
|
// c0re names no handles: a DAG is addressed by its container node,
|
|
// which `submit` inserts itself, and nothing downstream looks an
|
|
// individual step up by id.
|
|
Vec::new()
|
|
})
|
|
.map_err(|e| anyhow::anyhow!("job_queue: graph insert failed: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
impl JobQueue {
|
|
#[must_use]
|
|
pub fn new(build_slots: usize) -> Self {
|
|
let mut table = ResourceTable::new();
|
|
table.set_capacity(
|
|
Resource::BuildSlot,
|
|
u32::try_from(build_slots.max(1)).unwrap_or(u32::MAX),
|
|
);
|
|
Self {
|
|
sched: Arc::new(Mutex::new(Scheduler::new(Graph::new(), table))),
|
|
notify: Notify::new(),
|
|
}
|
|
}
|
|
|
|
fn lock(&self) -> std::sync::MutexGuard<'_, Sched> {
|
|
self.sched.lock().expect("job_queue mutex poisoned")
|
|
}
|
|
|
|
/// Submit a DAG: insert a [`NodeKind::Dag`] **container node** carrying the
|
|
/// group's metadata, then insert the template's nodes as its subtree (their
|
|
/// roots re-parented to the container). Returns the container's id as the
|
|
/// DAG id — its rolled-up state is the DAG state.
|
|
///
|
|
/// The container is an ordinary node: it declares no resources, so the
|
|
/// scheduler claims it on the next pass, runs its (empty) logic and parks
|
|
/// it in `Finishing`, at which point its children become runnable. Nothing
|
|
/// here completes it by hand — a node with no work of its own still goes
|
|
/// the way every other node goes.
|
|
///
|
|
/// `source` and `reason` are the container node's own payload — they are
|
|
/// arguments here rather than fields of a spec struct because that is all
|
|
/// they ever were. `declare` is the recipe, taken by generic and run
|
|
/// against a builder `hive_jobq` owns: it goes from the template straight
|
|
/// into this call, so there is nothing to allocate for.
|
|
///
|
|
/// # Errors
|
|
/// Propagates a graph-insert error (dependencies that aren't
|
|
/// dependency-topological).
|
|
pub fn submit(
|
|
&self,
|
|
source: Source,
|
|
reason: String,
|
|
declare: impl FnOnce(&JobBuilder),
|
|
) -> anyhow::Result<u64> {
|
|
let mut inner = self.lock();
|
|
let container = inner
|
|
.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);
|
|
self.notify.notify_one();
|
|
Ok(container.get())
|
|
}
|
|
|
|
/// The scheduler itself, for `hive_jobq`'s run-loop seam
|
|
/// (`Scheduler::claim_next`), which takes exactly this type.
|
|
///
|
|
/// Handing out the `Arc` rather than wrapping each crate call keeps the
|
|
/// host from growing a parallel API: the run loop uses `hive_jobq`'s
|
|
/// functions directly, and this module stays the thin glue it is being
|
|
/// reduced to.
|
|
pub(crate) fn sched(&self) -> &Arc<Mutex<Sched>> {
|
|
&self.sched
|
|
}
|
|
|
|
/// The DAG container id owning `node`, for log lines and the dashboard.
|
|
/// Derived from the graph rather than carried alongside the node — the
|
|
/// parent axis already knows it.
|
|
#[must_use]
|
|
pub fn dag_of(&self, node: NodeId) -> Option<u64> {
|
|
self.lock().graph().root_of(node).map(NodeId::get)
|
|
}
|
|
|
|
/// Cancel a DAG that hasn't started yet: every work node is still `Pending`,
|
|
/// so each is cancelled. `false` once any work node is running or terminal —
|
|
/// an in-flight nix build isn't interruptible.
|
|
///
|
|
/// **Nodes that explicitly observe cancellation are spared** — a node whose
|
|
/// edge names [`hive_jobq::TerminalState::Cancelled`] is asking to run when
|
|
/// the work it follows was dropped, which is exactly what an approval tail
|
|
/// needs: cancel the work, and the tail still fires to resolve the approval
|
|
/// row rather than leaving it dangling forever.
|
|
///
|
|
/// Nothing is special-cased by node kind. `AFTER_ANY` deliberately does *not*
|
|
/// accept `Cancelled`, so an ordinary weak-edged step (rebuild's `Reconcile`,
|
|
/// say) is cancelled along with everything else — there is nothing to converge
|
|
/// when no node ever ran. Only a node that named `Cancelled` survives, and it
|
|
/// survives because it asked to.
|
|
///
|
|
/// `id` names **any node**, not specifically a DAG. Cancelling a group root
|
|
/// drops that whole group (the cascade is the scheduler's), which is what
|
|
/// the dashboard's whole-DAG cancel does; cancelling an interior node drops
|
|
/// just that branch. Nothing here knows about DAGs.
|
|
pub fn cancel(&self, id: u64) -> bool {
|
|
let mut inner = self.lock();
|
|
let Some(node) = inner.graph().resolve_id(id) else {
|
|
return false;
|
|
};
|
|
if !inner.cancel_node(node) {
|
|
return false;
|
|
}
|
|
drop(inner);
|
|
self.notify.notify_one();
|
|
true
|
|
}
|
|
|
|
/// The first failed node's error in `dag_id`, if any has failed yet.
|
|
///
|
|
/// Unlike the roll-up summary this is readable *mid-flight*, which is the
|
|
/// point: a compensation node runs `AfterAny` its subject, so when it asks,
|
|
/// the DAG is still `Finishing` (the compensation node itself is running)
|
|
/// while the node it is compensating for has already settled `Failed`. That
|
|
/// lets the compensation annotate its bookkeeping with the reason the deploy
|
|
/// failed, instead of having the error handed down from the node that hit
|
|
/// it. `None` when nothing has failed — the ordinary success path.
|
|
#[must_use]
|
|
pub fn first_error(&self, dag_id: u64) -> Option<String> {
|
|
let inner = self.lock();
|
|
let node = find_node(&inner, dag_id)?;
|
|
inner.graph().first_error(node).map(ToOwned::to_owned)
|
|
}
|
|
|
|
/// `(agent, label, takes_container_down)` for the live transient-pill set,
|
|
/// recomputed from the nodes **actually running** — not from an intent a
|
|
/// template declared at submit time. (A rebuild used to report `rebuilding`
|
|
/// for its whole life: prebuild, stop, swap, tail and reconcile alike.)
|
|
///
|
|
/// **Status is the only test**: every `Running` node that names an agent is
|
|
/// in the set. Naming is targeting, not lease-holding — `Prebuild` /
|
|
/// `MetaSync` are lease-exempt (the container keeps serving through them)
|
|
/// but they *are* work on that agent, and the operator wants to see it.
|
|
///
|
|
/// ⚠️ **So there can be more than one entry per agent**, which is the whole
|
|
/// difference from the older lease-declaration test: lease-exemption is
|
|
/// exactly what lets one DAG build for `a` while another holds `a`'s lease,
|
|
/// so both are running and both name `a`. Anything keying this set by agent
|
|
/// alone will silently drop one — see [`super::scheduler`].
|
|
///
|
|
/// `label` is the node's own wire tag ([`NodeKind::as_str`]), the vocabulary
|
|
/// [`NodeView::kind`] already ships, so a pill and a DAG node name an
|
|
/// operation identically. `takes_container_down` is the crash watcher's
|
|
/// input and does **not** ride the wire to the frontend — a `Start` pill and
|
|
/// a `Stop` pill are both pills; only one means a vanished container is
|
|
/// expected.
|
|
///
|
|
/// Not the lease *owner* either: `resource_state()` answers "who holds the
|
|
/// slot", a different question.
|
|
#[must_use]
|
|
pub fn running_transients(&self) -> Vec<RunningTransient> {
|
|
let inner = self.lock();
|
|
inner
|
|
.graph()
|
|
.nodes()
|
|
.filter(|n| matches!(n.state, State::Running))
|
|
.filter_map(|n| {
|
|
// Status is the only test. The agent comes off the node's own
|
|
// payload, not off a declared `Resource::Agent` edge: the
|
|
// lease-exempt kinds (`Prebuild` / `MetaSync`) name an agent
|
|
// without declaring its lease, and they are work on that agent
|
|
// that the operator wants to see.
|
|
//
|
|
// Empty means an agentless container kind (`MetaLock`, `Dag`),
|
|
// which targets no agent and lights nothing.
|
|
let agent = n.payload.agent();
|
|
if agent.is_empty() {
|
|
return None;
|
|
}
|
|
Some(RunningTransient {
|
|
agent: agent.to_owned(),
|
|
label: n.payload.as_str().to_owned(),
|
|
takes_container_down: n.payload.takes_container_down(),
|
|
// `started_at` is set when a node enters `Running`, and this
|
|
// only sees `Running` nodes — the fallback is unreachable in
|
|
// practice, and "just now" is the honest answer if it isn't.
|
|
since: n.started_at.unwrap_or_else(Utc::now),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every node of every visible group, as generic graph nodes.
|
|
///
|
|
/// **Nothing is hidden.** Group roots ride as ordinary nodes (so a
|
|
/// consumer needs no special case for "the container" and reads the
|
|
/// root's own `state` as the group's answer), and `Done` nodes stay (so a
|
|
/// finished step is visible rather than vanishing from the payload, which
|
|
/// is what makes a fast rebuild render as a single node).
|
|
///
|
|
/// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies
|
|
/// is *which* groups to show — see [`visible_roots`] for why the graph
|
|
/// can't decide that for itself.
|
|
#[must_use]
|
|
pub fn graph_snapshot(&self) -> Vec<GraphNode> {
|
|
let inner = self.lock();
|
|
inner.graph().wire_snapshot(visible_roots(&inner))
|
|
}
|
|
|
|
/// Snapshot every live + retained DAG for `/api/state` + `RebuildQueueChanged`.
|
|
#[must_use]
|
|
pub fn snapshot(&self) -> Vec<DagView> {
|
|
let inner = self.lock();
|
|
let mut ids = visible_dags(&inner);
|
|
ids.sort_unstable_by_key(|c| c.get());
|
|
ids.into_iter()
|
|
.filter_map(|c| dag_view(&inner, c))
|
|
.collect()
|
|
}
|
|
|
|
/// One or more nodes plus their live subtrees, as generic wire nodes —
|
|
/// the `QueueNodes` polling surface behind `hivectl`'s wait/progress
|
|
/// loop. Sibling of [`Self::snapshot`] (which serves the same graph
|
|
/// through the typed `DagView`/`NodeView` projection for the
|
|
/// dashboard's `/api/state.rebuild_queue`), this one goes through
|
|
/// [`GraphWire::wire_snapshot`] instead — no `Done`-node filtering, no
|
|
/// roll-up field (a node's own `state` answers that, see
|
|
/// `hive_jobq_wire`'s doc comment). Looks each id up by identity
|
|
/// alone — no assumption that it names a DAG container or a root;
|
|
/// "just show whatever the backend sends" for whatever ids the caller
|
|
/// asks about. Multiple ids in one call is the normal shape for a
|
|
/// batch op (e.g. restarting every agent submits one root per agent) —
|
|
/// callers should request the whole batch together rather than poll
|
|
/// one id per round-trip.
|
|
///
|
|
/// An id with no matching node in the graph is silently dropped from
|
|
/// the result rather than erroring the whole batch — some ids in a
|
|
/// batch may already be evicted while others are still live. Today
|
|
/// that only happens for a genuinely unknown id: nothing prunes the
|
|
/// graph yet (bounded-prune is a Stage-C follow-up, see
|
|
/// [`visible_dags`]), so a *completed* DAG's nodes keep riding here
|
|
/// with a terminal `state` rather than disappearing — callers
|
|
/// watching for "done" should read the root's `state`, not absence.
|
|
#[must_use]
|
|
pub fn node_subtrees(&self, ids: &[u64]) -> Vec<GraphNode> {
|
|
let inner = self.lock();
|
|
let roots: Vec<NodeId> = ids.iter().filter_map(|id| find_node(&inner, *id)).collect();
|
|
inner.graph().wire_snapshot(roots)
|
|
}
|
|
}
|
|
|
|
/// The graph node whose id equals `id`, whatever its kind or depth.
|
|
/// `NodeId` is un-fabricable from a raw `u64`, so this is a search.
|
|
fn find_node(sched: &Sched, id: u64) -> Option<NodeId> {
|
|
sched
|
|
.graph()
|
|
.nodes()
|
|
.find_map(|n| (n.id.get() == id).then_some(n.id))
|
|
}
|
|
|
|
/// Project a DAG into its wire [`DagView`]: a near-raw view of the
|
|
/// container's work nodes, with `Done` nodes excluded. Lifecycle
|
|
/// (`state` / `started_at` / `finished_at` / `error`) is read straight
|
|
/// off each `hive_jobq::Node`; the client derives the DAG label, roll-up
|
|
/// state, and DAG timestamps from the node set. Non-derivable per-node
|
|
/// payload (`approval_id`, meta `inputs`) rides the owning node. Returns
|
|
/// `None` when every work node is `Done` or `Skipped` — a fully-settled
|
|
/// DAG drops out of the snapshot entirely (a `Failed` one lingers until
|
|
/// aged out).
|
|
fn dag_view(sched: &Sched, container: NodeId) -> Option<DagView> {
|
|
// 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.
|
|
//
|
|
// `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();
|
|
// DAG-level timestamps are taken over *all* subtree nodes (including the
|
|
// `Done` ones excluded from the wire) — the client can't derive them
|
|
// from a `Done`-filtered node set, so the host computes them here.
|
|
let mut started: Vec<DateTime<Utc>> = Vec::new();
|
|
let mut finished: Vec<DateTime<Utc>> = Vec::new();
|
|
for node in &all {
|
|
if let Some(s) = node.started_at {
|
|
started.push(s);
|
|
}
|
|
if let Some(f) = node.finished_at {
|
|
finished.push(f);
|
|
}
|
|
}
|
|
// Decide which nodes ride the wire *before* projecting any of them: a
|
|
// `NodeView` costs a `build_logs` lookup, so building one for a node
|
|
// that's about to be dropped would be a query per finished step.
|
|
let shown = shown_on_wire(&all.iter().map(|n| n.state).collect::<Vec<_>>())?;
|
|
let mut nodes = Vec::new();
|
|
for node in shown.into_iter().map(|i| all[i]) {
|
|
let id = node.id;
|
|
let deps: Vec<u64> = node
|
|
.deps
|
|
.iter()
|
|
.filter_map(|d| match d {
|
|
Dep::Node { id, .. } => Some(id.get()),
|
|
Dep::Resource { .. } => None,
|
|
})
|
|
.collect();
|
|
// Non-derivable per-node payload rides the node that owns it. Every
|
|
// deploy phase carries the approval id, but only the subtree root
|
|
// projects it onto the wire — hanging the approval link off all of
|
|
// them would render the same card once per phase.
|
|
let approval_id = match &node.payload {
|
|
NodeKind::DeployWindow { approval_id, .. } => Some(*approval_id),
|
|
_ => None,
|
|
};
|
|
let inputs = match &node.payload {
|
|
NodeKind::MetaLock { inputs, .. } => inputs.clone(),
|
|
_ => Vec::new(),
|
|
};
|
|
// Looked up from the log row itself (`build_logs.node_id`), not a
|
|
// host-side map. One indexed query per node in the snapshot; the
|
|
// node set is bounded by `MAX_HISTORY_DAGS` and the store is a
|
|
// local sqlite file, so this is cheaper than the lock contention
|
|
// a second shared map would reintroduce.
|
|
let build_log_id = crate::build_logs::global().and_then(|h| h.id_for_node(id.get()));
|
|
// `node.parent` is the structural jobq parent. Top-level nodes
|
|
// have `parent == Some(container)` (direct children of the Dag
|
|
// container); those become `parent: None` on the wire since the
|
|
// container itself is not part of the work-node payload. Sub-nodes
|
|
// carry the id of their containing parent work-node.
|
|
let parent = node
|
|
.parent
|
|
.filter(|&p| p != container)
|
|
.map(hive_jobq::NodeId::get);
|
|
nodes.push(NodeView {
|
|
id: id.get(),
|
|
agent: node.payload.agent().to_owned(),
|
|
kind: node.payload.as_str().to_owned(),
|
|
deps,
|
|
state: node.state,
|
|
started_at: node.started_at,
|
|
finished_at: node.finished_at,
|
|
error: node.error.clone(),
|
|
approval_id,
|
|
inputs,
|
|
build_log_id,
|
|
parent,
|
|
});
|
|
}
|
|
let is_terminal = sched.graph().is_settled(container) == Some(true);
|
|
Some(DagView {
|
|
id: container.get(),
|
|
source: *source,
|
|
reason: reason.clone(),
|
|
created_at: node.created_at,
|
|
started_at: started.into_iter().min(),
|
|
finished_at: is_terminal.then(|| finished.into_iter().max()).flatten(),
|
|
nodes,
|
|
})
|
|
}
|
|
|
|
/// Which of a DAG's work nodes ride the wire, by index into `states` — or
|
|
/// `None` when the DAG has nothing left worth showing and drops out of the
|
|
/// snapshot entirely.
|
|
///
|
|
/// Two separate decisions, and conflating them pins every completed deploy in
|
|
/// the queue view forever:
|
|
/// - **`Done` drops off the wire.** A finished step isn't interesting.
|
|
/// `Skipped` stays: which branch a run *didn't* take is the readable half of
|
|
/// an outcome-branched DAG.
|
|
/// - **`Skipped` alone doesn't hold a DAG in the snapshot.** So "the node list
|
|
/// is non-empty" and "there's still something here worth showing" are
|
|
/// different questions, and only the second one may drop the DAG.
|
|
///
|
|
/// Takes states rather than projected nodes so the caller can skip the work of
|
|
/// projecting what it's about to discard, and so this is testable without a
|
|
/// graph — the states it keys on are ones only a run can produce.
|
|
fn shown_on_wire(states: &[State]) -> Option<Vec<usize>> {
|
|
let worth_showing = states
|
|
.iter()
|
|
.any(|s| !matches!(s, State::Done | State::Skipped));
|
|
if !worth_showing {
|
|
return None;
|
|
}
|
|
Some(
|
|
states
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, s)| !matches!(s, State::Done))
|
|
.map(|(i, _)| i)
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// When a DAG's work node finishes on `finished_at` — the max over its
|
|
/// subtree (read off the graph `Node`, as unix seconds), for the history
|
|
/// cap ordering.
|
|
fn dag_finished_at(sched: &Sched, container: NodeId) -> i64 {
|
|
sched
|
|
.graph()
|
|
.descendants(container)
|
|
.filter_map(|n| n.finished_at)
|
|
.map(|t| t.timestamp())
|
|
.max()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Every DAG container node id in the graph.
|
|
fn containers(sched: &Sched) -> Vec<NodeId> {
|
|
sched
|
|
.graph()
|
|
.nodes()
|
|
.filter(|n| n.parent.is_none() && matches!(n.payload, NodeKind::Dag { .. }))
|
|
.map(|n| n.id)
|
|
.collect()
|
|
}
|
|
|
|
/// The **visible** DAG set for the snapshot: every live (non-terminal) DAG,
|
|
/// plus the newest [`MAX_HISTORY_DAGS`] terminal ones. Crate nodes for
|
|
/// evicted DAGs linger in the graph (bounded-prune is a Stage-C follow-up);
|
|
/// this filter is what bounds what the dashboard sees.
|
|
fn visible_dags(sched: &Sched) -> Vec<NodeId> {
|
|
let mut live: Vec<NodeId> = Vec::new();
|
|
let mut terminal: Vec<(NodeId, i64, u64)> = Vec::new();
|
|
for c in containers(sched) {
|
|
if sched.graph().is_settled(c) == Some(true) {
|
|
terminal.push((c, dag_finished_at(sched, c), c.get()));
|
|
} else {
|
|
live.push(c);
|
|
}
|
|
}
|
|
retain_history(live, terminal, MAX_HISTORY_DAGS)
|
|
}
|
|
|
|
/// The visible **group** set for [`Queue::graph_snapshot`]: every live group
|
|
/// root, plus the newest [`MAX_HISTORY_DAGS`] settled ones.
|
|
///
|
|
/// Same policy as [`visible_dags`], selected *structurally* — a root is a node
|
|
/// with no parent. The `DagView` path next door keys on `NodeKind::Dag`
|
|
/// instead, which is fine for a projection that already only means anything to
|
|
/// hive-c0re, but would make the generic endpoint depend on one node kind that
|
|
/// is itself slated for removal.
|
|
///
|
|
/// **This bound is load-bearing, not tidiness.** Nothing ever removes a node
|
|
/// from the graph (bounded pruning is a Stage-C follow-up), so serving
|
|
/// `graph.roots()` directly would grow the payload without limit for the whole
|
|
/// uptime of the daemon.
|
|
fn visible_roots(sched: &Sched) -> Vec<NodeId> {
|
|
let roots: Vec<NodeId> = sched.graph().roots().map(|n| n.id).collect();
|
|
let mut live: Vec<NodeId> = Vec::new();
|
|
let mut terminal: Vec<(NodeId, i64, u64)> = Vec::new();
|
|
for root in roots {
|
|
if sched.graph().is_settled(root) == Some(true) {
|
|
terminal.push((root, group_finished_at(sched, root), root.get()));
|
|
} else {
|
|
live.push(root);
|
|
}
|
|
}
|
|
retain_history(live, terminal, MAX_HISTORY_DAGS)
|
|
}
|
|
|
|
/// When a whole group last finished: the newest `finished_at` across the root
|
|
/// **and** its descendants. Unlike [`dag_finished_at`] the root itself counts,
|
|
/// because a generic group root can be an ordinary node with no children at
|
|
/// all — reading only descendants would date every such group to the epoch and
|
|
/// evict it first.
|
|
fn group_finished_at(sched: &Sched, root: NodeId) -> i64 {
|
|
sched
|
|
.graph()
|
|
.node(root)
|
|
.and_then(|n| n.finished_at)
|
|
.into_iter()
|
|
.chain(
|
|
sched
|
|
.graph()
|
|
.descendants(root)
|
|
.filter_map(|n| n.finished_at),
|
|
)
|
|
.map(|t| t.timestamp())
|
|
.max()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// [`visible_dags`]'s policy, split from the graph it reads: keep every live
|
|
/// DAG, plus the newest `cap` terminal ones.
|
|
///
|
|
/// `terminal` rows are `(handle, finished_at, tiebreak)`. The tiebreak orders
|
|
/// DAGs that settled inside the same wall-clock second — which is *most* of
|
|
/// them under a burst, and all of them in a test, so it is load-bearing rather
|
|
/// than a formality.
|
|
///
|
|
/// Generic over the handle purely so this is reachable without a graph: a
|
|
/// `NodeId` cannot be fabricated, so a test that had to pass real ones could
|
|
/// only get them by submitting and running DAGs.
|
|
fn retain_history<T>(live: Vec<T>, mut terminal: Vec<(T, i64, u64)>, cap: usize) -> Vec<T> {
|
|
// Newest first, so truncating to the cap keeps the most recent.
|
|
terminal.sort_by(|a, b| b.1.cmp(&a.1).then(b.2.cmp(&a.2)));
|
|
terminal.truncate(cap);
|
|
let mut kept = live;
|
|
kept.extend(terminal.into_iter().map(|(handle, _, _)| handle));
|
|
kept
|
|
}
|
|
|
|
/// Truncate a node error to [`MAX_ERROR_LEN`] on a char boundary, appending `…`.
|
|
fn truncate_error(e: &str) -> String {
|
|
if e.len() <= MAX_ERROR_LEN {
|
|
return e.to_owned();
|
|
}
|
|
let cut = (0..=MAX_ERROR_LEN)
|
|
.rev()
|
|
.find(|i| e.is_char_boundary(*i))
|
|
.unwrap_or(0);
|
|
let mut msg = e[..cut].to_owned();
|
|
msg.push('…');
|
|
msg
|
|
}
|