Skip to main content

subetha_cxc/
epoch_barrier.rs

1//! `EpochBarrier` - multi-process phase synchronization with
2//! heartbeat-driven dead-peer exclusion.
3//!
4//! Composes one [`SharedAtomicU64`] (the
5//! packed state) with an external
6//! [`HeartbeatTable`] (the live-peer source).
7//! Releases when all LIVE peers (per the heartbeat) have called
8//! `wait` at the current epoch.
9//!
10//! # Why this exists
11//!
12//! Standard barriers (`std::sync::Barrier`, MPI_Barrier) require a
13//! known fixed participant count. In a distributed setting that's
14//! brittle: one crashed process and the whole barrier deadlocks.
15//! EpochBarrier reads the live peer count from the heartbeat table
16//! each scan, so a dead peer (whose heartbeat has lapsed beyond the
17//! grace window) is automatically excluded. The barrier releases as
18//! soon as the surviving peers reach it.
19//!
20//! # State encoding
21//!
22//! ONE SharedAtomicU64 holds both the current epoch and the arrived
23//! count, packed as `(epoch << 32) | arrived`. This makes the entire
24//! protocol single-atomic: register-as-arrived and release-the-
25//! barrier are both single CAS operations, so there's no ordering
26//! puzzle between two separate atomics.
27//!
28//! # Protocol
29//!
30//! `wait(my_epoch)`:
31//! 1. Load packed state. If `cur_epoch > my_epoch`, the epoch has
32//!    already passed; return.
33//! 2. If `cur_epoch < my_epoch`, we're early; yield and retry.
34//! 3. If `cur_epoch == my_epoch`, CAS to (cur_epoch, arrived + 1) to
35//!    register ourselves. On CAS failure, retry.
36//! 4. Wait loop: load state; if `cur_epoch > my_epoch`, return
37//!    (released by some peer). Otherwise check whether
38//!    `arrived >= live_peer_count`; if so, try CAS to
39//!    (cur_epoch + 1, 0) to release. Backoff between checks.
40//!
41//! # Quorum variant
42//!
43//! `wait_quorum(my_epoch, quorum)` is identical except the release
44//! threshold is `arrived >= quorum` rather than `>= live_peer_count`.
45//! Useful when the exact participant set is uncertain (e.g.,
46//! 2-phase-commit prepare needing majority commitment).
47//!
48//! # Capacity
49//!
50//! The packed encoding gives 32 bits to the epoch counter (4B
51//! barriers per primitive lifetime) and 32 bits to the arrived
52//! count (4B participants per epoch). Real-world deployments are
53//! bounded by both far below 32 bits.
54
55use std::path::{Path, PathBuf};
56use std::sync::atomic::Ordering;
57use std::sync::Arc;
58use std::thread;
59use std::time::{Duration, Instant};
60
61use crate::heartbeat::{HeartbeatTable, EMPTY_PID};
62use crate::shared_atomic::{SharedAtomicError, SharedAtomicU64};
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum BarrierError {
66    Atomic(SharedAtomicError),
67    EpochTooFarBehind,
68    Timeout,
69    NoLivePeers,
70}
71
72impl From<SharedAtomicError> for BarrierError {
73    fn from(e: SharedAtomicError) -> Self { Self::Atomic(e) }
74}
75
76fn state_path(base: &Path) -> PathBuf {
77    let mut p = base.to_path_buf();
78    let stem = p.file_name().unwrap().to_string_lossy().to_string();
79    p.set_file_name(format!("{stem}.state.bin"));
80    p
81}
82
83#[inline]
84fn pack(epoch: u32, arrived: u32) -> u64 {
85    ((epoch as u64) << 32) | (arrived as u64)
86}
87#[inline]
88fn unpack(state: u64) -> (u32, u32) {
89    ((state >> 32) as u32, state as u32)
90}
91
92/// Default grace window for treating a heartbeat slot as live. A
93/// slot is live when `global_epoch - slot.last_seen_epoch <= grace`.
94pub const DEFAULT_BARRIER_GRACE_EPOCHS: u64 = 3;
95
96pub struct EpochBarrier {
97    state: Arc<SharedAtomicU64>,
98    heartbeat: Arc<HeartbeatTable>,
99    grace_epochs: u64,
100    header_sidecar: subetha_core::HandshakeHeader,
101    ring_sidecar: Box<subetha_core::ObservationRing>,
102}
103
104impl subetha_sidecar::AdaptiveInstance for EpochBarrier {
105    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
106    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
107    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
108        Box::new(subetha_sidecar::NoMigrationPolicy)
109    }
110}
111
112impl EpochBarrier {
113    /// Create a new EpochBarrier at `base_path`. Borrows an existing
114    /// HeartbeatTable for live-peer counting; `grace_epochs` controls
115    /// how stale a slot can be before it counts as dead.
116    pub fn create(
117        base_path: impl AsRef<Path>,
118        heartbeat: Arc<HeartbeatTable>,
119        grace_epochs: u64,
120    ) -> Result<Self, BarrierError> {
121        let base = base_path.as_ref();
122        let state = Arc::new(SharedAtomicU64::create(state_path(base), 0)?);
123        Ok(Self {
124            state, heartbeat, grace_epochs,
125            header_sidecar: subetha_core::HandshakeHeader::new(),
126            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
127        })
128    }
129
130    /// Open an existing EpochBarrier.
131    pub fn open(
132        base_path: impl AsRef<Path>,
133        heartbeat: Arc<HeartbeatTable>,
134        grace_epochs: u64,
135    ) -> Result<Self, BarrierError> {
136        let base = base_path.as_ref();
137        let state = Arc::new(SharedAtomicU64::open(state_path(base))?);
138        Ok(Self {
139            state, heartbeat, grace_epochs,
140            header_sidecar: subetha_core::HandshakeHeader::new(),
141            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
142        })
143    }
144
145    /// Count live peers from the heartbeat table (slots with
146    /// `last_seen_epoch >= global_epoch - grace`).
147    pub fn live_peer_count(&self) -> u32 {
148        let global = self.heartbeat.global_epoch();
149        let cap = self.heartbeat.capacity();
150        let mut count = 0u32;
151        for i in 0..cap {
152            if let Some(snap) = self.heartbeat.snapshot(i) {
153                if snap.pid == EMPTY_PID { continue; }
154                if global.saturating_sub(snap.last_seen_epoch) <= self.grace_epochs {
155                    count += 1;
156                }
157            }
158        }
159        count
160    }
161
162    /// Wait for ALL live peers to reach `my_epoch`. Blocks; uses an
163    /// adaptive spin / yield / sleep backoff between releaser checks.
164    pub fn wait(&self, my_epoch: u32) -> Result<(), BarrierError> {
165        let r = self.wait_inner(my_epoch, None, None);
166        self.ring_sidecar.push_op(
167            crate::sidecar_ops::liveness::OP_WAIT,
168            if r.is_err() { 1 } else { 0 },
169        );
170        r
171    }
172
173    /// Wait with a quorum threshold instead of all-live. Releases
174    /// when `arrived >= quorum`.
175    pub fn wait_quorum(&self, my_epoch: u32, quorum: u32) -> Result<(), BarrierError> {
176        let r = self.wait_inner(my_epoch, Some(quorum), None);
177        self.ring_sidecar.push_op(
178            crate::sidecar_ops::liveness::OP_WAIT,
179            if r.is_err() { 1 } else { 0 },
180        );
181        r
182    }
183
184    /// Wait with a deadline. Returns `Err(Timeout)` if the deadline
185    /// passes before release.
186    pub fn wait_timeout(
187        &self, my_epoch: u32, timeout: Duration,
188    ) -> Result<(), BarrierError> {
189        self.wait_inner(my_epoch, None, Some(Instant::now() + timeout))
190    }
191
192    /// Wait with a deadline AND a quorum threshold.
193    pub fn wait_quorum_timeout(
194        &self, my_epoch: u32, quorum: u32, timeout: Duration,
195    ) -> Result<(), BarrierError> {
196        self.wait_inner(my_epoch, Some(quorum), Some(Instant::now() + timeout))
197    }
198
199    fn wait_inner(
200        &self,
201        my_epoch: u32,
202        quorum: Option<u32>,
203        deadline: Option<Instant>,
204    ) -> Result<(), BarrierError> {
205        // Registration loop: bump arrived at my_epoch, or fast-exit
206        // when the epoch has already passed.
207        loop {
208            if let Some(d) = deadline
209                && Instant::now() >= d { return Err(BarrierError::Timeout); }
210            let state = self.state.load(Ordering::Acquire);
211            let (cur_epoch, cur_arrived) = unpack(state);
212            if cur_epoch > my_epoch {
213                return Ok(());
214            }
215            if cur_epoch < my_epoch {
216                thread::yield_now();
217                continue;
218            }
219            let new = pack(cur_epoch, cur_arrived.saturating_add(1));
220            if self.state.compare_exchange(
221                state, new, Ordering::AcqRel, Ordering::Acquire,
222            ).is_ok() {
223                break;
224            }
225        }
226
227        // Release-wait loop: act as the releaser when the threshold
228        // is reached, otherwise back off and retry.
229        let mut spins = 0u32;
230        loop {
231            if let Some(d) = deadline
232                && Instant::now() >= d { return Err(BarrierError::Timeout); }
233            let state = self.state.load(Ordering::Acquire);
234            let (cur_epoch, cur_arrived) = unpack(state);
235            if cur_epoch > my_epoch {
236                return Ok(());
237            }
238            let threshold = match quorum {
239                Some(q) => q,
240                None => {
241                    let live = self.live_peer_count();
242                    if live == 0 { return Err(BarrierError::NoLivePeers); }
243                    live
244                }
245            };
246            if cur_arrived >= threshold {
247                let new = pack(cur_epoch.saturating_add(1), 0);
248                if self.state.compare_exchange(
249                    state, new, Ordering::AcqRel, Ordering::Acquire,
250                ).is_ok() {
251                    return Ok(());
252                }
253                continue;
254            }
255            spins += 1;
256            if spins < 32 {
257                std::hint::spin_loop();
258            } else if spins < 256 {
259                thread::yield_now();
260            } else {
261                thread::sleep(Duration::from_micros(50));
262            }
263        }
264    }
265
266    /// Current epoch (next one to be waited on).
267    pub fn current_epoch(&self) -> u32 {
268        unpack(self.state.load(Ordering::Acquire)).0
269    }
270
271    /// Currently arrived count at the current epoch.
272    pub fn arrived_count(&self) -> u32 {
273        unpack(self.state.load(Ordering::Acquire)).1
274    }
275
276    /// Snapshot (epoch, arrived).
277    pub fn snapshot(&self) -> (u32, u32) {
278        unpack(self.state.load(Ordering::Acquire))
279    }
280
281    pub fn flush(&self) -> Result<(), BarrierError> {
282        self.state.flush()?;
283        Ok(())
284    }
285
286    /// Non-blocking flush: schedules a writeback via the OS.
287    /// Note: Windows is only partially async (sync to page cache,
288    /// not to disk).
289    pub fn flush_async(&self) -> Result<(), BarrierError> {
290        self.state.flush_async()?;
291        Ok(())
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::heartbeat::HeartbeatTable;
299    use std::sync::Barrier as StdBarrier;
300    use std::sync::atomic::{AtomicU32, Ordering as O};
301
302    fn tmp_base(name: &str) -> PathBuf {
303        let mut p = std::env::temp_dir();
304        let pid = std::process::id();
305        p.push(format!("subetha-barrier-{name}-{pid}"));
306        p
307    }
308
309    fn tmp_hb(name: &str) -> PathBuf {
310        let mut p = std::env::temp_dir();
311        let pid = std::process::id();
312        p.push(format!("subetha-barrier-hb-{name}-{pid}.bin"));
313        p
314    }
315
316    fn cleanup(base: &Path, hb: &Path) {
317        std::fs::remove_file(state_path(base)).ok();
318        std::fs::remove_file(hb).ok();
319    }
320
321    fn make(name: &str, n_slots: usize, grace: u64) -> (PathBuf, PathBuf, Arc<HeartbeatTable>, EpochBarrier) {
322        let base = tmp_base(name);
323        let hb_path = tmp_hb(name);
324        let hb = Arc::new(HeartbeatTable::create(&hb_path, n_slots).unwrap());
325        let barrier = EpochBarrier::create(&base, hb.clone(), grace).unwrap();
326        (base, hb_path, hb, barrier)
327    }
328
329    #[test]
330    fn create_initial_state_is_zero() {
331        let (base, hb_path, _hb, barrier) = make("init", 4, 3);
332        assert_eq!(barrier.current_epoch(), 0);
333        assert_eq!(barrier.arrived_count(), 0);
334        cleanup(&base, &hb_path);
335    }
336
337    #[test]
338    fn single_peer_releases_immediately() {
339        let (base, hb_path, hb, barrier) = make("solo", 4, 3);
340        let s = hb.register(1001).unwrap();
341        hb.beat(s);
342        assert_eq!(barrier.live_peer_count(), 1);
343        barrier.wait(0).unwrap();
344        assert_eq!(barrier.current_epoch(), 1);
345        cleanup(&base, &hb_path);
346    }
347
348    #[test]
349    fn three_peers_all_arrive_releases() {
350        let (base, hb_path, hb, barrier) = make("three", 4, 10);
351        let slots: Vec<usize> = (0..3).map(|i| {
352            let s = hb.register(1000 + i as u32).unwrap();
353            hb.beat(s);
354            s
355        }).collect();
356        let _slots = slots;
357        let barrier = Arc::new(barrier);
358        let arrived = Arc::new(AtomicU32::new(0));
359        let sync = Arc::new(StdBarrier::new(3));
360        let mut handles = vec![];
361        for _ in 0..3 {
362            let b = barrier.clone();
363            let a = arrived.clone();
364            let s = sync.clone();
365            handles.push(thread::spawn(move || {
366                s.wait();
367                b.wait(0).unwrap();
368                a.fetch_add(1, O::AcqRel);
369            }));
370        }
371        for h in handles { h.join().unwrap(); }
372        assert_eq!(arrived.load(O::Acquire), 3);
373        assert_eq!(barrier.current_epoch(), 1);
374        cleanup(&base, &hb_path);
375    }
376
377    #[test]
378    fn early_arriver_waits_for_late_arriver() {
379        let (base, hb_path, hb, barrier) = make("late", 4, 10);
380        for i in 0..2 {
381            let s = hb.register(2000 + i).unwrap();
382            hb.beat(s);
383        }
384        let barrier = Arc::new(barrier);
385
386        let b1 = barrier.clone();
387        let early = thread::spawn(move || {
388            let start = Instant::now();
389            b1.wait(0).unwrap();
390            start.elapsed()
391        });
392        thread::sleep(Duration::from_millis(20));
393        let b2 = barrier.clone();
394        let late = thread::spawn(move || {
395            b2.wait(0).unwrap();
396        });
397        let elapsed = early.join().unwrap();
398        late.join().unwrap();
399        assert!(elapsed >= Duration::from_millis(15),
400            "early arriver should have waited ~20ms, got {elapsed:?}");
401        cleanup(&base, &hb_path);
402    }
403
404    #[test]
405    fn quorum_releases_at_threshold_below_total() {
406        let (base, hb_path, hb, barrier) = make("quorum", 8, 10);
407        for i in 0..5 {
408            let s = hb.register(3000 + i).unwrap();
409            hb.beat(s);
410        }
411        let barrier = Arc::new(barrier);
412
413        let sync = Arc::new(StdBarrier::new(3));
414        let mut handles = vec![];
415        for _ in 0..3 {
416            let b = barrier.clone();
417            let s = sync.clone();
418            handles.push(thread::spawn(move || {
419                s.wait();
420                b.wait_quorum(0, 3).unwrap();
421            }));
422        }
423        for h in handles { h.join().unwrap(); }
424        assert_eq!(barrier.current_epoch(), 1);
425        cleanup(&base, &hb_path);
426    }
427
428    #[test]
429    fn wait_timeout_returns_timeout_when_not_enough_arrive() {
430        let (base, hb_path, hb, barrier) = make("timeout", 4, 10);
431        for i in 0..2 {
432            let s = hb.register(4000 + i).unwrap();
433            hb.beat(s);
434        }
435        let start = Instant::now();
436        let r = barrier.wait_timeout(0, Duration::from_millis(30));
437        let elapsed = start.elapsed();
438        assert_eq!(r.err(), Some(BarrierError::Timeout));
439        assert!(elapsed >= Duration::from_millis(25));
440        cleanup(&base, &hb_path);
441    }
442
443    #[test]
444    fn epoch_passed_returns_immediately() {
445        let (base, hb_path, hb, barrier) = make("passed", 4, 10);
446        let _val = hb.register(5000).unwrap();
447        hb.beat(0);
448        barrier.wait(0).unwrap();
449        assert_eq!(barrier.current_epoch(), 1);
450        let start = Instant::now();
451        barrier.wait(0).unwrap();
452        assert!(start.elapsed() < Duration::from_millis(5));
453        cleanup(&base, &hb_path);
454    }
455
456    #[test]
457    fn multiple_epochs_in_sequence() {
458        let (base, hb_path, hb, barrier) = make("seq", 4, 10);
459        for i in 0..2 {
460            let s = hb.register(6000 + i).unwrap();
461            hb.beat(s);
462        }
463        let barrier = Arc::new(barrier);
464        let mut handles = vec![];
465        for _ in 0..2 {
466            let b = barrier.clone();
467            handles.push(thread::spawn(move || {
468                for e in 0..5u32 { b.wait(e).unwrap(); }
469            }));
470        }
471        for h in handles { h.join().unwrap(); }
472        assert_eq!(barrier.current_epoch(), 5);
473        cleanup(&base, &hb_path);
474    }
475
476    #[test]
477    fn dead_peer_does_not_block_barrier() {
478        let (base, hb_path, hb, barrier) = make("dead-peer", 4, 1);
479        let live_slot = hb.register(7000).unwrap();
480        let _dead_slot = hb.register(7001).unwrap();
481        hb.beat(live_slot);
482        for _ in 0..5 { hb.tick_global_epoch(); }
483        hb.beat(live_slot);
484        assert_eq!(barrier.live_peer_count(), 1,
485            "dead peer should be excluded by grace_epochs");
486        let start = Instant::now();
487        barrier.wait(0).unwrap();
488        assert!(start.elapsed() < Duration::from_millis(50));
489        cleanup(&base, &hb_path);
490    }
491
492    #[test]
493    fn cross_handle_barrier_state_visible() {
494        let (base, hb_path, hb, barrier_a) = make("cross", 4, 10);
495        let _val = hb.register(8000).unwrap();
496        hb.beat(0);
497        let barrier_b = EpochBarrier::open(&base, hb.clone(), 10).unwrap();
498        barrier_a.wait(0).unwrap();
499        assert_eq!(barrier_b.current_epoch(), 1);
500        cleanup(&base, &hb_path);
501    }
502
503    #[test]
504    fn no_live_peers_returns_error() {
505        let (base, hb_path, _hb, barrier) = make("no-peers", 4, 1);
506        let r = barrier.wait(0);
507        assert_eq!(r.err(), Some(BarrierError::NoLivePeers));
508        cleanup(&base, &hb_path);
509    }
510
511    #[test]
512    fn arrived_resets_at_each_epoch() {
513        let (base, hb_path, hb, barrier) = make("reset", 4, 10);
514        for i in 0..2 {
515            let s = hb.register(9000 + i).unwrap();
516            hb.beat(s);
517        }
518        let barrier = Arc::new(barrier);
519        let sync = Arc::new(StdBarrier::new(2));
520        let mut handles = vec![];
521        for _ in 0..2 {
522            let b = barrier.clone();
523            let s = sync.clone();
524            handles.push(thread::spawn(move || {
525                s.wait();
526                b.wait(0).unwrap();
527                b.wait(1).unwrap();
528                b.wait(2).unwrap();
529            }));
530        }
531        for h in handles { h.join().unwrap(); }
532        assert_eq!(barrier.current_epoch(), 3);
533        assert_eq!(barrier.arrived_count(), 0,
534            "arrived must reset to 0 after final release");
535        cleanup(&base, &hb_path);
536    }
537}