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
- Direct file write - Simple but unsafe; crash during write corrupts file
- Backup before write - Better, but complex recovery logic needed
- 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.tmpwon’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
Related Tests
#[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)
Previous
AI at Authoring Time, Determinism at Execution Time Next
Bounded Loop with UUID Fallback for Unique Names
AI at Authoring Time, Determinism at Execution Time Next
Bounded Loop with UUID Fallback for Unique Names
Was this page helpful?
Thanks for your feedback!