DagMeta was three fields copied out of the NodeKind::Dag payload and read back out in one place; its own docstring conceded the data's single home is the payload. dag_view now destructures the payload directly. The borrow stays immutable alongside the existing descendants() borrow, so nothing needed cloning beyond the reason String the DagView field already required. 315 tests pass unchanged.
653 lines
28 KiB
Rust
653 lines
28 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 Job = 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(&Job),
|
|
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(&Job),
|
|
) -> anyhow::Result<u64> {
|
|
let mut inner = self.lock();
|
|
let container = inner
|
|
.append(
|
|
NodeKind::Dag {
|
|
source,
|
|
reason,
|
|
created_at: Utc::now(),
|
|
},
|
|
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 container = container(&inner, dag_id)?;
|
|
inner.graph().first_error(container).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.)
|
|
///
|
|
/// A node lights a pill when it is `Running` **and declares the agent's
|
|
/// resource itself**. Declaring is the test, not targeting — `Prebuild` /
|
|
/// `MetaSync` name an agent but are lease-exempt on purpose, since the
|
|
/// container keeps serving through them. Nor is it the lease *owner*:
|
|
/// `resource_state()` answers "who holds the slot", a different question.
|
|
///
|
|
/// `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, carried rather than inferred from the label — a `Start` pill and a
|
|
/// `Stop` pill are both pills; only one means a vanished container is
|
|
/// expected.
|
|
///
|
|
/// Read off the node's **declared** resource edges, not off its kind. Those
|
|
/// are the same thing now that every construction site states what it holds,
|
|
/// and the distinction is the whole point: `Start` / `Stop` / `PostSwap` run
|
|
/// inside a lease-holding ancestor, and while the declaration was derived
|
|
/// from the kind they re-declared nothing and lit no pill. Asking the node
|
|
/// what it holds cannot go stale that way. An agent's lease is cap-1, so at
|
|
/// most one entry per agent.
|
|
#[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| {
|
|
let agent = n.deps.iter().find_map(|dep| match dep {
|
|
hive_jobq::Dep::Resource {
|
|
name: Resource::Agent(a),
|
|
..
|
|
} => Some(a.clone()),
|
|
_ => None,
|
|
})?;
|
|
Some(RunningTransient {
|
|
agent,
|
|
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()
|
|
}
|
|
}
|
|
|
|
/// The container node of `dag_id` — the `NodeKind::Dag` root whose id equals
|
|
/// `dag_id`. `NodeId` is un-fabricable from a raw `u64`, so this is a search.
|
|
fn container(sched: &Sched, dag_id: u64) -> Option<NodeId> {
|
|
sched.graph().nodes().find_map(|n| {
|
|
(n.parent.is_none() && n.id.get() == dag_id && matches!(n.payload, NodeKind::Dag { .. }))
|
|
.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 three 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 {
|
|
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: *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
|
|
}
|