1use std::collections::{HashMap, HashSet};
52use std::sync::atomic::{AtomicU64, Ordering};
53use std::sync::Arc;
54use std::time::Duration;
55
56use parking_lot::Mutex;
57use tokio::sync::Notify;
58
59use atomr_cluster::QuorumObserver;
60use atomr_distributed_data::{Flag, LwwRegister, Replicator};
61
62#[derive(Debug, Clone, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum HaltReason {
67 Manual(String),
69 QuorumLost,
72 RiskBreach(String),
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
79pub struct HaltToken(pub u64);
80
81pub trait HaltGuarded: Send {
85 fn on_halt(&mut self, reason: &HaltReason);
86}
87
88#[derive(Debug, Clone)]
90pub struct ResetAuthorization {
91 pub approver_a: String,
92 pub approver_b: String,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct QuiescenceReport {
101 pub acked: usize,
103 pub total: usize,
105 pub timed_out: bool,
107 pub remote_acked: usize,
109 pub remote_total: usize,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
116pub enum ResetError {
117 #[error("reset requires two non-empty approvers")]
118 MissingApprover,
119 #[error("reset requires two distinct approvers (got the same identity twice)")]
120 DuplicateApprover,
121}
122
123struct Guarded {
126 party: Mutex<Box<dyn HaltGuarded>>,
127 ack_rx: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
128}
129
130pub struct AckHandle {
133 tx: Option<tokio::sync::oneshot::Sender<()>>,
134}
135
136impl AckHandle {
137 pub fn ack(mut self) {
139 if let Some(tx) = self.tx.take() {
140 let _ = tx.send(());
141 }
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum HaltPdu {
152 Halt { from: String, epoch: u64, reason: HaltReason },
157 Ack { from: String, epoch: u64 },
159}
160
161pub trait HaltTransport: Send + Sync + 'static {
171 fn peers(&self) -> Vec<String>;
173 fn send(&self, target_node: &str, pdu: HaltPdu);
175}
176
177pub struct ClusterKillSwitch {
179 replicator: Arc<Replicator>,
180 base_key: String,
182 node: String,
184 seq: AtomicU64,
186 last_reason: Mutex<Option<HaltReason>>,
188 guarded: Mutex<Vec<Guarded>>,
190 transport: Option<Arc<dyn HaltTransport>>,
192 remote_acks: Mutex<HashMap<u64, HashSet<String>>>,
194 ack_notify: Notify,
196}
197
198impl ClusterKillSwitch {
199 pub fn new(
203 replicator: Arc<Replicator>,
204 base_key: impl Into<String>,
205 node: impl Into<String>,
206 ) -> Arc<Self> {
207 Self::build(replicator, base_key, node, None)
208 }
209
210 pub fn with_transport(
214 replicator: Arc<Replicator>,
215 base_key: impl Into<String>,
216 node: impl Into<String>,
217 transport: Arc<dyn HaltTransport>,
218 ) -> Arc<Self> {
219 Self::build(replicator, base_key, node, Some(transport))
220 }
221
222 fn build(
223 replicator: Arc<Replicator>,
224 base_key: impl Into<String>,
225 node: impl Into<String>,
226 transport: Option<Arc<dyn HaltTransport>>,
227 ) -> Arc<Self> {
228 let base_key = base_key.into();
229 let this = Arc::new(Self {
230 replicator,
231 base_key,
232 node: node.into(),
233 seq: AtomicU64::new(0),
234 last_reason: Mutex::new(None),
235 guarded: Mutex::new(Vec::new()),
236 transport,
237 remote_acks: Mutex::new(HashMap::new()),
238 ack_notify: Notify::new(),
239 });
240 if this.epoch_register().is_none() {
242 this.replicator.update(&this.epoch_key(), LwwRegister::new(&this.node, 0u64, this.next_ts()));
243 }
244 this
245 }
246
247 pub fn replicator(&self) -> &Arc<Replicator> {
249 &self.replicator
250 }
251
252 fn epoch_key(&self) -> String {
253 format!("{}::epoch", self.base_key)
254 }
255
256 fn flag_key(&self, epoch: u64) -> String {
258 format!("{}::flag::{}", self.base_key, epoch)
259 }
260
261 fn next_ts(&self) -> u64 {
262 self.seq.fetch_add(1, Ordering::SeqCst) + 1
263 }
264
265 fn epoch_register(&self) -> Option<LwwRegister<u64>> {
266 self.replicator.get::<LwwRegister<u64>>(&self.epoch_key())
267 }
268
269 pub fn epoch(&self) -> u64 {
271 self.epoch_register().map(|r| *r.value()).unwrap_or(0)
272 }
273
274 pub fn engage(&self, reason: HaltReason) -> HaltToken {
277 self.engage_epoch(self.epoch(), reason);
278 HaltToken(self.next_ts())
279 }
280
281 fn engage_epoch(&self, epoch: u64, reason: HaltReason) {
285 let mut flag = self.replicator.get::<Flag>(&self.flag_key(epoch)).unwrap_or_default();
286 flag.switch_on();
287 self.replicator.update(&self.flag_key(epoch), flag);
288 *self.last_reason.lock() = Some(reason);
289 }
290
291 pub fn is_engaged(&self) -> bool {
294 let epoch = self.epoch();
295 self.replicator.get::<Flag>(&self.flag_key(epoch)).map(|f| f.enabled()).unwrap_or(false)
296 }
297
298 pub fn last_reason(&self) -> Option<HaltReason> {
300 self.last_reason.lock().clone()
301 }
302
303 pub fn register_guarded(&self, party: Box<dyn HaltGuarded>) -> AckHandle {
307 let (tx, rx) = tokio::sync::oneshot::channel();
308 self.guarded.lock().push(Guarded { party: Mutex::new(party), ack_rx: Mutex::new(Some(rx)) });
309 AckHandle { tx: Some(tx) }
310 }
311
312 pub async fn await_quiescence(&self, timeout: Duration) -> QuiescenceReport {
321 let deadline = tokio::time::Instant::now() + timeout;
322 let epoch = self.epoch();
323 let reason = self.last_reason().unwrap_or(HaltReason::Manual(String::new()));
324
325 let (local_acked, local_total) = self.collect_local(deadline, &reason).await;
327
328 let (remote_acked, remote_total) = match &self.transport {
330 Some(transport) => {
331 let peers = transport.peers();
332 let remote_total = peers.len();
333 if remote_total == 0 {
334 (0, 0)
335 } else {
336 self.remote_acks.lock().entry(epoch).or_default().clear();
338 for peer in &peers {
339 transport.send(
340 peer,
341 HaltPdu::Halt { from: self.node.clone(), epoch, reason: reason.clone() },
342 );
343 }
344 let acked = self.collect_remote(epoch, peers, deadline).await;
345 (acked, remote_total)
346 }
347 }
348 None => (0, 0),
349 };
350
351 let acked = local_acked + remote_acked;
352 let total = local_total + remote_total;
353 QuiescenceReport { acked, total, timed_out: acked < total, remote_acked, remote_total }
354 }
355
356 async fn collect_local(&self, deadline: tokio::time::Instant, reason: &HaltReason) -> (usize, usize) {
359 let mut receivers = Vec::new();
360 {
361 let guard = self.guarded.lock();
362 for g in guard.iter() {
363 g.party.lock().on_halt(reason);
364 if let Some(rx) = g.ack_rx.lock().take() {
365 receivers.push(rx);
366 }
367 }
368 }
369 let total = receivers.len();
370 let mut acked = 0usize;
371 for rx in receivers {
374 if let Ok(Ok(())) = tokio::time::timeout_at(deadline, rx).await {
375 acked += 1;
376 }
377 }
378 (acked, total)
379 }
380
381 async fn collect_remote(&self, epoch: u64, peers: Vec<String>, deadline: tokio::time::Instant) -> usize {
384 let target = peers.len();
385 loop {
386 let notified = self.ack_notify.notified();
389 tokio::pin!(notified);
390 notified.as_mut().enable();
391
392 let have = self.remote_ack_count(epoch, &peers);
393 if have >= target {
394 return have;
395 }
396 if tokio::time::timeout_at(deadline, notified).await.is_err() {
397 return self.remote_ack_count(epoch, &peers);
398 }
399 }
400 }
401
402 fn remote_ack_count(&self, epoch: u64, peers: &[String]) -> usize {
404 let acks = self.remote_acks.lock();
405 match acks.get(&epoch) {
406 Some(set) => peers.iter().filter(|p| set.contains(p.as_str())).count(),
407 None => 0,
408 }
409 }
410
411 pub async fn apply_pdu(&self, pdu: HaltPdu, local_timeout: Duration) {
419 match pdu {
420 HaltPdu::Halt { from, epoch, reason } => {
421 self.engage_epoch(epoch, reason.clone());
422 let deadline = tokio::time::Instant::now() + local_timeout;
423 let _ = self.collect_local(deadline, &reason).await;
424 if let Some(transport) = &self.transport {
425 transport.send(&from, HaltPdu::Ack { from: self.node.clone(), epoch });
426 }
427 }
428 HaltPdu::Ack { from, epoch } => {
429 self.remote_acks.lock().entry(epoch).or_default().insert(from);
430 self.ack_notify.notify_waiters();
431 }
432 }
433 }
434
435 pub fn reset(&self, authz: ResetAuthorization) -> Result<u64, ResetError> {
442 if authz.approver_a.trim().is_empty() || authz.approver_b.trim().is_empty() {
443 return Err(ResetError::MissingApprover);
444 }
445 if authz.approver_a == authz.approver_b {
446 return Err(ResetError::DuplicateApprover);
447 }
448 let new_epoch = self.epoch() + 1;
449 self.replicator.update(&self.epoch_key(), LwwRegister::new(&self.node, new_epoch, self.next_ts()));
450 *self.last_reason.lock() = None;
452 Ok(new_epoch)
453 }
454}
455
456pub struct KillSwitchQuorumObserver(pub Arc<ClusterKillSwitch>);
460
461impl QuorumObserver for KillSwitchQuorumObserver {
462 fn on_quorum_lost(&self) {
463 self.0.engage(HaltReason::QuorumLost);
464 }
465 fn on_quorum_regained(&self) {
466 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use std::sync::atomic::AtomicBool;
475
476 fn switch() -> Arc<ClusterKillSwitch> {
477 ClusterKillSwitch::new(Replicator::new(), "atomr/killswitch", "node-1")
478 }
479
480 #[test]
481 fn engage_latches_and_is_engaged() {
482 let ks = switch();
483 assert!(!ks.is_engaged());
484 let t = ks.engage(HaltReason::Manual("drill".into()));
485 assert!(ks.is_engaged());
486 assert_eq!(ks.last_reason(), Some(HaltReason::Manual("drill".into())));
487 assert!(t.0 > 0);
488 ks.engage(HaltReason::Manual("again".into()));
490 assert!(ks.is_engaged());
491 }
492
493 #[test]
494 fn engaged_survives_independent_merge_of_off_copy() {
495 let ks = switch();
497 ks.engage(HaltReason::QuorumLost);
498 let epoch = ks.epoch();
499 ks.replicator().update(&format!("atomr/killswitch::flag::{epoch}"), Flag::new());
501 assert!(ks.is_engaged());
502 }
503
504 #[test]
505 fn reset_requires_two_distinct_nonempty_approvers() {
506 let ks = switch();
507 ks.engage(HaltReason::Manual("x".into()));
508 assert!(ks.is_engaged());
509
510 assert_eq!(
511 ks.reset(ResetAuthorization { approver_a: "".into(), approver_b: "bob".into() }),
512 Err(ResetError::MissingApprover)
513 );
514 assert_eq!(
515 ks.reset(ResetAuthorization { approver_a: "amy".into(), approver_b: "amy".into() }),
516 Err(ResetError::DuplicateApprover)
517 );
518 assert!(ks.is_engaged());
520
521 let new_epoch = ks
523 .reset(ResetAuthorization { approver_a: "amy".into(), approver_b: "bob".into() })
524 .expect("valid authz");
525 assert_eq!(new_epoch, 1);
526 assert!(!ks.is_engaged(), "new epoch flag must start unset");
527 assert!(ks.last_reason().is_none());
528
529 ks.engage(HaltReason::RiskBreach("limit".into()));
531 assert!(ks.is_engaged());
532 }
533
534 #[tokio::test]
535 async fn await_quiescence_collects_acks() {
536 struct Party {
539 halted: Arc<AtomicBool>,
540 ack: Arc<Mutex<Option<AckHandle>>>,
541 }
542 impl HaltGuarded for Party {
543 fn on_halt(&mut self, _reason: &HaltReason) {
544 self.halted.store(true, Ordering::SeqCst);
545 if let Some(h) = self.ack.lock().take() {
546 h.ack();
547 }
548 }
549 }
550
551 let ks = switch();
552 let halted = Arc::new(AtomicBool::new(false));
553 let ack_slot = Arc::new(Mutex::new(None));
554 let party = Box::new(Party { halted: halted.clone(), ack: ack_slot.clone() });
555 let handle = ks.register_guarded(party);
557 *ack_slot.lock() = Some(handle);
558
559 ks.engage(HaltReason::Manual("drill".into()));
560 let report = ks.await_quiescence(Duration::from_millis(500)).await;
561 assert!(halted.load(Ordering::SeqCst));
562 assert_eq!(report.total, 1);
563 assert_eq!(report.acked, 1);
564 assert!(!report.timed_out);
565 }
566
567 #[tokio::test]
568 async fn await_quiescence_times_out_without_ack() {
569 struct Silent;
570 impl HaltGuarded for Silent {
571 fn on_halt(&mut self, _reason: &HaltReason) {}
572 }
573 let ks = switch();
574 let _handle = ks.register_guarded(Box::new(Silent));
576 let report = ks.await_quiescence(Duration::from_millis(50)).await;
577 assert_eq!(report.total, 1);
578 assert!(report.timed_out);
579 }
580
581 #[test]
582 fn quorum_observer_engages_on_loss() {
583 let ks = switch();
584 let obs = KillSwitchQuorumObserver(ks.clone());
585 assert!(!ks.is_engaged());
586 obs.on_quorum_lost();
587 assert!(ks.is_engaged());
588 assert_eq!(ks.last_reason(), Some(HaltReason::QuorumLost));
589 obs.on_quorum_regained();
591 assert!(ks.is_engaged());
592 }
593
594 #[test]
595 fn no_transport_reports_zero_remote() {
596 let rt = tokio::runtime::Runtime::new().unwrap();
598 rt.block_on(async {
599 let ks = switch();
600 ks.engage(HaltReason::Manual("x".into()));
601 let report = ks.await_quiescence(Duration::from_millis(50)).await;
602 assert_eq!(report.remote_total, 0);
603 assert_eq!(report.remote_acked, 0);
604 assert_eq!(report.total, 0); assert!(!report.timed_out);
606 });
607 }
608
609 #[derive(Clone)]
612 struct MemNet {
613 self_node: String,
614 reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>>,
615 ack_timeout: Duration,
616 }
617
618 impl HaltTransport for MemNet {
619 fn peers(&self) -> Vec<String> {
620 self.reg.lock().keys().filter(|k| *k != &self.self_node).cloned().collect()
621 }
622 fn send(&self, target: &str, pdu: HaltPdu) {
623 let sw = self.reg.lock().get(target).cloned();
624 if let Some(sw) = sw {
625 let to = self.ack_timeout;
626 tokio::spawn(async move {
627 sw.apply_pdu(pdu, to).await;
628 });
629 }
630 }
631 }
632
633 struct Party {
635 halted: Arc<AtomicBool>,
636 ack: Arc<Mutex<Option<AckHandle>>>,
637 }
638 impl HaltGuarded for Party {
639 fn on_halt(&mut self, _reason: &HaltReason) {
640 self.halted.store(true, Ordering::SeqCst);
641 if let Some(h) = self.ack.lock().take() {
642 h.ack();
643 }
644 }
645 }
646
647 #[tokio::test]
648 async fn cross_node_halt_fans_out_and_collects_remote_acks() {
649 let reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>> = Arc::new(Mutex::new(HashMap::new()));
650 let net_a = Arc::new(MemNet {
651 self_node: "A".into(),
652 reg: reg.clone(),
653 ack_timeout: Duration::from_millis(500),
654 });
655 let net_b = Arc::new(MemNet {
656 self_node: "B".into(),
657 reg: reg.clone(),
658 ack_timeout: Duration::from_millis(500),
659 });
660 let a = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "A", net_a);
661 let b = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "B", net_b);
662 reg.lock().insert("A".into(), a.clone());
663 reg.lock().insert("B".into(), b.clone());
664
665 let b_halted = Arc::new(AtomicBool::new(false));
667 let b_ack = Arc::new(Mutex::new(None));
668 let handle = b.register_guarded(Box::new(Party { halted: b_halted.clone(), ack: b_ack.clone() }));
669 *b_ack.lock() = Some(handle);
670
671 a.engage(HaltReason::RiskBreach("limit breach".into()));
673 let report = a.await_quiescence(Duration::from_millis(2000)).await;
674
675 assert_eq!(report.remote_total, 1, "A should broadcast to its one peer (B)");
676 assert_eq!(report.remote_acked, 1, "B should ack its halt");
677 assert_eq!(report.total, 1);
678 assert_eq!(report.acked, 1);
679 assert!(!report.timed_out);
680
681 assert!(b.is_engaged(), "B's latch must engage from the Halt PDU");
683 assert!(b_halted.load(Ordering::SeqCst), "B's guarded party must be halted");
684 assert_eq!(b.last_reason(), Some(HaltReason::RiskBreach("limit breach".into())));
686 }
687
688 #[tokio::test]
689 async fn cross_node_times_out_when_peer_silent() {
690 let reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>> = Arc::new(Mutex::new(HashMap::new()));
693 let net_a = Arc::new(MemNet {
694 self_node: "A".into(),
695 reg: reg.clone(),
696 ack_timeout: Duration::from_millis(100),
697 });
698 let a = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "A", net_a);
699 let b = ClusterKillSwitch::new(Replicator::new(), "atomr/ks", "B");
701 reg.lock().insert("A".into(), a.clone());
702 reg.lock().insert("B".into(), b.clone());
703
704 a.engage(HaltReason::Manual("drill".into()));
705 let report = a.await_quiescence(Duration::from_millis(150)).await;
706
707 assert_eq!(report.remote_total, 1);
708 assert_eq!(report.remote_acked, 0);
709 assert!(report.timed_out, "missing remote ack must surface as a timeout");
710 assert!(b.is_engaged());
712 }
713}