Skip to main content

subetha_cxc/
shared_deque_loh.rs

1//! `SharedDequeLoh` - LCRQ-on-LIFO Hybrid deque, MMF-backed.
2//!
3//! Sibling to [`SharedDeque`](crate::SharedDeque) (Chase-Lev) and
4//! [`SharedDequeKhpd`](crate::SharedDequeKhpd) (publication-line). LOH
5//! targets the producer-fast burst shape: owner-side push goes into a
6//! process-private LIFO with *no atomic*; the migration step drains a
7//! batch into a Vyukov-sequence-number ring with one
8//! `tail.fetch_add(N)` plus N Release-stores. Thieves race on `head`
9//! via CAS with a sequence-number check that pre-validates the slot.
10//!
11//! ## Why this shape
12//!
13//! Chase-Lev pays one Release-store on `bottom` per item and the
14//! steal-side CAS on `top` per claimed item. For per-item request-
15//! reply that matches the cost of one cache-line bounce; for a
16//! workload that publishes many items per coherence interval
17//! (parallel-for fan-out, fork-join leaves) the per-item bookkeeping
18//! is unamortized. LOH amortizes by letting the owner stage many
19//! items in a private heap (no atomic) and pay one ring-tail update
20//! per migration batch.
21//!
22//! Trade-offs vs Chase-Lev MMF:
23//!
24//! - **Owner push** drops from one Release-store on `bottom` per item
25//!   to a plain `Vec::push` (~3 ns).
26//! - **Migration** is one `tail.fetch_add(batch)` plus `batch`
27//!   Release-stores on per-slot sequence numbers.
28//! - **Thief steal** is one CAS on `head` (same shape as Chase-Lev's
29//!   `top` CAS) plus a sequence-number check on the slot. The
30//!   wasted-ticket race that pure-XADD LCRQ exhibits is avoided by
31//!   gating the CAS on `head < tail`.
32//!
33//! Where LOH wins per the cost model: bursty dispatch where the
34//! per-burst migration amortizes over many items per cache-line
35//! bounce. Where LOH does NOT win: single-item request-reply,
36//! because there's no batching to amortize against.
37//!
38//! ## Hot path API: [`publish_batch`](SharedDequeLoh::publish_batch)
39//!
40//! The canonical producer-fast API takes a slice of [`LineItem`] and
41//! migrates the whole batch in one shot. It bypasses the local LIFO
42//! entirely, paying exactly one Mutex acquire + one
43//! `tail.fetch_add(items.len())` + `items.len()` Release-stores for
44//! the call. This is the path that exercises the amortization lever
45//! and is the shape benchmarks measure.
46//!
47//! The [`push`](SharedDequeLoh::push) /
48//! [`flush`](SharedDequeLoh::flush) pair is still exposed for callers
49//! that want to stage items incrementally and migrate later
50//! (autoflushes at a configurable threshold). Per-item `push` does
51//! NOT exercise the amortization lever; it pays the same Mutex on
52//! every staged item.
53//!
54//! ## Layout
55//!
56//! ```text
57//! +-----------------------------+
58//! | LohHeader (128B)            |  magic, capacity, owner_pid,
59//! |                             |  epoch, tail on its own cache
60//! |                             |  line, head on its own cache line
61//! +-----------------------------+
62//! | LcrqJobSlot[0]  (64B)       |  sequence (8B) + LineItem (16B)
63//! | LcrqJobSlot[1]              |  + 40B trailing padding
64//! | ...                         |
65//! | LcrqJobSlot[capacity-1]     |
66//! +-----------------------------+
67//! ```
68//!
69//! Each slot is exactly one cache line so adjacent slots never share
70//! coherence-traffic lines. The `LineItem` payload is the same
71//! byte-oriented 16-byte struct
72//! [`SharedDequeKhpd`](crate::SharedDequeKhpd) and
73//! [`SharedDequeUrd`](crate::SharedDequeUrd) use, re-exported via
74//! [`crate::LineItem`] so consumers can ferry the same byte pattern
75//! across all three primitives without re-marshalling.
76//!
77//! ## When to use this vs `SharedDeque` / `SharedDequeKhpd`
78//!
79//! - **`SharedDeque<T>` (Chase-Lev)**: per-item dispatch and steal,
80//!   strict LIFO at the owner; lowest constant when there is no
81//!   batching.
82//! - **`SharedDequeKhpd`**: producer packs `LINE_ITEMS = 3` items per
83//!   publication line and publishes them with one Release-store on
84//!   `state`. The win zone is "K items per call where K is a small
85//!   multiple of 3."
86//! - **`SharedDequeLoh` (this primitive)**: producer batches K items
87//!   per call and pays one `tail.fetch_add(K)` plus K Release-stores.
88//!   The win zone is "K items per call where the producer wants to
89//!   amortize the producer-counter atomic over an arbitrary batch
90//!   size."
91
92#![allow(clippy::missing_errors_doc)]
93
94use std::fs::{File, OpenOptions};
95use std::io;
96use std::path::Path;
97use std::sync::atomic::{AtomicI64, AtomicU64, Ordering, fence};
98
99use memmap2::{MmapMut, MmapOptions};
100use parking_lot::Mutex;
101
102use crate::shared_deque_khpd::LineItem;
103
104/// Prefetch the cache line at `slot` with write-intent (the M-state
105/// hint). Emits `PREFETCHW` directly via inline asm on x86_64
106/// because Rust's stable `_mm_prefetch` only exposes the T0/T1/T2/
107/// NTA hints (S-state targets), which force a publisher write to
108/// pay an RFO coherence upgrade. `PREFETCHW` brings the line to
109/// M-state directly so the publisher's payload Release-store costs
110/// one cycle instead of a cross-core RFO. The instruction is a NOP
111/// on x86_64 CPUs without the `PRFCHW` feature flag (3DNow-era
112/// AMD has it natively; Intel since Broadwell), so it is safe to
113/// unconditionally emit on x86_64.
114///
115/// On non-x86_64 architectures this compiles to a no-op.
116#[inline(always)]
117fn prefetch_slot(slot: *const LcrqJobSlot) {
118    #[cfg(target_arch = "x86_64")]
119    {
120        // SAFETY: `prefetchw` is a hardware hint and never faults on
121        // unmapped memory; the CPU silently ignores invalid
122        // addresses. `nostack` + `preserves_flags` lets the
123        // optimizer schedule freely around the asm.
124        unsafe {
125            core::arch::asm!(
126                "prefetchw [{ptr}]",
127                ptr = in(reg) slot,
128                options(nostack, preserves_flags),
129            );
130        }
131    }
132    #[cfg(not(target_arch = "x86_64"))]
133    {
134        _ = slot;
135    }
136}
137
138/// Magic byte sequence marking a valid LOH file. Reads as ASCII
139/// "WLOH" + version. Distinct from the Chase-Lev / KHPD / URD magics
140/// so a file-confusion is rejected at open time.
141pub const LOH_MAGIC: u64 = 0x574C_4F48_0000_0001;
142
143/// One slot is exactly one cache line.
144pub const LOH_SLOT_SIZE: usize = 64;
145
146/// Default LIFO soft cap. Push past this returns
147/// [`PushError::LifoFull`]; caller must `flush()` or back off.
148pub const DEFAULT_LIFO_CAP: usize = 256;
149
150/// File header. Cache-line aligned. `head` and `tail` each get their
151/// own cache line so the producer-side `tail.fetch_add` does not
152/// invalidate the consumer-side `head` line.
153#[repr(C, align(64))]
154pub struct LohHeader {
155    /// Magic constant.
156    pub magic: u64,
157    /// Number of ring slots; always a power of two.
158    pub capacity: u64,
159    /// Pid of the owner process; informational. Cleared on
160    /// `close_owner()`.
161    pub owner_pid: AtomicU64,
162    /// Epoch counter advanced by the owner on shutdown.
163    pub epoch: AtomicU64,
164    /// Padding to push `tail` to its own cache line.
165    pub _pad_meta: [u8; 24],
166    /// Producer counter. Owner `fetch_add(batch_size)` during
167    /// migration to claim a contiguous block of slots.
168    pub tail: AtomicI64,
169    /// Padding to push `head` to its own cache line.
170    pub _pad_tail: [u8; 56],
171    /// Consumer counter. Thieves CAS this to claim a slot.
172    pub head: AtomicI64,
173    /// Padding round to two whole cache lines after `head`.
174    pub _pad_head: [u8; 56],
175}
176
177/// Ring slot: Vyukov sequence + byte-oriented [`LineItem`] payload.
178/// Fixed shape, 64 bytes, process-portable.
179#[repr(C, align(64))]
180pub struct LcrqJobSlot {
181    /// Vyukov-style sequence number gating payload access:
182    /// - On creation: `seq == idx` (slot empty, ready to publish).
183    /// - After producer Release-store: `seq == idx + 1` (published,
184    ///   consumer may read).
185    /// - After consumer Release-store: `seq == idx + capacity`
186    ///   (consumed, ready for next round at `idx + capacity`).
187    pub sequence: AtomicI64,
188    /// Caller's byte-oriented payload.
189    pub item: LineItem,
190    /// Trailing padding rounding the slot to 64 bytes.
191    pub _pad: [u8; 40],
192}
193
194/// Total file size for a ring with `capacity` slots, including the
195/// header.
196pub const fn loh_file_size(capacity: usize) -> usize {
197    std::mem::size_of::<LohHeader>() + capacity * LOH_SLOT_SIZE
198}
199
200/// Outcome of [`SharedDequeLoh::push`] / [`SharedDequeLoh::flush`] /
201/// [`SharedDequeLoh::publish_batch`].
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum PushError {
204    /// Ring at capacity; consumer hasn't caught up. Caller may spin,
205    /// back off, or report upstream pressure.
206    Full,
207    /// Owner-side LIFO at its soft cap; caller must `flush()` or
208    /// back off before pushing more.
209    LifoFull,
210}
211
212/// Outcome of [`SharedDequeLoh::steal`].
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum Steal {
215    /// Got a slot's payload.
216    Success(StealResult),
217    /// Ring empty (no published item past `head`).
218    Empty,
219    /// CAS lost to a competing thief, or the publisher's Release on
220    /// the sequence number is missing from the slot snapshot; outer
221    /// loop should retry.
222    Retry,
223}
224
225/// Payload returned by a successful steal.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct StealResult {
228    /// The slot's 16-byte byte-oriented payload.
229    pub item: LineItem,
230}
231
232/// MMF-backed LOH deque. Single owner (the process that created the
233/// file); arbitrarily many thieves across processes.
234pub struct SharedDequeLoh {
235    _file: File,
236    mmap: MmapMut,
237    capacity: usize,
238    capacity_mask: i64,
239    flush_threshold: usize,
240    lifo_cap: usize,
241    /// Owner-side LIFO. `parking_lot::Mutex` is uncontended on the
242    /// hot path because, by protocol, only the originator thread
243    /// pushes; the Mutex exists so [`SharedDequeLoh`] can be shared
244    /// as `Arc<SharedDequeLoh>` between the originator and a
245    /// flush-trigger thread without losing `Sync`. The
246    /// [`Self::publish_batch`] hot path bypasses this Mutex entirely
247    /// (it does not touch the LIFO), so the batched-publish
248    /// throughput is set by `tail.fetch_add` cost only.
249    local_lifo: Mutex<Vec<LineItem>>,
250}
251
252// SAFETY: All fields are Send. Mmap handle is Send + Sync per
253// memmap2; every ring access goes through the LCRQ sequence-number
254// protocol (per-slot Acquire / Release pair) so concurrent producers
255// and consumers see a consistent view. The Mutex around the LIFO
256// linearizes owner-side accesses across any thread the originator
257// happens to schedule the push on.
258unsafe impl Send for SharedDequeLoh {}
259// SAFETY: Same justification as the `Send` impl directly above.
260unsafe impl Sync for SharedDequeLoh {}
261
262impl SharedDequeLoh {
263    /// Create a fresh LOH file. `capacity` rounds up to the next
264    /// power of two (min 2). `flush_threshold` is the LIFO length at
265    /// which an automatic [`Self::flush`] fires on the next push.
266    pub fn create<P: AsRef<Path>>(
267        path: P,
268        capacity: usize,
269        flush_threshold: usize,
270    ) -> io::Result<Self> {
271        let capacity = capacity.max(2).next_power_of_two();
272        let size = loh_file_size(capacity);
273
274        let file = OpenOptions::new()
275            .read(true)
276            .write(true)
277            .create(true)
278            .truncate(true)
279            .open(path.as_ref())?;
280        file.set_len(size as u64)?;
281
282        // SAFETY: `map_mut` is unsafe because the kernel cannot
283        // prevent another process from truncating or mutating the
284        // backing file in ways that violate Rust's aliasing rules.
285        // This call site upholds the soundness contract by writing
286        // only through the LCRQ per-slot sequence-number protocol;
287        // the file size is fixed by `file.set_len` immediately above
288        // and never shrunk for the lifetime of any mapping.
289        let mut mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
290
291        let header_ptr = mmap.as_mut_ptr() as *mut LohHeader;
292        // SAFETY: mmap is page-aligned (well above the 64-byte
293        // alignment LohHeader requires); the map covers
294        // `loh_file_size(capacity)` bytes by construction.
295        unsafe {
296            (*header_ptr).magic = LOH_MAGIC;
297            (*header_ptr).capacity = capacity as u64;
298            (*header_ptr).owner_pid = AtomicU64::new(std::process::id() as u64);
299            (*header_ptr).epoch = AtomicU64::new(0);
300            std::ptr::write_bytes((*header_ptr)._pad_meta.as_mut_ptr(), 0, 24);
301            (*header_ptr).tail = AtomicI64::new(0);
302            std::ptr::write_bytes((*header_ptr)._pad_tail.as_mut_ptr(), 0, 56);
303            (*header_ptr).head = AtomicI64::new(0);
304            std::ptr::write_bytes((*header_ptr)._pad_head.as_mut_ptr(), 0, 56);
305        }
306
307        // Initialise each slot's sequence to its index. On first
308        // producer touch, `sequence == idx`, so the publisher knows
309        // the slot is ready to publish (write payload, then
310        // Release-store `idx + 1`).
311        let slots_start = std::mem::size_of::<LohHeader>();
312        for i in 0..capacity {
313            let off = slots_start + i * LOH_SLOT_SIZE;
314            // SAFETY: `off + LOH_SLOT_SIZE <= loh_file_size(capacity)`
315            // by construction; the cast to `*mut LcrqJobSlot` is sound
316            // because the slot is `repr(C, align(64))` and `off` is a
317            // multiple of 64.
318            let slot_ptr = unsafe { mmap.as_mut_ptr().add(off) as *mut LcrqJobSlot };
319            // SAFETY: `slot_ptr` is in-bounds and aligned; payload
320            // bytes are valid for any bit pattern.
321            unsafe {
322                (*slot_ptr).sequence = AtomicI64::new(i as i64);
323                (*slot_ptr).item = LineItem::default();
324                std::ptr::write_bytes((*slot_ptr)._pad.as_mut_ptr(), 0, 40);
325            }
326        }
327
328        mmap.flush()?;
329
330        let flush_threshold = flush_threshold.max(1);
331        Ok(Self {
332            _file: file,
333            mmap,
334            capacity,
335            capacity_mask: (capacity as i64) - 1,
336            flush_threshold,
337            lifo_cap: DEFAULT_LIFO_CAP,
338            local_lifo: Mutex::new(Vec::with_capacity(DEFAULT_LIFO_CAP)),
339        })
340    }
341
342    /// Open an existing LOH file. Validates magic and capacity.
343    pub fn open<P: AsRef<Path>>(path: P, flush_threshold: usize) -> io::Result<Self> {
344        let file = OpenOptions::new()
345            .read(true)
346            .write(true)
347            .open(path.as_ref())?;
348        let size = file.metadata()?.len() as usize;
349        if size < std::mem::size_of::<LohHeader>() {
350            return Err(io::Error::new(
351                io::ErrorKind::InvalidData,
352                "loh file too small to contain header",
353            ));
354        }
355
356        // SAFETY: Same justification as `create` - protocol-only
357        // access through the per-slot sequence number.
358        let mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
359
360        let header_ptr = mmap.as_ptr() as *const LohHeader;
361        // SAFETY: map size verified to cover header; mmap alignment
362        // exceeds header alignment.
363        let (magic, capacity) =
364            unsafe { ((*header_ptr).magic, (*header_ptr).capacity as usize) };
365        if magic != LOH_MAGIC {
366            return Err(io::Error::new(
367                io::ErrorKind::InvalidData,
368                format!("loh magic mismatch: got {magic:#x}, want {LOH_MAGIC:#x}"),
369            ));
370        }
371        if !capacity.is_power_of_two() || capacity < 2 {
372            return Err(io::Error::new(
373                io::ErrorKind::InvalidData,
374                format!("loh capacity {capacity} is not a power of two >= 2"),
375            ));
376        }
377        if size < loh_file_size(capacity) {
378            return Err(io::Error::new(
379                io::ErrorKind::InvalidData,
380                format!(
381                    "loh file size {size} below expected {}",
382                    loh_file_size(capacity)
383                ),
384            ));
385        }
386
387        let flush_threshold = flush_threshold.max(1);
388        Ok(Self {
389            _file: file,
390            mmap,
391            capacity,
392            capacity_mask: (capacity as i64) - 1,
393            flush_threshold,
394            lifo_cap: DEFAULT_LIFO_CAP,
395            local_lifo: Mutex::new(Vec::with_capacity(DEFAULT_LIFO_CAP)),
396        })
397    }
398
399    /// Slot count of the ring (always a power of two).
400    pub fn capacity(&self) -> usize {
401        self.capacity
402    }
403
404    /// Configured auto-flush threshold (LIFO length that triggers a
405    /// flush on the next push).
406    pub fn flush_threshold(&self) -> usize {
407        self.flush_threshold
408    }
409
410    /// Pid of the owner process at create time, or 0 if cleared.
411    pub fn owner_pid(&self) -> u64 {
412        self.header().owner_pid.load(Ordering::Acquire)
413    }
414
415    /// Owner shutdown: zero pid + advance epoch.
416    pub fn close_owner(&self) {
417        self.header().owner_pid.store(0, Ordering::Release);
418        self.header().epoch.fetch_add(1, Ordering::Release);
419    }
420
421    fn header(&self) -> &LohHeader {
422        // SAFETY: map covers the header; alignment is page-aligned.
423        unsafe { &*(self.mmap.as_ptr() as *const LohHeader) }
424    }
425
426    fn slot_ptr(&self, idx: i64) -> *mut LcrqJobSlot {
427        let slot_idx = (idx & self.capacity_mask) as usize;
428        let off = std::mem::size_of::<LohHeader>() + slot_idx * LOH_SLOT_SIZE;
429        // SAFETY: `slot_idx` is in [0, capacity); `off` is within the
430        // mapped region and 64-byte aligned.
431        unsafe { self.mmap.as_ptr().add(off) as *mut LcrqJobSlot }
432    }
433
434    /// Snapshot the current `(head, tail, ring_size, lifo_len)`.
435    /// Loads are independent; the tuple is not a linearizable
436    /// snapshot - useful for debug / introspection only.
437    pub fn snapshot_size(&self) -> (i64, i64, i64, usize) {
438        let h = self.header();
439        let head = h.head.load(Ordering::Acquire);
440        let tail = h.tail.load(Ordering::Acquire);
441        let lifo_len = self.local_lifo.try_lock().map(|g| g.len()).unwrap_or(0);
442        (head, tail, tail - head, lifo_len)
443    }
444
445    /// Owner-side push. Stages the item in the local LIFO; when the
446    /// LIFO reaches `flush_threshold` an automatic [`Self::flush`]
447    /// fires that drains the LIFO into the ring tail.
448    ///
449    /// **Only the owner process may call this.**
450    pub fn push(&self, item: LineItem) -> Result<(), PushError> {
451        let mut lifo = self.local_lifo.lock();
452        if lifo.len() >= self.lifo_cap {
453            return Err(PushError::LifoFull);
454        }
455        lifo.push(item);
456        if lifo.len() >= self.flush_threshold {
457            // Flush from inside the lock to keep the LIFO consistent
458            // with the migration count. If the flush fails (ring at
459            // capacity), undo the push so the caller can retry with
460            // a clean LIFO state.
461            if let Err(e) = self.flush_locked(&mut lifo) {
462                lifo.pop();
463                return Err(e);
464            }
465        }
466        Ok(())
467    }
468
469    /// Owner-side explicit flush. Drains the local LIFO into the
470    /// ring's tail in one batch (one `tail.fetch_add(N)` + N
471    /// Release-stores). Returns the number of items migrated.
472    pub fn flush(&self) -> Result<usize, PushError> {
473        let mut lifo = self.local_lifo.lock();
474        self.flush_locked(&mut lifo)
475    }
476
477    /// Owner-side single-call batch publish. **Holds zero locks.**
478    /// The LIFO-bypass property is the SubEtha-native lever: the
479    /// upstream LCRQ-on-LIFO design held a Mutex during batch
480    /// publish to satisfy a separate dispatch-backend `&self`
481    /// contract; SubEtha's owner-only protocol makes that Mutex
482    /// gratuitous on the batch path. `tail.fetch_add(N)` atomically
483    /// reserves a disjoint slot range; the per-slot sequence-number
484    /// protocol gates the writes. A sibling `flush()` or `push()`
485    /// touching the LIFO is independent: it competes only on
486    /// `tail.fetch_add`, not on the LIFO Vec.
487    ///
488    /// Cost per call: one `tail.fetch_add(items.len())` plus
489    /// `items.len()` per-slot Release-stores on the sequence number.
490    ///
491    /// Returns the number of items migrated.
492    pub fn publish_batch(&self, items: &[LineItem]) -> Result<usize, PushError> {
493        if items.is_empty() {
494            return Ok(0);
495        }
496        let n = items.len();
497        let h = self.header();
498        let head_snapshot = h.head.load(Ordering::Acquire);
499        let tail_snapshot = h.tail.load(Ordering::Relaxed);
500        if (tail_snapshot - head_snapshot + n as i64) > self.capacity as i64 {
501            return Err(PushError::Full);
502        }
503        let base = h.tail.fetch_add(n as i64, Ordering::AcqRel);
504
505        // Prefetch the first slot before entering the publish loop so
506        // the producer's sequence Acquire-load hits a warm line.
507        prefetch_slot(self.slot_ptr(base));
508
509        for (i, item) in items.iter().enumerate() {
510            let idx = base + i as i64;
511            // Warm the NEXT slot's cache line while we publish this
512            // one. The `i + 1 < n` guard avoids prefetching past the
513            // reserved range.
514            if i + 1 < n {
515                prefetch_slot(self.slot_ptr(idx + 1));
516            }
517            // SAFETY: slot_ptr returns an in-bounds aligned pointer.
518            unsafe {
519                self.publish_at(idx, *item);
520            }
521        }
522        Ok(n)
523    }
524
525    fn flush_locked(&self, lifo: &mut Vec<LineItem>) -> Result<usize, PushError> {
526        let n = lifo.len();
527        if n == 0 {
528            return Ok(0);
529        }
530        let h = self.header();
531        let head_snapshot = h.head.load(Ordering::Acquire);
532        let tail_snapshot = h.tail.load(Ordering::Relaxed);
533        if (tail_snapshot - head_snapshot + n as i64) > self.capacity as i64 {
534            // Ring would overflow; report Full so caller can back
535            // off. Items remain in the LIFO for the next flush
536            // attempt.
537            return Err(PushError::Full);
538        }
539        let base = h.tail.fetch_add(n as i64, Ordering::AcqRel);
540
541        // Drain LIFO in FIFO order (oldest first) so the ring sees
542        // items in their original push order. `drain()` avoids the
543        // O(N) shift cost of pop()-into-reverse.
544        for (i, item) in lifo.drain(..).enumerate() {
545            let idx = base + i as i64;
546            // SAFETY: slot_ptr returns an in-bounds aligned pointer.
547            unsafe {
548                self.publish_at(idx, item);
549            }
550        }
551        Ok(n)
552    }
553
554    /// Migrate one item into the slot at ring index `idx` under the
555    /// Vyukov sequence-number protocol.
556    ///
557    /// # Safety
558    ///
559    /// Caller must have reserved the slot by holding the producer
560    /// lock and having `idx` in `[base, base + N)` of a successful
561    /// `tail.fetch_add(N)`.
562    unsafe fn publish_at(&self, idx: i64, item: LineItem) {
563        let slot = self.slot_ptr(idx);
564        // Spin-wait until the slot is publishable (sequence == idx).
565        // For the owner path this should usually already be true:
566        // head <= tail always and slot.sequence advances past idx
567        // only when a consumer has taken it.
568        loop {
569            // SAFETY: `slot` is the in-bounds aligned pointer returned
570            // by `slot_ptr`; the LCRQ sequence-number protocol ensures
571            // no other writer touches this slot between our reservation
572            // (caller-held `tail.fetch_add`) and the Release-store at
573            // the bottom of this function.
574            let seq = unsafe { (*slot).sequence.load(Ordering::Acquire) };
575            let diff = seq - idx;
576            if diff == 0 {
577                // Slot ready: consumer released the prior round (or
578                // this is the first publish, where init set
579                // sequence == idx).
580                break;
581            }
582            if diff < 0 {
583                // Prior round's consumer still owns the slot. Spin.
584                std::hint::spin_loop();
585                continue;
586            }
587            // diff > 0: the slot's sequence is for a future round.
588            // With a single producer and the capacity-check guard
589            // this is unreachable; loud panic so the cause can be
590            // diagnosed instead of silently overwriting a slot.
591            panic!(
592                "LOH producer protocol violation: slot[{}] seq={} ahead of idx={}",
593                idx & self.capacity_mask,
594                seq,
595                idx
596            );
597        }
598        // SAFETY: same as the Acquire-load above; we own the slot for
599        // this round per the caller's reservation in `tail.fetch_add`.
600        unsafe {
601            (*slot).item = item;
602            (*slot).sequence.store(idx + 1, Ordering::Release);
603        }
604    }
605
606    /// Owner-side pop from the local LIFO. Items still in the LIFO
607    /// (unmigrated) may be retrieved locally without round-tripping
608    /// through the ring.
609    pub fn pop_local(&self) -> Option<LineItem> {
610        let mut lifo = self.local_lifo.lock();
611        lifo.pop()
612    }
613
614    /// Thief-side steal. Race-free CAS-on-head with sequence-number
615    /// validation on the slot. Returns [`Steal::Retry`] when a
616    /// competing thief beat us on the head CAS, or when the
617    /// publisher's Release on the sequence number is missing from
618    /// the slot snapshot; outer loop should retry.
619    pub fn steal(&self) -> Steal {
620        let h = self.header();
621        let head = h.head.load(Ordering::Acquire);
622        fence(Ordering::SeqCst);
623        let tail = h.tail.load(Ordering::Acquire);
624        if head >= tail {
625            return Steal::Empty;
626        }
627        let slot = self.slot_ptr(head);
628        // Check the sequence ahead of the CAS. The producer Release-
629        // stores `head + 1` after writing the slot bytes; a value
630        // less than that means the publisher's Release on the slot
631        // is missing from our snapshot, and a value greater than
632        // that means the ring has wrapped and the producer has
633        // re-published this slot for a future round (the head we
634        // loaded is stale).
635        //
636        // SAFETY: slot is in-bounds + aligned.
637        let seq = unsafe { (*slot).sequence.load(Ordering::Acquire) };
638        if seq != head + 1 {
639            return Steal::Retry;
640        }
641        // Try to claim head. Once we win the CAS we own slot[head &
642        // mask] for this round: the producer cannot re-publish the
643        // slot until we release the sequence to `head + capacity`,
644        // and the seq-check above already confirmed the publisher
645        // released `head + 1`. The slot bytes we read below are the
646        // bytes the producer wrote for this round.
647        let won = h
648            .head
649            .compare_exchange(head, head + 1, Ordering::SeqCst, Ordering::Relaxed)
650            .is_ok();
651        if !won {
652            return Steal::Retry;
653        }
654        // SAFETY: same as above; head is now ours and the producer's
655        // Release on slot.sequence happens-before our Acquire load
656        // of slot.sequence above.
657        let result = unsafe {
658            StealResult {
659                item: (*slot).item,
660            }
661        };
662        // Release the slot for the next round at `head + capacity`.
663        //
664        // SAFETY: still our slot; the Release synchronises with the
665        // next producer's Acquire-spin in `publish_at`.
666        unsafe {
667            (*slot)
668                .sequence
669                .store(head + self.capacity as i64, Ordering::Release);
670        }
671        Steal::Success(result)
672    }
673
674    /// Force any dirty pages to disk.
675    pub fn flush_to_disk(&self) -> io::Result<()> {
676        self.mmap.flush()
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use std::sync::Arc;
684    use std::sync::atomic::{AtomicUsize, Ordering as O};
685    use std::thread;
686
687    fn temp_path(name: &str) -> std::path::PathBuf {
688        let mut p = std::env::temp_dir();
689        let pid = std::process::id();
690        let nonce = std::time::SystemTime::now()
691            .duration_since(std::time::UNIX_EPOCH)
692            .map(|d| d.as_nanos())
693            .unwrap_or(0);
694        p.push(format!("subetha_loh_{pid}_{nonce}_{name}.bin"));
695        p
696    }
697
698    fn u32_item(id: u32) -> LineItem {
699        LineItem::new(&id.to_le_bytes()).expect("build item")
700    }
701
702    fn item_id(item: &LineItem) -> u32 {
703        u32::from_le_bytes(item.payload[..4].try_into().unwrap())
704    }
705
706    #[test]
707    fn create_then_open_round_trips_header() {
708        let path = temp_path("create_open");
709        let _d = SharedDequeLoh::create(&path, 8, 4).expect("create");
710        let o = SharedDequeLoh::open(&path, 4).expect("open");
711        assert_eq!(o.capacity(), 8);
712        assert_eq!(o.owner_pid(), std::process::id() as u64);
713        std::fs::remove_file(&path).ok();
714    }
715
716    #[test]
717    fn open_rejects_bad_magic() {
718        let path = temp_path("bad_magic");
719        std::fs::write(&path, vec![0xCDu8; 8192]).expect("seed");
720        let r = SharedDequeLoh::open(&path, 4);
721        assert!(r.is_err());
722        std::fs::remove_file(&path).ok();
723    }
724
725    #[test]
726    fn push_and_explicit_flush_migrates() {
727        let path = temp_path("flush");
728        // flush_threshold = usize::MAX so auto-flush never fires;
729        // the explicit `flush()` is the only path to the ring.
730        let d = SharedDequeLoh::create(&path, 8, usize::MAX).expect("create");
731        for i in 0..3u32 {
732            d.push(u32_item(i)).expect("push");
733        }
734        // Ring is still empty before flush.
735        let (head, tail, sz, lifo_len) = d.snapshot_size();
736        assert_eq!(head, 0);
737        assert_eq!(tail, 0);
738        assert_eq!(sz, 0);
739        assert_eq!(lifo_len, 3);
740        // Flush: 3 items migrate.
741        let n = d.flush().expect("flush");
742        assert_eq!(n, 3);
743        let (_, tail, sz, lifo_len) = d.snapshot_size();
744        assert_eq!(tail, 3);
745        assert_eq!(sz, 3);
746        assert_eq!(lifo_len, 0);
747        std::fs::remove_file(&path).ok();
748    }
749
750    #[test]
751    fn push_auto_flushes_at_threshold() {
752        let path = temp_path("autoflush");
753        let d = SharedDequeLoh::create(&path, 8, 4).expect("create");
754        for i in 0..4u32 {
755            d.push(u32_item(i)).expect("push");
756        }
757        // The 4th push triggers auto-flush.
758        let (_, tail, sz, lifo_len) = d.snapshot_size();
759        assert_eq!(tail, 4);
760        assert_eq!(sz, 4);
761        assert_eq!(lifo_len, 0);
762        std::fs::remove_file(&path).ok();
763    }
764
765    #[test]
766    fn publish_batch_migrates_in_fifo_order() {
767        let path = temp_path("publish_batch");
768        let d = SharedDequeLoh::create(&path, 64, usize::MAX).expect("create");
769        let items: Vec<LineItem> = (1..=5u32).map(u32_item).collect();
770        let n = d.publish_batch(&items).expect("publish_batch");
771        assert_eq!(n, 5);
772        let (_, tail, sz, lifo_len) = d.snapshot_size();
773        assert_eq!(tail, 5);
774        assert_eq!(sz, 5);
775        // publish_batch bypasses the LIFO entirely.
776        assert_eq!(lifo_len, 0);
777        for expected in 1..=5u32 {
778            loop {
779                match d.steal() {
780                    Steal::Success(r) => {
781                        assert_eq!(item_id(&r.item), expected);
782                        break;
783                    }
784                    Steal::Empty | Steal::Retry => std::thread::yield_now(),
785                }
786            }
787        }
788        assert!(matches!(d.steal(), Steal::Empty));
789        std::fs::remove_file(&path).ok();
790    }
791
792    #[test]
793    fn publish_batch_empty_is_noop() {
794        let path = temp_path("publish_batch_empty");
795        let d = SharedDequeLoh::create(&path, 4, usize::MAX).expect("create");
796        let n = d.publish_batch(&[]).expect("publish_batch empty");
797        assert_eq!(n, 0);
798        let (_, tail, sz, _) = d.snapshot_size();
799        assert_eq!(tail, 0);
800        assert_eq!(sz, 0);
801        std::fs::remove_file(&path).ok();
802    }
803
804    #[test]
805    fn publish_batch_full_returns_full() {
806        let path = temp_path("publish_batch_full");
807        let d = SharedDequeLoh::create(&path, 4, usize::MAX).expect("create");
808        let items: Vec<LineItem> = (1..=4u32).map(u32_item).collect();
809        d.publish_batch(&items).expect("publish first batch");
810        // Ring at capacity; the follow-up publish_batch reports Full.
811        let err = d
812            .publish_batch(&[u32_item(99)])
813            .expect_err("publish past capacity");
814        assert_eq!(err, PushError::Full);
815        std::fs::remove_file(&path).ok();
816    }
817
818    #[test]
819    fn steal_drains_in_fifo_order_after_flush() {
820        let path = temp_path("fifo");
821        let d = SharedDequeLoh::create(&path, 8, usize::MAX).expect("create");
822        for i in 1..=3u32 {
823            d.push(u32_item(i)).expect("push");
824        }
825        d.flush().expect("flush");
826        for expected in 1..=3u32 {
827            loop {
828                match d.steal() {
829                    Steal::Success(slot) => {
830                        assert_eq!(item_id(&slot.item), expected);
831                        break;
832                    }
833                    Steal::Empty | Steal::Retry => std::thread::yield_now(),
834                }
835            }
836        }
837        assert!(matches!(d.steal(), Steal::Empty));
838        std::fs::remove_file(&path).ok();
839    }
840
841    #[test]
842    fn pop_local_drains_lifo_in_lifo_order() {
843        let path = temp_path("pop_local_lifo");
844        let d = SharedDequeLoh::create(&path, 4, usize::MAX).expect("create");
845        for i in 1..=3u32 {
846            d.push(u32_item(i)).expect("push");
847        }
848        // Owner pops in LIFO order (newest first).
849        for expected in (1..=3u32).rev() {
850            let e = d.pop_local().expect("pop_local");
851            assert_eq!(item_id(&e), expected);
852        }
853        assert!(d.pop_local().is_none());
854        std::fs::remove_file(&path).ok();
855    }
856
857    #[test]
858    fn ring_full_at_capacity() {
859        let path = temp_path("full");
860        let d = SharedDequeLoh::create(&path, 2, usize::MAX).expect("create");
861        d.push(u32_item(1)).expect("push");
862        d.push(u32_item(2)).expect("push");
863        let n = d.flush().expect("flush");
864        assert_eq!(n, 2);
865        // Ring is at capacity; pushing more + flushing reports Full.
866        d.push(u32_item(3)).expect("push to lifo");
867        let err = d.flush().expect_err("flush past capacity");
868        assert_eq!(err, PushError::Full);
869        std::fs::remove_file(&path).ok();
870    }
871
872    #[test]
873    fn close_owner_zeros_pid_and_advances_epoch() {
874        let path = temp_path("close");
875        let d = SharedDequeLoh::create(&path, 2, 1).expect("create");
876        assert_eq!(d.owner_pid(), std::process::id() as u64);
877        let h = d.header();
878        let before = h.epoch.load(O::Acquire);
879        d.close_owner();
880        assert_eq!(d.owner_pid(), 0);
881        assert_eq!(h.epoch.load(O::Acquire), before + 1);
882        std::fs::remove_file(&path).ok();
883    }
884
885    #[test]
886    fn concurrent_thieves_no_double_take() {
887        // Stress: owner pushes + auto-flushes; two thief threads
888        // race to drain. Every slot must be consumed exactly once.
889        let path = temp_path("stress");
890        let d = Arc::new(SharedDequeLoh::create(&path, 128, 8).expect("create"));
891        let n = 5_000usize;
892
893        let consumed = Arc::new(AtomicUsize::new(0));
894        let sum = Arc::new(AtomicUsize::new(0));
895
896        let mut thieves = Vec::new();
897        for _ in 0..2 {
898            let d = Arc::clone(&d);
899            let consumed = Arc::clone(&consumed);
900            let sum = Arc::clone(&sum);
901            thieves.push(thread::spawn(move || {
902                while consumed.load(O::Relaxed) < n {
903                    match d.steal() {
904                        Steal::Success(slot) => {
905                            consumed.fetch_add(1, O::Relaxed);
906                            sum.fetch_add(item_id(&slot.item) as usize, O::Relaxed);
907                        }
908                        Steal::Empty | Steal::Retry => std::thread::yield_now(),
909                    }
910                }
911            }));
912        }
913
914        for i in 0..n {
915            loop {
916                match d.push(u32_item(i as u32)) {
917                    Ok(()) => break,
918                    Err(PushError::LifoFull) | Err(PushError::Full) => {
919                        std::thread::yield_now();
920                        // Opportunistic: a Full flush here just means
921                        // the ring is congested; the outer loop keeps
922                        // retrying the push.
923                        d.flush().ok();
924                    }
925                }
926            }
927        }
928        // The TERMINAL flush must succeed or the tail of the run
929        // (up to flush_threshold - 1 items) stays stranded in the
930        // process-local LIFO and the thieves spin on `consumed < n`
931        // forever - flush() returning Full leaves items staged by
932        // contract ("items remain in the LIFO for the next flush
933        // attempt"). Retry until the thieves free ring space.
934        loop {
935            match d.flush() {
936                Ok(_) => break,
937                Err(PushError::Full) => std::thread::yield_now(),
938                Err(e) => panic!("terminal flush: {e:?}"),
939            }
940        }
941        for h in thieves {
942            h.join().expect("thief");
943        }
944        let expected: usize = (0..n).sum();
945        assert_eq!(
946            sum.load(O::Relaxed),
947            expected,
948            "every slot consumed once"
949        );
950        std::fs::remove_file(&path).ok();
951    }
952
953    #[test]
954    fn publish_batch_stress_two_thieves() {
955        // Stress the canonical hot path: publish_batch fires N=64
956        // items per call; two thieves race to drain.
957        let path = temp_path("publish_batch_stress");
958        let d = Arc::new(
959            SharedDequeLoh::create(&path, 256, usize::MAX).expect("create"),
960        );
961        let n = 5_000usize;
962
963        let consumed = Arc::new(AtomicUsize::new(0));
964        let sum = Arc::new(AtomicUsize::new(0));
965
966        let mut thieves = Vec::new();
967        for _ in 0..2 {
968            let d = Arc::clone(&d);
969            let consumed = Arc::clone(&consumed);
970            let sum = Arc::clone(&sum);
971            thieves.push(thread::spawn(move || {
972                while consumed.load(O::Relaxed) < n {
973                    match d.steal() {
974                        Steal::Success(slot) => {
975                            consumed.fetch_add(1, O::Relaxed);
976                            sum.fetch_add(item_id(&slot.item) as usize, O::Relaxed);
977                        }
978                        Steal::Empty | Steal::Retry => std::thread::yield_now(),
979                    }
980                }
981            }));
982        }
983
984        let mut pushed = 0usize;
985        let burst = 64usize;
986        while pushed < n {
987            let want = burst.min(n - pushed);
988            let batch: Vec<LineItem> = (0..want)
989                .map(|j| u32_item((pushed + j) as u32))
990                .collect();
991            loop {
992                match d.publish_batch(&batch) {
993                    Ok(_) => break,
994                    Err(PushError::Full) => std::thread::yield_now(),
995                    Err(other) => panic!("publish_batch: {other:?}"),
996                }
997            }
998            pushed += want;
999        }
1000
1001        for t in thieves {
1002            t.join().expect("thief");
1003        }
1004        let expected: usize = (0..n).sum();
1005        assert_eq!(
1006            sum.load(O::Relaxed),
1007            expected,
1008            "publish_batch stress: every item consumed once"
1009        );
1010        std::fs::remove_file(&path).ok();
1011    }
1012}