gunnar_server/durable.rs
1//! One place that writes a small control file durably.
2//!
3//! Both `replication.json` and `webhooks.json` are tiny JSON documents in the
4//! data root that an operator sets over the control plane and that MUST survive
5//! a power cut: a box that acknowledged the change and then lost power has to
6//! come back with it. The write is the same on both, and getting it subtly
7//! wrong is what left a 0-byte `replication.json` that panicked PID 1 on the
8//! next boot. So there is one writer, and it is here (one implementation).
9//!
10//! # Why the fsync dance
11//!
12//! `std::fs::write` + `rename` is NOT durable on xfs: the rename's directory
13//! entry is journaled while the data blocks are not, so an interrupted write
14//! leaves the file present but empty. So the bytes are fsync'd BEFORE the
15//! rename, and the directory is fsync'd AFTER it — a crash then leaves either
16//! the whole old file or the whole new one, never a torn tail.
17
18use std::path::Path;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21
22use anyhow::{Context, Result};
23
24/// Exponential backoff: `base · 2^(attempt-1)`, capped at `max`; zero for
25/// `attempt == 0`.
26///
27/// One writer for the retry ladder both the replicator and the webhook worker
28/// walk (one implementation) — a "still trying" schedule that a black-holing endpoint cannot
29/// turn into a busy loop or an overflow.
30pub fn backoff(attempt: u32, base: Duration, max: Duration) -> Duration {
31 if attempt == 0 {
32 return Duration::ZERO;
33 }
34 // Clamp the shift: 2^30 · a millisecond base already exceeds any sane cap,
35 // and clamping keeps the multiply from wrapping before `min` sees it.
36 let factor = 1u32.checked_shl((attempt - 1).min(30)).unwrap_or(u32::MAX);
37 base.saturating_mul(factor).min(max)
38}
39
40/// Atomically replace `<root>/<filename>` with `bytes`, durably.
41///
42/// Writes a `.tmp` sibling, fsyncs it, renames it over the target, then fsyncs
43/// the directory so the rename itself survives a cut and not just the bytes it
44/// points at.
45pub fn write_atomic(root: &Path, filename: &str, bytes: &[u8]) -> Result<()> {
46 // The twin hears of it FIRST (`gunnar_core::txlog::sink`): a document
47 // under a primary's data root is a `File` frame before it is a file.
48 let _framed = gunnar_core::txlog::sink::replace_file(&root.join(filename), bytes, false)
49 .with_context(|| format!("framing {filename} in the transaction log"))?;
50 write_atomic_unframed(root, filename, bytes)
51}
52
53/// [`write_atomic`] without the frame: the per-box metadata MIRROR (`slow`'s
54/// `meta`), which is a second copy on this box's other spindle and never a
55/// transaction — the fallback slow root sits UNDER the data root, where a
56/// framed mirror write would put every document on the twin twice.
57pub fn write_atomic_unframed(root: &Path, filename: &str, bytes: &[u8]) -> Result<()> {
58 static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
59 let path = root.join(filename);
60 // A temp of this save's own (stinky row 8): two saves of one document used
61 // to share `<file>.tmp` and truncate, or rename away, each other's bytes.
62 // It keeps the `.tmp` suffix — `txlog::is_cache_path` knows it as scratch.
63 let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
64 let temp = root.join(format!("{filename}.{}-{seq}.tmp", std::process::id()));
65 let written = (|| -> Result<()> {
66 let mut f = std::fs::File::create(&temp)
67 .with_context(|| format!("writing {}", temp.display()))?;
68 std::io::Write::write_all(&mut f, bytes)
69 .with_context(|| format!("writing {}", temp.display()))?;
70 f.sync_all()
71 .with_context(|| format!("flushing {} to disk", temp.display()))?;
72 std::fs::rename(&temp, &path).with_context(|| format!("replacing {}", path.display()))
73 })();
74 if let Err(e) = written {
75 let _ = std::fs::remove_file(&temp);
76 return Err(e);
77 }
78 // The rename is durable only once the directory is: a save whose
79 // directory fsync failed is not reported as saved.
80 sync_dir(root).with_context(|| format!("flushing the save of {filename} in {}", root.display()))
81}
82
83/// **fsync a directory**, so a rename into it or an unlink from it survives a
84/// power cut — its failure is the caller's failure, never swallowed.
85pub fn sync_dir(dir: &Path) -> Result<()> {
86 #[cfg(test)]
87 if let Some(e) = dir_sync_seam::injected() {
88 return Err(e).with_context(|| format!("flushing {}", dir.display()));
89 }
90 let d = std::fs::File::open(dir).with_context(|| format!("opening {} to flush it", dir.display()))?;
91 d.sync_all().with_context(|| format!("flushing {}", dir.display()))
92}
93
94/// **Remove a per-box MARKER durably**: unlink it, then fsync the directory, so
95/// the removal survives a power cut and not only the next `stat`.
96///
97/// MEASURED under KVM 2026-09-21 (lane T3): `twin::restore_finish` unlinked
98/// `txlog-restoring` with a bare `remove_file`, and the operator's "restart the
99/// box" came as a stop ten seconds later. The unlink was still in the guest's
100/// page cache; the restored box reopened WITH the marker, as a box mid-restore —
101/// follower-only, every control-plane write refused ("this box is a twin (or
102/// restoring); it originates no transactions"), its audit trail written to a
103/// per-box path, no re-seal. A marker whose PRESENCE changes what a box is must
104/// have its ABSENCE made as durable as its presence was (`write_atomic_unframed`
105/// already fsyncs the directory after creating one). A missing file is `Ok`.
106pub fn remove_durable(root: &Path, filename: &str) -> Result<()> {
107 match std::fs::remove_file(root.join(filename)) {
108 Ok(()) => {}
109 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
110 Err(e) => return Err(e).with_context(|| format!("removing {}", root.join(filename).display())),
111 }
112 sync_dir(root).with_context(|| format!("flushing the removal of {filename} from {}", root.display()))
113}
114
115/// [`write_atomic`], and then the SAME bytes again into the metadata mirror
116/// (`crate::slow`'s `dir.meta.path` — a second copy on the other spindle).
117///
118/// # Why the mirror cannot fail the save
119///
120/// The primary write has already landed and been fsync'd by the time the
121/// mirror is attempted. Returning the mirror's error would report a save that
122/// DID happen as a failure, and the caller's only sane response — retry — would
123/// rewrite a file that is already correct. Worse, on the appliance the caller
124/// is a control-plane verb an operator typed on a box with no shell: a full or
125/// read-only slow disk would take the roster away from them entirely.
126///
127/// So a mirror failure is one WARN naming the path, and the save is `Ok`. What
128/// stops that from becoming a silent lie is the other half of this design:
129/// `SlowRoot::claim_of` never reports the mirror as satisfied unless the layout
130/// config's `dir.meta.off_stripe_satisfied` says so, and so nobody plans a
131/// recovery around a mirror that is not there.
132pub fn write_atomic_mirrored(
133 root: &Path,
134 slow: &crate::slow::SlowRoot,
135 filename: &str,
136 bytes: &[u8],
137) -> Result<()> {
138 write_atomic(root, filename, bytes)?;
139 let mirrored = slow
140 .meta_dir()
141 .and_then(|dir: std::path::PathBuf| write_atomic_unframed(&dir, filename, bytes));
142 if let Err(err) = mirrored {
143 tracing::warn!(
144 target: crate::slow::TARGET_SLOW,
145 file = filename,
146 mirror_dir = %slow.path_of(crate::slow::META_DIR).display(),
147 error = %format!("{err:#}"),
148 "the metadata mirror on the slow disk could not be written; the file itself is \
149 saved on /data. This box now has ONE copy of it."
150 );
151 }
152 Ok(())
153}
154
155/// The test seam over the directory fsync after a rename: an injected failure.
156#[cfg(test)]
157pub(crate) mod dir_sync_seam {
158 use std::cell::Cell;
159
160 thread_local! {
161 static FAIL: Cell<bool> = const { Cell::new(false) };
162 }
163
164 /// Make every later directory fsync on this thread fail.
165 pub(crate) fn fail_from_now() {
166 FAIL.with(|f| f.set(true));
167 }
168
169 /// The injected failure, if armed.
170 pub(crate) fn injected() -> Option<std::io::Error> {
171 FAIL.with(|f| f.get()).then(|| std::io::Error::other("injected directory fsync failure"))
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 /// ★ **Two writers of one file do not share a temp name** (stinky row 8).
180 /// Every writer used `<file>.tmp`, so two saves of one document truncated
181 /// each other's bytes mid-write, or one renamed the other's half-written
182 /// file into place, or found its temp already renamed away.
183 #[test]
184 fn concurrent_writers_of_one_file_never_trip_over_each_others_temp() {
185 let dir = tempfile::tempdir().unwrap();
186 let root = std::sync::Arc::new(dir.path().to_path_buf());
187 let writers: Vec<_> = (0..8u8)
188 .map(|w| {
189 let root = std::sync::Arc::clone(&root);
190 std::thread::spawn(move || {
191 let body = vec![b'a' + w; 64 * 1024];
192 (0..40).map(|_| write_atomic_unframed(&root, "doc.json", &body).map_err(|e| format!("{e:#}"))).collect::<Vec<_>>()
193 })
194 })
195 .collect();
196 let failures: Vec<String> = writers.into_iter().flat_map(|w| w.join().unwrap()).filter_map(|r| r.err()).collect();
197 assert!(failures.is_empty(), "{} of 320 saves failed, first: {:?}", failures.len(), failures.first());
198 let landed = std::fs::read(dir.path().join("doc.json")).unwrap();
199 assert_eq!(landed.len(), 64 * 1024, "a save landed torn");
200 assert!(landed.iter().all(|b| *b == landed[0]), "a save landed interleaved");
201 }
202
203 /// …and a temp left behind by a crashed save does not wedge the next one.
204 #[test]
205 fn a_leftover_temp_does_not_wedge_the_next_save() {
206 let dir = tempfile::tempdir().unwrap();
207 std::fs::create_dir(dir.path().join("doc.json.tmp")).unwrap();
208 write_atomic_unframed(dir.path(), "doc.json", b"{}").unwrap();
209 assert_eq!(std::fs::read(dir.path().join("doc.json")).unwrap(), b"{}");
210 }
211
212 /// ★ **A save whose rename was never made durable says so.** The directory
213 /// fsync was best-effort, so a box acknowledged a document that a power cut
214 /// could still take back.
215 #[test]
216 fn a_failed_directory_fsync_fails_the_save() {
217 let dir = tempfile::tempdir().unwrap();
218 dir_sync_seam::fail_from_now();
219 assert!(write_atomic_unframed(dir.path(), "doc.json", b"{}").is_err(), "a save whose directory fsync failed reported success");
220 }
221}