From ab012381d009321f33d4654212aefc664257cbfb Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 24 Jun 2026 11:55:12 +0300 Subject: [PATCH] feat(common): durable atomic write (power-safe #5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write-temp -> fsync -> rename -> fsync(dir); тесты целостности. Co-Authored-By: Claude Opus 4.8 --- crates/shturman-common/src/atomic.rs | 82 +++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/shturman-common/src/atomic.rs b/crates/shturman-common/src/atomic.rs index bf6f001..2223809 100644 --- a/crates/shturman-common/src/atomic.rs +++ b/crates/shturman-common/src/atomic.rs @@ -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()); + } +}