Skip to main content

heliosdb_proxy/edge/
client.rs

1//! Edge-role invalidation client (control plane).
2//!
3//! When this proxy runs with `role = "edge"`, `EdgeClient::spawn`
4//! holds a long-lived SSE subscription against the home proxy's
5//! *admin* listener (`GET /api/edge/subscribe`, same bearer gate as
6//! every other admin route). Each `event: invalidate` frame the home
7//! pushes is applied to the local `EdgeCache`: matching entries are
8//! dropped and the local version clock is advanced past the home's
9//! (`observe_home_version`), so reads cached after the event can
10//! never be mistaken for pre-invalidation state.
11//!
12//! Delivery is best-effort by design. A dropped connection is
13//! re-established with capped exponential backoff (the subscribe
14//! endpoint re-registers the edge on every connect), and any events
15//! missed while disconnected are covered by the cache TTL — the
16//! bounded-staleness contract from the module doc. The data plane
17//! (cache misses and writes) does NOT flow through here: it uses the
18//! ordinary PG-wire forwarding path with the home listed as this
19//! proxy's backend node.
20
21use std::sync::Arc;
22use std::time::Duration;
23
24use super::cache::EdgeCache;
25use super::registry::InvalidationEvent;
26use super::EdgeConfig;
27
28/// TCP/TLS connect budget. The request itself gets NO overall timeout
29/// — the SSE stream must live forever.
30const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
31
32/// First reconnect delay; doubles per consecutive failure.
33const BACKOFF_INITIAL: Duration = Duration::from_millis(500);
34
35/// Reconnect delay ceiling.
36const BACKOFF_CAP: Duration = Duration::from_secs(10);
37
38/// The home writes a `: keepalive` comment every 15s. Three missed
39/// beats with no other traffic means the connection is presumed dead
40/// (half-open TCP after a home crash / NAT drop would otherwise leave
41/// the edge deaf forever) — tear down and reconnect.
42const IDLE_TIMEOUT: Duration = Duration::from_secs(45);
43
44/// One SSE line is a single JSON invalidation event — tiny. A partial
45/// line larger than this means the stream is garbage; drop the
46/// buffered prefix instead of ballooning memory. The tail of the
47/// oversized line later parses as junk fields and is skipped, so the
48/// parser resynchronises on the next real frame.
49const MAX_BUFFERED_LINE: usize = 256 * 1024;
50
51/// Handle-less namespace for the edge-side subscribe loop.
52pub struct EdgeClient;
53
54impl EdgeClient {
55    /// Spawn the background subscribe/reconnect loop. Runs for the
56    /// life of the process (abort the returned handle to stop it).
57    ///
58    /// The server wires this up only when `edge.enabled` and
59    /// `role == Edge`; the loop itself just trusts its config.
60    pub fn spawn(cfg: EdgeConfig, cache: Arc<EdgeCache>) -> tokio::task::JoinHandle<()> {
61        tokio::spawn(async move {
62            run_subscribe_loop(cfg, cache).await;
63        })
64    }
65}
66
67/// Reconnect-forever driver: connect, pump events until the stream
68/// dies, back off, repeat. Backoff resets on every successful connect.
69async fn run_subscribe_loop(cfg: EdgeConfig, cache: Arc<EdgeCache>) {
70    let edge_id = resolve_edge_id(&cfg.edge_id);
71    let subscribe_url = format!("{}/api/edge/subscribe", cfg.home_url.trim_end_matches('/'));
72
73    // One client for the life of the task. Connect timeout only — an
74    // overall request timeout would kill the healthy long-lived GET.
75    // For an https home_url, refuse any downgrade (a redirect to
76    // plain http would otherwise re-send the admin bearer cleartext);
77    // validate() already refuses a plain-http home_url when an
78    // auth_token is configured (unless explicitly opted out).
79    let mut builder = reqwest::Client::builder().connect_timeout(CONNECT_TIMEOUT);
80    // URL schemes are case-insensitive (RFC 3986); lower before matching so an
81    // `HTTPS://` home still gets downgrade protection.
82    if cfg
83        .home_url
84        .trim_start()
85        .to_ascii_lowercase()
86        .starts_with("https://")
87    {
88        builder = builder.https_only(true);
89    }
90    let client = match builder.build() {
91        Ok(c) => c,
92        Err(e) => {
93            tracing::error!(
94                error = %e,
95                "edge client: failed to build HTTP client — invalidation subscription disabled"
96            );
97            return;
98        }
99    };
100
101    let mut backoff = BACKOFF_INITIAL;
102    loop {
103        match open_stream(&client, &subscribe_url, &cfg, &edge_id).await {
104            Ok(resp) => {
105                tracing::info!(
106                    edge_id = %edge_id,
107                    region = %cfg.region,
108                    url = %subscribe_url,
109                    "edge client: subscribed to home invalidation stream"
110                );
111                // Successful connect resets the backoff ladder.
112                backoff = BACKOFF_INITIAL;
113                match pump_stream(resp, &cache).await {
114                    // Clean EOF: home closed us (shutdown, an eviction,
115                    // or a same-id re-register replaced this stream) —
116                    // reconnecting re-registers. Healthy idle edges are
117                    // NOT GC-churned: successful heartbeat writes
118                    // refresh registry liveness on the home.
119                    Ok(()) => tracing::info!(
120                        edge_id = %edge_id,
121                        "edge client: home closed the invalidation stream — reconnecting"
122                    ),
123                    Err(e) => tracing::warn!(
124                        edge_id = %edge_id,
125                        error = %e,
126                        "edge client: invalidation stream failed — reconnecting"
127                    ),
128                }
129            }
130            Err(e) => {
131                tracing::warn!(
132                    edge_id = %edge_id,
133                    error = %e,
134                    retry_in_ms = backoff.as_millis() as u64,
135                    "edge client: subscribe attempt failed"
136                );
137            }
138        }
139        tokio::time::sleep(backoff).await;
140        backoff = next_backoff(backoff);
141    }
142}
143
144/// One subscribe attempt: send the GET, require a 2xx. Returns the
145/// still-streaming response on success.
146async fn open_stream(
147    client: &reqwest::Client,
148    url: &str,
149    cfg: &EdgeConfig,
150    edge_id: &str,
151) -> Result<reqwest::Response, String> {
152    // `query` url-encodes the values. `base_url` is the callback slot
153    // the registry records for future ack-checks — empty today (the
154    // edge has no HTTP listener of its own to advertise).
155    let mut req = client.get(url).query(&[
156        ("edge_id", edge_id),
157        ("region", cfg.region.as_str()),
158        ("base_url", ""),
159    ]);
160    if !cfg.auth_token.is_empty() {
161        req = req.bearer_auth(&cfg.auth_token);
162    }
163    let resp = req.send().await.map_err(|e| format!("request: {}", e))?;
164    let status = resp.status();
165    if !status.is_success() {
166        return Err(format!("home returned HTTP {}", status));
167    }
168    Ok(resp)
169}
170
171/// Read the SSE body chunk-by-chunk until EOF (`Ok`), a read error, or
172/// an idle timeout (`Err`), applying every completed invalidation
173/// frame to the cache as it arrives.
174///
175/// `bytes_stream()` needs reqwest's `stream` feature, which this crate
176/// doesn't enable — `Response::chunk()` in a loop is the equivalent.
177async fn pump_stream(mut resp: reqwest::Response, cache: &EdgeCache) -> Result<(), String> {
178    let mut parser = SseParser::default();
179    loop {
180        let chunk = match tokio::time::timeout(IDLE_TIMEOUT, resp.chunk()).await {
181            Err(_) => {
182                return Err(format!(
183                    "no data or heartbeat for {}s — connection presumed dead",
184                    IDLE_TIMEOUT.as_secs()
185                ))
186            }
187            Ok(Err(e)) => return Err(format!("read: {}", e)),
188            Ok(Ok(None)) => return Ok(()), // clean EOF
189            Ok(Ok(Some(c))) => c,
190        };
191        for payload in parser.feed(&chunk) {
192            apply_invalidation(&payload, cache);
193        }
194    }
195}
196
197/// Parse one `data:` payload as an `InvalidationEvent` and apply it.
198/// Order is load-bearing:
199///
200/// 1. `on_home_epoch` — a changed home epoch (home restart) flushes
201///    everything and resets the observed-home clock, since stamps from
202///    the previous epoch are incomparable with the new counter.
203/// 2. `invalidate` — drops matching entries and bumps the invalidation
204///    epoch BEFORE the map lock, rejecting in-flight stores.
205/// 3. `observe_home_version` — advances the observed-home clock LAST,
206///    so an entry stamped `H` is only insertable after `invalidate(H)`
207///    already ran and can never be swept by its own event.
208///
209/// Unparseable frames are skipped (warn), never fatal.
210fn apply_invalidation(payload: &str, cache: &EdgeCache) {
211    match serde_json::from_str::<InvalidationEvent>(payload) {
212        Ok(ev) => {
213            let flushed = cache.on_home_epoch(ev.epoch);
214            if flushed > 0 {
215                tracing::info!(
216                    epoch = ev.epoch,
217                    flushed,
218                    "edge client: home epoch changed (home restart) — flushed local cache"
219                );
220            }
221            let dropped = cache.invalidate(ev.up_to_version, &ev.tables);
222            cache.observe_home_version(ev.up_to_version);
223            tracing::info!(
224                up_to_version = ev.up_to_version,
225                tables = ?ev.tables,
226                dropped,
227                committed_at = %ev.committed_at,
228                "edge client: applied invalidation from home"
229            );
230        }
231        Err(e) => {
232            tracing::warn!(
233                error = %e,
234                payload = %payload,
235                "edge client: skipping unparseable invalidation frame"
236            );
237        }
238    }
239}
240
241/// Stable id this edge registers under: the configured `edge_id`, or
242/// `edge-<pid>` when unset (the documented `EdgeConfig` fallback).
243fn resolve_edge_id(configured: &str) -> String {
244    if configured.is_empty() {
245        format!("edge-{}", std::process::id())
246    } else {
247        configured.to_string()
248    }
249}
250
251fn next_backoff(cur: Duration) -> Duration {
252    (cur * 2).min(BACKOFF_CAP)
253}
254
255/// Incremental server-sent-events parser.
256///
257/// Feed raw body bytes as they arrive; the `data:` payloads of every
258/// event completed by that chunk come back in order. Handles frames
259/// split anywhere across chunk boundaries, multiple frames per chunk,
260/// `\r\n` line endings, comment/heartbeat lines (leading `:`), and
261/// ignores non-data fields (`event:`, `id:`, `retry:`) — the home only
262/// ever emits `event: invalidate`, so the event name carries nothing.
263#[derive(Debug, Default)]
264struct SseParser {
265    /// Unconsumed bytes — at most one partial line between feeds.
266    buf: Vec<u8>,
267    /// `data:` lines of the event currently being accumulated.
268    data_lines: Vec<String>,
269}
270
271impl SseParser {
272    /// Append a chunk and return the payloads of all events it
273    /// completed.
274    fn feed(&mut self, chunk: &[u8]) -> Vec<String> {
275        self.buf.extend_from_slice(chunk);
276        let mut completed = Vec::new();
277        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
278            // Take the line out of the buffer, without its terminator.
279            let mut line: Vec<u8> = self.buf.drain(..=nl).collect();
280            line.pop(); // the '\n'
281            if line.last() == Some(&b'\r') {
282                line.pop();
283            }
284            self.process_line(&line, &mut completed);
285        }
286        if self.buf.len() > MAX_BUFFERED_LINE {
287            tracing::warn!(
288                buffered = self.buf.len(),
289                "edge client: oversized SSE line — discarding buffered bytes"
290            );
291            self.buf.clear();
292        }
293        completed
294    }
295
296    fn process_line(&mut self, line: &[u8], completed: &mut Vec<String>) {
297        if line.is_empty() {
298            // Blank line = event boundary: dispatch what accumulated.
299            if !self.data_lines.is_empty() {
300                completed.push(self.data_lines.join("\n"));
301                self.data_lines.clear();
302            }
303            return;
304        }
305        if line.starts_with(b":") {
306            return; // comment — the home's keepalive heartbeat
307        }
308        if let Some(rest) = line.strip_prefix(b"data:") {
309            // The SSE spec strips exactly one space after the colon.
310            let rest = rest.strip_prefix(b" ").unwrap_or(rest);
311            self.data_lines
312                .push(String::from_utf8_lossy(rest).into_owned());
313        }
314        // Every other field (`event:`, `id:`, `retry:`) is ignored.
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::edge::cache::{CacheEntry, CacheKey};
322    use std::time::Instant;
323
324    // ---- SSE parser ----
325
326    #[test]
327    fn parser_extracts_single_event() {
328        let mut p = SseParser::default();
329        let got = p.feed(b"event: invalidate\ndata: {\"up_to_version\":3}\n\n");
330        assert_eq!(got, vec!["{\"up_to_version\":3}".to_string()]);
331    }
332
333    #[test]
334    fn parser_handles_frames_split_across_chunks() {
335        let mut p = SseParser::default();
336        assert!(p.feed(b"event: inval").is_empty());
337        assert!(p.feed(b"idate\nda").is_empty());
338        assert!(p.feed(b"ta: {\"a\":1}").is_empty());
339        // The terminating blank line arrives in two pieces too.
340        assert!(p.feed(b"\n").is_empty());
341        let got = p.feed(b"\n");
342        assert_eq!(got, vec!["{\"a\":1}".to_string()]);
343    }
344
345    #[test]
346    fn parser_ignores_heartbeat_comments() {
347        let mut p = SseParser::default();
348        assert!(p.feed(b": keepalive\n\n").is_empty());
349        // Heartbeats interleaved with a real event don't disturb it.
350        let got = p.feed(b": keepalive\n\ndata: X\n\n: keepalive\n\n");
351        assert_eq!(got, vec!["X".to_string()]);
352    }
353
354    #[test]
355    fn parser_yields_multiple_events_from_one_chunk() {
356        let mut p = SseParser::default();
357        let got =
358            p.feed(b"event: invalidate\ndata: A\n\nevent: invalidate\ndata: B\n\ndata: C\n\n");
359        assert_eq!(got, vec!["A".to_string(), "B".to_string(), "C".to_string()]);
360    }
361
362    #[test]
363    fn parser_tolerates_crlf_and_unspaced_data() {
364        let mut p = SseParser::default();
365        let got = p.feed(b"data:no-space\r\n\r\n");
366        assert_eq!(got, vec!["no-space".to_string()]);
367    }
368
369    #[test]
370    fn parser_joins_multi_line_data_per_sse_spec() {
371        let mut p = SseParser::default();
372        let got = p.feed(b"data: line1\ndata: line2\n\n");
373        assert_eq!(got, vec!["line1\nline2".to_string()]);
374    }
375
376    #[test]
377    fn parser_discards_oversized_garbage_line() {
378        let mut p = SseParser::default();
379        let junk = vec![b'x'; MAX_BUFFERED_LINE + 1];
380        assert!(p.feed(&junk).is_empty());
381        assert!(p.buf.is_empty(), "oversized partial line dropped");
382        // Resynchronises on the next complete frame: the tail of the
383        // giant line reads as one junk field line and is ignored.
384        let got = p.feed(b"junk-tail\ndata: ok\n\n");
385        assert_eq!(got, vec!["ok".to_string()]);
386    }
387
388    // ---- event application ----
389
390    #[test]
391    fn apply_invalidation_drops_entries_and_advances_clock() {
392        let cache = EdgeCache::new(10);
393        cache.insert(
394            CacheKey::new("fp", "p"),
395            CacheEntry {
396                version: 1,
397                response_bytes: bytes::Bytes::from_static(b"row"),
398                tables: vec!["users".into()],
399                expires_at: Instant::now() + Duration::from_secs(60),
400            },
401        );
402        // Legacy frame without an epoch field parses (serde default 0)
403        // and never triggers epoch handling.
404        apply_invalidation(
405            r#"{"up_to_version":5,"tables":["users"],"committed_at":"ts"}"#,
406            &cache,
407        );
408        assert!(cache.get(&CacheKey::new("fp", "p")).is_none());
409        // hwm gate: reads stamped at or below 5 are no longer cacheable…
410        assert!(!cache.should_cache(5));
411        // …the observed-home clock tracks the event…
412        assert_eq!(cache.observed_home_version(), 5);
413        // …and the local clock jumped past the home's.
414        assert!(cache.next_version() > 5);
415        assert_eq!(cache.stats().invalidations_received, 1);
416    }
417
418    #[test]
419    fn apply_invalidation_epoch_change_flushes_cache() {
420        // F9: a restarted home resets its version clock. Entries
421        // stamped under the previous epoch (arbitrarily high) must not
422        // survive just because the new counter is small.
423        let cache = EdgeCache::new(10);
424        apply_invalidation(
425            r#"{"up_to_version":1000000,"tables":[],"committed_at":"ts","epoch":11}"#,
426            &cache,
427        );
428        assert_eq!(cache.observed_home_version(), 1_000_000);
429        cache.insert(
430            CacheKey::new("fp", "p"),
431            CacheEntry {
432                version: cache.observed_home_version(),
433                response_bytes: bytes::Bytes::from_static(b"row"),
434                tables: vec!["users".into()],
435                expires_at: Instant::now() + Duration::from_secs(60),
436            },
437        );
438        // New epoch, tiny post-restart version: the old-scheme sweep
439        // (2 <= 1000000) would drop nothing — the epoch flush must.
440        apply_invalidation(
441            r#"{"up_to_version":2,"tables":["users"],"committed_at":"ts","epoch":22}"#,
442            &cache,
443        );
444        assert!(cache.get(&CacheKey::new("fp", "p")).is_none());
445        // Clock re-synced to the new home domain.
446        assert_eq!(cache.observed_home_version(), 2);
447    }
448
449    #[test]
450    fn apply_invalidation_skips_garbage_payload() {
451        let cache = EdgeCache::new(10);
452        apply_invalidation("not json", &cache); // must not panic
453        assert_eq!(cache.stats().invalidations_received, 0);
454    }
455
456    // ---- helpers ----
457
458    #[test]
459    fn edge_id_falls_back_to_pid() {
460        assert_eq!(resolve_edge_id("edge-eu-1"), "edge-eu-1");
461        assert_eq!(resolve_edge_id(""), format!("edge-{}", std::process::id()));
462    }
463
464    #[test]
465    fn backoff_doubles_to_cap_only() {
466        let mut b = BACKOFF_INITIAL;
467        let mut seen = Vec::new();
468        for _ in 0..8 {
469            seen.push(b.as_millis());
470            b = next_backoff(b);
471        }
472        assert_eq!(seen, vec![500, 1000, 2000, 4000, 8000, 10000, 10000, 10000]);
473    }
474}