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.
128    pub fn create(path: impl AsRef<Path>, capacity: usize) -> std::io::Result<Self> {
129        assert!(capacity.is_power_of_two() && capacity >= 2,
130                "capacity must be pow2 >= 2");
131        let total = pubsub_ring_file_size(capacity);
132        let file = OpenOptions::new()
133            .read(true).write(true).create(true).truncate(true)
134            .open(path.as_ref())?;
135        file.set_len(total as u64)?;
136        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
137        let raw_ptr = mmap.as_mut_ptr();
138        init_pubsub_layout(raw_ptr, capacity);
139        Ok(Self {
140            _backing: PubSubBacking::File(file, mmap),
141            raw_ptr, capacity,
142        })
143    }
144
145    /// Open an existing file-backed pub/sub ring. Validates magic
146    /// + capacity.
147    pub fn open(path: impl AsRef<Path>, expected_capacity: usize) -> std::io::Result<Self> {
148        let total = pubsub_ring_file_size(expected_capacity);
149        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
150        if (file.metadata()?.len() as usize) < total {
151            return Err(std::io::Error::new(
152                std::io::ErrorKind::InvalidData,
153                "pubsub file too small for expected capacity",
154            ));
155        }
156        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
157        let raw_ptr = mmap.as_mut_ptr();
158        let header = unsafe { &*(raw_ptr as *const PubSubHeader) };
159        if header.magic != PUBSUB_MAGIC
160            || header.capacity != expected_capacity as u64
161            || header.slot_size != PUBSUB_SLOT_SIZE as u64
162        {
163            return Err(std::io::Error::new(
164                std::io::ErrorKind::InvalidData,
165                "pubsub file layout mismatch",
166            ));
167        }
168        Ok(Self {
169            _backing: PubSubBacking::File(file, mmap),
170            raw_ptr, capacity: expected_capacity,
171        })
172    }
173
174    /// Construct a fresh pub/sub ring on top of a named
175    /// RAM-resident shared-memory backing.
176    pub fn create_from_shm(
177        mut shm: crate::shm_file::ShmFile,
178        capacity: usize,
179    ) -> std::io::Result<Self> {
180        assert!(capacity.is_power_of_two() && capacity >= 2,
181                "capacity must be pow2 >= 2");
182        let total = pubsub_ring_file_size(capacity);
183        if shm.len() < total {
184            return Err(std::io::Error::new(
185                std::io::ErrorKind::InvalidData,
186                "shm region too small for pubsub layout",
187            ));
188        }
189        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
190        init_pubsub_layout(raw_ptr, capacity);
191        Ok(Self {
192            _backing: PubSubBacking::Shm(shm),
193            raw_ptr, capacity,
194        })
195    }
196
197    /// Open an existing named ShmFs-backed pub/sub ring.
198    pub fn open_from_shm(
199        mut shm: crate::shm_file::ShmFile,
200        expected_capacity: usize,
201    ) -> std::io::Result<Self> {
202        let total = pubsub_ring_file_size(expected_capacity);
203        if shm.len() < total {
204            return Err(std::io::Error::new(
205                std::io::ErrorKind::InvalidData,
206                "shm region too small for expected capacity",
207            ));
208        }
209        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
210        let header = unsafe { &*(raw_ptr as *const PubSubHeader) };
211        if header.magic != PUBSUB_MAGIC
212            || header.capacity != expected_capacity as u64
213            || header.slot_size != PUBSUB_SLOT_SIZE as u64
214        {
215            return Err(std::io::Error::new(
216                std::io::ErrorKind::InvalidData,
217                "shm layout mismatch",
218            ));
219        }
220        Ok(Self {
221            _backing: PubSubBacking::Shm(shm),
222            raw_ptr, capacity: expected_capacity,
223        })
224    }
225
226    fn header(&self) -> &PubSubHeader {
227        unsafe { &*(self.raw_ptr as *const PubSubHeader) }
228    }
229
230    fn slot(&self, idx: usize) -> &PubSubSlot {
231        let slots_base = unsafe {
232            self.raw_ptr.add(std::mem::size_of::<PubSubHeader>())
233        };
234        let masked = idx & (self.capacity - 1);
235        unsafe { &*(slots_base.add(masked * PUBSUB_SLOT_SIZE) as *const PubSubSlot) }
236    }
237
238    /// Producer's published head. Equals the next position that
239    /// will be assigned to a `publish` call.
240    pub fn head(&self) -> u64 {
241        self.header().head.load(Ordering::Acquire)
242    }
243
244    /// Capacity in slots (always a power of 2).
245    pub fn capacity(&self) -> usize { self.capacity }
246
247    /// Publish one payload. Returns the absolute position assigned
248    /// to this item. Caller MUST be the single producer.
249    pub fn publish(&self, payload: &[u8]) -> u64 {
250        assert!(payload.len() <= PUBSUB_PAYLOAD_BYTES);
251        let header = self.header();
252        let head = header.head.load(Ordering::Relaxed);
253        let slot = self.slot(head as usize);
254        // Write payload first.
255        unsafe {
256            let dst = (*slot.payload.get()).as_mut_ptr();
257            std::ptr::copy_nonoverlapping(payload.as_ptr(), dst, payload.len());
258            if payload.len() < PUBSUB_PAYLOAD_BYTES {
259                std::ptr::write_bytes(
260                    dst.add(payload.len()), 0,
261                    PUBSUB_PAYLOAD_BYTES - payload.len(),
262                );
263            }
264        }
265        // Release-store the slot sequence so subscribers see the
266        // payload BEFORE the sequence advances.
267        slot.sequence.store(head + 1, Ordering::Release);
268        // Advance the header head; subscribers walking the head
269        // pointer see the new item.
270        header.head.store(head + 1, Ordering::Release);
271        head
272    }
273
274    /// Read the payload at absolute `position`. The subscriber
275    /// supplies a buffer of at least [`PUBSUB_PAYLOAD_BYTES`].
276    ///
277    /// Returns `Ok(())` on success (payload copied into `out`),
278    /// `Err(Pending)` when the position has not been published yet,
279    /// `Err(Lost)` when the slot has wrapped past `position`.
280    pub fn read_at(
281        &self,
282        position: u64,
283        out: &mut [u8],
284    ) -> Result<(), PubSubReadError> {
285        assert!(out.len() >= PUBSUB_PAYLOAD_BYTES);
286        let slot = self.slot(position as usize);
287        let observed_seq = slot.sequence.load(Ordering::Acquire);
288        let expected_seq = position + 1;
289        if observed_seq == expected_seq {
290            unsafe {
291                let src = (*slot.payload.get()).as_ptr();
292                std::ptr::copy_nonoverlapping(
293                    src, out.as_mut_ptr(), PUBSUB_PAYLOAD_BYTES,
294                );
295            }
296            Ok(())
297        } else if observed_seq > expected_seq {
298            Err(PubSubReadError::Lost)
299        } else {
300            Err(PubSubReadError::Pending)
301        }
302    }
303}
304
305/// Subscriber-side helper that holds a [`SubscriberPosition`] and
306/// pulls items from a `PubSubRing` in order.
307pub struct PubSubSubscriber {
308    ring: Arc<PubSubRing>,
309    position: SubscriberPosition,
310}
311
312impl PubSubSubscriber {
313    /// Wrap a ring + position into a subscriber.
314    pub fn new(ring: Arc<PubSubRing>, position: SubscriberPosition) -> Self {
315        Self { ring, position }
316    }
317
318    /// Current absolute position this subscriber has consumed up to.
319    pub fn position(&self) -> u64 { self.position.get() }
320
321    /// Ring this subscriber is attached to.
322    pub fn ring(&self) -> &Arc<PubSubRing> { &self.ring }
323
324    /// Advance the subscriber's position by `n` without reading.
325    /// Used by callers that want to skip items deliberately
326    /// (sampled subscriptions, late-join skip-ahead).
327    pub fn skip(&self, n: u64) -> u64 {
328        self.position.advance(n)
329    }
330
331    /// Try to read the next item. On success, advances the
332    /// subscriber's position by 1. On `Pending`, leaves the
333    /// position alone. On `Lost`, advances the position to the
334    /// ring's current head (skipping past the gap).
335    pub fn try_next(&self, out: &mut [u8]) -> Result<(), PubSubReadError> {
336        let pos = self.position.get();
337        match self.ring.read_at(pos, out) {
338            Ok(()) => {
339                self.position.advance(1);
340                Ok(())
341            }
342            Err(PubSubReadError::Lost) => {
343                // Skip past the gap by jumping to the current head.
344                self.position.set(self.ring.head());
345                Err(PubSubReadError::Lost)
346            }
347            Err(other) => Err(other),
348        }
349    }
350}
351
352fn init_pubsub_layout(ptr: *mut u8, capacity: usize) {
353    let header_ptr = ptr as *mut PubSubHeader;
354    unsafe {
355        std::ptr::write(header_ptr, PubSubHeader {
356            magic: PUBSUB_MAGIC,
357            capacity: capacity as u64,
358            slot_size: PUBSUB_SLOT_SIZE as u64,
359            _pad_meta: [0; 64 - 24],
360            head: AtomicU64::new(0),
361            _pad_head: [0; 64 - 8],
362        });
363    }
364    let slots_base = unsafe { ptr.add(std::mem::size_of::<PubSubHeader>()) };
365    for i in 0..capacity {
366        let slot_ptr = unsafe { slots_base.add(i * PUBSUB_SLOT_SIZE) as *mut PubSubSlot };
367        unsafe {
368            std::ptr::write(slot_ptr, PubSubSlot {
369                sequence: AtomicU64::new(0),
370                payload: UnsafeCell::new([0; PUBSUB_PAYLOAD_BYTES]),
371            });
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn tmp_pos(name: &str) -> std::path::PathBuf {
381        let mut p = std::env::temp_dir();
382        let pid = std::process::id();
383        let nonce = std::time::SystemTime::now()
384            .duration_since(std::time::UNIX_EPOCH)
385            .map(|d| d.as_nanos())
386            .unwrap_or(0);
387        p.push(format!("pubsub_pos_{pid}_{nonce}_{name}.bin"));
388        p
389    }
390
391    #[test]
392    fn publish_then_read_at() {
393        let ring = PubSubRing::create_anon(8).expect("create");
394        let payload = [0xABu8; PUBSUB_PAYLOAD_BYTES];
395        let pos = ring.publish(&payload);
396        assert_eq!(pos, 0);
397        assert_eq!(ring.head(), 1);
398
399        let mut out = [0u8; PUBSUB_PAYLOAD_BYTES];
400        ring.read_at(0, &mut out).expect("read at 0");
401        assert_eq!(out, payload);
402    }
403
404    #[test]
405    fn read_pending_for_unpublished_position() {
406        let ring = PubSubRing::create_anon(8).expect("create");
407        let mut out = [0u8; PUBSUB_PAYLOAD_BYTES];
408        assert_eq!(ring.read_at(0, &mut out), Err(PubSubReadError::Pending));
409    }
410
411    #[test]
412    fn read_lost_for_overwritten_position() {
413        let ring = PubSubRing::create_anon(4).expect("create");
414        // Publish 8 items into a 4-slot ring; position 0 gets
415        // overwritten by position 4.
416        for i in 0u64..8 {
417            let mut payload = [0u8; PUBSUB_PAYLOAD_BYTES];
418            payload[..8].copy_from_slice(&i.to_le_bytes());
419            ring.publish(&payload);
420        }
421        let mut out = [0u8; PUBSUB_PAYLOAD_BYTES];
422        // Position 0's slot has been overwritten by position 4
423        // (both map to slot index 0). Reading at 0 sees seq=5 > 1.
424        assert_eq!(ring.read_at(0, &mut out), Err(PubSubReadError::Lost));
425        // Position 7 (just published) still has the right seq.
426        ring.read_at(7, &mut out).expect("read at 7");
427        assert_eq!(&out[..8], &7u64.to_le_bytes());
428    }
429
430    #[test]
431    fn two_subscribers_independent_positions() {
432        let ring = Arc::new(PubSubRing::create_anon(16).expect("create"));
433        for i in 0u64..5 {
434            let mut payload = [0u8; PUBSUB_PAYLOAD_BYTES];
435            payload[..8].copy_from_slice(&i.to_le_bytes());
436            ring.publish(&payload);
437        }
438
439        let pos_a = SubscriberPosition::create(tmp_pos("sub_a"), 0).expect("pos a");
440        let pos_b = SubscriberPosition::create(tmp_pos("sub_b"), 0).expect("pos b");
441        let sub_a = PubSubSubscriber::new(ring.clone(), pos_a);
442        let sub_b = PubSubSubscriber::new(ring.clone(), pos_b);
443
444        // Both subs read independently; their positions stay separate.
445        let mut buf = [0u8; PUBSUB_PAYLOAD_BYTES];
446        sub_a.try_next(&mut buf).expect("a 0"); assert_eq!(&buf[..8], &0u64.to_le_bytes());
447        sub_a.try_next(&mut buf).expect("a 1"); assert_eq!(&buf[..8], &1u64.to_le_bytes());
448        // Sub B is still at 0.
449        sub_b.try_next(&mut buf).expect("b 0"); assert_eq!(&buf[..8], &0u64.to_le_bytes());
450        assert_eq!(sub_a.position(), 2);
451        assert_eq!(sub_b.position(), 1);
452    }
453
454    #[test]
455    fn subscriber_skips_past_lost_items() {
456        let ring = Arc::new(PubSubRing::create_anon(4).expect("create"));
457        let pos = SubscriberPosition::create(tmp_pos("lost"), 0).expect("pos");
458        let sub = PubSubSubscriber::new(ring.clone(), pos);
459
460        // Publish 8 items into 4 slots -> positions 0..4 overwritten.
461        for i in 0u64..8 {
462            let mut payload = [0u8; PUBSUB_PAYLOAD_BYTES];
463            payload[..8].copy_from_slice(&i.to_le_bytes());
464            ring.publish(&payload);
465        }
466
467        // Subscriber at position 0 reads -> sees Lost.
468        let mut buf = [0u8; PUBSUB_PAYLOAD_BYTES];
469        assert_eq!(sub.try_next(&mut buf), Err(PubSubReadError::Lost));
470        // Subscriber's position is now at ring head = 8.
471        assert_eq!(sub.position(), 8);
472        // Next read is Pending (no item at position 8 yet).
473        assert_eq!(sub.try_next(&mut buf), Err(PubSubReadError::Pending));
474    }
475}