Skip to main content

rust_web_server/cluster/
mod.rs

1//! Runtime state shared across replicas (18.22.0, #76).
2//!
3//! With `[cluster] redis = "host:port"` (else `[acme] redis`), changes made
4//! on one replica through the admin API, `rws ctl` or the MCP tools — the
5//! IP blocklist, maintenance mode, drained backends, feature flags and cache
6//! purges — reach every replica within about a second.
7//!
8//! How: one JSON document per cluster (`<prefix>state`) holds the shared
9//! state and a version. A change is applied locally first, then sent to
10//! Redis as an operation that a Lua script applies to the document
11//! atomically (Redis's `cjson`), so concurrent changes on different
12//! replicas never overwrite each other. Every replica reads the document
13//! once a second and, when its version moved, makes its own state match.
14//! With Redis down, local changes still apply and are sent once it's back;
15//! a replica that starts adopts the document. Each replica also writes a
16//! heartbeat into `<prefix>nodes`, which `GET /admin/api/v1/status` lists.
17//!
18//! The admin operations ([`crate::admin::ops`]) publish; code that changes
19//! [`crate::blocklist::global`] or the feature store directly stays local.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::sync::{Mutex, OnceLock};
23use std::time::Duration;
24
25use crate::core::json::escape as json_escape;
26
27/// `[cluster]` settings.
28pub 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
32/// How often the shared document is read.
33pub const POLL: Duration = Duration::from_secs(1);
34/// How often a heartbeat is written; nodes silent for 60 s drop out.
35pub const HEARTBEAT: Duration = Duration::from_secs(5);
36/// Purges kept in the document (older ones have been applied by then).
37pub const PURGES_KEPT: u64 = 50;
38
39/// One change to the shared state.
40#[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    /// The Lua script's arguments.
52    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/// The shared document.
70#[derive(Debug, Clone, Default, PartialEq)]
71pub struct State {
72    pub version: u64,
73    pub maintenance: bool,
74    pub blocked: BTreeSet<String>,
75    /// `upstream|backend`.
76    pub drained: BTreeSet<String>,
77    pub flags: BTreeMap<String, bool>,
78    /// Purge id → (cache, path, prefix); empty strings for "none".
79    pub purges: BTreeMap<u64, (String, String, String)>,
80}
81
82impl State {
83    /// The operations that rebuild this state in an empty document.
84    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    /// Apply `op` (the same rules as the Lua script).
100    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    /// The document as the Lua script writes it (`cjson`).
130    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    /// Read the document; `None` when it isn't one.
150    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        // cjson writes an empty table as {}; a non-empty one as an object.
155        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
177/// Where the shared document lives.
178pub trait Store: Send + Sync {
179    /// Apply `op` to the document atomically.
180    fn apply(&self, op: &Op) -> std::io::Result<()>;
181    /// The document, if there is one.
182    fn state(&self) -> std::io::Result<Option<State>>;
183    /// Record `node` as alive now; the nodes seen in the last minute.
184    fn heartbeat(&self, node: &str, version: u64) -> std::io::Result<Vec<String>>;
185}
186
187/// The document in Redis.
188pub 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/// The document in memory: for tests (several replicas in one process) and
284/// as a reference for the Lua script's rules.
285#[derive(Default)]
286pub struct MemoryStore {
287    state: Mutex<Option<State>>,
288    nodes: Mutex<BTreeMap<String, u64>>,
289    /// When set, every call fails (Redis down).
290    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        // Round-trip through JSON, as Redis does.
313        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
330/// Makes this process's runtime state match a [`State`]; the default
331/// applies to the process-wide stores.
332pub trait Local: Send + Sync {
333    fn apply(&self, state: &State, purges_after: u64);
334    /// This process's current state, to seed an empty store (a Redis that
335    /// restarted without its data, or a new cluster).
336    fn snapshot(&self) -> State;
337}
338
339/// The process-wide blocklist, maintenance switch, upstream drains, feature
340/// flags and named caches.
341pub 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(&current) {
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
399/// One replica's view of the cluster: the store, what it applied last, and
400/// operations waiting for the store to come back.
401pub struct ClusterSync {
402    store: Box<dyn Store>,
403    local: Box<dyn Local>,
404    node: String,
405    inner: Mutex<SyncState>,
406    /// Changes made here, waiting for the sync thread (18.24.0: `publish`
407    /// never waits on Redis, so an admin change doesn't hang when it's
408    /// down); `wake` starts a round at once.
409    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    /// Send a change made here (already applied locally).
429    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    /// Wait up to `timeout` for a published change (the sync thread's pause
435    /// between rounds).
436    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    /// One round: send what's pending, read the document, apply a newer
444    /// one. `heartbeat` also writes this node's heartbeat.
445    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                // An empty store: seed it with what this replica has, so a
452                // Redis that lost its data doesn't undo drains and blocks.
453                for op in self.local.snapshot().to_ops() {
454                    self.store.apply(&op)?;
455                }
456                // The queued changes are sent after it: each one sets a
457                // value, so replaying what the snapshot already has is harmless.
458            }
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                // The first read adopts the document without replaying old purges.
465                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    /// `{"node":…,"redis":"up"|"down","version":N,"nodes":[…],"pending":N}`.
496    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
515/// Send a change made on this replica to the others (no-op without
516/// `[cluster]`).
517pub fn publish(op: Op) {
518    if let Some(sync) = global().get() {
519        sync.publish(op);
520    }
521}
522
523/// The cluster status for the admin API, or `None` without `[cluster]`.
524pub fn status_json() -> Option<String> {
525    global().get().map(ClusterSync::status_json)
526}
527
528/// The binary's entry point: with `[cluster] redis` (or `[acme] redis`),
529/// start syncing on a thread of its own.
530pub 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;