Skip to content

fix: write persistence files atomically#34

Merged
danReynolds merged 6 commits into
mainfrom
claude/atomic-file-persistence
May 31, 2026
Merged

fix: write persistence files atomically#34
danReynolds merged 6 commits into
mainfrom
claude/atomic-file-persistence

Conversation

@danReynolds

Copy link
Copy Markdown
Owner

Summary

FileDataStoreConfig persisted both data stores and the resolver index with file.writeAsString, which opens with FileMode.write — truncating the target to zero before writing the new contents. That leaves a window where a crash, OOM kill, or power loss corrupts the file:

[complete old JSON] → [empty] → [partial new JSON] → [complete new JSON]

On the next launch jsonDecode throws (and only PathNotFoundException is caught), so the error propagates through Loon.hydrate and that partition's data is lost or startup breaks. This affects the default persistor on mobile/desktop, and the resolver file is equally exposed (it's the path → store-name index).

Fix

Write to a sibling temp file, flush it to disk, then rename over the target:

write users.json.tmp → flush → rename(users.json.tmp → users.json)

A same-filesystem rename is atomic, so the target always holds either the complete previous contents or the complete new contents — never a torn file. The flush before the rename ensures the data is durable so the rename can't expose an empty file after power loss.

Why this is safe cross-platform

Modern Dart (3.x, which the package requires) documents that File.rename removes an existing destination file first, and on Windows it uses MoveFileEx-style replace semantics for regular files (our case — these are plain .json files, not symlinks). So no platform special-casing is needed.

Orphaned temp files are harmless

The worker's data store file listing (fileRegex) matches only …\.json$, so a users.json.tmp orphaned by an interrupted write is not loaded as a store — it's simply overwritten on the next write. A test pins this.

Scope / what this does not cover

This makes each file individually torn-proof. It does not make a multi-file sync (multiple partitions + resolver) transactionally consistent — that would need a manifest/generation pointer and is out of scope. For static persistence keys the resolver rarely changes, so this fix covers the real, common failure (a torn individual file on a process kill). SQLite/IndexedDB backends are already transactional and are unaffected.

Test plan

  • flutter test test/native --concurrency=1 — 86/86 green (the existing 42 round-trip persistor tests confirm the atomic write produces identical content)
  • New: asserts no .tmp files remain after a sync
  • New: asserts the data store listing ignores orphaned .tmp files
  • flutter analyze clean on changed files

Generated by Claude Code

FileDataStoreConfig persisted both data stores and the resolver index
with file.writeAsString, which truncates the target before writing the
new contents. A crash, OOM kill, or power loss mid-write left the file
torn — on the next launch jsonDecode throws (only PathNotFoundException
is caught), so that partition's data is lost or hydration breaks. This
is the default persistor on mobile/desktop.

Write to a sibling temp file, flush it to disk, then rename over the
target. A same-filesystem rename is atomic, so the target always holds
either the complete previous contents or the complete new contents.
Modern Dart's File.rename replaces an existing regular file on both
POSIX and Windows, so no platform special-casing is needed. The .tmp
suffix is not matched by the worker's data store file listing, so a
temp file orphaned by an interrupted write is ignored and overwritten
on the next write rather than loaded as a store.
Copilot AI review requested due to automatic review settings May 26, 2026 15:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@danReynolds danReynolds merged commit c4ca5c3 into main May 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants