Skip to main content

heliosdb_proxy/edge/
registry.rs

1//! Home-side registry of subscribed edges + invalidation broadcast.
2//!
3//! When an edge boots in `EdgeRole::Edge`, it calls home's
4//! `POST /api/edge/register` once at startup. The home stores the
5//! edge's address + auth token and adds it to the broadcast set.
6//!
7//! On every committed write, the home calls `broadcast` with an
8//! `InvalidationEvent { up_to_version, tables }`. Each registered
9//! edge receives a copy via the SSE channel that `register` opened.
10//!
11//! Delivery is best-effort by design: a full per-edge buffer means
12//! that edge just misses the event (it is *not* pruned for being
13//! slow — only closed channels and liveness-window expiry prune).
14//! A missed invalidation degrades correctness only as far as the
15//! cache TTL: stale entries age out within `default_ttl`. That's the
16//! explicit "bounded staleness" contract from the module doc.
17
18use 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/// One registered edge node from the home's perspective.
26#[derive(Debug, Clone, Serialize)]
27pub struct EdgeNode {
28    pub edge_id: String,
29    pub region: String,
30    /// HTTP base URL the home pings for ack-checks.
31    pub base_url: String,
32    /// First-seen + last-acked timestamps.
33    pub registered_at: String,
34    pub last_seen: String,
35    /// Total invalidations broadcast to this edge.
36    pub invalidations_sent: u64,
37}
38
39/// Wire shape of an invalidation. Carried over the SSE channel from
40/// home to every edge.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct InvalidationEvent {
43    /// Logical version assigned by the home at write commit.
44    pub up_to_version: u64,
45    /// Tables touched. Empty = invalidate every cached entry within
46    /// the version bound.
47    pub tables: Vec<String>,
48    /// Wall-clock at which the home committed the write — useful for
49    /// log correlation.
50    pub committed_at: String,
51    /// The home's per-boot epoch. A restarted home resets its version
52    /// clock, so version bounds are only comparable within one epoch;
53    /// an edge that observes a NEW epoch flushes its cache and
54    /// re-syncs its clock. `0` (the serde default, for events from
55    /// homes that predate this field) disables epoch handling.
56    #[serde(default)]
57    pub epoch: u64,
58}
59
60/// Per-edge in-process channel the registry pushes events into.
61/// Each edge holds the matching receiver in its SSE connection.
62struct EdgeSubscription {
63    node: EdgeNode,
64    sender: mpsc::Sender<InvalidationEvent>,
65    /// last_seen as Instant for liveness check (the public node
66    /// stringifies this too).
67    last_seen_inst: Instant,
68}
69
70/// Home-side edge registry. Cheap to clone via Arc.
71#[derive(Clone)]
72pub struct EdgeRegistry {
73    inner: Arc<RwLock<HashMap<String, EdgeSubscription>>>,
74    max_edges: usize,
75    /// Edges that don't ack within this window get expired on the
76    /// next broadcast pass.
77    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    /// Register a new edge. Returns the receiver the SSE handler
90    /// holds open. Caller is responsible for keeping the receiver
91    /// alive — when it drops, the next broadcast prunes the edge.
92    ///
93    /// The channel is bounded: a slow edge that doesn't drain fast
94    /// enough starts *missing* events (`broadcast` uses `try_send`,
95    /// never blocking the fan-out on one consumer). Capacity 64
96    /// lets bursts ride through without dropping.
97    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    /// Remove an edge — used when the home decides to evict
126    /// (manual unregister, or cleanup during shutdown).
127    pub fn unregister(&self, edge_id: &str) -> bool {
128        self.inner.write().remove(edge_id).is_some()
129    }
130
131    /// Broadcast an invalidation to every subscribed edge. Edges
132    /// whose channel has closed (receiver dropped) are pruned.
133    /// Returns (sent, pruned).
134    ///
135    /// Per-edge `try_send` so one slow edge can never stall the whole
136    /// fan-out (this runs on the write path). A full buffer counts as
137    /// not-sent — the edge keeps its slot (only closed channels are
138    /// pruned here; a persistently-stuck edge stops getting `last_seen`
139    /// bumps and ages out via `prune_stale` instead) and the missed
140    /// event is covered by the bounded-staleness TTL contract.
141    pub async fn broadcast(&self, ev: InvalidationEvent) -> (u32, u32) {
142        use tokio::sync::mpsc::error::TrySendError;
143
144        // try_send never awaits, so doing the whole pass under the
145        // write lock is safe (no lock held across an await point).
146        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                    // Keep the surfaced timestamp honest too (the event
156                    // already carries a wall-clock string — reuse it).
157                    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    /// Refresh an edge's liveness. Called by the SSE handler after
179    /// every successfully-written heartbeat, so a healthy but
180    /// write-idle home never GC-prunes its live subscribers —
181    /// `prune_stale` is thereby demoted to a backstop for wedged or
182    /// dead connections whose heartbeat writes stop succeeding.
183    /// Returns false when the edge_id is not registered (e.g. a
184    /// concurrent re-register replaced then dropped the slot).
185    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    /// Read-only snapshot of currently-registered edges. Used by
198    /// the admin UI / `/api/edge` endpoint.
199    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    /// Garbage-collect edges that haven't been seen within
208    /// `liveness_window`. Returns the count pruned.
209    pub fn prune_stale(&self) -> u32 {
210        // `Instant - Duration` panics on underflow, and the monotonic
211        // clock can start near zero (e.g. shortly after host boot). If
212        // the process hasn't even been up for one liveness window,
213        // nothing can be stale yet — prune nothing this pass.
214        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            // _rx_drop dropped at the end of this scope.
278        }
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        // Re-register with same id under a different region — replaces
306        // the slot, count stays the same.
307        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        // Edge reconnect: after a network drop the edge re-subscribes
316        // under the SAME edge_id while its stale SSE connection may
317        // linger up to a heartbeat interval. The re-register must
318        // replace the slot's sender — the old receiver then reads None
319        // so the stale subscribe loop exits — and must succeed even at
320        // max_edges capacity (no double-counted slot).
321        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        // Old channel's sender was dropped by the replacement → closed.
327        assert!(
328            rx_old.recv().await.is_none(),
329            "stale receiver must observe closure"
330        );
331
332        // Broadcasts reach only the new subscription.
333        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        // Idempotent.
353        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        // Heartbeat-touch semantics: a healthy idle edge whose SSE
399        // heartbeats keep succeeding must never be GC-pruned; once the
400        // touches stop, the liveness window reaps it.
401        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        // Touching an unknown id is a no-op.
412        assert!(!r.touch("e1", "t2"));
413    }
414
415    #[test]
416    fn prune_stale_survives_near_boot_underflow() {
417        // Regression: `Instant::now() - liveness_window` used to panic
418        // when the monotonic clock was younger than the window. A window
419        // larger than any possible uptime forces the checked_sub(None)
420        // path deterministically: prune nothing, keep every edge.
421        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        // Fill the bounded channel (capacity 64) without draining.
434        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        // Buffer full: the event is dropped for this edge (not sent),
447        // but the edge is NOT pruned — slowness isn't death.
448        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        // invalidations_sent counts only actual deliveries.
460        assert_eq!(r.list()[0].invalidations_sent, 64);
461
462        // Once the edge drains, delivery resumes.
463        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}