nmbrs_workload/edit/backup.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backup-rotation transaction for workload edits.
5//!
6//! Per SRD-64 §6.5: every workload mutation rotates two
7//! sibling files alongside the workload:
8//!
9//! - `<workload>.bak` — the immediate-previous content,
10//! written *before* the new content lands on disk.
11//! - `<workload>.bak.prev` — one step further back (the
12//! pre-previous content). Lets the user recover from
13//! "the last edit was right but the one before was the
14//! one I wanted" without reaching for git.
15//!
16//! Two-deep is the policy floor; deeper history belongs in
17//! version control.
18//!
19//! ## Rotation order
20//!
21//! Three steps per edit, all `fs::rename` for atomicity:
22//!
23//! 1. Move `<workload>.bak` → `<workload>.bak.prev`
24//! (overwriting any existing `.bak.prev`).
25//! 2. Copy `<workload>` → `<workload>.bak`. Copy, not
26//! rename, because the workload itself has to stay in
27//! place until step 3 (otherwise readers see a missing
28//! file mid-edit). On filesystems that support reflinks
29//! (btrfs, xfs), the copy is essentially free.
30//! 3. Write the new content to `<workload>` via a temp
31//! file + atomic rename so the workload itself is never
32//! half-written.
33//!
34//! Failure during step 1 leaves the prior backup pair
35//! untouched. Failure during step 2 leaves a mismatched
36//! pair (`bak.prev` is the old `bak`, `bak` is missing) —
37//! the rollback path restores `bak.prev` → `bak` to keep
38//! the pair consistent. Failure during step 3 leaves the
39//! workload at its pre-edit state with a fresh `bak` that
40//! matches it.
41
42use std::fs;
43use std::io;
44use std::path::{Path, PathBuf};
45
46/// Sibling-file paths for one workload.
47#[derive(Debug, Clone)]
48pub struct BackupPaths {
49 /// The workload file itself.
50 pub workload: PathBuf,
51 /// `<workload>.bak` — most-recent prior content.
52 pub bak: PathBuf,
53 /// `<workload>.bak.prev` — one step further back.
54 pub bak_prev: PathBuf,
55 /// Tempfile used for the atomic write of the new
56 /// content; renamed over `workload` once the buffer is
57 /// flushed.
58 pub temp: PathBuf,
59}
60
61impl BackupPaths {
62 /// Derive the sibling-file paths for `workload_path`.
63 /// Convention: `<file>.bak`, `<file>.bak.prev`, and
64 /// `<file>.tmp` all live in the same directory as the
65 /// workload, so the renames stay within one filesystem
66 /// (atomic).
67 pub fn for_workload(workload_path: &Path) -> Self {
68 let workload = workload_path.to_path_buf();
69 let bak = path_with_suffix(&workload, ".bak");
70 let bak_prev = path_with_suffix(&workload, ".bak.prev");
71 let temp = path_with_suffix(&workload, ".tmp");
72 Self {
73 workload,
74 bak,
75 bak_prev,
76 temp,
77 }
78 }
79}
80
81fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf {
82 let mut s = p.as_os_str().to_owned();
83 s.push(suffix);
84 PathBuf::from(s)
85}
86
87/// Run the backup rotation: rotate `.bak` → `.bak.prev`,
88/// copy `workload` → `.bak`. Caller subsequently writes the
89/// new content to `temp` and atomically renames it over
90/// `workload`. See [`commit_temp`].
91///
92/// Returns the [`BackupPaths`] so the caller doesn't have
93/// to re-derive them.
94pub fn rotate(workload_path: &Path) -> io::Result<BackupPaths> {
95 let paths = BackupPaths::for_workload(workload_path);
96 if !paths.workload.exists() {
97 return Err(io::Error::new(
98 io::ErrorKind::NotFound,
99 format!(
100 "workload '{}' does not exist; cannot create backup",
101 paths.workload.display()
102 ),
103 ));
104 }
105
106 // Step 1: bak → bak.prev. Either may be absent on the
107 // first edit; absence is fine, we just skip.
108 if paths.bak.exists() {
109 // Remove any stale bak.prev so the rename succeeds
110 // on Windows (which fails rename when destination
111 // exists; Unix overwrites silently).
112 let _ = fs::remove_file(&paths.bak_prev);
113 fs::rename(&paths.bak, &paths.bak_prev).map_err(|e| {
114 io::Error::new(
115 e.kind(),
116 format!(
117 "backup rotate {} → {}: {e}",
118 paths.bak.display(),
119 paths.bak_prev.display()
120 ),
121 )
122 })?;
123 }
124
125 // Step 2: workload → bak (copy, not rename — the
126 // workload has to stay in place for callers reading it
127 // mid-edit).
128 fs::copy(&paths.workload, &paths.bak).map_err(|e| {
129 io::Error::new(
130 e.kind(),
131 format!(
132 "backup copy {} → {}: {e}",
133 paths.workload.display(),
134 paths.bak.display()
135 ),
136 )
137 })?;
138
139 Ok(paths)
140}
141
142/// Atomically replace the workload with the contents of
143/// `temp`. Caller has written the new content to `temp`;
144/// this rename promotes it to the workload's path. After
145/// this returns, `<workload>.bak` holds the pre-edit
146/// content and the workload itself holds the post-edit
147/// content.
148pub fn commit_temp(paths: &BackupPaths) -> io::Result<()> {
149 fs::rename(&paths.temp, &paths.workload).map_err(|e| {
150 io::Error::new(
151 e.kind(),
152 format!(
153 "commit {} → {}: {e}",
154 paths.temp.display(),
155 paths.workload.display()
156 ),
157 )
158 })
159}
160
161/// Roll back a partially-applied rotation. Used when the
162/// in-memory mutation step fails (or its post-write parse
163/// fails). Restores the on-disk state to what it was
164/// before [`rotate`] ran:
165///
166/// - The workload itself wasn't touched by `rotate` (we
167/// copied, didn't move), so it's already correct.
168/// - `<workload>.bak` was overwritten by the copy from the
169/// workload — that's a no-op semantically (the bak now
170/// matches the workload, same as it would after a clean
171/// commit on the same content). To preserve the
172/// "<workload>.bak holds the pre-edit content" invariant
173/// strictly, we restore the previous `.bak` from
174/// `.bak.prev` (which was the prior pre-edit content
175/// before the rotate).
176/// - `<workload>.bak.prev` is restored to whatever it was
177/// before the rotate — but we don't have that; the
178/// rotation overwrote it. Best effort: leave the current
179/// `.bak.prev` as the recovery point.
180///
181/// In short: roll back the bak↔bak.prev swap so the
182/// invariant "<workload>.bak == content prior to the most
183/// recent successful edit" holds.
184pub fn rollback(paths: &BackupPaths) -> io::Result<()> {
185 // If the temp file exists from an aborted write, drop it.
186 let _ = fs::remove_file(&paths.temp);
187
188 // Reverse step 1 — bak.prev → bak — if a prev exists.
189 // This restores the .bak to what it was before the
190 // (failed) edit kicked off.
191 if paths.bak_prev.exists() {
192 let _ = fs::remove_file(&paths.bak);
193 fs::rename(&paths.bak_prev, &paths.bak).map_err(|e| {
194 io::Error::new(
195 e.kind(),
196 format!(
197 "backup rollback {} → {}: {e}",
198 paths.bak_prev.display(),
199 paths.bak.display()
200 ),
201 )
202 })?;
203 } else {
204 // No prev — first edit. Drop the new bak so we
205 // return to the no-history state.
206 let _ = fs::remove_file(&paths.bak);
207 }
208 Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::sync::atomic::{AtomicUsize, Ordering};
215
216 static COUNTER: AtomicUsize = AtomicUsize::new(0);
217 fn fresh_dir(label: &str) -> PathBuf {
218 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
219 let p = std::env::temp_dir().join(format!(
220 "nmbrs-edit-backup-{label}-{}-{n}",
221 std::process::id(),
222 ));
223 let _ = fs::remove_dir_all(&p);
224 fs::create_dir_all(&p).unwrap();
225 p
226 }
227
228 #[test]
229 fn first_edit_creates_bak_only() {
230 let dir = fresh_dir("first_edit");
231 let workload = dir.join("w.yaml");
232 fs::write(&workload, b"v1\n").unwrap();
233 let paths = rotate(&workload).expect("rotate");
234 assert!(paths.bak.exists(), ".bak should exist");
235 assert!(
236 !paths.bak_prev.exists(),
237 ".bak.prev should not exist on first edit"
238 );
239 assert_eq!(fs::read(&paths.bak).unwrap(), b"v1\n");
240 }
241
242 #[test]
243 fn second_edit_promotes_bak_to_bak_prev() {
244 let dir = fresh_dir("second_edit");
245 let workload = dir.join("w.yaml");
246 fs::write(&workload, b"v1\n").unwrap();
247 let paths = rotate(&workload).expect("rotate v1");
248 // Simulate a successful first edit: workload now
249 // holds v2, .bak holds v1.
250 fs::write(&workload, b"v2\n").unwrap();
251 // Second edit: rotate again with workload=v2.
252 let paths2 = rotate(&workload).expect("rotate v2");
253 assert!(paths2.bak.exists());
254 assert!(paths2.bak_prev.exists());
255 assert_eq!(
256 fs::read(&paths2.bak).unwrap(),
257 b"v2\n",
258 ".bak should hold the just-prior content (v2)"
259 );
260 assert_eq!(
261 fs::read(&paths2.bak_prev).unwrap(),
262 b"v1\n",
263 ".bak.prev should hold the one-before-prior content (v1)"
264 );
265 // Original BackupPaths still consistent.
266 let _ = paths;
267 }
268
269 #[test]
270 fn third_edit_drops_oldest() {
271 let dir = fresh_dir("third_edit");
272 let workload = dir.join("w.yaml");
273 fs::write(&workload, b"v1\n").unwrap();
274 let _ = rotate(&workload).expect("rotate v1");
275 fs::write(&workload, b"v2\n").unwrap();
276 let _ = rotate(&workload).expect("rotate v2");
277 fs::write(&workload, b"v3\n").unwrap();
278 let paths = rotate(&workload).expect("rotate v3");
279 // Two-deep: .bak=v3 (just-prior), .bak.prev=v2.
280 // The original v1 is gone.
281 assert_eq!(fs::read(&paths.bak).unwrap(), b"v3\n");
282 assert_eq!(fs::read(&paths.bak_prev).unwrap(), b"v2\n");
283 }
284
285 #[test]
286 fn commit_temp_renames_atomically() {
287 let dir = fresh_dir("commit_temp");
288 let workload = dir.join("w.yaml");
289 fs::write(&workload, b"v1\n").unwrap();
290 let paths = rotate(&workload).expect("rotate");
291 fs::write(&paths.temp, b"v2 new content\n").unwrap();
292 commit_temp(&paths).expect("commit");
293 assert_eq!(fs::read(&workload).unwrap(), b"v2 new content\n");
294 assert!(!paths.temp.exists(), "temp consumed by rename");
295 assert_eq!(
296 fs::read(&paths.bak).unwrap(),
297 b"v1\n",
298 ".bak still holds pre-edit content"
299 );
300 }
301
302 #[test]
303 fn rollback_after_first_edit_restores_no_history_state() {
304 let dir = fresh_dir("rollback_first");
305 let workload = dir.join("w.yaml");
306 fs::write(&workload, b"v1\n").unwrap();
307 let paths = rotate(&workload).expect("rotate");
308 // Simulate the mutation step failing — caller never
309 // wrote temp, never committed. Roll back.
310 rollback(&paths).expect("rollback");
311 assert!(
312 !paths.bak.exists(),
313 ".bak should be dropped on rollback of first edit"
314 );
315 assert!(!paths.bak_prev.exists());
316 assert_eq!(
317 fs::read(&workload).unwrap(),
318 b"v1\n",
319 "workload itself untouched"
320 );
321 }
322
323 #[test]
324 fn rollback_after_second_edit_restores_pre_rotate_state() {
325 let dir = fresh_dir("rollback_second");
326 let workload = dir.join("w.yaml");
327 fs::write(&workload, b"v1\n").unwrap();
328 let _ = rotate(&workload).expect("first rotate");
329 fs::write(&workload, b"v2\n").unwrap();
330 let paths2 = rotate(&workload).expect("second rotate");
331 // Pre-rollback state: .bak=v2, .bak.prev=v1.
332 // Roll back the second rotate. Expected post-state:
333 // .bak=v1 (the original pre-history backup),
334 // .bak.prev gone.
335 rollback(&paths2).expect("rollback");
336 assert_eq!(
337 fs::read(&paths2.bak).unwrap(),
338 b"v1\n",
339 ".bak restored from .bak.prev"
340 );
341 assert!(!paths2.bak_prev.exists(), ".bak.prev consumed by rollback");
342 assert_eq!(
343 fs::read(&workload).unwrap(),
344 b"v2\n",
345 "workload still at v2 (rollback doesn't touch workload — that's the temp's job)"
346 );
347 }
348
349 #[test]
350 fn rotate_errors_when_workload_missing() {
351 let dir = fresh_dir("missing_workload");
352 let workload = dir.join("missing.yaml");
353 let err = rotate(&workload).unwrap_err();
354 assert_eq!(err.kind(), io::ErrorKind::NotFound);
355 assert!(err.to_string().contains("does not exist"), "got: {err}");
356 }
357}