Skip to main content

subetha_cxc/
capacity_broadcast_ring.rs

1//! `CapacityBroadcastRing`: runtime-resizable wrapper around
2//! [`SharedBroadcastRing`] that adds the capacity-axis morph to
3//! the broadcast (1P/NC fan-out) primitive.
4//!
5//! Sibling of [`CapacityAdaptiveRing`](crate::CapacityAdaptiveRing)
6//! which morphs the SPSC / MPSC / MPMC / Vyukov family.
7//! `CapacityBroadcastRing` morphs SharedBroadcastRing's slot count
8//! at runtime under the same stale-list invariant: producers only
9//! ever write to the active backing; subscribers walk the stale
10//! list oldest-first before falling through to active, so the
11//! per-subscriber position state baked into each
12//! `SharedBroadcastRing` continues to advance through the stale
13//! ring until that backing is fully drained by every subscriber.
14//!
15//! # Per-subscriber position tracking
16//!
17//! Broadcast's distinguishing property: every registered consumer
18//! reads every slot independently. Each `SharedBroadcastRing`
19//! already tracks per-consumer positions in its header
20//! (`consumer_seqs[MAX_CONSUMERS]`), so the per-stale-ring
21//! position tracker the capacity-morph wrapper needs is provided
22//! by the underlying primitive at zero extra cost. Consumers walk
23//! every stale backing at their own pace; a stale entry is
24//! reclaimed when [`SharedBroadcastRing::is_fully_drained`]
25//! returns true (every active consumer's seq has caught up to the
26//! frozen producer seq).
27//!
28//! # Consumer registration model
29//!
30//! The wrapper tracks a monotonic consumer count (`n_consumers`)
31//! and mirrors that many `register_consumer()` calls onto each
32//! new backing at morph time. Consumers register in
33//! `0..n_consumers` order and never unregister - this matches the
34//! capacity-morph use case (subscribers join, capacity grows /
35//! shrinks under load, no subscriber churn). A future iteration
36//! could carry an explicit per-slot bitmap to support
37//! unregister-and-rejoin without renumbering, but the simple
38//! grow-only model fits the in-scope tests.
39
40use std::path::{Path, PathBuf};
41use std::sync::Arc;
42use std::sync::atomic::{AtomicU64, Ordering};
43
44use arc_swap::ArcSwap;
45use parking_lot::Mutex;
46
47use crate::shared_broadcast_ring::{BroadcastError, SharedBroadcastRing};
48
49/// Errors returned by capacity-morph operations on a broadcast
50/// ring.
51#[derive(Debug)]
52pub enum BroadcastCapacityMorphError {
53    /// Target capacity is not a power of two, or less than 2.
54    InvalidCapacity,
55    /// Underlying broadcast ring allocation failed during the
56    /// morph. The active backing is unchanged.
57    Broadcast(BroadcastError),
58    /// I/O error during file / shmfs backing creation.
59    Io(std::io::Error),
60}
61
62impl From<BroadcastError> for BroadcastCapacityMorphError {
63    fn from(e: BroadcastError) -> Self { Self::Broadcast(e) }
64}
65
66impl From<std::io::Error> for BroadcastCapacityMorphError {
67    fn from(e: std::io::Error) -> Self { Self::Io(e) }
68}
69
70impl std::fmt::Display for BroadcastCapacityMorphError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::InvalidCapacity => write!(f, "capacity must be pow2 >= 2"),
74            Self::Broadcast(e) => write!(f, "broadcast ring error during morph: {e:?}"),
75            Self::Io(e) => write!(f, "io error during morph: {e}"),
76        }
77    }
78}
79
80impl std::error::Error for BroadcastCapacityMorphError {}
81
82/// Runtime-resizable broadcast ring. See module-level docs for
83/// the morph protocol. Hot-path `try_push` / `try_recv` perform
84/// one ArcSwap load (~5-10 ns) and delegate to the active backing
85/// (try_push) or walk the snapshot's stale list then fall through
86/// to active (try_recv). No mutex acquisition on the steady-state
87/// path; the morph swaps a fresh `BroadcastRingState` atomically.
88pub struct CapacityBroadcastRing {
89    /// Combined active + stale list behind a single ArcSwap. The
90    /// snapshot's atomicity gives FIFO-correct combined view
91    /// across a morph; producers go straight to `state.active`,
92    /// subscribers walk `state.stale` then fall through.
93    state: ArcSwap<BroadcastRingState>,
94    /// Bumped on every successful morph for caller-polled pin
95    /// invalidation.
96    pin_generation: AtomicU64,
97    /// Cached observable capacity; tracks the active backing.
98    capacity_atom: AtomicU64,
99    /// Locale source for morph-allocated backings.
100    backing_source: BroadcastBackingSource,
101    /// Monotonic morph counter. Used to disambiguate file paths /
102    /// shm names so morphs cycling through the same capacity do
103    /// not collide on the prior backing's name.
104    morph_seq: AtomicU64,
105    /// Monotonic count of consumers registered against the wrapper.
106    /// The morph mirrors this many registrations onto each new
107    /// backing in order so consumer_idx assignments stay in
108    /// lockstep across morphs. Grow-only by design.
109    n_consumers: AtomicU64,
110    /// Serialises concurrent `morph_capacity_to` callers.
111    morph_lock: Mutex<()>,
112    /// One-slot warm cache: a fully constructed backing at a
113    /// predicted capacity, built off the morph lock by
114    /// [`prewarm`](Self::prewarm). Same design as
115    /// `CapacityAdaptiveRing`'s warm cache.
116    warm: Mutex<Option<(usize, Arc<SharedBroadcastRing>)>>,
117    /// Successful warm-cache hits consumed by `morph_capacity_to`.
118    warm_hits: AtomicU64,
119}
120
121unsafe impl Send for CapacityBroadcastRing {}
122unsafe impl Sync for CapacityBroadcastRing {}
123
124/// Atomic snapshot of the broadcast ring's active backing +
125/// stale list. Same shape as `CapacityAdaptiveRing::RingState`;
126/// the wrapper's hot path performs one ArcSwap load to capture
127/// both simultaneously, eliminating the mutex acquisition that
128/// the prior design needed for FIFO-correctness combined-snapshot.
129struct BroadcastRingState {
130    active: Arc<SharedBroadcastRing>,
131    stale: Vec<Arc<SharedBroadcastRing>>,
132}
133
134/// Locale source for capacity-morph-allocated broadcast backings.
135enum BroadcastBackingSource {
136    Anon,
137    File(PathBuf),
138    Shm(String),
139}
140
141impl CapacityBroadcastRing {
142    /// Anon (in-process) capacity-adaptive broadcast ring.
143    pub fn create_anon(
144        initial_capacity: usize,
145    ) -> Result<Self, BroadcastCapacityMorphError> {
146        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
147            return Err(BroadcastCapacityMorphError::InvalidCapacity);
148        }
149        let ring = SharedBroadcastRing::create_anon(initial_capacity)?;
150        Ok(Self {
151            state: ArcSwap::from(Arc::new(BroadcastRingState {
152                active: Arc::new(ring),
153                stale: Vec::new(),
154            })),
155            pin_generation: AtomicU64::new(0),
156            capacity_atom: AtomicU64::new(initial_capacity as u64),
157            backing_source: BroadcastBackingSource::Anon,
158            morph_seq: AtomicU64::new(0),
159            n_consumers: AtomicU64::new(0),
160            morph_lock: Mutex::new(()),
161            warm: Mutex::new(None),
162            warm_hits: AtomicU64::new(0),
163        })
164    }
165
166    /// File-backed capacity-adaptive broadcast ring. New backings
167    /// allocate at `{base}.cap_{N}_g{morph_seq}.bin`.
168    pub fn create(
169        base_path: impl AsRef<Path>,
170        initial_capacity: usize,
171    ) -> Result<Self, BroadcastCapacityMorphError> {
172        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
173            return Err(BroadcastCapacityMorphError::InvalidCapacity);
174        }
175        let base = base_path.as_ref().to_path_buf();
176        let path = path_for_capacity_seq(&base, initial_capacity, 0);
177        let ring = SharedBroadcastRing::create(&path, initial_capacity)?;
178        Ok(Self {
179            state: ArcSwap::from(Arc::new(BroadcastRingState {
180                active: Arc::new(ring),
181                stale: Vec::new(),
182            })),
183            pin_generation: AtomicU64::new(0),
184            capacity_atom: AtomicU64::new(initial_capacity as u64),
185            backing_source: BroadcastBackingSource::File(base),
186            morph_seq: AtomicU64::new(1),
187            n_consumers: AtomicU64::new(0),
188            morph_lock: Mutex::new(()),
189            warm: Mutex::new(None),
190            warm_hits: AtomicU64::new(0),
191        })
192    }
193
194    /// ShmFs (named shared memory) capacity-adaptive broadcast
195    /// ring. New backings allocate at `{prefix}_cap_{N}_g{seq}`.
196    pub fn create_shmfs(
197        name_prefix: &str,
198        initial_capacity: usize,
199    ) -> Result<Self, BroadcastCapacityMorphError> {
200        if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
201            return Err(BroadcastCapacityMorphError::InvalidCapacity);
202        }
203        let name = format!("{name_prefix}_cap_{initial_capacity}_g0");
204        let total = crate::shared_broadcast_ring::broadcast_file_size(initial_capacity);
205        let shm = crate::shm_file::ShmFile::create_or_open_named(&name, total)?;
206        let ring = SharedBroadcastRing::create_from_shm(shm, initial_capacity)?;
207        Ok(Self {
208            state: ArcSwap::from(Arc::new(BroadcastRingState {
209                active: Arc::new(ring),
210                stale: Vec::new(),
211            })),
212            pin_generation: AtomicU64::new(0),
213            capacity_atom: AtomicU64::new(initial_capacity as u64),
214            backing_source: BroadcastBackingSource::Shm(name_prefix.to_owned()),
215            morph_seq: AtomicU64::new(1),
216            n_consumers: AtomicU64::new(0),
217            morph_lock: Mutex::new(()),
218            warm: Mutex::new(None),
219            warm_hits: AtomicU64::new(0),
220        })
221    }
222
223    /// Current capacity of the active backing.
224    pub fn current_capacity(&self) -> usize {
225        self.capacity_atom.load(Ordering::Acquire) as usize
226    }
227
228    /// Current pin generation.
229    pub fn pin_generation(&self) -> u64 {
230        self.pin_generation.load(Ordering::Acquire)
231    }
232
233    /// Register a consumer against the active backing. Returns
234    /// the assigned consumer_idx (matches what the active backing
235    /// itself returned). The wrapper tracks the count so each
236    /// subsequent morph mirrors the same number of registrations
237    /// in order onto the new backing. Consumers join "from now"
238    /// against existing stale backings: they get no slot there,
239    /// so try_recv against those backings returns InvalidConsumer
240    /// which is treated as Empty by the wrapper's stale-walk.
241    pub fn register_consumer(&self) -> Result<usize, BroadcastError> {
242        let idx = self.state.load().active.register_consumer()?;
243        self.n_consumers.fetch_add(1, Ordering::AcqRel);
244        Ok(idx)
245    }
246
247    /// Hot-path push. One ArcSwap load + the active backing's
248    /// native `try_push`.
249    #[inline]
250    pub fn try_push(&self, payload: &[u8]) -> Result<(), BroadcastError> {
251        self.state.load().active.try_push(payload)
252    }
253
254    /// Hot-path recv. Walks the stale list oldest-first; falls
255    /// through to active when every stale entry returns empty or
256    /// the consumer has no slot in that stale.
257    ///
258    /// FIFO ordering invariant: one ArcSwap load gives a
259    /// consistent snapshot of BOTH stale and active. A concurrent
260    /// morph either fully precedes or fully follows this load -
261    /// it never slips between two separate observations.
262    #[inline]
263    pub fn try_recv(
264        &self,
265        consumer_idx: usize,
266        out: &mut [u8],
267    ) -> Result<usize, BroadcastError> {
268        // Per-stale-ring spin discipline (FIFO correctness):
269        // see CapacityAdaptiveRing::try_recv for the full rationale.
270        // For broadcast specifically: SharedBroadcastRing.try_recv
271        // returns `Err(Empty)` when this consumer's seq >= producer
272        // seq, but a producer mid-write under the SeqLock is also
273        // observable to the consumer as `Empty` until the version
274        // commits. `is_fully_drained()` checks whether every active
275        // consumer's seq has caught up to producer_seq - the
276        // "no mid-claim" condition - so spin until either we get
277        // an item or the stale ring is fully drained for this
278        // consumer.
279        let state = self.state.load();
280        for ring in &state.stale {
281            loop {
282                match ring.try_recv(consumer_idx, out) {
283                    Ok(n) => return Ok(n),
284                    Err(_) => {
285                        if ring.lag(consumer_idx) == 0 {
286                            break;
287                        }
288                        std::hint::spin_loop();
289                    }
290                }
291            }
292        }
293        state.active.try_recv(consumer_idx, out)
294    }
295
296    /// Morph the broadcast ring's capacity to `new_capacity`.
297    pub fn morph_capacity_to(
298        &self,
299        new_capacity: usize,
300    ) -> Result<(), BroadcastCapacityMorphError> {
301        let _morph_guard = self.morph_lock.lock();
302
303        if !new_capacity.is_power_of_two() || new_capacity < 2 {
304            return Err(BroadcastCapacityMorphError::InvalidCapacity);
305        }
306
307        let old_state = self.state.load_full();
308        let old = Arc::clone(&old_state.active);
309        let old_capacity = self.capacity_atom.load(Ordering::Acquire) as usize;
310        if old_capacity == new_capacity {
311            return Ok(());
312        }
313
314        // Warm-cache probe: a prediction matching the morph target
315        // skips allocation entirely; a mismatch stays cached and
316        // the cold path runs unchanged.
317        let warm_hit = {
318            let mut warm = self.warm.lock();
319            warm.take_if(|(cap, _)| *cap == new_capacity)
320        };
321        let new = match warm_hit {
322            Some((_, ring)) => {
323                self.warm_hits.fetch_add(1, Ordering::Relaxed);
324                ring
325            }
326            None => self.build_backing(new_capacity)?,
327        };
328
329        // Mirror n_consumers registrations onto the new backing
330        // in order so consumer_idx assignments stay in lockstep.
331        // Surfacing failures (NoConsumerSlot) loudly via ? so
332        // the morph fails fast if the new backing is undersized.
333        let n = self.n_consumers.load(Ordering::Acquire) as usize;
334        for _ in 0..n {
335            new.register_consumer()?;
336        }
337
338        self.pin_generation.fetch_add(1, Ordering::AcqRel);
339
340        // Build the new state in one shot: prune fully-drained
341        // stale entries, append the prior active, publish
342        // atomically. Subscribers reading via `self.state.load()`
343        // see either the pre-morph snapshot or the post-morph
344        // snapshot, never a half-state.
345        let mut new_stale: Vec<Arc<SharedBroadcastRing>> = old_state
346            .stale
347            .iter()
348            .filter(|r| !r.is_fully_drained())
349            .cloned()
350            .collect();
351        new_stale.push(old);
352        let new_state = BroadcastRingState { active: new, stale: new_stale };
353        self.state.store(Arc::new(new_state));
354        self.capacity_atom
355            .store(new_capacity as u64, Ordering::Release);
356
357        Ok(())
358    }
359
360    /// Construct a fresh backing at `capacity`, at the wrapper's
361    /// locale, with a unique per-build name. Shared by the cold
362    /// morph path and [`prewarm`](Self::prewarm).
363    fn build_backing(
364        &self,
365        capacity: usize,
366    ) -> Result<Arc<SharedBroadcastRing>, BroadcastCapacityMorphError> {
367        let seq = self.morph_seq.fetch_add(1, Ordering::AcqRel);
368        let ring = match &self.backing_source {
369            BroadcastBackingSource::Anon => {
370                SharedBroadcastRing::create_anon(capacity)?
371            }
372            BroadcastBackingSource::File(base) => {
373                let path = path_for_capacity_seq(base, capacity, seq);
374                SharedBroadcastRing::create(&path, capacity)?
375            }
376            BroadcastBackingSource::Shm(prefix) => {
377                let name = format!("{prefix}_cap_{capacity}_g{seq}");
378                let total = crate::shared_broadcast_ring::broadcast_file_size(capacity);
379                let shm = crate::shm_file::ShmFile::create_or_open_named(&name, total)?;
380                SharedBroadcastRing::create_from_shm(shm, capacity)?
381            }
382        };
383        Ok(Arc::new(ring))
384    }
385
386    /// Speculatively build a backing at `capacity` into the
387    /// one-slot warm cache, off the morph lock's critical path.
388    /// The next `morph_capacity_to(capacity)` consumes it and
389    /// skips allocation. Re-prewarming the cached capacity is a
390    /// no-op; a different capacity replaces the slot.
391    pub fn prewarm(&self, capacity: usize) -> Result<(), BroadcastCapacityMorphError> {
392        if !capacity.is_power_of_two() || capacity < 2 {
393            return Err(BroadcastCapacityMorphError::InvalidCapacity);
394        }
395        if self.warm.lock().as_ref().map(|(c, _)| *c) == Some(capacity) {
396            return Ok(());
397        }
398        let ring = self.build_backing(capacity)?;
399        *self.warm.lock() = Some((capacity, ring));
400        Ok(())
401    }
402
403    /// Capacity currently held in the warm cache, if any.
404    pub fn warm_capacity(&self) -> Option<usize> {
405        self.warm.lock().as_ref().map(|(c, _)| *c)
406    }
407
408    /// Number of morphs that consumed a warm-cache prediction.
409    pub fn warm_hits(&self) -> u64 {
410        self.warm_hits.load(Ordering::Relaxed)
411    }
412
413    /// Drop any cached prediction, releasing its memory (and its
414    /// file / shm region for non-anon locales).
415    pub fn clear_warm(&self) {
416        *self.warm.lock() = None;
417    }
418
419    /// Pin the current capacity backing for a hot loop.
420    pub fn pin_current_capacity(&self) -> PinnedBroadcastCapacity<'_> {
421        let captured_gen = self.pin_generation.load(Ordering::Acquire);
422        let ring = Arc::clone(&self.state.load().active);
423        let capacity = self.capacity_atom.load(Ordering::Acquire) as usize;
424        PinnedBroadcastCapacity {
425            parent: self,
426            pinned_generation: captured_gen,
427            ring,
428            capacity,
429            _not_sync: std::marker::PhantomData,
430        }
431    }
432
433    /// Direct access to the active [`SharedBroadcastRing`].
434    pub fn ring_handle(&self) -> Arc<SharedBroadcastRing> {
435        Arc::clone(&self.state.load().active)
436    }
437}
438
439/// Pinned snapshot of a `CapacityBroadcastRing`'s current capacity
440/// backing.
441pub struct PinnedBroadcastCapacity<'a> {
442    parent: &'a CapacityBroadcastRing,
443    pinned_generation: u64,
444    ring: Arc<SharedBroadcastRing>,
445    capacity: usize,
446    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
447}
448
449impl<'a> PinnedBroadcastCapacity<'a> {
450    pub fn is_still_valid(&self) -> bool {
451        self.parent.pin_generation.load(Ordering::Acquire) == self.pinned_generation
452    }
453    pub fn capacity(&self) -> usize { self.capacity }
454    pub fn generation(&self) -> u64 { self.pinned_generation }
455    pub fn ring(&self) -> &Arc<SharedBroadcastRing> { &self.ring }
456}
457
458/// Compose the per-morph broadcast file path.
459fn path_for_capacity_seq(base: &Path, capacity: usize, seq: u64) -> PathBuf {
460    let mut s = base.as_os_str().to_owned();
461    s.push(format!(".cap_{capacity}_g{seq}.bin"));
462    PathBuf::from(s)
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn prewarm_hit_consumes_cache_and_broadcast_works() {
471        let ring = CapacityBroadcastRing::create_anon(64).unwrap();
472        let idx = ring.register_consumer().unwrap();
473        ring.try_push(&7u64.to_le_bytes()).unwrap();
474
475        ring.prewarm(256).unwrap();
476        assert_eq!(ring.warm_capacity(), Some(256));
477        ring.morph_capacity_to(256).unwrap();
478        assert_eq!(ring.warm_hits(), 1, "morph must consume the prediction");
479        assert_eq!(ring.warm_capacity(), None, "the slot is one-shot");
480        assert_eq!(ring.current_capacity(), 256);
481
482        // In-flight item drains from the stale backing; a fresh
483        // push lands on the warm backing and drains too.
484        ring.try_push(&9u64.to_le_bytes()).unwrap();
485        let mut out = [0u8; 64];
486        let n = ring.try_recv(idx, &mut out).unwrap();
487        assert!(n >= 8);
488        assert_eq!(u64::from_le_bytes(out[..8].try_into().unwrap()), 7);
489        let n = ring.try_recv(idx, &mut out).unwrap();
490        assert!(n >= 8);
491        assert_eq!(u64::from_le_bytes(out[..8].try_into().unwrap()), 9);
492    }
493
494    #[test]
495    fn prewarm_mismatch_stays_cached() {
496        let ring = CapacityBroadcastRing::create_anon(64).unwrap();
497        ring.prewarm(512).unwrap();
498        ring.morph_capacity_to(256).unwrap();
499        assert_eq!(ring.warm_hits(), 0);
500        assert_eq!(ring.warm_capacity(), Some(512));
501        ring.morph_capacity_to(512).unwrap();
502        assert_eq!(ring.warm_hits(), 1);
503        assert_eq!(ring.warm_capacity(), None);
504    }
505
506    #[test]
507    fn prewarm_rejects_non_pow2_and_clear_drops() {
508        let ring = CapacityBroadcastRing::create_anon(64).unwrap();
509        assert!(matches!(
510            ring.prewarm(100),
511            Err(BroadcastCapacityMorphError::InvalidCapacity)
512        ));
513        ring.prewarm(128).unwrap();
514        ring.clear_warm();
515        assert_eq!(ring.warm_capacity(), None);
516    }
517}