1use parking_lot::RwLock;
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23use tokio::sync::mpsc;
24
25#[derive(Debug, Clone, Serialize)]
27pub struct EdgeNode {
28 pub edge_id: String,
29 pub region: String,
30 pub base_url: String,
32 pub registered_at: String,
34 pub last_seen: String,
35 pub invalidations_sent: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct InvalidationEvent {
43 pub up_to_version: u64,
45 pub tables: Vec<String>,
48 pub committed_at: String,
51 #[serde(default)]
57 pub epoch: u64,
58}
59
60struct EdgeSubscription {
63 node: EdgeNode,
64 sender: mpsc::Sender<InvalidationEvent>,
65 last_seen_inst: Instant,
68}
69
70#[derive(Clone)]
72pub struct EdgeRegistry {
73 inner: Arc<RwLock<HashMap<String, EdgeSubscription>>>,
74 max_edges: usize,
75 liveness_window: Duration,
78}
79
80impl EdgeRegistry {
81 pub fn new(max_edges: usize, liveness_window: Duration) -> Self {
82 Self {
83 inner: Arc::new(RwLock::new(HashMap::new())),
84 max_edges,
85 liveness_window,
86 }
87 }
88
89 pub fn register(
98 &self,
99 edge_id: &str,
100 region: &str,
101 base_url: &str,
102 now_iso: &str,
103 ) -> Result<mpsc::Receiver<InvalidationEvent>, RegistryError> {
104 let mut g = self.inner.write();
105 if !g.contains_key(edge_id) && g.len() >= self.max_edges {
106 return Err(RegistryError::CapacityExceeded(self.max_edges));
107 }
108 let (tx, rx) = mpsc::channel(64);
109 let sub = EdgeSubscription {
110 node: EdgeNode {
111 edge_id: edge_id.to_string(),
112 region: region.to_string(),
113 base_url: base_url.to_string(),
114 registered_at: now_iso.to_string(),
115 last_seen: now_iso.to_string(),
116 invalidations_sent: 0,
117 },
118 sender: tx,
119 last_seen_inst: Instant::now(),
120 };
121 g.insert(edge_id.to_string(), sub);
122 Ok(rx)
123 }
124
125 pub fn unregister(&self, edge_id: &str) -> bool {
128 self.inner.write().remove(edge_id).is_some()
129 }
130
131 pub async fn broadcast(&self, ev: InvalidationEvent) -> (u32, u32) {
142 use tokio::sync::mpsc::error::TrySendError;
143
144 let mut g = self.inner.write();
147 let mut sent = 0u32;
148 let mut dead: Vec<String> = Vec::new();
149 for (id, sub) in g.iter_mut() {
150 match sub.sender.try_send(ev.clone()) {
151 Ok(()) => {
152 sent += 1;
153 sub.node.invalidations_sent = sub.node.invalidations_sent.saturating_add(1);
154 sub.last_seen_inst = Instant::now();
155 sub.node.last_seen = ev.committed_at.clone();
158 }
159 Err(TrySendError::Full(_)) => {
160 tracing::warn!(
161 edge_id = %id,
162 up_to_version = ev.up_to_version,
163 "edge invalidation buffer full — event dropped for this edge \
164 (stale entries age out within the cache TTL)"
165 );
166 }
167 Err(TrySendError::Closed(_)) => {
168 dead.push(id.clone());
169 }
170 }
171 }
172 for id in &dead {
173 g.remove(id);
174 }
175 (sent, dead.len() as u32)
176 }
177
178 pub fn touch(&self, edge_id: &str, now_iso: &str) -> bool {
186 let mut g = self.inner.write();
187 match g.get_mut(edge_id) {
188 Some(sub) => {
189 sub.last_seen_inst = Instant::now();
190 sub.node.last_seen = now_iso.to_string();
191 true
192 }
193 None => false,
194 }
195 }
196
197 pub fn list(&self) -> Vec<EdgeNode> {
200 self.inner.read().values().map(|s| s.node.clone()).collect()
201 }
202
203 pub fn count(&self) -> usize {
204 self.inner.read().len()
205 }
206
207 pub fn prune_stale(&self) -> u32 {
210 let Some(cutoff) = Instant::now().checked_sub(self.liveness_window) else {
215 return 0;
216 };
217 let mut g = self.inner.write();
218 let dead: Vec<String> = g
219 .iter()
220 .filter(|(_, s)| s.last_seen_inst < cutoff)
221 .map(|(id, _)| id.clone())
222 .collect();
223 for id in &dead {
224 g.remove(id);
225 }
226 dead.len() as u32
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub enum RegistryError {
232 CapacityExceeded(usize),
233}
234
235impl std::fmt::Display for RegistryError {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 match self {
238 RegistryError::CapacityExceeded(n) => {
239 write!(f, "edge registry full (max {})", n)
240 }
241 }
242 }
243}
244
245impl std::error::Error for RegistryError {}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[tokio::test]
252 async fn register_returns_receiver_with_invalidations() {
253 let r = EdgeRegistry::new(10, Duration::from_secs(60));
254 let mut rx = r.register("edge-1", "us-east", "https://e1", "ts").unwrap();
255 assert_eq!(r.count(), 1);
256 let (sent, pruned) = r
257 .broadcast(InvalidationEvent {
258 up_to_version: 5,
259 tables: vec!["users".into()],
260 committed_at: "ts".into(),
261 epoch: 0,
262 })
263 .await;
264 assert_eq!(sent, 1);
265 assert_eq!(pruned, 0);
266 let ev = rx.recv().await.expect("receive");
267 assert_eq!(ev.up_to_version, 5);
268 assert_eq!(ev.tables, vec!["users".to_string()]);
269 }
270
271 #[tokio::test]
272 async fn broadcast_prunes_dropped_receivers() {
273 let r = EdgeRegistry::new(10, Duration::from_secs(60));
274 let _rx_keep = r.register("edge-keep", "us-east", "u", "ts").unwrap();
275 {
276 let _rx_drop = r.register("edge-drop", "us-west", "u", "ts").unwrap();
277 }
279 let (sent, pruned) = r
280 .broadcast(InvalidationEvent {
281 up_to_version: 1,
282 tables: vec![],
283 committed_at: "ts".into(),
284 epoch: 0,
285 })
286 .await;
287 assert_eq!(sent, 1);
288 assert_eq!(pruned, 1);
289 assert_eq!(r.count(), 1);
290 }
291
292 #[test]
293 fn register_rejects_when_at_capacity() {
294 let r = EdgeRegistry::new(2, Duration::from_secs(60));
295 let _a = r.register("a", "us-east", "u", "ts").unwrap();
296 let _b = r.register("b", "us-west", "u", "ts").unwrap();
297 let err = r.register("c", "eu-west", "u", "ts").unwrap_err();
298 assert!(matches!(err, RegistryError::CapacityExceeded(2)));
299 }
300
301 #[test]
302 fn register_replaces_existing_id() {
303 let r = EdgeRegistry::new(2, Duration::from_secs(60));
304 let _a1 = r.register("a", "us-east", "u", "t1").unwrap();
305 let _a2 = r.register("a", "eu-west", "u", "t2").unwrap();
308 assert_eq!(r.count(), 1);
309 let nodes = r.list();
310 assert_eq!(nodes[0].region, "eu-west");
311 }
312
313 #[tokio::test]
314 async fn reregister_same_id_replaces_sender_and_closes_old_receiver() {
315 let r = EdgeRegistry::new(1, Duration::from_secs(60));
322 let mut rx_old = r.register("a", "us-east", "u", "t1").unwrap();
323 let mut rx_new = r.register("a", "us-east", "u", "t2").unwrap();
324 assert_eq!(r.count(), 1);
325
326 assert!(
328 rx_old.recv().await.is_none(),
329 "stale receiver must observe closure"
330 );
331
332 let (sent, pruned) = r
334 .broadcast(InvalidationEvent {
335 up_to_version: 9,
336 tables: vec![],
337 committed_at: "ts".into(),
338 epoch: 0,
339 })
340 .await;
341 assert_eq!((sent, pruned), (1, 0));
342 let ev = rx_new.recv().await.expect("new receiver is live");
343 assert_eq!(ev.up_to_version, 9);
344 }
345
346 #[test]
347 fn unregister_removes_edge() {
348 let r = EdgeRegistry::new(10, Duration::from_secs(60));
349 let _rx = r.register("edge-1", "us-east", "u", "ts").unwrap();
350 assert!(r.unregister("edge-1"));
351 assert_eq!(r.count(), 0);
352 assert!(!r.unregister("edge-1"));
354 }
355
356 #[test]
357 fn list_returns_snapshot() {
358 let r = EdgeRegistry::new(10, Duration::from_secs(60));
359 let _a = r.register("a", "r1", "u1", "ts").unwrap();
360 let _b = r.register("b", "r2", "u2", "ts").unwrap();
361 let mut nodes = r.list();
362 nodes.sort_by(|a, b| a.edge_id.cmp(&b.edge_id));
363 assert_eq!(nodes.len(), 2);
364 assert_eq!(nodes[0].edge_id, "a");
365 assert_eq!(nodes[1].edge_id, "b");
366 }
367
368 #[tokio::test]
369 async fn invalidations_sent_counter_increments() {
370 let r = EdgeRegistry::new(10, Duration::from_secs(60));
371 let mut _rx = r.register("e1", "r", "u", "ts").unwrap();
372 for _ in 0..3 {
373 let _ = r
374 .broadcast(InvalidationEvent {
375 up_to_version: 1,
376 tables: vec![],
377 committed_at: "ts".into(),
378 epoch: 0,
379 })
380 .await;
381 }
382 let n = r.list();
383 assert_eq!(n[0].invalidations_sent, 3);
384 }
385
386 #[test]
387 fn prune_stale_removes_old_entries() {
388 let r = EdgeRegistry::new(10, Duration::from_millis(10));
389 let _rx = r.register("old", "r", "u", "ts").unwrap();
390 std::thread::sleep(Duration::from_millis(20));
391 let pruned = r.prune_stale();
392 assert_eq!(pruned, 1);
393 assert_eq!(r.count(), 0);
394 }
395
396 #[test]
397 fn touch_keeps_edge_alive_across_prune() {
398 let r = EdgeRegistry::new(10, Duration::from_millis(50));
402 let _rx = r.register("e1", "r", "u", "t0").unwrap();
403 std::thread::sleep(Duration::from_millis(60));
404 assert!(r.touch("e1", "t1"));
405 assert_eq!(r.prune_stale(), 0, "touched edge survives the sweep");
406 assert_eq!(r.count(), 1);
407 assert_eq!(r.list()[0].last_seen, "t1", "surfaced timestamp advances");
408 std::thread::sleep(Duration::from_millis(60));
409 assert_eq!(r.prune_stale(), 1, "untouched edge ages out");
410 assert_eq!(r.count(), 0);
411 assert!(!r.touch("e1", "t2"));
413 }
414
415 #[test]
416 fn prune_stale_survives_near_boot_underflow() {
417 let r = EdgeRegistry::new(10, Duration::from_secs(u64::MAX));
422 let _rx = r.register("young", "r", "u", "ts").unwrap();
423 let pruned = r.prune_stale();
424 assert_eq!(pruned, 0);
425 assert_eq!(r.count(), 1);
426 }
427
428 #[tokio::test]
429 async fn broadcast_full_buffer_drops_event_but_keeps_edge() {
430 let r = EdgeRegistry::new(10, Duration::from_secs(60));
431 let mut rx = r.register("slow", "r", "u", "ts").unwrap();
432
433 for i in 0..64 {
435 let (sent, pruned) = r
436 .broadcast(InvalidationEvent {
437 up_to_version: i,
438 tables: vec![],
439 committed_at: "ts".into(),
440 epoch: 0,
441 })
442 .await;
443 assert_eq!((sent, pruned), (1, 0));
444 }
445
446 let (sent, pruned) = r
449 .broadcast(InvalidationEvent {
450 up_to_version: 64,
451 tables: vec![],
452 committed_at: "ts".into(),
453 epoch: 0,
454 })
455 .await;
456 assert_eq!(sent, 0);
457 assert_eq!(pruned, 0);
458 assert_eq!(r.count(), 1);
459 assert_eq!(r.list()[0].invalidations_sent, 64);
461
462 let first = rx.recv().await.expect("receive");
464 assert_eq!(first.up_to_version, 0);
465 let (sent, _) = r
466 .broadcast(InvalidationEvent {
467 up_to_version: 65,
468 tables: vec![],
469 committed_at: "ts".into(),
470 epoch: 0,
471 })
472 .await;
473 assert_eq!(sent, 1);
474 }
475}