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
}
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 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),
}]
);
}
}