Skip to content

Publish cache files with an atomic rename - #83

Open
navidemad wants to merge 1 commit into
Gusto:mainfrom
navidemad:atomic-cache-writes
Open

Publish cache files with an atomic rename#83
navidemad wants to merge 1 commit into
Gusto:mainfrom
navidemad:atomic-cache-writes

Conversation

@navidemad

Copy link
Copy Markdown

Problem

FileCache#write was a plain File.write, which truncates the destination and fills it back in. A process reading that file inside the window gets a prefix of the JSON and a JSON::ParserError, which reads as a corrupt cache when nothing is actually corrupt.

Nothing in a single-process run reads a file while it is being written. But the cache directory is shared as soon as the cache outlives the process that wrote it, which is exactly the setup #80 is about: a warm-up process that fills the cache before the suite starts, a second suite started against a preserved cache, or workers pointed at one cache_path.

Fix

Write a sibling temp file and rename it over the destination. The rename publishes the new content in one step, so a concurrent reader sees either the whole previous file or the whole new one. The temp file is a sibling so the rename stays on one filesystem, and carries the pid so two processes writing the same fixture cannot collide on it.

Test

The spec forks a reader that parses the file in a loop while the parent rewrites it a hundred times, and reports a torn read through its exit status. It fails on the previous implementation (checked, three runs out of three) and passes on this one (five runs out of five). It skips where fork is unavailable and finishes in about a tenth of a second.

A second spec asserts the directory holds nothing but the cache file, so a failed write cannot leave a .tmp sibling behind.

Relation to #62

#62 turns a torn read into a CacheCorruptError with a path and a hint. This removes one of the causes. They are independent and stack in either order.

Full suite green: 211 examples, 0 failures.

`FileCache#write` truncated the destination and filled it back in, so any
process reading that file during the write got a prefix of the JSON and a
`JSON::ParserError` -- a corrupt-cache failure with no corrupt cache behind it.

Nothing in a single-process run reads a file while it is written, but a cache
directory is shared as soon as the cache outlives the process that wrote it:
a warm-up process that fills the cache before the suite starts, a second suite
started against a preserved cache, or workers pointed at one `cache_path` (see
issue Gusto#80). Writing a sibling file and renaming it over the destination
publishes the new content in one step: a reader sees either the whole previous
file or the whole new one.

The spec forks a reader that parses the file in a loop while the parent
rewrites it a hundred times; it reports a torn read through its exit status.
It fails on the previous implementation and passes on this one.
@ngan

ngan commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Want to just use https://api.rubyonrails.org/classes/File.html#method-c-atomic_write since we depend on active support?

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.

2 participants