Skip to content

fix(storage/local): write files atomically - #27

Open
marino39 wants to merge 1 commit into
masterfrom
mg/local-fs-atomic-create
Open

marino39 wants to merge 1 commit into
masterfrom
mg/local-fs-atomic-create

Conversation

@marino39

@marino39 marino39 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Problem

LocalFS.Create (go-storage's local FS) truncates the target and writes it in place. For files rewritten in place, most importantly the .fileIndex:

  • Readers race the writer. A reader loading the index while the writer saves it can read a partial file and fail with failed to load file index: unexpected EOF.
  • A crash mid-save corrupts the index for good. We hit this in a chainsight test run: a reader's partial read failed the process, the ethlog writer exited mid-save, and its 380 KB index was left with only the first 40,330 of 76,265 entries. It had to be rebuilt by hand from the data files.

Change

storage/local.LocalFS now implements Create itself:

  • Temp file: the data goes to a hidden temporary file in the target's directory, .<name>.tmp-*.
  • Atomic publish: Close syncs that file and renames it over the target. Readers see the previous file or the complete new one, never part of one. A crash leaves the previous file intact.
  • Failed writes discarded: if any Write failed, Close removes the temporary file, keeps the previous one and returns the error.
  • Unchanged behaviour: files are created 0644, as os.Create gives them under the usual umask, and WriterOptions mod/creation times are still applied.

Only the local FS changes. GCS uploads are already atomic.

Tests

  • TestFileIndex_ConcurrentSaveAndLoad: saves a 20k-entry index 30 times while another goroutine loads it. On master it fails with unexpected EOF within milliseconds; with this change it passes.
  • storage/local unit tests:
    • nothing is visible at the path before Close;
    • the previous file stays readable until Close;
    • a failed write never replaces the file;
    • no temporary files are left behind;
    • file mode is 0644.

go test ./... passes.

🤖 Generated with Claude Code

Create truncated the target and wrote it in place, so a reader that loaded
the .fileIndex while the writer saved it could get a partial file
(unexpected EOF), and a process exiting mid-save left the index truncated
for good. Seen in production: a 380 KB ethlog index cut to its first
40k of 76k entries.

The local FS now writes to a hidden temporary file in the same directory
and Close syncs it and renames it over the target, so readers see the old
or the new file, never part of one. A failed write is discarded and the
previous file kept. Other file systems are unchanged; GCS uploads are
already atomic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant