metrale_storage/
layout.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: On-disk layout of the high-speed-swap KV tier: one pre-allocated file
4//! per layer, `layer_{:05}.kv` under `HighSpeedSwapConfig::dir` (`--high-speed-swap-dir`).
5//!
6//! Owner: storage, high-speed swap.
7//! Invariants:
8//! - A `Layout` holds one open file per layer of `spec`, each at least
9//!   `spec.bytes_per_layer()` long; `create` and `open` return an error otherwise.
10//!
11//! On Linux the files are opened `O_DIRECT`. Space is reserved with
12//! `posix_fallocate` on unix and `set_len` on Windows.
13
14use anyhow::{Context, Result};
15use std::fs::{File, OpenOptions};
16#[cfg(unix)]
17use std::os::fd::{AsRawFd, RawFd};
18use std::path::{Path, PathBuf};
19
20use crate::group::{GroupKey, GroupLayout};
21
22pub struct Layout {
23    pub dir: PathBuf,
24    pub spec: GroupLayout,
25    /// 2026-09-25: One file per layer. A `File`, because `PosixBackend` does
26    /// positional I/O on it (`tier::pio`) on every platform.
27    files: Vec<File>,
28}
29
30impl Layout {
31    pub fn create(dir: &Path, spec: GroupLayout) -> Result<Self> {
32        std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?;
33        let mut files = Vec::with_capacity(spec.num_layers as usize);
34        for layer in 0..spec.num_layers {
35            let p = dir.join(format!("layer_{layer:05}.kv"));
36            let mut opts = OpenOptions::new();
37            opts.read(true).write(true).create(true).truncate(false);
38            set_direct_flag(&mut opts);
39            let f = opts
40                .open(&p)
41                .with_context(|| format!("open {}", p.display()))?;
42            preallocate(&f, spec.bytes_per_layer())
43                .with_context(|| format!("preallocate {}", p.display()))?;
44            files.push(f);
45        }
46        Ok(Self {
47            dir: dir.to_path_buf(),
48            spec,
49            files,
50        })
51    }
52
53    /// 2026-09-25: Open an existing layout. A missing or undersized layer file is an
54    /// error.
55    pub fn open(dir: &Path, spec: GroupLayout) -> Result<Self> {
56        let mut files = Vec::with_capacity(spec.num_layers as usize);
57        for layer in 0..spec.num_layers {
58            let p = dir.join(format!("layer_{layer:05}.kv"));
59            let mut opts = OpenOptions::new();
60            opts.read(true).write(true);
61            set_direct_flag(&mut opts);
62            let f = opts
63                .open(&p)
64                .with_context(|| format!("open {}", p.display()))?;
65            let len = f.metadata()?.len();
66            if len < spec.bytes_per_layer() {
67                anyhow::bail!(
68                    "layer file {} is undersized: {} < {}",
69                    p.display(),
70                    len,
71                    spec.bytes_per_layer()
72                );
73            }
74            files.push(f);
75        }
76        Ok(Self {
77            dir: dir.to_path_buf(),
78            spec,
79            files,
80        })
81    }
82
83    /// 2026-09-25: Raw fd of a layer file, for `IoUringBackend` and
84    /// `PosixBackend::drop_pagecache`.
85    #[cfg(unix)]
86    pub fn fd(&self, layer: u32) -> RawFd {
87        self.files[layer as usize].as_raw_fd()
88    }
89
90    /// 2026-09-25: The layer file, for `PosixBackend`'s positional I/O; the only
91    /// accessor on Windows.
92    pub fn file(&self, layer: u32) -> &File {
93        &self.files[layer as usize]
94    }
95
96    pub fn offset(&self, key: GroupKey) -> u64 {
97        self.spec.file_offset(key)
98    }
99
100    pub fn group_bytes(&self) -> u64 {
101        self.spec.group_bytes()
102    }
103}
104
105#[cfg(target_os = "linux")]
106fn set_direct_flag(opts: &mut OpenOptions) {
107    use std::os::unix::fs::OpenOptionsExt;
108    opts.custom_flags(libc::O_DIRECT);
109}
110
111#[cfg(not(target_os = "linux"))]
112fn set_direct_flag(_opts: &mut OpenOptions) {}
113
114#[cfg(unix)]
115fn preallocate(file: &File, size: u64) -> Result<()> {
116    // 2026-09-25: This grows the file to `size`; the round-trip test checks the
117    // length.
118    let fd = file.as_raw_fd();
119    let res = unsafe { libc::posix_fallocate(fd, 0, size as libc::off_t) };
120    if res != 0 {
121        anyhow::bail!("posix_fallocate({size}) failed: {res}");
122    }
123    Ok(())
124}
125
126#[cfg(windows)]
127fn preallocate(file: &File, size: u64) -> Result<()> {
128    file.set_len(size)
129        .with_context(|| format!("set_len({size})"))?;
130    Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::group::{GroupKey, KvKind};
137
138    #[test]
139    fn create_open_round_trip() {
140        let tmp = tempdir();
141        let spec = GroupLayout::new(2, 4, 2, 16, 128, 2, 4096);
142        {
143            let l = Layout::create(&tmp, spec).unwrap();
144            assert_eq!(l.spec.num_layers, 2);
145            let p = tmp.join("layer_00000.kv");
146            let len = std::fs::metadata(&p).unwrap().len();
147            assert_eq!(len, spec.bytes_per_layer());
148        }
149        {
150            let l = Layout::open(&tmp, spec).unwrap();
151            let off = l.offset(GroupKey::new(0, 1, 1, KvKind::V));
152            assert_eq!(off, spec.file_offset(GroupKey::new(0, 1, 1, KvKind::V)));
153        }
154        std::fs::remove_dir_all(&tmp).ok();
155    }
156
157    fn tempdir() -> PathBuf {
158        let p = std::env::temp_dir().join(format!("metrale-storage-test-{}", std::process::id()));
159        std::fs::create_dir_all(&p).unwrap();
160        p
161    }
162}