use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Serialize)]
pub struct EdgeNode {
pub edge_id: String,
pub region: String,
pub base_url: String,
pub registered_at: String,
pub last_seen: String,
pub invalidations_sent: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvalidationEvent {
pub up_to_version: u64,
pub tables: Vec<String>,
pub committed_at: String,
#[serde(default)]
pub epoch: u64,
}
struct EdgeSubscription {
node: EdgeNode,
sender: mpsc::Sender<InvalidationEvent>,
last_seen_inst: Instant,
}
#[derive(Clone)]
pub struct EdgeRegistry {
inner: Arc<RwLock<HashMap<String, EdgeSubscription>>>,
max_edges: usize,
liveness_window: Duration,
}
impl EdgeRegistry {
pub fn new(max_edges: usize, liveness_window: Duration) -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
max_edges,
liveness_window,
}
}
pub fn register(
&self,
edge_id: &str,
region: &str,
base_url: &str,
now_iso: &str,
) -> Result<mpsc::Receiver<InvalidationEvent>, RegistryError> {
let mut g = self.inner.write();
if !g.contains_key(edge_id) && g.len() >= self.max_edges {
return Err(RegistryError::CapacityExceeded(self.max_edges));
}
let (tx, rx) = mpsc::channel(64);
let sub = EdgeSubscription {
node: EdgeNode {
edge_id: edge_id.to_string(),
region: region.to_string(),
base_url: base_url.to_string(),
registered_at: now_iso.to_string(),
last_seen: now_iso.to_string(),
invalidations_sent: 0,
},
sender: tx,
last_seen_inst: Instant::now(),
};
g.insert(edge_id.to_string(), sub);
Ok(rx)
}
pub fn unregister(&self, edge_id: &str) -> bool {
self.inner.write().remove(edge_id).is_some()
}
pub async fn broadcast(&self, ev: InvalidationEvent) -> (u32, u32) {
use tokio::sync::mpsc::error::TrySendError;
let mut g = self.inner.write();
let mut sent = 0u32;
let mut dead: Vec<String> = Vec::new();
for (id, sub) in g.iter_mut() {
match sub.sender.try_send(ev.clone()) {
Ok(()) => {
sent += 1;
sub.node.invalidations_sent = sub.node.invalidations_sent.saturating_add(1);
sub.last_seen_inst = Instant::now();
sub.node.last_seen = ev.committed_at.clone();
}
Err(TrySendError::Full(_)) => {
tracing::warn!(
edge_id = %id,
up_to_version = ev.up_to_version,
"edge invalidation buffer full — event dropped for this edge \
(stale entries age out within the cache TTL)"
);
}
Err(TrySendError::Closed(_)) => {
dead.push(id.clone());
}
}
}
for id in &dead {
g.remove(id);
}
(sent, dead.len() as u32)
}
pub fn touch(&self, edge_id: &str, now_iso: &str) -> bool {
let mut g = self.inner.write();
match g.get_mut(edge_id) {
Some(sub) => {
sub.last_seen_inst = Instant::now();
sub.node.last_seen = now_iso.to_string();
true
}
None => false,
}
}
pub fn list(&self) -> Vec<EdgeNode> {
self.inner.read().values().map(|s| s.node.clone()).collect()
}
pub fn count(&self) -> usize {
self.inner.read().len()
}
pub fn prune_stale(&self) -> u32 {
let Some(cutoff) = Instant::now().checked_sub(self.liveness_window) else {
return 0;
};
let mut g = self.inner.write();
let dead: Vec<String> = g
.iter()
.filter(|(_, s)| s.last_seen_inst < cutoff)
.map(|(id, _)| id.clone())
.collect();
for id in &dead {
g.remove(id);
}
dead.len() as u32
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
CapacityExceeded(usize),
}
impl std::fmt::Display for RegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RegistryError::CapacityExceeded(n) => {
write!(f, "edge registry full (max {})", n)
}
}
}
}
impl std::error::Error for RegistryError {}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn register_returns_receiver_with_invalidations() {
let r = EdgeRegistry::new(10, Duration::from_secs(60));
let mut rx = r.register("edge-1", "us-east", "https://e1", "ts").unwrap();
assert_eq!(r.count(), 1);
let (sent, pruned) = r
.broadcast(InvalidationEvent {
up_to_version: 5,
tables: vec!["users".into()],
committed_at: "ts".into(),
epoch: 0,
})
.await;
assert_eq!(sent, 1);
assert_eq!(pruned, 0);
let ev = rx.recv().await.expect("receive");
assert_eq!(ev.up_to_version, 5);
assert_eq!(ev.tables, vec!["users".to_string()]);
}
#[tokio::test]
async fn broadcast_prunes_dropped_receivers() {
let r = EdgeRegistry::new(10, Duration::from_secs(60));
let _rx_keep = r.register("edge-keep", "us-east", "u", "ts").unwrap();
{
let _rx_drop = r.register("edge-drop", "us-west", "u", "ts").unwrap();
}
let (sent, pruned) = r
.broadcast(InvalidationEvent {
up_to_version: 1,
tables: vec![],
committed_at: "ts".into(),
epoch: 0,
})
.await;
assert_eq!(sent, 1);
assert_eq!(pruned, 1);
assert_eq!(r.count(), 1);
}
#[test]
fn register_rejects_when_at_capacity() {
let r = EdgeRegistry::new(2, Duration::from_secs(60));
let _a = r.register("a", "us-east", "u", "ts").unwrap();
let _b = r.register("b", "us-west", "u", "ts").unwrap();
let err = r.register("c", "eu-west", "u", "ts").unwrap_err();
assert!(matches!(err, RegistryError::CapacityExceeded(2)));
}
#[test]
fn register_replaces_existing_id() {
let r = EdgeRegistry::new(2, Duration::from_secs(60));
let _a1 = r.register("a", "us-east", "u", "t1").unwrap();
let _a2 = r.register("a", "eu-west", "u", "t2").unwrap();
assert_eq!(r.count(), 1);
let nodes = r.list();
assert_eq!(nodes[0].region, "eu-west");
}
#[tokio::test]
async fn reregister_same_id_replaces_sender_and_closes_old_receiver() {
let r = EdgeRegistry::new(1, Duration::from_secs(60));
let mut rx_old = r.register("a", "us-east", "u", "t1").unwrap();
let mut rx_new = r.register("a", "us-east", "u", "t2").unwrap();
assert_eq!(r.count(), 1);
assert!(
rx_old.recv().await.is_none(),
"stale receiver must observe closure"
);
let (sent, pruned) = r
.broadcast(InvalidationEvent {
up_to_version: 9,
tables: vec![],
committed_at: "ts".into(),
epoch: 0,
})
.await;
assert_eq!((sent, pruned), (1, 0));
let ev = rx_new.recv().await.expect("new receiver is live");
assert_eq!(ev.up_to_version, 9);
}
#[test]
fn unregister_removes_edge() {
let r = EdgeRegistry::new(10, Duration::from_secs(60));
let _rx = r.register("edge-1", "us-east", "u", "ts").unwrap();
assert!(r.unregister("edge-1"));
assert_eq!(r.count(), 0);
assert!(!r.unregister("edge-1"));
}
#[test]
fn list_returns_snapshot() {
let r = EdgeRegistry::new(10, Duration::from_secs(60));
let _a = r.register("a", "r1", "u1", "ts").unwrap();
let _b = r.register("b", "r2", "u2", "ts").unwrap();
let mut nodes = r.list();
nodes.sort_by(|a, b| a.edge_id.cmp(&b.edge_id));
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].edge_id, "a");
assert_eq!(nodes[1].edge_id, "b");
}
#[tokio::test]
async fn invalidations_sent_counter_increments() {
let r = EdgeRegistry::new(10, Duration::from_secs(60));
let mut _rx = r.register("e1", "r", "u", "ts").unwrap();
for _ in 0..3 {
let _ = r
.broadcast(InvalidationEvent {
up_to_version: 1,
tables: vec![],
committed_at: "ts".into(),
epoch: 0,
})
.await;
}
let n = r.list();
assert_eq!(n[0].invalidations_sent, 3);
}
#[test]
fn prune_stale_removes_old_entries() {
let r = EdgeRegistry::new(10, Duration::from_millis(10));
let _rx = r.register("old", "r", "u", "ts").unwrap();
std::thread::sleep(Duration::from_millis(20));
let pruned = r.prune_stale();
assert_eq!(pruned, 1);
assert_eq!(r.count(), 0);
}
#[test]
fn touch_keeps_edge_alive_across_prune() {
let r = EdgeRegistry::new(10, Duration::from_millis(50));
let _rx = r.register("e1", "r", "u", "t0").unwrap();
std::thread::sleep(Duration::from_millis(60));
assert!(r.touch("e1", "t1"));
assert_eq!(r.prune_stale(), 0, "touched edge survives the sweep");
assert_eq!(r.count(), 1);
assert_eq!(r.list()[0].last_seen, "t1", "surfaced timestamp advances");
std::thread::sleep(Duration::from_millis(60));
assert_eq!(r.prune_stale(), 1, "untouched edge ages out");
assert_eq!(r.count(), 0);
assert!(!r.touch("e1", "t2"));
}
#[test]
fn prune_stale_survives_near_boot_underflow() {
let r = EdgeRegistry::new(10, Duration::from_secs(u64::MAX));
let _rx = r.register("young", "r", "u", "ts").unwrap();
let pruned = r.prune_stale();
assert_eq!(pruned, 0);
assert_eq!(r.count(), 1);
}
#[tokio::test]
async fn broadcast_full_buffer_drops_event_but_keeps_edge() {
let r = EdgeRegistry::new(10, Duration::from_secs(60));
let mut rx = r.register("slow", "r", "u", "ts").unwrap();
for i in 0..64 {
let (sent, pruned) = r
.broadcast(InvalidationEvent {
up_to_version: i,
tables: vec![],
committed_at: "ts".into(),
epoch: 0,
})
.await;
assert_eq!((sent, pruned), (1, 0));
}
let (sent, pruned) = r
.broadcast(InvalidationEvent {
up_to_version: 64,
tables: vec![],
committed_at: "ts".into(),
epoch: 0,
})
.await;
assert_eq!(sent, 0);
assert_eq!(pruned, 0);
assert_eq!(r.count(), 1);
assert_eq!(r.list()[0].invalidations_sent, 64);
let first = rx.recv().await.expect("receive");
assert_eq!(first.up_to_version, 0);
let (sent, _) = r
.broadcast(InvalidationEvent {
up_to_version: 65,
tables: vec![],
committed_at: "ts".into(),
epoch: 0,
})
.await;
assert_eq!(sent, 1);
}
}