mz_ore/pool/file.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 file-backed extent store: a userspace extent allocator over one
17//! anonymous file per extent class in a scratch directory.
18//!
19//! **Files.** Each class of the ladder the caller passes to
20//! [`FileStore::open`] gets one file, opened `O_TMPFILE` so it has no name
21//! and the kernel frees its blocks when the process exits, crash included.
22//! Where the filesystem refuses `O_TMPFILE`, the store creates a uniquely
23//! named file and unlinks it immediately. A slot `(class, index)` lives at
24//! byte offset `index * class_size` of its class's file.
25//!
26//! **Block ownership.** Each class allocates slot indices with a
27//! [`SlotAllocator`] behind its own mutex. Its warm side holds free slots
28//! whose blocks are still allocated, its cold side free slots whose blocks
29//! were punched, and slots above its high-water mark were never touched.
30//! Freeing a slot pushes it warm with no filesystem call, so slot reuse
31//! performs no metadata operation. A slot taken cold or from the high-water
32//! mark is `fallocate`d before it is handed out, which turns `ENOSPC` into
33//! an allocation failure before any data is in flight.
34//!
35//! **Capacity.** Every slot with allocated blocks, in use or warm, is
36//! charged at its class size against the store's capacity. When an
37//! allocation does not fit, the store punches holes over warm slots of the
38//! other class holding the most warm bytes, moving them cold, until it
39//! fits. Independently, [`FileStore::release_warm`] punches warm slots on
40//! its caller's schedule, so the bytes the store holds follow its live data
41//! and space freed in one class is available to any other. A slot it punches
42//! pays a `fallocate` when next allocated.
43//!
44//! **Alignment.** Class sizes are page multiples, and every transfer covers
45//! whole pages from a page-aligned buffer, which satisfies `O_DIRECT` on
46//! devices with 512-byte and 4 KiB logical blocks. The store owns that
47//! rounding: [`FileStore::write`] and [`FileStore::read`] return the length
48//! they transferred. `open` probes one page-aligned write and read. A probe
49//! failing with `EINVAL` means the device needs a larger alignment or the
50//! filesystem lacks direct I/O, and the store reopens its files buffered:
51//! each write is then followed by a synchronous writeback and a page-cache
52//! drop over its range. Memory-backed filesystems are rejected by type,
53//! since tmpfs accepts the probe and would hold extents in RAM.
54//!
55//! **Errors.** Running out of capacity, or `ENOSPC` from `fallocate`, fails
56//! the allocation with [`AllocError::Full`]. An `ENOSPC` from a write fails
57//! it with [`WriteError::Full`] after the store punches and takes back the
58//! slot. A short direct write is retried whole first, and its final error
59//! is classified the same way. Either `ENOSPC` additionally lowers the
60//! capacity to the bytes allocated at that moment. The volume can be shared
61//! with other writers, so [`FileStore::refresh_capacity`] re-derives the
62//! capacity from the volume's free space, and room another writer frees
63//! becomes usable again. Any other write or `fallocate` error disables
64//! writes for the store's lifetime. Data
65//! already written stays readable. A read error, short read, or checksum
66//! mismatch panics: there is no correct value to return, and the pool's
67//! contents are recreatable.
68
69use std::alloc::Layout;
70use std::fs::File;
71use std::io;
72use std::path::{Path, PathBuf};
73use std::ptr::NonNull;
74use std::sync::Mutex;
75use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
76use std::time::Instant;
77
78use itertools::Itertools;
79
80use crate::cast::CastFrom;
81use crate::pool::region::{SlotAllocator, page_size};
82
83/// Where the store's I/O goes.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub(crate) enum IoMode {
86 /// `O_DIRECT`: transfers bypass the page cache.
87 Direct,
88 /// Page-cache I/O, written back and dropped after each write.
89 Buffered,
90}
91
92/// A slot in the store: `index` in the file of extent class `class`.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub(crate) struct FileSlot {
95 pub(crate) class: usize,
96 pub(crate) index: u32,
97}
98
99/// Why an allocation failed.
100#[derive(Debug, PartialEq, Eq)]
101pub(crate) enum AllocError {
102 /// The store's capacity, or the filesystem, has no room for the slot.
103 Full,
104 /// An earlier I/O error disabled writes for the store's lifetime.
105 WritesDisabled,
106}
107
108/// Why a write failed.
109#[derive(Debug)]
110pub(crate) enum WriteError {
111 /// The filesystem had no room for the slot's data. The store took the
112 /// slot back, so the caller must not free it.
113 Full,
114 /// Writes are disabled for the store's lifetime, by this write's I/O
115 /// error or an earlier one. The caller still owns the slot and must free
116 /// it.
117 Disabled,
118}
119
120/// Number of read-latency buckets. Bucket `i < READ_LATENCY_BUCKETS - 1`
121/// counts reads that took less than `32 µs << i` and, for `i > 0`, at least
122/// `16 µs << i`. The last bucket counts reads of 65.536 ms and more.
123pub const READ_LATENCY_BUCKETS: usize = 13;
124
125/// A snapshot of the store's counters.
126#[derive(Debug, Default, Clone, Copy)]
127pub(crate) struct FileStoreStats {
128 pub(crate) writes: u64,
129 pub(crate) reads: u64,
130 /// Bytes transferred by reads, page-rounded.
131 pub(crate) read_bytes: u64,
132 pub(crate) read_latency_buckets: [u64; READ_LATENCY_BUCKETS],
133 /// I/O errors that disabled writes.
134 pub(crate) write_errors: u64,
135 pub(crate) holes_punched_bytes: u64,
136 /// Bytes of slots with allocated blocks, in use or warm: what the store
137 /// holds on the filesystem.
138 pub(crate) allocated_bytes: u64,
139}
140
141/// Attempts a direct write makes at a slot before a short write counts as
142/// failed.
143const DIRECT_WRITE_ATTEMPTS: usize = 3;
144
145/// The checksum the store verifies on every read.
146pub(crate) fn crc(bytes: &[u8]) -> u32 {
147 crc32fast::hash(bytes)
148}
149
150/// The bucket of [`FileStoreStats::read_latency_buckets`] for a read that
151/// took `micros` microseconds.
152fn read_latency_bucket(micros: u64) -> usize {
153 // `floor(log2(micros / 16))`, with everything below 32 µs in bucket 0.
154 let log2 = (micros / 16).checked_ilog2().unwrap_or(0);
155 usize::cast_from(log2).min(READ_LATENCY_BUCKETS - 1)
156}
157
158/// One extent class: its file and slot allocator.
159#[derive(Debug)]
160struct Class {
161 size: usize,
162 file: File,
163 slots: Mutex<SlotAllocator>,
164 /// Bytes on the warm side of `slots`, updated under its mutex and read
165 /// without it.
166 warm_bytes: AtomicU64,
167}
168
169impl Class {
170 fn slots(&self) -> std::sync::MutexGuard<'_, SlotAllocator> {
171 self.slots.lock().expect("file store allocator poisoned")
172 }
173}
174
175#[derive(Debug, Default)]
176struct Counters {
177 writes: AtomicU64,
178 reads: AtomicU64,
179 read_bytes: AtomicU64,
180 read_latency_buckets: [AtomicU64; READ_LATENCY_BUCKETS],
181 write_errors: AtomicU64,
182 holes_punched_bytes: AtomicU64,
183}
184
185/// The file-backed extent store. See the module documentation.
186#[derive(Debug)]
187pub(crate) struct FileStore {
188 classes: Vec<Class>,
189 io_mode: IoMode,
190 page: usize,
191 capacity: AtomicU64,
192 /// The capacity `open` set. Re-derivation never raises the capacity
193 /// above it.
194 capacity_limit: u64,
195 dir: PathBuf,
196 /// Bytes of slots with allocated blocks, in use or warm. Exceeds
197 /// `capacity` after `ENOSPC` or a re-derivation lowers the capacity,
198 /// until slots are punched.
199 allocated: AtomicU64,
200 /// Bytes of slots taken off a warm list to be punched and not yet put
201 /// back. They are still charged to `allocated`.
202 punching: AtomicU64,
203 writes_disabled: AtomicBool,
204 enospc_seen: AtomicBool,
205 counters: Counters,
206}
207
208/// The bytes a store may allocate on a volume with `available` bytes free
209/// to unprivileged writers: those bytes minus the larger of 1 GiB and 2% of
210/// them.
211fn usable_bytes(available: u64) -> u64 {
212 // Headroom for filesystem metadata of the preallocated extents and for
213 // incidental writers sharing the volume.
214 available.saturating_sub(std::cmp::max(1 << 30, available / 50))
215}
216
217/// The capacity of a new store on a volume with `available` bytes free, per
218/// [`usable_bytes`]. `None` if that cannot hold one slot of `largest_class`
219/// bytes, the largest extent class.
220fn derived_capacity(available: u64, largest_class: usize) -> Option<u64> {
221 let capacity = usable_bytes(available);
222 (capacity >= u64::cast_from(largest_class)).then_some(capacity)
223}
224
225impl FileStore {
226 /// Opens one anonymous file per entry of `classes` in `dir`. `classes`
227 /// is the ladder of slot sizes, ascending page multiples. `capacity_bytes`
228 /// `None` derives capacity from the volume per [`derived_capacity`].
229 ///
230 /// Fails with [`io::ErrorKind::Unsupported`] when `dir` is on a
231 /// memory-backed filesystem or the platform is not Linux, and with
232 /// [`io::ErrorKind::StorageFull`] when a derived capacity cannot hold
233 /// one slot of the largest class.
234 pub(crate) fn open(
235 dir: &Path,
236 capacity_bytes: Option<u64>,
237 classes: &[usize],
238 ) -> io::Result<FileStore> {
239 if sys::is_memory_backed(dir)? {
240 return Err(io::Error::new(
241 io::ErrorKind::Unsupported,
242 "scratch directory is memory-backed",
243 ));
244 }
245 let page = page_size();
246 assert!(
247 !classes.is_empty()
248 && classes.iter().all(|&size| size > 0 && size % page == 0)
249 && classes.is_sorted(),
250 "classes must be ascending page multiples: {classes:?}",
251 );
252 let capacity = match capacity_bytes {
253 Some(capacity) => capacity,
254 None => {
255 let available = sys::available_bytes(dir)?;
256 let largest = *classes.last().expect("classes is not empty");
257 derived_capacity(available, largest).ok_or_else(|| {
258 io::Error::new(
259 io::ErrorKind::StorageFull,
260 format!(
261 "scratch volume has {available} bytes available, too few for \
262 the file store"
263 ),
264 )
265 })?
266 }
267 };
268 let (files, io_mode) = match open_files(dir, classes.len(), page, IoMode::Direct) {
269 Ok(files) => (files, IoMode::Direct),
270 Err(err) if err.raw_os_error() == Some(libc::EINVAL) => {
271 tracing::warn!(
272 dir = %dir.display(),
273 "pool file store: direct I/O unsupported ({err}), using buffered I/O",
274 );
275 (
276 open_files(dir, classes.len(), page, IoMode::Buffered)?,
277 IoMode::Buffered,
278 )
279 }
280 Err(err) => return Err(err),
281 };
282 let classes = classes
283 .iter()
284 .copied()
285 .zip_eq(files)
286 .map(|(size, file)| Class {
287 size,
288 file,
289 slots: Mutex::new(SlotAllocator::new(u32::MAX)),
290 warm_bytes: AtomicU64::new(0),
291 })
292 .collect();
293 Ok(FileStore {
294 classes,
295 io_mode,
296 page,
297 capacity: AtomicU64::new(capacity),
298 capacity_limit: capacity,
299 dir: dir.to_path_buf(),
300 allocated: AtomicU64::new(0),
301 punching: AtomicU64::new(0),
302 writes_disabled: AtomicBool::new(false),
303 enospc_seen: AtomicBool::new(false),
304 counters: Counters::default(),
305 })
306 }
307
308 pub(crate) fn io_mode(&self) -> IoMode {
309 self.io_mode
310 }
311
312 /// The class for a stored payload of `comp_len` bytes, `None` if none fits.
313 pub(crate) fn class_for(&self, comp_len: usize) -> Option<usize> {
314 self.classes.iter().position(|c| c.size >= comp_len)
315 }
316
317 pub(crate) fn class_size(&self, class: usize) -> usize {
318 self.classes[class].size
319 }
320
321 /// Reserves a slot with allocated blocks.
322 pub(crate) fn alloc(&self, class: usize) -> Result<FileSlot, AllocError> {
323 if self.writes_disabled() {
324 return Err(AllocError::WritesDisabled);
325 }
326 let c = &self.classes[class];
327 let size = u64::cast_from(c.size);
328 let allocated = {
329 let mut slots = c.slots();
330 let allocated = slots.alloc();
331 if let Some((_, true)) = allocated {
332 c.warm_bytes.fetch_sub(size, Ordering::Relaxed);
333 }
334 allocated
335 };
336 let Some((index, warm)) = allocated else {
337 // Every `u32` index of the class is in use: `u32::MAX` slots,
338 // 16 TiB at a one-page class on a 4 KiB page.
339 return Err(AllocError::Full);
340 };
341 let slot = FileSlot { class, index };
342 if warm {
343 return Ok(slot);
344 }
345 // A cold or never-touched slot has no blocks. A concurrent
346 // allocation can take the room a punch made, so punch again until
347 // the reservation succeeds or nothing is left to punch.
348 loop {
349 if self.reserve(size) {
350 break;
351 }
352 if self.punch_for(class, size) {
353 continue;
354 }
355 // Slots another thread is punching, including this class's warm
356 // slots, stay charged until each punch returns. Failing now
357 // would report `Full` with room about to free.
358 if self.punching.load(Ordering::Acquire) > 0 {
359 std::thread::yield_now();
360 continue;
361 }
362 // A punch that finished after the failed reservation released
363 // its bytes before it cleared `punching`.
364 if self.reserve(size) {
365 break;
366 }
367 c.slots().free(index, false);
368 return Err(AllocError::Full);
369 }
370 match sys::fallocate(&c.file, self.offset(slot), c.size) {
371 Ok(()) => Ok(slot),
372 Err(err) => {
373 // A failed `fallocate` can leave part of the range
374 // allocated. Punching returns those blocks. If the punch
375 // fails too, the slot goes warm and stays charged, so its
376 // blocks stay counted.
377 match sys::punch_hole(&c.file, self.offset(slot), c.size) {
378 Ok(()) => {
379 self.allocated.fetch_sub(size, Ordering::Relaxed);
380 self.counters
381 .holes_punched_bytes
382 .fetch_add(size, Ordering::Relaxed);
383 c.slots().free(index, false);
384 }
385 Err(punch_err) => {
386 tracing::warn!(
387 "pool file store: punching a slot after a failed fallocate failed: \
388 {punch_err}"
389 );
390 let mut slots = c.slots();
391 slots.free(index, true);
392 c.warm_bytes.fetch_add(size, Ordering::Relaxed);
393 }
394 }
395 if err.raw_os_error() == Some(libc::ENOSPC) {
396 self.lower_capacity("fallocate");
397 Err(AllocError::Full)
398 } else {
399 self.disable_writes("fallocate", &err);
400 Err(AllocError::WritesDisabled)
401 }
402 }
403 }
404 }
405
406 /// Returns a slot. Its blocks stay allocated. Must not be called while a
407 /// read or write of the slot is in flight, since the slot can be punched
408 /// or handed out again at once.
409 pub(crate) fn free(&self, slot: FileSlot) {
410 let c = &self.classes[slot.class];
411 let mut slots = c.slots();
412 slots.free(slot.index, true);
413 c.warm_bytes
414 .fetch_add(u64::cast_from(c.size), Ordering::Relaxed);
415 }
416
417 /// Writes `buf[..round_up(len, page)]` to `slot` and returns the rounded
418 /// length. `buf` must be at least that long, and page aligned in
419 /// [`IoMode::Direct`]. Fails without I/O once writes are disabled. On
420 /// `ENOSPC`, takes the slot back and fails with [`WriteError::Full`]. On
421 /// any other I/O error, disables further writes for the store's lifetime.
422 pub(crate) fn write(
423 &self,
424 slot: FileSlot,
425 buf: &[u8],
426 len: usize,
427 ) -> Result<usize, WriteError> {
428 if self.writes_disabled() {
429 return Err(WriteError::Disabled);
430 }
431 let c = &self.classes[slot.class];
432 let n = len.next_multiple_of(self.page);
433 assert!(
434 n <= c.size,
435 "write of {len} bytes exceeds class size {}",
436 c.size
437 );
438 assert!(
439 n <= buf.len(),
440 "write of {n} bytes from a {}-byte buffer",
441 buf.len()
442 );
443 if self.io_mode == IoMode::Direct {
444 assert_eq!(
445 buf.as_ptr().addr() % self.page,
446 0,
447 "write buffer is not page aligned"
448 );
449 }
450 let offset = self.offset(slot);
451 let result =
452 write_all(&c.file, &buf[..n], offset, self.io_mode).and_then(|()| match self.io_mode {
453 IoMode::Direct => Ok(()),
454 IoMode::Buffered => sys::writeback_and_drop(&c.file, offset, n),
455 });
456 match result {
457 Ok(()) => {
458 self.counters.writes.fetch_add(1, Ordering::Relaxed);
459 Ok(n)
460 }
461 Err(err) if err.raw_os_error() == Some(libc::ENOSPC) => {
462 self.reclaim_after_enospc(slot);
463 Err(WriteError::Full)
464 }
465 Err(err) => {
466 self.disable_writes("write", &err);
467 Err(WriteError::Disabled)
468 }
469 }
470 }
471
472 /// Takes back `slot` after a write to it hit `ENOSPC`, and lowers the
473 /// capacity.
474 fn reclaim_after_enospc(&self, slot: FileSlot) {
475 let c = &self.classes[slot.class];
476 let size = u64::cast_from(c.size);
477 // A filesystem that allocates on overwrite (copy-on-write, or thin
478 // provisioning) can fail the write despite the `fallocate`. Its
479 // blocks are then in an unknown state, and a warm slot would hand
480 // the same failure to the next demotion of this class. Punching
481 // returns the blocks and makes the slot cold. If the punch fails,
482 // the slot goes on no free list and stays charged, so it is never
483 // reused and its blocks stay counted.
484 match sys::punch_hole(&c.file, self.offset(slot), c.size) {
485 Ok(()) => {
486 c.slots().free(slot.index, false);
487 self.allocated.fetch_sub(size, Ordering::Relaxed);
488 self.counters
489 .holes_punched_bytes
490 .fetch_add(size, Ordering::Relaxed);
491 }
492 Err(err) => {
493 tracing::warn!(
494 "pool file store: punching a slot after ENOSPC failed, leaking it: {err}"
495 );
496 }
497 }
498 self.lower_capacity("write");
499 }
500
501 /// Lowers the capacity to the bytes allocated now, after `op` hit
502 /// `ENOSPC`.
503 fn lower_capacity(&self, op: &str) {
504 // The headroom was too small for this filesystem. The bytes
505 // allocated now are evidently what fits.
506 let allocated = self.allocated.load(Ordering::Relaxed);
507 self.capacity.fetch_min(allocated, Ordering::Relaxed);
508 if !self.enospc_seen.swap(true, Ordering::Relaxed) {
509 tracing::warn!(
510 capacity = allocated,
511 "pool file store: {op} hit ENOSPC, lowering capacity",
512 );
513 }
514 }
515
516 /// Reads the slot's first `len` stored bytes into `dst` (resized to
517 /// `len`), verifying `crc`, and returns the length transferred, `len`
518 /// rounded up to a page. Panics on I/O error, short read, or checksum
519 /// mismatch.
520 pub(crate) fn read(&self, slot: FileSlot, len: usize, crc: u32, dst: &mut AlignedBuf) -> usize {
521 let c = &self.classes[slot.class];
522 let n = len.next_multiple_of(self.page);
523 assert!(
524 n <= c.size,
525 "read of {len} bytes exceeds class size {}",
526 c.size
527 );
528 let offset = self.offset(slot);
529 dst.resize(n);
530 let start = Instant::now();
531 let mut done = 0;
532 while done < n {
533 match sys::pread(
534 &c.file,
535 &mut dst.as_mut_slice()[done..],
536 offset + u64::cast_from(done),
537 ) {
538 Ok(0) => panic!(
539 "pool file store: short read: class {}, index {}, offset {offset}, \
540 {done} of {n} bytes",
541 slot.class, slot.index,
542 ),
543 // A direct read resumed at `offset + done` would be
544 // unaligned unless `done` is, and fail `EINVAL`.
545 Ok(k) if self.io_mode == IoMode::Direct && done + k < n => panic!(
546 "pool file store: short read: class {}, index {}, offset {offset}, \
547 {} of {n} bytes",
548 slot.class,
549 slot.index,
550 done + k,
551 ),
552 Ok(k) => done += k,
553 Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
554 Err(err) => panic!(
555 "pool file store: read failed: class {}, index {}, offset {offset}: {err}",
556 slot.class, slot.index,
557 ),
558 }
559 }
560 let micros = u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX);
561 if self.io_mode == IoMode::Buffered {
562 // The read left the range in the page cache, charged to the
563 // process's cgroup. A failed advice leaves the read correct.
564 let _ = sys::drop_cache(&c.file, offset, n);
565 }
566 dst.truncate(len);
567 let observed = self::crc(dst.as_slice());
568 assert!(
569 observed == crc,
570 "pool file store: checksum mismatch: class {}, index {}, offset {offset}, \
571 expected {crc:#010x}, observed {observed:#010x}",
572 slot.class,
573 slot.index,
574 );
575 self.counters.reads.fetch_add(1, Ordering::Relaxed);
576 self.counters
577 .read_bytes
578 .fetch_add(u64::cast_from(n), Ordering::Relaxed);
579 self.counters.read_latency_buckets[read_latency_bucket(micros)]
580 .fetch_add(1, Ordering::Relaxed);
581 n
582 }
583
584 pub(crate) fn capacity_bytes(&self) -> u64 {
585 self.capacity.load(Ordering::Relaxed)
586 }
587
588 pub(crate) fn writes_disabled(&self) -> bool {
589 self.writes_disabled.load(Ordering::Relaxed)
590 }
591
592 /// Whether an `alloc` of `class` could currently succeed without I/O
593 /// errors: writes enabled and either a warm free slot, capacity room, or
594 /// room after punching other classes' warm slots exists. Takes no lock.
595 pub(crate) fn can_alloc(&self, class: usize) -> bool {
596 if self.writes_disabled() {
597 return false;
598 }
599 let c = &self.classes[class];
600 let size = u64::cast_from(c.size);
601 if c.warm_bytes.load(Ordering::Relaxed) > 0 || self.fits(size) {
602 return true;
603 }
604 let punchable: u64 = self
605 .classes
606 .iter()
607 .enumerate()
608 .filter(|&(i, _)| i != class)
609 .map(|(_, c)| c.warm_bytes.load(Ordering::Relaxed))
610 .sum::<u64>()
611 .saturating_add(self.punching.load(Ordering::Relaxed));
612 self.allocated
613 .load(Ordering::Relaxed)
614 .saturating_sub(punchable)
615 .saturating_add(size)
616 <= self.capacity_bytes()
617 }
618
619 /// Whether [`FileStore::can_alloc`] holds for some class.
620 pub(crate) fn can_alloc_any(&self) -> bool {
621 (0..self.classes.len()).any(|class| self.can_alloc(class))
622 }
623
624 /// Punches warm slots of every class, up to `max_bytes` per class
625 /// rounded up to a whole slot, and returns the bytes punched.
626 pub(crate) fn release_warm(&self, max_bytes: u64) -> u64 {
627 (0..self.classes.len())
628 .map(|class| self.punch_warm(class, max_bytes).0)
629 .sum()
630 }
631
632 /// Sets the capacity to the bytes allocated plus the volume's
633 /// [`usable_bytes`], at most the capacity `open` set. Keeps the capacity
634 /// if the volume cannot be queried.
635 pub(crate) fn refresh_capacity(&self) {
636 let Ok(available) = sys::available_bytes(&self.dir) else {
637 return;
638 };
639 // The store's own blocks are absent from `available`, so they are
640 // added back. A concurrent `ENOSPC` can lower the capacity between
641 // the load and the store, and is then overwritten by a value derived
642 // from free space observed just before it, which the next `ENOSPC`
643 // lowers again.
644 let capacity = self
645 .allocated
646 .load(Ordering::Relaxed)
647 .saturating_add(usable_bytes(available))
648 .min(self.capacity_limit);
649 self.capacity.store(capacity, Ordering::Relaxed);
650 }
651
652 pub(crate) fn stats(&self) -> FileStoreStats {
653 let c = &self.counters;
654 FileStoreStats {
655 writes: c.writes.load(Ordering::Relaxed),
656 reads: c.reads.load(Ordering::Relaxed),
657 read_bytes: c.read_bytes.load(Ordering::Relaxed),
658 read_latency_buckets: std::array::from_fn(|i| {
659 c.read_latency_buckets[i].load(Ordering::Relaxed)
660 }),
661 write_errors: c.write_errors.load(Ordering::Relaxed),
662 holes_punched_bytes: c.holes_punched_bytes.load(Ordering::Relaxed),
663 allocated_bytes: self.allocated.load(Ordering::Relaxed),
664 }
665 }
666
667 fn offset(&self, slot: FileSlot) -> u64 {
668 u64::cast_from(slot.index) * u64::cast_from(self.classes[slot.class].size)
669 }
670
671 fn fits(&self, size: u64) -> bool {
672 self.allocated.load(Ordering::Relaxed).saturating_add(size) <= self.capacity_bytes()
673 }
674
675 /// Charges `size` bytes against capacity, or returns `false` if they do
676 /// not fit.
677 fn reserve(&self, size: u64) -> bool {
678 self.allocated
679 .try_update(Ordering::Relaxed, Ordering::Relaxed, |allocated| {
680 allocated
681 .checked_add(size)
682 .filter(|&n| n <= self.capacity_bytes())
683 })
684 .is_ok()
685 }
686
687 /// Punches warm slots of classes other than `class` until `size` more
688 /// bytes fit or no other class has a warm slot, and returns whether they
689 /// fit.
690 fn punch_for(&self, class: usize, size: u64) -> bool {
691 while !self.fits(size) {
692 // Choose from the lock-free warm-bytes snapshot: holding two
693 // class mutexes at once could deadlock against a concurrent
694 // punch.
695 let victim = self
696 .classes
697 .iter()
698 .enumerate()
699 .filter(|&(i, _)| i != class)
700 .map(|(i, c)| (c.warm_bytes.load(Ordering::Relaxed), i))
701 .filter(|&(warm_bytes, _)| warm_bytes > 0)
702 .max();
703 let Some((_, victim)) = victim else {
704 return false;
705 };
706 let shortfall = self
707 .allocated
708 .load(Ordering::Relaxed)
709 .saturating_add(size)
710 .saturating_sub(self.capacity_bytes());
711 let (_, failed) = self.punch_warm(victim, shortfall);
712 if failed {
713 return false;
714 }
715 }
716 true
717 }
718
719 /// Punches warm slots of `class` until at least `want` bytes are punched
720 /// or none is left, moving them cold. Returns the bytes punched and
721 /// whether a punch failed, which leaves the failed slot and the ones not
722 /// yet punched warm.
723 fn punch_warm(&self, class: usize, want: u64) -> (u64, bool) {
724 let c = &self.classes[class];
725 let class_size = u64::cast_from(c.size);
726 // Take the victims off the warm list under the lock and punch them
727 // after releasing it. `free` takes this mutex on workers holding a
728 // chunk's state lock, so it must not wait out the punches. A popped
729 // slot is on no free list, so nothing can allocate it meanwhile.
730 let popped: Vec<u32> = {
731 let mut slots = c.slots();
732 let mut popped = Vec::new();
733 while u64::cast_from(popped.len()).saturating_mul(class_size) < want {
734 let Some(index) = slots.pop_oldest_warm() else {
735 break;
736 };
737 c.warm_bytes.fetch_sub(class_size, Ordering::Relaxed);
738 popped.push(index);
739 }
740 // Under the lock, so an `alloc` never sees the slots neither
741 // warm nor punching.
742 self.punching.fetch_add(
743 u64::cast_from(popped.len()).saturating_mul(class_size),
744 Ordering::Relaxed,
745 );
746 popped
747 };
748 let mut punched = 0;
749 for &index in &popped {
750 let slot = FileSlot { class, index };
751 if let Err(err) = sys::punch_hole(&c.file, self.offset(slot), c.size) {
752 tracing::warn!("pool file store: punching a free slot failed: {err}");
753 break;
754 }
755 punched += 1;
756 self.allocated.fetch_sub(class_size, Ordering::Relaxed);
757 // Release orders the `allocated` decrement before it for
758 // `alloc`, which reads `punching` with acquire.
759 self.punching.fetch_sub(class_size, Ordering::Release);
760 self.counters
761 .holes_punched_bytes
762 .fetch_add(class_size, Ordering::Relaxed);
763 }
764 let (cold, warm) = popped.split_at(punched);
765 let mut slots = c.slots();
766 for &index in cold {
767 slots.free(index, false);
768 }
769 // Back to the old end, in their original order, so the next release
770 // retries them first and `alloc` does not prefer them.
771 for &index in warm.iter().rev() {
772 slots.return_oldest_warm(index);
773 c.warm_bytes.fetch_add(class_size, Ordering::Relaxed);
774 }
775 self.punching.fetch_sub(
776 u64::cast_from(warm.len()).saturating_mul(class_size),
777 Ordering::Release,
778 );
779 (
780 u64::cast_from(punched).saturating_mul(class_size),
781 !warm.is_empty(),
782 )
783 }
784
785 fn disable_writes(&self, op: &str, err: &io::Error) {
786 self.counters.write_errors.fetch_add(1, Ordering::Relaxed);
787 if !self.writes_disabled.swap(true, Ordering::Relaxed) {
788 tracing::warn!("pool file store: {op} failed, disabling writes: {err}");
789 }
790 }
791}
792
793/// Opens `classes` anonymous files for `io_mode` and probes the first. In
794/// [`IoMode::Direct`], an `EINVAL` from the open or the probe means direct
795/// I/O is unusable here.
796fn open_files(dir: &Path, classes: usize, page: usize, io_mode: IoMode) -> io::Result<Vec<File>> {
797 let direct = io_mode == IoMode::Direct;
798 let files = (0..classes)
799 .map(|class| sys::open_anonymous(dir, class, direct))
800 .collect::<io::Result<Vec<_>>>()?;
801 probe(&files[0], page, io_mode)?;
802 Ok(files)
803}
804
805/// Allocates, writes, reads back, and punches one page at offset 0.
806fn probe(file: &File, page: usize, io_mode: IoMode) -> io::Result<()> {
807 let pattern: Vec<u8> = (0..page)
808 .map(|i| u8::try_from(i % 251).expect("fits"))
809 .collect();
810 let src = AlignedBuf::from_bytes(&pattern);
811 sys::fallocate(file, 0, page)?;
812 write_all(file, src.as_slice(), 0, io_mode)?;
813 let mut dst = AlignedBuf::new();
814 dst.resize(page);
815 let mut done = 0;
816 while done < page {
817 match sys::pread(file, &mut dst.as_mut_slice()[done..], u64::cast_from(done)) {
818 Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)),
819 Ok(k) => done += k,
820 Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
821 Err(err) => return Err(err),
822 }
823 }
824 if dst.as_slice() != src.as_slice() {
825 return Err(io::Error::other("probe read back different bytes"));
826 }
827 sys::punch_hole(file, 0, page)
828}
829
830/// Writes all of `buf` at `offset`, retrying on `EINTR` and short writes.
831/// In [`IoMode::Direct`], a short write restarts the whole buffer at
832/// `offset`, at most [`DIRECT_WRITE_ATTEMPTS`] times, and the final attempt's
833/// error is returned.
834fn write_all(file: &File, buf: &[u8], offset: u64, io_mode: IoMode) -> io::Result<()> {
835 match io_mode {
836 IoMode::Direct => {
837 // Resuming at `offset + done` would be a direct write that is
838 // aligned only if `done` is, which the kernel does not promise,
839 // and an unaligned direct write fails `EINVAL`, which disables
840 // writes. Rewriting the same bytes is idempotent, and when the
841 // short write came from a full filesystem the retry returns the
842 // `ENOSPC` the caller classifies.
843 let mut attempts = 0;
844 loop {
845 match sys::pwrite(file, buf, offset) {
846 Ok(k) if k == buf.len() => return Ok(()),
847 Ok(_) => {
848 attempts += 1;
849 if attempts == DIRECT_WRITE_ATTEMPTS {
850 return Err(io::Error::from(io::ErrorKind::WriteZero));
851 }
852 }
853 Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
854 Err(err) => return Err(err),
855 }
856 }
857 }
858 IoMode::Buffered => {
859 let mut done = 0;
860 while done < buf.len() {
861 match sys::pwrite(file, &buf[done..], offset + u64::cast_from(done)) {
862 Ok(0) => return Err(io::Error::from(io::ErrorKind::WriteZero)),
863 Ok(k) => done += k,
864 Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
865 Err(err) => return Err(err),
866 }
867 }
868 Ok(())
869 }
870 }
871}
872
873/// Page-aligned, growable byte buffer for `O_DIRECT` reads.
874#[derive(Debug)]
875pub(crate) struct AlignedBuf {
876 /// Valid for `cap` initialized bytes, or dangling when `cap == 0`.
877 ptr: NonNull<u8>,
878 len: usize,
879 cap: usize,
880}
881
882// SAFETY: `AlignedBuf` exclusively owns its allocation, like `Vec<u8>`.
883unsafe impl Send for AlignedBuf {}
884
885impl AlignedBuf {
886 pub(crate) const fn new() -> AlignedBuf {
887 AlignedBuf {
888 ptr: NonNull::dangling(),
889 len: 0,
890 cap: 0,
891 }
892 }
893
894 pub(crate) fn as_slice(&self) -> &[u8] {
895 // SAFETY: `ptr` is valid for `cap >= len` initialized bytes, or
896 // dangling and well aligned with `len == 0`.
897 unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
898 }
899
900 fn as_mut_slice(&mut self) -> &mut [u8] {
901 // SAFETY: as in `as_slice`, and `&mut self` makes the borrow unique.
902 unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) }
903 }
904
905 /// Releases the allocation if its capacity exceeds `max` bytes, which
906 /// also empties the buffer.
907 pub(crate) fn shrink_above(&mut self, max: usize) {
908 if self.cap > max {
909 self.release();
910 }
911 }
912
913 /// Sets the length to `len`, growing capacity to a page multiple if
914 /// needed. Contents are unspecified but initialized.
915 fn resize(&mut self, len: usize) {
916 if len > self.cap {
917 self.release();
918 let cap = len.next_multiple_of(page_size());
919 let layout = Self::layout(cap);
920 // SAFETY: `layout` has nonzero size because `len > 0`. Zeroing
921 // keeps every byte up to `cap` initialized.
922 let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
923 self.ptr = NonNull::new(ptr).unwrap_or_else(|| std::alloc::handle_alloc_error(layout));
924 self.cap = cap;
925 }
926 self.len = len;
927 }
928
929 fn truncate(&mut self, len: usize) {
930 self.len = self.len.min(len);
931 }
932
933 fn release(&mut self) {
934 if self.cap > 0 {
935 // SAFETY: `ptr` was allocated in `resize` with exactly this
936 // layout, and no borrow outlives `&mut self`.
937 unsafe { std::alloc::dealloc(self.ptr.as_ptr(), Self::layout(self.cap)) };
938 }
939 // NOTE: assigning `*self` would drop the old value and recurse
940 // through `Drop`.
941 self.ptr = NonNull::dangling();
942 self.len = 0;
943 self.cap = 0;
944 }
945
946 fn layout(cap: usize) -> Layout {
947 Layout::from_size_align(cap, page_size()).expect("valid aligned buffer layout")
948 }
949
950 /// A buffer holding a copy of `bytes`.
951 pub(crate) fn from_bytes(bytes: &[u8]) -> AlignedBuf {
952 let mut buf = AlignedBuf::new();
953 buf.resize(bytes.len());
954 buf.as_mut_slice().copy_from_slice(bytes);
955 buf
956 }
957}
958
959impl Drop for AlignedBuf {
960 fn drop(&mut self) {
961 self.release();
962 }
963}
964
965mod sys;
966
967#[cfg(test)]
968pub(crate) mod testing;
969
970#[cfg(all(test, target_os = "linux"))]
971mod tests;