Skip to main content

subetha_cxc/
shared_fence_clock.rs

1//! `SharedFenceClock` - Hybrid Logical Clock (HLC) lifted to
2//! cross-process MMF.
3//!
4//! Each participating process registers an HLC slot in a shared
5//! table and publishes its `(physical_us, logical)` HLC there on
6//! every meaningful event. Any reader can walk the table to compute
7//! `global_fence = max(all slots)` - the timestamp at which all
8//! process events are causally observable. That fence is exactly
9//! what distributed snapshot isolation needs.
10//!
11//! # Why HLC instead of vector clocks
12//!
13//! Vector clocks give exact causal ordering but cost O(N) per event
14//! (each event has to update N-dim coordinate). HLC gives total
15//! order that respects causality with two u64 fields per process,
16//! bounded difference from physical clock skew, and O(1) per event.
17//! For cross-process snapshots over modest N (say <256 processes),
18//! HLC's tradeoff dominates VC.
19//!
20//! # HLC update rules (Kulkarni et al.)
21//!
22//! - `tick`:
23//!   - `wall = now()`, `new_phys = max(prev_phys, wall)`
24//!   - `new_log = if new_phys == prev_phys { prev_log + 1 } else { 0 }`
25//! - `merge(remote)`:
26//!   - `new_phys = max(prev_phys, remote_phys, wall)`
27//!   - `new_log = max(prev_log, remote_log) + 1` when both equal new_phys
28//!   - `= prev_log + 1` when only prev equals new_phys
29//!   - `= remote_log + 1` when only remote equals new_phys
30//!   - `= 0` when wall strictly dominates
31//!
32//! # Layout
33//!
34//! ONE MMF file: `<base>.bin` with `HlcHeader` (64B) +
35//! `HlcSlot[capacity]` (64B each, one cache line so cross-process
36//! writes don't false-share).
37//!
38//! # Race tolerance
39//!
40//! Per-slot writes are: `physical.store(Release)` then
41//! `logical.store(Release)`. A reader may observe a fresh physical
42//! with stale logical (or vice versa). That's HLC-safe because:
43//! - physical is monotonically non-decreasing
44//! - logical only increases at a given physical
45//! - the only invariant is total order, which lexicographic
46//!   `(physical, logical)` preserves even with one-field skew
47//!
48//! For strict torn-write protection, wrap with SeqLock; we omit it
49//! here because HLC's coarse-granularity guarantees absorb the
50//! single-cycle window.
51
52use std::fs::{File, OpenOptions};
53use std::path::Path;
54use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
55
56use memmap2::{MmapMut, MmapOptions};
57
58pub const FENCE_CLOCK_MAGIC: u64 = 0x4150_5546_434C_4B30;
59
60/// HLC value: `(physical_us, logical)`. Total order is lexicographic.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Hlc {
63    pub physical_us: u64,
64    pub logical: u64,
65}
66
67impl PartialOrd for Hlc {
68    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
69        Some(self.cmp(other))
70    }
71}
72impl Ord for Hlc {
73    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
74        match self.physical_us.cmp(&other.physical_us) {
75            std::cmp::Ordering::Equal => self.logical.cmp(&other.logical),
76            other => other,
77        }
78    }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum FenceClockError {
83    Full,
84    LayoutMismatch,
85    InvalidSlot,
86    IoError(std::io::ErrorKind),
87}
88
89impl From<std::io::Error> for FenceClockError {
90    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
91}
92
93pub const EMPTY_PID: u32 = 0;
94
95#[repr(C, align(64))]
96pub struct HlcHeader {
97    pub magic: u64,
98    pub capacity: u64,
99    pub global_fence_physical: AtomicU64,
100    pub global_fence_logical: AtomicU64,
101    pub last_fence_epoch: AtomicU64,
102    /// Shared cross-process cached wall clock. Each process publishes its
103    /// own 250 us-fresh local cache here via `fetch_max`, so every process
104    /// mapping this MMF reads the freshest timestamp across all of them -
105    /// reconciling the per-process cache *phase* on a single host (where
106    /// the hardware clock is shared, so there is no real skew, only phase).
107    /// Monotonic by construction. Occupies 8 of the former 16 pad bytes, so
108    /// the header size and magic are unchanged.
109    pub cached_us: AtomicU64,
110    _pad: [u8; 8],
111}
112
113#[repr(C, align(64))]
114pub struct HlcSlot {
115    pub pid: AtomicU32,
116    _pad1: [u8; 4],
117    pub physical_us: AtomicU64,
118    pub logical: AtomicU64,
119    pub last_updated_us: AtomicU64,
120    _pad2: [u8; 32],
121}
122
123const _: () = {
124    assert!(std::mem::size_of::<HlcHeader>() == 64);
125    assert!(std::mem::size_of::<HlcSlot>() == 64);
126};
127
128pub const fn fence_clock_file_size(capacity: usize) -> usize {
129    std::mem::size_of::<HlcHeader>() + capacity * std::mem::size_of::<HlcSlot>()
130}
131
132pub struct SharedFenceClock {
133    _file: File,
134    mmap: MmapMut,
135    capacity: usize,
136    header_sidecar: subetha_core::HandshakeHeader,
137    ring_sidecar: Box<subetha_core::ObservationRing>,
138}
139
140unsafe impl Send for SharedFenceClock {}
141unsafe impl Sync for SharedFenceClock {}
142
143impl subetha_sidecar::AdaptiveInstance for SharedFenceClock {
144    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
145    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
146    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
147        Box::new(subetha_sidecar::NoMigrationPolicy)
148    }
149}
150
151
152impl SharedFenceClock {
153    /// Obtain the clock table at `path`, initializing an empty one if
154    /// the path does not yet exist and attaching to it if it does.
155    /// Attaching leaves the registered slots and the global fence in
156    /// place; a region built with a different capacity is a
157    /// `LayoutMismatch`. [`reset`](Self::reset) reinitializes.
158    pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, FenceClockError> {
159        assert!(capacity >= 1);
160        // Start the background clock updater so `now_us` (a cached read) is
161        // populated before the first tick.
162        crate::cached_clock::start();
163        let total = fence_clock_file_size(capacity);
164        let (file, mmap) = crate::mmf_attach::create_or_attach(
165            path.as_ref(),
166            total,
167            |ptr| unsafe { Self::init_region(ptr, capacity) },
168            |ptr| unsafe { (*(ptr as *const HlcHeader)).magic == FENCE_CLOCK_MAGIC },
169        )?;
170        Self::from_region(file, mmap, capacity)
171    }
172
173    /// Truncate the clock table at `path` and initialize an empty one,
174    /// unregistering every slot a live peer holds. For a caller that
175    /// knows it owns the path.
176    pub fn reset(path: impl AsRef<Path>, capacity: usize) -> Result<Self, FenceClockError> {
177        assert!(capacity >= 1);
178        crate::cached_clock::start();
179        let total = fence_clock_file_size(capacity);
180        let (file, mmap) = crate::mmf_attach::reset(path.as_ref(), total, |ptr| unsafe {
181            Self::init_region(ptr, capacity)
182        })?;
183        Self::from_region(file, mmap, capacity)
184    }
185
186    /// Lay out an empty clock table: the zeroed region is already the
187    /// empty slot array (`EMPTY_PID` and zero timestamps) and a zero
188    /// global fence, so only the capacity and then the magic are
189    /// written, magic last, because attachers spin on it.
190    ///
191    /// # Safety
192    /// `ptr` addresses at least `fence_clock_file_size(capacity)`
193    /// writable zeroed bytes.
194    unsafe fn init_region(ptr: *mut u8, capacity: usize) {
195        let hdr = ptr as *mut HlcHeader;
196        unsafe {
197            (*hdr).capacity = capacity as u64;
198            std::ptr::write_volatile(&raw mut (*hdr).magic, FENCE_CLOCK_MAGIC);
199        }
200    }
201
202    /// Wrap an initialized region, refusing one built with a different
203    /// capacity.
204    fn from_region(
205        file: File,
206        mmap: MmapMut,
207        capacity: usize,
208    ) -> Result<Self, FenceClockError> {
209        let hdr = unsafe { &*(mmap.as_ptr() as *const HlcHeader) };
210        if hdr.magic != FENCE_CLOCK_MAGIC || hdr.capacity != capacity as u64 {
211            return Err(FenceClockError::LayoutMismatch);
212        }
213        Ok(Self {
214            _file: file, mmap, capacity,
215            header_sidecar: subetha_core::HandshakeHeader::new(),
216            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
217        })
218    }
219
220    pub fn open(path: impl AsRef<Path>, expected_capacity: usize) -> Result<Self, FenceClockError> {
221        crate::cached_clock::start();
222        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
223        let total = fence_clock_file_size(expected_capacity);
224        if file.metadata()?.len() < total as u64 {
225            return Err(FenceClockError::LayoutMismatch);
226        }
227        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
228        Self::from_region(file, mmap, expected_capacity)
229    }
230
231    #[inline]
232    pub fn capacity(&self) -> usize { self.capacity }
233
234    /// Current shared cross-process cached wall clock (microseconds). This
235    /// is the freshest cache any process on the host has published to the
236    /// MMF; it advances when any process ticks. One atomic load.
237    #[inline]
238    pub fn shared_clock_us(&self) -> u64 {
239        self.header().cached_us.load(Ordering::Acquire)
240    }
241
242    fn header(&self) -> &HlcHeader {
243        unsafe { &*(self.mmap.as_ptr() as *const HlcHeader) }
244    }
245
246    /// Cross-process cached wall clock. Reads this process's 250 us-fresh
247    /// local cache and the MMF-shared value; if the local cache is fresher
248    /// (this process advanced its phase first), it publishes it with
249    /// `fetch_max` and returns it, otherwise it returns the shared value.
250    /// The result is therefore the freshest across every process on the
251    /// host, monotonic, and contended only ~once per refresh interval per
252    /// process (the load is the common case; the write is rare). Cross-host
253    /// skew is reconciled separately by [`merge`](Self::merge).
254    #[inline]
255    fn shared_now_us(&self) -> u64 {
256        let local = crate::cached_clock::now_us();
257        let cached = &self.header().cached_us;
258        let shared = cached.load(Ordering::Acquire);
259        if local > shared {
260            cached.fetch_max(local, Ordering::AcqRel);
261            local
262        } else {
263            shared
264        }
265    }
266
267    fn slot(&self, idx: usize) -> &HlcSlot {
268        assert!(idx < self.capacity, "slot index {idx} out of range {}", self.capacity);
269        let base = unsafe {
270            self.mmap.as_ptr().add(std::mem::size_of::<HlcHeader>())
271        };
272        unsafe {
273            &*(base.add(idx * std::mem::size_of::<HlcSlot>()) as *const HlcSlot)
274        }
275    }
276
277    /// Register the calling process. CAS-claims the first empty slot;
278    /// returns slot index.
279    pub fn register(&self, pid: u32) -> Result<usize, FenceClockError> {
280        assert!(pid != EMPTY_PID, "pid must be != EMPTY_PID");
281        for i in 0..self.capacity {
282            let slot = self.slot(i);
283            if slot.pid.compare_exchange(
284                EMPTY_PID, pid, Ordering::AcqRel, Ordering::Acquire,
285            ).is_ok() {
286                slot.physical_us.store(0, Ordering::Release);
287                slot.logical.store(0, Ordering::Release);
288                slot.last_updated_us.store(self.shared_now_us(), Ordering::Release);
289                return Ok(i);
290            }
291        }
292        Err(FenceClockError::Full)
293    }
294
295    /// Release a slot so another process can claim it.
296    pub fn unregister(&self, idx: usize) {
297        if idx >= self.capacity { return; }
298        let slot = self.slot(idx);
299        slot.physical_us.store(0, Ordering::Release);
300        slot.logical.store(0, Ordering::Release);
301        slot.pid.store(EMPTY_PID, Ordering::Release);
302    }
303
304    /// Local internal-event HLC tick. Advances this slot's HLC per
305    /// the standard rules.
306    pub fn tick(&self, idx: usize) -> Hlc {
307        let slot = self.slot(idx);
308        let wall = self.shared_now_us();
309        let prev_phys = slot.physical_us.load(Ordering::Acquire);
310        let prev_log = slot.logical.load(Ordering::Acquire);
311        let new_phys = prev_phys.max(wall);
312        let new_log = if new_phys == prev_phys { prev_log + 1 } else { 0 };
313        slot.physical_us.store(new_phys, Ordering::Release);
314        slot.logical.store(new_log, Ordering::Release);
315        slot.last_updated_us.store(wall, Ordering::Release);
316        self.ring_sidecar
317            .push_op(crate::sidecar_ops::fence_clock::OP_TICK, 0);
318        Hlc { physical_us: new_phys, logical: new_log }
319    }
320
321    /// Merge a remote HLC (e.g., received in a message) into this
322    /// slot. Returns the new local HLC.
323    pub fn merge(&self, idx: usize, remote: Hlc) -> Hlc {
324        let slot = self.slot(idx);
325        let wall = self.shared_now_us();
326        let prev_phys = slot.physical_us.load(Ordering::Acquire);
327        let prev_log = slot.logical.load(Ordering::Acquire);
328        let new_phys = prev_phys.max(remote.physical_us).max(wall);
329        let new_log = if new_phys == prev_phys && new_phys == remote.physical_us {
330            prev_log.max(remote.logical) + 1
331        } else if new_phys == prev_phys {
332            prev_log + 1
333        } else if new_phys == remote.physical_us {
334            remote.logical + 1
335        } else {
336            0
337        };
338        slot.physical_us.store(new_phys, Ordering::Release);
339        slot.logical.store(new_log, Ordering::Release);
340        slot.last_updated_us.store(wall, Ordering::Release);
341        self.ring_sidecar
342            .push_op(crate::sidecar_ops::fence_clock::OP_MERGE, 0);
343        Hlc { physical_us: new_phys, logical: new_log }
344    }
345
346    /// Read this slot's current HLC.
347    pub fn get_local(&self, idx: usize) -> Hlc {
348        let slot = self.slot(idx);
349        let h = Hlc {
350            physical_us: slot.physical_us.load(Ordering::Acquire),
351            logical: slot.logical.load(Ordering::Acquire),
352        };
353        self.ring_sidecar
354            .push_op(crate::sidecar_ops::fence_clock::OP_GET_LOCAL, 0);
355        h
356    }
357
358    /// Walk all slots and compute `max(slot.hlc)` across all non-vacant
359    /// slots. This is the global fence: the timestamp at which all
360    /// peers' events are observable.
361    pub fn compute_global_fence(&self) -> Hlc {
362        let mut max = Hlc { physical_us: 0, logical: 0 };
363        for i in 0..self.capacity {
364            let slot = self.slot(i);
365            if slot.pid.load(Ordering::Acquire) == EMPTY_PID { continue; }
366            let h = Hlc {
367                physical_us: slot.physical_us.load(Ordering::Acquire),
368                logical: slot.logical.load(Ordering::Acquire),
369            };
370            if h > max { max = h; }
371        }
372        self.ring_sidecar
373            .push_op(crate::sidecar_ops::fence_clock::OP_COMPUTE_FENCE, 0);
374        max
375    }
376
377    /// Publish the computed global fence to the header so other
378    /// processes can read it via `read_global_fence` at O(1).
379    pub fn publish_global_fence(&self) -> Hlc {
380        let fence = self.compute_global_fence();
381        let hdr = self.header();
382        hdr.global_fence_physical.store(fence.physical_us, Ordering::Release);
383        hdr.global_fence_logical.store(fence.logical, Ordering::Release);
384        hdr.last_fence_epoch.fetch_add(1, Ordering::Release);
385        fence
386    }
387
388    /// Read the most-recently-published global fence (O(1); set by a
389    /// publisher process / coordinator).
390    pub fn read_global_fence(&self) -> Hlc {
391        let hdr = self.header();
392        Hlc {
393            physical_us: hdr.global_fence_physical.load(Ordering::Acquire),
394            logical: hdr.global_fence_logical.load(Ordering::Acquire),
395        }
396    }
397
398    /// Snapshot a specific slot (returns None when vacant).
399    pub fn slot_snapshot(&self, idx: usize) -> Option<HlcSlotSnapshot> {
400        if idx >= self.capacity { return None; }
401        let slot = self.slot(idx);
402        let pid = slot.pid.load(Ordering::Acquire);
403        if pid == EMPTY_PID { return None; }
404        Some(HlcSlotSnapshot {
405            pid,
406            hlc: Hlc {
407                physical_us: slot.physical_us.load(Ordering::Acquire),
408                logical: slot.logical.load(Ordering::Acquire),
409            },
410            last_updated_us: slot.last_updated_us.load(Ordering::Acquire),
411        })
412    }
413
414    /// Total fence-publish epochs (counter bumped by `publish_global_fence`).
415    pub fn fence_epoch(&self) -> u64 {
416        self.header().last_fence_epoch.load(Ordering::Acquire)
417    }
418
419    pub fn flush(&self) -> Result<(), FenceClockError> {
420        self.mmap.flush()?;
421        Ok(())
422    }
423
424    /// Non-blocking flush: schedules a writeback via the OS.
425    /// Note: Windows is only partially async (sync to page cache,
426    /// not to disk).
427    pub fn flush_async(&self) -> Result<(), FenceClockError> {
428        self.mmap.flush_async()?;
429        Ok(())
430    }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub struct HlcSlotSnapshot {
435    pub pid: u32,
436    pub hlc: Hlc,
437    pub last_updated_us: u64,
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use std::sync::Arc;
444    use std::thread;
445
446    fn tmp(name: &str) -> std::path::PathBuf {
447        let mut p = std::env::temp_dir();
448        let pid = std::process::id();
449        p.push(format!("subetha-fenceclock-{name}-{pid}.bin"));
450        p
451    }
452
453    #[test]
454    fn create_initial_state_is_empty() {
455        let p = tmp("init");
456        let c = SharedFenceClock::create(&p, 4).unwrap();
457        assert_eq!(c.capacity(), 4);
458        assert_eq!(c.compute_global_fence(), Hlc { physical_us: 0, logical: 0 });
459        for i in 0..4 { assert!(c.slot_snapshot(i).is_none()); }
460        std::fs::remove_file(&p).ok();
461    }
462
463    /// A second create attaches with the registered slots in place;
464    /// reset is what unregisters them.
465    #[test]
466    fn second_create_attaches_and_keeps_registrations() {
467        let p = tmp("attach");
468        std::fs::remove_file(&p).ok();
469        let c = SharedFenceClock::create(&p, 4).unwrap();
470        let s0 = c.register(1001).unwrap();
471
472        let c2 = SharedFenceClock::create(&p, 4).unwrap();
473        assert_eq!(
474            c2.slot_snapshot(s0).map(|s| s.pid),
475            Some(1001),
476            "attach dropped a registered slot",
477        );
478        assert!(matches!(
479            SharedFenceClock::create(&p, 2),
480            Err(FenceClockError::LayoutMismatch),
481        ));
482
483        // Windows refuses to truncate a mapped file, so every handle goes
484        // before the reset.
485        drop(c);
486        drop(c2);
487        let fresh = SharedFenceClock::reset(&p, 4).unwrap();
488        assert!(fresh.slot_snapshot(s0).is_none(), "reset left a slot registered");
489        drop(fresh);
490        std::fs::remove_file(&p).ok();
491    }
492
493    #[test]
494    fn register_returns_distinct_slot_indices() {
495        let p = tmp("reg");
496        let c = SharedFenceClock::create(&p, 4).unwrap();
497        let s0 = c.register(1001).unwrap();
498        let s1 = c.register(1002).unwrap();
499        assert_ne!(s0, s1);
500        std::fs::remove_file(&p).ok();
501    }
502
503    #[test]
504    fn register_fails_when_table_is_full() {
505        let p = tmp("full");
506        let c = SharedFenceClock::create(&p, 2).unwrap();
507        c.register(1).unwrap();
508        c.register(2).unwrap();
509        assert_eq!(c.register(3).err(), Some(FenceClockError::Full));
510        std::fs::remove_file(&p).ok();
511    }
512
513    #[test]
514    fn tick_advances_physical_and_resets_logical() {
515        let p = tmp("tick");
516        let c = SharedFenceClock::create(&p, 2).unwrap();
517        let idx = c.register(1).unwrap();
518        let h1 = c.tick(idx);
519        thread::sleep(std::time::Duration::from_millis(2));
520        let h2 = c.tick(idx);
521        assert!(h2 > h1, "second tick should be strictly greater");
522        std::fs::remove_file(&p).ok();
523    }
524
525    #[test]
526    fn tick_increments_logical_when_physical_unchanged() {
527        let p = tmp("logical");
528        let c = SharedFenceClock::create(&p, 2).unwrap();
529        let idx = c.register(1).unwrap();
530        // Force physical to a very high value so subsequent ticks
531        // within the same microsecond keep the same physical and bump
532        // logical.
533        let slot = c.slot(idx);
534        slot.physical_us.store(u64::MAX / 2, Ordering::Release);
535        slot.logical.store(0, Ordering::Release);
536        let h0 = c.tick(idx);
537        let h1 = c.tick(idx);
538        assert_eq!(h0.physical_us, h1.physical_us);
539        assert_eq!(h1.logical, h0.logical + 1);
540        std::fs::remove_file(&p).ok();
541    }
542
543    #[test]
544    fn merge_picks_max_physical_and_increments_logical() {
545        let p = tmp("merge");
546        let c = SharedFenceClock::create(&p, 2).unwrap();
547        let idx = c.register(1).unwrap();
548        // Set local to a known value.
549        let slot = c.slot(idx);
550        slot.physical_us.store(1000, Ordering::Release);
551        slot.logical.store(5, Ordering::Release);
552        // Merge a remote with higher physical.
553        let remote = Hlc { physical_us: 2000, logical: 3 };
554        // Note: now_us() will likely dominate both (it's wall time).
555        // To test the merge logic isolated from wall, the remote
556        // physical must exceed both prev and now_us().
557        let remote_far_future = Hlc { physical_us: u64::MAX / 2, logical: 7 };
558        let merged = c.merge(idx, remote_far_future);
559        assert_eq!(merged.physical_us, u64::MAX / 2);
560        assert_eq!(merged.logical, 8);
561        // (the older merge is not observable here; the local
562        // dropped <- u64::MAX/2 / 8 was the test signal.)
563        let _remote = remote;
564        std::fs::remove_file(&p).ok();
565    }
566
567    #[test]
568    fn compute_global_fence_returns_max_of_all_slots() {
569        let p = tmp("global");
570        let c = SharedFenceClock::create(&p, 4).unwrap();
571        let a = c.register(10).unwrap();
572        let b = c.register(20).unwrap();
573        let d = c.register(30).unwrap();
574        // Manually set each slot to a known HLC.
575        c.slot(a).physical_us.store(100, Ordering::Release);
576        c.slot(a).logical.store(5, Ordering::Release);
577        c.slot(b).physical_us.store(200, Ordering::Release);
578        c.slot(b).logical.store(0, Ordering::Release);
579        c.slot(d).physical_us.store(150, Ordering::Release);
580        c.slot(d).logical.store(9, Ordering::Release);
581        let fence = c.compute_global_fence();
582        assert_eq!(fence, Hlc { physical_us: 200, logical: 0 });
583        std::fs::remove_file(&p).ok();
584    }
585
586    #[test]
587    fn publish_and_read_global_fence_round_trip() {
588        let p = tmp("publish");
589        let c = SharedFenceClock::create(&p, 2).unwrap();
590        let idx = c.register(1).unwrap();
591        c.slot(idx).physical_us.store(7777, Ordering::Release);
592        c.slot(idx).logical.store(3, Ordering::Release);
593        let published = c.publish_global_fence();
594        let read = c.read_global_fence();
595        assert_eq!(read, published);
596        assert_eq!(read, Hlc { physical_us: 7777, logical: 3 });
597        assert_eq!(c.fence_epoch(), 1);
598        std::fs::remove_file(&p).ok();
599    }
600
601    #[test]
602    fn cross_handle_fence_visible() {
603        let p = tmp("cross-handle");
604        let owner = SharedFenceClock::create(&p, 4).unwrap();
605        let observer = SharedFenceClock::open(&p, 4).unwrap();
606        let idx = owner.register(42).unwrap();
607        owner.slot(idx).physical_us.store(5555, Ordering::Release);
608        owner.slot(idx).logical.store(1, Ordering::Release);
609        let owner_fence = owner.publish_global_fence();
610        let observer_fence = observer.read_global_fence();
611        assert_eq!(owner_fence, observer_fence);
612        // Observer can also recompute directly.
613        assert_eq!(observer.compute_global_fence(), Hlc { physical_us: 5555, logical: 1 });
614        std::fs::remove_file(&p).ok();
615    }
616
617    #[test]
618    fn concurrent_ticks_remain_monotonic() {
619        let p = tmp("monotonic");
620        let c = Arc::new(SharedFenceClock::create(&p, 8).unwrap());
621        let mut handles = vec![];
622        for t in 0..4 {
623            let c = c.clone();
624            handles.push(thread::spawn(move || {
625                let idx = c.register(1000 + t as u32).unwrap();
626                let mut prev = Hlc { physical_us: 0, logical: 0 };
627                for _ in 0..50 {
628                    let cur = c.tick(idx);
629                    assert!(cur > prev, "tick must produce strictly greater HLC");
630                    prev = cur;
631                }
632            }));
633        }
634        for h in handles { h.join().unwrap(); }
635        let fence = c.compute_global_fence();
636        assert!(fence.physical_us > 0);
637        std::fs::remove_file(&p).ok();
638    }
639
640    #[test]
641    fn unregister_clears_slot() {
642        let p = tmp("unreg");
643        let c = SharedFenceClock::create(&p, 4).unwrap();
644        let idx = c.register(999).unwrap();
645        c.tick(idx);
646        assert!(c.slot_snapshot(idx).is_some());
647        c.unregister(idx);
648        assert!(c.slot_snapshot(idx).is_none());
649        // Slot can be re-claimed.
650        let idx2 = c.register(1000).unwrap();
651        assert_eq!(idx, idx2);
652        std::fs::remove_file(&p).ok();
653    }
654
655    #[test]
656    fn disk_persistence_fence_survives_reopen() {
657        let p = tmp("disk");
658        {
659            let c = SharedFenceClock::create(&p, 4).unwrap();
660            let idx = c.register(100).unwrap();
661            c.slot(idx).physical_us.store(1234, Ordering::Release);
662            c.slot(idx).logical.store(7, Ordering::Release);
663            c.publish_global_fence();
664            c.flush().unwrap();
665        }
666        let c2 = SharedFenceClock::open(&p, 4).unwrap();
667        assert_eq!(c2.read_global_fence(), Hlc { physical_us: 1234, logical: 7 });
668        std::fs::remove_file(&p).ok();
669    }
670
671    #[test]
672    fn hlc_ord_is_lexicographic() {
673        let a = Hlc { physical_us: 100, logical: 5 };
674        let b = Hlc { physical_us: 100, logical: 6 };
675        let c = Hlc { physical_us: 101, logical: 0 };
676        assert!(a < b);
677        assert!(b < c);
678        assert!(a < c);
679        assert_eq!(a.max(c), c);
680    }
681}