rushdb_storage/
copy_on_write.rs1use std::path::Path;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum CopyOnWrite {
20 NotApplicable,
22 Off,
24 On {
26 paths: Vec<String>,
28 reason: String,
30 },
31}
32
33pub(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 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 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 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}