use std::sync::Arc;
use std::time::Duration;
use super::cache::EdgeCache;
use super::registry::InvalidationEvent;
use super::EdgeConfig;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const BACKOFF_INITIAL: Duration = Duration::from_millis(500);
const BACKOFF_CAP: Duration = Duration::from_secs(10);
const IDLE_TIMEOUT: Duration = Duration::from_secs(45);
const MAX_BUFFERED_LINE: usize = 256 * 1024;
pub struct EdgeClient;
impl EdgeClient {
pub fn spawn(cfg: EdgeConfig, cache: Arc<EdgeCache>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
run_subscribe_loop(cfg, cache).await;
})
}
}
async fn run_subscribe_loop(cfg: EdgeConfig, cache: Arc<EdgeCache>) {
let edge_id = resolve_edge_id(&cfg.edge_id);
let subscribe_url = format!("{}/api/edge/subscribe", cfg.home_url.trim_end_matches('/'));
let mut builder = reqwest::Client::builder().connect_timeout(CONNECT_TIMEOUT);
if cfg
.home_url
.trim_start()
.to_ascii_lowercase()
.starts_with("https://")
{
builder = builder.https_only(true);
}
let client = match builder.build() {
Ok(c) => c,
Err(e) => {
tracing::error!(
error = %e,
"edge client: failed to build HTTP client — invalidation subscription disabled"
);
return;
}
};
let mut backoff = BACKOFF_INITIAL;
loop {
match open_stream(&client, &subscribe_url, &cfg, &edge_id).await {
Ok(resp) => {
tracing::info!(
edge_id = %edge_id,
region = %cfg.region,
url = %subscribe_url,
"edge client: subscribed to home invalidation stream"
);
backoff = BACKOFF_INITIAL;
match pump_stream(resp, &cache).await {
Ok(()) => tracing::info!(
edge_id = %edge_id,
"edge client: home closed the invalidation stream — reconnecting"
),
Err(e) => tracing::warn!(
edge_id = %edge_id,
error = %e,
"edge client: invalidation stream failed — reconnecting"
),
}
}
Err(e) => {
tracing::warn!(
edge_id = %edge_id,
error = %e,
retry_in_ms = backoff.as_millis() as u64,
"edge client: subscribe attempt failed"
);
}
}
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff);
}
}
async fn open_stream(
client: &reqwest::Client,
url: &str,
cfg: &EdgeConfig,
edge_id: &str,
) -> Result<reqwest::Response, String> {
let mut req = client.get(url).query(&[
("edge_id", edge_id),
("region", cfg.region.as_str()),
("base_url", ""),
]);
if !cfg.auth_token.is_empty() {
req = req.bearer_auth(&cfg.auth_token);
}
let resp = req.send().await.map_err(|e| format!("request: {}", e))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("home returned HTTP {}", status));
}
Ok(resp)
}
async fn pump_stream(mut resp: reqwest::Response, cache: &EdgeCache) -> Result<(), String> {
let mut parser = SseParser::default();
loop {
let chunk = match tokio::time::timeout(IDLE_TIMEOUT, resp.chunk()).await {
Err(_) => {
return Err(format!(
"no data or heartbeat for {}s — connection presumed dead",
IDLE_TIMEOUT.as_secs()
))
}
Ok(Err(e)) => return Err(format!("read: {}", e)),
Ok(Ok(None)) => return Ok(()), Ok(Ok(Some(c))) => c,
};
for payload in parser.feed(&chunk) {
apply_invalidation(&payload, cache);
}
}
}
fn apply_invalidation(payload: &str, cache: &EdgeCache) {
match serde_json::from_str::<InvalidationEvent>(payload) {
Ok(ev) => {
let flushed = cache.on_home_epoch(ev.epoch);
if flushed > 0 {
tracing::info!(
epoch = ev.epoch,
flushed,
"edge client: home epoch changed (home restart) — flushed local cache"
);
}
let dropped = cache.invalidate(ev.up_to_version, &ev.tables);
cache.observe_home_version(ev.up_to_version);
tracing::info!(
up_to_version = ev.up_to_version,
tables = ?ev.tables,
dropped,
committed_at = %ev.committed_at,
"edge client: applied invalidation from home"
);
}
Err(e) => {
tracing::warn!(
error = %e,
payload = %payload,
"edge client: skipping unparseable invalidation frame"
);
}
}
}
fn resolve_edge_id(configured: &str) -> String {
if configured.is_empty() {
format!("edge-{}", std::process::id())
} else {
configured.to_string()
}
}
fn next_backoff(cur: Duration) -> Duration {
(cur * 2).min(BACKOFF_CAP)
}
#[derive(Debug, Default)]
struct SseParser {
buf: Vec<u8>,
data_lines: Vec<String>,
}
impl SseParser {
fn feed(&mut self, chunk: &[u8]) -> Vec<String> {
self.buf.extend_from_slice(chunk);
let mut completed = Vec::new();
while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
let mut line: Vec<u8> = self.buf.drain(..=nl).collect();
line.pop(); if line.last() == Some(&b'\r') {
line.pop();
}
self.process_line(&line, &mut completed);
}
if self.buf.len() > MAX_BUFFERED_LINE {
tracing::warn!(
buffered = self.buf.len(),
"edge client: oversized SSE line — discarding buffered bytes"
);
self.buf.clear();
}
completed
}
fn process_line(&mut self, line: &[u8], completed: &mut Vec<String>) {
if line.is_empty() {
if !self.data_lines.is_empty() {
completed.push(self.data_lines.join("\n"));
self.data_lines.clear();
}
return;
}
if line.starts_with(b":") {
return; }
if let Some(rest) = line.strip_prefix(b"data:") {
let rest = rest.strip_prefix(b" ").unwrap_or(rest);
self.data_lines
.push(String::from_utf8_lossy(rest).into_owned());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edge::cache::{CacheEntry, CacheKey};
use std::time::Instant;
#[test]
fn parser_extracts_single_event() {
let mut p = SseParser::default();
let got = p.feed(b"event: invalidate\ndata: {\"up_to_version\":3}\n\n");
assert_eq!(got, vec!["{\"up_to_version\":3}".to_string()]);
}
#[test]
fn parser_handles_frames_split_across_chunks() {
let mut p = SseParser::default();
assert!(p.feed(b"event: inval").is_empty());
assert!(p.feed(b"idate\nda").is_empty());
assert!(p.feed(b"ta: {\"a\":1}").is_empty());
assert!(p.feed(b"\n").is_empty());
let got = p.feed(b"\n");
assert_eq!(got, vec!["{\"a\":1}".to_string()]);
}
#[test]
fn parser_ignores_heartbeat_comments() {
let mut p = SseParser::default();
assert!(p.feed(b": keepalive\n\n").is_empty());
let got = p.feed(b": keepalive\n\ndata: X\n\n: keepalive\n\n");
assert_eq!(got, vec!["X".to_string()]);
}
#[test]
fn parser_yields_multiple_events_from_one_chunk() {
let mut p = SseParser::default();
let got =
p.feed(b"event: invalidate\ndata: A\n\nevent: invalidate\ndata: B\n\ndata: C\n\n");
assert_eq!(got, vec!["A".to_string(), "B".to_string(), "C".to_string()]);
}
#[test]
fn parser_tolerates_crlf_and_unspaced_data() {
let mut p = SseParser::default();
let got = p.feed(b"data:no-space\r\n\r\n");
assert_eq!(got, vec!["no-space".to_string()]);
}
#[test]
fn parser_joins_multi_line_data_per_sse_spec() {
let mut p = SseParser::default();
let got = p.feed(b"data: line1\ndata: line2\n\n");
assert_eq!(got, vec!["line1\nline2".to_string()]);
}
#[test]
fn parser_discards_oversized_garbage_line() {
let mut p = SseParser::default();
let junk = vec![b'x'; MAX_BUFFERED_LINE + 1];
assert!(p.feed(&junk).is_empty());
assert!(p.buf.is_empty(), "oversized partial line dropped");
let got = p.feed(b"junk-tail\ndata: ok\n\n");
assert_eq!(got, vec!["ok".to_string()]);
}
#[test]
fn apply_invalidation_drops_entries_and_advances_clock() {
let cache = EdgeCache::new(10);
cache.insert(
CacheKey::new("fp", "p"),
CacheEntry {
version: 1,
response_bytes: bytes::Bytes::from_static(b"row"),
tables: vec!["users".into()],
expires_at: Instant::now() + Duration::from_secs(60),
},
);
apply_invalidation(
r#"{"up_to_version":5,"tables":["users"],"committed_at":"ts"}"#,
&cache,
);
assert!(cache.get(&CacheKey::new("fp", "p")).is_none());
assert!(!cache.should_cache(5));
assert_eq!(cache.observed_home_version(), 5);
assert!(cache.next_version() > 5);
assert_eq!(cache.stats().invalidations_received, 1);
}
#[test]
fn apply_invalidation_epoch_change_flushes_cache() {
let cache = EdgeCache::new(10);
apply_invalidation(
r#"{"up_to_version":1000000,"tables":[],"committed_at":"ts","epoch":11}"#,
&cache,
);
assert_eq!(cache.observed_home_version(), 1_000_000);
cache.insert(
CacheKey::new("fp", "p"),
CacheEntry {
version: cache.observed_home_version(),
response_bytes: bytes::Bytes::from_static(b"row"),
tables: vec!["users".into()],
expires_at: Instant::now() + Duration::from_secs(60),
},
);
apply_invalidation(
r#"{"up_to_version":2,"tables":["users"],"committed_at":"ts","epoch":22}"#,
&cache,
);
assert!(cache.get(&CacheKey::new("fp", "p")).is_none());
assert_eq!(cache.observed_home_version(), 2);
}
#[test]
fn apply_invalidation_skips_garbage_payload() {
let cache = EdgeCache::new(10);
apply_invalidation("not json", &cache); assert_eq!(cache.stats().invalidations_received, 0);
}
#[test]
fn edge_id_falls_back_to_pid() {
assert_eq!(resolve_edge_id("edge-eu-1"), "edge-eu-1");
assert_eq!(resolve_edge_id(""), format!("edge-{}", std::process::id()));
}
#[test]
fn backoff_doubles_to_cap_only() {
let mut b = BACKOFF_INITIAL;
let mut seen = Vec::new();
for _ in 0..8 {
seen.push(b.as_millis());
b = next_backoff(b);
}
assert_eq!(seen, vec![500, 1000, 2000, 4000, 8000, 10000, 10000, 10000]);
}
}