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    pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, FenceClockError> {
154        assert!(capacity >= 1);
155        // Start the background clock updater so `now_us` (a cached read) is
156        // populated before the first tick.
157        crate::cached_clock::start();
158        let total = fence_clock_file_size(capacity);
159        let file = OpenOptions::new()
160            .read(true).write(true).create(true).truncate(true)
161            .open(path.as_ref())?;
162        file.set_len(total as u64)?;
163        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
164        let hdr = mmap.as_mut_ptr() as *mut HlcHeader;
165        unsafe {
166            std::ptr::write(hdr, HlcHeader {
167                magic: FENCE_CLOCK_MAGIC,
168                capacity: capacity as u64,
169                global_fence_physical: AtomicU64::new(0),
170                global_fence_logical: AtomicU64::new(0),
171                last_fence_epoch: AtomicU64::new(0),
172                cached_us: AtomicU64::new(0),
173                _pad: [0; 8],
174            });
175        }
176        for i in 0..capacity {
177            let slot_ptr = unsafe {
178                mmap.as_mut_ptr()
179                    .add(std::mem::size_of::<HlcHeader>())
180                    .add(i * std::mem::size_of::<HlcSlot>())
181            } as *mut HlcSlot;
182            unsafe {
183                std::ptr::write(slot_ptr, HlcSlot {
184                    pid: AtomicU32::new(EMPTY_PID),
185                    _pad1: [0; 4],
186                    physical_us: AtomicU64::new(0),
187                    logical: AtomicU64::new(0),
188                    last_updated_us: AtomicU64::new(0),
189                    _pad2: [0; 32],
190                });
191            }
192        }
193        Ok(Self {
194            _file: file, mmap, capacity,
195            header_sidecar: subetha_core::HandshakeHeader::new(),
196            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
197        })
198    }
199
200    pub fn open(path: impl AsRef<Path>, expected_capacity: usize) -> Result<Self, FenceClockError> {
201        crate::cached_clock::start();
202        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
203        let total = fence_clock_file_size(expected_capacity);
204        if file.metadata()?.len() < total as u64 {
205            return Err(FenceClockError::LayoutMismatch);
206        }
207        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
208        let hdr = unsafe { &*(mmap.as_ptr() as *const HlcHeader) };
209        if hdr.magic != FENCE_CLOCK_MAGIC || hdr.capacity != expected_capacity as u64 {
210            return Err(FenceClockError::LayoutMismatch);
211        }
212        Ok(Self {
213            _file: file, mmap, capacity: expected_capacity,
214            header_sidecar: subetha_core::HandshakeHeader::new(),
215            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
216        })
217    }
218
219    #[inline]
220    pub fn capacity(&self) -> usize { self.capacity }
221
222    /// Current shared cross-process cached wall clock (microseconds). This
223    /// is the freshest cache any process on the host has published to the
224    /// MMF; it advances when any process ticks. One atomic load.
225    #[inline]
226    pub fn shared_clock_us(&self) -> u64 {
227        self.header().cached_us.load(Ordering::Acquire)
228    }
229
230    fn header(&self) -> &HlcHeader {
231        unsafe { &*(self.mmap.as_ptr() as *const HlcHeader) }
232    }
233
234    /// Cross-process cached wall clock. Reads this process's 250 us-fresh
235    /// local cache and the MMF-shared value; if the local cache is fresher
236    /// (this process advanced its phase first), it publishes it with
237    /// `fetch_max` and returns it, otherwise it returns the shared value.
238    /// The result is therefore the freshest across every process on the
239    /// host, monotonic, and contended only ~once per refresh interval per
240    /// process (the load is the common case; the write is rare). Cross-host
241    /// skew is reconciled separately by [`merge`](Self::merge).
242    #[inline]
243    fn shared_now_us(&self) -> u64 {
244        let local = crate::cached_clock::now_us();
245        let cached = &self.header().cached_us;
246        let shared = cached.load(Ordering::Acquire);
247        if local > shared {
248            cached.fetch_max(local, Ordering::AcqRel);
249            local
250        } else {
251            shared
252        }
253    }
254
255    fn slot(&self, idx: usize) -> &HlcSlot {
256        assert!(idx < self.capacity, "slot index {idx} out of range {}", self.capacity);
257        let base = unsafe {
258            self.mmap.as_ptr().add(std::mem::size_of::<HlcHeader>())
259        };
260        unsafe {
261            &*(base.add(idx * std::mem::size_of::<HlcSlot>()) as *const HlcSlot)
262        }
263    }
264
265    /// Register the calling process. CAS-claims the first empty slot;
266    /// returns slot index.
267    pub fn register(&self, pid: u32) -> Result<usize, FenceClockError> {
268        assert!(pid != EMPTY_PID, "pid must be != EMPTY_PID");
269        for i in 0..self.capacity {
270            let slot = self.slot(i);
271            if slot.pid.compare_exchange(
272                EMPTY_PID, pid, Ordering::AcqRel, Ordering::Acquire,
273            ).is_ok() {
274                slot.physical_us.store(0, Ordering::Release);
275                slot.logical.store(0, Ordering::Release);
276                slot.last_updated_us.store(self.shared_now_us(), Ordering::Release);
277                return Ok(i);
278            }
279        }
280        Err(FenceClockError::Full)
281    }
282
283    /// Release a slot so another process can claim it.
284    pub fn unregister(&self, idx: usize) {
285        if idx >= self.capacity { return; }
286        let slot = self.slot(idx);
287        slot.physical_us.store(0, Ordering::Release);
288        slot.logical.store(0, Ordering::Release);
289        slot.pid.store(EMPTY_PID, Ordering::Release);
290    }
291
292    /// Local internal-event HLC tick. Advances this slot's HLC per
293    /// the standard rules.
294    pub fn tick(&self, idx: usize) -> Hlc {
295        let slot = self.slot(idx);
296        let wall = self.shared_now_us();
297        let prev_phys = slot.physical_us.load(Ordering::Acquire);
298        let prev_log = slot.logical.load(Ordering::Acquire);
299        let new_phys = prev_phys.max(wall);
300        let new_log = if new_phys == prev_phys { prev_log + 1 } else { 0 };
301        slot.physical_us.store(new_phys, Ordering::Release);
302        slot.logical.store(new_log, Ordering::Release);
303        slot.last_updated_us.store(wall, Ordering::Release);
304        self.ring_sidecar
305            .push_op(crate::sidecar_ops::fence_clock::OP_TICK, 0);
306        Hlc { physical_us: new_phys, logical: new_log }
307    }
308
309    /// Merge a remote HLC (e.g., received in a message) into this
310    /// slot. Returns the new local HLC.
311    pub fn merge(&self, idx: usize, remote: Hlc) -> Hlc {
312        let slot = self.slot(idx);
313        let wall = self.shared_now_us();
314        let prev_phys = slot.physical_us.load(Ordering::Acquire);
315        let prev_log = slot.logical.load(Ordering::Acquire);
316        let new_phys = prev_phys.max(remote.physical_us).max(wall);
317        let new_log = if new_phys == prev_phys && new_phys == remote.physical_us {
318            prev_log.max(remote.logical) + 1
319        } else if new_phys == prev_phys {
320            prev_log + 1
321        } else if new_phys == remote.physical_us {
322            remote.logical + 1
323        } else {
324            0
325        };
326        slot.physical_us.store(new_phys, Ordering::Release);
327        slot.logical.store(new_log, Ordering::Release);
328        slot.last_updated_us.store(wall, Ordering::Release);
329        self.ring_sidecar
330            .push_op(crate::sidecar_ops::fence_clock::OP_MERGE, 0);
331        Hlc { physical_us: new_phys, logical: new_log }
332    }
333
334    /// Read this slot's current HLC.
335    pub fn get_local(&self, idx: usize) -> Hlc {
336        let slot = self.slot(idx);
337        let h = Hlc {
338            physical_us: slot.physical_us.load(Ordering::Acquire),
339            logical: slot.logical.load(Ordering::Acquire),
340        };
341        self.ring_sidecar
342            .push_op(crate::sidecar_ops::fence_clock::OP_GET_LOCAL, 0);
343        h
344    }
345
346    /// Walk all slots and compute `max(slot.hlc)` across all non-vacant
347    /// slots. This is the global fence: the timestamp at which all
348    /// peers' events are observable.
349    pub fn compute_global_fence(&self) -> Hlc {
350        let mut max = Hlc { physical_us: 0, logical: 0 };
351        for i in 0..self.capacity {
352            let slot = self.slot(i);
353            if slot.pid.load(Ordering::Acquire) == EMPTY_PID { continue; }
354            let h = Hlc {
355                physical_us: slot.physical_us.load(Ordering::Acquire),
356                logical: slot.logical.load(Ordering::Acquire),
357            };
358            if h > max { max = h; }
359        }
360        self.ring_sidecar
361            .push_op(crate::sidecar_ops::fence_clock::OP_COMPUTE_FENCE, 0);
362        max
363    }
364
365    /// Publish the computed global fence to the header so other
366    /// processes can read it via `read_global_fence` at O(1).
367    pub fn publish_global_fence(&self) -> Hlc {
368        let fence = self.compute_global_fence();
369        let hdr = self.header();
370        hdr.global_fence_physical.store(fence.physical_us, Ordering::Release);
371        hdr.global_fence_logical.store(fence.logical, Ordering::Release);
372        hdr.last_fence_epoch.fetch_add(1, Ordering::Release);
373        fence
374    }
375
376    /// Read the most-recently-published global fence (O(1); set by a
377    /// publisher process / coordinator).
378    pub fn read_global_fence(&self) -> Hlc {
379        let hdr = self.header();
380        Hlc {
381            physical_us: hdr.global_fence_physical.load(Ordering::Acquire),
382            logical: hdr.global_fence_logical.load(Ordering::Acquire),
383        }
384    }
385
386    /// Snapshot a specific slot (returns None when vacant).
387    pub fn slot_snapshot(&self, idx: usize) -> Option<HlcSlotSnapshot> {
388        if idx >= self.capacity { return None; }
389        let slot = self.slot(idx);
390        let pid = slot.pid.load(Ordering::Acquire);
391        if pid == EMPTY_PID { return None; }
392        Some(HlcSlotSnapshot {
393            pid,
394            hlc: Hlc {
395                physical_us: slot.physical_us.load(Ordering::Acquire),
396                logical: slot.logical.load(Ordering::Acquire),
397            },
398            last_updated_us: slot.last_updated_us.load(Ordering::Acquire),
399        })
400    }
401
402    /// Total fence-publish epochs (counter bumped by `publish_global_fence`).
403    pub fn fence_epoch(&self) -> u64 {
404        self.header().last_fence_epoch.load(Ordering::Acquire)
405    }
406
407    pub fn flush(&self) -> Result<(), FenceClockError> {
408        self.mmap.flush()?;
409        Ok(())
410    }
411
412    /// Non-blocking flush: schedules a writeback via the OS.
413    /// Note: Windows is only partially async (sync to page cache,
414    /// not to disk).
415    pub fn flush_async(&self) -> Result<(), FenceClockError> {
416        self.mmap.flush_async()?;
417        Ok(())
418    }
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub struct HlcSlotSnapshot {
423    pub pid: u32,
424    pub hlc: Hlc,
425    pub last_updated_us: u64,
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use std::sync::Arc;
432    use std::thread;
433
434    fn tmp(name: &str) -> std::path::PathBuf {
435        let mut p = std::env::temp_dir();
436        let pid = std::process::id();
437        p.push(format!("subetha-fenceclock-{name}-{pid}.bin"));
438        p
439    }
440
441    #[test]
442    fn create_initial_state_is_empty() {
443        let p = tmp("init");
444        let c = SharedFenceClock::create(&p, 4).unwrap();
445        assert_eq!(c.capacity(), 4);
446        assert_eq!(c.compute_global_fence(), Hlc { physical_us: 0, logical: 0 });
447        for i in 0..4 { assert!(c.slot_snapshot(i).is_none()); }
448        std::fs::remove_file(&p).ok();
449    }
450
451    #[test]
452    fn register_returns_distinct_slot_indices() {
453        let p = tmp("reg");
454        let c = SharedFenceClock::create(&p, 4).unwrap();
455        let s0 = c.register(1001).unwrap();
456        let s1 = c.register(1002).unwrap();
457        assert_ne!(s0, s1);
458        std::fs::remove_file(&p).ok();
459    }
460
461    #[test]
462    fn register_fails_when_table_is_full() {
463        let p = tmp("full");
464        let c = SharedFenceClock::create(&p, 2).unwrap();
465        c.register(1).unwrap();
466        c.register(2).unwrap();
467        assert_eq!(c.register(3).err(), Some(FenceClockError::Full));
468        std::fs::remove_file(&p).ok();
469    }
470
471    #[test]
472    fn tick_advances_physical_and_resets_logical() {
473        let p = tmp("tick");
474        let c = SharedFenceClock::create(&p, 2).unwrap();
475        let idx = c.register(1).unwrap();
476        let h1 = c.tick(idx);
477        thread::sleep(std::time::Duration::from_millis(2));
478        let h2 = c.tick(idx);
479        assert!(h2 > h1, "second tick should be strictly greater");
480        std::fs::remove_file(&p).ok();
481    }
482
483    #[test]
484    fn tick_increments_logical_when_physical_unchanged() {
485        let p = tmp("logical");
486        let c = SharedFenceClock::create(&p, 2).unwrap();
487        let idx = c.register(1).unwrap();
488        // Force physical to a very high value so subsequent ticks
489        // within the same microsecond keep the same physical and bump
490        // logical.
491        let slot = c.slot(idx);
492        slot.physical_us.store(u64::MAX / 2, Ordering::Release);
493        slot.logical.store(0, Ordering::Release);
494        let h0 = c.tick(idx);
495        let h1 = c.tick(idx);
496        assert_eq!(h0.physical_us, h1.physical_us);
497        assert_eq!(h1.logical, h0.logical + 1);
498        std::fs::remove_file(&p).ok();
499    }
500
501    #[test]
502    fn merge_picks_max_physical_and_increments_logical() {
503        let p = tmp("merge");
504        let c = SharedFenceClock::create(&p, 2).unwrap();
505        let idx = c.register(1).unwrap();
506        // Set local to a known value.
507        let slot = c.slot(idx);
508        slot.physical_us.store(1000, Ordering::Release);
509        slot.logical.store(5, Ordering::Release);
510        // Merge a remote with higher physical.
511        let remote = Hlc { physical_us: 2000, logical: 3 };
512        // Note: now_us() will likely dominate both (it's wall time).
513        // To test the merge logic isolated from wall, the remote
514        // physical must exceed both prev and now_us().
515        let remote_far_future = Hlc { physical_us: u64::MAX / 2, logical: 7 };
516        let merged = c.merge(idx, remote_far_future);
517        assert_eq!(merged.physical_us, u64::MAX / 2);
518        assert_eq!(merged.logical, 8);
519        // (the older merge is not observable here; the local
520        // dropped <- u64::MAX/2 / 8 was the test signal.)
521        let _remote = remote;
522        std::fs::remove_file(&p).ok();
523    }
524
525    #[test]
526    fn compute_global_fence_returns_max_of_all_slots() {
527        let p = tmp("global");
528        let c = SharedFenceClock::create(&p, 4).unwrap();
529        let a = c.register(10).unwrap();
530        let b = c.register(20).unwrap();
531        let d = c.register(30).unwrap();
532        // Manually set each slot to a known HLC.
533        c.slot(a).physical_us.store(100, Ordering::Release);
534        c.slot(a).logical.store(5, Ordering::Release);
535        c.slot(b).physical_us.store(200, Ordering::Release);
536        c.slot(b).logical.store(0, Ordering::Release);
537        c.slot(d).physical_us.store(150, Ordering::Release);
538        c.slot(d).logical.store(9, Ordering::Release);
539        let fence = c.compute_global_fence();
540        assert_eq!(fence, Hlc { physical_us: 200, logical: 0 });
541        std::fs::remove_file(&p).ok();
542    }
543
544    #[test]
545    fn publish_and_read_global_fence_round_trip() {
546        let p = tmp("publish");
547        let c = SharedFenceClock::create(&p, 2).unwrap();
548        let idx = c.register(1).unwrap();
549        c.slot(idx).physical_us.store(7777, Ordering::Release);
550        c.slot(idx).logical.store(3, Ordering::Release);
551        let published = c.publish_global_fence();
552        let read = c.read_global_fence();
553        assert_eq!(read, published);
554        assert_eq!(read, Hlc { physical_us: 7777, logical: 3 });
555        assert_eq!(c.fence_epoch(), 1);
556        std::fs::remove_file(&p).ok();
557    }
558
559    #[test]
560    fn cross_handle_fence_visible() {
561        let p = tmp("cross-handle");
562        let owner = SharedFenceClock::create(&p, 4).unwrap();
563        let observer = SharedFenceClock::open(&p, 4).unwrap();
564        let idx = owner.register(42).unwrap();
565        owner.slot(idx).physical_us.store(5555, Ordering::Release);
566        owner.slot(idx).logical.store(1, Ordering::Release);
567        let owner_fence = owner.publish_global_fence();
568        let observer_fence = observer.read_global_fence();
569        assert_eq!(owner_fence, observer_fence);
570        // Observer can also recompute directly.
571        assert_eq!(observer.compute_global_fence(), Hlc { physical_us: 5555, logical: 1 });
572        std::fs::remove_file(&p).ok();
573    }
574
575    #[test]
576    fn concurrent_ticks_remain_monotonic() {
577        let p = tmp("monotonic");
578        let c = Arc::new(SharedFenceClock::create(&p, 8).unwrap());
579        let mut handles = vec![];
580        for t in 0..4 {
581            let c = c.clone();
582            handles.push(thread::spawn(move || {
583                let idx = c.register(1000 + t as u32).unwrap();
584                let mut prev = Hlc { physical_us: 0, logical: 0 };
585                for _ in 0..50 {
586                    let cur = c.tick(idx);
587                    assert!(cur > prev, "tick must produce strictly greater HLC");
588                    prev = cur;
589                }
590            }));
591        }
592        for h in handles { h.join().unwrap(); }
593        let fence = c.compute_global_fence();
594        assert!(fence.physical_us > 0);
595        std::fs::remove_file(&p).ok();
596    }
597
598    #[test]
599    fn unregister_clears_slot() {
600        let p = tmp("unreg");
601        let c = SharedFenceClock::create(&p, 4).unwrap();
602        let idx = c.register(999).unwrap();
603        c.tick(idx);
604        assert!(c.slot_snapshot(idx).is_some());
605        c.unregister(idx);
606        assert!(c.slot_snapshot(idx).is_none());
607        // Slot can be re-claimed.
608        let idx2 = c.register(1000).unwrap();
609        assert_eq!(idx, idx2);
610        std::fs::remove_file(&p).ok();
611    }
612
613    #[test]
614    fn disk_persistence_fence_survives_reopen() {
615        let p = tmp("disk");
616        {
617            let c = SharedFenceClock::create(&p, 4).unwrap();
618            let idx = c.register(100).unwrap();
619            c.slot(idx).physical_us.store(1234, Ordering::Release);
620            c.slot(idx).logical.store(7, Ordering::Release);
621            c.publish_global_fence();
622            c.flush().unwrap();
623        }
624        let c2 = SharedFenceClock::open(&p, 4).unwrap();
625        assert_eq!(c2.read_global_fence(), Hlc { physical_us: 1234, logical: 7 });
626        std::fs::remove_file(&p).ok();
627    }
628
629    #[test]
630    fn hlc_ord_is_lexicographic() {
631        let a = Hlc { physical_us: 100, logical: 5 };
632        let b = Hlc { physical_us: 100, logical: 6 };
633        let c = Hlc { physical_us: 101, logical: 0 };
634        assert!(a < b);
635        assert!(b < c);
636        assert!(a < c);
637        assert_eq!(a.max(c), c);
638    }
639}