feat(state): expire stale agent devices with configurable retention

This commit is contained in:
lda committed 2026-10-10 23:59:12 +07:00 Verified
1 parent f098d85621
commit ce3ef13b73
9 files changed
+274 -3

No files matched your search

@@ -6,6 +6,35 @@ use super::helpers::core::*;
use crate::state::types::*;
impl Store {
/// Expire agent-reported devices, preserving all operator-saved identities.
/// `last_seen_unix` on the parent is CC snapshot inclusion time, not proof
/// of online presence. Cascades remove child addresses, endpoints and facts.
#[tracing::instrument(name = "db_prune_devices", skip_all)]
pub async fn prune_devices(&self, retention: std::time::Duration) -> Result<u64> {
self.prune_devices_at(retention, now_unix()).await
}
async fn prune_devices_at(&self, retention: std::time::Duration, now: u64) -> Result<u64> {
if retention.is_zero() {
return Ok(0);
}
let cutoff = i64::try_from(now.saturating_sub(retention.as_secs()))
.context("device retention cutoff overflow")?;
let mut tx = self.begin_write().await?;
let removed = sqlx::query!(
"DELETE FROM agent_devices WHERE last_seen_unix < ?1",
cutoff
)
.execute(&mut *tx)
.await
.context("failed pruning expired agent devices")?
.rows_affected();
tx.commit()
.await
.context("failed committing device retention cleanup")?;
Ok(removed)
}
/// Replace the complete device snapshot for an agent.
///
/// This is the only write path for agent device state. Every sync source
@@ -566,6 +595,151 @@ mod tests {
}
}
#[tokio::test]
async fn device_retention_uses_snapshot_age_and_preserves_saved_identity() {
use crate::state::types::{DeviceIdentifierInput, KnownDeviceInput};
let ts = TestStore::new().await;
let store = ts.store();
let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc");
device.neighbors[0].state = NeighborState::Failed;
device.observations.push(DeviceObservationFact {
kind: "neigh".into(),
action: "remove".into(),
mac: device.macs.first().copied(),
ip: device.ips.first().copied(),
hostname: None,
first_seen_unix: Some(1),
last_seen_unix: Some(1),
});
let saved = store
.create_known_device(KnownDeviceInput {
display_name: "saved pc".into(),
pinned: true,
notes: None,
identifiers: vec![DeviceIdentifierInput {
kind: "mac".into(),
value: "aa:bb:cc:dd:ee:01".into(),
}],
})
.await
.unwrap();
// Explicit timestamps exercise the exact boundary and future rows
// without sleeping or relying on the test machine's wall clock.
for (agent, seen) in [("expired", 99_i64), ("boundary", 100), ("fresh", 101)] {
store
.replace_agent_device_snapshot(agent, &[device.clone()])
.await
.unwrap();
sqlx::query("UPDATE agent_devices SET last_seen_unix = ?1 WHERE agent_id = ?2")
.bind(seen)
.bind(agent)
.execute(&store.pool)
.await
.unwrap();
sqlx::query("UPDATE agent_device_endpoints SET last_seen_unix = 1 WHERE agent_id = ?1")
.bind(agent)
.execute(&store.pool)
.await
.unwrap();
}
assert_eq!(
store.prune_devices_at(Duration::ZERO, 1_000).await.unwrap(),
0
);
assert_eq!(
store
.prune_devices_at(Duration::from_secs(900), 1_000)
.await
.unwrap(),
1
);
assert_eq!(
store
.prune_devices_at(Duration::from_secs(900), 1_000)
.await
.unwrap(),
0
);
for table in [
"agent_devices",
"agent_device_macs",
"agent_device_ips",
"agent_device_hostnames",
"agent_device_endpoints",
"agent_device_facts",
] {
let count: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {table} WHERE agent_id = 'expired'"
))
.fetch_one(&store.pool)
.await
.unwrap();
assert_eq!(count, 0, "expired rows remain in {table}");
}
for agent in ["boundary", "fresh"] {
let rows = store.list_agent_device_rows_for_agent(agent).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].presence(), Presence::Offline);
}
let retained = store
.get_known_device(&saved.device_id)
.await
.unwrap()
.unwrap();
assert_eq!(retained.identifiers.len(), 1);
assert!(retained.pinned);
// Retention longer than the current epoch must not wrap the cutoff.
assert_eq!(
store
.prune_devices_at(Duration::from_secs(2_000), 1_000)
.await
.unwrap(),
0
);
}
#[tokio::test]
async fn a_new_snapshot_renews_device_retention_even_when_offline() {
let ts = TestStore::new().await;
let store = ts.store();
let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc");
device.neighbors[0].state = NeighborState::Failed;
store
.replace_agent_device_snapshot("agent-a", &[device.clone()])
.await
.unwrap();
sqlx::query("UPDATE agent_devices SET last_seen_unix = 1")
.execute(&store.pool)
.await
.unwrap();
store
.replace_agent_device_snapshot("agent-a", &[device])
.await
.unwrap();
let rows = store
.list_agent_device_rows_for_agent("agent-a")
.await
.unwrap();
let included_at = u64::try_from(rows[0].device.last_seen_unix).unwrap();
assert_eq!(
store
.prune_devices_at(Duration::from_secs(60), included_at + 60)
.await
.unwrap(),
0
);
assert_eq!(
store
.prune_devices_at(Duration::from_secs(60), included_at + 61)
.await
.unwrap(),
1
);
}
#[tokio::test]
async fn snapshot_presence_is_derived_from_stored_endpoints() {
let ts = TestStore::new().await;