Skip to main content

subetha_cxc/
shared_deque_khpd.rs

1//! `SharedDequeKhpd` - K-axis Hierarchical Publication Deque, MMF-backed.
2//!
3//! Companion primitive to [`SharedDeque`](crate::SharedDeque) (the
4//! Chase-Lev work-stealing deque) for workloads where the producer
5//! batches and the per-line transfer cost dominates the round-trip.
6//!
7//! ## The amortization lever
8//!
9//! Chase-Lev pays one cache-line bounce per single-item handoff
10//! between owner and thief. KHPD packs `LINE_ITEMS = 3` items into
11//! one 64-byte cache line and atomically publishes them with a
12//! single Release-store on the line's state word. A thief takes the
13//! whole line in one CAS, reading all three items in a single
14//! cache-line transfer. The architectural saving is one Release-
15//! store per item amortized over three items - measured at 1.16x
16//! producer-side throughput vs Chase-Lev on a Zen+ R7 2700 when the
17//! workload uses the batch publish API.
18//!
19//! ## Layout
20//!
21//! ```text
22//! +-----------------------------+
23//! | KhpdHeader (128B)           |  magic, capacity, owner_pid,
24//! |                             |  tail on its own line,
25//! |                             |  head on its own line
26//! +-----------------------------+
27//! | PublicationLine[0]  (64B)   |  state (8B) + 3 LineItems (48B)
28//! | PublicationLine[1]          |  + 8B padding
29//! | ...                         |
30//! | PublicationLine[capacity-1] |
31//! +-----------------------------+
32//! ```
33//!
34//! Each `PublicationLine` is exactly one cache line so adjacent
35//! lines never share coherence-traffic lines. `state` is an
36//! `AtomicU64` packed as `(epoch: u32 << 32) | (n_items: u16 << 16)
37//! | claim: u16`; the publisher writes the line items in place and
38//! issues one Release-store on `state` with `claim = CLAIM_BIT` and
39//! `n_items` set. The claimer reads `state` Acquire, validates the
40//! epoch matches its head, CAS-takes the head, then reads the line
41//! items and releases the slot by storing `STATE_EMPTY` for the next
42//! round's producer.
43//!
44//! Each `LineItem` is a 16-byte byte-oriented payload. Callers
45//! marshal their own value into the payload at publish time and
46//! unmarshal it at steal time. SubEtha's [`Marshal`](subetha_core::Marshal)
47//! trait is the recommended packing contract.
48//!
49//! ## When to use this vs `SharedDeque`
50//!
51//! - **`SharedDeque<T>` (Chase-Lev)** - per-item dispatch and steal,
52//!   strict LIFO at the owner, optimal at low per-item batch size.
53//! - **`SharedDequeKhpd` (this primitive)** - producer batches
54//!   multiple items per publication line. Beats Chase-Lev by ~16%
55//!   on producer-side throughput when the workload calls
56//!   [`publish`](SharedDequeKhpd::publish) with several staged items.
57//!   On per-item dispatch (one stage + one publish per call), KHPD
58//!   gives back the amortization win and may underperform.
59
60#![allow(clippy::missing_errors_doc)]
61
62use std::fs::{File, OpenOptions};
63use std::io;
64use std::path::Path;
65use std::sync::Mutex;
66use std::sync::atomic::{AtomicI64, AtomicU64, Ordering, fence};
67
68use memmap2::{MmapMut, MmapOptions};
69
70/// Magic byte sequence marking a valid KHPD file. ASCII 'WKHP' + ver.
71pub const KHPD_MAGIC: u64 = 0x574B_4850_0000_0001;
72
73/// Cache-line size; one publication line per cache line.
74pub const KHPD_LINE_SIZE: usize = 64;
75
76/// Items per publication line. State (8 B) + 3 * 16 = 56 B; 8 B
77/// trailing padding rounds the line to 64.
78pub const LINE_ITEMS: usize = 3;
79
80/// Bytes per [`LineItem`] payload. Callers marshal their value into
81/// these 16 bytes (and unmarshal at steal time).
82pub const KHPD_ITEM_BYTES: usize = 16;
83
84/// `state` packed-bit-field layout: epoch in the top 32 bits,
85/// `n_items` in the next 16, `claim` in the bottom 16.
86const STATE_EMPTY: u64 = 0;
87const CLAIM_BIT: u64 = 1;
88
89/// File header. Cache-line aligned. `head` and `tail` each get
90/// their own cache line to prevent producer and consumer counters
91/// from invalidating each other.
92#[repr(C, align(64))]
93pub struct KhpdHeader {
94    /// Magic constant.
95    pub magic: u64,
96    /// Number of publication lines; always a power of two.
97    pub capacity: u64,
98    /// Pid of the owner process; informational. Cleared on
99    /// `close_owner()`.
100    pub owner_pid: AtomicU64,
101    /// Epoch counter advanced on owner shutdown.
102    pub epoch: AtomicU64,
103    /// Padding to push `tail` to its own cache line.
104    pub _pad_meta: [u8; 24],
105    /// Producer counter. Owner `fetch_add(1)` per published line.
106    pub tail: AtomicI64,
107    /// Padding to push `head` to its own line.
108    pub _pad_tail: [u8; 56],
109    /// Consumer counter. Thieves CAS this to claim a line.
110    pub head: AtomicI64,
111    /// Padding to round the header to two whole cache lines after
112    /// `head`.
113    pub _pad_head: [u8; 56],
114}
115
116/// One item carried in a publication line. 16 bytes.
117#[repr(C, align(8))]
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub struct LineItem {
120    /// Byte-oriented payload. Callers marshal in / unmarshal out;
121    /// the KHPD layer treats this as an opaque 16-byte slot.
122    pub payload: [u8; KHPD_ITEM_BYTES],
123}
124
125impl LineItem {
126    /// Build a line item from a caller-supplied byte slice. The
127    /// slice must be at most [`KHPD_ITEM_BYTES`] bytes; shorter
128    /// slices are zero-padded on the right.
129    pub fn new(bytes: &[u8]) -> Result<Self, PushError> {
130        if bytes.len() > KHPD_ITEM_BYTES {
131            return Err(PushError::PayloadTooLarge);
132        }
133        let mut item = Self::default();
134        item.payload[..bytes.len()].copy_from_slice(bytes);
135        Ok(item)
136    }
137
138    /// Borrow the 16-byte payload.
139    pub fn bytes(&self) -> &[u8; KHPD_ITEM_BYTES] {
140        &self.payload
141    }
142}
143
144/// 64-byte cache-line-sized payload carrying up to [`LINE_ITEMS`] =
145/// 3 [`LineItem`] payloads plus a count. The deque-family hybrid
146/// [`SharedDequeFcl`](crate::SharedDequeFcl) uses this as the slot
147/// type for counter-only Chase-Lev with `K_inner = 3`: each push
148/// publishes 3 items in one cache-line write, with NO per-slot
149/// atomic and ONE owner-private `bottom` store amortized across the
150/// whole batch.
151///
152/// Layout:
153/// - `n_items` (4 B): count of valid items in `items` (1..=3)
154/// - `reserved` (4 B): caller-use tag / cache-line alignment
155/// - `items` (48 B): the [`LineItem`] payloads
156/// - `_pad` (8 B): tail padding to round to exactly 64 B
157#[repr(C, align(64))]
158#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
159pub struct FatLineItem {
160    /// Number of valid items in `items` (1..=[`LINE_ITEMS`]).
161    pub n_items: u32,
162    /// Reserved for caller use (variant tag / numa hint / etc.).
163    pub reserved: u32,
164    /// Up to [`LINE_ITEMS`] caller payloads.
165    pub items: [LineItem; LINE_ITEMS],
166    /// Trailing padding to round the struct to exactly 64 B.
167    pub _pad: [u8; 8],
168}
169
170const _: () = assert!(std::mem::size_of::<FatLineItem>() == 64);
171
172impl FatLineItem {
173    /// Build a fat item from a slice of up to [`LINE_ITEMS`]
174    /// [`LineItem`] values. Returns [`PushError::TooManyItems`] if
175    /// the slice has more than [`LINE_ITEMS`] elements.
176    pub fn from_items(items: &[LineItem]) -> Result<Self, PushError> {
177        if items.len() > LINE_ITEMS {
178            return Err(PushError::TooManyItems);
179        }
180        let mut fat = Self {
181            n_items: items.len() as u32,
182            ..Self::default()
183        };
184        fat.items[..items.len()].copy_from_slice(items);
185        Ok(fat)
186    }
187
188    /// Borrow the valid items (`&items[..n_items]`).
189    pub fn live_items(&self) -> &[LineItem] {
190        let n = (self.n_items as usize).min(LINE_ITEMS);
191        &self.items[..n]
192    }
193}
194
195// SAFETY: `FatLineItem` is `#[repr(C, align(64))]` with explicitly
196// laid out fields (n_items: u32 + reserved: u32 + items: [LineItem;
197// 3] + _pad: [u8; 8] = 64 bytes), no padding holes, every field is
198// itself byte-portable. The bytes are position-independent across
199// address spaces; round-trip is a memcpy.
200unsafe impl subetha_core::Marshal for FatLineItem {
201    const PAYLOAD_BYTES: usize = 64;
202
203    fn marshal(&self, dst: &mut [u8]) {
204        // SAFETY: `Self` has size 64, layout is repr(C) with no
205        // padding holes (asserted via the const above).
206        let bytes = unsafe {
207            std::slice::from_raw_parts(self as *const Self as *const u8, 64)
208        };
209        dst[..64].copy_from_slice(bytes);
210    }
211
212    fn unmarshal(src: &[u8]) -> Result<Self, subetha_core::MarshalError> {
213        if src.len() < 64 {
214            return Err(subetha_core::MarshalError::ShortBuffer {
215                expected: 64,
216                got: src.len(),
217            });
218        }
219        let mut out = Self::default();
220        // SAFETY: same layout justification as `marshal`.
221        let dst_bytes = unsafe {
222            std::slice::from_raw_parts_mut(&mut out as *mut Self as *mut u8, 64)
223        };
224        dst_bytes.copy_from_slice(&src[..64]);
225        Ok(out)
226    }
227}
228
229// SAFETY: `LineItem` is `#[repr(C, align(8))]` over a single
230// `[u8; KHPD_ITEM_BYTES]` payload field. The bytes are position-
231// independent across address spaces; round-trip is a memcpy.
232unsafe impl subetha_core::Marshal for LineItem {
233    const PAYLOAD_BYTES: usize = KHPD_ITEM_BYTES;
234
235    fn marshal(&self, dst: &mut [u8]) {
236        dst[..KHPD_ITEM_BYTES].copy_from_slice(&self.payload);
237    }
238
239    fn unmarshal(src: &[u8]) -> Result<Self, subetha_core::MarshalError> {
240        if src.len() < KHPD_ITEM_BYTES {
241            return Err(subetha_core::MarshalError::ShortBuffer {
242                expected: KHPD_ITEM_BYTES,
243                got: src.len(),
244            });
245        }
246        let mut payload = [0u8; KHPD_ITEM_BYTES];
247        payload.copy_from_slice(&src[..KHPD_ITEM_BYTES]);
248        Ok(Self { payload })
249    }
250}
251
252/// One publication line: state + `LINE_ITEMS` items + padding.
253#[repr(C, align(64))]
254pub struct PublicationLine {
255    /// `(epoch:32) << 32 | (n_items:16) << 16 | claim:16`.
256    /// `claim` = 0 (empty), 1 (READY for claim).
257    pub state: AtomicU64,
258    /// Inline items.
259    pub items: [LineItem; LINE_ITEMS],
260    /// Trailing padding to round to 64 bytes.
261    pub _pad: [u8; 8],
262}
263
264/// Total file size for a KHPD with `capacity` publication lines.
265pub const fn khpd_file_size(capacity: usize) -> usize {
266    std::mem::size_of::<KhpdHeader>() + capacity * KHPD_LINE_SIZE
267}
268
269/// Outcome of [`SharedDequeKhpd::publish`].
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum PushError {
272    /// Ring at capacity; consumer has not caught up.
273    Full,
274    /// Items count exceeds [`LINE_ITEMS`].
275    TooManyItems,
276    /// Payload exceeds [`KHPD_ITEM_BYTES`].
277    PayloadTooLarge,
278}
279
280/// Outcome of [`SharedDequeKhpd::steal_line`].
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum Steal {
283    /// Got a publication line; carries up to [`LINE_ITEMS`] items.
284    Success(StealResult),
285    /// Ring was empty (head >= tail).
286    Empty,
287    /// Lost the CAS race on `head` to a competing thief, or the
288    /// publisher has not finished writing this line yet.
289    Retry,
290}
291
292/// Result of a successful steal: the publication line's items.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct StealResult {
295    /// How many of the `items` slots are filled.
296    pub n_items: usize,
297    /// The items (only `items[..n_items]` are valid).
298    pub items: [LineItem; LINE_ITEMS],
299}
300
301/// MMF-backed K-axis Hierarchical Publication Deque. Single owner,
302/// arbitrarily many thieves.
303pub struct SharedDequeKhpd {
304    _file: File,
305    mmap: MmapMut,
306    capacity: usize,
307    capacity_mask: i64,
308    /// Owner-side staging buffer. Items accumulate here until the
309    /// caller calls [`publish`](Self::publish) to flush the buffer
310    /// into one or more publication lines. `Mutex` is uncontended
311    /// on the hot path (only the owner stages).
312    pending: Mutex<Vec<LineItem>>,
313}
314
315// SAFETY: all fields are Send. Mmap handle is Send + Sync per
316// memmap2. Every line access goes through the per-line state-atomic
317// protocol; the `pending` Mutex linearises owner-side accesses.
318unsafe impl Send for SharedDequeKhpd {}
319// SAFETY: same justification as the Send impl directly above.
320unsafe impl Sync for SharedDequeKhpd {}
321
322impl SharedDequeKhpd {
323    /// Create a fresh KHPD file. `capacity` rounds up to the next
324    /// power of two; minimum 2.
325    pub fn create<P: AsRef<Path>>(path: P, capacity: usize) -> io::Result<Self> {
326        let capacity = capacity.max(2).next_power_of_two();
327        let size = khpd_file_size(capacity);
328
329        let file = OpenOptions::new()
330            .read(true)
331            .write(true)
332            .create(true)
333            .truncate(true)
334            .open(path.as_ref())?;
335        file.set_len(size as u64)?;
336
337        // SAFETY: `map_mut` is unsafe because the kernel cannot
338        // prevent another process from truncating the file. This
339        // call site upholds the soundness contract by writing only
340        // through the KHPD per-line state-atomic protocol; file
341        // size is fixed by `set_len` above and never shrunk for the
342        // lifetime of any mapping.
343        let mut mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
344
345        let header_ptr = mmap.as_mut_ptr() as *mut KhpdHeader;
346        // SAFETY: mmap is page-aligned (well above the 64-byte
347        // alignment KhpdHeader requires); the map covers
348        // `khpd_file_size(capacity)` bytes by construction.
349        unsafe {
350            (*header_ptr).magic = KHPD_MAGIC;
351            (*header_ptr).capacity = capacity as u64;
352            (*header_ptr).owner_pid = AtomicU64::new(std::process::id() as u64);
353            (*header_ptr).epoch = AtomicU64::new(0);
354            std::ptr::write_bytes((*header_ptr)._pad_meta.as_mut_ptr(), 0, 24);
355            (*header_ptr).tail = AtomicI64::new(0);
356            std::ptr::write_bytes((*header_ptr)._pad_tail.as_mut_ptr(), 0, 56);
357            (*header_ptr).head = AtomicI64::new(0);
358            std::ptr::write_bytes((*header_ptr)._pad_head.as_mut_ptr(), 0, 56);
359        }
360
361        // Zero the lines (state == 0 == STATE_EMPTY).
362        let lines_start = std::mem::size_of::<KhpdHeader>();
363        // SAFETY: lines_start..lines_start + capacity*KHPD_LINE_SIZE
364        // is the unwritten tail of the map.
365        unsafe {
366            std::ptr::write_bytes(
367                mmap.as_mut_ptr().add(lines_start),
368                0,
369                capacity * KHPD_LINE_SIZE,
370            );
371        }
372
373        mmap.flush()?;
374
375        Ok(Self {
376            _file: file,
377            mmap,
378            capacity,
379            capacity_mask: (capacity as i64) - 1,
380            pending: Mutex::new(Vec::with_capacity(LINE_ITEMS)),
381        })
382    }
383
384    /// Open an existing KHPD file.
385    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
386        let file = OpenOptions::new()
387            .read(true)
388            .write(true)
389            .open(path.as_ref())?;
390        let size = file.metadata()?.len() as usize;
391        if size < std::mem::size_of::<KhpdHeader>() {
392            return Err(io::Error::new(
393                io::ErrorKind::InvalidData,
394                "khpd file too small",
395            ));
396        }
397        // SAFETY: same protocol-only-access justification as
398        // `create`.
399        let mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
400        let header_ptr = mmap.as_ptr() as *const KhpdHeader;
401        // SAFETY: map size verified to cover header.
402        let (magic, capacity) =
403            unsafe { ((*header_ptr).magic, (*header_ptr).capacity as usize) };
404        if magic != KHPD_MAGIC {
405            return Err(io::Error::new(
406                io::ErrorKind::InvalidData,
407                format!("khpd magic mismatch {magic:#x}"),
408            ));
409        }
410        if !capacity.is_power_of_two() || capacity < 2 {
411            return Err(io::Error::new(
412                io::ErrorKind::InvalidData,
413                format!("khpd capacity {capacity} not pow2 >= 2"),
414            ));
415        }
416        if size < khpd_file_size(capacity) {
417            return Err(io::Error::new(
418                io::ErrorKind::InvalidData,
419                format!(
420                    "khpd file size {size} below expected {}",
421                    khpd_file_size(capacity)
422                ),
423            ));
424        }
425        Ok(Self {
426            _file: file,
427            mmap,
428            capacity,
429            capacity_mask: (capacity as i64) - 1,
430            pending: Mutex::new(Vec::with_capacity(LINE_ITEMS)),
431        })
432    }
433
434    /// Capacity in publication lines (always a power of two).
435    pub fn capacity(&self) -> usize {
436        self.capacity
437    }
438
439    /// Owner pid at create time, or 0 after `close_owner()`.
440    pub fn owner_pid(&self) -> u64 {
441        self.header().owner_pid.load(Ordering::Acquire)
442    }
443
444    /// Advance epoch + zero the owner pid on shutdown.
445    pub fn close_owner(&self) {
446        self.header().owner_pid.store(0, Ordering::Release);
447        self.header().epoch.fetch_add(1, Ordering::Release);
448    }
449
450    fn header(&self) -> &KhpdHeader {
451        // SAFETY: header is at the start of the map; mmap is
452        // page-aligned.
453        unsafe { &*(self.mmap.as_ptr() as *const KhpdHeader) }
454    }
455
456    fn line_ptr(&self, idx: i64) -> *mut PublicationLine {
457        let line_idx = (idx & self.capacity_mask) as usize;
458        let off = std::mem::size_of::<KhpdHeader>() + line_idx * KHPD_LINE_SIZE;
459        // SAFETY: `line_idx` < capacity; `off` is in-bounds + 64-byte aligned.
460        unsafe { self.mmap.as_ptr().add(off) as *mut PublicationLine }
461    }
462
463    /// Snapshot `(head, tail, ring_size_lines, pending_items)`.
464    pub fn snapshot_size(&self) -> (i64, i64, i64, usize) {
465        let h = self.header();
466        let head = h.head.load(Ordering::Acquire);
467        let tail = h.tail.load(Ordering::Acquire);
468        let pending = self
469            .pending
470            .try_lock()
471            .map(|g| g.len())
472            .unwrap_or(0);
473        (head, tail, tail - head, pending)
474    }
475
476    /// Owner-side stage. Adds one item to the pending buffer.
477    /// Returns the running pending count (so the caller can decide
478    /// to flush at [`LINE_ITEMS`]). **Only the owner process may
479    /// stage.**
480    pub fn stage(&self, item: LineItem) -> Result<usize, PushError> {
481        let mut p = self.pending.lock().expect("KHPD pending poisoned");
482        p.push(item);
483        Ok(p.len())
484    }
485
486    /// Owner-side single-call batch publish. Bypasses the
487    /// [`stage`](Self::stage)/[`publish`](Self::publish) pair so the
488    /// caller pays only ONE Mutex acquire per batch instead of one
489    /// per staged item. This is the canonical hot-path API: the
490    /// caller hands in a slice of [`LineItem`] values and the method
491    /// publishes them into `ceil(items.len() / LINE_ITEMS)`
492    /// publication lines with one `tail.fetch_add(n_lines)` plus
493    /// one Release-store per line.
494    ///
495    /// Returns the number of LINES published.
496    pub fn publish_batch(&self, items: &[LineItem]) -> Result<usize, PushError> {
497        if items.is_empty() {
498            return Ok(0);
499        }
500        // Hold migration_lock-equivalent: serialise against other
501        // owner-side publishes by going through the same Mutex the
502        // staged path uses.
503        let _g = self.pending.lock().expect("KHPD pending poisoned");
504        let n_lines = items.len().div_ceil(LINE_ITEMS);
505        let h = self.header();
506        let head_snap = h.head.load(Ordering::Acquire);
507        let tail_snap = h.tail.load(Ordering::Relaxed);
508        if (tail_snap - head_snap + n_lines as i64) > self.capacity as i64 {
509            return Err(PushError::Full);
510        }
511        let base = h.tail.fetch_add(n_lines as i64, Ordering::AcqRel);
512
513        // No PREFETCHW here: empirical 30-second criterion bench on
514        // Zen+ R7 2700 measured a 12% regression vs the unprefetched
515        // path (p = 0.01). KHPD's publication lines are L1d-warm from
516        // the prior iteration of `publish_batch`; explicit prefetch
517        // pollutes the prefetch queue without payoff. The architectural
518        // lever is preserved for Chase-Lev and LOH where the slot line
519        // is cold per push.
520
521        let mut it = items.iter();
522        for line_i in 0..n_lines {
523            let idx = base + line_i as i64;
524            let line = self.line_ptr(idx);
525            // SAFETY: line is in-bounds + aligned.
526            unsafe {
527                loop {
528                    let st = (*line).state.load(Ordering::Acquire);
529                    if st == STATE_EMPTY { break; }
530                    std::hint::spin_loop();
531                }
532                let mut n_filled = 0usize;
533                for slot in 0..LINE_ITEMS {
534                    match it.next() {
535                        Some(item) => {
536                            (*line).items[slot] = *item;
537                            n_filled += 1;
538                        }
539                        None => break,
540                    }
541                }
542                let new_state =
543                    ((idx as u64) << 32) | ((n_filled as u64) << 16) | CLAIM_BIT;
544                (*line).state.store(new_state, Ordering::Release);
545            }
546        }
547        Ok(n_lines)
548    }
549
550    /// Owner-side publish. Drains the pending buffer into one or
551    /// more publication lines ([`LINE_ITEMS`] items per line). Each
552    /// line takes one `tail.fetch_add(1)` plus one Release-store on
553    /// the line's state. Returns the number of LINES published.
554    pub fn publish(&self) -> Result<usize, PushError> {
555        let mut p = self.pending.lock().expect("KHPD pending poisoned");
556        if p.is_empty() {
557            return Ok(0);
558        }
559        let total = p.len();
560        let n_lines = total.div_ceil(LINE_ITEMS);
561        let h = self.header();
562        let head_snap = h.head.load(Ordering::Acquire);
563        let tail_snap = h.tail.load(Ordering::Relaxed);
564        if (tail_snap - head_snap + n_lines as i64) > self.capacity as i64 {
565            return Err(PushError::Full);
566        }
567        let base = h.tail.fetch_add(n_lines as i64, Ordering::AcqRel);
568
569        let mut item_iter = p.drain(..);
570        for line_i in 0..n_lines {
571            let idx = base + line_i as i64;
572            let line = self.line_ptr(idx);
573            // Spin-wait for the slot to be reusable. The consumer's
574            // release stores `STATE_EMPTY` (= 0); the producer at
575            // `idx` spins until state == 0 for this slot's next
576            // round.
577            //
578            // SAFETY: line is in-bounds + aligned.
579            unsafe {
580                loop {
581                    let st = (*line).state.load(Ordering::Acquire);
582                    if st == STATE_EMPTY {
583                        break;
584                    }
585                    std::hint::spin_loop();
586                }
587
588                // Fill items.
589                let mut n_filled = 0usize;
590                for i in 0..LINE_ITEMS {
591                    match item_iter.next() {
592                        Some(item) => {
593                            (*line).items[i] = item;
594                            n_filled += 1;
595                        }
596                        None => break,
597                    }
598                }
599                // Pack state: (epoch:32 from idx) | (n_filled:16) |
600                // claim:16 == CLAIM_BIT (READY).
601                let new_state =
602                    ((idx as u64) << 32) | ((n_filled as u64) << 16) | CLAIM_BIT;
603                (*line).state.store(new_state, Ordering::Release);
604            }
605        }
606        Ok(n_lines)
607    }
608
609    /// Thief-side. Claim one publication line.
610    pub fn steal_line(&self) -> Steal {
611        let h = self.header();
612        let head = h.head.load(Ordering::Acquire);
613        fence(Ordering::SeqCst);
614        let tail = h.tail.load(Ordering::Acquire);
615        if head >= tail {
616            return Steal::Empty;
617        }
618        let line = self.line_ptr(head);
619        // SAFETY: line is in-bounds + aligned.
620        let state = unsafe { (*line).state.load(Ordering::Acquire) };
621        // Validate: state's epoch matches our head and CLAIM_BIT is
622        // set.
623        let expected_epoch = (head as u64) << 32;
624        if state & 0xFFFF_FFFF_0000_0000 != expected_epoch {
625            // Publisher has not written this line for our round yet.
626            return Steal::Retry;
627        }
628        if state & CLAIM_BIT == 0 {
629            // Line empty (no items for this round). Skip.
630            return Steal::Retry;
631        }
632        // CAS head to claim.
633        let won = h
634            .head
635            .compare_exchange(head, head + 1, Ordering::SeqCst, Ordering::Relaxed)
636            .is_ok();
637        if !won {
638            return Steal::Retry;
639        }
640        // We own the line; read items and release the slot for the
641        // next round.
642        //
643        // SAFETY: line is in-bounds + aligned; the CAS established
644        // exclusive read access for this round.
645        let result = unsafe {
646            let n_items = ((state >> 16) & 0xFFFF) as usize;
647            let n_items = n_items.min(LINE_ITEMS);
648            StealResult {
649                n_items,
650                items: (*line).items,
651            }
652        };
653        // Release the slot: store STATE_EMPTY so the next round's
654        // producer (at idx = head + capacity) sees the slot ready.
655        //
656        // SAFETY: still our slot; the Release synchronises with the
657        // next producer's Acquire-spin in `publish`.
658        unsafe {
659            (*line).state.store(STATE_EMPTY, Ordering::Release);
660        }
661        Steal::Success(result)
662    }
663
664    /// Force any dirty pages to disk.
665    pub fn flush_to_disk(&self) -> io::Result<()> {
666        self.mmap.flush()
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use std::sync::Arc;
674    use std::sync::atomic::{AtomicUsize, Ordering as O};
675    use std::thread;
676
677    fn temp_path(name: &str) -> std::path::PathBuf {
678        let mut p = std::env::temp_dir();
679        let pid = std::process::id();
680        let nonce = std::time::SystemTime::now()
681            .duration_since(std::time::UNIX_EPOCH)
682            .map(|d| d.as_nanos())
683            .unwrap_or(0);
684        p.push(format!("subetha_khpd_{pid}_{nonce}_{name}.bin"));
685        p
686    }
687
688    fn u32_item(id: u32) -> LineItem {
689        LineItem::new(&id.to_le_bytes()).expect("item")
690    }
691
692    fn item_id(item: &LineItem) -> u32 {
693        u32::from_le_bytes(item.payload[..4].try_into().unwrap())
694    }
695
696    #[test]
697    fn create_open_round_trips_header() {
698        let path = temp_path("create_open");
699        let _d = SharedDequeKhpd::create(&path, 8).expect("create");
700        let o = SharedDequeKhpd::open(&path).expect("open");
701        assert_eq!(o.capacity(), 8);
702        assert_eq!(o.owner_pid(), std::process::id() as u64);
703        std::fs::remove_file(&path).ok();
704    }
705
706    #[test]
707    fn open_rejects_bad_magic() {
708        let path = temp_path("badmagic");
709        std::fs::write(&path, vec![0u8; 8192]).expect("seed");
710        assert!(SharedDequeKhpd::open(&path).is_err());
711        std::fs::remove_file(&path).ok();
712    }
713
714    #[test]
715    fn stage_then_publish_writes_one_line() {
716        let path = temp_path("stage_publish");
717        let d = SharedDequeKhpd::create(&path, 4).expect("create");
718        d.stage(u32_item(1)).expect("stage 1");
719        d.stage(u32_item(2)).expect("stage 2");
720        let lines = d.publish().expect("publish");
721        assert_eq!(lines, 1);
722        let (_, tail, sz, pending) = d.snapshot_size();
723        assert_eq!(tail, 1);
724        assert_eq!(sz, 1);
725        assert_eq!(pending, 0);
726        std::fs::remove_file(&path).ok();
727    }
728
729    #[test]
730    fn publish_spans_multiple_lines() {
731        let path = temp_path("multi_line");
732        let d = SharedDequeKhpd::create(&path, 4).expect("create");
733        // 7 items: 3 + 3 + 1 = 3 lines.
734        for i in 1..=7u32 {
735            d.stage(u32_item(i)).expect("stage");
736        }
737        let lines = d.publish().expect("publish");
738        assert_eq!(lines, 3);
739        let (_, tail, sz, _) = d.snapshot_size();
740        assert_eq!(tail, 3);
741        assert_eq!(sz, 3);
742        std::fs::remove_file(&path).ok();
743    }
744
745    #[test]
746    fn steal_returns_items_in_publication_order() {
747        let path = temp_path("fifo");
748        let d = SharedDequeKhpd::create(&path, 4).expect("create");
749        for i in 1..=5u32 {
750            d.stage(u32_item(i)).expect("stage");
751        }
752        d.publish().expect("publish");
753        // Line 0 carries (1, 2, 3); line 1 carries (4, 5).
754        loop {
755            match d.steal_line() {
756                Steal::Success(r) => {
757                    assert_eq!(r.n_items, 3);
758                    assert_eq!(item_id(&r.items[0]), 1);
759                    assert_eq!(item_id(&r.items[1]), 2);
760                    assert_eq!(item_id(&r.items[2]), 3);
761                    break;
762                }
763                Steal::Empty | Steal::Retry => std::thread::yield_now(),
764            }
765        }
766        loop {
767            match d.steal_line() {
768                Steal::Success(r) => {
769                    assert_eq!(r.n_items, 2);
770                    assert_eq!(item_id(&r.items[0]), 4);
771                    assert_eq!(item_id(&r.items[1]), 5);
772                    break;
773                }
774                Steal::Empty | Steal::Retry => std::thread::yield_now(),
775            }
776        }
777        loop {
778            match d.steal_line() {
779                Steal::Empty => break,
780                Steal::Retry => continue,
781                Steal::Success(_) => panic!("unexpected success after drain"),
782            }
783        }
784        std::fs::remove_file(&path).ok();
785    }
786
787    #[test]
788    fn oversize_payload_rejected() {
789        let big = vec![0u8; KHPD_ITEM_BYTES + 1];
790        let err = LineItem::new(&big).expect_err("oversize");
791        assert_eq!(err, PushError::PayloadTooLarge);
792    }
793
794    #[test]
795    fn ring_full_at_capacity_returns_full() {
796        let path = temp_path("full");
797        let d = SharedDequeKhpd::create(&path, 2).expect("create");
798        // Fill the ring (2 publication lines * 3 items = 6 items).
799        for i in 1..=6u32 {
800            d.stage(u32_item(i)).expect("stage");
801        }
802        d.publish().expect("publish 2 lines");
803        // Stage more + publish; ring is full.
804        d.stage(u32_item(7)).expect("stage 7");
805        let err = d.publish().expect_err("publish past capacity");
806        assert_eq!(err, PushError::Full);
807        std::fs::remove_file(&path).ok();
808    }
809
810    #[test]
811    fn close_owner_zeros_pid_and_advances_epoch() {
812        let path = temp_path("close");
813        let d = SharedDequeKhpd::create(&path, 2).expect("create");
814        let before = d.header().epoch.load(O::Acquire);
815        d.close_owner();
816        assert_eq!(d.owner_pid(), 0);
817        assert_eq!(d.header().epoch.load(O::Acquire), before + 1);
818        std::fs::remove_file(&path).ok();
819    }
820
821    #[test]
822    fn concurrent_thieves_no_double_take() {
823        // Stress: 5000 items via repeated stage + publish; 2 thieves
824        // race to drain. Every item must be consumed exactly once.
825        let path = temp_path("stress");
826        let d = Arc::new(SharedDequeKhpd::create(&path, 64).expect("create"));
827        let n = 5_000usize;
828        let consumed = Arc::new(AtomicUsize::new(0));
829        let sum = Arc::new(AtomicUsize::new(0));
830
831        let mut thieves = Vec::new();
832        for _ in 0..2 {
833            let d = Arc::clone(&d);
834            let consumed = Arc::clone(&consumed);
835            let sum = Arc::clone(&sum);
836            thieves.push(thread::spawn(move || {
837                while consumed.load(O::Relaxed) < n {
838                    match d.steal_line() {
839                        Steal::Success(r) => {
840                            for i in 0..r.n_items {
841                                consumed.fetch_add(1, O::Relaxed);
842                                sum.fetch_add(item_id(&r.items[i]) as usize, O::Relaxed);
843                            }
844                        }
845                        Steal::Empty | Steal::Retry => std::thread::yield_now(),
846                    }
847                }
848            }));
849        }
850
851        // Publisher: stage LINE_ITEMS, publish, repeat until n items.
852        let mut pushed = 0usize;
853        while pushed < n {
854            let want = LINE_ITEMS.min(n - pushed);
855            for _ in 0..want {
856                d.stage(u32_item(pushed as u32)).expect("stage");
857                pushed += 1;
858            }
859            loop {
860                match d.publish() {
861                    Ok(_) => break,
862                    Err(PushError::Full) => {
863                        std::thread::yield_now();
864                    }
865                    Err(other) => panic!("publish: {other:?}"),
866                }
867            }
868        }
869
870        for t in thieves {
871            t.join().expect("thief");
872        }
873        let expected: usize = (0..n).sum();
874        assert_eq!(
875            sum.load(O::Relaxed),
876            expected,
877            "stress sum mismatch (expected every item consumed exactly once)"
878        );
879        std::fs::remove_file(&path).ok();
880    }
881}