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}