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}