Skip to main content

subetha_cxc/
protocol_pubsub.rs

1//! `PubSubRing`: one-producer many-subscriber broadcast primitive
2//! with per-subscriber positions.
3//!
4//! Where a regular ring (SpscRingCore) has one consumer position
5//! (the tail), PubSubRing exposes the producer's monotonic head
6//! as the absolute position and lets each subscriber walk
7//! positions independently. Subscriber positions are tracked
8//! externally via [`SubscriberPosition`], so they can survive a
9//! subscriber restart.
10//!
11//! # Slot layout
12//!
13//! Each slot carries a `sequence: AtomicU64` + 56-byte payload.
14//! On a successful `publish(payload)`, the producer:
15//! 1. Writes the payload into slot[head % capacity].
16//! 2. Releases the new sequence = head + 1.
17//! 3. Releases head + 1 into the header.
18//!
19//! On `read_at(position)`, a subscriber:
20//! 1. Reads the slot's sequence with Acquire.
21//! 2. Validates `sequence == position + 1` (matches expected slot).
22//!    If `sequence > position + 1`, the slot has been overwritten
23//!    (wraparound); subscriber returns `PubSubReadError::Lost`.
24//!    If `sequence < position + 1`, the slot is not yet published;
25//!    subscriber returns `PubSubReadError::Pending`.
26//! 3. On match: copies the payload to the out buffer.
27//!
28//! # KeepAll vs KeepLastN policy
29//!
30//! The primitive itself is KeepLastN-shaped: producer never blocks
31//! on subscribers; wraparound happens at capacity. Callers that
32//! want KeepAll semantics check the minimum subscriber position
33//! before publishing and back off when the ring is about to wrap
34//! past it. Helpers for that pattern can layer on top.
35
36use std::cell::UnsafeCell;
37use std::fs::{File, OpenOptions};
38use std::path::Path;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::Arc;
41
42use memmap2::{MmapMut, MmapOptions};
43
44use crate::replay_positions::SubscriberPosition;
45
46/// Payload bytes per slot. Matches the Vyukov-side payload size to
47/// keep the substrate's per-slot byte layout consistent across
48/// primitives.
49pub const PUBSUB_PAYLOAD_BYTES: usize = 56;
50
51const PUBSUB_SLOT_SIZE: usize = 64; // 8-byte seq + 56-byte payload
52const PUBSUB_MAGIC: u64 = 0xE7_E7_E7_E7_50_55_42_53; // ASCII "...PUBS"
53
54#[repr(C, align(64))]
55struct PubSubHeader {
56    magic: u64,
57    capacity: u64,
58    slot_size: u64,
59    _pad_meta: [u8; 64 - 24],
60    head: AtomicU64,
61    _pad_head: [u8; 64 - 8],
62}
63
64#[repr(C, align(64))]
65struct PubSubSlot {
66    sequence: AtomicU64,
67    payload: UnsafeCell<[u8; PUBSUB_PAYLOAD_BYTES]>,
68}
69
70/// One-producer many-subscriber broadcast ring with per-subscriber
71/// positions.
72pub struct PubSubRing {
73    _backing: PubSubBacking,
74    raw_ptr: *mut u8,
75    capacity: usize,
76}
77
78unsafe impl Send for PubSubRing {}
79unsafe impl Sync for PubSubRing {}
80
81/// Storage backing for a `PubSubRing`. Owns the underlying
82/// resource for the lifetime of the ring; the hot-path
83/// pointer (`raw_ptr` on `PubSubRing`) is cached at construction.
84/// The held values are intentionally only kept for ownership;
85/// the `File` and `ShmFile` payloads are not read at runtime.
86#[allow(dead_code)]
87enum PubSubBacking {
88    /// In-process anonymous mmap.
89    Anon(MmapMut),
90    /// File-backed mmap (cross-process via OS page cache).
91    File(File, MmapMut),
92    /// Named shared-memory mmap (ShmFs locale; cross-process,
93    /// RAM-resident).
94    Shm(crate::shm_file::ShmFile),
95}
96
97/// Errors a subscriber read can return.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum PubSubReadError {
100    /// The requested position has not been published yet.
101    Pending,
102    /// The requested position has been overwritten by the producer.
103    /// Subscriber lagged more than `capacity` positions behind.
104    Lost,
105}
106
107pub const fn pubsub_ring_file_size(capacity: usize) -> usize {
108    std::mem::size_of::<PubSubHeader>() + capacity * PUBSUB_SLOT_SIZE
109}
110
111impl PubSubRing {
112    /// Construct an anonymous in-process pub/sub ring.
113    pub fn create_anon(capacity: usize) -> std::io::Result<Self> {
114        assert!(capacity.is_power_of_two() && capacity >= 2,
115                "capacity must be pow2 >= 2");
116        let total = pubsub_ring_file_size(capacity);
117        let mut mmap = MmapOptions::new().len(total).map_anon()?;
118        let raw_ptr = mmap.as_mut_ptr();
119        init_pubsub_layout(raw_ptr, capacity);
120        Ok(Self {
121            _backing: PubSubBacking::Anon(mmap),
122            raw_ptr, capacity,
123        })
124    }
125
126    /// Construct a file-backed pub/sub ring. Cross-process via
127    /// the OS page cache. Obtains the ring at `path`: initializes an
128    /// empty one if the path does not yet exist and attaches to it if
129    /// it does, with published slots and the head in place; a ring
130    /// built with a different capacity is refused.
131    /// [`reset`](Self::reset) reinitializes.
132    pub fn create(path: impl AsRef<Path>, capacity: usize) -> std::io::Result<Self> {
133        assert!(capacity.is_power_of_two() && capacity >= 2,
134                "capacity must be pow2 >= 2");
135        let total = pubsub_ring_file_size(capacity);
136        let (file, mut mmap) = crate::mmf_attach::create_or_attach(
137            path.as_ref(),
138            total,
139            |ptr| init_pubsub_layout(ptr, capacity),
140            |ptr| unsafe { (*(ptr as *const PubSubHeader)).magic == PUBSUB_MAGIC },
141        )?;
142        let raw_ptr = mmap.as_mut_ptr();
143        let header = unsafe { &*(raw_ptr as *const PubSubHeader) };
144        if header.magic != PUBSUB_MAGIC
145            || header.capacity != capacity as u64
146            || header.slot_size != PUBSUB_SLOT_SIZE as u64
147        {
148            return Err(std::io::Error::new(
149                std::io::ErrorKind::InvalidData,
150                "pubsub file layout mismatch",
151            ));
152        }
153        Ok(Self {
154            _backing: PubSubBacking::File(file, mmap),
155            raw_ptr, capacity,
156        })
157    }
158
159    /// Truncate the ring at `path` and initialize an empty one,
160    /// discarding published slots live subscribers hold. For a caller
161    /// that knows it owns the path.
162    pub fn reset(path: impl AsRef<Path>, capacity: usize) -> std::io::Result<Self> {
163        assert!(capacity.is_power_of_two() && capacity >= 2,
164                "capacity must be pow2 >= 2");
165        let total = pubsub_ring_file_size(capacity);
166        let (file, mut mmap) = crate::mmf_attach::reset(
167            path.as_ref(),
168            total,
169            |ptr| init_pubsub_layout(ptr, capacity),
170        )?;
171        let raw_ptr = mmap.as_mut_ptr();
172        Ok(Self {
173            _backing: PubSubBacking::File(file, mmap),
174            raw_ptr, capacity,
175        })
176    }
177
178    /// Open an existing file-backed pub/sub ring. Validates magic
179    /// + capacity.
180    pub fn open(path: impl AsRef<Path>, expected_capacity: usize) -> std::io::Result<Self> {
181        let total = pubsub_ring_file_size(expected_capacity);
182        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
183        if (file.metadata()?.len() as usize) < total {
184            return Err(std::io::Error::new(
185                std::io::ErrorKind::InvalidData,
186                "pubsub file too small for expected capacity",
187            ));
188        }
189        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
190        let raw_ptr = mmap.as_mut_ptr();
191        let header = unsafe { &*(raw_ptr as *const PubSubHeader) };
192        if header.magic != PUBSUB_MAGIC
193            || header.capacity != expected_capacity as u64
194            || header.slot_size != PUBSUB_SLOT_SIZE as u64
195        {
196            return Err(std::io::Error::new(
197                std::io::ErrorKind::InvalidData,
198                "pubsub file layout mismatch",
199            ));
200        }
201        Ok(Self {
202            _backing: PubSubBacking::File(file, mmap),
203            raw_ptr, capacity: expected_capacity,
204        })
205    }
206
207    /// Construct a fresh pub/sub ring on top of a named
208    /// RAM-resident shared-memory backing.
209    pub fn create_from_shm(
210        mut shm: crate::shm_file::ShmFile,
211        capacity: usize,
212    ) -> std::io::Result<Self> {
213        assert!(capacity.is_power_of_two() && capacity >= 2,
214                "capacity must be pow2 >= 2");
215        let total = pubsub_ring_file_size(capacity);
216        if shm.len() < total {
217            return Err(std::io::Error::new(
218                std::io::ErrorKind::InvalidData,
219                "shm region too small for pubsub layout",
220            ));
221        }
222        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
223        init_pubsub_layout(raw_ptr, capacity);
224        Ok(Self {
225            _backing: PubSubBacking::Shm(shm),
226            raw_ptr, capacity,
227        })
228    }
229
230    /// Open an existing named ShmFs-backed pub/sub ring.
231    pub fn open_from_shm(
232        mut shm: crate::shm_file::ShmFile,
233        expected_capacity: usize,
234    ) -> std::io::Result<Self> {
235        let total = pubsub_ring_file_size(expected_capacity);
236        if shm.len() < total {
237            return Err(std::io::Error::new(
238                std::io::ErrorKind::InvalidData,
239                "shm region too small for expected capacity",
240            ));
241        }
242        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
243        let header = unsafe { &*(raw_ptr as *const PubSubHeader) };
244        if header.magic != PUBSUB_MAGIC
245            || header.capacity != expected_capacity as u64
246            || header.slot_size != PUBSUB_SLOT_SIZE as u64
247        {
248            return Err(std::io::Error::new(
249                std::io::ErrorKind::InvalidData,
250                "shm layout mismatch",
251            ));
252        }
253        Ok(Self {
254            _backing: PubSubBacking::Shm(shm),
255            raw_ptr, capacity: expected_capacity,
256        })
257    }
258
259    fn header(&self) -> &PubSubHeader {
260        unsafe { &*(self.raw_ptr as *const PubSubHeader) }
261    }
262
263    fn slot(&self, idx: usize) -> &PubSubSlot {
264        let slots_base = unsafe {
265            self.raw_ptr.add(std::mem::size_of::<PubSubHeader>())
266        };
267        let masked = idx & (self.capacity - 1);
268        unsafe { &*(slots_base.add(masked * PUBSUB_SLOT_SIZE) as *const PubSubSlot) }
269    }
270
271    /// Producer's published head. Equals the next position that
272    /// will be assigned to a `publish` call.
273    pub fn head(&self) -> u64 {
274        self.header().head.load(Ordering::Acquire)
275    }
276
277    /// Capacity in slots (always a power of 2).
278    pub fn capacity(&self) -> usize { self.capacity }
279
280    /// Publish one payload. Returns the absolute position assigned
281    /// to this item. Caller MUST be the single producer.
282    pub fn publish(&self, payload: &[u8]) -> u64 {
283        assert!(payload.len() <= PUBSUB_PAYLOAD_BYTES);
284        let header = self.header();
285        let head = header.head.load(Ordering::Relaxed);
286        let slot = self.slot(head as usize);
287        // Write payload first.
288        unsafe {
289            let dst = (*slot.payload.get()).as_mut_ptr();
290            std::ptr::copy_nonoverlapping(payload.as_ptr(), dst, payload.len());
291            if payload.len() < PUBSUB_PAYLOAD_BYTES {
292                std::ptr::write_bytes(
293                    dst.add(payload.len()), 0,
294                    PUBSUB_PAYLOAD_BYTES - payload.len(),
295                );
296            }
297        }
298        // Release-store the slot sequence so subscribers see the
299        // payload BEFORE the sequence advances.
300        slot.sequence.store(head + 1, Ordering::Release);
301        // Advance the header head; subscribers walking the head
302        // pointer see the new item.
303        header.head.store(head + 1, Ordering::Release);
304        head
305    }
306
307    /// Read the payload at absolute `position`. The subscriber
308    /// supplies a buffer of at least [`PUBSUB_PAYLOAD_BYTES`].
309    ///
310    /// Returns `Ok(())` on success (payload copied into `out`),
311    /// `Err(Pending)` when the position has not been published yet,
312    /// `Err(Lost)` when the slot has wrapped past `position`.
313    pub fn read_at(
314        &self,
315        position: u64,
316        out: &mut [u8],
317    ) -> Result<(), PubSubReadError> {
318        assert!(out.len() >= PUBSUB_PAYLOAD_BYTES);
319        let slot = self.slot(position as usize);
320        let observed_seq = slot.sequence.load(Ordering::Acquire);
321        let expected_seq = position + 1;
322        if observed_seq == expected_seq {
323            unsafe {
324                let src = (*slot.payload.get()).as_ptr();
325                std::ptr::copy_nonoverlapping(
326                    src, out.as_mut_ptr(), PUBSUB_PAYLOAD_BYTES,
327                );
328            }
329            Ok(())
330        } else if observed_seq > expected_seq {
331            Err(PubSubReadError::Lost)
332        } else {
333            Err(PubSubReadError::Pending)
334        }
335    }
336}
337
338/// Subscriber-side helper that holds a [`SubscriberPosition`] and
339/// pulls items from a `PubSubRing` in order.
340pub struct PubSubSubscriber {
341    ring: Arc<PubSubRing>,
342    position: SubscriberPosition,
343}
344
345impl PubSubSubscriber {
346    /// Wrap a ring + position into a subscriber.
347    pub fn new(ring: Arc<PubSubRing>, position: SubscriberPosition) -> Self {
348        Self { ring, position }
349    }
350
351    /// Current absolute position this subscriber has consumed up to.
352    pub fn position(&self) -> u64 { self.position.get() }
353
354    /// Ring this subscriber is attached to.
355    pub fn ring(&self) -> &Arc<PubSubRing> { &self.ring }
356
357    /// Advance the subscriber's position by `n` without reading.
358    /// Used by callers that want to skip items deliberately
359    /// (sampled subscriptions, late-join skip-ahead).
360    pub fn skip(&self, n: u64) -> u64 {
361        self.position.advance(n)
362    }
363
364    /// Try to read the next item. On success, advances the
365    /// subscriber's position by 1. On `Pending`, leaves the
366    /// position alone. On `Lost`, advances the position to the
367    /// ring's current head (skipping past the gap).
368    pub fn try_next(&self, out: &mut [u8]) -> Result<(), PubSubReadError> {
369        let pos = self.position.get();
370        match self.ring.read_at(pos, out) {
371            Ok(()) => {
372                self.position.advance(1);
373                Ok(())
374            }
375            Err(PubSubReadError::Lost) => {
376                // Skip past the gap by jumping to the current head.
377                self.position.set(self.ring.head());
378                Err(PubSubReadError::Lost)
379            }
380            Err(other) => Err(other),
381        }
382    }
383}
384
385/// Lay out an empty pub/sub ring: the whole region zeroed (a zero
386/// slot sequence is the never-published state and `head` starts at
387/// zero), the geometry fields, then the magic, last, because
388/// attachers spin on it. `ptr` must address at least
389/// `pubsub_ring_file_size(capacity)` writable bytes.
390fn init_pubsub_layout(ptr: *mut u8, capacity: usize) {
391    unsafe {
392        std::ptr::write_bytes(ptr, 0, pubsub_ring_file_size(capacity));
393        let header_ptr = ptr as *mut PubSubHeader;
394        (*header_ptr).capacity = capacity as u64;
395        (*header_ptr).slot_size = PUBSUB_SLOT_SIZE as u64;
396        std::ptr::write_volatile(&raw mut (*header_ptr).magic, PUBSUB_MAGIC);
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn tmp_pos(name: &str) -> std::path::PathBuf {
405        let mut p = std::env::temp_dir();
406        let pid = std::process::id();
407        let nonce = std::time::SystemTime::now()
408            .duration_since(std::time::UNIX_EPOCH)
409            .map(|d| d.as_nanos())
410            .unwrap_or(0);
411        p.push(format!("pubsub_pos_{pid}_{nonce}_{name}.bin"));
412        p
413    }
414
415    #[test]
416    fn publish_then_read_at() {
417        let ring = PubSubRing::create_anon(8).expect("create");
418        let payload = [0xABu8; PUBSUB_PAYLOAD_BYTES];
419        let pos = ring.publish(&payload);
420        assert_eq!(pos, 0);
421        assert_eq!(ring.head(), 1);
422
423        let mut out = [0u8; PUBSUB_PAYLOAD_BYTES];
424        ring.read_at(0, &mut out).expect("read at 0");
425        assert_eq!(out, payload);
426    }
427
428    /// A second create attaches with published slots and the head in
429    /// place; reset is what strips them.
430    #[test]
431    fn second_create_attaches_and_keeps_published() {
432        let p = tmp_pos("attach");
433        let ring = PubSubRing::create(&p, 8).expect("create");
434        let payload = [0x77u8; PUBSUB_PAYLOAD_BYTES];
435        ring.publish(&payload);
436
437        let ring2 = PubSubRing::create(&p, 8).expect("second create");
438        assert_eq!(ring2.head(), 1, "attach lost the head");
439        let mut out = [0u8; PUBSUB_PAYLOAD_BYTES];
440        ring2.read_at(0, &mut out).expect("read after attach");
441        assert_eq!(out, payload, "attach lost a published slot");
442        assert!(PubSubRing::create(&p, 4).is_err());
443
444        // Windows refuses to truncate a mapped file, so every handle
445        // goes before the reset.
446        drop(ring);
447        drop(ring2);
448        let fresh = PubSubRing::reset(&p, 8).expect("reset");
449        assert_eq!(fresh.head(), 0, "reset kept the head");
450        assert_eq!(fresh.read_at(0, &mut out), Err(PubSubReadError::Pending),
451                   "reset kept a published slot");
452        drop(fresh);
453        std::fs::remove_file(&p).ok();
454    }
455
456    #[test]
457    fn read_pending_for_unpublished_position() {
458        let ring = PubSubRing::create_anon(8).expect("create");
459        let mut out = [0u8; PUBSUB_PAYLOAD_BYTES];
460        assert_eq!(ring.read_at(0, &mut out), Err(PubSubReadError::Pending));
461    }
462
463    #[test]
464    fn read_lost_for_overwritten_position() {
465        let ring = PubSubRing::create_anon(4).expect("create");
466        // Publish 8 items into a 4-slot ring; position 0 gets
467        // overwritten by position 4.
468        for i in 0u64..8 {
469            let mut payload = [0u8; PUBSUB_PAYLOAD_BYTES];
470            payload[..8].copy_from_slice(&i.to_le_bytes());
471            ring.publish(&payload);
472        }
473        let mut out = [0u8; PUBSUB_PAYLOAD_BYTES];
474        // Position 0's slot has been overwritten by position 4
475        // (both map to slot index 0). Reading at 0 sees seq=5 > 1.
476        assert_eq!(ring.read_at(0, &mut out), Err(PubSubReadError::Lost));
477        // Position 7 (just published) still has the right seq.
478        ring.read_at(7, &mut out).expect("read at 7");
479        assert_eq!(&out[..8], &7u64.to_le_bytes());
480    }
481
482    #[test]
483    fn two_subscribers_independent_positions() {
484        let ring = Arc::new(PubSubRing::create_anon(16).expect("create"));
485        for i in 0u64..5 {
486            let mut payload = [0u8; PUBSUB_PAYLOAD_BYTES];
487            payload[..8].copy_from_slice(&i.to_le_bytes());
488            ring.publish(&payload);
489        }
490
491        let pos_a = SubscriberPosition::create(tmp_pos("sub_a"), 0).expect("pos a");
492        let pos_b = SubscriberPosition::create(tmp_pos("sub_b"), 0).expect("pos b");
493        let sub_a = PubSubSubscriber::new(ring.clone(), pos_a);
494        let sub_b = PubSubSubscriber::new(ring.clone(), pos_b);
495
496        // Both subs read independently; their positions stay separate.
497        let mut buf = [0u8; PUBSUB_PAYLOAD_BYTES];
498        sub_a.try_next(&mut buf).expect("a 0"); assert_eq!(&buf[..8], &0u64.to_le_bytes());
499        sub_a.try_next(&mut buf).expect("a 1"); assert_eq!(&buf[..8], &1u64.to_le_bytes());
500        // Sub B is still at 0.
501        sub_b.try_next(&mut buf).expect("b 0"); assert_eq!(&buf[..8], &0u64.to_le_bytes());
502        assert_eq!(sub_a.position(), 2);
503        assert_eq!(sub_b.position(), 1);
504    }
505
506    #[test]
507    fn subscriber_skips_past_lost_items() {
508        let ring = Arc::new(PubSubRing::create_anon(4).expect("create"));
509        let pos = SubscriberPosition::create(tmp_pos("lost"), 0).expect("pos");
510        let sub = PubSubSubscriber::new(ring.clone(), pos);
511
512        // Publish 8 items into 4 slots -> positions 0..4 overwritten.
513        for i in 0u64..8 {
514            let mut payload = [0u8; PUBSUB_PAYLOAD_BYTES];
515            payload[..8].copy_from_slice(&i.to_le_bytes());
516            ring.publish(&payload);
517        }
518
519        // Subscriber at position 0 reads -> sees Lost.
520        let mut buf = [0u8; PUBSUB_PAYLOAD_BYTES];
521        assert_eq!(sub.try_next(&mut buf), Err(PubSubReadError::Lost));
522        // Subscriber's position is now at ring head = 8.
523        assert_eq!(sub.position(), 8);
524        // Next read is Pending (no item at position 8 yet).
525        assert_eq!(sub.try_next(&mut buf), Err(PubSubReadError::Pending));
526    }
527}