hyperhive/swarm-controller/src/status.rs
atlas 3273971328 refactor(swarm-queue-client): typed errors for the bucket and the guard
Finishes the anyhow removal for the parts this branch adds: the status
bucket's open-or-create and the connected-client precondition. Two
variants, one of them behind the `kv` feature because the error type it
wraps does not exist without it — the error enum respects the same gate
the module does.

NotConnected is deliberately distinct from Connect: one is a connect that
was attempted and refused, the other is a request made before any
connection exists. The first is a deployment problem and the second is a
caller-ordering one, which is the whole reason a caller wants an enum
rather than a string.

The controller's `store` now returns the queue client's error rather than
an anyhow one: `OnceCell::get_or_try_init` takes its error type from the
closure, so widening there would mean converting inside the closure for
no gain. `view` `?`s it and anyhow converts at that boundary — the
library keeps a typed error, the binary keeps anyhow, and no call site
pays for the split.
2026-08-16 13:14:03 +02:00

513 lines
19 KiB
Rust

//! Swarm-wide view of what each hive last said about itself.
//!
//! Hives **offer** a snapshot upward; this daemon never reaches down to
//! collect one. That direction is the design, not an implementation
//! detail: the hive gateway has gone down in a way where every recovery
//! channel ran through the one broken thing, so a status path that
//! depended on the controller would have gone dark exactly when it was
//! needed to diagnose the controller's own network. A hive computes its
//! status locally regardless of whether the swarm can be reached.
//!
//! **The queue is the store.** A hive publishes into a `JetStream` KV
//! bucket, which retains the last value per key; this daemon reads that
//! bucket per request and keeps no copy. A cache here would be a second
//! answer to the same question, free to disagree with the first — and
//! the disagreement would surface as a hive reading healthy on a
//! dashboard while the bucket says otherwise.
//!
//! **Absence is the case this is built around** — the freshness states
//! and the reasoning behind each are in `docs/swarm/README.md`. The two
//! properties that constrain the code rather than describe it:
//! freshness is **derived at read time**, never stored (a stored
//! `healthy: bool` goes stale silently the moment nothing arrives), and
//! rows come from the **roster**, not the bucket, so an empty bucket
//! cannot render as a healthy swarm.
//!
//! One consequence worth stating because it is the opposite of what a
//! cache would give: losing the bucket degrades **to honesty**. Every
//! hive reads `never_reported` until its next publish, which is the true
//! answer — not a remembered "healthy" from before the loss.
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use futures_util::TryStreamExt as _;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::HiveEntry;
/// Default age past which a snapshot is reported stale.
///
/// A threshold is a statement about how often hives offer, and that
/// cadence is decided by the publisher (a later slice), so this is a
/// default to be overridden rather than a constant to be relied on.
pub const DEFAULT_STALE_AFTER: Duration = Duration::from_mins(2);
/// Env var the NixOS module sets from
/// `services.hyperhive.swarm.controller.staleAfterSeconds`.
pub const STALE_AFTER_ENV: &str = "SWARM_CONTROLLER_STALE_AFTER_SECS";
/// How a hive's last report reads *now* — a function of the clock, not a
/// property of the report.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Freshness {
/// Reported within the staleness threshold.
Fresh,
/// Reported, but longer ago than the threshold. The payload is still
/// rendered: "old" and "absent" are different answers and a consumer
/// may want the last thing a hive managed to say.
Stale,
/// In the roster, has never offered a snapshot. Distinct from
/// `Stale` because it separates "went quiet" from "never spoke" —
/// the first is a fault, the second is usually a deployment that
/// hasn't happened yet.
NeverReported,
/// Offered a snapshot but is not in the roster. Not an error this
/// daemon can resolve, and not one it should hide.
Unknown,
}
/// One row of the aggregate.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct HiveStatus {
pub name: String,
/// From the roster; `None` for a hive the roster doesn't list.
pub domain: Option<String>,
pub freshness: Freshness,
/// When the snapshot was stored, unix seconds. `None` when nothing
/// has been.
///
/// This is the **bucket's** stamp, applied by the NATS server when
/// the value landed — not a field inside the payload. A publisher
/// therefore cannot make itself look fresher than it is, and a hive
/// with a wrong clock skews its own payload rather than this.
pub last_seen_unix: Option<i64>,
/// Age at render time. Carried alongside `last_seen_unix` so a
/// consumer with a different threshold need not re-derive it from a
/// clock that may not match this host's.
pub age_seconds: Option<u64>,
/// Whatever the hive published, unopened — stored opaquely so the
/// snapshot's contents stay settleable later without reworking the
/// aggregate.
///
/// `None` in two cases the consumer can tell apart by `freshness`:
/// nothing has ever been published (`never_reported`), or something
/// was published that is not JSON (any other freshness — the row
/// still reports *when* the hive last spoke, and the read logs a
/// warning naming it).
pub snapshot: Option<serde_json::Value>,
}
/// A snapshot as retained by the bucket.
#[derive(Clone, Debug)]
struct Offered {
received_at: SystemTime,
/// `None` when the stored bytes are not JSON — see [`HiveStatus::snapshot`].
payload: Option<serde_json::Value>,
}
/// Reads the aggregate out of the KV bucket.
///
/// Holds a NATS client rather than a bucket handle: the bucket is
/// resolved on first use and cached, so a controller that starts before
/// the bucket exists picks it up without a restart. Resolution failures
/// are not cached — [`tokio::sync::OnceCell::get_or_try_init`] retries —
/// which is what makes the queue coming up *after* this daemon a
/// non-event rather than a permanent degradation.
pub struct StatusReader {
client: async_nats::Client,
store: tokio::sync::OnceCell<async_nats::jetstream::kv::Store>,
stale_after: Duration,
}
impl StatusReader {
#[must_use]
pub fn new(client: async_nats::Client, stale_after: Duration) -> Self {
Self {
client,
store: tokio::sync::OnceCell::new(),
stale_after,
}
}
/// Reads [`STALE_AFTER_ENV`], falling back to
/// [`DEFAULT_STALE_AFTER`]. A zero or unparseable value takes the
/// default rather than failing startup — same rule as `load_hives`:
/// a controller whose own config is wrong must still serve.
#[must_use]
pub fn stale_after_from_env() -> Duration {
std::env::var(STALE_AFTER_ENV)
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.filter(|secs| *secs > 0)
.map_or(DEFAULT_STALE_AFTER, Duration::from_secs)
}
/// The bucket handle, created on first use if nothing has made it yet.
///
/// The name and the creation config come from
/// [`swarm_queue_client::status`] rather than from here: the hive that
/// writes this bucket opens it with the same call, and a bucket both
/// ends may create is one both ends have to describe identically.
///
/// Whichever side arrives first creates it, and both sides ask for the
/// same shape, so this is a race with one outcome.
///
/// Returns the queue client's own error rather than an `anyhow::Error`:
/// `OnceCell::get_or_try_init` takes its error type from the closure,
/// so widening here would mean converting *inside* the closure for no
/// gain. `view` below `?`s it and anyhow converts there — which is the
/// whole point of the library keeping a typed error while the binary
/// keeps anyhow.
async fn store(
&self,
) -> std::result::Result<&async_nats::jetstream::kv::Store, swarm_queue_client::Error> {
self.store
.get_or_try_init(|| swarm_queue_client::status::open_or_create(&self.client))
.await
}
/// The aggregate, rendered against `now`.
///
/// Every roster hive produces a row whether or not it has ever
/// reported; a reporting hive outside the roster produces one too.
pub async fn view(&self, roster: &[HiveEntry], now: SystemTime) -> Result<Vec<HiveStatus>> {
// Before anything JetStream: an unconnected client does not fail a
// request, it hangs on it. The rule and the measurement behind it live
// in `swarm_queue_client::ensure_connected` — every consumer of the
// queue needs it, so it is not this daemon's to keep.
swarm_queue_client::ensure_connected(&self.client)?;
let store = self.store().await?;
// Keys first, then a fetch per key. The roster is a handful of
// hives, so the round-trip count is not worth trading for a
// watcher whose "I have seen everything current" condition is
// one more thing to get right on a read path.
let mut keys = store.keys().await.context("listing status bucket keys")?;
let mut entries: BTreeMap<String, Offered> = BTreeMap::new();
while let Some(key) = keys
.try_next()
.await
.context("reading the status bucket's key list")?
{
let Some(entry) = store
.entry(&key)
.await
.with_context(|| format!("reading status entry {key}"))?
else {
// Deleted between listing and fetching. Not an error:
// the next read simply won't list it.
continue;
};
let payload = match serde_json::from_slice(&entry.value) {
Ok(value) => Some(value),
Err(e) => {
tracing::warn!(
hive = %key,
error = %e,
"status snapshot is not JSON; reporting the timestamp without it"
);
None
}
};
entries.insert(
key,
Offered {
received_at: to_system_time(entry.created.unix_timestamp()),
payload,
},
);
}
Ok(render(roster, &entries, now, self.stale_after))
}
}
/// A bucket timestamp as a [`SystemTime`].
///
/// A pre-epoch stamp is not representable here and is not a thing a NATS
/// server produces; treating it as the epoch renders the row as
/// extremely stale, which is the safe direction — a nonsense timestamp
/// must never read as fresh.
fn to_system_time(unix_seconds: i64) -> SystemTime {
u64::try_from(unix_seconds).map_or(UNIX_EPOCH, |secs| UNIX_EPOCH + Duration::from_secs(secs))
}
/// Turn a roster plus whatever the bucket held into the rendered rows.
///
/// Split out of [`StatusReader::view`] deliberately: this is where every
/// rule the acceptance criterion cares about lives, and keeping it a
/// pure function means those rules are tested against a table rather
/// than against a running NATS server.
fn render(
roster: &[HiveEntry],
entries: &BTreeMap<String, Offered>,
now: SystemTime,
stale_after: Duration,
) -> Vec<HiveStatus> {
let mut rows: Vec<HiveStatus> = roster
.iter()
.map(|hive| match entries.get(&hive.name) {
Some(offered) => row(
hive.name.clone(),
Some(hive.domain.clone()),
offered,
now,
stale_after,
),
None => HiveStatus {
name: hive.name.clone(),
domain: Some(hive.domain.clone()),
freshness: Freshness::NeverReported,
last_seen_unix: None,
age_seconds: None,
snapshot: None,
},
})
.collect();
rows.extend(
entries
.iter()
.filter(|(name, _)| !roster.iter().any(|hive| &&hive.name == name))
.map(|(name, offered)| {
let mut unknown = row(name.clone(), None, offered, now, stale_after);
unknown.freshness = Freshness::Unknown;
unknown
}),
);
rows
}
fn row(
name: String,
domain: Option<String>,
offered: &Offered,
now: SystemTime,
stale_after: Duration,
) -> HiveStatus {
// A snapshot stamped in the future (clock skew between the NATS
// server and this host) yields no age rather than a negative one,
// and is treated as fresh — the honest reading of "this arrived, I
// cannot tell how long ago".
let age = now.duration_since(offered.received_at).ok();
let freshness = match age {
Some(age) if age > stale_after => Freshness::Stale,
_ => Freshness::Fresh,
};
HiveStatus {
name,
domain,
freshness,
last_seen_unix: offered
.received_at
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok()),
age_seconds: age.map(|age| age.as_secs()),
snapshot: offered.payload.clone(),
}
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_STALE_AFTER, Freshness, Offered, STALE_AFTER_ENV, StatusReader, render};
use crate::HiveEntry;
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime};
fn roster() -> Vec<HiveEntry> {
vec![
HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example.com".to_owned(),
},
HiveEntry {
name: "umbra".to_owned(),
domain: "umbra.example.com".to_owned(),
},
]
}
fn entries(rows: &[(&str, SystemTime)]) -> BTreeMap<String, Offered> {
rows.iter()
.map(|(name, at)| {
(
(*name).to_owned(),
Offered {
received_at: *at,
payload: Some(serde_json::json!({ "ok": true })),
},
)
})
.collect()
}
fn t0() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
/// The whole point of the module: an empty bucket must not render as
/// a healthy swarm. Rows come from the roster, so silence is visible.
#[test]
fn an_empty_bucket_renders_every_hive_as_never_reported() {
let rows = render(&roster(), &BTreeMap::new(), t0(), DEFAULT_STALE_AFTER);
assert_eq!(rows.len(), 2, "a row per roster hive, not per report");
assert!(
rows.iter().all(|r| r.freshness == Freshness::NeverReported),
"nothing published means nothing known — not healthy"
);
assert!(rows.iter().all(|r| r.snapshot.is_none()));
}
/// Freshness is derived from the clock at read time, so the same
/// stored value reads differently as it ages. The boundary is where
/// an off-by-one would hide, so it is pinned in both directions.
#[test]
fn the_threshold_boundary_is_inclusive() {
let stale_after = Duration::from_secs(90);
let stored = entries(&[("pr1ma", t0())]);
assert_eq!(
render(
&roster(),
&stored,
t0() + Duration::from_secs(90),
stale_after
)[0]
.freshness,
Freshness::Fresh,
"exactly at the threshold is inside it"
);
assert_eq!(
render(
&roster(),
&stored,
t0() + Duration::from_secs(91),
stale_after
)[0]
.freshness,
Freshness::Stale,
"one second past is outside it"
);
}
/// One hive reporting must not make its silent neighbour look
/// healthy — the failure mode of any aggregate that renders only
/// what it has.
#[test]
fn a_reporting_hive_does_not_vouch_for_a_silent_one() {
let rows = render(
&roster(),
&entries(&[("pr1ma", t0())]),
t0(),
DEFAULT_STALE_AFTER,
);
assert_eq!(rows[0].name, "pr1ma");
assert_eq!(rows[0].freshness, Freshness::Fresh);
assert_eq!(rows[1].name, "umbra");
assert_eq!(rows[1].freshness, Freshness::NeverReported);
}
/// An observation the daemon cannot explain is surfaced, not dropped.
#[test]
fn a_hive_outside_the_roster_is_surfaced_as_unknown() {
let rows = render(
&roster(),
&entries(&[("ghost", t0())]),
t0(),
DEFAULT_STALE_AFTER,
);
assert_eq!(rows.len(), 3, "two roster hives plus the stranger");
let ghost = rows.last().expect("rows is non-empty");
assert_eq!(ghost.name, "ghost");
assert_eq!(ghost.freshness, Freshness::Unknown);
assert!(
ghost.domain.is_none(),
"the roster is where a domain comes from, and this hive isn't in it"
);
}
/// Clock skew must not produce a negative age or a panic. A snapshot
/// stamped in the future reads fresh with no age — "it arrived, I
/// cannot tell how long ago".
#[test]
fn a_future_timestamp_yields_no_age_rather_than_a_wrong_one() {
let rows = render(
&roster(),
&entries(&[("pr1ma", t0() + Duration::from_secs(30))]),
t0(),
Duration::from_secs(90),
);
assert_eq!(rows[0].freshness, Freshness::Fresh);
assert_eq!(rows[0].age_seconds, None);
}
/// A hive that published something unreadable still gets its
/// timestamp reported: *when* it last spoke is exactly what this
/// aggregate is for, and dropping the row would read as silence.
#[test]
fn an_unparseable_payload_still_reports_when_it_arrived() {
let mut stored = BTreeMap::new();
stored.insert(
"pr1ma".to_owned(),
Offered {
received_at: t0(),
payload: None,
},
);
let row = &render(&roster(), &stored, t0(), DEFAULT_STALE_AFTER)[0];
assert_eq!(
row.freshness,
Freshness::Fresh,
"unreadable is not the same as absent — freshness is what \
separates them on the wire"
);
assert!(row.snapshot.is_none());
assert_eq!(row.last_seen_unix, Some(1_700_000_000));
}
/// SAFETY: single-threaded mutation of a process env var no other
/// test in this crate reads; restored before returning. One test
/// rather than four for the same reason `load_hives`'s is — the
/// parallel runner would race them.
#[test]
fn stale_after_from_env_covers_missing_bogus_zero_and_valid() {
unsafe {
std::env::remove_var(STALE_AFTER_ENV);
}
assert_eq!(StatusReader::stale_after_from_env(), DEFAULT_STALE_AFTER);
unsafe {
std::env::set_var(STALE_AFTER_ENV, "not a number");
}
assert_eq!(
StatusReader::stale_after_from_env(),
DEFAULT_STALE_AFTER,
"a controller whose own config is wrong must still serve"
);
unsafe {
std::env::set_var(STALE_AFTER_ENV, "0");
}
assert_eq!(
StatusReader::stale_after_from_env(),
DEFAULT_STALE_AFTER,
"zero would make every snapshot instantly stale — take the default"
);
unsafe {
std::env::set_var(STALE_AFTER_ENV, "300");
}
assert_eq!(StatusReader::stale_after_from_env(), Duration::from_mins(5));
unsafe {
std::env::remove_var(STALE_AFTER_ENV);
}
}
}