use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use tokio::sync::Notify;
use atomr_cluster::QuorumObserver;
use atomr_distributed_data::{Flag, LwwRegister, Replicator};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HaltReason {
Manual(String),
QuorumLost,
RiskBreach(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct HaltToken(pub u64);
pub trait HaltGuarded: Send {
fn on_halt(&mut self, reason: &HaltReason);
}
#[derive(Debug, Clone)]
pub struct ResetAuthorization {
pub approver_a: String,
pub approver_b: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuiescenceReport {
pub acked: usize,
pub total: usize,
pub timed_out: bool,
pub remote_acked: usize,
pub remote_total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResetError {
#[error("reset requires two non-empty approvers")]
MissingApprover,
#[error("reset requires two distinct approvers (got the same identity twice)")]
DuplicateApprover,
}
struct Guarded {
party: Mutex<Box<dyn HaltGuarded>>,
ack_rx: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
}
pub struct AckHandle {
tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl AckHandle {
pub fn ack(mut self) {
if let Some(tx) = self.tx.take() {
let _ = tx.send(());
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HaltPdu {
Halt { from: String, epoch: u64, reason: HaltReason },
Ack { from: String, epoch: u64 },
}
pub trait HaltTransport: Send + Sync + 'static {
fn peers(&self) -> Vec<String>;
fn send(&self, target_node: &str, pdu: HaltPdu);
}
pub struct ClusterKillSwitch {
replicator: Arc<Replicator>,
base_key: String,
node: String,
seq: AtomicU64,
last_reason: Mutex<Option<HaltReason>>,
guarded: Mutex<Vec<Guarded>>,
transport: Option<Arc<dyn HaltTransport>>,
remote_acks: Mutex<HashMap<u64, HashSet<String>>>,
ack_notify: Notify,
}
impl ClusterKillSwitch {
pub fn new(
replicator: Arc<Replicator>,
base_key: impl Into<String>,
node: impl Into<String>,
) -> Arc<Self> {
Self::build(replicator, base_key, node, None)
}
pub fn with_transport(
replicator: Arc<Replicator>,
base_key: impl Into<String>,
node: impl Into<String>,
transport: Arc<dyn HaltTransport>,
) -> Arc<Self> {
Self::build(replicator, base_key, node, Some(transport))
}
fn build(
replicator: Arc<Replicator>,
base_key: impl Into<String>,
node: impl Into<String>,
transport: Option<Arc<dyn HaltTransport>>,
) -> Arc<Self> {
let base_key = base_key.into();
let this = Arc::new(Self {
replicator,
base_key,
node: node.into(),
seq: AtomicU64::new(0),
last_reason: Mutex::new(None),
guarded: Mutex::new(Vec::new()),
transport,
remote_acks: Mutex::new(HashMap::new()),
ack_notify: Notify::new(),
});
if this.epoch_register().is_none() {
this.replicator.update(&this.epoch_key(), LwwRegister::new(&this.node, 0u64, this.next_ts()));
}
this
}
pub fn replicator(&self) -> &Arc<Replicator> {
&self.replicator
}
fn epoch_key(&self) -> String {
format!("{}::epoch", self.base_key)
}
fn flag_key(&self, epoch: u64) -> String {
format!("{}::flag::{}", self.base_key, epoch)
}
fn next_ts(&self) -> u64 {
self.seq.fetch_add(1, Ordering::SeqCst) + 1
}
fn epoch_register(&self) -> Option<LwwRegister<u64>> {
self.replicator.get::<LwwRegister<u64>>(&self.epoch_key())
}
pub fn epoch(&self) -> u64 {
self.epoch_register().map(|r| *r.value()).unwrap_or(0)
}
pub fn engage(&self, reason: HaltReason) -> HaltToken {
self.engage_epoch(self.epoch(), reason);
HaltToken(self.next_ts())
}
fn engage_epoch(&self, epoch: u64, reason: HaltReason) {
let mut flag = self.replicator.get::<Flag>(&self.flag_key(epoch)).unwrap_or_default();
flag.switch_on();
self.replicator.update(&self.flag_key(epoch), flag);
*self.last_reason.lock() = Some(reason);
}
pub fn is_engaged(&self) -> bool {
let epoch = self.epoch();
self.replicator.get::<Flag>(&self.flag_key(epoch)).map(|f| f.enabled()).unwrap_or(false)
}
pub fn last_reason(&self) -> Option<HaltReason> {
self.last_reason.lock().clone()
}
pub fn register_guarded(&self, party: Box<dyn HaltGuarded>) -> AckHandle {
let (tx, rx) = tokio::sync::oneshot::channel();
self.guarded.lock().push(Guarded { party: Mutex::new(party), ack_rx: Mutex::new(Some(rx)) });
AckHandle { tx: Some(tx) }
}
pub async fn await_quiescence(&self, timeout: Duration) -> QuiescenceReport {
let deadline = tokio::time::Instant::now() + timeout;
let epoch = self.epoch();
let reason = self.last_reason().unwrap_or(HaltReason::Manual(String::new()));
let (local_acked, local_total) = self.collect_local(deadline, &reason).await;
let (remote_acked, remote_total) = match &self.transport {
Some(transport) => {
let peers = transport.peers();
let remote_total = peers.len();
if remote_total == 0 {
(0, 0)
} else {
self.remote_acks.lock().entry(epoch).or_default().clear();
for peer in &peers {
transport.send(
peer,
HaltPdu::Halt { from: self.node.clone(), epoch, reason: reason.clone() },
);
}
let acked = self.collect_remote(epoch, peers, deadline).await;
(acked, remote_total)
}
}
None => (0, 0),
};
let acked = local_acked + remote_acked;
let total = local_total + remote_total;
QuiescenceReport { acked, total, timed_out: acked < total, remote_acked, remote_total }
}
async fn collect_local(&self, deadline: tokio::time::Instant, reason: &HaltReason) -> (usize, usize) {
let mut receivers = Vec::new();
{
let guard = self.guarded.lock();
for g in guard.iter() {
g.party.lock().on_halt(reason);
if let Some(rx) = g.ack_rx.lock().take() {
receivers.push(rx);
}
}
}
let total = receivers.len();
let mut acked = 0usize;
for rx in receivers {
if let Ok(Ok(())) = tokio::time::timeout_at(deadline, rx).await {
acked += 1;
}
}
(acked, total)
}
async fn collect_remote(&self, epoch: u64, peers: Vec<String>, deadline: tokio::time::Instant) -> usize {
let target = peers.len();
loop {
let notified = self.ack_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let have = self.remote_ack_count(epoch, &peers);
if have >= target {
return have;
}
if tokio::time::timeout_at(deadline, notified).await.is_err() {
return self.remote_ack_count(epoch, &peers);
}
}
}
fn remote_ack_count(&self, epoch: u64, peers: &[String]) -> usize {
let acks = self.remote_acks.lock();
match acks.get(&epoch) {
Some(set) => peers.iter().filter(|p| set.contains(p.as_str())).count(),
None => 0,
}
}
pub async fn apply_pdu(&self, pdu: HaltPdu, local_timeout: Duration) {
match pdu {
HaltPdu::Halt { from, epoch, reason } => {
self.engage_epoch(epoch, reason.clone());
let deadline = tokio::time::Instant::now() + local_timeout;
let _ = self.collect_local(deadline, &reason).await;
if let Some(transport) = &self.transport {
transport.send(&from, HaltPdu::Ack { from: self.node.clone(), epoch });
}
}
HaltPdu::Ack { from, epoch } => {
self.remote_acks.lock().entry(epoch).or_default().insert(from);
self.ack_notify.notify_waiters();
}
}
}
pub fn reset(&self, authz: ResetAuthorization) -> Result<u64, ResetError> {
if authz.approver_a.trim().is_empty() || authz.approver_b.trim().is_empty() {
return Err(ResetError::MissingApprover);
}
if authz.approver_a == authz.approver_b {
return Err(ResetError::DuplicateApprover);
}
let new_epoch = self.epoch() + 1;
self.replicator.update(&self.epoch_key(), LwwRegister::new(&self.node, new_epoch, self.next_ts()));
*self.last_reason.lock() = None;
Ok(new_epoch)
}
}
pub struct KillSwitchQuorumObserver(pub Arc<ClusterKillSwitch>);
impl QuorumObserver for KillSwitchQuorumObserver {
fn on_quorum_lost(&self) {
self.0.engage(HaltReason::QuorumLost);
}
fn on_quorum_regained(&self) {
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
fn switch() -> Arc<ClusterKillSwitch> {
ClusterKillSwitch::new(Replicator::new(), "atomr/killswitch", "node-1")
}
#[test]
fn engage_latches_and_is_engaged() {
let ks = switch();
assert!(!ks.is_engaged());
let t = ks.engage(HaltReason::Manual("drill".into()));
assert!(ks.is_engaged());
assert_eq!(ks.last_reason(), Some(HaltReason::Manual("drill".into())));
assert!(t.0 > 0);
ks.engage(HaltReason::Manual("again".into()));
assert!(ks.is_engaged());
}
#[test]
fn engaged_survives_independent_merge_of_off_copy() {
let ks = switch();
ks.engage(HaltReason::QuorumLost);
let epoch = ks.epoch();
ks.replicator().update(&format!("atomr/killswitch::flag::{epoch}"), Flag::new());
assert!(ks.is_engaged());
}
#[test]
fn reset_requires_two_distinct_nonempty_approvers() {
let ks = switch();
ks.engage(HaltReason::Manual("x".into()));
assert!(ks.is_engaged());
assert_eq!(
ks.reset(ResetAuthorization { approver_a: "".into(), approver_b: "bob".into() }),
Err(ResetError::MissingApprover)
);
assert_eq!(
ks.reset(ResetAuthorization { approver_a: "amy".into(), approver_b: "amy".into() }),
Err(ResetError::DuplicateApprover)
);
assert!(ks.is_engaged());
let new_epoch = ks
.reset(ResetAuthorization { approver_a: "amy".into(), approver_b: "bob".into() })
.expect("valid authz");
assert_eq!(new_epoch, 1);
assert!(!ks.is_engaged(), "new epoch flag must start unset");
assert!(ks.last_reason().is_none());
ks.engage(HaltReason::RiskBreach("limit".into()));
assert!(ks.is_engaged());
}
#[tokio::test]
async fn await_quiescence_collects_acks() {
struct Party {
halted: Arc<AtomicBool>,
ack: Arc<Mutex<Option<AckHandle>>>,
}
impl HaltGuarded for Party {
fn on_halt(&mut self, _reason: &HaltReason) {
self.halted.store(true, Ordering::SeqCst);
if let Some(h) = self.ack.lock().take() {
h.ack();
}
}
}
let ks = switch();
let halted = Arc::new(AtomicBool::new(false));
let ack_slot = Arc::new(Mutex::new(None));
let party = Box::new(Party { halted: halted.clone(), ack: ack_slot.clone() });
let handle = ks.register_guarded(party);
*ack_slot.lock() = Some(handle);
ks.engage(HaltReason::Manual("drill".into()));
let report = ks.await_quiescence(Duration::from_millis(500)).await;
assert!(halted.load(Ordering::SeqCst));
assert_eq!(report.total, 1);
assert_eq!(report.acked, 1);
assert!(!report.timed_out);
}
#[tokio::test]
async fn await_quiescence_times_out_without_ack() {
struct Silent;
impl HaltGuarded for Silent {
fn on_halt(&mut self, _reason: &HaltReason) {}
}
let ks = switch();
let _handle = ks.register_guarded(Box::new(Silent));
let report = ks.await_quiescence(Duration::from_millis(50)).await;
assert_eq!(report.total, 1);
assert!(report.timed_out);
}
#[test]
fn quorum_observer_engages_on_loss() {
let ks = switch();
let obs = KillSwitchQuorumObserver(ks.clone());
assert!(!ks.is_engaged());
obs.on_quorum_lost();
assert!(ks.is_engaged());
assert_eq!(ks.last_reason(), Some(HaltReason::QuorumLost));
obs.on_quorum_regained();
assert!(ks.is_engaged());
}
#[test]
fn no_transport_reports_zero_remote() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let ks = switch();
ks.engage(HaltReason::Manual("x".into()));
let report = ks.await_quiescence(Duration::from_millis(50)).await;
assert_eq!(report.remote_total, 0);
assert_eq!(report.remote_acked, 0);
assert_eq!(report.total, 0); assert!(!report.timed_out);
});
}
#[derive(Clone)]
struct MemNet {
self_node: String,
reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>>,
ack_timeout: Duration,
}
impl HaltTransport for MemNet {
fn peers(&self) -> Vec<String> {
self.reg.lock().keys().filter(|k| *k != &self.self_node).cloned().collect()
}
fn send(&self, target: &str, pdu: HaltPdu) {
let sw = self.reg.lock().get(target).cloned();
if let Some(sw) = sw {
let to = self.ack_timeout;
tokio::spawn(async move {
sw.apply_pdu(pdu, to).await;
});
}
}
}
struct Party {
halted: Arc<AtomicBool>,
ack: Arc<Mutex<Option<AckHandle>>>,
}
impl HaltGuarded for Party {
fn on_halt(&mut self, _reason: &HaltReason) {
self.halted.store(true, Ordering::SeqCst);
if let Some(h) = self.ack.lock().take() {
h.ack();
}
}
}
#[tokio::test]
async fn cross_node_halt_fans_out_and_collects_remote_acks() {
let reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>> = Arc::new(Mutex::new(HashMap::new()));
let net_a = Arc::new(MemNet {
self_node: "A".into(),
reg: reg.clone(),
ack_timeout: Duration::from_millis(500),
});
let net_b = Arc::new(MemNet {
self_node: "B".into(),
reg: reg.clone(),
ack_timeout: Duration::from_millis(500),
});
let a = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "A", net_a);
let b = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "B", net_b);
reg.lock().insert("A".into(), a.clone());
reg.lock().insert("B".into(), b.clone());
let b_halted = Arc::new(AtomicBool::new(false));
let b_ack = Arc::new(Mutex::new(None));
let handle = b.register_guarded(Box::new(Party { halted: b_halted.clone(), ack: b_ack.clone() }));
*b_ack.lock() = Some(handle);
a.engage(HaltReason::RiskBreach("limit breach".into()));
let report = a.await_quiescence(Duration::from_millis(2000)).await;
assert_eq!(report.remote_total, 1, "A should broadcast to its one peer (B)");
assert_eq!(report.remote_acked, 1, "B should ack its halt");
assert_eq!(report.total, 1);
assert_eq!(report.acked, 1);
assert!(!report.timed_out);
assert!(b.is_engaged(), "B's latch must engage from the Halt PDU");
assert!(b_halted.load(Ordering::SeqCst), "B's guarded party must be halted");
assert_eq!(b.last_reason(), Some(HaltReason::RiskBreach("limit breach".into())));
}
#[tokio::test]
async fn cross_node_times_out_when_peer_silent() {
let reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>> = Arc::new(Mutex::new(HashMap::new()));
let net_a = Arc::new(MemNet {
self_node: "A".into(),
reg: reg.clone(),
ack_timeout: Duration::from_millis(100),
});
let a = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "A", net_a);
let b = ClusterKillSwitch::new(Replicator::new(), "atomr/ks", "B");
reg.lock().insert("A".into(), a.clone());
reg.lock().insert("B".into(), b.clone());
a.engage(HaltReason::Manual("drill".into()));
let report = a.await_quiescence(Duration::from_millis(150)).await;
assert_eq!(report.remote_total, 1);
assert_eq!(report.remote_acked, 0);
assert!(report.timed_out, "missing remote ack must surface as a timeout");
assert!(b.is_engaged());
}
}