refactor: extract write_json_atomic helper, DRY 8 call sites

Move the crash-safe write-then-rename pattern into
zesdex-utils::write_json_atomic and apply it across:

- zesdex-cms: app_config_repo, conversation_repo, settings_repo
- zesdex-iam: oauth_repo, session_repo
- zesdex-entities: Conversation::save_conversation, Session::save

Excluded (non-JSON format):
- rewind_blob_repo (binary blob)
- memory_repo (markdown + frontmatter, not JSON)
- session_lock (PID string, not JSON)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-18 03:18:27 +07:00
co-authored by Claude Opus 4.8
parent 9a6ab62562
commit 4cd38c9291
11 changed files with 74 additions and 125 deletions
@@ -12,11 +12,11 @@
clippy::cast_possible_wrap
)]
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use zesdex_utils::write_json_atomic;
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
use crate::domain::repository::AppConfigRepository;
@@ -134,31 +134,8 @@ impl AppConfigRepository for JsonAppConfigRepository {
std::fs::create_dir_all(base_dir)
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
let path = base_dir.join("app_config.json");
let tmp = base_dir.join("app_config.json.tmp");
let json =
serde_json::to_string_pretty(config).context("failed to serialize app config")?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"failed to rename '{}' -> '{}'",
tmp.display(),
path.display()
)
})?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
write_json_atomic(&path, config, None)
.with_context(|| "failed to save app_config")?;
tracing::debug!("app_config saved to '{}'", path.display());
Ok(())
}
@@ -11,10 +11,10 @@
clippy::cast_possible_wrap
)]
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use zesdex_utils::write_json_atomic;
use crate::domain::conversation::Conversation;
use crate::domain::repository::ConversationRepository;
@@ -44,31 +44,8 @@ impl ConversationRepository for JsonConversationRepository {
std::fs::create_dir_all(session_dir)
.with_context(|| format!("failed to create session dir '{}'", session_dir.display()))?;
let path = session_dir.join("conversation.json");
let tmp = session_dir.join("conversation.json.tmp");
let json = serde_json::to_string_pretty(conversation)
.context("failed to serialize conversation")?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"failed to rename '{}' -> '{}'",
tmp.display(),
path.display()
)
})?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
write_json_atomic(&path, conversation, None)
.with_context(|| "failed to save conversation")?;
tracing::debug!("conversation saved to '{}'", path.display());
Ok(())
}
@@ -11,10 +11,10 @@
clippy::cast_possible_wrap
)]
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use zesdex_utils::write_json_atomic;
use crate::domain::repository::SettingsRepository;
use crate::domain::settings::Settings;
@@ -56,31 +56,8 @@ impl SettingsRepository for JsonSettingsRepository {
std::fs::create_dir_all(base_dir)
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
let path = base_dir.join("settings.json");
let tmp = base_dir.join("settings.json.tmp");
let json =
serde_json::to_string_pretty(settings).context("failed to serialize settings")?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"failed to rename '{}' -> '{}'",
tmp.display(),
path.display()
)
})?;
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::File::open(parent) {
let _ = d.sync_all();
}
}
write_json_atomic(&path, settings, None)
.with_context(|| "failed to save settings")?;
tracing::debug!("settings saved to '{}'", path.display());
Ok(())
}
+1
View File
@@ -18,3 +18,4 @@ url.workspace = true
reqwest.workspace = true
tokio.workspace = true
tracing.workspace = true
zesdex-utils.workspace = true
@@ -58,26 +58,19 @@ impl Session {
/// Persist this session's metadata to `session.json`, atomically
/// with fsync for crash safety.
///
/// Flow: ensure the session directory exists → serialize to pretty
/// JSON → write to `session.json.tmp` → fsync → rename over
/// `session.json` → fsync parent directory.
/// Flow: ensure the session directory exists → atomically write
/// pretty-printed JSON via `write_json_atomic`.
///
/// Why: write-then-rename avoids a torn/partial `session.json` if
/// interrupted mid-write; fsync before rename ensures the data is
/// on disk before the rename makes it visible.
///
/// Return: `Ok(())` on success, or an `io::Error` from any step.
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
/// Return: `Ok(())` on success, or an `anyhow::Error` from any step.
pub fn save(&self, base_dir: &Path) -> anyhow::Result<()> {
let dir = self.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("session.json.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
zesdex_utils::write_json_atomic(&path, self, None)?;
Ok(())
}
@@ -77,20 +77,14 @@ impl Conversation {
/// Persist the conversation to a JSON file at the given base directory.
///
/// Flow: compute path from `session_id` → ensure directory exists →
/// serialize to pretty JSON → write-then-rename with fsync.
/// atomically write pretty-printed JSON via `write_json_atomic`.
///
/// Return: `Ok(())` on success, or an `io::Error` from any step.
pub fn save_conversation(&self, base_dir: &std::path::Path) -> std::io::Result<()> {
/// Return: `Ok(())` on success, or an `anyhow::Error` from any step.
pub fn save_conversation(&self, base_dir: &std::path::Path) -> anyhow::Result<()> {
let dir = base_dir.join("sessions").join(&self.session_id);
std::fs::create_dir_all(&dir)?;
let path = dir.join("conversation.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("conversation.json.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
zesdex_utils::write_json_atomic(&path, self, None)?;
Ok(())
}
@@ -11,6 +11,8 @@
//! 0o600 on Unix.
use std::path::Path;
use zesdex_utils::write_json_atomic;
use crate::domain::oauth::OAuthToken;
use crate::domain::repository::OAuthRepository;
@@ -30,20 +32,7 @@ impl OAuthRepository for FileSystemOAuthRepository {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(token)?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, data)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
if let Some(parent) = path.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
write_json_atomic(path, token, Some(0o600))?;
Ok(())
}
@@ -10,6 +10,8 @@
//! Writes use a write-then-rename + fsync pattern for crash safety.
use std::path::Path;
use zesdex_utils::write_json_atomic;
use crate::domain::repository::SessionRepository;
use crate::domain::session::Session;
@@ -61,17 +63,7 @@ impl SessionRepository for FileSystemSessionRepository {
let dir = session.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
let data = serde_json::to_string_pretty(session)?;
let tmp = dir.join("session.json.tmp");
std::fs::write(&tmp, data)?;
// fsync before rename ensures the data is on disk.
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
// fsync the parent directory so the rename survives a crash.
if let Some(parent) = dir.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
write_json_atomic(&path, session, None)?;
Ok(())
}
+46
View File
@@ -0,0 +1,46 @@
//! Crash-safe atomic file write helper.
//!
//! Writes serializable data to a temp file, fsyncs, then renames into
//! place to guarantee atomicity. On Unix, an optional `mode` sets the
//! permissions of the final file (e.g. `0o600` for OAuth tokens).
use std::io::Write;
use std::path::Path;
use serde::Serialize;
/// Atomically write serializable `data` to `path`.
///
/// Flow: serialize to pretty JSON -> write to `path.tmp` -> fsync -> rename -> fsync parent.
/// If `mode` is `Some`, set permissions before rename (Unix only).
///
/// Edge case: tmp file name uses `with_extension("tmp")` which replaces
/// the existing extension -- correct for `foo.json` -> `foo.tmp`. For paths
/// without an extension (unlikely in this codebase), appends `.tmp`.
pub fn write_json_atomic<T: Serialize>(path: &Path, data: &T, mode: Option<u32>) -> anyhow::Result<()> {
let tmp = path.with_extension("tmp");
let bytes = serde_json::to_vec_pretty(data)?;
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
if let Some(m) = mode {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(m))?;
}
#[cfg(not(unix))]
{ let _ = m; }
}
std::fs::rename(&tmp, path)?;
if let Some(parent) = path.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
Ok(())
}
+2
View File
@@ -1,3 +1,5 @@
pub mod atomic_write;
pub use atomic_write::write_json_atomic;
pub mod clipboard;
pub mod error;
pub mod logger;