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//! The extent type and the arena that holds extents in memory, the first
17//! home of every extent whatever the pool's backend.
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 [`Extent::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//!
48//! On a pool with a file store, enforcement demotes an arena extent to a
49//! file slot instead of advising it out: the extent's home moves from
50//! `Arena` through `Demoting` (written without the chunk lock) to `File`,
51//! which is terminal, and reads go to the file.
52
53use std::alloc::Layout;
54use std::io;
55use std::sync::Arc;
56use std::sync::atomic::{AtomicU64, Ordering};
57
58use crate::pool::file::{self, AlignedBuf, FileSlot, FileStore, WriteError};
59use crate::pool::region::{self, Region};
60use crate::pool::{ExtentCodec, max_stored_len};
61
62/// Consecutive incomplete pageout passes after which an extent stops being
63/// advised out until a read faults it back in. Transient declines are
64/// kernel-internal page pins that defeat the reclaim's isolation step (a
65/// folio sitting in another CPU's LRU batch, a momentary swap-slot
66/// allocation failure); they clear as soon as the pin drains, so a retry
67/// or two recovers them. Persistent declines (no swap device, an exhausted
68/// or unswappable cgroup) never clear, and further advice is pure
69/// page-table walking. Three passes covers the transient causes while
70/// bounding the wasted advice at two extra passes per extent.
71pub(crate) const PAGEOUT_RETRY_CAP: u8 = 3;
72
73/// Capacity a thread keeps in its read-side scratch buffers across reads.
74/// Reads run on worker threads, so capacity beyond the ~2 MiB chunk target
75/// is released after each read rather than parked per worker.
76const READ_SCRATCH_RETAIN: usize = 2 << 20;
77
78/// The extent size-class ladder for a `page`-byte page size: `page`, then
79/// sizes of the form `2^k` and `3 * 2^(k-1)` bytes, up through the first
80/// class that fits [`max_stored_len`], the codec contract's worst case,
81/// over the largest chunk size class. Every class is a multiple of `page`
82/// (the sub-page mid class between `page` and `2 * page` is skipped), so
83/// slot-granular paging advice is exact on any kernel page size.
84/// Consecutive classes above the smallest are within 1.5x of each other,
85/// which bounds a stored payload's internal fragmentation below 1.5x
86/// (page-granular slack at the small end).
87pub(crate) fn extent_classes(page: usize) -> Vec<usize> {
88    let max_comp = max_stored_len(max_chunk_bytes());
89    let mut classes = vec![page];
90    let mut base = 2 * page;
91    loop {
92        classes.push(base);
93        if base >= max_comp {
94            break;
95        }
96        // `3 * 2^(k-1)`: a page multiple because `base` is at least two
97        // pages.
98        let mid = base + base / 2;
99        classes.push(mid);
100        if mid >= max_comp {
101            break;
102        }
103        base *= 2;
104    }
105    classes
106}
107
108/// The largest chunk payload the pool can ask an extent to back.
109fn max_chunk_bytes() -> usize {
110    region::SIZE_CLASSES[region::SIZE_CLASSES.len() - 1]
111}
112
113/// Pool-owned arena of anonymous-memory regions backing extents, one region
114/// per entry of the [`extent_classes`] ladder. Slots are allocated at write,
115/// freed cold (pages and any swap copy discarded) at extent drop, and never
116/// kept warm: a freed extent's compressed bytes are dead by definition.
117#[derive(Debug)]
118pub(crate) struct ExtentArena {
119    /// Ladder of extent class sizes in bytes, ascending; same order as
120    /// `regions`.
121    classes: Vec<usize>,
122    regions: Vec<Region>,
123    /// Extent writes that degraded to the heap because their class had no
124    /// free slot.
125    fallbacks: AtomicU64,
126}
127
128impl ExtentArena {
129    /// Reserves one region per extent class, `class_capacity_bytes` of
130    /// virtual space each. The reservation knob mirrors the slot regions'
131    /// so tests can exercise class exhaustion with small arenas.
132    pub(crate) fn new(class_capacity_bytes: usize) -> io::Result<ExtentArena> {
133        let classes = extent_classes(region::page_size());
134        let regions = classes
135            .iter()
136            .map(|&class_size| Region::new_nohuge(class_size, class_capacity_bytes))
137            .collect::<io::Result<Vec<_>>>()?;
138        Ok(ExtentArena {
139            classes,
140            regions,
141            fallbacks: AtomicU64::new(0),
142        })
143    }
144
145    /// The class ladder, ascending.
146    pub(crate) fn classes(&self) -> &[usize] {
147        &self.classes
148    }
149
150    /// Number of extent writes that degraded to the heap.
151    pub(crate) fn fallbacks(&self) -> u64 {
152        self.fallbacks.load(Ordering::Relaxed)
153    }
154
155    /// Allocates a slot fitting a compressed payload of `comp_len` bytes,
156    /// or `None` when the class is exhausted (the caller degrades to the
157    /// heap).
158    fn alloc(&self, comp_len: usize) -> Option<(usize, u32)> {
159        let class = self.classes.iter().position(|&c| c >= comp_len)?;
160        let (slot, _warm) = self.regions[class].alloc()?;
161        Some((class, slot))
162    }
163}
164
165/// One chunk's compressed backing copy.
166#[derive(Debug)]
167pub(crate) struct Extent {
168    /// Byte size of the backing allocation (the extent's class size, or the
169    /// heap layout on the fallback path): the granule the resident
170    /// accounting and the pageout operate on.
171    alloc_size: usize,
172    comp_len: usize,
173    /// Whether the extent's pages are (engine-)resident: set at write and by
174    /// [`Extent::read_into`], cleared by a [`Extent::pageout`] whose
175    /// residency observation found the whole range gone. Meaningful only in
176    /// the `Arena` and `Heap` homes, see [`Extent::is_resident`]. Mutated
177    /// only under the owning chunk's state mutex.
178    resident: bool,
179    /// Consecutive pageout passes whose observation found pages still
180    /// resident. Reset by [`Extent::read_into`]. At
181    /// [`PAGEOUT_RETRY_CAP`] the extent stops being advised out.
182    incomplete_passes: u8,
183    /// Checksum of the first `comp_len` stored bytes, set when a demotion
184    /// commits and verified by every file read.
185    crc: u32,
186    /// Whether the extent was read from its file since its demotion.
187    read_since_demotion: bool,
188    home: Home,
189}
190
191/// Where an extent's bytes live.
192#[derive(Debug)]
193enum Home {
194    /// A slot in the pool's extent arena.
195    Arena(ArenaSlot),
196    /// Global-allocator fallback for an exhausted class. Never paged out:
197    /// `MADV_PAGEOUT` over allocator-owned pages leaves swap-entry PTEs on
198    /// freed ranges for the allocator to recycle into unrelated
199    /// allocations, which is the failure the arena exists to avoid.
200    Heap { ptr: *mut u8, layout: Layout },
201    /// File mode, transient: the arena slot still holds the bytes, which
202    /// are immutable and readable while a demoter writes them to
203    /// `file_slot` without the chunk lock. The demoter owns the extent and
204    /// the transition out of this home: a free of the chunk leaves the
205    /// extent in place for the demoter's commit to drop, which also keeps
206    /// the file slot from being reused mid-write.
207    Demoting {
208        arena: ArenaSlot,
209        file_slot: FileSlot,
210    },
211    /// File mode: the bytes live only in `slot` of `store`. Terminal until
212    /// the extent is dropped, so a reader that keeps the extent alive (by
213    /// borrowing its chunk's handle) may use the location without the chunk
214    /// lock.
215    File {
216        store: Arc<FileStore>,
217        slot: FileSlot,
218    },
219}
220
221impl Home {
222    /// The in-memory copy of the stored bytes, `None` for a file home.
223    fn ptr(&self) -> Option<*mut u8> {
224        match self {
225            Home::Arena(arena) | Home::Demoting { arena, .. } => Some(arena.ptr),
226            Home::Heap { ptr, .. } => Some(*ptr),
227            Home::File { .. } => None,
228        }
229    }
230}
231
232/// An extent's slot in the pool's extent arena.
233#[derive(Debug, Clone)]
234struct ArenaSlot {
235    arena: Arc<ExtentArena>,
236    class: usize,
237    slot: u32,
238    ptr: *mut u8,
239}
240
241impl ArenaSlot {
242    /// Returns the slot to its region with its pages discarded.
243    /// Discarding the pages also drops any copy on the swap device
244    /// (`MADV_DONTNEED` frees an anonymous range's swap entries), so the slot
245    /// returns to the free list with no dead compressed data left to fault back
246    /// in.
247    ///
248    /// # Safety
249    ///
250    /// The caller owned the slot, and no reference into it exists.
251    unsafe fn free(&self, alloc_size: usize) {
252        // SAFETY: per the function contract.
253        unsafe {
254            region::dontneed(self.ptr, alloc_size);
255        }
256        self.arena.regions[self.class].free(self.slot, false);
257    }
258}
259
260/// Where a `File`-home extent's bytes live.
261#[derive(Debug)]
262pub(crate) struct FileLocation {
263    store: Arc<FileStore>,
264    slot: FileSlot,
265    comp_len: usize,
266    crc: u32,
267}
268
269/// The arena bytes of a `Demoting` extent, captured under the chunk lock
270/// for the demoter's unlocked write.
271#[derive(Debug)]
272pub(crate) struct DemotionSource {
273    ptr: *const u8,
274    comp_len: usize,
275    alloc_size: usize,
276    file_slot: FileSlot,
277}
278
279impl DemotionSource {
280    /// Checksums the stored bytes and writes them to the file slot,
281    /// returning the checksum and the write's result.
282    ///
283    /// # Safety
284    ///
285    /// The extent this was captured from must stay in its `Demoting` home
286    /// for the duration of the call: not dropped, and not moved to another
287    /// home.
288    pub(crate) unsafe fn write(&self, store: &FileStore) -> (u32, Result<usize, WriteError>) {
289        // SAFETY: the arena slot is `alloc_size` bytes of mapped memory
290        // owned by the extent, which the caller keeps `Demoting`, so the
291        // slot is neither freed nor reused. Its bytes are immutable in that
292        // home: nothing writes an extent's slot after `Extent::write`, and
293        // concurrent readers only read. Bytes past `comp_len` are
294        // initialized memory of the mapping with unspecified contents. The
295        // store transfers whole pages, reads consume only `comp_len` bytes,
296        // and the checksum covers only those, so the tail's contents do not
297        // matter.
298        let bytes = unsafe { std::slice::from_raw_parts(self.ptr, self.alloc_size) };
299        let crc = file::crc(&bytes[..self.comp_len]);
300        (crc, store.write(self.file_slot, bytes, self.comp_len))
301    }
302}
303
304/// Retention policy for the thread-local compression scratch across
305/// [`Extent::write`] calls.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub(crate) enum Scratch {
308    /// Keep the grown scratch for the next job: for spill threads, whose
309    /// job stream reuses it immediately.
310    Retain,
311    /// Release the scratch after the job: for inline compression on worker
312    /// threads, where a retained scratch would park the largest class's
313    /// worst case (~8 MiB) per thread indefinitely, invisible to every
314    /// gauge.
315    Shrink,
316}
317
318// SAFETY: the extent exclusively owns its backing (an arena slot handed out
319// by the region allocator, a heap allocation, or a file slot), and its raw
320// pointers point into that backing only, so moving the owner across threads
321// is sound.
322unsafe impl Send for Extent {}
323
324impl Extent {
325    /// Encodes `data` through `codec` into a fresh extent, preferring an
326    /// arena slot and degrading to the heap when the payload's class is
327    /// exhausted. The pages stay resident; the pool's RSS-target
328    /// enforcement decides when [`Extent::pageout`] pushes them to the
329    /// device.
330    ///
331    /// Encoding goes through a reused thread-local scratch buffer so the
332    /// extent slot can be chosen by the *actual* stored payload rather
333    /// than the codec's worst case. Worst-case sizing costs ~5.6× on
334    /// compressible data, in swap capacity and in swap write bandwidth per
335    /// eviction (the whole allocation is paged out), which at hydration
336    /// eviction rates backs up device writeback and bloats the working set
337    /// with swap-cache pages. `scratch` says whether the caller's thread
338    /// keeps the grown scratch for its next job.
339    pub(crate) fn write(
340        arena: &Arc<ExtentArena>,
341        data: &[u64],
342        codec: &dyn ExtentCodec,
343        scratch: Scratch,
344    ) -> Extent {
345        use std::cell::RefCell;
346        thread_local! {
347            static SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
348        }
349        let bytes: &[u8] = bytemuck::cast_slice(data);
350        SCRATCH.with(|cell| {
351            let mut buf = cell.borrow_mut();
352            codec.encode(bytes, &mut buf);
353            let comp_len = buf.len();
354            crate::soft_assert_no_log!(
355                comp_len <= max_stored_len(bytes.len()),
356                "codec output exceeds the extent-store bound",
357            );
358
359            let (home, alloc_size) = match arena.alloc(comp_len) {
360                Some((class, slot)) => (
361                    Home::Arena(ArenaSlot {
362                        arena: Arc::clone(arena),
363                        class,
364                        slot,
365                        ptr: arena.regions[class].slot_ptr(slot),
366                    }),
367                    arena.classes[class],
368                ),
369                None => {
370                    arena.fallbacks.fetch_add(1, Ordering::Relaxed);
371                    let layout = heap_layout(comp_len);
372                    // SAFETY: `layout` has nonzero size (`comp_len` includes
373                    // the prefix).
374                    let ptr = unsafe { std::alloc::alloc(layout) };
375                    if ptr.is_null() {
376                        std::alloc::handle_alloc_error(layout);
377                    }
378                    (Home::Heap { ptr, layout }, layout.size())
379                }
380            };
381            // The slack past `comp_len` is deliberately never touched: only
382            // the first `comp_len` bytes are ever read back, and writing the
383            // tail would fault pages the class's virtual slack is meant to
384            // keep free.
385            //
386            // SAFETY: the destination is exclusively owned here (a freshly
387            // allocated arena slot or heap allocation) and covers `comp_len`
388            // bytes (the selected class fits the payload; the heap layout is
389            // sized to it). The source is the scratch buffer, which cannot
390            // alias a fresh allocation.
391            unsafe {
392                std::ptr::copy_nonoverlapping(
393                    buf.as_ptr(),
394                    home.ptr().expect("fresh extent is in memory"),
395                    comp_len,
396                );
397            }
398            if scratch == Scratch::Shrink {
399                buf.clear();
400                // TODO: consider retaining some capacity here (shrinking to
401                // the next power of two, say) rather than releasing all of
402                // it; measure the realloc traffic before tuning.
403                buf.shrink_to_fit();
404            }
405            Extent {
406                alloc_size,
407                comp_len,
408                resident: true,
409                incomplete_passes: 0,
410                crc: 0,
411                read_since_demotion: false,
412                home,
413            }
414        })
415    }
416
417    /// The in-memory copy of the stored bytes. Panics for a `File` extent,
418    /// which has none.
419    fn ptr(&self) -> *mut u8 {
420        self.home.ptr().expect("extent has an in-memory copy")
421    }
422
423    /// The byte size of the extent's allocation: the granule the resident
424    /// accounting and the pageout operate on.
425    pub(crate) fn alloc_size(&self) -> usize {
426        self.alloc_size
427    }
428
429    /// Whether the extent's pages are engine-resident (not pushed to the
430    /// device since the last write or read). A `Demoting` extent is
431    /// resident until its demotion commits, and a `File` extent never is.
432    pub(crate) fn is_resident(&self) -> bool {
433        match self.home {
434            Home::Arena(_) | Home::Heap { .. } => self.resident,
435            Home::Demoting { .. } => true,
436            Home::File { .. } => false,
437        }
438    }
439
440    /// Whether the extent sits in an arena slot, resident and not
441    /// retry-capped: the state RSS-target enforcement acts on, and the one
442    /// in which freeing the extent avoids a device write.
443    pub(crate) fn is_reclaimable_arena(&self) -> bool {
444        matches!(self.home, Home::Arena(_)) && self.is_resident() && !self.pageout_capped()
445    }
446
447    /// Whether a demoter currently owns the extent.
448    pub(crate) fn is_demoting(&self) -> bool {
449        matches!(self.home, Home::Demoting { .. })
450    }
451
452    /// The file slot's size for a `File` extent, `None` otherwise.
453    pub(crate) fn file_bytes(&self) -> Option<usize> {
454        match &self.home {
455            Home::File { store, slot } => Some(store.class_size(slot.class)),
456            Home::Arena(_) | Home::Heap { .. } | Home::Demoting { .. } => None,
457        }
458    }
459
460    /// The file location of a `File` extent, `None` otherwise.
461    pub(crate) fn file_location(&self) -> Option<FileLocation> {
462        match &self.home {
463            Home::File { store, slot } => Some(FileLocation {
464                store: Arc::clone(store),
465                slot: *slot,
466                comp_len: self.comp_len,
467                crc: self.crc,
468            }),
469            Home::Arena(_) | Home::Heap { .. } | Home::Demoting { .. } => None,
470        }
471    }
472
473    /// Records a read of the extent's file, returning whether it was already
474    /// read since its demotion.
475    pub(crate) fn note_file_read(&mut self) -> bool {
476        std::mem::replace(&mut self.read_since_demotion, true)
477    }
478
479    /// Moves an `Arena` extent to `Demoting` toward `file_slot` and returns
480    /// what the demoter's unlocked write needs. Panics for any other home.
481    pub(crate) fn begin_demotion(&mut self, file_slot: FileSlot) -> DemotionSource {
482        let Home::Arena(arena) = &self.home else {
483            panic!("only arena extents demote");
484        };
485        let arena = arena.clone();
486        let source = DemotionSource {
487            ptr: arena.ptr.cast_const(),
488            comp_len: self.comp_len,
489            alloc_size: self.alloc_size,
490            file_slot,
491        };
492        self.home = Home::Demoting { arena, file_slot };
493        source
494    }
495
496    /// Commits a demotion whose write succeeded: the extent moves to its
497    /// `File` home with checksum `crc`, and its arena slot is freed with its
498    /// pages discarded. Panics unless the extent is `Demoting`. The caller
499    /// holds the chunk's state lock, so no reader borrows the arena slot.
500    pub(crate) fn commit_demotion(&mut self, store: Arc<FileStore>, crc: u32) {
501        let Home::Demoting { arena, file_slot } = &self.home else {
502            panic!("only demoting extents commit");
503        };
504        let (arena, file_slot) = (arena.clone(), *file_slot);
505        self.home = Home::File {
506            store,
507            slot: file_slot,
508        };
509        self.crc = crc;
510        self.read_since_demotion = false;
511        // SAFETY: the extent owned the slot until the home change above, and
512        // readers borrow it only under the chunk lock the caller holds.
513        unsafe { arena.free(self.alloc_size) };
514    }
515
516    /// Returns a `Demoting` extent to its `Arena` home and hands back the
517    /// file slot the demotion reserved, for the caller to return to the
518    /// store. Panics unless the extent is `Demoting`.
519    pub(crate) fn abort_demotion(&mut self) -> FileSlot {
520        let Home::Demoting { arena, file_slot } = &self.home else {
521            panic!("only demoting extents abort");
522        };
523        let file_slot = *file_slot;
524        self.home = Home::Arena(arena.clone());
525        file_slot
526    }
527
528    /// Whether the extent's pageout retry budget is exhausted: consecutive
529    /// incomplete passes reached [`PAGEOUT_RETRY_CAP`], so callers stop
530    /// calling [`Extent::pageout`] until a read resets the budget.
531    /// Heap-fallback extents are permanently capped: they are never advised
532    /// out and stay counted resident until freed.
533    pub(crate) fn pageout_capped(&self) -> bool {
534        match self.home {
535            Home::Heap { .. } => true,
536            Home::Arena(_) => self.incomplete_passes >= PAGEOUT_RETRY_CAP,
537            // File mode demotes instead of advising pages out.
538            Home::Demoting { .. } | Home::File { .. } => false,
539        }
540    }
541
542    /// Hints the kernel to push the extent's pages to the swap device and
543    /// observes the result: returns `true`, marking the extent non-resident,
544    /// only when the observation finds the whole range unmapped (a page
545    /// whose clean copy lingers in the swap cache counts as reclaimed). An
546    /// incomplete pass leaves the extent fully resident for accounting and
547    /// spends one unit of the retry budget: a single pinned page keeps the
548    /// whole extent counted, which is the safe direction, and the retry
549    /// budget exists exactly for such transient pins.
550    ///
551    /// Callers must not invoke this on a [`Extent::pageout_capped`]
552    /// extent.
553    pub(crate) fn pageout(&mut self) -> bool {
554        crate::soft_assert_no_log!(!self.pageout_capped());
555        region::pageout(self.ptr(), self.alloc_size);
556        if region::nonresident(self.ptr(), self.alloc_size) {
557            self.resident = false;
558            self.incomplete_passes = 0;
559            true
560        } else {
561            self.incomplete_passes += 1;
562            false
563        }
564    }
565
566    /// Compressed size in bytes, including the size prefix.
567    pub(crate) fn comp_len(&self) -> usize {
568        self.comp_len
569    }
570
571    /// Hints the kernel to swap the extent's pages back in ahead of a read.
572    /// A no-op for `Demoting` and `File` extents: the first is resident, and
573    /// the second would need an asynchronous read into a buffer a later read
574    /// can claim.
575    pub(crate) fn prefetch(&self) {
576        match self.home {
577            Home::Arena(ArenaSlot { ptr, .. }) | Home::Heap { ptr, .. } => {
578                region::willneed(ptr, self.alloc_size)
579            }
580            Home::Demoting { .. } | Home::File { .. } => {}
581        }
582    }
583
584    /// Decodes the extent through `codec` into `dst`, which must be exactly
585    /// the chunk's body length. Reading an in-memory extent faults its pages
586    /// back in, so it is resident again afterwards. The caller owns the
587    /// accounting for that transition (the pool re-counts and re-enqueues it
588    /// for the RSS target). A `File` extent is read per
589    /// [`FileLocation::read_range_into`] and stays on file.
590    pub(crate) fn read_into(&mut self, codec: &dyn ExtentCodec, dst: &mut [u8]) {
591        self.read_range_into(codec, dst.len(), 0, dst);
592    }
593
594    /// Decodes the byte range `[offset, offset + dst.len())` of the
595    /// extent's `body_len`-byte body into `dst`. The range must lie within
596    /// the body, and `body_len` must be the body's exact length (the codec
597    /// validates it against the stored form). Residency effects are those
598    /// of [`Extent::read_into`] regardless of the range: the stored
599    /// form is one whole codec block, so any read faults (or reads from
600    /// file) and decodes the entire extent, and a sub-range only narrows
601    /// the final copy.
602    pub(crate) fn read_range_into(
603        &mut self,
604        codec: &dyn ExtentCodec,
605        body_len: usize,
606        offset: usize,
607        dst: &mut [u8],
608    ) {
609        match self.file_location() {
610            Some(location) => location.read_range_into(codec, body_len, offset, dst),
611            None => {
612                self.resident = true;
613                // The decode faults every page back in, so prior incomplete
614                // pageout passes no longer describe the mapping and the retry
615                // budget starts over.
616                self.incomplete_passes = 0;
617                self.prefetch();
618                // SAFETY: the extent exclusively owns its in-memory copy, and
619                // the first `comp_len` bytes were initialized by `write`. A
620                // `Demoting` extent's arena bytes are immutable while the
621                // demoter writes them out, so this read may overlap that write.
622                let buf = unsafe { std::slice::from_raw_parts(self.ptr(), self.comp_len) };
623                decode_range(buf, codec, body_len, offset, dst);
624            }
625        }
626    }
627}
628
629impl FileLocation {
630    /// Reads the stored bytes from file, verifies their checksum, and
631    /// decodes the byte range `[offset, offset + dst.len())` of the
632    /// `body_len`-byte body into `dst`, per [`Extent::read_range_into`].
633    /// Panics on an I/O error, a short read, or a checksum mismatch.
634    pub(crate) fn read_range_into(
635        &self,
636        codec: &dyn ExtentCodec,
637        body_len: usize,
638        offset: usize,
639        dst: &mut [u8],
640    ) {
641        use std::cell::RefCell;
642        thread_local! {
643            static READ_BUF: RefCell<AlignedBuf> = const { RefCell::new(AlignedBuf::new()) };
644        }
645        READ_BUF.with(|cell| {
646            let mut buf = cell.borrow_mut();
647            self.store
648                .read(self.slot, self.comp_len, self.crc, &mut buf);
649            decode_range(buf.as_slice(), codec, body_len, offset, dst);
650            buf.shrink_above(READ_SCRATCH_RETAIN);
651        });
652    }
653}
654
655/// Decodes the byte range `[offset, offset + dst.len())` of the
656/// `body_len`-byte body whose stored form is `stored` into `dst`.
657fn decode_range(
658    stored: &[u8],
659    codec: &dyn ExtentCodec,
660    body_len: usize,
661    offset: usize,
662    dst: &mut [u8],
663) {
664    let end = offset
665        .checked_add(dst.len())
666        .expect("range end overflows usize");
667    assert!(
668        end <= body_len,
669        "range end {end} exceeds the extent's body length {body_len}",
670    );
671    if offset == 0 && dst.len() == body_len {
672        codec.decode(stored, dst);
673        return;
674    }
675    // A sub-range still decodes the whole block, into a reused
676    // thread-local scratch, and copies the range out. The scratch mirrors
677    // the write side's `Shrink` policy, per `READ_SCRATCH_RETAIN`.
678    use std::cell::RefCell;
679    thread_local! {
680        static SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
681    }
682    SCRATCH.with(|cell| {
683        let mut scratch = cell.borrow_mut();
684        scratch.resize(body_len, 0);
685        codec.decode(stored, &mut scratch);
686        dst.copy_from_slice(&scratch[offset..end]);
687        if scratch.capacity() > READ_SCRATCH_RETAIN {
688            scratch.clear();
689            scratch.shrink_to_fit();
690        }
691    });
692}
693
694/// The heap layout of a fallback extent for a compressed payload of
695/// `comp_len` bytes.
696fn heap_layout(comp_len: usize) -> Layout {
697    Layout::array::<u8>(comp_len).expect("valid extent layout")
698}
699
700impl Drop for Extent {
701    fn drop(&mut self) {
702        match &self.home {
703            Home::Arena(arena) => {
704                // SAFETY: the extent exclusively owns the slot and is being
705                // dropped, so no reference into it exists.
706                unsafe { arena.free(self.alloc_size) };
707            }
708            Home::Heap { ptr, layout } => {
709                // SAFETY: `ptr` was returned by `alloc` with exactly this
710                // `layout` in `write` and is deallocated exactly once, here.
711                unsafe {
712                    std::alloc::dealloc(*ptr, *layout);
713                }
714            }
715            Home::File { store, slot } => store.free(*slot),
716            Home::Demoting { .. } => {
717                // Freeing either slot here would hand it to a new owner
718                // mid-I/O, so both leak instead.
719                crate::soft_panic_or_log!("dropped an extent mid-demotion");
720            }
721        }
722    }
723}
724
725/// Kani proof harnesses over the extent ladder arithmetic; see the sibling
726/// module in `region.rs` for scope and run instructions.
727#[cfg(kani)]
728mod proofs {
729    use super::*;
730
731    /// The extent ladder's contract for every plausible page size: every
732    /// class is a page multiple, the top class fits the codec contract's
733    /// worst case over the largest chunk class, a class is found for every
734    /// payload up to that bound and fits it, and the selected class
735    /// overshoots the payload by less than 1.5x (with page-granular slack
736    /// at the smallest classes).
737    #[kani::proof]
738    #[kani::unwind(64)]
739    fn extent_ladder_fits_payloads() {
740        let page_shift: u32 = kani::any();
741        kani::assume(page_shift >= 12 && page_shift <= 16);
742        let page = 1usize << page_shift;
743        let classes = extent_classes(page);
744        let max_comp = max_stored_len(max_chunk_bytes());
745        for &class in &classes {
746            assert!(class % page == 0);
747        }
748        assert!(classes[classes.len() - 1] >= max_comp);
749        let comp_len: usize = kani::any();
750        kani::assume(comp_len >= 1 && comp_len <= max_comp);
751        let class = classes
752            .iter()
753            .position(|&c| c >= comp_len)
754            .expect("ladder covers every payload up to the bound");
755        assert!(classes[class] >= comp_len);
756        if class <= 1 {
757            assert!(classes[class] - comp_len < page);
758        } else {
759            // The previous class was too small and consecutive classes are
760            // within 3/2 of each other, so the allocation is below 1.5x the
761            // payload.
762            assert!(classes[class - 1] < comp_len);
763            assert!(classes[class] * 2 <= classes[class - 1] * 3);
764        }
765    }
766}
767
768#[cfg(test)]
769pub(crate) mod testing;
770
771#[cfg(test)]
772mod tests {
773    use super::testing::TEST_CODEC;
774    use super::*;
775
776    /// A small arena for extent tests. Under Miri the region backing is real
777    /// interpreter heap rather than lazy virtual memory, so shrink further.
778    fn arena() -> Arc<ExtentArena> {
779        let capacity = if cfg!(miri) { 1 << 20 } else { 64 << 20 };
780        Arc::new(ExtentArena::new(capacity).expect("arena creation"))
781    }
782
783    #[mz_ore::test]
784    fn round_trip() {
785        let arena = arena();
786        let data: Vec<u64> = (0..10_000).map(|i| i * 37).collect();
787        let mut extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
788        assert!(extent.comp_len() > 4);
789        extent.prefetch();
790        let mut out = vec![0u64; data.len()];
791        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
792        assert_eq!(out, data);
793    }
794
795    #[mz_ore::test]
796    fn compressible_data_shrinks() {
797        let arena = arena();
798        let data = vec![42u64; 100_000];
799        let mut extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
800        assert!(extent.comp_len() < data.len() * 8 / 4);
801        let mut out = vec![0u64; data.len()];
802        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
803        assert_eq!(out, data);
804    }
805
806    /// The slot is sized to the compressed payload, not lz4's worst case:
807    /// extents must cost swap capacity and write bandwidth in proportion to
808    /// what they store.
809    #[mz_ore::test]
810    fn allocation_is_sized_to_payload() {
811        let arena = arena();
812        let data = vec![7u64; 100_000];
813        let extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
814        let page = region::page_size();
815        assert!(extent.alloc_size() >= extent.comp_len());
816        assert_eq!(extent.alloc_size() % page, 0, "class is a page multiple");
817        assert!(
818            extent.alloc_size() < data.len() * 8 / 8,
819            "compressible data must not be stored at worst-case size",
820        );
821        assert!(
822            extent.alloc_size() <= (extent.comp_len() * 3 / 2).max(2 * page),
823            "the ladder bounds internal fragmentation",
824        );
825    }
826
827    #[mz_ore::test]
828    fn ladder_shape() {
829        for page in [4096usize, 16384, 65536] {
830            let classes = extent_classes(page);
831            let max_comp = max_stored_len(max_chunk_bytes());
832            assert!(classes.windows(2).all(|w| w[0] < w[1]), "ascending");
833            assert!(classes.iter().all(|&c| c % page == 0), "page multiples");
834            assert!(classes[classes.len() - 1] >= max_comp, "covers worst case");
835            for w in classes.windows(2).skip(1) {
836                assert!(w[1] * 2 <= w[0] * 3, "steps at most 1.5x above the base");
837            }
838        }
839    }
840
841    /// An exhausted extent class degrades to a heap-backed extent that still
842    /// round-trips, is never advised out, and is counted; freeing an arena
843    /// extent lets the next write reuse its slot.
844    #[mz_ore::test]
845    fn exhaustion_falls_back_to_heap() {
846        // One page of capacity: the smallest class holds one slot, every
847        // larger class is empty.
848        let arena = Arc::new(ExtentArena::new(region::page_size()).expect("arena creation"));
849        let data = vec![3u64; 64];
850        let a = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
851        assert_eq!(arena.fallbacks(), 0);
852        assert!(!a.pageout_capped(), "arena extents start with retry budget");
853        let mut b = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
854        assert_eq!(arena.fallbacks(), 1, "second same-class write degrades");
855        assert!(b.pageout_capped(), "heap extents are never advised out");
856        assert!(b.is_resident());
857        let mut out = vec![0u64; data.len()];
858        b.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
859        assert_eq!(out, data);
860        // Freeing the arena extent frees its slot for the next write.
861        drop(a);
862        let c = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
863        assert_eq!(arena.fallbacks(), 1, "freed slot is reused, no fallback");
864        drop(c);
865        drop(b);
866    }
867
868    /// A ranged read returns exactly the corresponding slice of a full
869    /// read, at aligned and unaligned offsets, across page boundaries, and
870    /// at the body's edges.
871    #[mz_ore::test]
872    fn ranged_read_matches_full_read_slice() {
873        let arena = arena();
874        let data: Vec<u64> = (0..10_000u64).map(|i| i.wrapping_mul(0x9E37)).collect();
875        let bytes: &[u8] = bytemuck::cast_slice(&data);
876        let mut extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
877        let mut full = vec![0u8; bytes.len()];
878        extent.read_into(&TEST_CODEC, &mut full);
879        assert_eq!(full, bytes);
880        let page = region::page_size();
881        let ranges = [
882            (0, 8),
883            (8, 16),
884            (page - 3, page + 7),
885            (bytes.len() - 24, 24),
886            (0, bytes.len()),
887        ];
888        for (offset, len) in ranges {
889            let mut out = vec![0u8; len];
890            extent.read_range_into(&TEST_CODEC, bytes.len(), offset, &mut out);
891            assert_eq!(out, &full[offset..offset + len], "range ({offset}, {len})");
892        }
893    }
894
895    #[mz_ore::test]
896    #[should_panic(expected = "range end")]
897    fn ranged_read_out_of_bounds_panics() {
898        let arena = arena();
899        let data = vec![5u64; 64];
900        let mut extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
901        let mut out = vec![0u8; 16];
902        extent.read_range_into(&TEST_CODEC, 64 * 8, 64 * 8 - 8, &mut out);
903    }
904
905    #[mz_ore::test]
906    #[should_panic(expected = "destination must match")]
907    fn wrong_destination_length_panics() {
908        let arena = arena();
909        let data = vec![1u64; 16];
910        let mut extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
911        let mut out = vec![0u64; 8];
912        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
913    }
914
915    #[mz_ore::test]
916    fn pageout_is_observed_not_trusted() {
917        let arena = arena();
918        let data = vec![9u64; 10_000];
919        let mut extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
920        assert!(extent.is_resident());
921        region::fake_residency::decline_next(1);
922        assert!(!extent.pageout(), "declined pass reports incomplete");
923        assert!(extent.is_resident(), "declined pass leaves it resident");
924        assert!(!extent.pageout_capped());
925        assert!(extent.pageout(), "accepted pass reports reclaimed");
926        assert!(!extent.is_resident());
927    }
928
929    #[mz_ore::test]
930    fn pageout_retry_cap_and_read_reset() {
931        let arena = arena();
932        let data: Vec<u64> = (0..10_000).collect();
933        let mut extent = Extent::write(&arena, &data, &TEST_CODEC, Scratch::Shrink);
934        region::fake_residency::decline_next(u64::MAX);
935        let mut passes = 0u8;
936        while !extent.pageout_capped() {
937            assert!(!extent.pageout());
938            passes += 1;
939        }
940        assert_eq!(passes, PAGEOUT_RETRY_CAP, "capped after exactly the cap");
941        assert!(extent.is_resident(), "capped extent stays resident");
942        region::fake_residency::decline_next(0);
943        let mut out = vec![0u64; data.len()];
944        extent.read_into(&TEST_CODEC, bytemuck::cast_slice_mut(&mut out));
945        assert_eq!(out, data);
946        assert!(!extent.pageout_capped(), "a read resets the retry budget");
947        assert!(extent.pageout());
948    }
949}