1use std::sync::Arc;
22use std::time::Duration;
23
24use super::cache::EdgeCache;
25use super::registry::InvalidationEvent;
26use super::EdgeConfig;
27
28const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
31
32const BACKOFF_INITIAL: Duration = Duration::from_millis(500);
34
35const BACKOFF_CAP: Duration = Duration::from_secs(10);
37
38const IDLE_TIMEOUT: Duration = Duration::from_secs(45);
43
44const MAX_BUFFERED_LINE: usize = 256 * 1024;
50
51pub struct EdgeClient;
53
54impl EdgeClient {
55 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
67async 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 let mut builder = reqwest::Client::builder().connect_timeout(CONNECT_TIMEOUT);
80 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 backoff = BACKOFF_INITIAL;
113 match pump_stream(resp, &cache).await {
114 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
144async fn open_stream(
147 client: &reqwest::Client,
148 url: &str,
149 cfg: &EdgeConfig,
150 edge_id: &str,
151) -> Result<reqwest::Response, String> {
152 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
171async 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(()), Ok(Ok(Some(c))) => c,
190 };
191 for payload in parser.feed(&chunk) {
192 apply_invalidation(&payload, cache);
193 }
194 }
195}
196
197fn 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
241fn 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#[derive(Debug, Default)]
264struct SseParser {
265 buf: Vec<u8>,
267 data_lines: Vec<String>,
269}
270
271impl SseParser {
272 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 let mut line: Vec<u8> = self.buf.drain(..=nl).collect();
280 line.pop(); 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 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; }
308 if let Some(rest) = line.strip_prefix(b"data:") {
309 let rest = rest.strip_prefix(b" ").unwrap_or(rest);
311 self.data_lines
312 .push(String::from_utf8_lossy(rest).into_owned());
313 }
314 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::edge::cache::{CacheEntry, CacheKey};
322 use std::time::Instant;
323
324 #[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 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 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 let got = p.feed(b"junk-tail\ndata: ok\n\n");
385 assert_eq!(got, vec!["ok".to_string()]);
386 }
387
388 #[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 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 assert!(!cache.should_cache(5));
411 assert_eq!(cache.observed_home_version(), 5);
413 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 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 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 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); assert_eq!(cache.stats().invalidations_received, 0);
454 }
455
456 #[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}