subetha_cxc/capacity_adaptive_ring.rs
1//! `CapacityAdaptiveRing`: runtime-resizable wrapper around
2//! [`AdaptiveRing`] that adds capacity-axis
3//! morphing to the polymorphic substrate.
4//!
5//! Where [`AdaptiveRing`] morphs between shapes
6//! (SPSC / MPSC / MPMC / Vyukov) at a fixed capacity and
7//! [`LocaleAdaptiveRing`](crate::LocaleAdaptiveRing) morphs between
8//! locales (Anon / File / ShmFs) at a fixed capacity,
9//! `CapacityAdaptiveRing` morphs the capacity itself: callers (or a
10//! sidecar policy) call [`morph_capacity_to`](CapacityAdaptiveRing::morph_capacity_to)
11//! with a new power-of-two slot count, the substrate allocates a
12//! fresh underlying ring at the new size, drains in-flight items
13//! from the old backing into the new one, bumps a pin generation so
14//! outstanding pinned handles invalidate, and atomically swaps the
15//! active backing.
16//!
17//! # Why a fourth axis
18//!
19//! Shape morph addresses "the number of producers / consumers
20//! changed at runtime". Locale morph addresses "we need to migrate
21//! the bytes between Anon / File / ShmFs storage tiers". Capacity
22//! morph addresses "the workload's queueing depth requirement
23//! exceeds (or falls below) the ring's slot count, and we want to
24//! grow (or shrink) without re-creating the whole ring from
25//! scratch". A sidecar that observes producer-side backpressure
26//! events or consumer-side starvation drives the morph; user code
27//! also calls `morph_capacity_to` directly when the application
28//! has out-of-band knowledge of expected load.
29//!
30//! # Constraints
31//!
32//! - **Power-of-two capacity preserved.** New capacity must be a
33//! power of two and at least 2. The slot-index calculation stays
34//! `hash & (capacity - 1)` = one AND instruction. Non-pow2 sizes
35//! return [`CapacityMorphError::InvalidCapacity`].
36//! - **Grow and shrink both succeed unconditionally.** In-flight
37//! items physically stay in the old (larger or smaller)
38//! AdaptiveRing as part of the stale list; the new capacity
39//! governs only items the producer pushes after the morph. The
40//! consumer's `try_recv` walks the stale list oldest-first then
41//! falls through to active, so every in-flight item still
42//! drains in send-order across the morph boundary. The
43//! `CannotShrinkInFlight` enum variant is preserved for API
44//! stability but is never returned by this implementation.
45//! - **Pin invalidation is caller-polled.** Outstanding
46//! [`PinnedCapacity`] handles observe the generation bump on the
47//! next `is_still_valid()` call. Hot loops sample at whatever
48//! cadence fits their latency budget; the substrate does not
49//! push.
50//! - **Morph is serialised.** A single in-flight morph at a time;
51//! concurrent callers of `morph_capacity_to` are mutex-serialised
52//! so the stale-list push and atomic active swap are atomic with
53//! respect to other morphs. Producer / consumer hot-path ops are
54//! NOT serialised against the morph - they keep dispatching via
55//! the ArcSwap pointer.
56//! - **Consumer is sole reader of every backing.** Producers only
57//! write to active; morphs never read from any backing. This is
58//! what keeps the per-backing SPSC/MPSC/MPMC contract intact
59//! across morphs - exactly one reader touches each
60//! `SpscRingCore`, even when the active backing changes.
61//!
62//! # Cross-process and cross-host
63//!
64//! For in-process and cross-thread use, the
65//! [`create_anon`](CapacityAdaptiveRing::create_anon) constructor
66//! holds the active ring in an [`ArcSwap`]; the morph is one
67//! atomic store on the active pointer plus a push onto the stale
68//! list. The consumer's `try_recv` walks the stale list before
69//! reading from active, picking up every in-flight item in
70//! send-order without ever racing the morph thread.
71//!
72//! For file-backed cross-process use,
73//! [`create`](CapacityAdaptiveRing::create) names the initial
74//! backing `{base}.cap_{N}.bin` and every morph's backing
75//! `{base}.cap_{N}_g{seq}.bin` (the [`Shm`](BackingTarget::Shm)
76//! locale uses `{prefix}_cap_{N}_g{seq}`); each backing is a full
77//! [`AdaptiveRing`], so a second process attaches to any one of them
78//! by that name through [`AdaptiveRing::open`]. The wrapper itself
79//! is per-process: a morph swaps THIS process's active pointer and
80//! never reaches into a peer, and the morph sequence is process-local
81//! (two processes each calling `morph_capacity_to` would mint
82//! different `seq` numbers, hence different files). The cross-process
83//! pattern is therefore one owner per backing: the morphing process
84//! creates each backing, the application publishes which backing is
85//! active (a shared control value the reader polls), and the reader
86//! process opens each successive backing as it becomes active,
87//! draining the prior one to empty before switching. The
88//! `capacity_morph_xproc` example drives exactly this - two
89//! processes, a shared control atomic, every item delivered once and
90//! in order across each resize.
91//!
92//! Over a QUIC / TCP bridge the ring's bytes are ferried as fixed
93//! 64-byte slots regardless of either side's ring size, so ring
94//! capacity is per-host independent: a capacity morph on one host
95//! needs no coordination with the peer for correctness. The bridges
96//! carry the ring's data on their stream; they carry no
97//! capacity-morph control signal.
98
99use std::path::{Path, PathBuf};
100use std::sync::Arc;
101use std::sync::atomic::{AtomicU64, Ordering};
102
103use arc_swap::ArcSwap;
104use parking_lot::Mutex;
105
106use crate::adaptive_ring::{AdaptiveError, AdaptiveRing, RingShape};
107use crate::ordering::{default_stamp_kind, OrderingMode, StampKind};
108use crate::shared_ring::RingError;
109
110/// Locale target for a capacity wrapper's backings. Public mirror
111/// of the construction-time locale choice, used by
112/// [`RingConfig`] to retarget the locale as part of a compound
113/// morph: subsequent backings (and prewarms) allocate at the new
114/// locale.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum BackingTarget {
117 /// In-process anonymous mmap backings.
118 Anon,
119 /// File-backed; per-morph file at `{base}.cap_{N}_g{seq}.bin`.
120 File(PathBuf),
121 /// Named-shm backings at `{prefix}_cap_{N}_g{seq}`.
122 Shm(String),
123}
124
125/// Compound morph target for [`CapacityAdaptiveRing::morph_to_config`].
126/// Every axis is optional; `None` keeps the current value. One
127/// compound morph builds ONE fresh backing at the combined target,
128/// mirrors registrations once, bumps the pin generation once, and
129/// appends the displaced active to the stale list once - however
130/// many axes changed.
131#[derive(Debug, Clone, Default)]
132pub struct RingConfig {
133 /// Target shape (`None` = keep the active backing's shape).
134 pub shape: Option<RingShape>,
135 /// Target capacity, pow2 >= 2 (`None` = keep).
136 pub capacity: Option<usize>,
137 /// Target locale (`None` = keep). Setting this retargets the
138 /// wrapper's locale for this morph AND every subsequent morph
139 /// / prewarm.
140 pub locale: Option<BackingTarget>,
141}
142
143/// Errors returned by capacity-morph operations.
144#[derive(Debug)]
145pub enum CapacityMorphError {
146 /// Target capacity is not a power of two, or is less than 2.
147 InvalidCapacity,
148 /// Never returned. Shrinks always succeed: in-flight items stay
149 /// physically in the old AdaptiveRing as part of the stale list
150 /// and the consumer drains them through `try_recv`'s stale-walk,
151 /// so there is nothing for a shrink to refuse. The variant is
152 /// part of the public enum and kept so matches stay exhaustive.
153 CannotShrinkInFlight { in_flight: usize, new_capacity: usize },
154 /// Underlying ring allocation, push, or pop failed during the
155 /// morph. The active backing is unchanged.
156 Ring(RingError),
157 /// Producer / consumer registration on the new backing
158 /// failed (e.g. mirroring more peers than the configured
159 /// max_producers / max_consumers).
160 Adaptive(AdaptiveError),
161}
162
163impl From<RingError> for CapacityMorphError {
164 fn from(e: RingError) -> Self { Self::Ring(e) }
165}
166
167impl From<AdaptiveError> for CapacityMorphError {
168 fn from(e: AdaptiveError) -> Self { Self::Adaptive(e) }
169}
170
171impl std::fmt::Display for CapacityMorphError {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 match self {
174 Self::InvalidCapacity => write!(f, "capacity must be pow2 >= 2"),
175 Self::CannotShrinkInFlight { in_flight, new_capacity } => write!(
176 f,
177 "shrink rejected: {in_flight} in-flight items exceed new capacity {new_capacity}",
178 ),
179 Self::Ring(e) => write!(f, "ring error during morph: {e:?}"),
180 Self::Adaptive(e) => write!(f, "adaptive ring error during morph: {e:?}"),
181 }
182 }
183}
184
185impl std::error::Error for CapacityMorphError {}
186
187/// Runtime-resizable adaptive ring.
188///
189/// See the module-level docs for the morph protocol. Hot-path
190/// `try_send` / `try_recv` calls hop through one ArcSwap load plus
191/// the active [`AdaptiveRing`]'s dispatch.
192pub struct CapacityAdaptiveRing {
193 /// Combined active + stale list behind a single ArcSwap. Hot-
194 /// path `try_send` / `try_recv` performs one ArcSwap load
195 /// (~5-10 ns) and delegates to the active backing's native
196 /// dispatch; no mutex acquisition in steady state. Morph
197 /// builds a fresh `RingState { active: new, stale: prune(old.stale) ++ old.active }`
198 /// and atomic-swaps it; the FIFO-correctness combined-snapshot
199 /// is given for free by the single atomic load.
200 state: ArcSwap<RingState>,
201 /// Bumped on every successful morph so outstanding
202 /// [`PinnedCapacity`] handles invalidate.
203 pin_generation: AtomicU64,
204 /// Cached observable capacity of the active backing; stays in
205 /// lockstep with `active`.
206 capacity_atom: AtomicU64,
207 /// max_producers configured at construction; mirrored on every
208 /// newly-allocated backing during a morph.
209 max_producers: usize,
210 /// max_consumers configured at construction; mirrored on every
211 /// newly-allocated backing during a morph.
212 max_consumers: usize,
213 /// Locale source for the morph-allocated backings. `Anon` is
214 /// in-process; `File(base)` allocates new backings at
215 /// `{base}.cap_{N}_g{morph_seq}.bin` per morph; `Shm(prefix)`
216 /// allocates named-shm backings at
217 /// `{prefix}_cap_{N}_g{morph_seq}` per morph (cross-process
218 /// visible, RAM-resident). Behind a mutex because a compound
219 /// morph with a locale axis retargets it at runtime; read by
220 /// `build_backing` (also reachable off the morph lock via
221 /// `prewarm`).
222 backing_source: Mutex<BackingTarget>,
223 /// Monotonic morph counter. Bumped on every morph BEFORE the
224 /// new backing is allocated so the new path / shm-name is
225 /// unique even when callers cycle through the same capacities
226 /// (e.g. 256 -> 1024 -> 256 -> 1024 -> ...). File-backed and
227 /// shmfs locales both need this because the prior backing's
228 /// file / shm region is still mapped from the stale list, and
229 /// attempting to create another at the same name fails on
230 /// Windows in particular.
231 morph_seq: AtomicU64,
232 /// Stamp kind when the wrapper was constructed via a
233 /// `*_stamped` constructor; mirrored (and seeded) onto every
234 /// morph-allocated backing so the ordering axis survives
235 /// capacity morphs.
236 stamped: Option<StampKind>,
237 /// Serialises concurrent `morph_capacity_to` callers.
238 morph_lock: Mutex<()>,
239 /// One-slot warm cache: a fully constructed (and stamped, when
240 /// the wrapper is stamped) backing at a predicted
241 /// (capacity, locale), built off the morph lock by
242 /// [`prewarm`](Self::prewarm) / [`prewarm_config`](Self::prewarm_config).
243 /// The morph takes it when both key components match the morph
244 /// target, skipping allocation + mapping + zeroing on the
245 /// critical path. Shape is deliberately NOT part of the key:
246 /// fresh backings start SPSC and the swap path's shape morph
247 /// on an empty backing costs microseconds. A wrong prediction
248 /// stays in the slot until the next prewarm replaces it or
249 /// the wrapper drops.
250 warm: Mutex<Option<(usize, BackingTarget, Arc<AdaptiveRing>)>>,
251 /// Successful warm-cache hits consumed by capacity morphs.
252 warm_hits: AtomicU64,
253 /// Items the consumer popped from stale (post-morph) backings
254 /// rather than the active one. Observability for transition
255 /// cost; incremented only on the stale-walk pop path, never on
256 /// the steady-state active path.
257 stale_pops: AtomicU64,
258}
259
260/// Atomic snapshot of the ring's active backing + stale list.
261/// Held behind an `ArcSwap` on [`CapacityAdaptiveRing`] so the
262/// hot path is a single Acquire load: producers go straight to
263/// `state.active`, consumers walk `state.stale` then fall
264/// through to `state.active`. Morph constructs a new `RingState`
265/// and swaps the whole thing atomically.
266struct RingState {
267 /// The currently-active backing. Producers write here.
268 active: Arc<AdaptiveRing>,
269 /// Post-morph backings the consumer is still draining;
270 /// oldest-first. Pruned of empty entries by the next morph.
271 /// Producers never write to these (they only see `active`
272 /// via the load).
273 stale: Vec<Arc<AdaptiveRing>>,
274}
275
276unsafe impl Send for CapacityAdaptiveRing {}
277unsafe impl Sync for CapacityAdaptiveRing {}
278
279impl CapacityAdaptiveRing {
280 /// Anon (in-process) capacity-adaptive ring. The active backing
281 /// is an anonymous mmap; subsequent morphs allocate fresh anon
282 /// mmaps at the new capacity and drop the prior one once
283 /// stragglers drain.
284 pub fn create_anon(
285 max_producers: usize,
286 max_consumers: usize,
287 initial_capacity: usize,
288 ) -> Result<Self, CapacityMorphError> {
289 if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
290 return Err(CapacityMorphError::InvalidCapacity);
291 }
292 let ring = AdaptiveRing::create_anon(
293 max_producers,
294 max_consumers,
295 initial_capacity,
296 )?;
297 Ok(Self {
298 state: ArcSwap::from(Arc::new(RingState {
299 active: Arc::new(ring),
300 stale: Vec::new(),
301 })),
302 pin_generation: AtomicU64::new(0),
303 capacity_atom: AtomicU64::new(initial_capacity as u64),
304 max_producers,
305 max_consumers,
306 backing_source: Mutex::new(BackingTarget::Anon),
307 morph_seq: AtomicU64::new(0),
308 stamped: None,
309 morph_lock: Mutex::new(()),
310 warm: Mutex::new(None),
311 warm_hits: AtomicU64::new(0),
312 stale_pops: AtomicU64::new(0),
313 })
314 }
315
316 /// As [`create_anon`](Self::create_anon) with ordering stamps
317 /// on the backing (and on every backing subsequent capacity
318 /// morphs allocate). See
319 /// [`AdaptiveRing::with_ordering_stamps`].
320 pub fn create_anon_stamped(
321 max_producers: usize,
322 max_consumers: usize,
323 initial_capacity: usize,
324 ) -> Result<Self, CapacityMorphError> {
325 if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
326 return Err(CapacityMorphError::InvalidCapacity);
327 }
328 let kind = default_stamp_kind();
329 let ring = AdaptiveRing::create_anon(
330 max_producers, max_consumers, initial_capacity,
331 )?
332 .with_ordering_stamps_kind(kind)
333 .map_err(CapacityMorphError::Ring)?;
334 Ok(Self {
335 state: ArcSwap::from(Arc::new(RingState {
336 active: Arc::new(ring),
337 stale: Vec::new(),
338 })),
339 pin_generation: AtomicU64::new(0),
340 capacity_atom: AtomicU64::new(initial_capacity as u64),
341 max_producers,
342 max_consumers,
343 backing_source: Mutex::new(BackingTarget::Anon),
344 morph_seq: AtomicU64::new(0),
345 stamped: Some(kind),
346 morph_lock: Mutex::new(()),
347 warm: Mutex::new(None),
348 warm_hits: AtomicU64::new(0),
349 stale_pops: AtomicU64::new(0),
350 })
351 }
352
353 /// File-backed capacity-adaptive ring. The active backing is
354 /// `{base_path}.cap_{initial_capacity}.bin`; morphs allocate
355 /// fresh files at the morph target's suffix and drop the prior
356 /// file once stragglers drain.
357 pub fn create(
358 base_path: impl AsRef<Path>,
359 max_producers: usize,
360 max_consumers: usize,
361 initial_capacity: usize,
362 ) -> Result<Self, CapacityMorphError> {
363 if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
364 return Err(CapacityMorphError::InvalidCapacity);
365 }
366 let base = base_path.as_ref().to_path_buf();
367 let path = path_for_capacity(&base, initial_capacity);
368 let ring = AdaptiveRing::create(
369 &path,
370 max_producers,
371 max_consumers,
372 initial_capacity,
373 )?;
374 Ok(Self {
375 state: ArcSwap::from(Arc::new(RingState {
376 active: Arc::new(ring),
377 stale: Vec::new(),
378 })),
379 pin_generation: AtomicU64::new(0),
380 capacity_atom: AtomicU64::new(initial_capacity as u64),
381 max_producers,
382 max_consumers,
383 backing_source: Mutex::new(BackingTarget::File(base)),
384 morph_seq: AtomicU64::new(0),
385 stamped: None,
386 morph_lock: Mutex::new(()),
387 warm: Mutex::new(None),
388 warm_hits: AtomicU64::new(0),
389 stale_pops: AtomicU64::new(0),
390 })
391 }
392
393 /// As [`create`](Self::create) with ordering stamps on the
394 /// backing and every morph-allocated successor.
395 pub fn create_stamped(
396 base_path: impl AsRef<Path>,
397 max_producers: usize,
398 max_consumers: usize,
399 initial_capacity: usize,
400 ) -> Result<Self, CapacityMorphError> {
401 if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
402 return Err(CapacityMorphError::InvalidCapacity);
403 }
404 let kind = default_stamp_kind();
405 let base = base_path.as_ref().to_path_buf();
406 let path = path_for_capacity(&base, initial_capacity);
407 let ring = AdaptiveRing::create(
408 &path, max_producers, max_consumers, initial_capacity,
409 )?
410 .with_ordering_stamps_kind(kind)
411 .map_err(CapacityMorphError::Ring)?;
412 Ok(Self {
413 state: ArcSwap::from(Arc::new(RingState {
414 active: Arc::new(ring),
415 stale: Vec::new(),
416 })),
417 pin_generation: AtomicU64::new(0),
418 capacity_atom: AtomicU64::new(initial_capacity as u64),
419 max_producers,
420 max_consumers,
421 backing_source: Mutex::new(BackingTarget::File(base)),
422 morph_seq: AtomicU64::new(0),
423 stamped: Some(kind),
424 morph_lock: Mutex::new(()),
425 warm: Mutex::new(None),
426 warm_hits: AtomicU64::new(0),
427 stale_pops: AtomicU64::new(0),
428 })
429 }
430
431 /// ShmFs (named shared memory) capacity-adaptive ring. The
432 /// active backing is named `{name_prefix}_cap_{initial_capacity}`;
433 /// morphs allocate fresh named-shm regions at the morph
434 /// target's suffix and drop the prior region once stragglers
435 /// drain. Cross-process visible: another process opens the
436 /// same logical ring by constructing a CapacityAdaptiveRing
437 /// with the same `name_prefix`.
438 pub fn create_shmfs(
439 name_prefix: &str,
440 max_producers: usize,
441 max_consumers: usize,
442 initial_capacity: usize,
443 ) -> Result<Self, CapacityMorphError> {
444 if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
445 return Err(CapacityMorphError::InvalidCapacity);
446 }
447 let name = format!("{name_prefix}_cap_{initial_capacity}");
448 let ring = AdaptiveRing::create_shmfs(
449 &name,
450 max_producers,
451 max_consumers,
452 initial_capacity,
453 )?;
454 Ok(Self {
455 state: ArcSwap::from(Arc::new(RingState {
456 active: Arc::new(ring),
457 stale: Vec::new(),
458 })),
459 pin_generation: AtomicU64::new(0),
460 capacity_atom: AtomicU64::new(initial_capacity as u64),
461 max_producers,
462 max_consumers,
463 backing_source: Mutex::new(BackingTarget::Shm(name_prefix.to_owned())),
464 morph_seq: AtomicU64::new(0),
465 stamped: None,
466 morph_lock: Mutex::new(()),
467 warm: Mutex::new(None),
468 warm_hits: AtomicU64::new(0),
469 stale_pops: AtomicU64::new(0),
470 })
471 }
472
473 /// As [`create_shmfs`](Self::create_shmfs) with ordering stamps
474 /// on the backing and every morph-allocated successor.
475 pub fn create_shmfs_stamped(
476 name_prefix: &str,
477 max_producers: usize,
478 max_consumers: usize,
479 initial_capacity: usize,
480 ) -> Result<Self, CapacityMorphError> {
481 if !initial_capacity.is_power_of_two() || initial_capacity < 2 {
482 return Err(CapacityMorphError::InvalidCapacity);
483 }
484 let kind = default_stamp_kind();
485 let name = format!("{name_prefix}_cap_{initial_capacity}");
486 let ring = AdaptiveRing::create_shmfs(
487 &name, max_producers, max_consumers, initial_capacity,
488 )?
489 .with_ordering_stamps_kind(kind)
490 .map_err(CapacityMorphError::Ring)?;
491 Ok(Self {
492 state: ArcSwap::from(Arc::new(RingState {
493 active: Arc::new(ring),
494 stale: Vec::new(),
495 })),
496 pin_generation: AtomicU64::new(0),
497 capacity_atom: AtomicU64::new(initial_capacity as u64),
498 max_producers,
499 max_consumers,
500 backing_source: Mutex::new(BackingTarget::Shm(name_prefix.to_owned())),
501 morph_seq: AtomicU64::new(0),
502 stamped: Some(kind),
503 morph_lock: Mutex::new(()),
504 warm: Mutex::new(None),
505 warm_hits: AtomicU64::new(0),
506 stale_pops: AtomicU64::new(0),
507 })
508 }
509
510 /// Current capacity of the active backing. Stays in lockstep
511 /// with the active ArcSwap; observers see the value the morph
512 /// publishes via a Release store.
513 pub fn current_capacity(&self) -> usize {
514 self.capacity_atom.load(Ordering::Acquire) as usize
515 }
516
517 /// Current pin generation. Pinned handles capture this at pin
518 /// time; a different live value means the pin is stale.
519 pub fn pin_generation(&self) -> u64 {
520 self.pin_generation.load(Ordering::Acquire)
521 }
522
523 /// Register a producer on the active backing. The returned id
524 /// is valid only against the current capacity backing; after a
525 /// morph the caller re-registers against the new active backing
526 /// (mirrored automatically by `morph_capacity_to`).
527 pub fn register_producer(&self) -> Result<usize, AdaptiveError> {
528 self.state.load().active.register_producer()
529 }
530
531 /// Register a consumer on the active backing. Same lifetime
532 /// caveat as `register_producer`.
533 pub fn register_consumer(&self) -> Result<usize, AdaptiveError> {
534 self.state.load().active.register_consumer()
535 }
536
537 /// Hot-path push. One ArcSwap load + the active backing's
538 /// dispatched `try_send`.
539 #[inline]
540 pub fn try_send(
541 &self,
542 producer_id: usize,
543 payload: &[u8],
544 ) -> Result<(), RingError> {
545 self.state.load().active.try_send(producer_id, payload)
546 }
547
548 /// Hot-path pop. Walks the stale backing list oldest-first,
549 /// returning the first non-empty backing's item; falls through
550 /// to the active backing when every stale entry is empty.
551 ///
552 /// The consumer is the SOLE reader of every backing (stale +
553 /// active). Producers only ever write to active. This is what
554 /// preserves the SPSC contract on the per-backing
555 /// `SpscRingCore`: exactly one consumer touches it, even
556 /// across morph boundaries.
557 ///
558 /// FIFO ordering invariant: stale and active are captured
559 /// under the SAME mutex acquisition (the stale lock). This
560 /// prevents the race where a morph slips in between the
561 /// stale-snapshot and the active-load and the consumer ends
562 /// up reading from the new active while the old active sits
563 /// in the new stale tail unread - which would reorder items
564 /// the producer pushed to the soon-to-be-stale ring AFTER
565 /// items the producer pushed to the brand-new active.
566 #[inline]
567 pub fn try_recv(
568 &self,
569 consumer_id: usize,
570 out: &mut [u8],
571 ) -> Result<usize, RingError> {
572 // One ArcSwap load gives us a consistent snapshot of
573 // BOTH stale and active. The wrapper does no mutex
574 // acquisition on the hot path.
575 //
576 // Per-stale-ring spin discipline (FIFO correctness):
577 // walking a stale ring may observe `Err(Empty)` in two
578 // distinct cases:
579 //
580 // (a) ring's consumer_seq >= producer_seq - truly
581 // drained for this consumer; safe to advance.
582 // (b) ring's consumer_seq < producer_seq AND the slot
583 // at consumer_seq is mid-claim (producer has CAS'd
584 // producer_seq forward but not yet stored the
585 // payload, OR another consumer is mid-claim on the
586 // same slot under MPMC) - NOT empty; advancing now
587 // and reading from `active` would let this consumer
588 // consume a higher-producer-index item from `active`
589 // before the lower-producer-index item from this
590 // stale ring becomes available, violating per-
591 // consumer per-producer FIFO.
592 //
593 // The fix: on Err from a stale ring, check `is_empty()`
594 // (which compares producer_seq == consumer_seq, NOT
595 // slot-sequence). If truly empty, advance. Otherwise spin
596 // and retry on the same stale ring until the in-flight
597 // claim commits (bounded by producer commit latency).
598 let state = self.state.load();
599 for ring in &state.stale {
600 loop {
601 match ring.try_recv(consumer_id, out) {
602 Ok(n) => {
603 self.stale_pops.fetch_add(1, Ordering::Relaxed);
604 return Ok(n);
605 }
606 Err(_) => {
607 if ring.is_empty() {
608 break;
609 }
610 std::hint::spin_loop();
611 }
612 }
613 }
614 }
615 state.active.try_recv(consumer_id, out)
616 }
617
618 /// Morph the ring's capacity to `new_capacity`. Allocates a
619 /// fresh backing at the new size, bumps `pin_generation`,
620 /// stashes the old backing onto the `stale` list (the
621 /// consumer drains it via `try_recv`'s stale-walk), and
622 /// atomic-swaps the active pointer. Concurrent morphs are
623 /// serialised through an internal mutex; hot-path ops are not
624 /// blocked.
625 ///
626 /// Critically the morph DOES NOT drain the old backing - that
627 /// would race against the consumer's concurrent `try_recv` on
628 /// the same backing, violating the per-backing
629 /// SPSC/MPSC/MPMC contract (two consumers on an SPSC ring is
630 /// undefined behavior). Instead the old backing stays
631 /// reachable via the stale list; the consumer is the sole
632 /// reader and pops every in-flight item via `try_recv`'s
633 /// stale-walk-then-active pattern.
634 ///
635 /// Shrink always succeeds. In-flight items physically remain
636 /// in the old (larger) backing as part of the stale list; the
637 /// new capacity governs only items the producer pushes after
638 /// the morph. Memory holds both old + new backings until the
639 /// consumer drains old, at which point the next morph prunes
640 /// the empty old entry from the stale list.
641 pub fn morph_capacity_to(
642 &self,
643 new_capacity: usize,
644 ) -> Result<(), CapacityMorphError> {
645 self.morph_to_config(&RingConfig {
646 capacity: Some(new_capacity),
647 ..RingConfig::default()
648 })
649 }
650
651 /// Compound morph: change any subset of {shape, capacity,
652 /// locale} in ONE transition. Builds a single fresh backing at
653 /// the combined target (warm-cache hit when
654 /// [`prewarm_config`](Self::prewarm_config) predicted it),
655 /// seeds stamps, mirrors registrations once, applies the
656 /// target shape to the empty new backing, bumps the pin
657 /// generation once, and appends the displaced active to the
658 /// stale list once - however many axes changed. A sequential
659 /// walk of the same axes pays each of those costs per axis.
660 ///
661 /// Special cases:
662 /// - Every axis already at target: no-op, no generation bump.
663 /// - Shape-only change (capacity + locale unchanged):
664 /// delegates to the active backing's in-place shape morph
665 /// (all four shape protocols are pre-allocated inside
666 /// `AdaptiveRing`), so no fresh backing is built, the
667 /// wrapper pin stays valid, and in-flight items stay put.
668 /// - A locale axis retargets the wrapper's [`BackingTarget`]
669 /// for this morph AND every subsequent morph / prewarm.
670 pub fn morph_to_config(
671 &self,
672 target: &RingConfig,
673 ) -> Result<(), CapacityMorphError> {
674 let _morph_guard = self.morph_lock.lock();
675
676 let old_state = self.state.load_full();
677 let old = Arc::clone(&old_state.active);
678 let old_capacity = self.capacity_atom.load(Ordering::Acquire) as usize;
679 let old_shape = old.current_shape();
680 let old_locale = self.backing_source.lock().clone();
681
682 let new_capacity = target.capacity.unwrap_or(old_capacity);
683 if !new_capacity.is_power_of_two() || new_capacity < 2 {
684 return Err(CapacityMorphError::InvalidCapacity);
685 }
686 let new_shape = target.shape.unwrap_or(old_shape);
687 let new_locale =
688 target.locale.clone().unwrap_or_else(|| old_locale.clone());
689
690 if new_capacity == old_capacity
691 && new_shape == old_shape
692 && new_locale == old_locale
693 {
694 return Ok(());
695 }
696
697 // Shape-only: in-place morph on the active backing. No
698 // fresh backing, no wrapper pin invalidation, no stale
699 // entry - AdaptiveRing pre-allocates all four shape
700 // protocols and handles its own transition.
701 if new_capacity == old_capacity && new_locale == old_locale {
702 return old.morph_to(new_shape).map_err(CapacityMorphError::Ring);
703 }
704
705 // Publish a locale retarget before building so this build
706 // and every later one allocate at the new locale.
707 if new_locale != old_locale {
708 *self.backing_source.lock() = new_locale.clone();
709 }
710
711 // Warm-cache probe: one uncontended lock + Option take. A
712 // prediction matching the morph target's (capacity, locale)
713 // skips allocation + mapping + zeroing entirely; a mismatch
714 // stays cached for a later morph and the cold path below
715 // runs unchanged.
716 let warm_hit = {
717 let mut warm = self.warm.lock();
718 warm.take_if(|(cap, loc, _)| {
719 *cap == new_capacity && *loc == new_locale
720 })
721 };
722 let new = match warm_hit {
723 Some((_, _, ring)) => {
724 self.warm_hits.fetch_add(1, Ordering::Relaxed);
725 ring
726 }
727 None => self.build_backing(new_capacity, &new_locale)?,
728 };
729
730 // Seed the ordering axis at swap time - counters move
731 // continuously, so seeding cannot happen at build time.
732 // The fresh region inherits the old one's counter stamps
733 // and live mode flag, keeping stamps monotone across the
734 // swap for warm and cold builds alike.
735 if self.stamped.is_some()
736 && let (Some(new_region), Some(old_region)) =
737 (new.ordering_region(), old.ordering_region())
738 {
739 new_region.seed_from(old_region);
740 }
741
742 // Mirror the producer/consumer registration counts so the
743 // new backing accepts ops against the same ids the old one
744 // accepted.
745 let n_producers = old.active_producers();
746 let n_consumers = old.active_consumers();
747 for _ in 0..n_producers {
748 new.register_producer()?;
749 }
750 for _ in 0..n_consumers {
751 new.register_consumer()?;
752 }
753
754 // Apply the target shape to the (empty, unobserved) new
755 // backing. Fresh and warm backings both start SPSC, so one
756 // call covers the keep-shape mirror AND the compound shape
757 // axis; registration counts alone never trigger a shape
758 // morph, and skipping this would silently drop an MPSC /
759 // MPMC / Vyukov ring back to SPSC on the new backing.
760 if new.current_shape() != new_shape {
761 new.morph_to(new_shape).map_err(CapacityMorphError::Ring)?;
762 }
763
764 // Bump the pin generation so outstanding pins invalidate.
765 self.pin_generation.fetch_add(1, Ordering::AcqRel);
766
767 // Build the new state in one shot: prune the old stale
768 // list (drop fully-drained entries), append the prior
769 // active onto the end, then publish atomically. Producers
770 // and consumers reading via `self.state.load()` see either
771 // the full old state or the full new state - never a
772 // half-state where active and stale disagree.
773 let mut new_stale: Vec<Arc<AdaptiveRing>> =
774 old_state.stale.iter().filter(|r| !r.is_empty()).cloned().collect();
775 new_stale.push(old);
776 let new_state = RingState { active: new, stale: new_stale };
777 self.state.store(Arc::new(new_state));
778
779 // Publish the new observable capacity.
780 self.capacity_atom
781 .store(new_capacity as u64, Ordering::Release);
782
783 Ok(())
784 }
785
786 /// Construct (and stamp, when the wrapper is stamped) a fresh
787 /// backing at `capacity`, at the wrapper's locale, with a
788 /// unique per-build name. Shared by the cold morph path and
789 /// [`prewarm`](Self::prewarm).
790 fn build_backing(
791 &self,
792 capacity: usize,
793 locale: &BackingTarget,
794 ) -> Result<Arc<AdaptiveRing>, CapacityMorphError> {
795 // Bump the morph sequence BEFORE allocating so file paths
796 // and shm names are unique even when callers cycle through
797 // the same capacities (the prior backing's file / shm
798 // region is still mapped from the stale list and cannot
799 // share its name with a new backing). Speculative builds
800 // that are never consumed burn a sequence number; gaps are
801 // harmless because the value only disambiguates names.
802 let seq = self.morph_seq.fetch_add(1, Ordering::AcqRel);
803 let mut ring = match locale {
804 BackingTarget::Anon => AdaptiveRing::create_anon(
805 self.max_producers,
806 self.max_consumers,
807 capacity,
808 )?,
809 BackingTarget::File(base) => AdaptiveRing::create(
810 path_for_capacity_seq(base, capacity, seq),
811 self.max_producers,
812 self.max_consumers,
813 capacity,
814 )?,
815 BackingTarget::Shm(prefix) => AdaptiveRing::create_shmfs(
816 &format!("{prefix}_cap_{capacity}_g{seq}"),
817 self.max_producers,
818 self.max_consumers,
819 capacity,
820 )?,
821 };
822 // Stamp at build time: stamping consumes the ring by
823 // value, so a cached warm backing must already carry its
824 // stamps. Seeding from the live region happens at swap
825 // time in `morph_to_config`.
826 if let Some(kind) = self.stamped {
827 ring = ring
828 .with_ordering_stamps_kind(kind)
829 .map_err(CapacityMorphError::Ring)?;
830 }
831 Ok(Arc::new(ring))
832 }
833
834 /// Speculatively build a backing at `capacity` (current
835 /// locale) into the one-slot warm cache, off the morph lock's
836 /// critical path. The next morph targeting that capacity
837 /// consumes it and skips allocation + mapping + zeroing.
838 pub fn prewarm(&self, capacity: usize) -> Result<(), CapacityMorphError> {
839 self.prewarm_config(&RingConfig {
840 capacity: Some(capacity),
841 ..RingConfig::default()
842 })
843 }
844
845 /// Speculatively build a backing at `target`'s (capacity,
846 /// locale) into the one-slot warm cache, off the morph lock's
847 /// critical path - the build half of a build-beside-and-
848 /// repatch transition: the following
849 /// [`morph_to_config`](Self::morph_to_config) at the same
850 /// target consumes it and pays only the swap. The shape axis
851 /// is ignored here: the swap path shapes the empty backing in
852 /// microseconds. Replaces any previously cached prediction
853 /// (the slot holds exactly one); re-prewarming the cached
854 /// (capacity, locale) is a no-op.
855 pub fn prewarm_config(
856 &self,
857 target: &RingConfig,
858 ) -> Result<(), CapacityMorphError> {
859 let capacity = target
860 .capacity
861 .unwrap_or_else(|| self.current_capacity());
862 if !capacity.is_power_of_two() || capacity < 2 {
863 return Err(CapacityMorphError::InvalidCapacity);
864 }
865 let locale = target
866 .locale
867 .clone()
868 .unwrap_or_else(|| self.backing_source.lock().clone());
869 if self
870 .warm
871 .lock()
872 .as_ref()
873 .is_some_and(|(c, l, _)| *c == capacity && *l == locale)
874 {
875 return Ok(());
876 }
877 // Build WITHOUT holding the warm lock - a large file-backed
878 // build takes milliseconds and the lock is probed by every
879 // morph. Concurrent prewarms race benignly: last store wins.
880 let ring = self.build_backing(capacity, &locale)?;
881 *self.warm.lock() = Some((capacity, locale, ring));
882 Ok(())
883 }
884
885 /// Capacity currently held in the warm cache, if any.
886 pub fn warm_capacity(&self) -> Option<usize> {
887 self.warm.lock().as_ref().map(|(c, _, _)| *c)
888 }
889
890 /// Number of morphs that consumed a warm-cache prediction.
891 pub fn warm_hits(&self) -> u64 {
892 self.warm_hits.load(Ordering::Relaxed)
893 }
894
895 /// Items the consumer popped from stale (post-morph) backings
896 /// rather than the active one, since construction. The
897 /// transition-cost observability counterpart to `warm_hits`.
898 pub fn stale_pops(&self) -> u64 {
899 self.stale_pops.load(Ordering::Relaxed)
900 }
901
902 /// Drop any cached prediction, releasing its memory (and its
903 /// file / shm region for non-anon locales).
904 pub fn clear_warm(&self) {
905 *self.warm.lock() = None;
906 }
907
908 /// Pin the current capacity backing for a hot loop. The
909 /// returned [`PinnedCapacity`] exposes the underlying
910 /// [`AdaptiveRing`] directly and validates
911 /// against the pin generation via
912 /// [`is_still_valid`](PinnedCapacity::is_still_valid).
913 pub fn pin_current_capacity(&self) -> PinnedCapacity<'_> {
914 let captured_gen = self.pin_generation.load(Ordering::Acquire);
915 let ring = Arc::clone(&self.state.load().active);
916 let capacity = self.capacity_atom.load(Ordering::Acquire) as usize;
917 PinnedCapacity {
918 parent: self,
919 pinned_generation: captured_gen,
920 ring,
921 capacity,
922 _not_sync: std::marker::PhantomData,
923 }
924 }
925
926 /// Direct access to the active [`AdaptiveRing`].
927 /// Override hatch for callers that want the shape-axis surface
928 /// on top of the capacity-axis morphing.
929 pub fn ring_handle(&self) -> Arc<AdaptiveRing> {
930 Arc::clone(&self.state.load().active)
931 }
932
933 /// Whether this wrapper's backings carry ordering stamps.
934 pub fn is_stamped(&self) -> bool {
935 self.stamped.is_some()
936 }
937
938 /// Live ordering mode of the active backing (`None` when
939 /// unstamped).
940 pub fn ordering_mode(&self) -> Option<OrderingMode> {
941 self.state.load().active.ordering_mode()
942 }
943
944 /// Flip the ordering mode across the active backing AND every
945 /// stale backing still draining, so the consumer's
946 /// stale-walk-then-active pop applies one consistent discipline.
947 /// Cross-backing order note: producers only ever write to the
948 /// active backing, so every stale item predates every active
949 /// item - the stale-oldest-first walk composes with per-backing
950 /// stamp merging into global stamp order across the morph
951 /// boundary (within the stamp source's skew window).
952 pub fn set_ordering_mode(&self, mode: OrderingMode) -> Result<(), RingError> {
953 let state = self.state.load();
954 for ring in &state.stale {
955 ring.set_ordering_mode(mode)?;
956 }
957 state.active.set_ordering_mode(mode)
958 }
959
960 /// Cross-producer inversions observed on the active backing.
961 /// Continuous across capacity morphs: each morph seeds the
962 /// fresh region's counter from the old one.
963 pub fn inversions(&self) -> u64 {
964 self.state.load().active.inversions()
965 }
966}
967
968/// Pinned snapshot of a [`CapacityAdaptiveRing`]'s current
969/// capacity backing. `Send` (an Arc lifetime extension), `!Sync`
970/// (single-owner-at-a-time semantics via [`std::cell::Cell`]
971/// marker).
972pub struct PinnedCapacity<'a> {
973 parent: &'a CapacityAdaptiveRing,
974 pinned_generation: u64,
975 ring: Arc<AdaptiveRing>,
976 capacity: usize,
977 _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
978}
979
980impl<'a> PinnedCapacity<'a> {
981 /// Whether this pin's capacity backing is still the active
982 /// backing. One Acquire load on the parent's generation atom.
983 pub fn is_still_valid(&self) -> bool {
984 self.parent.pin_generation.load(Ordering::Acquire) == self.pinned_generation
985 }
986
987 /// Capacity captured at pin time.
988 pub fn capacity(&self) -> usize { self.capacity }
989
990 /// Pin generation captured at pin time.
991 pub fn generation(&self) -> u64 { self.pinned_generation }
992
993 /// Direct access to the pinned [`AdaptiveRing`].
994 pub fn ring(&self) -> &Arc<AdaptiveRing> { &self.ring }
995}
996
997/// Compose the file path for a given capacity (initial-backing
998/// form). Used at constructor time when no morph has happened yet
999/// so no per-morph sequence number exists.
1000fn path_for_capacity(base: &Path, capacity: usize) -> PathBuf {
1001 let mut s = base.as_os_str().to_owned();
1002 s.push(format!(".cap_{capacity}.bin"));
1003 PathBuf::from(s)
1004}
1005
1006/// Compose the per-morph file path. The `seq` disambiguates
1007/// successive morphs that revisit the same capacity (e.g. cycling
1008/// 256 -> 1024 -> 256 -> 1024 ...) so the prior backing's file
1009/// can sit in the stale list while the new one allocates without
1010/// a path collision.
1011fn path_for_capacity_seq(base: &Path, capacity: usize, seq: u64) -> PathBuf {
1012 let mut s = base.as_os_str().to_owned();
1013 s.push(format!(".cap_{capacity}_g{seq}.bin"));
1014 PathBuf::from(s)
1015}
1016
1017// ===================================================================
1018// Sidecar capacity policy: automatic morphing based on fill-ratio
1019// observations, mirroring the shape-morph
1020// `AdaptiveRingSidecar` / `DefaultRingShapePolicy` design.
1021// ===================================================================
1022
1023/// A snapshot of the capacity-adaptive ring's observable state
1024/// passed to a [`CapacityPolicy`] on every sidecar scan.
1025#[derive(Debug, Clone, Copy)]
1026pub struct CapacityPolicyObservation {
1027 /// Current capacity of the active backing (slots per sub-ring,
1028 /// per the underlying SPSC / MPSC / MPMC / Vyukov shape).
1029 pub current_capacity: usize,
1030 /// Approximate item count across every sub-ring of the active
1031 /// backing right now (sum over per-producer rings for composed
1032 /// shapes; a single ring's depth for SPSC / Vyukov).
1033 pub active_approx_len: usize,
1034 /// Total slot inventory the producer can fill before back-
1035 /// pressure. Equals `current_capacity` for SPSC / Vyukov;
1036 /// `current_capacity * n_sub_rings` for composed shapes.
1037 pub total_slot_capacity: usize,
1038 /// Time since the last successful capacity morph. Used by the
1039 /// policy to suppress thrashing via hysteresis.
1040 pub since_last_morph: std::time::Duration,
1041}
1042
1043impl CapacityPolicyObservation {
1044 /// Convenience accessor: `active_approx_len / total_slot_capacity`
1045 /// clamped to `[0.0, 1.0]`. Policy logic typically branches on
1046 /// this against a `grow_at` upper threshold and a `shrink_at`
1047 /// lower threshold.
1048 pub fn fill_ratio(&self) -> f64 {
1049 if self.total_slot_capacity == 0 {
1050 return 0.0;
1051 }
1052 let ratio = self.active_approx_len as f64 / self.total_slot_capacity as f64;
1053 if ratio > 1.0 { 1.0 } else { ratio }
1054 }
1055}
1056
1057/// Policy that decides when (and to what new capacity) the sidecar
1058/// should grow / shrink the
1059/// [`CapacityAdaptiveRing`]. Returning `Some(new_capacity)`
1060/// triggers `morph_capacity_to(new_capacity)`. Returning `None`
1061/// leaves the capacity alone.
1062pub trait CapacityPolicy: Send + Sync + 'static {
1063 fn decide(&self, observation: &CapacityPolicyObservation) -> Option<usize>;
1064
1065 /// Capacity the policy expects `decide` to request soon, used
1066 /// by the sidecar to pre-build the backing off the morph
1067 /// lock's critical path ([`CapacityAdaptiveRing::prewarm`]).
1068 /// Purely speculative: a prediction never changes WHAT the
1069 /// ring morphs to, only how fast the morph executes when the
1070 /// prediction was right. The default returns `None`, so
1071 /// existing policy impls keep their behavior unchanged.
1072 fn predict(&self, _observation: &CapacityPolicyObservation) -> Option<usize> {
1073 None
1074 }
1075}
1076
1077/// Default capacity policy: fill-ratio with hysteresis.
1078///
1079/// On every scan the sidecar computes `fill_ratio = approx_len /
1080/// total_capacity`. If `fill_ratio >= grow_at`, the policy doubles
1081/// the capacity (up to `max_capacity`). If `fill_ratio <=
1082/// shrink_at`, the policy halves the capacity (down to
1083/// `min_capacity`). Otherwise it returns `None`.
1084///
1085/// Suppressed for `since_last_morph < hysteresis` to prevent
1086/// thrashing under bursty load. Default hysteresis 100 ms matches
1087/// the shape-morph policy.
1088pub struct DefaultCapacityPolicy {
1089 /// Upper fill-ratio that triggers a grow. Default 0.85.
1090 pub grow_at: f64,
1091 /// Lower fill-ratio that triggers a shrink. Default 0.10.
1092 pub shrink_at: f64,
1093 /// Minimum allowed capacity (pow2 >= 2). Default 64.
1094 pub min_capacity: usize,
1095 /// Maximum allowed capacity (pow2). Default 65536.
1096 pub max_capacity: usize,
1097 /// Cooldown after each morph. Default 100 ms.
1098 pub hysteresis: std::time::Duration,
1099}
1100
1101impl Default for DefaultCapacityPolicy {
1102 fn default() -> Self {
1103 Self {
1104 grow_at: 0.85,
1105 shrink_at: 0.10,
1106 min_capacity: 64,
1107 max_capacity: 65536,
1108 hysteresis: std::time::Duration::from_millis(100),
1109 }
1110 }
1111}
1112
1113impl CapacityPolicy for DefaultCapacityPolicy {
1114 fn decide(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1115 if obs.since_last_morph < self.hysteresis {
1116 return None;
1117 }
1118 let ratio = obs.fill_ratio();
1119 if ratio >= self.grow_at && obs.current_capacity < self.max_capacity {
1120 Some((obs.current_capacity * 2).min(self.max_capacity))
1121 } else if ratio <= self.shrink_at && obs.current_capacity > self.min_capacity {
1122 Some((obs.current_capacity / 2).max(self.min_capacity))
1123 } else {
1124 None
1125 }
1126 }
1127
1128 /// Predicts the doubled capacity once the fill ratio crosses
1129 /// 75% of the grow threshold, and the halved capacity once it
1130 /// falls under 150% of the shrink threshold - the trend bands
1131 /// in front of the decide thresholds. Deliberately NOT gated
1132 /// on hysteresis: the cooldown window after a morph is exactly
1133 /// the right time to build the next predicted backing.
1134 fn predict(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1135 let ratio = obs.fill_ratio();
1136 if ratio >= self.grow_at * 0.75 && obs.current_capacity < self.max_capacity {
1137 Some((obs.current_capacity * 2).min(self.max_capacity))
1138 } else if ratio <= self.shrink_at * 1.5 && obs.current_capacity > self.min_capacity {
1139 Some((obs.current_capacity / 2).max(self.min_capacity))
1140 } else {
1141 None
1142 }
1143 }
1144}
1145
1146/// Background scanner thread that drives capacity morphs on a
1147/// [`CapacityAdaptiveRing`] from a [`CapacityPolicy`].
1148///
1149/// `spawn` starts the thread; `shutdown` stops it. The thread
1150/// scans every `scan_interval`, builds a
1151/// [`CapacityPolicyObservation`], asks the policy, and calls
1152/// [`CapacityAdaptiveRing::morph_capacity_to`] on `Some(new_capacity)`
1153/// responses. Successful morphs increment the per-sidecar
1154/// `morphs_triggered` counter.
1155pub struct CapacityAdaptiveRingSidecar {
1156 handle: Option<std::thread::JoinHandle<()>>,
1157 stop: Arc<std::sync::atomic::AtomicBool>,
1158 morphs_triggered: Arc<AtomicU64>,
1159 prewarms_issued: Arc<AtomicU64>,
1160}
1161
1162impl CapacityAdaptiveRingSidecar {
1163 /// Spawn a sidecar thread that morphs `ring` according to
1164 /// `policy` decisions sampled every `scan_interval`.
1165 ///
1166 /// Prediction wiring: when `policy.predict` names the same
1167 /// target on two consecutive scans (a sustained trend, not a
1168 /// one-scan blip), the sidecar pre-builds that backing via
1169 /// [`CapacityAdaptiveRing::prewarm`] - off the morph lock, on
1170 /// this thread's idle time - so the eventual `decide`-driven
1171 /// morph consumes it instead of allocating on the critical
1172 /// path. Policies whose `predict` returns `None` (the trait
1173 /// default) get today's behavior exactly.
1174 pub fn spawn<P: CapacityPolicy>(
1175 ring: Arc<CapacityAdaptiveRing>,
1176 policy: P,
1177 scan_interval: std::time::Duration,
1178 ) -> Self {
1179 Self::spawn_gated(ring, policy, scan_interval, crate::policy_gate::GateConfig::default())
1180 }
1181
1182 /// As [`spawn`](Self::spawn) with a confidence gate between the
1183 /// policy's recommendation and the morph. With
1184 /// `gate_cfg.enabled == false` (the default) behavior is
1185 /// identical to `spawn`. Enabled, a recommendation must hold
1186 /// across consecutive scans until conviction crosses the
1187 /// gate's threshold (and any sample floor); recommendation
1188 /// reversals, peer-count changes, and fill-ratio jumps collapse
1189 /// conviction, so oscillating load starves the gate instead of
1190 /// thrashing the ring.
1191 pub fn spawn_gated<P: CapacityPolicy>(
1192 ring: Arc<CapacityAdaptiveRing>,
1193 policy: P,
1194 scan_interval: std::time::Duration,
1195 gate_cfg: crate::policy_gate::GateConfig,
1196 ) -> Self {
1197 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1198 let morphs_triggered = Arc::new(AtomicU64::new(0));
1199 let prewarms_issued = Arc::new(AtomicU64::new(0));
1200
1201 let stop_c = Arc::clone(&stop);
1202 let morphs_c = Arc::clone(&morphs_triggered);
1203 let prewarms_c = Arc::clone(&prewarms_issued);
1204 let handle = std::thread::spawn(move || {
1205 let mut last_morph = std::time::Instant::now();
1206 let mut last_predicted: Option<usize> = None;
1207 let mut gate = crate::policy_gate::ConfidenceGate::new(gate_cfg);
1208 let mut last_peers = (0usize, 0usize);
1209 let mut last_fill = 0.0f64;
1210 let mut first_scan = true;
1211 while !stop_c.load(Ordering::Acquire) {
1212 let active = ring.ring_handle();
1213 let obs = CapacityPolicyObservation {
1214 current_capacity: ring.current_capacity(),
1215 active_approx_len: active.approx_len(),
1216 total_slot_capacity: active.total_slot_capacity(),
1217 since_last_morph: last_morph.elapsed(),
1218 };
1219 let peers = (active.active_producers(), active.active_consumers());
1220 drop(active);
1221
1222 // Regime-shift signals collapse conviction: the
1223 // workload changed character, so any accumulated
1224 // agreement belongs to the old regime.
1225 let fill = obs.fill_ratio();
1226 if !first_scan {
1227 if peers != last_peers {
1228 gate.shock();
1229 }
1230 if (fill - last_fill).abs() > 0.5 {
1231 gate.shock();
1232 }
1233 }
1234 last_peers = peers;
1235 last_fill = fill;
1236 first_scan = false;
1237
1238 if let Some(new_cap) = gate.observe(policy.decide(&obs))
1239 && ring.morph_capacity_to(new_cap).is_ok()
1240 {
1241 last_morph = std::time::Instant::now();
1242 morphs_c.fetch_add(1, Ordering::Relaxed);
1243 }
1244 match policy.predict(&obs) {
1245 Some(target) if target != ring.current_capacity() => {
1246 // Two consecutive scans naming the same
1247 // target = a sustained trend; build it.
1248 if last_predicted == Some(target)
1249 && ring.warm_capacity() != Some(target)
1250 && ring.prewarm(target).is_ok()
1251 {
1252 prewarms_c.fetch_add(1, Ordering::Relaxed);
1253 }
1254 last_predicted = Some(target);
1255 }
1256 _ => last_predicted = None,
1257 }
1258 std::thread::sleep(scan_interval);
1259 }
1260 });
1261
1262 Self { handle: Some(handle), stop, morphs_triggered, prewarms_issued }
1263 }
1264
1265 /// Number of successful morphs triggered by this sidecar
1266 /// since `spawn`.
1267 pub fn morphs_triggered(&self) -> u64 {
1268 self.morphs_triggered.load(Ordering::Relaxed)
1269 }
1270
1271 /// Number of speculative backings this sidecar pre-built via
1272 /// `predict` trends since `spawn`.
1273 pub fn prewarms_issued(&self) -> u64 {
1274 self.prewarms_issued.load(Ordering::Relaxed)
1275 }
1276
1277 /// Stop the sidecar thread and wait for it to exit.
1278 pub fn shutdown(mut self) {
1279 self.stop.store(true, Ordering::Release);
1280 if let Some(h) = self.handle.take() {
1281 drop(h.join());
1282 }
1283 }
1284}
1285
1286impl Drop for CapacityAdaptiveRingSidecar {
1287 fn drop(&mut self) {
1288 self.stop.store(true, Ordering::Release);
1289 if let Some(h) = self.handle.take() {
1290 drop(h.join());
1291 }
1292 }
1293}
1294
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299
1300 #[test]
1301 fn create_anon_rejects_non_pow2() {
1302 let r = CapacityAdaptiveRing::create_anon(1, 1, 100);
1303 assert!(matches!(r, Err(CapacityMorphError::InvalidCapacity)));
1304 }
1305
1306 #[test]
1307 fn create_anon_rejects_capacity_below_two() {
1308 let r = CapacityAdaptiveRing::create_anon(1, 1, 1);
1309 assert!(matches!(r, Err(CapacityMorphError::InvalidCapacity)));
1310 }
1311
1312 #[test]
1313 fn anon_round_trip_after_create() {
1314 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1315 ring.register_producer().unwrap();
1316 ring.register_consumer().unwrap();
1317 let payload = [0xAAu8; 56];
1318 ring.try_send(0, &payload).unwrap();
1319 let mut out = [0u8; 64];
1320 let n = ring.try_recv(0, &mut out).unwrap();
1321 assert!(n >= 56);
1322 assert_eq!(&out[..56], &payload[..]);
1323 }
1324
1325 #[test]
1326 fn morph_grow_preserves_in_flight_items() {
1327 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1328 ring.register_producer().unwrap();
1329 ring.register_consumer().unwrap();
1330
1331 // Push 10 distinct items.
1332 for i in 0..10u64 {
1333 let mut payload = [0u8; 56];
1334 payload[..8].copy_from_slice(&i.to_le_bytes());
1335 ring.try_send(0, &payload).unwrap();
1336 }
1337
1338 // Grow to 256 slots.
1339 ring.morph_capacity_to(256).unwrap();
1340 assert_eq!(ring.current_capacity(), 256);
1341 assert_eq!(ring.pin_generation(), 1);
1342
1343 // Drain and verify every original item is present.
1344 let mut got = Vec::new();
1345 let mut out = [0u8; 64];
1346 while ring.try_recv(0, &mut out).is_ok() {
1347 let v = u64::from_le_bytes(out[..8].try_into().unwrap());
1348 got.push(v);
1349 }
1350 got.sort();
1351 assert_eq!(got, (0..10u64).collect::<Vec<_>>());
1352 }
1353
1354 #[test]
1355 fn morph_shrink_with_room_succeeds() {
1356 let ring = CapacityAdaptiveRing::create_anon(1, 1, 256).unwrap();
1357 ring.register_producer().unwrap();
1358 ring.register_consumer().unwrap();
1359
1360 // 5 items in a 256-slot ring.
1361 for i in 0..5u64 {
1362 let mut payload = [0u8; 56];
1363 payload[..8].copy_from_slice(&i.to_le_bytes());
1364 ring.try_send(0, &payload).unwrap();
1365 }
1366
1367 // Shrink to 64; 5 items fit easily.
1368 ring.morph_capacity_to(64).unwrap();
1369 assert_eq!(ring.current_capacity(), 64);
1370
1371 let mut got = Vec::new();
1372 let mut out = [0u8; 64];
1373 while ring.try_recv(0, &mut out).is_ok() {
1374 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1375 }
1376 got.sort();
1377 assert_eq!(got, (0..5u64).collect::<Vec<_>>());
1378 }
1379
1380 #[test]
1381 fn morph_shrink_with_more_in_flight_than_new_capacity_succeeds() {
1382 // Under the stale-list design, shrinks always succeed:
1383 // in-flight items physically stay in the old (larger)
1384 // AdaptiveRing as part of the stale list and the
1385 // consumer drains them via try_recv's stale-walk. The
1386 // new capacity governs only items pushed AFTER the morph.
1387 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1388 ring.register_producer().unwrap();
1389 ring.register_consumer().unwrap();
1390
1391 // Fill to 40 items in the 64-slot ring.
1392 for i in 0..40u64 {
1393 let mut payload = [0u8; 56];
1394 payload[..8].copy_from_slice(&i.to_le_bytes());
1395 ring.try_send(0, &payload).unwrap();
1396 }
1397
1398 // Shrink to 16 slots. With the old in-flight items
1399 // sitting in the stale list, this succeeds without
1400 // touching them.
1401 ring.morph_capacity_to(16).expect("shrink succeeds via stale list");
1402 assert_eq!(ring.current_capacity(), 16);
1403 assert_eq!(ring.pin_generation(), 1);
1404
1405 // The consumer drains all 40 original items via the
1406 // stale-list walk in try_recv. Order is send-order
1407 // because the producer pushed sequentially into the
1408 // original SPSC ring; the consumer pops in the same
1409 // order via try_recv's stale-first dispatch.
1410 let mut got = Vec::new();
1411 let mut out = [0u8; 64];
1412 while ring.try_recv(0, &mut out).is_ok() {
1413 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1414 }
1415 assert_eq!(got, (0..40u64).collect::<Vec<_>>(),
1416 "all 40 original items drained via stale list in send-order");
1417 }
1418
1419 #[test]
1420 fn morph_to_same_capacity_is_noop() {
1421 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1422 let gen_before = ring.pin_generation();
1423 ring.morph_capacity_to(64).unwrap();
1424 assert_eq!(ring.pin_generation(), gen_before);
1425 }
1426
1427 #[test]
1428 fn morph_rejects_non_pow2_target() {
1429 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1430 let r = ring.morph_capacity_to(100);
1431 assert!(matches!(r, Err(CapacityMorphError::InvalidCapacity)));
1432 }
1433
1434 #[test]
1435 fn pin_invalidates_after_morph() {
1436 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1437 ring.register_producer().unwrap();
1438 ring.register_consumer().unwrap();
1439 let pin = ring.pin_current_capacity();
1440 assert!(pin.is_still_valid());
1441 assert_eq!(pin.capacity(), 64);
1442
1443 ring.morph_capacity_to(128).unwrap();
1444 assert!(!pin.is_still_valid());
1445
1446 let pin2 = ring.pin_current_capacity();
1447 assert!(pin2.is_still_valid());
1448 assert_eq!(pin2.capacity(), 128);
1449 }
1450
1451 #[test]
1452 fn multiple_grow_morphs_increment_generation_correctly() {
1453 let ring = CapacityAdaptiveRing::create_anon(1, 1, 4).unwrap();
1454 ring.register_producer().unwrap();
1455 ring.register_consumer().unwrap();
1456 assert_eq!(ring.pin_generation(), 0);
1457 ring.morph_capacity_to(8).unwrap();
1458 assert_eq!(ring.pin_generation(), 1);
1459 ring.morph_capacity_to(16).unwrap();
1460 assert_eq!(ring.pin_generation(), 2);
1461 ring.morph_capacity_to(64).unwrap();
1462 assert_eq!(ring.pin_generation(), 3);
1463 assert_eq!(ring.current_capacity(), 64);
1464 }
1465
1466 #[test]
1467 fn stamped_capacity_morph_preserves_ordering_axis() {
1468 let ring = CapacityAdaptiveRing::create_anon_stamped(2, 1, 64).unwrap();
1469 assert!(ring.is_stamped());
1470 ring.register_producer().unwrap();
1471 ring.register_consumer().unwrap();
1472 ring.set_ordering_mode(OrderingMode::MergeByStamp).unwrap();
1473
1474 // Items pushed pre-morph...
1475 for i in 0..6u64 {
1476 let mut payload = [0u8; 48];
1477 payload[..8].copy_from_slice(&i.to_le_bytes());
1478 ring.try_send(0, &payload).unwrap();
1479 }
1480 ring.morph_capacity_to(256).unwrap();
1481 assert_eq!(ring.ordering_mode(), Some(OrderingMode::MergeByStamp),
1482 "the live mode flag must follow the capacity morph");
1483 // ...and post-morph items keep monotone stamps (the fresh
1484 // region is seeded from the old one).
1485 for i in 6..10u64 {
1486 let mut payload = [0u8; 48];
1487 payload[..8].copy_from_slice(&i.to_le_bytes());
1488 ring.try_send(0, &payload).unwrap();
1489 }
1490
1491 // Stale-first walk + per-backing merge = send order.
1492 let mut out = [0u8; 64];
1493 let mut got = Vec::new();
1494 while ring.try_recv(0, &mut out).is_ok() {
1495 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1496 }
1497 assert_eq!(got, (0..10u64).collect::<Vec<_>>(),
1498 "ordering must hold across the capacity morph boundary");
1499 assert_eq!(ring.inversions(), 0);
1500 }
1501
1502 #[test]
1503 fn cross_thread_concurrent_send_recv_through_morphs() {
1504 use std::thread;
1505
1506 let ring = Arc::new(CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap());
1507 ring.register_producer().unwrap();
1508 ring.register_consumer().unwrap();
1509
1510 let n = 5_000u64;
1511 let r_prod = Arc::clone(&ring);
1512 let prod = thread::spawn(move || {
1513 for i in 0..n {
1514 let mut payload = [0u8; 56];
1515 payload[..8].copy_from_slice(&i.to_le_bytes());
1516 while r_prod.try_send(0, &payload).is_err() {
1517 std::hint::spin_loop();
1518 }
1519 }
1520 });
1521
1522 let r_cons = Arc::clone(&ring);
1523 let cons = thread::spawn(move || {
1524 let mut got = Vec::with_capacity(n as usize);
1525 let mut out = [0u8; 64];
1526 while got.len() < n as usize {
1527 if r_cons.try_recv(0, &mut out).is_ok() {
1528 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1529 }
1530 }
1531 got
1532 });
1533
1534 // Morph thread: grow 64 -> 128 -> 256 -> 128 -> 64 during the run.
1535 // Shrinks always succeed under the stale-list design (items in
1536 // flight stay in the prior backing and the consumer drains them
1537 // via try_recv's stale-walk), so this loop never retries.
1538 let r_morph = Arc::clone(&ring);
1539 let morph = thread::spawn(move || {
1540 let targets = [128usize, 256, 128, 64];
1541 for t in targets {
1542 std::thread::sleep(std::time::Duration::from_micros(500));
1543 r_morph.morph_capacity_to(t).expect("morph succeeds");
1544 }
1545 });
1546
1547 prod.join().unwrap();
1548 morph.join().unwrap();
1549 let mut got = cons.join().unwrap();
1550 got.sort();
1551 let expected: Vec<u64> = (0..n).collect();
1552 assert_eq!(got, expected);
1553 }
1554
1555 // ============================================================
1556 // Warm-backing pre-allocation
1557 // ============================================================
1558
1559 #[test]
1560 fn prewarm_hit_consumes_cache_and_morph_works() {
1561 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1562 ring.register_producer().unwrap();
1563 ring.register_consumer().unwrap();
1564
1565 ring.prewarm(256).unwrap();
1566 assert_eq!(ring.warm_capacity(), Some(256));
1567 assert_eq!(ring.warm_hits(), 0);
1568
1569 for i in 0..10u64 {
1570 let mut payload = [0u8; 56];
1571 payload[..8].copy_from_slice(&i.to_le_bytes());
1572 ring.try_send(0, &payload).unwrap();
1573 }
1574
1575 ring.morph_capacity_to(256).unwrap();
1576 assert_eq!(ring.warm_hits(), 1, "the morph must consume the prediction");
1577 assert_eq!(ring.warm_capacity(), None, "the slot is one-shot");
1578 assert_eq!(ring.current_capacity(), 256);
1579 assert_eq!(ring.pin_generation(), 1);
1580
1581 // Post-hit ring is fully functional: in-flight items drain
1582 // in send order and new pushes land on the warm backing.
1583 ring.try_send(0, &[0xBBu8; 56]).unwrap();
1584 let mut out = [0u8; 64];
1585 let mut got = Vec::new();
1586 while ring.try_recv(0, &mut out).is_ok() {
1587 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1588 }
1589 assert_eq!(got.len(), 11);
1590 assert_eq!(&got[..10], &(0..10u64).collect::<Vec<_>>()[..]);
1591 }
1592
1593 #[test]
1594 fn prewarm_mismatch_stays_cached_and_cold_path_runs() {
1595 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1596 ring.register_producer().unwrap();
1597 ring.register_consumer().unwrap();
1598
1599 ring.prewarm(512).unwrap();
1600 ring.morph_capacity_to(256).unwrap();
1601 assert_eq!(ring.warm_hits(), 0, "mismatched prediction must not be consumed");
1602 assert_eq!(ring.warm_capacity(), Some(512), "mismatch stays cached");
1603 assert_eq!(ring.current_capacity(), 256);
1604
1605 ring.morph_capacity_to(512).unwrap();
1606 assert_eq!(ring.warm_hits(), 1, "the cached 512 serves the later morph");
1607 assert_eq!(ring.warm_capacity(), None);
1608 }
1609
1610 #[test]
1611 fn prewarm_rejects_non_pow2() {
1612 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1613 assert!(matches!(ring.prewarm(100), Err(CapacityMorphError::InvalidCapacity)));
1614 assert!(matches!(ring.prewarm(1), Err(CapacityMorphError::InvalidCapacity)));
1615 assert_eq!(ring.warm_capacity(), None);
1616 }
1617
1618 #[test]
1619 fn prewarm_same_capacity_is_idempotent_and_clear_drops() {
1620 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1621 ring.prewarm(128).unwrap();
1622 ring.prewarm(128).unwrap();
1623 assert_eq!(ring.warm_capacity(), Some(128));
1624 ring.prewarm(256).unwrap();
1625 assert_eq!(ring.warm_capacity(), Some(256), "new prediction replaces the old");
1626 ring.clear_warm();
1627 assert_eq!(ring.warm_capacity(), None);
1628 }
1629
1630 #[test]
1631 fn warm_morph_preserves_stamps_and_shape() {
1632 let ring = CapacityAdaptiveRing::create_anon_stamped(2, 1, 64).unwrap();
1633 ring.register_producer().unwrap();
1634 ring.register_consumer().unwrap();
1635 ring.set_ordering_mode(OrderingMode::MergeByStamp).unwrap();
1636 ring.ring_handle()
1637 .morph_to(crate::adaptive_ring::RingShape::Mpsc)
1638 .unwrap();
1639
1640 for i in 0..6u64 {
1641 let mut payload = [0u8; 48];
1642 payload[..8].copy_from_slice(&i.to_le_bytes());
1643 ring.try_send(0, &payload).unwrap();
1644 }
1645
1646 ring.prewarm(256).unwrap();
1647 ring.morph_capacity_to(256).unwrap();
1648 assert_eq!(ring.warm_hits(), 1);
1649 assert_eq!(ring.ordering_mode(), Some(OrderingMode::MergeByStamp),
1650 "live mode flag must follow a warm-hit morph");
1651 assert_eq!(ring.ring_handle().current_shape(),
1652 crate::adaptive_ring::RingShape::Mpsc,
1653 "shape must be mirrored onto the warm backing");
1654
1655 for i in 6..10u64 {
1656 let mut payload = [0u8; 48];
1657 payload[..8].copy_from_slice(&i.to_le_bytes());
1658 ring.try_send(0, &payload).unwrap();
1659 }
1660 let mut out = [0u8; 64];
1661 let mut got = Vec::new();
1662 while ring.try_recv(0, &mut out).is_ok() {
1663 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1664 }
1665 assert_eq!(got, (0..10u64).collect::<Vec<_>>(),
1666 "send order must hold across a warm-hit morph (seeded stamps)");
1667 assert_eq!(ring.inversions(), 0);
1668 }
1669
1670 #[test]
1671 fn warm_morph_file_locale_round_trips() {
1672 let dir = std::env::temp_dir().join(format!(
1673 "subetha_warm_file_{}", std::process::id(),
1674 ));
1675 std::fs::create_dir_all(&dir).unwrap();
1676 let base = dir.join("warm_probe");
1677 {
1678 let ring = CapacityAdaptiveRing::create(&base, 1, 1, 64).unwrap();
1679 ring.register_producer().unwrap();
1680 ring.register_consumer().unwrap();
1681 for i in 0..5u64 {
1682 let mut payload = [0u8; 56];
1683 payload[..8].copy_from_slice(&i.to_le_bytes());
1684 ring.try_send(0, &payload).unwrap();
1685 }
1686 ring.prewarm(128).unwrap();
1687 ring.morph_capacity_to(128).unwrap();
1688 assert_eq!(ring.warm_hits(), 1);
1689 // Cycle back down through a second prewarm at a
1690 // previously-used capacity - the per-build sequence
1691 // number keeps the file names unique.
1692 ring.prewarm(64).unwrap();
1693 ring.morph_capacity_to(64).unwrap();
1694 assert_eq!(ring.warm_hits(), 2);
1695 let mut out = [0u8; 64];
1696 let mut got = Vec::new();
1697 while ring.try_recv(0, &mut out).is_ok() {
1698 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1699 }
1700 assert_eq!(got, (0..5u64).collect::<Vec<_>>());
1701 }
1702 drop(std::fs::remove_dir_all(&dir));
1703 }
1704
1705 #[test]
1706 fn default_policy_predict_bands() {
1707 let policy = DefaultCapacityPolicy::default(); // grow 0.85 / shrink 0.10
1708 let obs = |len: usize, cap: usize| CapacityPolicyObservation {
1709 current_capacity: cap,
1710 active_approx_len: len,
1711 total_slot_capacity: cap,
1712 since_last_morph: std::time::Duration::ZERO,
1713 };
1714 // 0.70 fill >= 0.6375 trend band -> predict double.
1715 assert_eq!(policy.predict(&obs(716, 1024)), Some(2048));
1716 // 0.50 fill sits between the bands -> no prediction.
1717 assert_eq!(policy.predict(&obs(512, 1024)), None);
1718 // 0.14 fill <= 0.15 trend band -> predict half.
1719 assert_eq!(policy.predict(&obs(143, 1024)), Some(512));
1720 // Caps respected at the ladder ends.
1721 assert_eq!(policy.predict(&obs(60000, 65536)), None);
1722 assert_eq!(policy.predict(&obs(0, 64)), None);
1723 // predict ignores hysteresis (decide does not).
1724 let fresh = CapacityPolicyObservation {
1725 since_last_morph: std::time::Duration::ZERO,
1726 ..obs(716, 1024)
1727 };
1728 assert_eq!(policy.decide(&fresh), None, "decide is hysteresis-gated");
1729 assert_eq!(policy.predict(&fresh), Some(2048), "predict is not");
1730 }
1731
1732 #[test]
1733 fn policy_without_predict_override_never_prewarms() {
1734 struct GrowOnly;
1735 impl CapacityPolicy for GrowOnly {
1736 fn decide(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1737 (obs.fill_ratio() >= 0.85).then_some(obs.current_capacity * 2)
1738 }
1739 }
1740 let ring = Arc::new(CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap());
1741 ring.register_producer().unwrap();
1742 ring.register_consumer().unwrap();
1743 let sidecar = CapacityAdaptiveRingSidecar::spawn(
1744 Arc::clone(&ring), GrowOnly, std::time::Duration::from_millis(2),
1745 );
1746 // Hold fill high enough that a predicting policy is sure
1747 // to act; the trait-default one must not.
1748 for _ in 0..50 {
1749 ring.try_send(0, &[0u8; 56]).ok();
1750 }
1751 std::thread::sleep(std::time::Duration::from_millis(50));
1752 assert_eq!(sidecar.prewarms_issued(), 0,
1753 "trait-default predict() must keep today's behavior");
1754 sidecar.shutdown();
1755 }
1756
1757 #[test]
1758 fn sidecar_prewarms_on_sustained_trend_then_morph_hits_warm() {
1759 // Deterministic test policy: predict fires in a band BELOW
1760 // the decide threshold, so the test controls each stage by
1761 // fill level alone.
1762 struct Banded;
1763 impl CapacityPolicy for Banded {
1764 fn decide(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1765 (obs.fill_ratio() >= 0.85).then_some(obs.current_capacity * 2)
1766 }
1767 fn predict(&self, obs: &CapacityPolicyObservation) -> Option<usize> {
1768 (obs.fill_ratio() >= 0.60).then_some(obs.current_capacity * 2)
1769 }
1770 }
1771 let ring = Arc::new(CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap());
1772 ring.register_producer().unwrap();
1773 ring.register_consumer().unwrap();
1774 let sidecar = CapacityAdaptiveRingSidecar::spawn(
1775 Arc::clone(&ring), Banded, std::time::Duration::from_millis(2),
1776 );
1777
1778 // Stage 1: fill into the predict band (45/64 = 0.70) and
1779 // wait for the sustained-trend prewarm.
1780 for _ in 0..45 {
1781 ring.try_send(0, &[0u8; 56]).unwrap();
1782 }
1783 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1784 while ring.warm_capacity() != Some(128) {
1785 assert!(std::time::Instant::now() < deadline,
1786 "sidecar must prewarm 128 from the sustained trend");
1787 std::thread::sleep(std::time::Duration::from_millis(2));
1788 }
1789 assert!(sidecar.prewarms_issued() >= 1);
1790
1791 // Stage 2: push over the decide threshold (56/64 = 0.875)
1792 // and wait for the morph to consume the warm backing.
1793 for _ in 0..11 {
1794 ring.try_send(0, &[0u8; 56]).unwrap();
1795 }
1796 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1797 while ring.current_capacity() != 128 {
1798 assert!(std::time::Instant::now() < deadline,
1799 "sidecar must morph to 128 once fill crosses decide");
1800 std::thread::sleep(std::time::Duration::from_millis(2));
1801 }
1802 assert_eq!(ring.warm_hits(), 1,
1803 "the sidecar-driven morph must consume the prewarmed backing");
1804 sidecar.shutdown();
1805
1806 // Integrity: every pushed item drains.
1807 let mut out = [0u8; 64];
1808 let mut n = 0;
1809 while ring.try_recv(0, &mut out).is_ok() {
1810 n += 1;
1811 }
1812 assert_eq!(n, 56);
1813 }
1814
1815 // ============================================================
1816 // Compound multi-axis morphs
1817 // ============================================================
1818
1819 #[test]
1820 fn compound_capacity_plus_shape_is_one_generation() {
1821 let ring = CapacityAdaptiveRing::create_anon(4, 1, 64).unwrap();
1822 ring.register_producer().unwrap();
1823 ring.register_consumer().unwrap();
1824 for i in 0..10u64 {
1825 let mut p = [0u8; 56];
1826 p[..8].copy_from_slice(&i.to_le_bytes());
1827 ring.try_send(0, &p).unwrap();
1828 }
1829
1830 ring.morph_to_config(&RingConfig {
1831 shape: Some(RingShape::Mpmc),
1832 capacity: Some(512),
1833 locale: None,
1834 })
1835 .unwrap();
1836 assert_eq!(ring.pin_generation(), 1,
1837 "two axes, ONE pin invalidation");
1838 assert_eq!(ring.current_capacity(), 512);
1839 assert_eq!(ring.ring_handle().current_shape(), RingShape::Mpmc);
1840
1841 // Three more producers join post-morph and everything
1842 // drains exactly once.
1843 for _ in 0..3 {
1844 ring.register_producer().unwrap();
1845 }
1846 for pid in 1..4usize {
1847 let mut p = [0u8; 56];
1848 p[..8].copy_from_slice(&(100 + pid as u64).to_le_bytes());
1849 ring.try_send(pid, &p).unwrap();
1850 }
1851 let mut out = [0u8; 64];
1852 let mut got = Vec::new();
1853 while ring.try_recv(0, &mut out).is_ok() {
1854 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1855 }
1856 got.sort();
1857 let mut expected: Vec<u64> = (0..10).collect();
1858 expected.extend([101, 102, 103]);
1859 assert_eq!(got, expected);
1860 }
1861
1862 #[test]
1863 fn shape_only_config_morphs_in_place_without_pin_bump() {
1864 let ring = CapacityAdaptiveRing::create_anon(4, 1, 64).unwrap();
1865 ring.register_producer().unwrap();
1866 ring.register_consumer().unwrap();
1867 ring.try_send(0, &[0x11u8; 56]).unwrap();
1868
1869 let active_before = ring.ring_handle();
1870 ring.morph_to_config(&RingConfig {
1871 shape: Some(RingShape::Mpsc),
1872 ..RingConfig::default()
1873 })
1874 .unwrap();
1875 assert_eq!(ring.pin_generation(), 0,
1876 "in-place shape morph must not invalidate the capacity pin");
1877 assert!(Arc::ptr_eq(&active_before, &ring.ring_handle()),
1878 "active backing must be the same instance");
1879 assert_eq!(ring.ring_handle().current_shape(), RingShape::Mpsc);
1880 let mut out = [0u8; 64];
1881 assert!(ring.try_recv(0, &mut out).is_ok(),
1882 "in-flight item survives the in-place shape morph");
1883 }
1884
1885 #[test]
1886 fn config_noop_when_every_axis_matches() {
1887 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1888 ring.morph_to_config(&RingConfig::default()).unwrap();
1889 ring.morph_to_config(&RingConfig {
1890 shape: Some(RingShape::Spsc),
1891 capacity: Some(64),
1892 locale: Some(BackingTarget::Anon),
1893 })
1894 .unwrap();
1895 assert_eq!(ring.pin_generation(), 0);
1896 }
1897
1898 #[test]
1899 fn compound_locale_change_drains_across_locales() {
1900 let dir = std::env::temp_dir().join(format!(
1901 "subetha_compound_locale_{}", std::process::id(),
1902 ));
1903 std::fs::create_dir_all(&dir).unwrap();
1904 let base = dir.join("compound");
1905 {
1906 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1907 ring.register_producer().unwrap();
1908 ring.register_consumer().unwrap();
1909 for i in 0..8u64 {
1910 let mut p = [0u8; 56];
1911 p[..8].copy_from_slice(&i.to_le_bytes());
1912 ring.try_send(0, &p).unwrap();
1913 }
1914
1915 // Capacity + locale in one transition: anon -> file.
1916 ring.morph_to_config(&RingConfig {
1917 shape: None,
1918 capacity: Some(256),
1919 locale: Some(BackingTarget::File(base.clone())),
1920 })
1921 .unwrap();
1922 assert_eq!(ring.pin_generation(), 1);
1923 assert_eq!(ring.current_capacity(), 256);
1924
1925 // Items pushed pre-morph (anon) and post-morph (file)
1926 // drain in send order across the locale boundary.
1927 ring.try_send(0, &{
1928 let mut p = [0u8; 56];
1929 p[..8].copy_from_slice(&99u64.to_le_bytes());
1930 p
1931 }).unwrap();
1932 let mut out = [0u8; 64];
1933 let mut got = Vec::new();
1934 while ring.try_recv(0, &mut out).is_ok() {
1935 got.push(u64::from_le_bytes(out[..8].try_into().unwrap()));
1936 }
1937 let mut expected: Vec<u64> = (0..8).collect();
1938 expected.push(99);
1939 assert_eq!(got, expected);
1940
1941 // Subsequent morphs allocate at the retargeted locale.
1942 ring.morph_capacity_to(512).unwrap();
1943 let file_backings: Vec<_> = std::fs::read_dir(&dir).unwrap()
1944 .filter_map(|e| e.ok())
1945 .filter(|e| e.file_name().to_string_lossy().contains("cap_512"))
1946 .collect();
1947 assert!(!file_backings.is_empty(),
1948 "post-retarget morphs must allocate file backings");
1949 }
1950 drop(std::fs::remove_dir_all(&dir));
1951 }
1952
1953 #[test]
1954 fn repatch_prewarm_config_full_target_hits() {
1955 let ring = CapacityAdaptiveRing::create_anon(4, 1, 64).unwrap();
1956 ring.register_producer().unwrap();
1957 ring.register_consumer().unwrap();
1958
1959 let target = RingConfig {
1960 shape: Some(RingShape::Mpmc),
1961 capacity: Some(1024),
1962 locale: None,
1963 };
1964 ring.prewarm_config(&target).unwrap();
1965 assert_eq!(ring.warm_capacity(), Some(1024));
1966 ring.morph_to_config(&target).unwrap();
1967 assert_eq!(ring.warm_hits(), 1,
1968 "repatch must consume the full-target prediction");
1969 assert_eq!(ring.ring_handle().current_shape(), RingShape::Mpmc);
1970 assert_eq!(ring.current_capacity(), 1024);
1971 }
1972
1973 #[test]
1974 fn warm_key_locale_mismatch_is_cold() {
1975 let dir = std::env::temp_dir().join(format!(
1976 "subetha_warm_locale_key_{}", std::process::id(),
1977 ));
1978 std::fs::create_dir_all(&dir).unwrap();
1979 {
1980 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
1981 // Prewarm at the CURRENT (anon) locale...
1982 ring.prewarm(256).unwrap();
1983 // ...then morph to the same capacity at a DIFFERENT
1984 // locale: the key mismatch must force the cold path.
1985 ring.morph_to_config(&RingConfig {
1986 shape: None,
1987 capacity: Some(256),
1988 locale: Some(BackingTarget::File(dir.join("keyed"))),
1989 })
1990 .unwrap();
1991 assert_eq!(ring.warm_hits(), 0,
1992 "an anon-built backing must never serve a file-locale morph");
1993 assert_eq!(ring.warm_capacity(), Some(256),
1994 "the mismatched prediction stays cached");
1995 ring.clear_warm();
1996 }
1997 drop(std::fs::remove_dir_all(&dir));
1998 }
1999
2000 #[test]
2001 fn stale_pops_counts_transition_items() {
2002 let ring = CapacityAdaptiveRing::create_anon(1, 1, 64).unwrap();
2003 ring.register_producer().unwrap();
2004 ring.register_consumer().unwrap();
2005 for i in 0..7u64 {
2006 let mut p = [0u8; 56];
2007 p[..8].copy_from_slice(&i.to_le_bytes());
2008 ring.try_send(0, &p).unwrap();
2009 }
2010 ring.morph_capacity_to(256).unwrap();
2011 ring.try_send(0, &[0x22u8; 56]).unwrap();
2012 let mut out = [0u8; 64];
2013 while ring.try_recv(0, &mut out).is_ok() {}
2014 assert_eq!(ring.stale_pops(), 7,
2015 "exactly the pre-morph items traverse the stale walk");
2016 }
2017}