rebuild_queue: per-entry step label + worker phase annotations (#437)
This commit is contained in:
parent
ac4c8456e1
commit
a286ae777c
4 changed files with 159 additions and 7 deletions
|
|
@ -189,6 +189,17 @@ pub struct QueueEntry {
|
|||
/// the wire that way too.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approval_id: Option<i64>,
|
||||
/// Current sub-step inside the running entry (#437, option A from
|
||||
/// the issue). Worker mutates this as the kind-specific pipeline
|
||||
/// advances through phases (e.g. `"plant tags"` →
|
||||
/// `"nixos-container update"` → `"finalize deploy"`). `None` while
|
||||
/// `Queued` and after terminal — only meaningful with
|
||||
/// `state == Running`. Each transition fires a fresh
|
||||
/// `RebuildQueueChanged` snapshot so the dashboard can render
|
||||
/// the label as a sub-line on the queue card. Free-form per
|
||||
/// pipeline; the kind-specific worker is the source of truth.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub step: Option<String>,
|
||||
}
|
||||
|
||||
/// How many terminal-state entries (`Done` / `Failed` / `Cancelled`)
|
||||
|
|
@ -330,6 +341,7 @@ impl RebuildQueue {
|
|||
error: None,
|
||||
inputs,
|
||||
approval_id,
|
||||
step: None,
|
||||
};
|
||||
inner.entries.push_back(entry);
|
||||
// Wake the worker. `notify_one` is a no-op when there's no
|
||||
|
|
@ -356,6 +368,9 @@ impl RebuildQueue {
|
|||
|
||||
/// Mark an entry terminal. `error` is populated for `Failed`;
|
||||
/// `Done` / `Cancelled` ignore it. Trims the history tail.
|
||||
/// Clears `step` — the field is only meaningful while `Running`,
|
||||
/// and leaving a stale "in flight" label after a terminal
|
||||
/// transition would mislead the dashboard render.
|
||||
pub fn finish(&self, id: u64, state: QueueState, error: Option<String>) {
|
||||
debug_assert!(state.is_terminal(), "finish() called with non-terminal {state:?}");
|
||||
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
|
||||
|
|
@ -363,10 +378,33 @@ impl RebuildQueue {
|
|||
entry.state = state;
|
||||
entry.finished_at = Some(now_unix());
|
||||
entry.error = error.filter(|_| state == QueueState::Failed);
|
||||
entry.step = None;
|
||||
}
|
||||
Self::trim_history(&mut inner);
|
||||
}
|
||||
|
||||
/// Set the current sub-step label on a `Running` entry (#437).
|
||||
/// Returns `true` when the row was found AND the label changed
|
||||
/// (caller should emit a `RebuildQueueChanged` snapshot only on
|
||||
/// `true` to avoid noisy duplicate frames). No-op for entries not
|
||||
/// in `Running` — the field is conceptually undefined outside
|
||||
/// that state.
|
||||
pub fn set_step(&self, id: u64, step: impl Into<String>) -> bool {
|
||||
let new_step = step.into();
|
||||
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
|
||||
let Some(entry) = inner.entries.iter_mut().find(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
if entry.state != QueueState::Running {
|
||||
return false;
|
||||
}
|
||||
if entry.step.as_deref() == Some(new_step.as_str()) {
|
||||
return false;
|
||||
}
|
||||
entry.step = Some(new_step);
|
||||
true
|
||||
}
|
||||
|
||||
/// Snapshot the queue for `/api/state` and `RebuildQueueChanged`.
|
||||
/// Cheap clone — entries are small (~hundreds of bytes each).
|
||||
pub fn snapshot(&self) -> Vec<QueueEntry> {
|
||||
|
|
@ -515,19 +553,19 @@ async fn dispatch(
|
|||
) -> anyhow::Result<()> {
|
||||
match (entry.kind, entry.approval_id) {
|
||||
(QueueKind::Rebuild, Some(approval_id)) => {
|
||||
crate::actions::run_approval_apply_commit(coord, approval_id).await
|
||||
crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await
|
||||
}
|
||||
(QueueKind::Rebuild, None) => {
|
||||
let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake)
|
||||
.unwrap_or_default();
|
||||
crate::auto_update::rebuild_agent(coord, &entry.agent, ¤t_rev).await
|
||||
crate::auto_update::rebuild_agent(coord, &entry.agent, ¤t_rev, Some(entry.id)).await
|
||||
}
|
||||
(QueueKind::MetaUpdate, Some(approval_id)) => {
|
||||
crate::actions::run_approval_update_meta_inputs(coord, approval_id).await
|
||||
crate::actions::run_approval_update_meta_inputs(coord, Some(entry.id), approval_id).await
|
||||
}
|
||||
(QueueKind::MetaUpdate, None) => run_meta_update(coord, entry).await,
|
||||
(QueueKind::Spawn, Some(approval_id)) => {
|
||||
crate::actions::run_approval_spawn(coord, approval_id).await
|
||||
crate::actions::run_approval_spawn(coord, Some(entry.id), approval_id).await
|
||||
}
|
||||
(QueueKind::Spawn, None) => {
|
||||
// No non-approval Spawn caller today. The variant exists so
|
||||
|
|
@ -561,6 +599,7 @@ async fn run_meta_update(
|
|||
let _progress = coord.meta_update_guard();
|
||||
let inputs = entry.inputs.clone();
|
||||
tracing::info!(?inputs, parent = entry.id, "rebuild_queue: meta-update starting");
|
||||
coord.set_queue_step(Some(entry.id), "nix flake update");
|
||||
let result = if inputs.is_empty() {
|
||||
crate::meta::lock_update(&[]).await
|
||||
} else {
|
||||
|
|
@ -1054,4 +1093,69 @@ mod tests {
|
|||
assert_eq!(find(done).state, QueueState::Done);
|
||||
assert_eq!(find(queued).state, QueueState::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_step_updates_running_entry_and_signals_change() {
|
||||
let q = RebuildQueue::new();
|
||||
let id = q.enqueue(
|
||||
QueueKind::Rebuild,
|
||||
"a".to_owned(),
|
||||
QueueSource::Manual,
|
||||
"test".to_owned(),
|
||||
None,
|
||||
);
|
||||
// Queued — set_step should refuse (returns false).
|
||||
assert!(!q.set_step(id, "plant tags"));
|
||||
// Promote to Running.
|
||||
let entry = q.take_next().expect("queued entry");
|
||||
assert_eq!(entry.id, id);
|
||||
// First label transition — true.
|
||||
assert!(q.set_step(id, "plant tags"));
|
||||
assert_eq!(
|
||||
q.snapshot()
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.and_then(|e| e.step.as_deref()),
|
||||
Some("plant tags")
|
||||
);
|
||||
// Same label again — false (caller can skip the snapshot emit).
|
||||
assert!(!q.set_step(id, "plant tags"));
|
||||
// Different label — true.
|
||||
assert!(q.set_step(id, "nixos-container update"));
|
||||
assert_eq!(
|
||||
q.snapshot()
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.and_then(|e| e.step.as_deref()),
|
||||
Some("nixos-container update")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_step_no_op_on_unknown_id() {
|
||||
let q = RebuildQueue::new();
|
||||
assert!(!q.set_step(999, "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_clears_step() {
|
||||
let q = RebuildQueue::new();
|
||||
let id = q.enqueue(
|
||||
QueueKind::Rebuild,
|
||||
"a".to_owned(),
|
||||
QueueSource::Manual,
|
||||
"test".to_owned(),
|
||||
None,
|
||||
);
|
||||
q.take_next();
|
||||
assert!(q.set_step(id, "running phase"));
|
||||
q.finish(id, QueueState::Done, None);
|
||||
assert_eq!(
|
||||
q.snapshot()
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.and_then(|e| e.step.as_deref()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue