feat(common): durable atomic write (power-safe #5)

write-temp -> fsync -> rename -> fsync(dir); тесты целостности.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:55:12 +03:00
parent 334faeb000
commit ab012381d0
+81 -1
View File
@@ -1 +1,81 @@
//! Durable atomic write — power-safe #5 (наполняется в Task 3).
//! Durable atomic write — power-safe #5 (a-base §3):
//! `write-temp → fsync(file) → rename → fsync(dir)`.
//!
//! Гарантия: после успеха `path` содержит новые данные целиком; при сбое до `rename`
//! сохраняется прежняя версия (или отсутствие файла) — никогда не частично записанный файл.
//! Торн-райт-безопасность опирается на атомарность POSIX `rename` в пределах одной ФС +
//! упорядочивание `fsync`; полная проверка при резком обесточивании — E2E v0.3 (spec §3/§9.1).
//! Расчёт на единственного писателя поддерева (Settings/State, architecture §3) — фикс. имя `.tmp`.
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
/// Путь временного файла рядом с целевым (`settings.json` → `settings.json.tmp`).
pub fn tmp_path(path: &Path) -> PathBuf {
let name = path
.file_name()
.map(|n| format!("{}.tmp", n.to_string_lossy()))
.unwrap_or_else(|| "shturman.tmp".to_string());
path.with_file_name(name)
}
/// Атомарно и durable записать `contents` в `path`.
pub fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "путь без родительского каталога")
})?;
let tmp = tmp_path(path);
{
let mut f = File::create(&tmp)?;
f.write_all(contents)?;
f.sync_all()?; // fsync(file)
}
fs::rename(&tmp, path)?; // atomic в пределах одной ФС
// fsync(dir) делает сам rename durable
let dir_f = File::open(dir)?;
dir_f.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn persists_full_contents() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("settings.json");
write_atomic(&p, b"{\"v\":1}").unwrap();
assert_eq!(fs::read(&p).unwrap(), b"{\"v\":1}");
}
#[test]
fn overwrites_and_leaves_no_tmp() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("settings.json");
write_atomic(&p, b"old").unwrap();
write_atomic(&p, b"new").unwrap();
assert_eq!(fs::read(&p).unwrap(), b"new");
assert!(!tmp_path(&p).exists(), "осиротевший .tmp не должен оставаться");
}
// Целостность основного файла не зависит от постороннего .tmp (rename атомарен на одной ФС).
// Полная гарантия при power-cut в момент rename — POSIX-свойство rename + E2E v0.3 (spec §3/§9.1).
#[test]
fn orphan_tmp_does_not_corrupt_main() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("settings.json");
write_atomic(&p, b"v1").unwrap();
fs::write(tmp_path(&p), b"v2-incomplete").unwrap(); // как будто краш до rename
assert_eq!(fs::read(&p).unwrap(), b"v1");
// следующая корректная запись перетирает и подчищает tmp
write_atomic(&p, b"v2").unwrap();
assert_eq!(fs::read(&p).unwrap(), b"v2");
assert!(!tmp_path(&p).exists());
}
}