1use crate::heartbeat::{HeartbeatSnapshot, HeartbeatTable, IN_FLIGHT_SLOTS};
12
13pub const DEFAULT_GRACE_EPOCHS: u64 = 1;
16
17#[derive(Debug, Clone)]
19pub struct ReclaimReport {
20 pub dead_slots: Vec<(usize, HeartbeatSnapshot)>,
23 pub new_global_epoch: u64,
25}
26
27impl ReclaimReport {
28 pub fn is_empty(&self) -> bool { self.dead_slots.is_empty() }
29}
30
31pub struct FailoverWatchdog<'a> {
33 pub table: &'a HeartbeatTable,
34 pub grace_epochs: u64,
35}
36
37impl<'a> FailoverWatchdog<'a> {
38 pub fn new(table: &'a HeartbeatTable) -> Self {
39 Self { table, grace_epochs: DEFAULT_GRACE_EPOCHS }
40 }
41
42 pub fn with_grace(table: &'a HeartbeatTable, grace_epochs: u64) -> Self {
43 Self { table, grace_epochs }
44 }
45
46 pub fn scan(&self) -> ReclaimReport {
58 let new_epoch = self.table.tick_global_epoch();
59 let mut dead = Vec::new();
60 for i in 0..self.table.capacity() {
61 if let Some(snap) = self.table.snapshot(i) {
62 let lag = new_epoch.saturating_sub(snap.last_seen_epoch);
63 if lag > self.grace_epochs && snap.in_flight_bitmap != 0 {
64 dead.push((i, snap));
65 }
66 }
67 }
68 let dead_count = dead.len();
69 <HeartbeatTable as subetha_sidecar::AdaptiveInstance>::ring(self.table).push(
70 subetha_core::Observation {
71 op_kind: crate::sidecar_ops::liveness::OP_SCAN,
72 flags: if dead_count > 0 { 1 } else { 0 }, ..subetha_core::Observation::ZERO
74 },
75 );
76 ReclaimReport { dead_slots: dead, new_global_epoch: new_epoch }
77 }
78
79 pub fn iter_in_flight_bits(bitmap: u64) -> impl Iterator<Item = u8> {
82 (0u8..IN_FLIGHT_SLOTS as u8).filter(move |b| (bitmap >> b) & 1 == 1)
83 }
84
85 pub fn clear_dead_bitmap(&self, slot_idx: usize) {
89 let slot = self.table_slot(slot_idx);
90 slot.in_flight_bitmap.store(0, std::sync::atomic::Ordering::Release);
91 }
92
93 fn table_slot(&self, idx: usize) -> &crate::heartbeat::HeartbeatSlot {
94 crate::heartbeat::__slot_for_watchdog(self.table, idx)
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::heartbeat::HeartbeatTable;
105
106 fn tmp_path(name: &str) -> std::path::PathBuf {
107 let mut p = std::env::temp_dir();
108 let pid = std::process::id();
109 p.push(format!("subetha-failover-{name}-{pid}.bin"));
110 p
111 }
112
113 #[test]
114 fn watchdog_reports_no_dead_when_all_beat() {
115 let p = tmp_path("all-alive");
116 let t = HeartbeatTable::create(&p, 4).unwrap();
117 let s0 = t.register(1).unwrap();
118 let s1 = t.register(2).unwrap();
119 t.mark_in_flight(s0, 0);
120 t.mark_in_flight(s1, 1);
121
122 let w = FailoverWatchdog::new(&t);
123 t.beat(s0); t.beat(s1);
125 let r = w.scan();
126 assert!(r.is_empty(),
127 "no dead processes expected; got {} dead", r.dead_slots.len());
128 std::fs::remove_file(&p).ok();
129 }
130
131 #[test]
132 fn watchdog_reports_dead_when_grace_exceeded() {
133 let p = tmp_path("dead-one");
134 let t = HeartbeatTable::create(&p, 4).unwrap();
135 let s_alive = t.register(1).unwrap();
136 let s_dead = t.register(2).unwrap();
137 t.mark_in_flight(s_alive, 0);
138 t.mark_in_flight(s_dead, 1);
139 t.beat(s_alive); t.beat(s_dead);
140
141 let w = FailoverWatchdog::with_grace(&t, 2);
142 let r1 = w.scan();
144 assert!(r1.is_empty(), "first scan within grace; got {:?}", r1.dead_slots);
145 t.beat(s_alive);
147 let r2 = w.scan();
149 assert!(r2.is_empty(), "second scan equal to grace; got {:?}", r2.dead_slots);
150 t.beat(s_alive);
152 let r3 = w.scan();
154 assert_eq!(r3.dead_slots.len(), 1);
155 let (idx, snap) = &r3.dead_slots[0];
156 assert_eq!(*idx, s_dead);
157 assert_eq!(snap.pid, 2);
158 assert_eq!(snap.in_flight_bitmap, 1u64 << 1);
159 std::fs::remove_file(&p).ok();
160 }
161
162 #[test]
163 fn iter_in_flight_bits_walks_set_positions() {
164 let bm = (1u64 << 0) | (1u64 << 3) | (1u64 << 5) | (1u64 << 63);
165 let bits: Vec<u8> = FailoverWatchdog::iter_in_flight_bits(bm).collect();
166 assert_eq!(bits, vec![0, 3, 5, 63]);
167 }
168
169 #[test]
170 fn clear_dead_bitmap_silences_reports() {
171 let p = tmp_path("clear-dead");
172 let t = HeartbeatTable::create(&p, 1).unwrap();
173 let s = t.register(7).unwrap();
174 t.mark_in_flight(s, 4);
175 t.beat(s);
176 let w = FailoverWatchdog::with_grace(&t, 0);
177 let r1 = w.scan();
179 assert_eq!(r1.dead_slots.len(), 1);
180 w.clear_dead_bitmap(s);
182 let r2 = w.scan();
183 assert!(r2.is_empty(), "after clear, no dead slots reported");
184 std::fs::remove_file(&p).ok();
185 }
186}