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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user