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