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            crate::soft_assert_no_log!(
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.
363    ///
364    /// Callers must not invoke this on a [`SwapExtent::pageout_capped`]
365    /// extent.
366    pub(crate) fn pageout(&mut self) -> bool {
367        crate::soft_assert_no_log!(!self.pageout_capped());
368        region::pageout(self.ptr, self.alloc_size);
369        if region::nonresident(self.ptr, self.alloc_size) {
370            self.resident = false;
371            self.incomplete_passes = 0;
372            true
373        } else {
374            self.incomplete_passes += 1;
375            false
376        }
377    }
378
379    /// Compressed size in bytes, including the size prefix.
380    pub(crate) fn comp_len(&self) -> usize {
381        self.comp_len
382    }
383
384    /// Hints the kernel to swap the extent's pages back in ahead of a read.
385    pub(crate) fn prefetch(&self) {
386        region::willneed(self.ptr, self.alloc_size);
387    }
388
389    /// Decodes the extent through `codec` into `dst`, which must be exactly
390    /// the chunk's body length. Reading faults the pages back in, so the
391    /// extent is resident again afterwards; the caller owns the accounting
392    /// for that transition (the pool re-counts and re-enqueues it for the
393    /// RSS target).
394    pub(crate) fn read_into(&mut self, codec: &dyn ExtentCodec, dst: &mut [u8]) {
395        self.read_range_into(codec, dst.len(), 0, dst);
396    }
397
398    /// Decodes the byte range `[offset, offset + dst.len())` of the
399    /// extent's `body_len`-byte body into `dst`. The range must lie within
400    /// the body, and `body_len` must be the body's exact length (the codec
401    /// validates it against the stored form). Residency effects are those
402    /// of [`SwapExtent::read_into`] regardless of the range: the stored
403    /// form is one whole codec block, so any read faults and decodes the
404    /// entire extent, and a sub-range only narrows the final copy. A
405    /// backend whose stored form is rangeable (file extents reading with
406    /// `pread`, a sub-block-framed codec) can serve the range with
407    /// proportional I/O behind this same signature.
408    pub(crate) fn read_range_into(
409        &mut self,
410        codec: &dyn ExtentCodec,
411        body_len: usize,
412        offset: usize,
413        dst: &mut [u8],
414    ) {
415        self.resident = true;
416        // The decode faults every page back in, so prior incomplete
417        // pageout passes no longer describe the mapping and the retry
418        // budget starts over.
419        self.incomplete_passes = 0;
420        self.prefetch();
421        // SAFETY: the extent exclusively owns its backing, and the first
422        // `comp_len` bytes were initialized by `write`.
423        let buf = unsafe { std::slice::from_raw_parts(self.ptr, self.comp_len) };
424        let end = offset
425            .checked_add(dst.len())
426            .expect("range end overflows usize");
427        assert!(
428            end <= body_len,
429            "range end {end} exceeds the extent's body length {body_len}",
430        );
431        if offset == 0 && dst.len() == body_len {
432            codec.decode(buf, dst);
433            return;
434        }
435        // A sub-range still decodes the whole block, into a reused
436        // thread-local scratch, and copies the range out. Reads run on
437        // worker threads, so the scratch mirrors the write side's `Shrink`
438        // policy: capacity beyond the ~2 MiB chunk target is released after
439        // the copy rather than parked per worker.
440        use std::cell::RefCell;
441        thread_local! {
442            static SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
443        }
444        SCRATCH.with(|cell| {
445            let mut scratch = cell.borrow_mut();
446            scratch.resize(body_len, 0);
447            codec.decode(buf, &mut scratch);
448            dst.copy_from_slice(&scratch[offset..end]);
449            if scratch.capacity() > 2 << 20 {
450                scratch.clear();
451                scratch.shrink_to_fit();
452            }
453        });
454    }
455}
456
457/// The heap layout of a fallback extent for a compressed payload of
458/// `comp_len` bytes.
459fn heap_layout(comp_len: usize) -> Layout {
460    Layout::array::<u8>(comp_len).expect("valid extent layout")
461}
462
463impl Drop for SwapExtent {
464    fn drop(&mut self) {
465        match &self.backing {
466            Backing::Arena { arena, class, slot } => {
467                // Discarding the pages also drops any copy on the swap
468                // device (`MADV_DONTNEED` frees an anonymous range's swap
469                // entries), so the slot returns to the free list with no
470                // dead compressed data left to fault back in.
471                //
472                // SAFETY: the extent exclusively owns the slot and is being
473                // dropped, so no reference into it exists.
474                unsafe {
475                    region::dontneed(self.ptr, self.alloc_size);
476                }
477                arena.regions[*class].free(*slot, false);
478            }
479            Backing::Heap { layout } => {
480                // SAFETY: `ptr` was returned by `alloc` with exactly this
481                // `layout` in `write` and is deallocated exactly once, here.
482                unsafe {
483                    std::alloc::dealloc(self.ptr, *layout);
484                }
485            }
486        }
487    }
488}
489
490/// Kani proof harnesses over the extent ladder arithmetic; see the sibling
491/// module in `region.rs` for scope and run instructions.
492#[cfg(kani)]
493mod proofs {
494    use super::*;
495
496    /// The extent ladder's contract for every plausible page size: every
497    /// class is a page multiple, the top class fits the codec contract's
498    /// worst case over the largest chunk class, a class is found for every
499    /// payload up to that bound and fits it, and the selected class
500    /// overshoots the payload by less than 1.5x (with page-granular slack
501    /// at the smallest classes).
502    #[kani::proof]
503    #[kani::unwind(64)]
504    fn extent_ladder_fits_payloads() {
505        let page_shift: u32 = kani::any();
506        kani::assume(page_shift >= 12 && page_shift <= 16);
507        let page = 1usize << page_shift;
508        let classes = extent_classes(page);
509        let max_comp = max_stored_len(max_chunk_bytes());
510        for &class in &classes {
511            assert!(class % page == 0);
512        }
513        assert!(classes[classes.len() - 1] >= max_comp);
514        let comp_len: usize = kani::any();
515        kani::assume(comp_len >= 1 && comp_len <= max_comp);
516        let class = classes
517            .iter()
518            .position(|&c| c >= comp_len)
519            .expect("ladder covers every payload up to the bound");
520        assert!(classes[class] >= comp_len);
521        if class <= 1 {
522            assert!(classes[class] - comp_len < page);
523        } else {
524            // The previous class was too small and consecutive classes are
525            // within 3/2 of each other, so the allocation is below 1.5x the
526            // payload.
527            assert!(classes[class - 1] < comp_len);
528            assert!(classes[class] * 2 <= classes[class - 1] * 3);
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    /// A small arena for extent tests. Under Miri the region backing is real
538    /// interpreter heap rather than lazy virtual memory, so shrink further.
539    fn arena() -> Arc<ExtentArena> {
540        let capacity = if cfg!(miri) { 1 << 20 } else { 64 << 20 };
541        Arc::new(ExtentArena::new(capacity).expect("arena creation"))
542    }
543
544    #[mz_ore::test]
545    fn round_trip() {
546        let arena = arena();
547        let data: Vec<u64> = (0..10_000).map(|i| i * 37).collect();
548        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
549        assert!(extent.comp_len() > 4);
550        extent.prefetch();
551        let mut out = vec![0u64; data.len()];
552        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
553        assert_eq!(out, data);
554    }
555
556    #[mz_ore::test]
557    fn compressible_data_shrinks() {
558        let arena = arena();
559        let data = vec![42u64; 100_000];
560        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
561        assert!(extent.comp_len() < data.len() * 8 / 4);
562        let mut out = vec![0u64; data.len()];
563        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
564        assert_eq!(out, data);
565    }
566
567    /// The slot is sized to the compressed payload, not lz4's worst case:
568    /// extents must cost swap capacity and write bandwidth in proportion to
569    /// what they store.
570    #[mz_ore::test]
571    fn allocation_is_sized_to_payload() {
572        let arena = arena();
573        let data = vec![7u64; 100_000];
574        let extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
575        let page = region::page_size();
576        assert!(extent.alloc_size() >= extent.comp_len());
577        assert_eq!(extent.alloc_size() % page, 0, "class is a page multiple");
578        assert!(
579            extent.alloc_size() < data.len() * 8 / 8,
580            "compressible data must not be stored at worst-case size",
581        );
582        assert!(
583            extent.alloc_size() <= (extent.comp_len() * 3 / 2).max(2 * page),
584            "the ladder bounds internal fragmentation",
585        );
586    }
587
588    #[mz_ore::test]
589    fn ladder_shape() {
590        for page in [4096usize, 16384, 65536] {
591            let classes = extent_classes(page);
592            let max_comp = max_stored_len(max_chunk_bytes());
593            assert!(classes.windows(2).all(|w| w[0] < w[1]), "ascending");
594            assert!(classes.iter().all(|&c| c % page == 0), "page multiples");
595            assert!(classes[classes.len() - 1] >= max_comp, "covers worst case");
596            for w in classes.windows(2).skip(1) {
597                assert!(w[1] * 2 <= w[0] * 3, "steps at most 1.5x above the base");
598            }
599        }
600    }
601
602    /// An exhausted extent class degrades to a heap-backed extent that still
603    /// round-trips, is never advised out, and is counted; freeing an arena
604    /// extent lets the next write reuse its slot.
605    #[mz_ore::test]
606    fn exhaustion_falls_back_to_heap() {
607        // One page of capacity: the smallest class holds one slot, every
608        // larger class is empty.
609        let arena = Arc::new(ExtentArena::new(region::page_size()).expect("arena creation"));
610        let data = vec![3u64; 64];
611        let a = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
612        assert_eq!(arena.fallbacks(), 0);
613        assert!(!a.pageout_capped(), "arena extents start with retry budget");
614        let mut b = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
615        assert_eq!(arena.fallbacks(), 1, "second same-class write degrades");
616        assert!(b.pageout_capped(), "heap extents are never advised out");
617        assert!(b.is_resident());
618        let mut out = vec![0u64; data.len()];
619        b.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
620        assert_eq!(out, data);
621        // Freeing the arena extent frees its slot for the next write.
622        drop(a);
623        let c = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
624        assert_eq!(arena.fallbacks(), 1, "freed slot is reused, no fallback");
625        drop(c);
626        drop(b);
627    }
628
629    /// A ranged read returns exactly the corresponding slice of a full
630    /// read, at aligned and unaligned offsets, across page boundaries, and
631    /// at the body's edges.
632    #[mz_ore::test]
633    fn ranged_read_matches_full_read_slice() {
634        let arena = arena();
635        let data: Vec<u64> = (0..10_000u64).map(|i| i.wrapping_mul(0x9E37)).collect();
636        let bytes: &[u8] = bytemuck::cast_slice(&data);
637        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
638        let mut full = vec![0u8; bytes.len()];
639        extent.read_into(&TEST_CODEC, &mut full);
640        assert_eq!(full, bytes);
641        let page = region::page_size();
642        let ranges = [
643            (0, 8),
644            (8, 16),
645            (page - 3, page + 7),
646            (bytes.len() - 24, 24),
647            (0, bytes.len()),
648        ];
649        for (offset, len) in ranges {
650            let mut out = vec![0u8; len];
651            extent.read_range_into(&TEST_CODEC, bytes.len(), offset, &mut out);
652            assert_eq!(out, &full[offset..offset + len], "range ({offset}, {len})");
653        }
654    }
655
656    #[mz_ore::test]
657    #[should_panic(expected = "range end")]
658    fn ranged_read_out_of_bounds_panics() {
659        let arena = arena();
660        let data = vec![5u64; 64];
661        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
662        let mut out = vec![0u8; 16];
663        extent.read_range_into(&TEST_CODEC, 64 * 8, 64 * 8 - 8, &mut out);
664    }
665
666    #[mz_ore::test]
667    #[should_panic(expected = "destination must match")]
668    fn wrong_destination_length_panics() {
669        let arena = arena();
670        let data = vec![1u64; 16];
671        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
672        let mut out = vec![0u64; 8];
673        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
674    }
675
676    #[mz_ore::test]
677    fn pageout_is_observed_not_trusted() {
678        let arena = arena();
679        let data = vec![9u64; 10_000];
680        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
681        assert!(extent.is_resident());
682        region::fake_residency::decline_next(1);
683        assert!(!extent.pageout(), "declined pass reports incomplete");
684        assert!(extent.is_resident(), "declined pass leaves it resident");
685        assert!(!extent.pageout_capped());
686        assert!(extent.pageout(), "accepted pass reports reclaimed");
687        assert!(!extent.is_resident());
688    }
689
690    #[mz_ore::test]
691    fn pageout_retry_cap_and_read_reset() {
692        let arena = arena();
693        let data: Vec<u64> = (0..10_000).collect();
694        let mut extent = SwapExtent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
695        region::fake_residency::decline_next(u64::MAX);
696        let mut passes = 0u8;
697        while !extent.pageout_capped() {
698            assert!(!extent.pageout());
699            passes += 1;
700        }
701        assert_eq!(passes, PAGEOUT_RETRY_CAP, "capped after exactly the cap");
702        assert!(extent.is_resident(), "capped extent stays resident");
703        region::fake_residency::decline_next(0);
704        let mut out = vec![0u64; data.len()];
705        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
706        assert_eq!(out, data);
707        assert!(!extent.pageout_capped(), "a read resets the retry budget");
708        assert!(extent.pageout());
709    }
710}