swarm-queue-based lifecycle notices, replacing push_todo(MANAGER_AGENT)
This commit is contained in:
parent
10a294a2f8
commit
e44ea9d8d4
12 changed files with 388 additions and 142 deletions
|
|
@ -326,16 +326,13 @@ async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str, purge: b
|
|||
if let Err(e) = coord.power.remove(agent) {
|
||||
tracing::warn!(%agent, error = ?e, "agent_power: remove on destroy failed");
|
||||
}
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("destroyed:{agent}")),
|
||||
format!("agent '{agent}' destroyed"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
crate::swarm_notices::notify(
|
||||
"core",
|
||||
Some(format!("destroyed:{agent}")),
|
||||
format!("agent '{agent}' destroyed"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// Container row disappeared — rescan so the dashboard fires
|
||||
// `ContainerRemoved` for the gone row, then emit the tombstones snapshot
|
||||
// (gained one on destroy, lost one on purge — recompute either way).
|
||||
|
|
@ -358,16 +355,7 @@ async fn run_emit_rebuilt(coord: &Arc<Coordinator>, agent: &str, dag_id: Option<
|
|||
.then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag)))
|
||||
.flatten();
|
||||
let summary = crate::coordinator::rebuilt_todo_summary(agent, ok, note.as_deref(), None, None);
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("rebuilt:{agent}")),
|
||||
summary,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
crate::swarm_notices::notify("core", Some(format!("rebuilt:{agent}")), summary, None).await;
|
||||
}
|
||||
|
||||
/// Write the agent's durable power intent — the DAG-node form of the old
|
||||
|
|
@ -622,16 +610,13 @@ async fn run_stop(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
|
|||
// own kind now.
|
||||
crate::lifecycle::kill(name).await?;
|
||||
coord.unregister_agent(name);
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("killed:{name}")),
|
||||
format!("agent '{name}' killed"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
crate::swarm_notices::notify(
|
||||
"core",
|
||||
Some(format!("killed:{name}")),
|
||||
format!("agent '{name}' killed"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
coord.rescan_containers_and_emit().await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ mod snapshot_push;
|
|||
mod socket_server;
|
||||
mod stats;
|
||||
mod stores;
|
||||
mod swarm_notices;
|
||||
mod swarm_queue;
|
||||
mod swarm_status;
|
||||
#[cfg(test)]
|
||||
mod test_env;
|
||||
|
|
|
|||
|
|
@ -296,32 +296,26 @@ async fn handle_spawn(coord: &Arc<Coordinator>, name: &str) -> Result<HostRespon
|
|||
// Bind the MCP listener now that the container is starting up.
|
||||
// The harness connects to this socket on its first turn.
|
||||
coord.register_agent(name)?;
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("spawned:{name}")),
|
||||
format!("agent '{name}' spawned"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
crate::swarm_notices::notify(
|
||||
"core",
|
||||
Some(format!("spawned:{name}")),
|
||||
format!("agent '{name}' spawned"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// Update tmpfiles.d so the new agent's dirs survive a reboot.
|
||||
tokio::spawn(lifecycle::sync_tmpfiles());
|
||||
}
|
||||
Err(e) => {
|
||||
// Spawn failed: register_agent was never called, so there is
|
||||
// nothing to unregister. Notify the manager and propagate.
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("spawned:{name}")),
|
||||
format!("agent '{name}' spawn FAILED: {e:#}"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
// nothing to unregister. Notify the swarm and propagate.
|
||||
crate::swarm_notices::notify(
|
||||
"core",
|
||||
Some(format!("spawned:{name}")),
|
||||
format!("agent '{name}' spawn FAILED: {e:#}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ async fn handle_restart_infra(
|
|||
}
|
||||
}
|
||||
|
||||
/// `Kill` — kill a container, unregister it, notify the manager. The caller
|
||||
/// `Kill` — kill a container, unregister it, notify the swarm. The caller
|
||||
/// must be an ancestor of `name` in the topology.
|
||||
pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
|
||||
if let Some(err) = require_descendant(agent, name, "kill") {
|
||||
|
|
@ -138,16 +138,13 @@ pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &st
|
|||
.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("killed:{name}")),
|
||||
format!("agent '{name}' killed"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
crate::swarm_notices::notify(
|
||||
"core",
|
||||
Some(format!("killed:{name}")),
|
||||
format!("agent '{name}' killed"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Response::Ok
|
||||
}
|
||||
Err(e) => Response::Err {
|
||||
|
|
|
|||
140
hive-c0re/src/swarm_notices.rs
Normal file
140
hive-c0re/src/swarm_notices.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
//! Publishing lifecycle notices onto the swarm queue.
|
||||
//!
|
||||
//! Replaces the old `push_todo(MANAGER_AGENT, ...)` fallback the
|
||||
//! lifecycle-notice call sites used to reach for when there was nobody
|
||||
//! else to tell. Every hive is swarm-controlled now, so there is no case
|
||||
//! left that needs a manager-agent recipient — this module has no such
|
||||
//! fallback, on purpose, not by omission.
|
||||
//!
|
||||
//! **Why a stream and not the [`crate::swarm_status`] KV bucket shape**:
|
||||
//! a status snapshot has a current value a late reader can always ask
|
||||
//! for; a lifecycle notice ("container crashed at 04:12") does not — miss
|
||||
//! it and there is nothing left to read later that says it happened. See
|
||||
//! [`swarm_queue_client::notices`] for the stream this publishes into.
|
||||
//!
|
||||
//! **Best-effort, never fatal to the caller.** A hive with no queue
|
||||
//! configured is a silent no-op (the ordinary case). A hive whose queue
|
||||
//! is unreachable loses the swarm's visibility of the notice, not the
|
||||
//! host's — `warn!` fires on every failed attempt regardless, and the
|
||||
//! dashboard banners only after [`FAILURES_BEFORE_BANNER`] consecutive
|
||||
//! misses, the same debounce shape [`crate::swarm_status`] uses and for
|
||||
//! the same reason: a `warn!` that fires every call for three weeks is
|
||||
//! indistinguishable from silence in practice.
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use tokio::sync::{Mutex, OnceCell};
|
||||
|
||||
use crate::stats::sweep_health::{self, SweepHealth};
|
||||
|
||||
/// Consecutive failed publishes before the dashboard banners — same
|
||||
/// value [`crate::swarm_status`] uses and for the same reason: a debounce
|
||||
/// against one blip flapping a banner an operator learns to ignore.
|
||||
const FAILURES_BEFORE_BANNER: u32 = 3;
|
||||
|
||||
static HEALTH: OnceCell<Mutex<SweepHealth>> = OnceCell::const_new();
|
||||
|
||||
async fn health() -> tokio::sync::MutexGuard<'static, SweepHealth> {
|
||||
HEALTH
|
||||
.get_or_init(|| async {
|
||||
Mutex::new(SweepHealth::new(
|
||||
"swarm_notices_publish",
|
||||
"warn",
|
||||
FAILURES_BEFORE_BANNER,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.lock()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Publish one lifecycle notice for this hive.
|
||||
///
|
||||
/// `subsystem`/`key`/`summary`/`source` carry the same meaning and the
|
||||
/// same owned-`String` shape they did as `push_todo` arguments — a
|
||||
/// drop-in replacement for that call, minus the recipient (there is
|
||||
/// none) and `reopen_if_acked` (an inbox-todo concept with no equivalent
|
||||
/// on an append-only stream).
|
||||
pub async fn notify(subsystem: &str, key: Option<String>, summary: String, source: Option<String>) {
|
||||
let Some(client) = crate::swarm_queue::client().await else {
|
||||
return;
|
||||
};
|
||||
// Same absent-name condition `swarm_status` bails on — that module
|
||||
// already banners it under `swarm_status_config` the first time
|
||||
// either of us hits it; nothing more to add from here.
|
||||
let Some(hive) = crate::container_view::hive_swarm_names().0 else {
|
||||
return;
|
||||
};
|
||||
|
||||
match publish(
|
||||
&client,
|
||||
&hive,
|
||||
subsystem,
|
||||
key.as_deref(),
|
||||
&summary,
|
||||
source.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => health().await.record_ok(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, subsystem, summary, "swarm notice: publish failed");
|
||||
let err = format!("{e:#}");
|
||||
health().await.record_err(|ctx| {
|
||||
let age = ctx.since_last_ok.map_or_else(
|
||||
|| "no success this session".to_owned(),
|
||||
|d| format!("last ok {} ago", sweep_health::fmt_age(d)),
|
||||
);
|
||||
format!(
|
||||
"swarm notice publishing is failing ({} consecutive, {age}) \
|
||||
— notices are being lost, not just delayed: {err}",
|
||||
ctx.consecutive
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Notice<'a> {
|
||||
subsystem: &'a str,
|
||||
key: Option<&'a str>,
|
||||
summary: &'a str,
|
||||
source: Option<&'a str>,
|
||||
}
|
||||
|
||||
async fn publish(
|
||||
client: &async_nats::Client,
|
||||
hive: &str,
|
||||
subsystem: &str,
|
||||
key: Option<&str>,
|
||||
summary: &str,
|
||||
source: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// An unconnected client does not fail a JetStream request, it hangs
|
||||
// on it — see `ensure_connected`'s own doc comment for why this has
|
||||
// to run before every such request, not just the first one.
|
||||
swarm_queue_client::ensure_connected(client)?;
|
||||
|
||||
// Ensures the stream exists; the handle itself is unused below —
|
||||
// `Context::publish` routes by subject, it does not need the
|
||||
// `Stream` object in hand.
|
||||
swarm_queue_client::notices::open_or_create(client)
|
||||
.await
|
||||
.context("opening the notices stream")?;
|
||||
|
||||
let payload = serde_json::to_vec(&Notice {
|
||||
subsystem,
|
||||
key,
|
||||
summary,
|
||||
source,
|
||||
})
|
||||
.context("serialising the notice")?;
|
||||
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
js.publish(swarm_queue_client::notices::subject(hive), payload.into())
|
||||
.await
|
||||
.context("publishing the notice")?
|
||||
.await
|
||||
.context("awaiting the notice's ack")?;
|
||||
Ok(())
|
||||
}
|
||||
72
hive-c0re/src/swarm_queue.rs
Normal file
72
hive-c0re/src/swarm_queue.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//! One swarm-queue connection, shared by every consumer in this process.
|
||||
//!
|
||||
//! [`swarm_status`](crate::swarm_status) and
|
||||
//! [`swarm_notices`](crate::swarm_notices) both need the swarm queue, and
|
||||
//! both authenticate as the *same* identity (`hive-<name>`, minted for
|
||||
//! this hive — see `swarm-authelia.nix`). Two independent `connect()`
|
||||
//! calls would be two token mints and two live connections for one
|
||||
//! identity, not two different credentials — the same shape that turned
|
||||
//! `swarm-controller`'s own connect into the shared `swarm-queue-client`
|
||||
//! crate in the first place, one layer up. This module is that same move
|
||||
//! made again, this time between two consumers *inside* one process.
|
||||
//!
|
||||
//! Connects lazily on first use rather than at boot — nothing here
|
||||
//! blocks `hive-c0re` starting up on hosts with no queue configured,
|
||||
//! which is the ordinary case.
|
||||
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
/// Env var prefix for this daemon's swarm-queue credentials — see
|
||||
/// [`swarm_queue_client::QueueConfig::from_env`]. All four or none.
|
||||
const ENV_PREFIX: &str = "HIVE_C0RE";
|
||||
|
||||
static CLIENT: OnceCell<Option<async_nats::Client>> = OnceCell::const_new();
|
||||
|
||||
/// The shared swarm-queue client, connecting on first call and memoized
|
||||
/// for the rest of the process's life.
|
||||
///
|
||||
/// `None` covers both "no queue configured" (the ordinary case, logged
|
||||
/// once at `info`) and "config present but connecting failed" (bannered
|
||||
/// once via [`crate::warnings::set_boot_warning`] the first time this is
|
||||
/// called) — either way, a caller with `None` should just skip whatever
|
||||
/// it was about to publish. No caller needs to distinguish the two: both
|
||||
/// mean "this hive is not offering anything to the swarm right now."
|
||||
pub async fn client() -> Option<async_nats::Client> {
|
||||
CLIENT.get_or_init(connect_once).await.clone()
|
||||
}
|
||||
|
||||
async fn connect_once() -> Option<async_nats::Client> {
|
||||
let cfg = match swarm_queue_client::QueueConfig::from_env(ENV_PREFIX) {
|
||||
Ok(Some(cfg)) => cfg,
|
||||
Ok(None) => {
|
||||
tracing::info!("no swarm queue configured; this hive offers nothing upward");
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
// A one-shot startup step with no later retry to clear it —
|
||||
// exactly what `set_boot_warning` is for. `chain`, not
|
||||
// `{:#}`: this is `swarm_queue_client::Error`, whose
|
||||
// `Display` ignores the alternate flag (see `chain`'s own
|
||||
// doc comment), so `{:#}` would drop which env vars are
|
||||
// actually missing.
|
||||
crate::warnings::set_boot_warning(
|
||||
"swarm_queue_config",
|
||||
"warn",
|
||||
format!("swarm queue is off: {}", swarm_queue_client::chain(&e)),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match swarm_queue_client::connect(cfg).await {
|
||||
Ok(client) => Some(client),
|
||||
Err(e) => {
|
||||
crate::warnings::set_boot_warning(
|
||||
"swarm_queue_config",
|
||||
"warn",
|
||||
format!("swarm queue is off: {}", swarm_queue_client::chain(&e)),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,10 +47,6 @@ use crate::stats::sweep_health::{self, SweepHealth};
|
|||
/// other silently re-tunes the swarm's definition of "quiet".
|
||||
pub const PUBLISH_INTERVAL: Duration = Duration::from_mins(1);
|
||||
|
||||
/// Env var prefix for this daemon's swarm-queue credentials — see
|
||||
/// [`swarm_queue_client::QueueConfig::from_env`]. All four or none.
|
||||
const ENV_PREFIX: &str = "HIVE_C0RE";
|
||||
|
||||
/// Consecutive failed publishes before the dashboard banners.
|
||||
///
|
||||
/// At [`PUBLISH_INTERVAL`] this is ~3 minutes of genuine failure, so a
|
||||
|
|
@ -59,43 +55,15 @@ const FAILURES_BEFORE_BANNER: u32 = 3;
|
|||
|
||||
/// Start the publish loop, if this deployment wired up a swarm queue.
|
||||
///
|
||||
/// Absent queue config is the ordinary case — most hives are not in a
|
||||
/// swarm — so it is an `info` and not a warning. A *half*-set environment
|
||||
/// is a different thing entirely and [`swarm_queue_client::QueueConfig::from_env`]
|
||||
/// makes it a hard error; it is bannered here rather than swallowed,
|
||||
/// because the failure it otherwise produces is a hive that looks fine
|
||||
/// and silently never reports.
|
||||
/// The connect itself is shared with every other swarm-queue consumer in
|
||||
/// this process — see [`crate::swarm_queue`] for why one connection and
|
||||
/// not one per consumer, and for where "no queue configured" vs. "queue
|
||||
/// configured but unreachable" gets bannered. This function only decides
|
||||
/// whether *status* has anything to offer once a client exists.
|
||||
pub fn spawn(
|
||||
coord: std::sync::Arc<crate::coordinator::Coordinator>,
|
||||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let cfg = match swarm_queue_client::QueueConfig::from_env(ENV_PREFIX) {
|
||||
Ok(Some(cfg)) => cfg,
|
||||
Ok(None) => {
|
||||
tracing::info!("no swarm queue configured; this hive offers no status upward");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
// A one-shot startup step with no later retry to clear it —
|
||||
// exactly what `set_boot_warning` is for. The fix is a
|
||||
// redeploy, which restarts this process anyway.
|
||||
//
|
||||
// `chain`, not `{:#}`: this is the queue client's own error
|
||||
// type, whose Display ignores the alternate flag, so `{:#}`
|
||||
// would show only "swarm queue is half-configured" and drop
|
||||
// which variables are missing.
|
||||
crate::warnings::set_boot_warning(
|
||||
"swarm_status_config",
|
||||
"warn",
|
||||
format!(
|
||||
"swarm status publishing is off: {}",
|
||||
swarm_queue_client::chain(&e)
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(hive) = crate::container_view::hive_swarm_names().0 else {
|
||||
crate::warnings::set_boot_warning(
|
||||
"swarm_status_config",
|
||||
|
|
@ -107,26 +75,11 @@ pub fn spawn(
|
|||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
// `retry_on_initial_connect` inside, so this returns a client
|
||||
// that may not be connected yet rather than failing on a queue
|
||||
// that comes up second. The publish below is what discovers that,
|
||||
// and it is already the thing that reports it.
|
||||
let client = match swarm_queue_client::connect(cfg).await {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
// `chain` for the same reason as above: without it this
|
||||
// banner reads "connecting to the swarm queue at <url>"
|
||||
// and drops the nats error that says why.
|
||||
crate::warnings::set_boot_warning(
|
||||
"swarm_status_config",
|
||||
"warn",
|
||||
format!(
|
||||
"swarm status publishing is off: {}",
|
||||
swarm_queue_client::chain(&e)
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(client) = crate::swarm_queue::client().await else {
|
||||
// Absent or failed — either way already handled (an `info`
|
||||
// log or a `swarm_queue_config` banner) by the shared
|
||||
// connector; nothing left to report here.
|
||||
return;
|
||||
};
|
||||
|
||||
// The hive's ONE queue connection, now serving both directions:
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ pub fn spawn(coord: Arc<Coordinator>) {
|
|||
if seeded {
|
||||
emit_crash_transitions(&coord, &prev_running, ¤t_running);
|
||||
emit_login_transitions(
|
||||
&coord,
|
||||
&prev_logged_in,
|
||||
¤t_logged_in,
|
||||
&sub_agents,
|
||||
|
|
@ -128,7 +127,6 @@ fn is_deliberate_stop(active: Option<bool>, recently_cleared: Option<bool>) -> b
|
|||
}
|
||||
|
||||
async fn emit_login_transitions(
|
||||
coord: &Coordinator,
|
||||
prev: &HashSet<String>,
|
||||
current: &HashSet<String>,
|
||||
sub_agents: &[String],
|
||||
|
|
@ -136,16 +134,13 @@ async fn emit_login_transitions(
|
|||
) {
|
||||
for agent in current.difference(prev) {
|
||||
tracing::info!(%agent, "agent logged in");
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("logged_in:{agent}")),
|
||||
format!("agent '{agent}' logged in"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
crate::swarm_notices::notify(
|
||||
"core",
|
||||
Some(format!("logged_in:{agent}")),
|
||||
format!("agent '{agent}' logged in"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// Detect transitions into "needs login": an agent that was previously
|
||||
// logged-in goes unsigned (credentials deleted), OR a brand-new agent
|
||||
|
|
@ -168,16 +163,13 @@ async fn emit_login_transitions(
|
|||
.collect();
|
||||
for agent in current_needs.difference(&prev_needs) {
|
||||
tracing::info!(%agent, "agent needs login");
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("needs_login:{agent}")),
|
||||
format!("agent '{agent}' needs login"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
crate::swarm_notices::notify(
|
||||
"core",
|
||||
Some(format!("needs_login:{agent}")),
|
||||
format!("agent '{agent}' needs login"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue