Skip to main content

znippy_common/
slotpool.rs

1//! Magazine — shared large-slot buffer pool for the no-barrier slice pipeline.
2//!
3//! Design: TODO_NOW.md "THE FINAL SOLUTION 2026". Replaces the per-thread
4//! ChunkRevolver rings. One reader thread fills shared slots; N workers pull
5//! slices off a single global queue (built by the caller) and never wait for a
6//! slot to complete — a worker that finishes a slice grabs the next one from
7//! ANY slot.
8//!
9//! Lifecycle of a slot:
10//!   FREE → (reader `claim`s) FILLING → (reader `publish`es) DRAINING
11//!        → (workers `release_one` each slice; last one frees it) FREE
12//!
13//! Packing (done by the reader via `Clip`):
14//!   - small file (≤ slice_size): read into the slot at the cursor, committed
15//!     as one variable-width slice; many small files coalesce into one slot.
16//!   - big file (> slice_size): cut into slice_size pieces, each committed as a
17//!     slice; the file spills across as many slots as needed.
18//!   Invariant: a slice ⊆ one file AND ⊆ one slot (contiguous in its slot).
19//!
20//! Safety: while a slot is FILLING only the reader touches it (exclusive, via
21//! `writable`). After `publish` the reader drops the `Clip` and only workers
22//! read the slot (shared, via `Round::as_slice`). The slot is not handed back to
23//! the reader (`claim`) until its outstanding counter hits zero, so the
24//! &mut/&  windows never overlap.
25
26use crossbeam_channel::{Receiver, Sender, bounded};
27use std::sync::Arc;
28use std::sync::atomic::{AtomicUsize, Ordering};
29
30/// Slot size the pool uses when memory is plentiful and the input is large —
31/// the historical hardcoded value, kept exactly so the fat path is unchanged.
32pub const DEFAULT_SLOT_SIZE: usize = 200 * 1024 * 1024;
33
34/// Slot count the pool uses when memory is plentiful and the input is large.
35/// `DEFAULT_NUM_SLOTS * DEFAULT_SLOT_SIZE` = 1.6 GiB, the old unconditional
36/// reservation.
37pub const DEFAULT_NUM_SLOTS: usize = 8;
38
39/// The geometry of one pass's slot pool: how many slots, how big each is, and —
40/// the load-bearing part — the `slice_size` at which the reader cuts files.
41///
42/// ## Why `slice_size` is planned separately from `slot_size`
43/// `slice_size` is an **output-affecting** quantity: it is the big/small
44/// partition threshold in `compress_dir` AND the chunk length a big file is cut
45/// into, so it lands in `chunk_seq` / `fdata_offset` / every chunk checksum.
46/// Change it and the archive changes.
47///
48/// `slot_size` and `num_slots` are **not** output-affecting. They decide only how
49/// many slices coalesce into one buffer and how many buffers are in flight — the
50/// reader publishes and claims a fresh slot whenever the next slice does not fit
51/// ([`Clip::remaining`]), and the compress sink writes in producer order
52/// regardless. So the pool's memory footprint can be shrunk to fit the input or a
53/// memory budget while the bytes on disk stay **identical**.
54///
55/// That is exactly what [`PoolPlan::plan`] does: `slice_size` is pinned to
56/// `DEFAULT_SLOT_SIZE / num_workers` — the value the old hardcoded pool implied —
57/// and only the reservation moves.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct PoolPlan {
60    pub num_slots: usize,
61    pub slot_size: usize,
62    pub slice_size: usize,
63}
64
65impl PoolPlan {
66    /// Bytes this plan will allocate up front.
67    pub fn bytes(&self) -> u64 {
68        self.num_slots as u64 * self.slot_size as u64
69    }
70
71    /// The unshrunk geometry: `DEFAULT_NUM_SLOTS × DEFAULT_SLOT_SIZE` (1.6 GiB).
72    pub fn default_for(num_workers: usize) -> Self {
73        PoolPlan {
74            num_slots: DEFAULT_NUM_SLOTS,
75            slot_size: DEFAULT_SLOT_SIZE,
76            slice_size: Self::slice_size_for(num_workers),
77        }
78    }
79
80    /// The output-affecting cut length. Depends ONLY on `num_workers`, never on
81    /// how much of the pool we could afford — that invariant is what keeps a
82    /// memory-shrunk run byte-identical to a fat-box run on the same host.
83    pub fn slice_size_for(num_workers: usize) -> usize {
84        (DEFAULT_SLOT_SIZE / num_workers.max(1)).max(1)
85    }
86
87    /// Plan a pool that is no larger than it needs to be.
88    ///
89    /// The reservation is the smallest of three bounds:
90    ///  * **the old default** — `DEFAULT_NUM_SLOTS × DEFAULT_SLOT_SIZE`; this
91    ///    function never reserves *more* than the code did before,
92    ///  * **the input** — a pass that will read `input_bytes` can never keep more
93    ///    than `ceil(input_bytes / slice_size)` slices in flight, so slots beyond
94    ///    that are pure waste (a 5 MiB staging tree no longer reserves 1.6 GiB),
95    ///  * **the budget** — `budget_bytes` from the caller (see
96    ///    `common_config::slot_pool_budget_bytes`, which reads the cgroup limit),
97    ///    so a constrained pod shrinks instead of being OOM-killed.
98    ///
99    /// The floor is one slot of one slice: the pipeline must be able to hold the
100    /// largest cut it can produce, so it always makes progress no matter how
101    /// small the budget claims to be.
102    pub fn plan(input_bytes: u64, num_workers: usize, budget_bytes: u64) -> Self {
103        let slice_size = Self::slice_size_for(num_workers);
104        let slice = slice_size as u64;
105
106        // Slices the *default* pool holds. Reaching this bound means nothing is
107        // constrained — return the historical geometry EXACTLY (not a rounded
108        // reconstruction of it), so the fat/bench path is bit-for-bit the old code.
109        let default_slices = (DEFAULT_NUM_SLOTS as u64) * (DEFAULT_SLOT_SIZE as u64) / slice;
110
111        let needed = input_bytes.div_ceil(slice).max(1);
112        let affordable = (budget_bytes / slice).max(1);
113        let slices = needed.min(affordable).min(default_slices);
114
115        if slices >= default_slices {
116            return Self::default_for(num_workers);
117        }
118
119        // Spread the affordable slices over as many slots as we can (more slots =
120        // more reader/worker overlap). Round the per-slot count DOWN: rounding up
121        // would multiply the shortfall by `num_slots` and blow the budget the
122        // whole plan exists to respect.
123        let num_slots = (slices as usize).min(DEFAULT_NUM_SLOTS).max(1);
124        let per_slot = ((slices as usize) / num_slots).max(1);
125        let slot_size = (per_slot * slice_size).min(DEFAULT_SLOT_SIZE);
126
127        PoolPlan { num_slots, slot_size, slice_size }
128    }
129}
130
131/// Raw base pointer into a slot buffer. Send+Sync by the lifecycle discipline
132/// documented above (same contract as `chunkrevolver::SendPtr`).
133#[derive(Copy, Clone)]
134struct SlotPtr(*mut u8);
135unsafe impl Send for SlotPtr {}
136unsafe impl Sync for SlotPtr {}
137
138/// One unit of work. Borrows bytes inside slot `slot_id`; valid until the slot
139/// is released. Carries everything needed to build a `ChunkMeta` later.
140pub struct Round {
141    pub slot_id: u32,
142    ptr: *const u8,
143    pub len: usize,
144    pub skip: bool,
145    pub file_index: u64,
146    pub fdata_offset: u64,
147    pub chunk_seq: u32,
148}
149
150// Safety: the pointer addresses an immutable region of a published slot. No
151// thread writes the slot between publish and release, so sharing the read-only
152// view across worker threads is sound.
153unsafe impl Send for Round {}
154
155impl Round {
156    /// Borrow the slice bytes.
157    ///
158    /// Safety: the caller must hold this `Round` only while its slot is
159    /// unreleased (i.e. call `Ejector::release_one` for this slot only after
160    /// the returned borrow is dropped / the bytes are written).
161    pub unsafe fn as_slice<'a>(&self) -> &'a [u8] {
162        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
163    }
164}
165
166/// Shared handle workers use to release slots back to the reader. Cloneable
167/// (it is an `Arc` internally) — move a clone into each worker thread.
168#[derive(Clone)]
169pub struct Ejector {
170    inner: Arc<EjectorInner>,
171}
172
173struct EjectorInner {
174    free_tx: Sender<u32>,
175    outstanding: Vec<AtomicUsize>,
176}
177
178impl Ejector {
179    /// One slice of `slot_id` has been fully consumed (compressed, or pwritten
180    /// for the skip path). When the slot's last slice is done, the slot returns
181    /// to the free list so the reader may claim it again.
182    pub fn release_one(&self, slot_id: u32) {
183        let prev = self.inner.outstanding[slot_id as usize].fetch_sub(1, Ordering::AcqRel);
184        debug_assert!(prev >= 1, "release_one underflow on slot {slot_id}");
185        if prev == 1 {
186            // Never blocks: we only ever return ids we previously took out, so
187            // the bounded free channel can hold them all.
188            self.inner.free_tx.send(slot_id).ok();
189        }
190    }
191}
192
193/// Pool of `num_slots` reusable slot buffers. Owned by the reader thread.
194pub struct Magazine {
195    slot_size: usize,
196    slice_size: usize,
197    base: Vec<SlotPtr>,
198    _mem: Vec<Box<[u8]>>, // backing storage, kept alive for the pool's lifetime
199    free_rx: Receiver<u32>,
200    ret: Ejector,
201}
202
203impl Magazine {
204    /// Allocate the pool. `slice_size = slot_size / num_workers` (≥1), the
205    /// granularity at which the reader cuts big files. All slots start FREE.
206    pub fn new(num_slots: usize, slot_size: usize, num_workers: usize) -> Self {
207        let slice_size = (slot_size / num_workers.max(1)).max(1);
208        Self::with_slice_size(num_slots, slot_size, slice_size)
209    }
210
211    /// Allocate the pool from a [`PoolPlan`] — the memory-bounded entry point.
212    pub fn from_plan(plan: PoolPlan) -> Self {
213        Self::with_slice_size(plan.num_slots, plan.slot_size, plan.slice_size)
214    }
215
216    /// Allocate the pool with `slice_size` given **explicitly** rather than
217    /// derived from `slot_size`.
218    ///
219    /// This is the seam that lets the reservation shrink without moving a single
220    /// output byte: `slice_size` is the cut length (output-affecting — it lands in
221    /// chunk boundaries and checksums), `slot_size × num_slots` is only how much
222    /// buffer is held in flight. See [`PoolPlan`].
223    pub fn with_slice_size(num_slots: usize, slot_size: usize, slice_size: usize) -> Self {
224        assert!(num_slots > 0 && slot_size > 0 && slice_size > 0);
225        assert!(
226            slice_size <= slot_size,
227            "slice_size {slice_size} exceeds slot_size {slot_size}: a slice must fit \
228             contiguously in one slot"
229        );
230
231        let mut mem: Vec<Box<[u8]>> = (0..num_slots)
232            .map(|_| vec![0u8; slot_size].into_boxed_slice())
233            .collect();
234        // Take base pointers AFTER the buffers are in their final Vec slots; the
235        // heap data addresses are stable from here on (we never reallocate mem).
236        let base: Vec<SlotPtr> = mem.iter_mut().map(|b| SlotPtr(b.as_mut_ptr())).collect();
237
238        let (free_tx, free_rx) = bounded(num_slots);
239        for id in 0..num_slots as u32 {
240            free_tx.send(id).expect("free channel send during init");
241        }
242        let outstanding = (0..num_slots).map(|_| AtomicUsize::new(0)).collect();
243
244        Magazine {
245            slot_size,
246            slice_size,
247            base,
248            _mem: mem,
249            free_rx,
250            ret: Ejector { inner: Arc::new(EjectorInner { free_tx, outstanding }) },
251        }
252    }
253
254    pub fn slot_size(&self) -> usize {
255        self.slot_size
256    }
257    pub fn slice_size(&self) -> usize {
258        self.slice_size
259    }
260    pub fn num_slots(&self) -> usize {
261        self.base.len()
262    }
263
264    /// Handle workers use to release slots. Clone it into each worker thread.
265    pub fn returner(&self) -> Ejector {
266        self.ret.clone()
267    }
268
269    /// Reader: claim a free slot to fill. Blocks until one is free — this block
270    /// is the ONLY backpressure in the pipeline. Returns `None` once the pool is
271    /// shut down and drained (all return senders gone).
272    pub fn claim(&self) -> Option<Clip<'_>> {
273        let slot_id = self.free_rx.recv().ok()?;
274        Some(Clip { pool: self, slot_id, cursor: 0, slices: Vec::new() })
275    }
276}
277
278/// Reader-side handle for filling one claimed slot. Pack files into it via
279/// `writable` + `commit_slice`, then `publish` to hand the slices to the queue.
280pub struct Clip<'a> {
281    pool: &'a Magazine,
282    slot_id: u32,
283    cursor: usize,
284    slices: Vec<Round>,
285}
286
287impl<'a> Clip<'a> {
288    pub fn slot_id(&self) -> u32 {
289        self.slot_id
290    }
291
292    /// Free space left in the slot.
293    pub fn remaining(&self) -> usize {
294        self.pool.slot_size - self.cursor
295    }
296
297    /// Writable view at the cursor, up to `max` bytes (clamped to remaining).
298    /// The reader reads file bytes directly into this — no intermediate buffer.
299    /// The borrow must be dropped before calling `commit_slice`.
300    pub fn writable(&mut self, max: usize) -> &mut [u8] {
301        let n = max.min(self.remaining());
302        let base = self.pool.base[self.slot_id as usize].0;
303        // Safety: exclusive access — this slot is FILLING and only the reader
304        // (holding &mut self) touches it; no slices are published yet.
305        unsafe { std::slice::from_raw_parts_mut(base.add(self.cursor), n) }
306    }
307
308    /// Commit the `len` bytes just written at the cursor as one slice and advance.
309    pub fn commit_slice(
310        &mut self,
311        len: usize,
312        skip: bool,
313        file_index: u64,
314        fdata_offset: u64,
315        chunk_seq: u32,
316    ) {
317        debug_assert!(len <= self.remaining());
318        let base = self.pool.base[self.slot_id as usize].0 as *const u8;
319        // Safety: cursor stays within the slot (asserted above).
320        let ptr = unsafe { base.add(self.cursor) };
321        self.slices.push(Round {
322            slot_id: self.slot_id,
323            ptr,
324            len,
325            skip,
326            file_index,
327            fdata_offset,
328            chunk_seq,
329        });
330        self.cursor += len;
331    }
332
333    /// Publish the slot: arm its outstanding counter and return the slices for
334    /// the caller to push onto the global queue. An empty slot is immediately
335    /// returned to the free list (and an empty Vec is returned).
336    #[must_use]
337    pub fn publish(self) -> Vec<Round> {
338        let n = self.slices.len();
339        if n == 0 {
340            self.pool.ret.inner.free_tx.send(self.slot_id).ok();
341            return Vec::new();
342        }
343        self.pool.ret.inner.outstanding[self.slot_id as usize].store(n, Ordering::Release);
344        self.slices
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    /// `assert!`-with-emit for the Magazine slot-lifecycle invariants. The
353    /// `assert!` is the gate (a plain `cargo test` still fails on a broken
354    /// invariant); the emit turns the REAL verdict into a nornir matrix row under
355    /// `--features testmatrix`. HONEST detail: the Magazine is the forked slot
356    /// pool that replaced the per-thread `ChunkRevolver` rings (TODO_NOW "THE
357    /// FINAL SOLUTION 2026"); its `Send`/`Sync` is unsafe-by-discipline, so these
358    /// lifecycle tests are the standing proof the discipline holds.
359    macro_rules! assert_emit {
360        ($check:expr, $ok:expr, $($detail:tt)+) => {{
361            let __ok: bool = $ok;
362            let __detail = format!($($detail)+);
363            #[cfg(feature = "testmatrix")]
364            crate::functional_status("znippy-common/magazine", $check, __ok, &__detail);
365            assert!(__ok, "znippy-common/magazine::{} — {}", $check, __detail);
366        }};
367    }
368
369    #[test]
370    fn slot_returns_only_after_last_slice() {
371        // 2 slots, 64 bytes each, 4 workers → slice_size 16.
372        let pool = Magazine::new(2, 64, 4);
373        assert_eq!(pool.slice_size(), 16);
374        let ret = pool.returner();
375
376        // Claim both slots; pool is now empty.
377        let mut a = pool.claim().unwrap();
378        let _b = pool.claim().unwrap();
379
380        // Pack two small "files" into slot a.
381        let n = { a.writable(10).len().min(10) };
382        a.commit_slice(n, false, 0, 0, 0);
383        let n2 = { a.writable(20).len().min(20) };
384        a.commit_slice(n2, false, 1, 0, 0);
385        let slices = a.publish();
386        assert_eq!(slices.len(), 2);
387        let slot_a = slices[0].slot_id;
388
389        // Releasing the first slice must NOT free the slot yet.
390        ret.release_one(slot_a);
391        let held_after_first = pool.claim_now().is_none();
392        assert_emit!(
393            "slot_held_until_last_slice",
394            held_after_first,
395            "slot={slot_a} not reclaimable while 1/2 slices still outstanding"
396        );
397
398        // Releasing the last slice frees it.
399        ret.release_one(slot_a);
400        let freed_after_last = pool.claim_now().is_some();
401        assert_emit!(
402            "slot_freed_after_last_slice",
403            freed_after_last,
404            "slot={slot_a} reclaimed once outstanding counter hit zero"
405        );
406    }
407
408    /// MEMORY BOUND: the pool reservation follows the input and the budget, and
409    /// the cut length — the only output-affecting quantity — never moves.
410    ///
411    /// The old code reserved `8 × 200 MiB = 1.6 GiB` before opening a file,
412    /// whatever the input size and whatever the memory limit; that is what
413    /// OOM-killed a constrained backup pod. Inject real byte counts, assert real
414    /// reservations.
415    #[test]
416    fn pool_plan_is_bounded_by_input_and_budget() {
417        const WORKERS: usize = 16;
418        let slice = PoolPlan::slice_size_for(WORKERS);
419        let huge = u64::MAX / 4;
420        let default_bytes = (DEFAULT_NUM_SLOTS * DEFAULT_SLOT_SIZE) as u64;
421
422        // 1. UNCONSTRAINED — big input, plentiful RAM — is EXACTLY the old
423        //    geometry. The fat/bench path must not move.
424        let fat = PoolPlan::plan(huge, WORKERS, huge);
425        assert_eq!(
426            fat,
427            PoolPlan::default_for(WORKERS),
428            "an unconstrained plan must be the historical 8 × 200 MiB geometry"
429        );
430        assert_eq!(fat.bytes(), default_bytes, "unconstrained reservation is 1.6 GiB");
431
432        // 2. TINY INPUT, plentiful RAM: a 5 MiB staging tree no longer reserves
433        //    1.6 GiB — the reservation is bounded by the bytes there are to read.
434        let five_mib = 5 * 1024 * 1024;
435        let tiny = PoolPlan::plan(five_mib, WORKERS, huge);
436        assert!(
437            tiny.bytes() < default_bytes / 100,
438            "5 MiB of input reserved {} bytes — must be a sliver of the old 1.6 GiB",
439            tiny.bytes()
440        );
441        assert!(
442            tiny.bytes() >= five_mib,
443            "the pool must still be able to hold the input in flight ({} < {five_mib})",
444            tiny.bytes()
445        );
446
447        // 3. CONSTRAINED POD: big input, 64 MiB budget → the reservation obeys the
448        //    budget instead of OOM-killing the pod.
449        let budget = 64 * 1024 * 1024;
450        let pod = PoolPlan::plan(huge, WORKERS, budget);
451        assert!(
452            pod.bytes() <= budget,
453            "planned {} bytes over a {budget}-byte budget",
454            pod.bytes()
455        );
456
457        // 4. ABSURD BUDGET: still makes progress — one slot holding one slice.
458        let starved = PoolPlan::plan(huge, WORKERS, 1);
459        assert_eq!(starved.num_slots, 1);
460        assert_eq!(starved.slot_size, slice, "the floor is exactly one slice");
461
462        // 5. THE INVARIANT: slice_size is identical in every one of them, so all
463        //    four write byte-identical archives — only the RAM footprint differs.
464        for p in [fat, tiny, pod, starved] {
465            assert_eq!(
466                p.slice_size, slice,
467                "slice_size moved with the memory plan — that changes the big/small \
468                 partition and every big-file chunk boundary, i.e. the archive bytes"
469            );
470            assert!(p.slice_size <= p.slot_size, "a slice must fit in a slot");
471            assert!(p.num_slots >= 1 && p.slot_size >= 1);
472        }
473
474        // 6. SWEEP — the budget is honoured at EVERY size, not just the round
475        //    ones. (Rounding the per-slot slice count UP instead of DOWN broke
476        //    exactly here: a 64 MiB budget planned 110 MiB, because a 1-slice
477        //    shortfall got multiplied by 8 slots.)
478        for workers in [1usize, 2, 3, 7, 16, 29, 32, 64] {
479            let ss = PoolPlan::slice_size_for(workers) as u64;
480            for budget in [
481                1u64, 7, ss - 1, ss, ss + 1, ss * 3, ss * 8, ss * 9, ss * 17,
482                64 << 20, 100 << 20, 256 << 20, 1 << 30,
483            ] {
484                let p = PoolPlan::plan(huge, workers, budget);
485                assert_eq!(p.slice_size as u64, ss, "slice_size moved (workers={workers})");
486                assert!(p.slice_size <= p.slot_size, "slice must fit in a slot");
487                // The floor is one slice: below that the pipeline cannot run at
488                // all, so a budget under one slice buys exactly one slice.
489                let allowed = budget.max(ss).min(default_bytes);
490                assert!(
491                    p.bytes() <= allowed,
492                    "workers={workers} budget={budget}: planned {} bytes, allowed {allowed}",
493                    p.bytes()
494                );
495            }
496            // And the input bound holds independently of the budget.
497            for input in [0u64, 1, ss / 2, ss, ss * 5, ss * 300, 1 << 30] {
498                let p = PoolPlan::plan(input, workers, huge);
499                let allowed = input.max(ss).next_multiple_of(ss).min(default_bytes) + ss;
500                assert!(
501                    p.bytes() <= allowed,
502                    "workers={workers} input={input}: planned {} bytes for {input} of input",
503                    p.bytes()
504                );
505            }
506        }
507    }
508
509    /// A shrunk pool is a WORKING pool: pack and drain slices through the
510    /// smallest plan `plan` can return and prove the lifecycle still holds.
511    #[test]
512    fn a_starved_plan_still_cycles_slots() {
513        let plan = PoolPlan::plan(u64::MAX / 4, 8, 1);
514        let pool = Magazine::from_plan(plan);
515        assert_eq!(pool.num_slots(), 1, "starved plan is a single slot");
516        assert_eq!(pool.slice_size(), plan.slice_size);
517
518        let ret = pool.returner();
519        let mut clip = pool.claim().unwrap();
520        let n = clip.writable(plan.slice_size).len();
521        assert_eq!(n, plan.slice_size, "the one slot holds exactly one full slice");
522        clip.commit_slice(n, false, 0, 0, 0);
523        let slices = clip.publish();
524        assert_eq!(slices.len(), 1);
525        assert!(pool.claim_now().is_none(), "the only slot is still outstanding");
526        ret.release_one(slices[0].slot_id);
527        assert!(pool.claim_now().is_some(), "released slot returns to the free list");
528    }
529
530    #[test]
531    fn writable_clamps_to_remaining() {
532        let pool = Magazine::new(1, 32, 4);
533        let mut f = pool.claim().unwrap();
534        let full = f.writable(1000).len() == 32;
535        f.commit_slice(30, false, 0, 0, 0);
536        let clamped = f.remaining() == 2 && f.writable(1000).len() == 2;
537        assert_emit!(
538            "writable_clamps_to_remaining",
539            full && clamped,
540            "writable() never exceeds the slot's remaining bytes (no OOB into the next slot)"
541        );
542    }
543
544    impl Magazine {
545        /// Non-blocking claim, for tests only.
546        fn claim_now(&self) -> Option<u32> {
547            self.free_rx.try_recv().ok()
548        }
549    }
550}