Skip to main content

mz_ore/pool/
extent.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Swap-backed extents: the backing store for the buffer pool on nodes whose
17//! whole disk is provisioned as swap.
18//!
19//! An extent is a slot in the pool-owned [`ExtentArena`] holding the stored
20//! bytes of one chunk, produced by the chunk's [`ExtentCodec`] (lz4 in
21//! practice). "Write" encodes into the slot. The slot stays resident,
22//! forming the compressed-but-resident middle tier of the pool's ladder,
23//! until the pool's RSS target forces [`SwapExtent::pageout`], which pushes
24//! the pages to the swap device with `MADV_PAGEOUT`. "Read" issues
25//! `MADV_WILLNEED` ahead of the decode (and makes the pages resident
26//! again); "free" returns the slot to the arena with its pages discarded,
27//! which also drops any swapped copy for free. Chunk slots never reach the
28//! swap device: only these compressed extents are offered to it.
29//!
30//! The arena exists so that extent pages never belong to the global
31//! allocator: `MADV_PAGEOUT` over allocator-owned memory leaves swap-entry
32//! PTEs behind on freed ranges, which the allocator recycles into unrelated
33//! allocations that then major-fault reading dead compressed data. Arena
34//! regions are advised `MADV_NOHUGEPAGE` once at map time, so the reclaim
35//! never needs to split a large folio, and slot recycling never re-touches
36//! swap. A class whose region is exhausted degrades to a plain heap
37//! allocation (counted, never paged out) rather than failing.
38//!
39//! Pageout is observed, never assumed: `MADV_PAGEOUT` may decline any page
40//! and still return success (a kernel-internal pin fails isolation, the
41//! swap device may be full or absent), so after the advice the page table
42//! decides whether the extent left memory, and the extent stays fully
43//! resident for accounting until its entire range is unmapped. The
44//! observation reads pagemap present bits rather than `mincore`, which
45//! would count the clean swap-cache copies of successfully reclaimed pages
46//! as resident.
47
48use std::alloc::Layout;
49use std::io;
50use std::sync::Arc;
51use std::sync::atomic::{AtomicU64, Ordering};
52
53use crate::pool::region::{self, Region};
54use crate::pool::{ExtentCodec, max_stored_len};
55
56/// Consecutive incomplete pageout passes after which an extent stops being
57/// advised out until a read faults it back in. Transient declines are
58/// kernel-internal page pins that defeat the reclaim's isolation step (a
59/// folio sitting in another CPU's LRU batch, a momentary swap-slot
60/// allocation failure); they clear as soon as the pin drains, so a retry
61/// or two recovers them. Persistent declines (no swap device, an exhausted
62/// or unswappable cgroup) never clear, and further advice is pure
63/// page-table walking. Three passes covers the transient causes while
64/// bounding the wasted advice at two extra passes per extent.
65pub(crate) const PAGEOUT_RETRY_CAP: u8 = 3;
66
67/// The extent size-class ladder for a `page`-byte page size: `page`, then
68/// sizes of the form `2^k` and `3 * 2^(k-1)` bytes, up through the first
69/// class that fits [`max_stored_len`], the codec contract's worst case,
70/// over the largest chunk size class. Every class is a multiple of `page`
71/// (the sub-page mid class between `page` and `2 * page` is skipped), so
72/// slot-granular paging advice is exact on any kernel page size.
73/// Consecutive classes above the smallest are within 1.5x of each other,
74/// which bounds a stored payload's internal fragmentation below 1.5x
75/// (page-granular slack at the small end).
76fn extent_classes(page: usize) -> Vec<usize> {
77    let max_comp = max_stored_len(max_chunk_bytes());
78    let mut classes = vec![page];
79    let mut base = 2 * page;
80    loop {
81        classes.push(base);
82        if base >= max_comp {
83            break;
84        }
85        // `3 * 2^(k-1)`: a page multiple because `base` is at least two
86        // pages.
87        let mid = base + base / 2;
88        classes.push(mid);
89        if mid >= max_comp {
90            break;
91        }
92        base *= 2;
93    }
94    classes
95}
96
97/// The largest chunk payload the pool can ask an extent to back.
98fn max_chunk_bytes() -> usize {
99    region::SIZE_CLASSES[region::SIZE_CLASSES.len() - 1]
100}
101
102/// Pool-owned arena of anonymous-memory regions backing extents, one region
103/// per entry of the [`extent_classes`] ladder. Slots are allocated at write,
104/// freed cold (pages and any swap copy discarded) at extent drop, and never
105/// kept warm: a freed extent's compressed bytes are dead by definition.
106#[derive(Debug)]
107pub(crate) struct ExtentArena {
108    /// Ladder of extent class sizes in bytes, ascending; same order as
109    /// `regions`.
110    classes: Vec<usize>,
111    regions: Vec<Region>,
112    /// Extent writes that degraded to the heap because their class had no
113    /// free slot.
114    fallbacks: AtomicU64,
115}
116
117impl ExtentArena {
118    /// Reserves one region per extent class, `class_capacity_bytes` of
119    /// virtual space each. The reservation knob mirrors the slot regions'
120    /// so tests can exercise class exhaustion with small arenas.
121    pub(crate) fn new(class_capacity_bytes: usize) -> io::Result<ExtentArena> {
122        let classes = extent_classes(region::page_size());
123        let regions = classes
124            .iter()
125            .map(|&class_size| Region::new_nohuge(class_size, class_capacity_bytes))
126            .collect::<io::Result<Vec<_>>>()?;
127        Ok(ExtentArena {
128            classes,
129            regions,
130            fallbacks: AtomicU64::new(0),
131        })
132    }
133
134    /// Number of extent writes that degraded to the heap.
135    pub(crate) fn fallbacks(&self) -> u64 {
136        self.fallbacks.load(Ordering::Relaxed)
137    }
138
139    /// Allocates a slot fitting a compressed payload of `comp_len` bytes,
140    /// or `None` when the class is exhausted (the caller degrades to the
141    /// heap).
142    fn alloc(&self, comp_len: usize) -> Option<(usize, u32)> {
143        let class = self.classes.iter().position(|&c| c >= comp_len)?;
144        let (slot, _warm) = self.regions[class].alloc()?;
145        Some((class, slot))
146    }
147}
148
149/// One chunk's compressed backing copy.
150#[derive(Debug)]
151pub(crate) struct SwapExtent {
152    ptr: *mut u8,
153    /// Byte size of the backing allocation (the extent's class size, or the
154    /// heap layout on the fallback path): the granule the resident
155    /// accounting and the pageout operate on.
156    alloc_size: usize,
157    comp_len: usize,
158    /// Whether the extent's pages are (engine-)resident: set at write and by
159    /// [`SwapExtent::read_into`], cleared by a [`SwapExtent::pageout`] whose
160    /// residency observation found the whole range gone. Drives the pool's
161    /// `extent_resident_bytes` accounting; mutated only under the owning
162    /// chunk's state mutex.
163    resident: bool,
164    /// Consecutive pageout passes whose observation found pages still
165    /// resident. Reset by [`SwapExtent::read_into`]. At
166    /// [`PAGEOUT_RETRY_CAP`] the extent stops being advised out.
167    incomplete_passes: u8,
168    backing: Backing,
169}
170
171/// Where an extent's bytes live.
172#[derive(Debug)]
173enum Backing {
174    /// A slot in the pool's extent arena.
175    Arena {
176        arena: Arc<ExtentArena>,
177        class: usize,
178        slot: u32,
179    },
180    /// Global-allocator fallback for an exhausted class. Never paged out:
181    /// `MADV_PAGEOUT` over allocator-owned pages leaves swap-entry PTEs on
182    /// freed ranges for the allocator to recycle into unrelated
183    /// allocations, which is the failure the arena exists to avoid.
184    Heap { layout: Layout },
185}
186
187/// Retention policy for the thread-local compression scratch across
188/// [`SwapExtent::write`] calls.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub(crate) enum Scratch {
191    /// Keep the grown scratch for the next job: for spill threads, whose
192    /// job stream reuses it immediately.
193    Retain,
194    /// Release the scratch after the job: for inline compression on worker
195    /// threads, where a retained scratch would park the largest class's
196    /// worst case (~8 MiB) per thread indefinitely, invisible to every
197    /// gauge.
198    Shrink,
199}
200
201// SAFETY: the extent exclusively owns its backing (an arena slot handed out
202// by the region allocator, or a heap allocation); nothing else holds a
203// pointer into it, so moving the owner across threads is sound. All access
204// goes through the owning chunk's state mutex.
205unsafe impl Send for SwapExtent {}
206
207/// lz4 codec for the pool's own tests, mirroring the production codec that
208/// lives with the chunk implementation: a little-endian `u32` body-length
209/// prefix followed by one lz4 block.
210#[cfg(test)]
211#[derive(Debug)]
212pub(crate) struct TestLz4Codec;
213
214#[cfg(test)]
215pub(crate) static TEST_CODEC: TestLz4Codec = TestLz4Codec;
216
217#[cfg(test)]
218impl ExtentCodec for TestLz4Codec {
219    fn encode(&self, body: &[u8], out: &mut Vec<u8>) {
220        let max_out = lz4_flex::block::get_maximum_output_size(body.len());
221        out.resize(4 + max_out, 0);
222        let len = u32::try_from(body.len()).expect("chunk payloads fit u32");
223        out[..4].copy_from_slice(&len.to_le_bytes());
224        let compressed = lz4_flex::block::compress_into(body, &mut out[4..])
225            .expect("output sized to the maximum");
226        out.truncate(4 + compressed);
227    }
228
229    fn decode(&self, stored: &[u8], body: &mut [u8]) {
230        let prefix: [u8; 4] = stored[..4].try_into().expect("prefix length");
231        let len = usize::try_from(u32::from_le_bytes(prefix)).expect("length fits usize");
232        assert_eq!(
233            len,
234            body.len(),
235            "destination must match the encoded body length"
236        );
237        let written = lz4_flex::block::decompress_into(&stored[4..], body)
238            .expect("stored bytes hold a valid lz4 block");
239        assert_eq!(written, body.len(), "decoded length mismatch");
240    }
241}
242
243impl SwapExtent {
244    /// Encodes `data` through `codec` into a fresh extent, preferring an
245    /// arena slot and degrading to the heap when the payload's class is
246    /// exhausted. The pages stay resident; the pool's RSS-target
247    /// enforcement decides when [`SwapExtent::pageout`] pushes them to the
248    /// device.
249    ///
250    /// Encoding goes through a reused thread-local scratch buffer so the
251    /// extent slot can be chosen by the *actual* stored payload rather
252    /// than the codec's worst case. Worst-case sizing costs ~5.6× on
253    /// compressible data, in swap capacity and in swap write bandwidth per
254    /// eviction (the whole allocation is paged out), which at hydration
255    /// eviction rates backs up device writeback and bloats the working set
256    /// with swap-cache pages. `scratch` says whether the caller's thread
257    /// keeps the grown scratch for its next job.
258    pub(crate) fn write(
259        arena: &Arc<ExtentArena>,
260        data: &[u64],
261        codec: &dyn ExtentCodec,
262        scratch: Scratch,
263    ) -> SwapExtent {
264        use std::cell::RefCell;
265        thread_local! {
266            static SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
267        }
268        let bytes: &[u8] = bytemuck::cast_slice(data);
269        SCRATCH.with(|cell| {
270            let mut buf = cell.borrow_mut();
271            codec.encode(bytes, &mut buf);
272            let comp_len = buf.len();
273            debug_assert!(
274                comp_len <= max_stored_len(bytes.len()),
275                "codec output exceeds the extent-store bound",
276            );
277
278            let (ptr, alloc_size, backing) = match arena.alloc(comp_len) {
279                Some((class, slot)) => (
280                    arena.regions[class].slot_ptr(slot),
281                    arena.classes[class],
282                    Backing::Arena {
283                        arena: Arc::clone(arena),
284                        class,
285                        slot,
286                    },
287                ),
288                None => {
289                    arena.fallbacks.fetch_add(1, Ordering::Relaxed);
290                    let layout = heap_layout(comp_len);
291                    // SAFETY: `layout` has nonzero size (`comp_len` includes
292                    // the prefix).
293                    let ptr = unsafe { std::alloc::alloc(layout) };
294                    if ptr.is_null() {
295                        std::alloc::handle_alloc_error(layout);
296                    }
297                    (ptr, layout.size(), Backing::Heap { layout })
298                }
299            };
300            // The slack past `comp_len` is deliberately never touched: only
301            // the first `comp_len` bytes are ever read back, and writing the
302            // tail would fault pages the class's virtual slack is meant to
303            // keep free.
304            //
305            // SAFETY: the destination is exclusively owned here (a freshly
306            // allocated arena slot or heap allocation) and covers `comp_len`
307            // bytes (the selected class fits the payload; the heap layout is
308            // sized to it). The source is the scratch buffer, which cannot
309            // alias a fresh allocation.
310            unsafe {
311                std::ptr::copy_nonoverlapping(buf.as_ptr(), ptr, comp_len);
312            }
313            if scratch == Scratch::Shrink {
314                buf.clear();
315                // TODO: consider retaining some capacity here (shrinking to
316                // the next power of two, say) rather than releasing all of
317                // it; measure the realloc traffic before tuning.
318                buf.shrink_to_fit();
319            }
320            SwapExtent {
321                ptr,
322                alloc_size,
323                comp_len,
324                resident: true,
325                incomplete_passes: 0,
326                backing,
327            }
328        })
329    }
330
331    /// The byte size of the extent's allocation: the granule the resident
332    /// accounting and the pageout operate on.
333    pub(crate) fn alloc_size(&self) -> usize {
334        self.alloc_size
335    }
336
337    /// Whether the extent's pages are engine-resident (not pushed to the
338    /// device since the last write or read).
339    pub(crate) fn is_resident(&self) -> bool {
340        self.resident
341    }
342
343    /// Whether the extent's pageout retry budget is exhausted: consecutive
344    /// incomplete passes reached [`PAGEOUT_RETRY_CAP`], so callers stop
345    /// calling [`SwapExtent::pageout`] until a read resets the budget.
346    /// Heap-fallback extents are permanently capped: they are never advised
347    /// out and stay counted resident until freed.
348    pub(crate) fn pageout_capped(&self) -> bool {
349        match self.backing {
350            Backing::Heap { .. } => true,
351            Backing::Arena { .. } => self.incomplete_passes >= PAGEOUT_RETRY_CAP,
352        }
353    }
354
355    /// Hints the kernel to push the extent's pages to the swap device and
356    /// observes the result: returns `true`, marking the extent non-resident,
357    /// only when the observation finds the whole range unmapped (a page
358    /// whose clean copy lingers in the swap cache counts as reclaimed). An
359    /// incomplete pass leaves the extent fully resident for accounting and
360    /// spends one unit of the retry budget: a single pinned page keeps the
361    /// whole extent counted, which is the safe direction, and the retry
362    /// budget exists exactly for such transient pins. Cheap: the
363    /// compression is already paid, the madvise and page-table read are
364    /// microseconds, and the device write happens on the kernel's
365    /// asynchronous writeback path.
366    ///
367    /// Callers must not invoke this on a [`SwapExtent::pageout_capped`]
368    /// extent.
369    pub(crate) fn pageout(&mut self) -> bool {
370        debug_assert!(!self.pageout_capped());
371        region::pageout(self.ptr, self.alloc_size);
372        if region::nonresident(self.ptr, self.alloc_size) {
373            self.resident = false;
374            self.incomplete_passes = 0;
375            true
376        } else {
377            self.incomplete_passes += 1;
378            false
379        }
380    }
381
382    /// Compressed size in bytes, including the size prefix.
383    pub(crate) fn comp_len(&self) -> usize {
384        self.comp_len
385    }
386
387    /// Hints the kernel to swap the extent's pages back in ahead of a read.
388    pub(crate) fn prefetch(&self) {
389        region::willneed(self.ptr, self.alloc_size);
390    }
391
392    /// Decodes the extent through `codec` into `dst`, which must be exactly
393    /// the chunk's body length. Reading faults the pages back in, so the
394    /// extent is resident again afterwards; the caller owns the accounting
395    /// for that transition (the pool re-counts and re-enqueues it for the
396    /// RSS target).
397    pub(crate) fn read_into(&mut self, codec: &dyn ExtentCodec, dst: &mut [u8]) {
398        self.read_range_into(codec, dst.len(), 0, dst);
399    }
400
401    /// Decodes the byte range `[offset, offset + dst.len())` of the
402    /// extent's `body_len`-byte body into `dst`. The range must lie within
403    /// the body, and `body_len` must be the body's exact length (the codec
404    /// validates it against the stored form). Residency effects are those
405    /// of [`SwapExtent::read_into`] regardless of the range: the stored
406    /// form is one whole codec block, so any read faults and decodes the
407    /// entire extent, and a sub-range only narrows the final copy. A
408    /// backend whose stored form is rangeable (file extents reading with
409    /// `pread`, a sub-block-framed codec) can serve the range with
410    /// proportional I/O behind this same signature.
411    pub(crate) fn read_range_into(
412        &mut self,
413        codec: &dyn ExtentCodec,
414        body_len: usize,
415        offset: usize,
416        dst: &mut [u8],
417    ) {
418        self.resident = true;
419        // The decode faults every page back in, so prior incomplete
420        // pageout passes no longer describe the mapping and the retry
421        // budget starts over.
422        self.incomplete_passes = 0;
423        self.prefetch();
424        // SAFETY: the extent exclusively owns its backing, and the first
425        // `comp_len` bytes were initialized by `write`.
426        let buf = unsafe { std::slice::from_raw_parts(self.ptr, self.comp_len) };
427        let end = offset
428            .checked_add(dst.len())
429            .expect("range end overflows usize");
430        assert!(
431            end <= body_len,
432            "range end {end} exceeds the extent's body length {body_len}",
433        );
434        if offset == 0 && dst.len() == body_len {
435            codec.decode(buf, dst);
436            return;
437        }
438        // A sub-range still decodes the whole block, into a reused
439        // thread-local scratch, and copies the range out. Reads run on
440        // worker threads, so the scratch mirrors the write side's `Shrink`
441        // policy: capacity beyond the ~2 MiB chunk target is released after
442        // the copy rather than parked per worker.
443        use std::cell::RefCell;
444        thread_local! {
445            static SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
446        }
447        SCRATCH.with(|cell| {
448            let mut scratch = cell.borrow_mut();
449            scratch.resize(body_len, 0);
450            codec.decode(buf, &mut scratch);
451            dst.copy_from_slice(&scratch[offset..end]);
452            if scratch.capacity() > 2 << 20 {
453                scratch.clear();
454                scratch.shrink_to_fit();
455            }
456        });
457    }
458}
459
460/// The heap layout of a fallback extent for a compressed payload of
461/// `comp_len` bytes.
462fn heap_layout(comp_len: usize) -> Layout {
463    Layout::array::<u8>(comp_len).expect("valid extent layout")
464}
465
466impl Drop for SwapExtent {
467    fn drop(&mut self) {
468        match &self.backing {
469            Backing::Arena { arena, class, slot } => {
470                // Discarding the pages also drops any copy on the swap
471                // device (`MADV_DONTNEED` frees an anonymous range's swap
472                // entries), so the slot returns to the free list with no
473                // dead compressed data left to fault back in.
474                //
475                // SAFETY: the extent exclusively owns the slot and is being
476                // dropped, so no reference into it exists.
477                unsafe {
478                    region::dontneed(self.ptr, self.alloc_size);
479                }
480                arena.regions[*class].free(*slot, false);
481            }
482            Backing::Heap { layout } => {
483                // SAFETY: `ptr` was returned by `alloc` with exactly this
484                // `layout` in `write` and is deallocated exactly once, here.
485                unsafe {
486                    std::alloc::dealloc(self.ptr, *layout);
487                }
488            }
489        }
490    }
491}
492
493/// Kani proof harnesses over the extent ladder arithmetic; see the sibling
494/// module in `region.rs` for scope and run instructions.
495#[cfg(kani)]
496mod proofs {
497    use super::*;
498
499    /// The extent ladder's contract for every plausible page size: every
500    /// class is a page multiple, the top class fits the codec contract's
501    /// worst case over the largest chunk class, a class is found for every
502    /// payload up to that bound and fits it, and the selected class
503    /// overshoots the payload by less than 1.5x (with page-granular slack
504    /// at the smallest classes).
505    #[kani::proof]
506    #[kani::unwind(64)]
507    fn extent_ladder_fits_payloads() {
508        let page_shift: u32 = kani::any();
509        kani::assume(page_shift >= 12 && page_shift <= 16);
510        let page = 1usize << page_shift;
511        let classes = extent_classes(page);
512        let max_comp = max_stored_len(max_chunk_bytes());
513        for &class in &classes {
514            assert!(class % page == 0);
515        }
516        assert!(classes[classes.len() - 1] >= max_comp);
517        let comp_len: usize = kani::any();
518        kani::assume(comp_len >= 1 && comp_len <= max_comp);
519        let class = classes
520            .iter()
521            .position(|&c| c >= comp_len)
522            .expect("ladder covers every payload up to the bound");
523        assert!(classes[class] >= comp_len);
524        if class <= 1 {
525            assert!(classes[class] - comp_len < page);
526        } else {
527            // The previous class was too small and consecutive classes are
528            // within 3/2 of each other, so the allocation is below 1.5x the
529            // payload.
530            assert!(classes[class - 1] < comp_len);
531            assert!(classes[class] * 2 <= classes[class - 1] * 3);
532        }
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    /// A small arena for extent tests. Under Miri the region backing is real
541    /// interpreter heap rather than lazy virtual memory, so shrink further.
542    fn arena() -> Arc<ExtentArena> {
543        let capacity = if cfg!(miri) { 1 << 20 } else { 64 << 20 };
544        Arc::new(ExtentArena::new(capacity).expect("arena creation"))
545    }
546
547    #[mz_ore::test]
548    fn round_trip() {
549        let arena = arena();
550        let data: Vec<u64> = (0..10_000).map(|i| i * 37).collect();
551        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
552        assert!(extent.comp_len() > 4);
553        extent.prefetch();
554        let mut out = vec![0u64; data.len()];
555        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
556        assert_eq!(out, data);
557    }
558
559    #[mz_ore::test]
560    fn compressible_data_shrinks() {
561        let arena = arena();
562        let data = vec![42u64; 100_000];
563        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
564        assert!(extent.comp_len() < data.len() * 8 / 4);
565        let mut out = vec![0u64; data.len()];
566        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
567        assert_eq!(out, data);
568    }
569
570    /// The slot is sized to the compressed payload, not lz4's worst case:
571    /// extents must cost swap capacity and write bandwidth in proportion to
572    /// what they store.
573    #[mz_ore::test]
574    fn allocation_is_sized_to_payload() {
575        let arena = arena();
576        let data = vec![7u64; 100_000];
577        let extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
578        let page = region::page_size();
579        assert!(extent.alloc_size() >= extent.comp_len());
580        assert_eq!(extent.alloc_size() % page, 0, "class is a page multiple");
581        assert!(
582            extent.alloc_size() < data.len() * 8 / 8,
583            "compressible data must not be stored at worst-case size",
584        );
585        assert!(
586            extent.alloc_size() <= (extent.comp_len() * 3 / 2).max(2 * page),
587            "the ladder bounds internal fragmentation",
588        );
589    }
590
591    /// The ladder is page-granular, strictly ascending, covers lz4's worst
592    /// case over the largest chunk class, and steps by at most 1.5x above
593    /// the smallest class.
594    #[mz_ore::test]
595    fn ladder_shape() {
596        for page in [4096usize, 16384, 65536] {
597            let classes = extent_classes(page);
598            let max_comp = max_stored_len(max_chunk_bytes());
599            assert!(classes.windows(2).all(|w| w[0] < w[1]), "ascending");
600            assert!(classes.iter().all(|&c| c % page == 0), "page multiples");
601            assert!(classes[classes.len() - 1] >= max_comp, "covers worst case");
602            for w in classes.windows(2).skip(1) {
603                assert!(w[1] * 2 <= w[0] * 3, "steps at most 1.5x above the base");
604            }
605        }
606    }
607
608    /// An exhausted extent class degrades to a heap-backed extent that still
609    /// round-trips, is never advised out, and is counted; freeing an arena
610    /// extent lets the next write reuse its slot.
611    #[mz_ore::test]
612    fn exhaustion_falls_back_to_heap() {
613        // One page of capacity: the smallest class holds one slot, every
614        // larger class is empty.
615        let arena = Arc::new(ExtentArena::new(region::page_size()).expect("arena creation"));
616        let data = vec![3u64; 64];
617        let a = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
618        assert_eq!(arena.fallbacks(), 0);
619        assert!(!a.pageout_capped(), "arena extents start with retry budget");
620        let mut b = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
621        assert_eq!(arena.fallbacks(), 1, "second same-class write degrades");
622        assert!(b.pageout_capped(), "heap extents are never advised out");
623        assert!(b.is_resident());
624        let mut out = vec![0u64; data.len()];
625        b.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
626        assert_eq!(out, data);
627        // Freeing the arena extent frees its slot for the next write.
628        drop(a);
629        let c = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
630        assert_eq!(arena.fallbacks(), 1, "freed slot is reused, no fallback");
631        drop(c);
632        drop(b);
633    }
634
635    /// A ranged read returns exactly the corresponding slice of a full
636    /// read, at aligned and unaligned offsets, across page boundaries, and
637    /// at the body's edges.
638    #[mz_ore::test]
639    fn ranged_read_matches_full_read_slice() {
640        let arena = arena();
641        let data: Vec<u64> = (0..10_000u64).map(|i| i.wrapping_mul(0x9E37)).collect();
642        let bytes: &[u8] = bytemuck::cast_slice(&data);
643        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
644        let mut full = vec![0u8; bytes.len()];
645        extent.read_into(&TEST_CODEC, &mut full);
646        assert_eq!(full, bytes);
647        let page = region::page_size();
648        let ranges = [
649            (0, 8),
650            (8, 16),
651            (page - 3, page + 7),
652            (bytes.len() - 24, 24),
653            (0, bytes.len()),
654        ];
655        for (offset, len) in ranges {
656            let mut out = vec![0u8; len];
657            extent.read_range_into(&TEST_CODEC, bytes.len(), offset, &mut out);
658            assert_eq!(out, &full[offset..offset + len], "range ({offset}, {len})");
659        }
660    }
661
662    #[mz_ore::test]
663    #[should_panic(expected = "range end")]
664    fn ranged_read_out_of_bounds_panics() {
665        let arena = arena();
666        let data = vec![5u64; 64];
667        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
668        let mut out = vec![0u8; 16];
669        extent.read_range_into(&TEST_CODEC, 64 * 8, 64 * 8 - 8, &mut out);
670    }
671
672    #[mz_ore::test]
673    #[should_panic(expected = "destination must match")]
674    fn wrong_destination_length_panics() {
675        let arena = arena();
676        let data = vec![1u64; 16];
677        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
678        let mut out = vec![0u64; 8];
679        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
680    }
681
682    /// Residency follows the observation, not the advice: a declined pass
683    /// leaves the extent resident, an accepted one marks it gone.
684    #[mz_ore::test]
685    fn pageout_is_observed_not_trusted() {
686        let arena = arena();
687        let data = vec![9u64; 10_000];
688        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
689        assert!(extent.is_resident());
690        region::fake_residency::decline_next(1);
691        assert!(!extent.pageout(), "declined pass reports incomplete");
692        assert!(extent.is_resident(), "declined pass leaves it resident");
693        assert!(!extent.pageout_capped());
694        assert!(extent.pageout(), "accepted pass reports reclaimed");
695        assert!(!extent.is_resident());
696    }
697
698    /// Consecutive declined passes exhaust the retry budget. A read faults
699    /// the pages back in and restores it.
700    #[mz_ore::test]
701    fn pageout_retry_cap_and_read_reset() {
702        let arena = arena();
703        let data: Vec<u64> = (0..10_000).collect();
704        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
705        region::fake_residency::decline_next(u64::MAX);
706        let mut passes = 0u8;
707        while !extent.pageout_capped() {
708            assert!(!extent.pageout());
709            passes += 1;
710        }
711        assert_eq!(passes, PAGEOUT_RETRY_CAP, "capped after exactly the cap");
712        assert!(extent.is_resident(), "capped extent stays resident");
713        region::fake_residency::decline_next(0);
714        let mut out = vec![0u64; data.len()];
715        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
716        assert_eq!(out, data);
717        assert!(!extent.pageout_capped(), "a read resets the retry budget");
718        assert!(extent.pageout());
719    }
720}