use std::collections::HashMap;
use anyhow::Result;
use chrono::{DateTime, Utc};
use kanade_shared::{subject, wire::ObsEvent};
use sqlx::{Row, SqlitePool};
use tracing::{debug, info, warn};
use crate::api::agents::ALIVE_THRESHOLD;
const SOURCE: &str = "backend:heartbeat-watchdog";
pub const KIND_OFFLINE: &str = "agent_offline";
pub const KIND_ONLINE: &str = "agent_online";
#[derive(Debug, Clone, Copy)]
struct Outage {
since: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Offline { pc_id: String, at: DateTime<Utc> },
Online {
pc_id: String,
at: DateTime<Utc>,
since: DateTime<Utc>,
},
}
impl Action {
fn pc_id(&self) -> &str {
match self {
Action::Offline { pc_id, .. } | Action::Online { pc_id, .. } => pc_id,
}
}
}
fn apply_outcomes(
open: &mut HashMap<String, Outage>,
outcomes: &[(Action, bool)],
) -> (usize, usize) {
let failed: std::collections::HashSet<&str> = outcomes
.iter()
.filter(|(_, ok)| !ok)
.map(|(a, _)| a.pc_id())
.collect();
let mut went_offline = 0usize;
let mut came_online = 0usize;
for (action, ok) in outcomes {
if !ok || failed.contains(action.pc_id()) {
continue;
}
match action {
Action::Offline { pc_id, at } => {
open.insert(pc_id.clone(), Outage { since: *at });
went_offline += 1;
}
Action::Online { pc_id, .. } => {
open.remove(pc_id);
came_online += 1;
}
}
}
(went_offline, came_online)
}
fn decide(
agents: &[(String, DateTime<Utc>)],
observed_since: DateTime<Utc>,
open: &HashMap<String, Outage>,
now: DateTime<Utc>,
) -> Vec<Action> {
let cutoff = now - ALIVE_THRESHOLD;
let mut out = Vec::new();
for (pc_id, last) in agents {
if *last < observed_since {
continue;
}
if *last > cutoff {
if let Some(outage) = open.get(pc_id) {
out.push(Action::Online {
pc_id: pc_id.clone(),
at: *last,
since: outage.since,
});
}
continue;
}
if let Some(outage) = open.get(pc_id) {
if outage.since == *last {
continue;
}
out.push(Action::Online {
pc_id: pc_id.clone(),
at: *last,
since: outage.since,
});
}
out.push(Action::Offline {
pc_id: pc_id.clone(),
at: *last,
});
}
out
}
fn open_from_latest(latest: &[(String, String, DateTime<Utc>)]) -> HashMap<String, Outage> {
latest
.iter()
.filter(|(_, kind, _)| kind == KIND_OFFLINE)
.map(|(pc_id, _, at)| (pc_id.clone(), Outage { since: *at }))
.collect()
}
pub struct AgentWatchdog {
observed_since: DateTime<Utc>,
open: HashMap<String, Outage>,
}
impl AgentWatchdog {
pub fn new(observed_since: DateTime<Utc>) -> Self {
Self {
observed_since,
open: HashMap::new(),
}
}
pub async fn restore(&mut self, pool: &SqlitePool) -> Result<usize> {
let rows = sqlx::query(
"SELECT pc_id, kind, at FROM ( \
SELECT pc_id, kind, at, \
ROW_NUMBER() OVER (PARTITION BY pc_id ORDER BY at DESC, id DESC) AS rn \
FROM obs_events \
WHERE kind IN (?1, ?2) \
AND pc_id IN (SELECT pc_id FROM agents) \
) WHERE rn = 1",
)
.bind(KIND_OFFLINE)
.bind(KIND_ONLINE)
.fetch_all(pool)
.await?;
let latest: Vec<(String, String, DateTime<Utc>)> = rows
.iter()
.filter_map(|r| {
Some((
r.try_get("pc_id").ok()?,
r.try_get("kind").ok()?,
r.try_get("at").ok()?,
))
})
.collect();
self.open = open_from_latest(&latest);
if !self.open.is_empty() {
info!(
outages = self.open.len(),
"restored open agent outages recorded before this process started",
);
}
Ok(self.open.len())
}
pub async fn sweep(
&mut self,
pool: &SqlitePool,
js: &async_nats::jetstream::Context,
now: DateTime<Utc>,
) -> Result<(usize, usize)> {
let rows = sqlx::query(
"SELECT pc_id, last_heartbeat FROM agents WHERE last_heartbeat IS NOT NULL",
)
.fetch_all(pool)
.await?;
let agents: Vec<(String, DateTime<Utc>)> = rows
.iter()
.filter_map(|r| {
let pc_id: String = r.try_get("pc_id").ok()?;
let last: DateTime<Utc> = r.try_get("last_heartbeat").ok()?;
Some((pc_id, last))
})
.collect();
let mut outcomes: Vec<(Action, bool)> = Vec::new();
let mut failed: std::collections::HashSet<String> = std::collections::HashSet::new();
for action in decide(&agents, self.observed_since, &self.open, now) {
if failed.contains(action.pc_id()) {
outcomes.push((action, false));
continue;
}
let published = match &action {
Action::Offline { pc_id, at } => {
let r = self.publish(js, pc_id, KIND_OFFLINE, *at, *at).await;
match &r {
Ok(()) => info!(
pc_id = %pc_id,
last_heartbeat = %at,
threshold_secs = ALIVE_THRESHOLD.num_seconds(),
"agent stopped reporting; recorded agent_offline",
),
Err(e) => {
warn!(pc_id = %pc_id, error = %e, "failed to publish agent_offline")
}
}
r.is_ok()
}
Action::Online { pc_id, at, since } => {
let r = self.publish(js, pc_id, KIND_ONLINE, *at, *since).await;
match &r {
Ok(()) => info!(
pc_id = %pc_id,
offline_since = %since,
back_at = %at,
"agent recovered; recorded agent_online",
),
Err(e) => {
warn!(pc_id = %pc_id, error = %e, "failed to publish agent_online")
}
}
r.is_ok()
}
};
if !published {
failed.insert(action.pc_id().to_string());
}
outcomes.push((action, published));
}
let (went_offline, came_online) = apply_outcomes(&mut self.open, &outcomes);
debug!(
agents = agents.len(),
went_offline, came_online, "agent watchdog sweep"
);
Ok((went_offline, came_online))
}
async fn publish(
&self,
js: &async_nats::jetstream::Context,
pc_id: &str,
kind: &str,
at: DateTime<Utc>,
key: DateTime<Utc>,
) -> Result<()> {
let event = ObsEvent {
pc_id: pc_id.to_string(),
at,
kind: kind.to_string(),
source: SOURCE.to_string(),
event_record_id: Some(format!("{kind}:{}", key.timestamp_millis())),
payload: serde_json::json!({
"last_heartbeat": key,
"threshold_secs": ALIVE_THRESHOLD.num_seconds(),
}),
};
let bytes = serde_json::to_vec(&event)?;
let ack = js.publish(subject::obs(pc_id), bytes.into()).await?;
ack.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(1_800_000_000 + secs, 0).unwrap()
}
#[test]
fn offline_event_ids_are_deterministic_per_outage() {
let a = format!("{KIND_OFFLINE}:{}", ts(0).timestamp_millis());
let b = format!("{KIND_OFFLINE}:{}", ts(0).timestamp_millis());
assert_eq!(a, b);
let c = format!("{KIND_OFFLINE}:{}", ts(600).timestamp_millis());
assert_ne!(a, c);
}
#[test]
fn online_and_offline_ids_do_not_collide() {
let off = format!("{KIND_OFFLINE}:{}", ts(0).timestamp_millis());
let on = format!("{KIND_ONLINE}:{}", ts(0).timestamp_millis());
assert_ne!(off, on);
}
const WATCH_START: i64 = 0;
fn open_with(pc: &str, since: i64) -> HashMap<String, Outage> {
HashMap::from([(pc.to_string(), Outage { since: ts(since) })])
}
fn stale_now(hb: i64) -> DateTime<Utc> {
ts(hb) + ALIVE_THRESHOLD + chrono::Duration::seconds(1)
}
#[test]
fn a_fresh_agent_produces_nothing() {
let agents = vec![("pc1".into(), ts(100))];
let out = decide(&agents, ts(WATCH_START), &HashMap::new(), ts(110));
assert!(out.is_empty());
}
#[test]
fn a_stale_agent_goes_offline_at_its_last_heartbeat() {
let agents = vec![("pc1".into(), ts(100))];
let out = decide(&agents, ts(WATCH_START), &HashMap::new(), stale_now(100));
assert_eq!(
out,
vec![Action::Offline {
pc_id: "pc1".into(),
at: ts(100),
}]
);
}
#[test]
fn an_already_recorded_outage_is_not_repeated() {
let agents = vec![("pc1".into(), ts(100))];
let out = decide(
&agents,
ts(WATCH_START),
&open_with("pc1", 100),
stale_now(100),
);
assert!(out.is_empty());
}
#[test]
fn recovery_closes_the_outage() {
let agents = vec![("pc1".into(), ts(900))];
let out = decide(&agents, ts(WATCH_START), &open_with("pc1", 100), ts(905));
assert_eq!(
out,
vec![Action::Online {
pc_id: "pc1".into(),
at: ts(900),
since: ts(100),
}]
);
}
#[test]
fn a_recovery_missed_between_sweeps_is_still_recorded() {
let agents = vec![("pc1".into(), ts(900))];
let out = decide(
&agents,
ts(WATCH_START),
&open_with("pc1", 100),
stale_now(900),
);
assert_eq!(
out,
vec![
Action::Online {
pc_id: "pc1".into(),
at: ts(900),
since: ts(100),
},
Action::Offline {
pc_id: "pc1".into(),
at: ts(900),
},
],
"the close must come first, and both must be emitted",
);
}
#[test]
fn a_restart_does_not_invent_a_fleet_wide_outage() {
let agents: Vec<(String, DateTime<Utc>)> =
(0..50).map(|i| (format!("pc{i}"), ts(100))).collect();
let out = decide(&agents, ts(500), &HashMap::new(), stale_now(500));
assert!(
out.is_empty(),
"claimed {} outages for agents never observed reporting",
out.len()
);
}
#[test]
fn an_agent_seen_after_the_watch_started_is_still_reported() {
let agents = vec![("pc1".into(), ts(600))];
let out = decide(&agents, ts(500), &HashMap::new(), stale_now(600));
assert_eq!(out.len(), 1);
}
#[test]
fn the_threshold_boundary_is_not_stale() {
let agents = vec![("pc1".into(), ts(100))];
let just_inside = ts(100) + ALIVE_THRESHOLD - chrono::Duration::seconds(1);
assert!(decide(&agents, ts(WATCH_START), &HashMap::new(), just_inside).is_empty());
}
fn off(pc: &str, at: i64) -> Action {
Action::Offline {
pc_id: pc.into(),
at: ts(at),
}
}
fn on(pc: &str, at: i64, since: i64) -> Action {
Action::Online {
pc_id: pc.into(),
at: ts(at),
since: ts(since),
}
}
#[test]
fn a_successful_pair_leaves_the_new_outage_open() {
let mut open = open_with("pc1", 100);
let out = apply_outcomes(
&mut open,
&[(on("pc1", 900, 100), true), (off("pc1", 900), true)],
);
assert_eq!(out, (1, 1));
assert_eq!(open["pc1"].since, ts(900));
}
#[test]
fn a_failed_close_does_not_let_the_reopen_bury_it() {
let mut open = open_with("pc1", 100);
let out = apply_outcomes(
&mut open,
&[(on("pc1", 900, 100), false), (off("pc1", 900), true)],
);
assert_eq!(out, (0, 0), "nothing may be counted as recorded");
assert_eq!(
open["pc1"].since,
ts(100),
"the outage still awaiting its close must survive untouched",
);
let agents = vec![("pc1".into(), ts(900))];
assert_eq!(
decide(&agents, ts(WATCH_START), &open, stale_now(900)),
vec![on("pc1", 900, 100), off("pc1", 900)],
);
}
#[test]
fn a_failed_open_leaves_the_host_untouched_and_replays_the_pair() {
let mut open = open_with("pc1", 100);
let out = apply_outcomes(
&mut open,
&[(on("pc1", 900, 100), true), (off("pc1", 900), false)],
);
assert_eq!(out, (0, 0));
assert_eq!(open["pc1"].since, ts(100));
let agents = vec![("pc1".into(), ts(900))];
assert_eq!(
decide(&agents, ts(WATCH_START), &open, stale_now(900)),
vec![on("pc1", 900, 100), off("pc1", 900)],
"the pair replays; the already-landed close dedups on its id",
);
}
#[test]
fn one_hosts_failure_does_not_hold_back_another() {
let mut open = HashMap::new();
let out = apply_outcomes(
&mut open,
&[(off("pc1", 100), false), (off("pc2", 100), true)],
);
assert_eq!(out, (1, 0));
assert!(!open.contains_key("pc1"));
assert_eq!(open["pc2"].since, ts(100));
}
#[test]
fn hosts_are_judged_independently() {
let agents = vec![
("fresh".into(), ts(1000)),
("stale".into(), ts(100)),
("unseen".into(), ts(-100)),
];
let out = decide(&agents, ts(0), &HashMap::new(), stale_now(100));
assert_eq!(
out,
vec![Action::Offline {
pc_id: "stale".into(),
at: ts(100),
}]
);
}
fn latest(rows: &[(&str, &str, i64)]) -> Vec<(String, String, DateTime<Utc>)> {
rows.iter()
.map(|(pc, kind, at)| (pc.to_string(), kind.to_string(), ts(*at)))
.collect()
}
#[test]
fn a_host_whose_newest_record_is_offline_is_still_down() {
let open = open_from_latest(&latest(&[("pc1", KIND_OFFLINE, 100)]));
assert_eq!(open["pc1"].since, ts(100));
}
#[test]
fn a_host_whose_outage_already_closed_is_not_reopened() {
let open = open_from_latest(&latest(&[("pc1", KIND_ONLINE, 900)]));
assert!(open.is_empty());
}
#[test]
fn a_restored_outage_closes_on_the_next_beat() {
let open = open_from_latest(&latest(&[("pc1", KIND_OFFLINE, 100)]));
let agents = vec![("pc1".into(), ts(900))];
assert_eq!(
decide(&agents, ts(WATCH_START), &open, ts(905)),
vec![Action::Online {
pc_id: "pc1".into(),
at: ts(900),
since: ts(100),
}],
);
}
#[tokio::test]
async fn restore_reads_the_newest_watchdog_record_per_host() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
for pc in ["down", "back"] {
sqlx::query("INSERT INTO agents (pc_id, last_heartbeat) VALUES (?1, ?2)")
.bind(pc)
.bind(ts(900))
.execute(&pool)
.await
.unwrap();
}
let rows = [
("down", KIND_OFFLINE, 100),
("back", KIND_OFFLINE, 100),
("back", KIND_ONLINE, 500),
("stray", KIND_OFFLINE, 100),
];
for (i, (pc, kind, at)) in rows.iter().enumerate() {
sqlx::query(
"INSERT INTO obs_events (pc_id, at, kind, source, event_record_id, payload) \
VALUES (?1, ?2, ?3, ?4, ?5, '{}')",
)
.bind(pc)
.bind(ts(*at))
.bind(*kind)
.bind(SOURCE)
.bind(format!("{kind}:{i}"))
.execute(&pool)
.await
.unwrap();
}
let mut wd = AgentWatchdog::new(ts(1000));
assert_eq!(wd.restore(&pool).await.unwrap(), 1);
assert_eq!(wd.open["down"].since, ts(100));
assert!(!wd.open.contains_key("back"));
assert!(!wd.open.contains_key("stray"));
}
}