Skip to content

FileStore: write cache entries atomically - #2040

Open
frankleng wants to merge 1 commit into
react:mainfrom
frankleng:filestore-atomic-write
Open

frankleng wants to merge 1 commit into
react:mainfrom
frankleng:filestore-atomic-write

Conversation

@frankleng

Copy link
Copy Markdown

Summary

Refs #1961.

FileStore.set rewrites each cache entry in place with fs.promises.writeFile, which truncates the file on open and then writes it in 512 KiB chunks. When two writers of the same key overlap in one process, one can truncate the file while the other is between chunks, leaving a file that starts with zero bytes. A concurrent get then sees a leading 0x00, returns the rest as a binary Buffer entry, and Transformer fails with TypeError: dependencies is not iterable. A reader can also see truncated JSON, which is a silent miss.

This shows up in practice with expo export in a React Native app using Expo DOM components: it bundles every DOM component in parallel in one process, and they transform shared modules under the same keys. With metro-cache 0.84.5 the export failed in 8 of 16 runs, and the cached value was a Buffer of 524,287 zero bytes. @expo/metro-config extends this FileStore.

This change writes each entry to a unique temporary file in the same directory (<entry>.<pid>.<random>.tmp) and renames it into place, so a reader sees either the previous entry or the new one. If the write or rename fails, the temporary file is removed and the error is rethrown as before (the existing ENOENT retry that creates the directory still applies). The on-disk format is unchanged, so existing caches stay readable.

It does not address entries that are already corrupt on disk (for example after power loss on NTFS, as described in #1961); that still needs get to treat an invalid entry as a miss.

Changelog: [Fix] FileStore writes cache entries atomically, so concurrent readers never see a partially written entry

Test plan

New tests in packages/metro-cache/src/stores/__tests__/FileStore-test.js:

  • never exposes a partially written entry to a reader: pauses a write after the file is truncated and its tail written, then reads the key. Without this change the read returns a Buffer of zeros; with it, the read returns the previous entry.
  • removes the temporary file when a write fails: without this change the failed write corrupts the existing entry; with it, the entry and directory are unchanged.
  • leaves no temporary files behind after concurrent writes of one key.
yarn jest packages/metro-cache    # 7 suites, 50 tests passed
yarn test                         # flow, eslint, prettier, build, jest: 155 suites passed (2850 passed, 26 skipped)

Also a standalone stress test on real disk (ext4, Node 24): 8 concurrent set+get loops on one key with a 2 MB value, 16 runs. Before: truncated JSON in 16/16 runs and a zero-prefixed Buffer hit in 1/16. After: no bad reads and no leftover temporary files.

FileStore.set rewrote each entry in place with fs.promises.writeFile,
which truncates the file and then writes it in chunks. A concurrent
reader of the same key could see a file whose head was still zeros,
which get() returns as a binary (Buffer) entry, and the build then
fails with "dependencies is not iterable".

Write to a unique temporary file in the same directory and rename it
into place, removing the temporary file if the write fails. The
on-disk format is unchanged.
Copilot AI balanced review requested due to automatic review settings October 8, 2026 01:45
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 8, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

馃煛 Changes recommended

The atomic write and tests are correct, but the cleanup rm in the catch block can mask the original write/rename error if it rejects, which should be addressed before approval.

1 open finding
What changed in this PR

This PR makes FileStore.set atomic to fix a concurrency bug where overlapping writes of the same cache key (common with expo export bundling DOM components in parallel) could leave a truncated/zero-prefixed file. A concurrent reader would then interpret the leading 0x00 as a binary Buffer entry (causing TypeError: dependencies is not iterable) or read truncated JSON. Instead of calling writeFile in place (which truncates first), the code now writes to a unique temp file (<entry>.<pid>.<random>.tmp) and renames it into place, so readers always see either the previous or the new complete entry. The on-disk format is unchanged, and the existing ENOENT directory-creation retry still applies.

Changes:

  • Write cache entries to a unique temp file and atomically rename into place; remove the temp file and rethrow on failure.
  • Import node:crypto to generate the temp-file suffix.
  • Add three tests covering atomic visibility to readers, no leftover temp files after concurrent writes, and temp-file cleanup on write failure.
File Description
packages/鈥媘etro-cache/鈥媠rc/鈥媠tores/鈥婩ileStore.js Replaces in-place writeFile with temp-file write + rename, with cleanup on error
packages/鈥媘etro-cache/鈥媠rc/鈥媠tores/鈥媉_tests__/鈥婩ileStore-test.js Adds regression tests for atomic reads, temp-file cleanup, and failed-write cleanup

馃 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

await fs.promises.writeFile(tempPath, content);
await fs.promises.rename(tempPath, filePath);
} catch (err) {
await fs.promises.rm(tempPath, {force: true});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot Fix the code for all comments in this review comment.

When a review comment includes a suggested change, apply the suggestion exactly.

Do not make changes beyond what is described in the linked review comment.

@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 8, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants