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