refactor(#2949): the build-log row carries its node id

`QueueInner` was `{ sched, node_rt }`, where `node_rt` held exactly one
datum per node: the `build_logs` row id. It existed because a `hive_jobq`
node payload is immutable after insert while the log row is created when
the build starts — so the link could not ride the node.

Invert it: the log row names its node (`build_logs.node_id`, one migration
in the existing `schema_versions` framework). Same single-home property,
in the direction the type system allows.

`QueueInner` is now just the scheduler. That is the point: the queue holds
no per-node side map, so nothing has to be locked alongside the graph.

Deleted as a consequence, each surfaced by dead-code analysis after the
edit above rather than predicted:

- `NodeRuntime`, `node_rt`, `set_build_log_id`, and `build_log_id_of`
  (which linear-scanned the map to match a wire `u64` against opaque
  `NodeId`s). The lookup is an indexed query now.
- `struct Ctx`, entirely. It carried `coord` + `dag_id` + `node_id` into
  the executors so the build-log callback could reach the queue; without
  the callback, `coord`/`dag_id` were never read and `node_id` was already
  on the `Claim` both executors receive.
- `QueueInner::node_running`, which existed only for `set_build_log_id`'s
  "only while running" guard.
- The `Fn(i64)` callbacks on `prebuild_toplevel` / `swap_update` /
  `priv_run_inner`, replaced by a `node_id: Option<u64>` passed down. The
  id travels one way now instead of being registered back.

`meta.rs`'s `nix_logged` passes `None` deliberately: its callers reach it
from outside the queue as well as inside, and nothing reads the link for
them yet.

`id_for_node` takes `MAX(id)` rather than assuming uniqueness — a retried
node opens a second row and the panel wants the current attempt. The test
moved to where the behaviour lives and covers that, plus survival across
completion and non-collision with node-less rows.
This commit is contained in:
atlas 2026-08-02 18:26:32 +02:00 committed by mara
commit 77cc7bea6b
7 changed files with 150 additions and 162 deletions

View file

@ -156,7 +156,7 @@ pub(super) async fn get_build_log_for_node(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<u64>,
) -> Response {
match state.coord.job_queue.build_log_id_of(node_id) {
match state.coord.build_logs.id_for_node(node_id) {
Some(log_id) => get_build_log_full(State(state), AxumPath(log_id)).await,
None => (
StatusCode::NOT_FOUND,
@ -183,7 +183,7 @@ pub(super) async fn get_build_log_raw_for_node(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<u64>,
) -> Response {
match state.coord.job_queue.build_log_id_of(node_id) {
match state.coord.build_logs.id_for_node(node_id) {
Some(log_id) => get_build_log_raw(State(state), AxumPath(log_id)).await,
None => (
StatusCode::NOT_FOUND,

View file

@ -26,25 +26,6 @@ use crate::power::{ReconcileAction, reconcile_action};
/// N × this timeout.
pub const GRACEFUL_STOP_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(3);
/// Build-log sink for one claimed node.
struct Ctx<'a> {
coord: &'a Arc<Coordinator>,
dag_id: u64,
node_id: super::NodeId,
}
impl Ctx<'_> {
fn build_log(&self, log_id: i64) {
if self
.coord
.job_queue
.set_build_log_id(self.dag_id, self.node_id, log_id)
{
self.coord.emit_rebuild_queue_snapshot();
}
}
}
/// Run one claimed node to completion. Called from a task the
/// scheduler spawns per claim; the `Result` (stringified) becomes the
/// node's terminal state.
@ -67,19 +48,14 @@ pub(super) async fn run_node(
job: super::Job,
claim: &Claim,
) -> (super::Job, Result<()>) {
let ctx = Ctx {
coord,
dag_id: claim.dag_id,
node_id: claim.node_id,
};
// Every arm is `Result<()>`; the three that grow work declare into `job`
// *synchronously*, after their own awaits have finished. Borrowing `&job`
// inside an `.await` would make this future non-`Send` (see above), so the
// growth executors return what to grow rather than taking the builder.
let result = match &claim.kind {
NodeKind::MetaSync { relock, .. } => run_meta_sync(coord, claim, *relock).await,
NodeKind::Prebuild { .. } => run_prebuild(claim, &ctx).await,
NodeKind::Swap { .. } => run_swap(coord, claim, &ctx).await,
NodeKind::Prebuild { .. } => run_prebuild(claim).await,
NodeKind::Swap { .. } => run_swap(coord, claim).await,
NodeKind::PostSwap { .. } => run_post_swap(coord, claim).await,
NodeKind::Provision { .. } => run_provision(coord, claim).await,
NodeKind::Create { .. } => run_create(claim).await,
@ -241,7 +217,7 @@ async fn run_meta_sync(coord: &Arc<Coordinator>, claim: &Claim, relock: bool) ->
/// container is already down: its only purpose is to shrink the swap's
/// downtime window, so a stopped agent (no uptime to preserve) doesn't
/// pay the double eval — `Swap` builds inline instead.
async fn run_prebuild(claim: &Claim, ctx: &Ctx<'_>) -> Result<()> {
async fn run_prebuild(claim: &Claim) -> Result<()> {
let name = &claim.agent;
// Warm the toplevel build only when the container is up — the whole
// point of prebuild is to shrink the swap's downtime window. A
@ -249,8 +225,7 @@ async fn run_prebuild(claim: &Claim, ctx: &Ctx<'_>) -> Result<()> {
// eval and let the downstream `Swap` build inline.
if crate::lifecycle::is_running(name).await {
let flake_ref = format!("{}#{name}", crate::paths::meta_root().display());
crate::lifecycle::prebuild_toplevel(name, &flake_ref, &|log_id| ctx.build_log(log_id))
.await?;
crate::lifecycle::prebuild_toplevel(name, &flake_ref, Some(claim.node_id.get())).await?;
}
Ok(())
}
@ -260,17 +235,15 @@ async fn run_prebuild(claim: &Claim, ctx: &Ctx<'_>) -> Result<()> {
/// (rev marker, `Rebuilt` event, forge/matrix sync, kick, rescan).
/// The recovery-start on failure is NOT here — the DAG's tail
/// `Reconcile` runs after this node terminal ok *or* fail.
async fn run_swap(coord: &Arc<Coordinator>, claim: &Claim, ctx: &Ctx<'_>) -> Result<()> {
async fn run_swap(coord: &Arc<Coordinator>, claim: &Claim) -> Result<()> {
let name = &claim.agent;
// Swap runs on an already-existing (stopped) container — runtime dir
// and listener were created earlier. Pure path accessor suffices.
let agent_dir = crate::paths::agent_runtime_dir(name);
let hive = coord.hive_env();
let paths = Coordinator::agent_paths(name, agent_dir);
let result = crate::lifecycle::swap_update(name, &hive, &paths, &|log_id| {
ctx.build_log(log_id);
})
.await;
let result =
crate::lifecycle::swap_update(name, &hive, &paths, Some(claim.node_id.get())).await;
// On success the Ok-only bookkeeping tail (rev marker, forge/matrix
// sync, kick, rescan, snapshot) runs in the sibling `PostSwap` node,
// which deps `AfterOk(Swap)`. On failure `PostSwap` is cancel-cascaded

View file

@ -36,7 +36,6 @@ pub mod templates;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::sync::Mutex;
use chrono::{DateTime, Utc};
@ -101,15 +100,6 @@ pub struct Claim {
pub agent: String,
}
/// Per-node runtime metadata the crate graph doesn't carry. Lifecycle
/// (`started_at` / `finished_at` / `error`) lives on the `hive_jobq::Node`
/// itself now, so only the build-log row link remains host-side (the
/// client fetches the log by node id).
#[derive(Debug, Default, Clone)]
struct NodeRuntime {
build_log_id: Option<i64>,
}
/// An owned read-view of a DAG container's carried metadata ([`NodeKind::Dag`]).
/// Derived on read from the container node — the data has a single home (the
/// node payload); this is not a stored side-table.
@ -119,18 +109,21 @@ struct DagMeta {
created_at: DateTime<Utc>,
}
/// The mutable queue state behind the mutex: the crate scheduler plus the
/// per-node runtime metadata the graph can't carry. 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 ([`QueueInner::container`] / [`QueueInner::dag_meta`] +
/// the `hive_jobq::Graph` accessors). One shared crate [`Graph`] holds every DAG.
/// The mutable queue state behind the mutex: **just the crate scheduler**.
/// 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 ([`QueueInner::container`] /
/// [`QueueInner::dag_meta`] + the `hive_jobq::Graph` accessors). One shared
/// crate [`Graph`] holds every DAG.
///
/// There is deliberately **no per-node side map** any more. The last one held
/// the `build_logs` row id; that link now lives on the log row itself
/// (`build_logs.node_id`), so it survives a restart and needs no lock held
/// alongside the scheduler's — which is what lets the scheduler's own lock be
/// the only one the run loop takes.
struct QueueInner {
sched: Scheduler<NodeKind, Resource>,
/// Per-node runtime metadata (the build-log id) — mutable after
/// insert, so it can't ride the immutable node payload.
node_rt: HashMap<NodeId, NodeRuntime>,
}
/// The queue. Lives on `Coordinator` (one per hive-c0re process); a single
@ -208,7 +201,6 @@ impl JobQueue {
Self {
inner: Mutex::new(QueueInner {
sched: Scheduler::new(Graph::new(), table),
node_rt: HashMap::new(),
}),
notify: Notify::new(),
}
@ -244,7 +236,6 @@ impl JobQueue {
None,
)
.map_err(|e| anyhow::anyhow!("job_queue: container insert failed: {e}"))?;
inner.node_rt.insert(container, NodeRuntime::default());
insert_group(&mut inner, spec.declare, Some(container))?;
// Settle the container's own (no-op) logic immediately so it parks in
// `Finishing` and its children become runnable — it never needs claiming
@ -374,33 +365,6 @@ impl JobQueue {
true
}
/// Link a `build_logs` row to a specific `Running` node.
pub fn set_build_log_id(&self, dag_id: u64, node_id: NodeId, log_id: i64) -> bool {
let mut inner = self.lock();
if inner.sched.graph().root_of(node_id).map(NodeId::get) != Some(dag_id)
|| !inner.node_running(node_id)
{
return false;
}
inner.node_rt.entry(node_id).or_default().build_log_id = Some(log_id);
true
}
/// The `build_logs` row id linked to the wire node id `node_id`, if any —
/// the lookup behind the `GET /api/build-log/<node_id>` query endpoint (the
/// client fetches a node's captured build output on demand rather than
/// receiving it inline). Takes the raw wire `u64` (the endpoint's path
/// param); `node_rt` is keyed by the opaque `NodeId`, so this scans for the
/// matching id — the map is small (live + recently-terminal nodes).
#[must_use]
pub fn build_log_id_of(&self, node_id: u64) -> Option<i64> {
self.lock()
.node_rt
.iter()
.find(|(nid, _)| nid.get() == node_id)
.and_then(|(_, rt)| rt.build_log_id)
}
/// 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
@ -498,14 +462,6 @@ impl JobQueue {
}
impl QueueInner {
/// Whether `id` is a `Running` node.
fn node_running(&self, id: NodeId) -> bool {
self.sched
.graph()
.node(id)
.is_some_and(|n| n.state == State::Running)
}
/// 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(&self, dag_id: u64) -> Option<NodeId> {
@ -593,7 +549,12 @@ impl QueueInner {
NodeKind::MetaLock { inputs, .. } => inputs.clone(),
_ => Vec::new(),
};
let build_log_id = self.node_rt.get(&id).and_then(|r| r.build_log_id);
// 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

View file

@ -1544,27 +1544,12 @@ fn deploy_dag_skips_apply_but_still_runs_tail_when_verify_fails() {
assert_eq!(state_of(&q, id), State::Failed);
}
// ---- build logs, history ----
#[test]
fn set_build_log_id_links_running_node() {
let q = JobQueue::new(1);
let id = submit(&q, rebuild("agent-a", "r"));
let c = claim_one(&q);
assert!(q.set_build_log_id(id, c.node_id, 42));
q.complete_node(c.node_id, Ok(()));
assert!(
!q.set_build_log_id(id, c.node_id, 99),
"node no longer running → refused"
);
// The log id is fetched by node id (the `GET /api/build-log/<id>` lookup),
// not carried on the wire — it survives completion in the node runtime.
assert_eq!(
q.build_log_id_of(c.node_id.get()),
Some(42),
"log id survives completion"
);
}
// ---- history ----
//
// The node → build-log link is no longer queue state: the log row carries
// `node_id` and the lookup lives in `stores::build_logs` (see
// `node_link_survives_completion_and_newest_wins` there). Nothing in the queue
// needs testing for it any more, which is the point of that move.
/// History retention is a **flat** newest-first cap over all terminal DAGs
/// (`MAX_HISTORY_DAGS`), not a per-template bucket behind a grace window.

View file

@ -274,10 +274,10 @@ pub async fn swap_update(
name: &str,
hive: &HiveEnv,
paths: &AgentPaths,
on_build_log_id: &(dyn Fn(i64) + Send + Sync),
node_id: Option<u64>,
) -> Result<()> {
write_dropins(name, hive, paths).await?;
priv_run_inner("update", name, Some(on_build_log_id)).await
priv_run_inner("update", name, node_id).await
}
/// Build the `AgentSpec` list for the meta flake from `nixos-container
@ -582,14 +582,10 @@ pub async fn destroy(name: &str) -> Result<()> {
/// the prebuild happens before stop, and `docs/coordinator.md::Prebuild
/// attr path` for why the explicit nixosConfigurations attr is required.
///
/// `on_build_log_id` fires with the `build_logs` row id as soon as the
/// row opens, so queue-side callers can link their node to the live
/// stream. Pass `&|_| ()` when not needed.
pub async fn prebuild_toplevel(
name: &str,
flake_ref: &str,
on_build_log_id: &(dyn Fn(i64) + Send + Sync),
) -> Result<()> {
/// `node_id` is the queue node this build belongs to, when there is one —
/// it is stored on the `build_logs` row so the dashboard can find the log
/// from the node. Pass `None` for builds that run outside the queue.
pub async fn prebuild_toplevel(name: &str, flake_ref: &str, node_id: Option<u64>) -> Result<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
// Split `<root>#<name>` so we can re-emit with the explicit
// `nixosConfigurations.<name>` segment. The flake_ref shape is
@ -624,15 +620,12 @@ pub async fn prebuild_toplevel(
// into the row; `finish` lands the terminal status before we bail.
let logs = crate::build_logs::global();
let log_id = logs.as_ref().and_then(|h| {
h.start(name, "prebuild", &cmdline)
h.start(name, "prebuild", &cmdline, node_id)
.map_err(|e| {
tracing::warn!(error = ?e, "build_logs: start failed (prebuild log dropped)");
})
.ok()
});
if let Some(id) = log_id {
on_build_log_id(id);
}
let mut child = Command::new("nix")
.args(&args)
@ -784,37 +777,25 @@ async fn priv_run(kind: &str, name: &str) -> Result<()> {
priv_run_inner(kind, name, None).await
}
/// Like `priv_run` but calls `on_log_id(log_id)` immediately after the
/// build-log row is opened — before the actual container op starts.
/// This lets callers surface the row id for live streaming (e.g. the
/// rebuild-queue worker sets `build_log_id` on the queue entry so the
/// dashboard can link to `/api/build-logs/id/{id}/stream`).
/// Like `priv_run` but stamps `node_id` onto the build-log row it opens, so
/// the dashboard can find the log from the queue node (and link to
/// `/api/build-logs/id/{id}/stream`).
///
/// The callback fires only when a build-log row is successfully opened
/// (i.e. the global `BuildLogs` handle is installed AND `h.start()`
/// succeeds). No-op when `on_log_id` is `None` — that's the path for
/// all callers that don't need the id.
async fn priv_run_inner(
kind: &str,
name: &str,
on_log_id: Option<&(dyn Fn(i64) + Send + Sync)>,
) -> Result<()> {
/// This used to be a `Fn(i64)` callback that handed the row id *back* to the
/// queue, which then held it in a side map. The row carries the link itself
/// now, so the id only ever travels one way.
async fn priv_run_inner(kind: &str, name: &str, node_id: Option<u64>) -> Result<()> {
let container = container_name(name);
let cmdline = format!("nixos-container {kind} {container}");
let logs = crate::build_logs::global();
let log_id = logs.as_ref().and_then(|h| {
h.start(name, kind, &cmdline)
h.start(name, kind, &cmdline, node_id)
.map_err(|e| {
tracing::warn!(error = ?e, "build_logs: start failed (priv_run log dropped)");
})
.ok()
});
// Notify the caller as soon as the log row exists so it can surface
// the id for live streaming before the container op even starts.
if let (Some(id), Some(cb)) = (log_id, on_log_id) {
cb(id);
}
// For long-running ops use the streaming protocol so build_logs
// receives lines in real time rather than as a batch at completion.

View file

@ -1590,7 +1590,12 @@ async fn nix_logged(dir: &Path, args: &[&str], agent: &str, kind: &str) -> Resul
let cmdline = format!("nix {}", nix_argv(args).join(" "));
let logs = crate::build_logs::global();
let log_id = logs.as_ref().and_then(|h| {
h.start(agent, kind, &cmdline)
// No node id: `nix_logged`'s two callers are meta-flake operations
// reached from outside the queue as well as from inside it, and the
// agent+kind+time listing is how they're surfaced today. Linking them
// to a node would mean threading the id through `meta`'s public API
// for no current reader — worth doing when something wants it.
h.start(agent, kind, &cmdline, None)
.map_err(|e| {
tracing::warn!(error = ?e, %kind, "build_logs: start failed (meta log dropped)");
})

View file

@ -13,6 +13,8 @@ use serde::Serialize;
use tokio::sync::broadcast;
use utoipa::ToSchema;
use crate::db::Migration;
/// Process-singleton handle, set once at coordinator startup. Lets
/// the `lifecycle` module's `run` / `prebuild_toplevel` access the
/// writer without threading an `Arc<BuildLogs>` through every
@ -65,6 +67,27 @@ CREATE INDEX IF NOT EXISTS idx_build_logs_status_finished
WHERE finished_at IS NOT NULL;
";
/// Ordered schema migrations tracked in `schema_versions` (key `"build_logs"`).
///
/// v1 makes the log row carry its node, replacing the host-side
/// `NodeId -> build_log_id` map the job queue used to hold. The link has a
/// single home again, and the direction is the one the type system allows:
/// a `hive_jobq` node payload is immutable after insert, but the log row is
/// written when the build starts and can name the node it belongs to.
///
/// Legacy rows keep `node_id IS NULL` — they predate the column and no node
/// still exists to link them to, so the dashboard's by-node lookup simply
/// misses them (the by-agent listing, which is how they're reached, is
/// unaffected).
const MIGRATIONS: &[Migration] = &[Migration {
sql: "BEGIN;
ALTER TABLE build_logs ADD COLUMN node_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_build_logs_node
ON build_logs (node_id) WHERE node_id IS NOT NULL;
COMMIT;",
adds_column: Some(("build_logs", "node_id")),
}];
/// Status of a finished build attempt. Stored as the literal string in
/// the `status` column; `NULL` while the attempt is still in progress.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@ -152,6 +175,7 @@ impl BuildLogs {
let conn = crate::db::open(&path, "build_logs")?;
conn.execute_batch(SCHEMA)
.context("apply build_logs schema")?;
crate::db::apply_versioned_migrations(&conn, "build_logs", MIGRATIONS)?;
let (notify_tx, _) = broadcast::channel(NOTIFY_CAP);
Ok(Self {
conn: Mutex::new(conn),
@ -169,17 +193,49 @@ impl BuildLogs {
/// Open a row for a new build attempt. Returns the assigned id
/// — the caller threads it through `append_stdout` / `append_stderr`
/// while the child runs and into `finish` once it exits.
pub fn start(&self, agent: &str, kind: &str, cmdline: &str) -> Result<i64> {
///
/// `node_id` is the queue node this build belongs to, when there is one.
/// It is `None` for builds that run outside the job queue; those are
/// reachable by agent + time, just not by node.
pub fn start(
&self,
agent: &str,
kind: &str,
cmdline: &str,
node_id: Option<u64>,
) -> Result<i64> {
let now = Utc::now().timestamp();
let node_id = node_id.and_then(|n| i64::try_from(n).ok());
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO build_logs (agent, kind, cmdline, started_at) VALUES (?1, ?2, ?3, ?4)",
params![agent, kind, cmdline, now],
"INSERT INTO build_logs (agent, kind, cmdline, started_at, node_id) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![agent, kind, cmdline, now, node_id],
)
.context("insert build_logs row")?;
Ok(conn.last_insert_rowid())
}
/// The most recent build-log row for `node_id`, if any. Replaces the job
/// queue's in-memory `NodeId -> build_log_id` side map: the link lives in
/// the row itself now, so it survives a restart and needs no lock held
/// alongside the scheduler's.
///
/// `MAX(id)` rather than a uniqueness assumption — a node that is retried
/// opens a second row, and the newest is the one the panel should show.
#[must_use]
pub fn id_for_node(&self, node_id: u64) -> Option<i64> {
let node_id = i64::try_from(node_id).ok()?;
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT MAX(id) FROM build_logs WHERE node_id = ?1",
params![node_id],
|row| row.get::<_, Option<i64>>(0),
)
.ok()
.flatten()
}
/// Append a single stdout line. Best-effort: errors are logged
/// but never returned to the caller, so a transient sqlite blip
/// never tears down a rebuild's stdout pump.
@ -453,7 +509,7 @@ mod tests {
fn start_appends_finish_flow() {
let (_d, db) = tmpdb();
let id = db
.start("alice", "prebuild", "nix build foo")
.start("alice", "prebuild", "nix build foo", None)
.expect("start");
db.append_stdout(id, "building '/nix/store/abc.drv'");
db.append_stderr(id, "error: line 12");
@ -482,9 +538,9 @@ mod tests {
// assert id-ordering (autoincrement) is the tiebreaker — list
// sorts by started_at DESC but the ORDER BY still produces the
// last-inserted row first when timestamps match.
let id_a1 = db.start("alice", "run", "cmd one").expect("start");
let _id_b = db.start("bob", "run", "cmd two").expect("start");
let id_a2 = db.start("alice", "run", "cmd three").expect("start");
let id_a1 = db.start("alice", "run", "cmd one", None).expect("start");
let _id_b = db.start("bob", "run", "cmd two", None).expect("start");
let id_a2 = db.start("alice", "run", "cmd three", None).expect("start");
db.finish(id_a1, BuildStatus::Ok);
let alice_rows = db.list_recent_for_agent("alice", 10).expect("list");
@ -515,10 +571,12 @@ mod tests {
#[test]
fn vacuum_drops_old_finished_only_per_status() {
let (_d, db) = tmpdb();
let id_fresh_fail = db.start("alice", "run", "fresh fail").expect("start");
let id_old_fail = db.start("alice", "run", "old fail").expect("start");
let id_old_ok = db.start("alice", "run", "old ok").expect("start");
let id_running = db.start("alice", "run", "still running").expect("start");
let id_fresh_fail = db.start("alice", "run", "fresh fail", None).expect("start");
let id_old_fail = db.start("alice", "run", "old fail", None).expect("start");
let id_old_ok = db.start("alice", "run", "old ok", None).expect("start");
let id_running = db
.start("alice", "run", "still running", None)
.expect("start");
db.finish(id_fresh_fail, BuildStatus::Fail);
db.finish(id_old_fail, BuildStatus::Fail);
db.finish(id_old_ok, BuildStatus::Ok);
@ -550,6 +608,31 @@ mod tests {
assert!(db.get_full(id_running).unwrap().is_some());
}
#[test]
fn node_link_survives_completion_and_newest_wins() {
// The queue used to hold this link in an in-memory side map, which
// meant it died with the process and needed the queue lock to read.
// On the row it outlives both the node's completion and a restart.
let (_d, db) = tmpdb();
let first = db.start("alice", "swap", "cmd", Some(7)).expect("start");
db.finish(first, BuildStatus::Fail);
assert_eq!(
db.id_for_node(7),
Some(first),
"link survives the build finishing"
);
// A retried node opens a second row; the panel wants the current
// attempt, not the first one.
let retry = db.start("alice", "swap", "cmd", Some(7)).expect("start");
assert_eq!(db.id_for_node(7), Some(retry), "newest attempt wins");
// Builds that run outside the queue carry no node and are found by
// agent + time instead — they must not collide with node lookups.
db.start("alice", "run", "no node", None).expect("start");
assert_eq!(db.id_for_node(999), None, "unknown node → no row");
}
#[test]
fn append_after_finish_still_appends() {
// Defensive: if a child's stdout pump fires one last line
@ -557,7 +640,7 @@ mod tests {
// append should land on the row (status already set, but the
// log stays consistent with what happened).
let (_d, db) = tmpdb();
let id = db.start("alice", "run", "cmd").expect("start");
let id = db.start("alice", "run", "cmd", None).expect("start");
db.finish(id, BuildStatus::Ok);
db.append_stdout(id, "post-finish trailing line");
let full = db.get_full(id).expect("get").expect("Some");