Skip to content
Documentation GitHub
Patterns

Atomic Settings Persistence with Corruption Recovery

Atomic Settings Persistence with Corruption Recovery

Problem

When persisting user settings to disk, several failure modes can corrupt data:

Symptoms:

  • Application crash mid-write leaves partial JSON
  • Power loss results in empty or truncated file
  • Parse errors on next launch break the application
  • User loses all settings and must reconfigure

Investigation

Steps Tried

  1. Direct file write - Simple but unsafe; crash during write corrupts file
  2. Backup before write - Better, but complex recovery logic needed
  3. Atomic rename pattern - Clean, filesystem-guaranteed atomicity

Root Cause

Standard fs::write() is not atomic. If the process crashes or loses power during the write:

  • The file may be partially written
  • The file may be empty (truncated but not filled)
  • JSON parsing will fail on next read

Solution

Implement atomic writes using temp file + rename, combined with corruption recovery.

Code Changes

// Before (unsafe)
fn save(&self, settings: &Settings) -> Result<()> {
let json = serde_json::to_string_pretty(settings)?;
fs::write(&self.settings_path, json)?; // NOT ATOMIC
Ok(())
}
// After (atomic with recovery)
impl FileSystemSettingsRepository {
/// Perform an atomic write using a temp file and rename.
fn atomic_write(&self, content: &str) -> SettingsResult<()> {
self.ensure_dir()?;
let temp_path = self.settings_dir.join(".settings.json.tmp");
let final_path = self.settings_path();
// Write to temp file first
fs::write(&temp_path, content)?;
// Atomic rename (filesystem-level guarantee)
fs::rename(&temp_path, &final_path)?;
Ok(())
}
fn save(&self, settings: &Settings) -> SettingsResult<()> {
let json = serde_json::to_string_pretty(settings)
.map_err(|e| SettingsRepositoryError::SerializationError(e.to_string()))?;
self.atomic_write(&json)?;
Ok(())
}
}

Corruption Recovery

fn load(&self) -> SettingsResult<Settings> {
let path = self.settings_path();
if !path.exists() {
// No file = return defaults (first launch)
return Ok(Settings::default());
}
let content = fs::read_to_string(&path)?;
match serde_json::from_str::<Settings>(&content) {
Ok(settings) => Ok(settings),
Err(e) => {
// Log the corruption but don't fail
tracing::warn!(
"Failed to parse settings ({}), resetting to defaults: {}",
path.display(),
e
);
// Fix the corrupted file immediately
let defaults = Settings::default();
if let Err(save_err) = self.save(&defaults) {
tracing::error!("Failed to save default settings: {}", save_err);
}
Ok(defaults)
}
}
}

Implementation Notes

  • Temp file in same directory: Ensures rename is atomic (same filesystem)
  • Hidden temp file prefix: .settings.json.tmp won’t be visible in file browsers
  • Recovery preserves UX: Corrupted settings reset to first-launch state (user sees onboarding again)
  • Idempotent save: Can safely retry on failure

Prevention

Best Practices

  • Always use atomic writes for user-critical data
  • Place temp files in the same directory as the target (same filesystem)
  • Implement graceful degradation with defaults on read failure
  • Log corruption warnings for debugging without blocking the user

Warning Signs

  • Direct fs::write() for important data
  • No error handling on JSON parse
  • Application crashes on invalid config files
  • Tests only cover happy path
#[test]
fn test_corrupted_file_returns_defaults() {
let (temp, repo) = setup();
// Write invalid JSON
let settings_path = temp.path().join("settings.json");
fs::write(&settings_path, "not valid json {{{}").unwrap();
// Should return defaults without error
let settings = repo.load().unwrap();
assert!(!settings.first_launch_completed);
// File should now contain valid defaults
let loaded = repo.load().unwrap();
assert!(!loaded.first_launch_completed);
}
#[test]
fn test_atomic_write_creates_dir() {
let temp = TempDir::new().unwrap();
let nested_path = temp.path().join("nested").join("deep");
let repo = FileSystemSettingsRepository::new(&nested_path);
// Directory doesn't exist yet
assert!(!nested_path.exists());
// Save should create it
repo.save(&Settings::default()).unwrap();
assert!(nested_path.exists());
assert!(repo.exists());
}

References

  • Implementation: crates/inklings-infrastructure/src/filesystem/settings_repository.rs
  • Pattern used by: SQLite, Redis, Git, and most database systems
  • Linux man page: rename(2) - “rename() is atomic”

When to Apply

Use this pattern for:

  • User preferences and settings
  • Application state that must survive restarts
  • Any file where corruption would degrade UX

Skip this pattern for:

  • Temporary/cache files (can be regenerated)
  • Log files (append-only, corruption is acceptable)
  • Large files (consider chunked/incremental writes)

Was this page helpful?