hive-c0re: publish each agent's status upward to the swarm queue (#3341 item 1)
This commit is contained in:
parent
5b27aa18c2
commit
e6d5e2da28
4 changed files with 286 additions and 0 deletions
130
swarm-queue-client/src/agent_status.rs
Normal file
130
swarm-queue-client/src/agent_status.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! The per-agent status KV bucket: one key per `(hive, agent)` pair.
|
||||
//!
|
||||
//! Deliberately **not** a field inside [`crate::status::BUCKET`]'s hive
|
||||
//! document, even though a hive already publishes its own status there.
|
||||
//! Three reasons, none of which show up until the second write:
|
||||
//!
|
||||
//! - **Cardinality and write rate.** Hive status is one value per hive.
|
||||
//! Agent status is N values per hive, changing independently and far
|
||||
//! more often — folded into the hive document, every agent's change
|
||||
//! rewrites the whole thing, and two agents changing at once race on
|
||||
//! the same key.
|
||||
//! - **A KV key is the unit of update and of watch.** A consumer that
|
||||
//! cares about one agent wants a per-key read with its own revision,
|
||||
//! not a hive-wide document to diff.
|
||||
//! - **Staleness means different things.** A hive can be healthy while
|
||||
//! one agent is wedged, and the reverse; one timestamp cannot answer
|
||||
//! both questions honestly.
|
||||
//!
|
||||
//! The transport *is* shared — this rides the same [`async_nats::Client`]
|
||||
//! and the same auth callout as [`crate::status`] and [`crate::wanted`],
|
||||
//! just a different bucket.
|
||||
|
||||
#[cfg(feature = "kv")]
|
||||
use crate::Error;
|
||||
|
||||
/// The KV bucket per-agent status snapshots are published into, keyed by
|
||||
/// [`key`].
|
||||
///
|
||||
/// A constant and not an option, for the reason [`crate::status::BUCKET`]
|
||||
/// gives: writer (a hive) and reader (the controller) must name the same
|
||||
/// bucket, and an option is a way for the two to disagree about which one
|
||||
/// that is.
|
||||
pub const BUCKET: &str = "agent-status";
|
||||
|
||||
/// The bucket key for one agent on one hive.
|
||||
///
|
||||
/// `/`-joined rather than NATS's usual `.`-joined subject style: this is a
|
||||
/// KV key, not a subject, and neither `hive` nor `agent` names can contain
|
||||
/// `/` ([`hive_types::Ident`]'s charset is `[a-z0-9-]`), so the join is
|
||||
/// unambiguous to split back apart if a consumer ever needs to.
|
||||
#[must_use]
|
||||
pub fn key(hive: &str, agent: &str) -> String {
|
||||
format!("{hive}/{agent}")
|
||||
}
|
||||
|
||||
/// One agent's status snapshot, as published under [`key`].
|
||||
///
|
||||
/// Mirrors the tuple `container_view::read_agent_status_live` returns on
|
||||
/// the publishing side — this is that value, not a recomputation of it.
|
||||
/// Nothing here stamps a time: same rule as [`crate::status`], freshness is
|
||||
/// derived by the reader from when the value landed in the bucket, so a
|
||||
/// hive with a wrong clock only skews its own payload.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AgentStatus {
|
||||
/// The agent's free-text status (`set_status`), or `None` if it has
|
||||
/// never set one, or if the container isn't running.
|
||||
pub status_text: Option<String>,
|
||||
/// Unix timestamp the status was last set. `None` alongside
|
||||
/// `status_text: None` — the two are never independently absent.
|
||||
pub status_set_at: Option<i64>,
|
||||
/// Whether the container is currently running. `false` forces the two
|
||||
/// fields above to `None` even if an on-disk status file is stale from
|
||||
/// before the container stopped — the same "stopped containers have
|
||||
/// stale state" rule `read_agent_status_live` already enforces.
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Open the agent-status bucket, creating it if nothing has yet.
|
||||
///
|
||||
/// `history: 1`, same rationale as [`crate::status::open_or_create`]: a
|
||||
/// consumer wants the last thing each agent said, not a log of everything
|
||||
/// it has ever said.
|
||||
#[cfg(feature = "kv")]
|
||||
pub async fn open_or_create(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<async_nats::jetstream::kv::Store, Error> {
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
match js.get_key_value(BUCKET).await {
|
||||
Ok(store) => Ok(store),
|
||||
Err(e) => {
|
||||
tracing::info!(
|
||||
bucket = BUCKET,
|
||||
reason = %e,
|
||||
"agent-status bucket not available, creating it"
|
||||
);
|
||||
js.create_key_value(async_nats::jetstream::kv::Config {
|
||||
bucket: BUCKET.to_owned(),
|
||||
description: "Last status snapshot offered by each agent".to_owned(),
|
||||
history: 1,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|source| Error::CreateBucket {
|
||||
bucket: BUCKET,
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AgentStatus, key};
|
||||
|
||||
#[test]
|
||||
fn key_joins_hive_and_agent_with_a_slash() {
|
||||
assert_eq!(key("prod", "iris"), "prod/iris");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_status_round_trips() {
|
||||
let status = AgentStatus {
|
||||
status_text: Some("reviewing a pull request".to_owned()),
|
||||
status_set_at: Some(1_725_000_000),
|
||||
running: true,
|
||||
};
|
||||
let json = serde_json::to_string(&status).expect("serialises");
|
||||
let decoded: AgentStatus = serde_json::from_str(&json).expect("decodes");
|
||||
assert_eq!(decoded, status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_agent_serialises_with_no_status() {
|
||||
let status = AgentStatus::default();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&status).expect("serialises"),
|
||||
r#"{"status_text":null,"status_set_at":null,"running":false}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue