Skip to main content

heliosdb_proxy/edge/
mod.rs

1//! Edge / geo proxy mode (T3.2).
2//!
3//! A HeliosProxy can run in *edge* mode: it terminates client SQL
4//! against a local in-memory cache and only forwards to the *home*
5//! proxy on cache miss. Writes always pass through to home, and
6//! home broadcasts invalidations back to every registered edge so
7//! cached results don't go stale on subsequent reads.
8//!
9//! ## Coherence model — last-write-wins with TTL
10//!
11//! - **Reads**: edge looks up `(query_fingerprint, params)` in the
12//!   local cache. Hit → serve from cache. Miss → forward to home,
13//!   cache the result with the configured TTL, serve.
14//! - **Writes**: edge forwards verbatim. On success, home computes
15//!   the set of touched-tables (via the analytics fingerprint) and
16//!   pushes an `Invalidate { tables, version }` event to every
17//!   registered edge. This covers simple-protocol statements,
18//!   extended-protocol (Parse/Bind/Execute) batches, and `COPY ...
19//!   FROM` loads (invalidated when the COPY drains, i.e. when the
20//!   rows become visible).
21//! - **Conflict resolution**: each cache entry carries a `version` in
22//!   the *invalidation clock's* domain — the home stamps entries from
23//!   its own counter; an edge stamps entries with the last home
24//!   version it observed over SSE. An invalidation drops every entry
25//!   whose `version <= invalidation.version`, so a read that began
26//!   before a write is always swept by that write's event. Events
27//!   also carry the home's per-boot `epoch`: a restarted home resets
28//!   its clock, and edges flush wholesale on an epoch change instead
29//!   of comparing incomparable versions.
30//!
31//! ## Cross-region link — PG-wire data plane + SSE control plane
32//!
33//! Data plane (PG-wire): the edge lists the *home proxy's client
34//! port* as its `[[nodes]]` backend, so cache misses and all writes
35//! flow through the ordinary forwarding path to the home — the
36//! captured PG-wire response bytes are exactly what the edge caches.
37//! No JSON↔PG-wire re-encoding.
38//!
39//! Control plane (SSE): each edge holds a long-lived
40//! `GET /api/edge/subscribe` against the home's *admin* listener
41//! (bearer-token auth, same gate as every other admin route). The
42//! home pushes `event: invalidate\ndata: {...}\n\n` frames whenever
43//! a write commits; the edge drops matching entries as they arrive.
44//!
45//! No per-region central registrar, no distributed consensus, no
46//! vector clocks. Picks "eventual consistency with bounded
47//! staleness" as the explicit contract — readers may see stale data
48//! on any region after a write to another region, bounded by SSE
49//! delivery lag plus the cache TTL.
50
51#[cfg(feature = "edge-proxy")]
52pub mod cache;
53#[cfg(feature = "edge-proxy")]
54pub mod client;
55#[cfg(feature = "edge-proxy")]
56pub mod fingerprint;
57#[cfg(feature = "edge-proxy")]
58pub mod registry;
59
60#[cfg(feature = "edge-proxy")]
61pub use cache::{CacheEntry, CacheKey, EdgeCache, EdgeCacheStats};
62#[cfg(feature = "edge-proxy")]
63pub use registry::{EdgeNode, EdgeRegistry, InvalidationEvent};
64
65use serde::{Deserialize, Serialize};
66use std::time::Duration;
67
68/// Edge-mode runtime config (`[edge]` in proxy.toml). Every field is
69/// serde-defaulted so a partial — or absent — section parses on any
70/// build; the machinery only runs when `enabled` *and* the
71/// `edge-proxy` feature is compiled in (`validate()` rejects the
72/// former without the latter).
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct EdgeConfig {
75    /// Master switch. Off (default) = the edge cache/registry wiring
76    /// is skipped entirely and this section is inert.
77    #[serde(default)]
78    pub enabled: bool,
79
80    /// "edge" or "home" mode. Edges forward writes + cache reads;
81    /// home is authoritative.
82    #[serde(default = "default_role")]
83    pub role: EdgeRole,
84
85    /// For edge: home proxy admin URL (e.g. "https://proxy.home.svc:9090").
86    /// Empty when role = home.
87    #[serde(default)]
88    pub home_url: String,
89
90    /// For edge: bearer token presented to home's admin API for the
91    /// invalidation subscription. Empty when role = home. When set,
92    /// `home_url` must be https:// (or `allow_insecure_home_url`
93    /// opted into) — the token is the home's ADMIN bearer and must
94    /// not cross an untrusted network in cleartext.
95    #[serde(default)]
96    pub auth_token: String,
97
98    /// For edge: allow presenting `auth_token` to a plain-http
99    /// `home_url`. Only for provably private links (VPN/WireGuard/
100    /// service mesh, loopback dev setups) — mirrors
101    /// `admin_allow_insecure` on the home side.
102    #[serde(default)]
103    pub allow_insecure_home_url: bool,
104
105    /// Default TTL in seconds applied to cache entries when the home
106    /// doesn't supply one explicitly. Reads expire after this elapses.
107    /// (Integer seconds, like every other `*_secs` duration knob.)
108    #[serde(default = "default_cache_ttl_secs")]
109    pub default_ttl_secs: u64,
110
111    /// Maximum cache entries before LRU eviction kicks in.
112    #[serde(default = "default_max_entries")]
113    pub max_entries: usize,
114
115    /// For home: maximum simultaneous registered edge nodes.
116    #[serde(default = "default_max_edges")]
117    pub max_edges: usize,
118
119    /// For home: edges not seen within this window are pruned from
120    /// the registry by the GC sweep (a pruned edge just re-registers
121    /// on its next reconnect). Liveness is refreshed by successful
122    /// SSE heartbeat writes (every 15s), so keep this comfortably
123    /// above ~45s (3 heartbeats) or healthy-but-idle edges will churn.
124    #[serde(default = "default_liveness_window_secs")]
125    pub liveness_window_secs: u64,
126
127    /// For home: cadence of the registry GC sweep.
128    #[serde(default = "default_subscribe_gc_secs")]
129    pub subscribe_gc_secs: u64,
130
131    /// For edge: region label reported when subscribing to the home
132    /// (shows up in the home's `/api/edge` listing).
133    #[serde(default)]
134    pub region: String,
135
136    /// For edge: stable id this proxy registers under. Empty
137    /// (default) = the runtime falls back to "edge-<pid>".
138    #[serde(default)]
139    pub edge_id: String,
140}
141
142fn default_role() -> EdgeRole {
143    EdgeRole::Home
144}
145
146fn default_cache_ttl_secs() -> u64 {
147    60
148}
149
150fn default_max_entries() -> usize {
151    10_000
152}
153
154fn default_max_edges() -> usize {
155    32
156}
157
158fn default_liveness_window_secs() -> u64 {
159    120
160}
161
162fn default_subscribe_gc_secs() -> u64 {
163    30
164}
165
166impl EdgeConfig {
167    /// The entry TTL as a `Duration` (config carries integer seconds,
168    /// matching every other `*_secs` knob).
169    pub fn default_ttl(&self) -> Duration {
170        Duration::from_secs(self.default_ttl_secs)
171    }
172}
173
174impl Default for EdgeConfig {
175    fn default() -> Self {
176        Self {
177            enabled: false,
178            role: default_role(),
179            home_url: String::new(),
180            auth_token: String::new(),
181            allow_insecure_home_url: false,
182            default_ttl_secs: default_cache_ttl_secs(),
183            max_entries: default_max_entries(),
184            max_edges: default_max_edges(),
185            liveness_window_secs: default_liveness_window_secs(),
186            subscribe_gc_secs: default_subscribe_gc_secs(),
187            region: String::new(),
188            edge_id: String::new(),
189        }
190    }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "lowercase")]
195pub enum EdgeRole {
196    /// Authoritative proxy. Routes writes to backends, broadcasts
197    /// invalidations to every registered edge.
198    Home,
199    /// Cache-first proxy. Forwards writes to home, cache reads,
200    /// listens for invalidations.
201    Edge,
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn default_role_is_home() {
210        let cfg = EdgeConfig::default();
211        assert_eq!(cfg.role, EdgeRole::Home);
212        assert!(!cfg.enabled);
213    }
214
215    #[test]
216    fn config_round_trips_through_serde() {
217        let cfg = EdgeConfig {
218            enabled: true,
219            role: EdgeRole::Edge,
220            home_url: "https://home.svc:9090".into(),
221            auth_token: "tkn".into(),
222            default_ttl_secs: 30,
223            max_entries: 5_000,
224            max_edges: 0,
225            region: "eu-west".into(),
226            edge_id: "edge-a".into(),
227            ..EdgeConfig::default()
228        };
229        let json = serde_json::to_string(&cfg).unwrap();
230        let back: EdgeConfig = serde_json::from_str(&json).unwrap();
231        assert!(back.enabled);
232        assert_eq!(back.role, EdgeRole::Edge);
233        assert_eq!(back.home_url, "https://home.svc:9090");
234        assert_eq!(back.default_ttl_secs, 30);
235        assert_eq!(back.default_ttl(), Duration::from_secs(30));
236        assert_eq!(back.region, "eu-west");
237        assert_eq!(back.edge_id, "edge-a");
238    }
239
240    #[test]
241    fn empty_section_deserializes_with_defaults() {
242        // Every field is serde-defaulted: an empty `[edge]` section
243        // (or a config written before a field existed) must parse.
244        let cfg: EdgeConfig = serde_json::from_str("{}").unwrap();
245        assert!(!cfg.enabled);
246        assert_eq!(cfg.role, EdgeRole::Home);
247        assert_eq!(cfg.home_url, "");
248        assert_eq!(cfg.auth_token, "");
249        assert!(!cfg.allow_insecure_home_url);
250        assert_eq!(cfg.default_ttl_secs, 60);
251        assert_eq!(cfg.default_ttl(), Duration::from_secs(60));
252        assert_eq!(cfg.max_entries, 10_000);
253        assert_eq!(cfg.max_edges, 32);
254        assert_eq!(cfg.liveness_window_secs, 120);
255        assert_eq!(cfg.subscribe_gc_secs, 30);
256        assert_eq!(cfg.region, "");
257        assert_eq!(cfg.edge_id, "");
258    }
259
260    #[test]
261    fn partial_toml_section_parses() {
262        let cfg: EdgeConfig = toml::from_str(
263            r#"
264            enabled = true
265            role = "edge"
266            home_url = "http://home:9090"
267            region = "us-east"
268            default_ttl_secs = 45
269            "#,
270        )
271        .unwrap();
272        assert!(cfg.enabled);
273        assert_eq!(cfg.role, EdgeRole::Edge);
274        assert_eq!(cfg.home_url, "http://home:9090");
275        assert_eq!(cfg.region, "us-east");
276        // Durations are plain integer seconds, like every other
277        // *_secs knob in proxy.toml (locks the TOML shape in).
278        assert_eq!(cfg.default_ttl(), Duration::from_secs(45));
279        // Unspecified fields fall back to defaults.
280        assert_eq!(cfg.liveness_window_secs, 120);
281        assert_eq!(cfg.subscribe_gc_secs, 30);
282        assert_eq!(cfg.max_entries, 10_000);
283    }
284}