Skip to main content

rushdb_storage/
copy_on_write.rs

1//! Copy-on-write opt-out for the data directory (SCALE-2, 2026-10-18).
2//!
3//! redb materializes commits without an fsync and makes them durable at each
4//! checkpoint, which holds the single writer through the fsync. On a
5//! copy-on-write filesystem (btrfs) that fsync re-writes and re-compresses
6//! every dirty extent: measured 10–28 s per checkpoint under a bulk load,
7//! stalling every tenant, against ≤ 1.13 s with copy-on-write off. The store
8//! therefore sets `FS_NOCOW_FL` on the data directory before it creates any
9//! file, so the redb files, the WAL directory and its segments inherit it.
10//! The flag only takes effect on files created empty, so a file created
11//! before the opt-out keeps copy-on-write until it is re-created (restore
12//! from a backup). Filesystems without the flag (ext4, xfs, tmpfs) never
13//! copy on write and report [`CopyOnWrite::NotApplicable`].
14
15use std::path::Path;
16
17/// Copy-on-write state of a store's data files, reported at boot.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum CopyOnWrite {
20    /// The filesystem has no copy-on-write flag (ext4, xfs, tmpfs, …).
21    NotApplicable,
22    /// The data directory and every data file have copy-on-write off.
23    Off,
24    /// Some data files still copy on write: their paths, and why.
25    On {
26        /// Files or directories that still copy on write.
27        paths: Vec<String>,
28        /// Why the opt-out did not reach them.
29        reason: String,
30    },
31}
32
33/// Turn copy-on-write off for the directory of the store file `db` (new
34/// files inherit it), then report the state of that directory, the shard
35/// files (`db`, `db-N`) and the files of the WAL directory (`db.wal`).
36pub(crate) fn opt_out(db: &Path) -> CopyOnWrite {
37    imp::opt_out(db)
38}
39
40#[cfg(target_os = "linux")]
41mod imp {
42    use super::CopyOnWrite;
43    use rustix::fs::{ioctl_getflags, ioctl_setflags, IFlags};
44    use rustix::io::Errno;
45    use std::path::{Path, PathBuf};
46
47    pub(super) fn opt_out(db: &Path) -> CopyOnWrite {
48        let Some(dir) = db.parent().filter(|dir| !dir.as_os_str().is_empty()) else {
49            return CopyOnWrite::NotApplicable;
50        };
51        let Some(name) = db.file_name().and_then(|name| name.to_str()) else {
52            return CopyOnWrite::NotApplicable;
53        };
54        let flags = match flags_of(dir) {
55            Ok(flags) => flags,
56            Err(error) if unsupported(error) => return CopyOnWrite::NotApplicable,
57            Err(error) => return on(&[dir.to_path_buf()], format!("read flags: {error}")),
58        };
59        if !flags.contains(IFlags::NOCOW) {
60            match set_nocow(dir, flags) {
61                Ok(()) => {}
62                Err(error) if unsupported(error) => return CopyOnWrite::NotApplicable,
63                Err(error) => return on(&[dir.to_path_buf()], format!("set FS_NOCOW_FL: {error}")),
64            }
65        }
66        let mut still_cow = Vec::new();
67        for file in data_files(dir, name) {
68            match flags_of(&file) {
69                Ok(flags) if flags.contains(IFlags::NOCOW) => {}
70                // A file created empty after the opt-out inherits the flag;
71                // an empty one from before can still take it.
72                Ok(flags) if std::fs::metadata(&file).is_ok_and(|meta| meta.len() == 0) => {
73                    if set_nocow(&file, flags).is_err() {
74                        still_cow.push(file);
75                    }
76                }
77                Ok(_) | Err(_) => still_cow.push(file),
78            }
79        }
80        if still_cow.is_empty() {
81            CopyOnWrite::Off
82        } else {
83            on(
84                &still_cow,
85                "created before copy-on-write was turned off; re-create them (restore from a backup) to drop it".into(),
86            )
87        }
88    }
89
90    fn on(paths: &[PathBuf], reason: String) -> CopyOnWrite {
91        CopyOnWrite::On {
92            paths: paths
93                .iter()
94                .map(|path| path.display().to_string())
95                .collect(),
96            reason,
97        }
98    }
99
100    fn unsupported(error: Errno) -> bool {
101        matches!(
102            error,
103            Errno::NOTTY | Errno::OPNOTSUPP | Errno::INVAL | Errno::NOSYS
104        )
105    }
106
107    fn flags_of(path: &Path) -> Result<IFlags, Errno> {
108        let file = std::fs::File::open(path)
109            .map_err(|error| Errno::from_io_error(&error).unwrap_or(Errno::IO))?;
110        ioctl_getflags(&file)
111    }
112
113    fn set_nocow(path: &Path, flags: IFlags) -> Result<(), Errno> {
114        let file = std::fs::File::open(path)
115            .map_err(|error| Errno::from_io_error(&error).unwrap_or(Errno::IO))?;
116        ioctl_setflags(&file, flags | IFlags::NOCOW)
117    }
118
119    /// The store's files in `dir`: shard files (`name`, `name-N`) and the
120    /// regular files of the WAL directory (`name.wal`).
121    fn data_files(dir: &Path, name: &str) -> Vec<PathBuf> {
122        let shard = |file: &str| {
123            file == name
124                || file
125                    .strip_prefix(name)
126                    .and_then(|rest| rest.strip_prefix('-'))
127                    .is_some_and(|n| n.bytes().all(|b| b.is_ascii_digit()))
128        };
129        let wal = format!("{name}.wal");
130        let mut files = Vec::new();
131        let Ok(entries) = std::fs::read_dir(dir) else {
132            return files;
133        };
134        for entry in entries.flatten() {
135            let path = entry.path();
136            let file = entry.file_name();
137            let file = file.to_string_lossy();
138            match entry.file_type() {
139                Ok(kind) if kind.is_file() && shard(&file) => files.push(path),
140                Ok(kind) if kind.is_dir() && file == wal => {
141                    if let Ok(inner) = std::fs::read_dir(&path) {
142                        files.extend(
143                            inner
144                                .flatten()
145                                .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
146                                .map(|entry| entry.path()),
147                        );
148                    }
149                }
150                _ => {}
151            }
152        }
153        files
154    }
155}
156
157#[cfg(not(target_os = "linux"))]
158mod imp {
159    use super::CopyOnWrite;
160    use std::path::Path;
161
162    pub(super) fn opt_out(_db: &Path) -> CopyOnWrite {
163        CopyOnWrite::NotApplicable
164    }
165}
166
167#[cfg(all(test, target_os = "linux"))]
168mod tests {
169    use super::{opt_out, CopyOnWrite};
170    use rustix::fs::{ioctl_getflags, IFlags};
171    use std::path::{Path, PathBuf};
172
173    /// A fresh directory on the filesystem holding the build (`/tmp` is often
174    /// tmpfs, which has no copy-on-write to turn off).
175    struct BuildDir(PathBuf);
176
177    impl BuildDir {
178        fn new() -> Self {
179            let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
180                .join("../../target/copy-on-write-tests")
181                .join(uuid::Uuid::now_v7().to_string());
182            std::fs::create_dir_all(&dir).unwrap();
183            Self(dir)
184        }
185    }
186
187    impl Drop for BuildDir {
188        fn drop(&mut self) {
189            let _ = std::fs::remove_dir_all(&self.0);
190        }
191    }
192
193    fn nocow(path: &Path) -> bool {
194        ioctl_getflags(std::fs::File::open(path).unwrap())
195            .unwrap()
196            .contains(IFlags::NOCOW)
197    }
198
199    #[test]
200    fn a_durable_store_creates_its_files_without_copy_on_write() {
201        let dir = BuildDir::new();
202        let db = dir.0.join("data").join("store.redb");
203        let store = crate::Store::open_with_deriver(
204            &db,
205            crate::resources::StorageResources::default(),
206            None,
207        )
208        .unwrap();
209        match store.copy_on_write().expect("a durable store records it") {
210            CopyOnWrite::NotApplicable => {}
211            CopyOnWrite::Off => {
212                assert!(nocow(db.parent().unwrap()));
213                assert!(nocow(&db));
214                let wal = PathBuf::from(format!("{}.wal", db.display()));
215                for segment in std::fs::read_dir(wal).unwrap() {
216                    assert!(nocow(&segment.unwrap().path()));
217                }
218            }
219            CopyOnWrite::On { paths, reason } => panic!("{paths:?} still copy on write: {reason}"),
220        }
221    }
222
223    #[test]
224    fn a_file_written_before_the_opt_out_is_reported_by_path() {
225        let dir = BuildDir::new();
226        let db = dir.0.join("store.redb");
227        std::fs::write(&db, b"written while the directory still copied on write").unwrap();
228        let unrelated = dir.0.join("config.yml");
229        std::fs::write(&unrelated, b"not a store file").unwrap();
230        match opt_out(&db) {
231            CopyOnWrite::NotApplicable => {}
232            CopyOnWrite::On { paths, reason } => {
233                assert_eq!(paths, vec![db.display().to_string()]);
234                assert!(reason.contains("restore from a backup"), "{reason}");
235                assert!(nocow(&dir.0), "new files still inherit the opt-out");
236            }
237            CopyOnWrite::Off => panic!("a non-empty file cannot drop copy-on-write in place"),
238        }
239    }
240}