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