1use std::collections::{BTreeMap, BTreeSet};
22use std::sync::{Mutex, OnceLock};
23use std::time::Duration;
24
25use crate::core::json::escape as json_escape;
26
27pub const RWS_CONFIG_CLUSTER_REDIS: &str = "RWS_CONFIG_CLUSTER_REDIS";
29pub const RWS_CONFIG_CLUSTER_REDIS_PASSWORD: &str = "RWS_CONFIG_CLUSTER_REDIS_PASSWORD";
30pub const RWS_CONFIG_CLUSTER_PREFIX: &str = "RWS_CONFIG_CLUSTER_PREFIX";
31
32pub const POLL: Duration = Duration::from_secs(1);
34pub const HEARTBEAT: Duration = Duration::from_secs(5);
36pub const PURGES_KEPT: u64 = 50;
38
39#[derive(Debug, Clone, PartialEq)]
41pub enum Op {
42 Maintenance(bool),
43 Block(String),
44 Unblock(String),
45 Drain { upstream: String, backend: String, drained: bool },
46 Flag { name: String, enabled: bool },
47 Purge { cache: String, path: Option<String>, prefix: Option<String> },
48}
49
50impl Op {
51 fn args(&self) -> Vec<String> {
53 let bit = |b: bool| if b { "1" } else { "0" }.to_string();
54 match self {
55 Op::Maintenance(on) => vec!["maintenance".into(), bit(*on)],
56 Op::Block(ip) => vec!["block".into(), ip.clone()],
57 Op::Unblock(ip) => vec!["unblock".into(), ip.clone()],
58 Op::Drain { upstream, backend, drained } => vec!["drain".into(), drain_key(upstream, backend), bit(*drained)],
59 Op::Flag { name, enabled } => vec!["flag".into(), name.clone(), bit(*enabled)],
60 Op::Purge { cache, path, prefix } => vec!["purge".into(), cache.clone(), path.clone().unwrap_or_default(), prefix.clone().unwrap_or_default()],
61 }
62 }
63}
64
65fn drain_key(upstream: &str, backend: &str) -> String {
66 format!("{upstream}|{backend}")
67}
68
69#[derive(Debug, Clone, Default, PartialEq)]
71pub struct State {
72 pub version: u64,
73 pub maintenance: bool,
74 pub blocked: BTreeSet<String>,
75 pub drained: BTreeSet<String>,
77 pub flags: BTreeMap<String, bool>,
78 pub purges: BTreeMap<u64, (String, String, String)>,
80}
81
82impl State {
83 pub fn to_ops(&self) -> Vec<Op> {
85 let mut ops = Vec::new();
86 if self.maintenance {
87 ops.push(Op::Maintenance(true));
88 }
89 ops.extend(self.blocked.iter().map(|ip| Op::Block(ip.clone())));
90 for key in &self.drained {
91 if let Some((upstream, backend)) = key.split_once('|') {
92 ops.push(Op::Drain { upstream: upstream.to_string(), backend: backend.to_string(), drained: true });
93 }
94 }
95 ops.extend(self.flags.iter().map(|(name, enabled)| Op::Flag { name: name.clone(), enabled: *enabled }));
96 ops
97 }
98
99 pub fn apply(&mut self, op: &Op) {
101 match op {
102 Op::Maintenance(on) => self.maintenance = *on,
103 Op::Block(ip) => {
104 self.blocked.insert(ip.clone());
105 }
106 Op::Unblock(ip) => {
107 self.blocked.remove(ip);
108 }
109 Op::Drain { upstream, backend, drained } => {
110 let key = drain_key(upstream, backend);
111 if *drained {
112 self.drained.insert(key);
113 } else {
114 self.drained.remove(&key);
115 }
116 }
117 Op::Flag { name, enabled } => {
118 self.flags.insert(name.clone(), *enabled);
119 }
120 Op::Purge { cache, path, prefix } => {
121 let id = self.purges.keys().next_back().copied().unwrap_or(0) + 1;
122 self.purges.insert(id, (cache.clone(), path.clone().unwrap_or_default(), prefix.clone().unwrap_or_default()));
123 self.purges.retain(|k, _| *k + PURGES_KEPT > id);
124 }
125 }
126 self.version += 1;
127 }
128
129 pub fn to_json(&self) -> String {
131 let set = |s: &BTreeSet<String>| s.iter().map(|k| format!("\"{}\":true", json_escape(k))).collect::<Vec<_>>().join(",");
132 let flags: Vec<String> = self.flags.iter().map(|(k, v)| format!("\"{}\":{v}", json_escape(k))).collect();
133 let purges: Vec<String> = self
134 .purges
135 .iter()
136 .map(|(id, (c, p, x))| format!(r#""{id}":{{"cache":"{}","path":"{}","prefix":"{}"}}"#, json_escape(c), json_escape(p), json_escape(x)))
137 .collect();
138 format!(
139 r#"{{"v":{},"maintenance":{},"blocked":{{{}}},"drained":{{{}}},"flags":{{{}}},"purges":{{{}}}}}"#,
140 self.version,
141 self.maintenance,
142 set(&self.blocked),
143 set(&self.drained),
144 flags.join(","),
145 purges.join(",")
146 )
147 }
148
149 pub fn from_json(text: &str) -> Option<State> {
151 use crate::mcp::json_rpc::{extract_str, object_members};
152 let top = object_members(text.trim())?;
153 let field = |k: &str| top.iter().find(|(key, _)| key == k).map(|(_, v)| v.trim().to_string());
154 let keys = |raw: Option<String>| -> BTreeSet<String> { raw.and_then(|r| object_members(&r)).map(|m| m.into_iter().map(|(k, _)| k).collect()).unwrap_or_default() };
156 let version = field("v")?.trim_end_matches(".0").parse().ok()?;
157 let purges = field("purges")
158 .and_then(|r| object_members(&r))
159 .unwrap_or_default()
160 .into_iter()
161 .filter_map(|(id, spec)| {
162 let get = |k: &str| extract_str(&spec, k).unwrap_or_default();
163 Some((id.parse().ok()?, (get("cache"), get("path"), get("prefix"))))
164 })
165 .collect();
166 Some(State {
167 version,
168 maintenance: field("maintenance").as_deref() == Some("true"),
169 blocked: keys(field("blocked")),
170 drained: keys(field("drained")),
171 flags: field("flags").and_then(|r| object_members(&r)).unwrap_or_default().into_iter().map(|(k, v)| (k, v.trim() == "true")).collect(),
172 purges,
173 })
174 }
175}
176
177pub trait Store: Send + Sync {
179 fn apply(&self, op: &Op) -> std::io::Result<()>;
181 fn state(&self) -> std::io::Result<Option<State>>;
183 fn heartbeat(&self, node: &str, version: u64) -> std::io::Result<Vec<String>>;
185}
186
187pub struct RedisStore {
189 conn: crate::redis_protocol::RespConn,
190 prefix: String,
191}
192
193const APPLY_SCRIPT: &str = r#"
194local s = redis.call('GET', KEYS[1])
195local t = {}
196if s then t = cjson.decode(s) end
197t.blocked = t.blocked or {}
198t.drained = t.drained or {}
199t.flags = t.flags or {}
200t.purges = t.purges or {}
201local op = ARGV[1]
202if op == 'maintenance' then t.maintenance = (ARGV[2] == '1')
203elseif op == 'block' then t.blocked[ARGV[2]] = true
204elseif op == 'unblock' then t.blocked[ARGV[2]] = nil
205elseif op == 'drain' then if ARGV[3] == '1' then t.drained[ARGV[2]] = true else t.drained[ARGV[2]] = nil end
206elseif op == 'flag' then t.flags[ARGV[2]] = (ARGV[3] == '1')
207elseif op == 'purge' then
208 local id = 0
209 for k, _ in pairs(t.purges) do if tonumber(k) > id then id = tonumber(k) end end
210 id = id + 1
211 t.purges[tostring(id)] = { cache = ARGV[2], path = ARGV[3], prefix = ARGV[4] }
212 for k, _ in pairs(t.purges) do if tonumber(k) + tonumber(ARGV[5]) <= id then t.purges[k] = nil end end
213end
214t.v = (t.v or 0) + 1
215redis.call('SET', KEYS[1], cjson.encode(t))
216return t.v
217"#;
218
219const HEARTBEAT_SCRIPT: &str = r#"
220local s = redis.call('GET', KEYS[1])
221local t = {}
222if s then t = cjson.decode(s) end
223local now = tonumber(ARGV[2])
224t[ARGV[1]] = { at = now, v = tonumber(ARGV[3]) }
225local names = {}
226for k, n in pairs(t) do
227 if n.at < now - 60 then t[k] = nil else table.insert(names, k) end
228end
229redis.call('SET', KEYS[1], cjson.encode(t))
230table.sort(names)
231return table.concat(names, ',')
232"#;
233
234impl RedisStore {
235 pub fn new(addr: &str, password: Option<String>, prefix: &str) -> RedisStore {
236 RedisStore { conn: crate::redis_protocol::RespConn::new(addr, password), prefix: prefix.to_string() }
237 }
238}
239
240impl Store for RedisStore {
241 fn apply(&self, op: &Op) -> std::io::Result<()> {
242 let key = format!("{}state", self.prefix);
243 let mut args = op.args();
244 if matches!(op, Op::Purge { .. }) {
245 args.push(PURGES_KEPT.to_string());
246 }
247 let mut cmd: Vec<&[u8]> = vec![b"EVAL", APPLY_SCRIPT.as_bytes(), b"1", key.as_bytes()];
248 cmd.extend(args.iter().map(|a| a.as_bytes()));
249 self.conn.cmd(&cmd).map(|_| ())
250 }
251
252 fn state(&self) -> std::io::Result<Option<State>> {
253 let key = format!("{}state", self.prefix);
254 match self.conn.cmd(&[b"GET", key.as_bytes()])? {
255 crate::redis_protocol::RespReply::Bulk(Some(bytes)) => Ok(State::from_json(&String::from_utf8_lossy(&bytes))),
256 _ => Ok(None),
257 }
258 }
259
260 fn heartbeat(&self, node: &str, version: u64) -> std::io::Result<Vec<String>> {
261 let key = format!("{}nodes", self.prefix);
262 let now = unix_now().to_string();
263 let v = version.to_string();
264 match self.conn.cmd(&[b"EVAL", HEARTBEAT_SCRIPT.as_bytes(), b"1", key.as_bytes(), node.as_bytes(), now.as_bytes(), v.as_bytes()])? {
265 crate::redis_protocol::RespReply::Bulk(Some(bytes)) => Ok(String::from_utf8_lossy(&bytes).split(',').filter(|n| !n.is_empty()).map(str::to_string).collect()),
266 _ => Ok(Vec::new()),
267 }
268 }
269}
270
271impl<T: Store> Store for std::sync::Arc<T> {
272 fn apply(&self, op: &Op) -> std::io::Result<()> {
273 (**self).apply(op)
274 }
275 fn state(&self) -> std::io::Result<Option<State>> {
276 (**self).state()
277 }
278 fn heartbeat(&self, node: &str, version: u64) -> std::io::Result<Vec<String>> {
279 (**self).heartbeat(node, version)
280 }
281}
282
283#[derive(Default)]
286pub struct MemoryStore {
287 state: Mutex<Option<State>>,
288 nodes: Mutex<BTreeMap<String, u64>>,
289 pub down: std::sync::atomic::AtomicBool,
291}
292
293impl MemoryStore {
294 fn check(&self) -> std::io::Result<()> {
295 if self.down.load(std::sync::atomic::Ordering::SeqCst) {
296 Err(std::io::Error::other("store down"))
297 } else {
298 Ok(())
299 }
300 }
301}
302
303impl Store for MemoryStore {
304 fn apply(&self, op: &Op) -> std::io::Result<()> {
305 self.check()?;
306 self.state.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(State::default).apply(op);
307 Ok(())
308 }
309
310 fn state(&self) -> std::io::Result<Option<State>> {
311 self.check()?;
312 Ok(self.state.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|s| State::from_json(&s.to_json())))
314 }
315
316 fn heartbeat(&self, node: &str, _version: u64) -> std::io::Result<Vec<String>> {
317 self.check()?;
318 let mut nodes = self.nodes.lock().unwrap_or_else(|e| e.into_inner());
319 let now = unix_now();
320 nodes.insert(node.to_string(), now);
321 nodes.retain(|_, at| *at + 60 >= now);
322 Ok(nodes.keys().cloned().collect())
323 }
324}
325
326fn unix_now() -> u64 {
327 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
328}
329
330pub trait Local: Send + Sync {
333 fn apply(&self, state: &State, purges_after: u64);
334 fn snapshot(&self) -> State;
337}
338
339pub struct ProcessState;
342
343impl Local for ProcessState {
344 fn snapshot(&self) -> State {
345 let mut state = State {
346 maintenance: crate::maintenance::MAINTENANCE_MODE.load(std::sync::atomic::Ordering::SeqCst),
347 blocked: crate::blocklist::global().list().into_iter().collect(),
348 flags: crate::feature::global().list().into_iter().collect(),
349 ..State::default()
350 };
351 #[cfg(not(target_arch = "wasm32"))]
352 for (upstream, backends) in crate::proxy_config::upstream::snapshot() {
353 for b in backends.iter().filter(|b| b.drained) {
354 state.drained.insert(drain_key(&upstream, &b.url));
355 }
356 }
357 state
358 }
359
360 fn apply(&self, state: &State, purges_after: u64) {
361 crate::maintenance::MAINTENANCE_MODE.store(state.maintenance, std::sync::atomic::Ordering::SeqCst);
362 let blocklist = crate::blocklist::global();
363 let current: BTreeSet<String> = blocklist.list().into_iter().collect();
364 for ip in state.blocked.difference(¤t) {
365 blocklist.block(ip);
366 }
367 for ip in current.difference(&state.blocked) {
368 blocklist.unblock(ip);
369 }
370 #[cfg(not(target_arch = "wasm32"))]
371 for (upstream, backends) in crate::proxy_config::upstream::snapshot() {
372 for b in backends {
373 let drained = state.drained.contains(&drain_key(&upstream, &b.url));
374 if b.drained != drained {
375 crate::proxy_config::upstream::set_drained(&upstream, &b.url, drained);
376 }
377 }
378 }
379 for (name, enabled) in &state.flags {
380 crate::feature::global().set(name, *enabled);
381 }
382 for (id, (cache, path, prefix)) in &state.purges {
383 if *id > purges_after {
384 if let Some(c) = crate::cache::registry::get(cache) {
385 let target = if !path.is_empty() {
386 crate::cache::Purge::Exact(path.clone())
387 } else if !prefix.is_empty() {
388 crate::cache::Purge::Prefix(prefix.clone())
389 } else {
390 crate::cache::Purge::All
391 };
392 c.purge(&target);
393 }
394 }
395 }
396 }
397}
398
399pub struct ClusterSync {
402 store: Box<dyn Store>,
403 local: Box<dyn Local>,
404 node: String,
405 inner: Mutex<SyncState>,
406 queue: Mutex<Vec<Op>>,
410 wake: std::sync::Condvar,
411}
412
413#[derive(Default)]
414struct SyncState {
415 applied: u64,
416 purges_applied: Option<u64>,
417 pending: Vec<Op>,
418 nodes: Vec<String>,
419 up: bool,
420 reported_down: bool,
421}
422
423impl ClusterSync {
424 pub fn new(store: Box<dyn Store>, local: Box<dyn Local>, node: &str) -> ClusterSync {
425 ClusterSync { store, local, node: node.to_string(), inner: Mutex::new(SyncState::default()), queue: Mutex::new(Vec::new()), wake: std::sync::Condvar::new() }
426 }
427
428 pub fn publish(&self, op: Op) {
430 self.queue.lock().unwrap_or_else(|e| e.into_inner()).push(op);
431 self.wake.notify_one();
432 }
433
434 fn wait(&self, timeout: Duration) {
437 let queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
438 if queue.is_empty() {
439 let _ = self.wake.wait_timeout(queue, timeout);
440 }
441 }
442
443 pub fn tick(&self, heartbeat: bool) {
446 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
447 let published = std::mem::take(&mut *self.queue.lock().unwrap_or_else(|e| e.into_inner()));
448 inner.pending.extend(published);
449 let result = (|| -> std::io::Result<()> {
450 if self.store.state()?.is_none() {
451 for op in self.local.snapshot().to_ops() {
454 self.store.apply(&op)?;
455 }
456 }
459 while let Some(op) = inner.pending.first().cloned() {
460 self.store.apply(&op)?;
461 inner.pending.remove(0);
462 }
463 if let Some(state) = self.store.state()? {
464 let purges_after = *inner.purges_applied.get_or_insert_with(|| state.purges.keys().next_back().copied().unwrap_or(0));
466 if state.version != inner.applied {
467 self.local.apply(&state, purges_after);
468 inner.applied = state.version;
469 inner.purges_applied = Some(state.purges.keys().next_back().copied().unwrap_or(purges_after).max(purges_after));
470 }
471 }
472 if heartbeat {
473 inner.nodes = self.store.heartbeat(&self.node, inner.applied)?;
474 }
475 Ok(())
476 })();
477 match result {
478 Ok(()) => {
479 if inner.reported_down {
480 println!("[cluster] Redis reachable again; this replica is in sync");
481 }
482 inner.up = true;
483 inner.reported_down = false;
484 }
485 Err(e) => {
486 if !inner.reported_down {
487 eprintln!("[cluster] Redis unreachable ({e}); changes apply on this replica and are sent when it's back");
488 inner.reported_down = true;
489 }
490 inner.up = false;
491 }
492 }
493 }
494
495 pub fn status_json(&self) -> String {
497 let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
498 let nodes: Vec<String> = inner.nodes.iter().map(|n| format!("\"{}\"", json_escape(n))).collect();
499 format!(
500 r#"{{"node":"{}","redis":"{}","version":{},"nodes":[{}],"pending":{}}}"#,
501 json_escape(&self.node),
502 if inner.up { "up" } else { "down" },
503 inner.applied,
504 nodes.join(","),
505 inner.pending.len()
506 )
507 }
508}
509
510fn global() -> &'static OnceLock<ClusterSync> {
511 static SYNC: OnceLock<ClusterSync> = OnceLock::new();
512 &SYNC
513}
514
515pub fn publish(op: Op) {
518 if let Some(sync) = global().get() {
519 sync.publish(op);
520 }
521}
522
523pub fn status_json() -> Option<String> {
525 global().get().map(ClusterSync::status_json)
526}
527
528pub fn start_from_env() {
531 let env = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
532 let Some(addr) = env(RWS_CONFIG_CLUSTER_REDIS).or_else(|| env(crate::entry_point::Config::RWS_CONFIG_ACME_REDIS)) else { return };
533 let password = env(RWS_CONFIG_CLUSTER_REDIS_PASSWORD).or_else(|| env(crate::entry_point::Config::RWS_CONFIG_ACME_REDIS_PASSWORD));
534 let prefix = env(RWS_CONFIG_CLUSTER_PREFIX).unwrap_or_else(|| "rws:cluster:".to_string());
535 let node = crate::admin::access::node_name();
536 let sync = ClusterSync::new(Box::new(RedisStore::new(&addr, password, &prefix)), Box::new(ProcessState), &node);
537 if global().set(sync).is_err() {
538 return;
539 }
540 println!("[cluster] sharing runtime state through Redis at {addr} as node {node}");
541 let _ = std::thread::Builder::new().name("rws-cluster".into()).spawn(|| {
542 let mut last_heartbeat: Option<std::time::Instant> = None;
543 loop {
544 if let Some(sync) = global().get() {
545 let heartbeat = last_heartbeat.is_none_or(|t| t.elapsed() >= HEARTBEAT);
546 sync.tick(heartbeat);
547 if heartbeat {
548 last_heartbeat = Some(std::time::Instant::now());
549 }
550 sync.wait(POLL);
551 } else {
552 std::thread::sleep(POLL);
553 }
554 }
555 });
556}
557
558#[cfg(test)]
559mod tests;