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}