Skip to main content

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;