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 =
67 std::fs::File::create(&temp).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)
91 .with_context(|| format!("opening {} to flush it", dir.display()))?;
92 d.sync_all()
93 .with_context(|| format!("flushing {}", dir.display()))
94}
95
96/// **Remove a per-box MARKER durably**: unlink it, then fsync the directory, so
97/// the removal survives a power cut and not only the next `stat`.
98///
99/// MEASURED under KVM 2026-09-21 (lane T3): `twin::restore_finish` unlinked
100/// `txlog-restoring` with a bare `remove_file`, and the operator's "restart the
101/// box" came as a stop ten seconds later. The unlink was still in the guest's
102/// page cache; the restored box reopened WITH the marker, as a box mid-restore —
103/// follower-only, every control-plane write refused ("this box is a twin (or
104/// restoring); it originates no transactions"), its audit trail written to a
105/// per-box path, no re-seal. A marker whose PRESENCE changes what a box is must
106/// have its ABSENCE made as durable as its presence was (`write_atomic_unframed`
107/// already fsyncs the directory after creating one). A missing file is `Ok`.
108pub fn remove_durable(root: &Path, filename: &str) -> Result<()> {
109 match std::fs::remove_file(root.join(filename)) {
110 Ok(()) => {}
111 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
112 Err(e) => {
113 return Err(e).with_context(|| format!("removing {}", root.join(filename).display()))
114 }
115 }
116 sync_dir(root)
117 .with_context(|| format!("flushing the removal of {filename} from {}", root.display()))
118}
119
120/// [`write_atomic`], and then the SAME bytes again into the metadata mirror
121/// (`crate::slow`'s `dir.meta.path` — a second copy on the other spindle).
122///
123/// # Why the mirror cannot fail the save
124///
125/// The primary write has already landed and been fsync'd by the time the
126/// mirror is attempted. Returning the mirror's error would report a save that
127/// DID happen as a failure, and the caller's only sane response — retry — would
128/// rewrite a file that is already correct. Worse, on the appliance the caller
129/// is a control-plane verb an operator typed on a box with no shell: a full or
130/// read-only slow disk would take the roster away from them entirely.
131///
132/// So a mirror failure is one WARN naming the path, and the save is `Ok`. What
133/// stops that from becoming a silent lie is the other half of this design:
134/// `SlowRoot::claim_of` never reports the mirror as satisfied unless the layout
135/// config's `dir.meta.off_stripe_satisfied` says so, and so nobody plans a
136/// recovery around a mirror that is not there.
137pub fn write_atomic_mirrored(
138 root: &Path,
139 slow: &crate::slow::SlowRoot,
140 filename: &str,
141 bytes: &[u8],
142) -> Result<()> {
143 write_atomic(root, filename, bytes)?;
144 let mirrored = slow
145 .meta_dir()
146 .and_then(|dir: std::path::PathBuf| write_atomic_unframed(&dir, filename, bytes));
147 if let Err(err) = mirrored {
148 tracing::warn!(
149 target: crate::slow::TARGET_SLOW,
150 file = filename,
151 mirror_dir = %slow.path_of(crate::slow::META_DIR).display(),
152 error = %format!("{err:#}"),
153 "the metadata mirror on the slow disk could not be written; the file itself is \
154 saved on /data. This box now has ONE copy of it."
155 );
156 }
157 Ok(())
158}
159
160/// The test seam over the directory fsync after a rename: an injected failure.
161#[cfg(test)]
162pub(crate) mod dir_sync_seam {
163 use std::cell::Cell;
164
165 thread_local! {
166 static FAIL: Cell<bool> = const { Cell::new(false) };
167 }
168
169 /// Make every later directory fsync on this thread fail.
170 pub(crate) fn fail_from_now() {
171 FAIL.with(|f| f.set(true));
172 }
173
174 /// The injected failure, if armed.
175 pub(crate) fn injected() -> Option<std::io::Error> {
176 FAIL.with(|f| f.get())
177 .then(|| std::io::Error::other("injected directory fsync failure"))
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 /// ★ **Two writers of one file do not share a temp name** (stinky row 8).
186 /// Every writer used `<file>.tmp`, so two saves of one document truncated
187 /// each other's bytes mid-write, or one renamed the other's half-written
188 /// file into place, or found its temp already renamed away.
189 #[test]
190 fn concurrent_writers_of_one_file_never_trip_over_each_others_temp() {
191 let dir = tempfile::tempdir().unwrap();
192 let root = std::sync::Arc::new(dir.path().to_path_buf());
193 let writers: Vec<_> = (0..8u8)
194 .map(|w| {
195 let root = std::sync::Arc::clone(&root);
196 std::thread::spawn(move || {
197 let body = vec![b'a' + w; 64 * 1024];
198 (0..40)
199 .map(|_| {
200 write_atomic_unframed(&root, "doc.json", &body)
201 .map_err(|e| format!("{e:#}"))
202 })
203 .collect::<Vec<_>>()
204 })
205 })
206 .collect();
207 let failures: Vec<String> = writers
208 .into_iter()
209 .flat_map(|w| w.join().unwrap())
210 .filter_map(|r| r.err())
211 .collect();
212 assert!(
213 failures.is_empty(),
214 "{} of 320 saves failed, first: {:?}",
215 failures.len(),
216 failures.first()
217 );
218 let landed = std::fs::read(dir.path().join("doc.json")).unwrap();
219 assert_eq!(landed.len(), 64 * 1024, "a save landed torn");
220 assert!(
221 landed.iter().all(|b| *b == landed[0]),
222 "a save landed interleaved"
223 );
224 }
225
226 /// …and a temp left behind by a crashed save does not wedge the next one.
227 #[test]
228 fn a_leftover_temp_does_not_wedge_the_next_save() {
229 let dir = tempfile::tempdir().unwrap();
230 std::fs::create_dir(dir.path().join("doc.json.tmp")).unwrap();
231 write_atomic_unframed(dir.path(), "doc.json", b"{}").unwrap();
232 assert_eq!(std::fs::read(dir.path().join("doc.json")).unwrap(), b"{}");
233 }
234
235 /// ★ **A save whose rename was never made durable says so.** The directory
236 /// fsync was best-effort, so a box acknowledged a document that a power cut
237 /// could still take back.
238 #[test]
239 fn a_failed_directory_fsync_fails_the_save() {
240 let dir = tempfile::tempdir().unwrap();
241 dir_sync_seam::fail_from_now();
242 assert!(
243 write_atomic_unframed(dir.path(), "doc.json", b"{}").is_err(),
244 "a save whose directory fsync failed reported success"
245 );
246 }
247}