use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use crate::core::json::escape as json_escape;
pub const RWS_CONFIG_CLUSTER_REDIS: &str = "RWS_CONFIG_CLUSTER_REDIS";
pub const RWS_CONFIG_CLUSTER_REDIS_PASSWORD: &str = "RWS_CONFIG_CLUSTER_REDIS_PASSWORD";
pub const RWS_CONFIG_CLUSTER_PREFIX: &str = "RWS_CONFIG_CLUSTER_PREFIX";
pub const POLL: Duration = Duration::from_secs(1);
pub const HEARTBEAT: Duration = Duration::from_secs(5);
pub const PURGES_KEPT: u64 = 50;
#[derive(Debug, Clone, PartialEq)]
pub enum Op {
Maintenance(bool),
Block(String),
Unblock(String),
Drain { upstream: String, backend: String, drained: bool },
Flag { name: String, enabled: bool },
Purge { cache: String, path: Option<String>, prefix: Option<String> },
}
impl Op {
fn args(&self) -> Vec<String> {
let bit = |b: bool| if b { "1" } else { "0" }.to_string();
match self {
Op::Maintenance(on) => vec!["maintenance".into(), bit(*on)],
Op::Block(ip) => vec!["block".into(), ip.clone()],
Op::Unblock(ip) => vec!["unblock".into(), ip.clone()],
Op::Drain { upstream, backend, drained } => vec!["drain".into(), drain_key(upstream, backend), bit(*drained)],
Op::Flag { name, enabled } => vec!["flag".into(), name.clone(), bit(*enabled)],
Op::Purge { cache, path, prefix } => vec!["purge".into(), cache.clone(), path.clone().unwrap_or_default(), prefix.clone().unwrap_or_default()],
}
}
}
fn drain_key(upstream: &str, backend: &str) -> String {
format!("{upstream}|{backend}")
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct State {
pub version: u64,
pub maintenance: bool,
pub blocked: BTreeSet<String>,
pub drained: BTreeSet<String>,
pub flags: BTreeMap<String, bool>,
pub purges: BTreeMap<u64, (String, String, String)>,
}
impl State {
pub fn to_ops(&self) -> Vec<Op> {
let mut ops = Vec::new();
if self.maintenance {
ops.push(Op::Maintenance(true));
}
ops.extend(self.blocked.iter().map(|ip| Op::Block(ip.clone())));
for key in &self.drained {
if let Some((upstream, backend)) = key.split_once('|') {
ops.push(Op::Drain { upstream: upstream.to_string(), backend: backend.to_string(), drained: true });
}
}
ops.extend(self.flags.iter().map(|(name, enabled)| Op::Flag { name: name.clone(), enabled: *enabled }));
ops
}
pub fn apply(&mut self, op: &Op) {
match op {
Op::Maintenance(on) => self.maintenance = *on,
Op::Block(ip) => {
self.blocked.insert(ip.clone());
}
Op::Unblock(ip) => {
self.blocked.remove(ip);
}
Op::Drain { upstream, backend, drained } => {
let key = drain_key(upstream, backend);
if *drained {
self.drained.insert(key);
} else {
self.drained.remove(&key);
}
}
Op::Flag { name, enabled } => {
self.flags.insert(name.clone(), *enabled);
}
Op::Purge { cache, path, prefix } => {
let id = self.purges.keys().next_back().copied().unwrap_or(0) + 1;
self.purges.insert(id, (cache.clone(), path.clone().unwrap_or_default(), prefix.clone().unwrap_or_default()));
self.purges.retain(|k, _| *k + PURGES_KEPT > id);
}
}
self.version += 1;
}
pub fn to_json(&self) -> String {
let set = |s: &BTreeSet<String>| s.iter().map(|k| format!("\"{}\":true", json_escape(k))).collect::<Vec<_>>().join(",");
let flags: Vec<String> = self.flags.iter().map(|(k, v)| format!("\"{}\":{v}", json_escape(k))).collect();
let purges: Vec<String> = self
.purges
.iter()
.map(|(id, (c, p, x))| format!(r#""{id}":{{"cache":"{}","path":"{}","prefix":"{}"}}"#, json_escape(c), json_escape(p), json_escape(x)))
.collect();
format!(
r#"{{"v":{},"maintenance":{},"blocked":{{{}}},"drained":{{{}}},"flags":{{{}}},"purges":{{{}}}}}"#,
self.version,
self.maintenance,
set(&self.blocked),
set(&self.drained),
flags.join(","),
purges.join(",")
)
}
pub fn from_json(text: &str) -> Option<State> {
use crate::mcp::json_rpc::{extract_str, object_members};
let top = object_members(text.trim())?;
let field = |k: &str| top.iter().find(|(key, _)| key == k).map(|(_, v)| v.trim().to_string());
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() };
let version = field("v")?.trim_end_matches(".0").parse().ok()?;
let purges = field("purges")
.and_then(|r| object_members(&r))
.unwrap_or_default()
.into_iter()
.filter_map(|(id, spec)| {
let get = |k: &str| extract_str(&spec, k).unwrap_or_default();
Some((id.parse().ok()?, (get("cache"), get("path"), get("prefix"))))
})
.collect();
Some(State {
version,
maintenance: field("maintenance").as_deref() == Some("true"),
blocked: keys(field("blocked")),
drained: keys(field("drained")),
flags: field("flags").and_then(|r| object_members(&r)).unwrap_or_default().into_iter().map(|(k, v)| (k, v.trim() == "true")).collect(),
purges,
})
}
}
pub trait Store: Send + Sync {
fn apply(&self, op: &Op) -> std::io::Result<()>;
fn state(&self) -> std::io::Result<Option<State>>;
fn heartbeat(&self, node: &str, version: u64) -> std::io::Result<Vec<String>>;
}
pub struct RedisStore {
conn: crate::redis_protocol::RespConn,
prefix: String,
}
const APPLY_SCRIPT: &str = r#"
local s = redis.call('GET', KEYS[1])
local t = {}
if s then t = cjson.decode(s) end
t.blocked = t.blocked or {}
t.drained = t.drained or {}
t.flags = t.flags or {}
t.purges = t.purges or {}
local op = ARGV[1]
if op == 'maintenance' then t.maintenance = (ARGV[2] == '1')
elseif op == 'block' then t.blocked[ARGV[2]] = true
elseif op == 'unblock' then t.blocked[ARGV[2]] = nil
elseif op == 'drain' then if ARGV[3] == '1' then t.drained[ARGV[2]] = true else t.drained[ARGV[2]] = nil end
elseif op == 'flag' then t.flags[ARGV[2]] = (ARGV[3] == '1')
elseif op == 'purge' then
local id = 0
for k, _ in pairs(t.purges) do if tonumber(k) > id then id = tonumber(k) end end
id = id + 1
t.purges[tostring(id)] = { cache = ARGV[2], path = ARGV[3], prefix = ARGV[4] }
for k, _ in pairs(t.purges) do if tonumber(k) + tonumber(ARGV[5]) <= id then t.purges[k] = nil end end
end
t.v = (t.v or 0) + 1
redis.call('SET', KEYS[1], cjson.encode(t))
return t.v
"#;
const HEARTBEAT_SCRIPT: &str = r#"
local s = redis.call('GET', KEYS[1])
local t = {}
if s then t = cjson.decode(s) end
local now = tonumber(ARGV[2])
t[ARGV[1]] = { at = now, v = tonumber(ARGV[3]) }
local names = {}
for k, n in pairs(t) do
if n.at < now - 60 then t[k] = nil else table.insert(names, k) end
end
redis.call('SET', KEYS[1], cjson.encode(t))
table.sort(names)
return table.concat(names, ',')
"#;
impl RedisStore {
pub fn new(addr: &str, password: Option<String>, prefix: &str) -> RedisStore {
RedisStore { conn: crate::redis_protocol::RespConn::new(addr, password), prefix: prefix.to_string() }
}
}
impl Store for RedisStore {
fn apply(&self, op: &Op) -> std::io::Result<()> {
let key = format!("{}state", self.prefix);
let mut args = op.args();
if matches!(op, Op::Purge { .. }) {
args.push(PURGES_KEPT.to_string());
}
let mut cmd: Vec<&[u8]> = vec![b"EVAL", APPLY_SCRIPT.as_bytes(), b"1", key.as_bytes()];
cmd.extend(args.iter().map(|a| a.as_bytes()));
self.conn.cmd(&cmd).map(|_| ())
}
fn state(&self) -> std::io::Result<Option<State>> {
let key = format!("{}state", self.prefix);
match self.conn.cmd(&[b"GET", key.as_bytes()])? {
crate::redis_protocol::RespReply::Bulk(Some(bytes)) => Ok(State::from_json(&String::from_utf8_lossy(&bytes))),
_ => Ok(None),
}
}
fn heartbeat(&self, node: &str, version: u64) -> std::io::Result<Vec<String>> {
let key = format!("{}nodes", self.prefix);
let now = unix_now().to_string();
let v = version.to_string();
match self.conn.cmd(&[b"EVAL", HEARTBEAT_SCRIPT.as_bytes(), b"1", key.as_bytes(), node.as_bytes(), now.as_bytes(), v.as_bytes()])? {
crate::redis_protocol::RespReply::Bulk(Some(bytes)) => Ok(String::from_utf8_lossy(&bytes).split(',').filter(|n| !n.is_empty()).map(str::to_string).collect()),
_ => Ok(Vec::new()),
}
}
}
impl<T: Store> Store for std::sync::Arc<T> {
fn apply(&self, op: &Op) -> std::io::Result<()> {
(**self).apply(op)
}
fn state(&self) -> std::io::Result<Option<State>> {
(**self).state()
}
fn heartbeat(&self, node: &str, version: u64) -> std::io::Result<Vec<String>> {
(**self).heartbeat(node, version)
}
}
#[derive(Default)]
pub struct MemoryStore {
state: Mutex<Option<State>>,
nodes: Mutex<BTreeMap<String, u64>>,
pub down: std::sync::atomic::AtomicBool,
}
impl MemoryStore {
fn check(&self) -> std::io::Result<()> {
if self.down.load(std::sync::atomic::Ordering::SeqCst) {
Err(std::io::Error::other("store down"))
} else {
Ok(())
}
}
}
impl Store for MemoryStore {
fn apply(&self, op: &Op) -> std::io::Result<()> {
self.check()?;
self.state.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(State::default).apply(op);
Ok(())
}
fn state(&self) -> std::io::Result<Option<State>> {
self.check()?;
Ok(self.state.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|s| State::from_json(&s.to_json())))
}
fn heartbeat(&self, node: &str, _version: u64) -> std::io::Result<Vec<String>> {
self.check()?;
let mut nodes = self.nodes.lock().unwrap_or_else(|e| e.into_inner());
let now = unix_now();
nodes.insert(node.to_string(), now);
nodes.retain(|_, at| *at + 60 >= now);
Ok(nodes.keys().cloned().collect())
}
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
pub trait Local: Send + Sync {
fn apply(&self, state: &State, purges_after: u64);
fn snapshot(&self) -> State;
}
pub struct ProcessState;
impl Local for ProcessState {
fn snapshot(&self) -> State {
let mut state = State {
maintenance: crate::maintenance::MAINTENANCE_MODE.load(std::sync::atomic::Ordering::SeqCst),
blocked: crate::blocklist::global().list().into_iter().collect(),
flags: crate::feature::global().list().into_iter().collect(),
..State::default()
};
#[cfg(not(target_arch = "wasm32"))]
for (upstream, backends) in crate::proxy_config::upstream::snapshot() {
for b in backends.iter().filter(|b| b.drained) {
state.drained.insert(drain_key(&upstream, &b.url));
}
}
state
}
fn apply(&self, state: &State, purges_after: u64) {
crate::maintenance::MAINTENANCE_MODE.store(state.maintenance, std::sync::atomic::Ordering::SeqCst);
let blocklist = crate::blocklist::global();
let current: BTreeSet<String> = blocklist.list().into_iter().collect();
for ip in state.blocked.difference(¤t) {
blocklist.block(ip);
}
for ip in current.difference(&state.blocked) {
blocklist.unblock(ip);
}
#[cfg(not(target_arch = "wasm32"))]
for (upstream, backends) in crate::proxy_config::upstream::snapshot() {
for b in backends {
let drained = state.drained.contains(&drain_key(&upstream, &b.url));
if b.drained != drained {
crate::proxy_config::upstream::set_drained(&upstream, &b.url, drained);
}
}
}
for (name, enabled) in &state.flags {
crate::feature::global().set(name, *enabled);
}
for (id, (cache, path, prefix)) in &state.purges {
if *id > purges_after {
if let Some(c) = crate::cache::registry::get(cache) {
let target = if !path.is_empty() {
crate::cache::Purge::Exact(path.clone())
} else if !prefix.is_empty() {
crate::cache::Purge::Prefix(prefix.clone())
} else {
crate::cache::Purge::All
};
c.purge(&target);
}
}
}
}
}
pub struct ClusterSync {
store: Box<dyn Store>,
local: Box<dyn Local>,
node: String,
inner: Mutex<SyncState>,
queue: Mutex<Vec<Op>>,
wake: std::sync::Condvar,
}
#[derive(Default)]
struct SyncState {
applied: u64,
purges_applied: Option<u64>,
pending: Vec<Op>,
nodes: Vec<String>,
up: bool,
reported_down: bool,
}
impl ClusterSync {
pub fn new(store: Box<dyn Store>, local: Box<dyn Local>, node: &str) -> ClusterSync {
ClusterSync { store, local, node: node.to_string(), inner: Mutex::new(SyncState::default()), queue: Mutex::new(Vec::new()), wake: std::sync::Condvar::new() }
}
pub fn publish(&self, op: Op) {
self.queue.lock().unwrap_or_else(|e| e.into_inner()).push(op);
self.wake.notify_one();
}
fn wait(&self, timeout: Duration) {
let queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
if queue.is_empty() {
let _ = self.wake.wait_timeout(queue, timeout);
}
}
pub fn tick(&self, heartbeat: bool) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let published = std::mem::take(&mut *self.queue.lock().unwrap_or_else(|e| e.into_inner()));
inner.pending.extend(published);
let result = (|| -> std::io::Result<()> {
if self.store.state()?.is_none() {
for op in self.local.snapshot().to_ops() {
self.store.apply(&op)?;
}
}
while let Some(op) = inner.pending.first().cloned() {
self.store.apply(&op)?;
inner.pending.remove(0);
}
if let Some(state) = self.store.state()? {
let purges_after = *inner.purges_applied.get_or_insert_with(|| state.purges.keys().next_back().copied().unwrap_or(0));
if state.version != inner.applied {
self.local.apply(&state, purges_after);
inner.applied = state.version;
inner.purges_applied = Some(state.purges.keys().next_back().copied().unwrap_or(purges_after).max(purges_after));
}
}
if heartbeat {
inner.nodes = self.store.heartbeat(&self.node, inner.applied)?;
}
Ok(())
})();
match result {
Ok(()) => {
if inner.reported_down {
println!("[cluster] Redis reachable again; this replica is in sync");
}
inner.up = true;
inner.reported_down = false;
}
Err(e) => {
if !inner.reported_down {
eprintln!("[cluster] Redis unreachable ({e}); changes apply on this replica and are sent when it's back");
inner.reported_down = true;
}
inner.up = false;
}
}
}
pub fn status_json(&self) -> String {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let nodes: Vec<String> = inner.nodes.iter().map(|n| format!("\"{}\"", json_escape(n))).collect();
format!(
r#"{{"node":"{}","redis":"{}","version":{},"nodes":[{}],"pending":{}}}"#,
json_escape(&self.node),
if inner.up { "up" } else { "down" },
inner.applied,
nodes.join(","),
inner.pending.len()
)
}
}
fn global() -> &'static OnceLock<ClusterSync> {
static SYNC: OnceLock<ClusterSync> = OnceLock::new();
&SYNC
}
pub fn publish(op: Op) {
if let Some(sync) = global().get() {
sync.publish(op);
}
}
pub fn status_json() -> Option<String> {
global().get().map(ClusterSync::status_json)
}
pub fn start_from_env() {
let env = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let Some(addr) = env(RWS_CONFIG_CLUSTER_REDIS).or_else(|| env(crate::entry_point::Config::RWS_CONFIG_ACME_REDIS)) else { return };
let password = env(RWS_CONFIG_CLUSTER_REDIS_PASSWORD).or_else(|| env(crate::entry_point::Config::RWS_CONFIG_ACME_REDIS_PASSWORD));
let prefix = env(RWS_CONFIG_CLUSTER_PREFIX).unwrap_or_else(|| "rws:cluster:".to_string());
let node = crate::admin::access::node_name();
let sync = ClusterSync::new(Box::new(RedisStore::new(&addr, password, &prefix)), Box::new(ProcessState), &node);
if global().set(sync).is_err() {
return;
}
println!("[cluster] sharing runtime state through Redis at {addr} as node {node}");
let _ = std::thread::Builder::new().name("rws-cluster".into()).spawn(|| {
let mut last_heartbeat: Option<std::time::Instant> = None;
loop {
if let Some(sync) = global().get() {
let heartbeat = last_heartbeat.is_none_or(|t| t.elapsed() >= HEARTBEAT);
sync.tick(heartbeat);
if heartbeat {
last_heartbeat = Some(std::time::Instant::now());
}
sync.wait(POLL);
} else {
std::thread::sleep(POLL);
}
}
});
}
#[cfg(test)]
mod tests;