Skip to content

fix(cache): publish complete disk cache entries atomically - #965

Open
ningyixi wants to merge 2 commits into
braintrustdata:mainfrom
ningyixi:fix/atomic-disk-cache-writes
Open

ningyixi wants to merge 2 commits into
braintrustdata:mainfrom
ningyixi:fix/atomic-disk-cache-writes

Conversation

@ningyixi

@ningyixi ningyixi commented Oct 19, 2026 •

Copy link
Copy Markdown

DiskCache.set() opens the live cache entry with wb before serialization. A serializer/JSON error therefore destroys the previous usable value, and a concurrent reader can see an incomplete gzip file and get a cache miss while a replacement is still being prepared. Concurrent eviction can also unlink an entry that another writer has not finished writing.

Write each replacement to a unique temporary file in the cache directory, close the complete gzip stream, then publish it with os.replace. Failed writes retain the existing entry and clean up the temporary file. Eviction ignores in-progress staging files. Publication retries Windows access/sharing violations up to five attempts with 150 ms total backoff, then retains the old value on failure. The first write per cache instance, and at most once an hour afterward, removes staging files older than 24 hours, including in unbounded caches. A write taking longer than a day may expire under this best-effort policy. Existing SHA-256 entry names, gzip/JSON contents, maximum-size behavior, and best-effort error handling are preserved.

Validation (pinned Mise Python 3.14.7 / uv 0.12.10 / Ruff 0.15.21):

  • On unchanged upstream dec33a8, the four new regressions fail while the 11 existing disk-cache tests pass. The cases cover JSON encoding failure, a failing custom serializer, a reader during replacement, and eviction during a second pending write.
  • After the fix: 19 disk-cache tests pass, with one native-Windows overlap test skipped on this Linux environment. These use real temporary files, gzip, and a ThreadPoolExecutor with event-coordinated serializers. The original four tests use no mocks. Review follow-up additionally injects OS sharing errors to exercise retry success and bounded failure, checks permanent permission errors, and exits a real subprocess mid-write to test stale staging-file cleanup. A Windows-only test holds a real reader open during replacement; it has not been run on Windows here. The eviction test sets an explicit old mtime to avoid filesystem clock ties.
  • Exact full nox -s test_core -- --vcr-record=none: 1,098 passed, 107 skipped, 12 xfailed. Optional provider packages remain absent as required by the session.
  • Required make fixup: all repository hooks pass. Changed-file Pylint with the repository's --errors-only setting and git diff --check pass.
  • A local 2.6 KB overwrite benchmark (five rounds of 300 writes, same filesystem) measured median writes of about 41 microseconds before / 69 microseconds after in the final run. The extra temporary-file and rename operations are the cost of complete publication; these are local measurements, not a general throughput claim.

No dependencies, credentials, cassettes, network APIs, or generated files changed. This branch is based independently on main and does not depend on #964.

AI assistance disclosure: Codex prepared the code, tests, and description and ran the checks above. No human code review or approval is claimed.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T03:56:24.946722Z e2b0fa8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2b0fa85eb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

value = self._serializer(value)
f.write(json.dumps(value).encode("utf-8"))

os.replace(temporary_path, file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retry replacement while Windows readers hold the entry

On Windows, if another thread or process is still inside get() with the destination file open, os.replace can raise PermissionError because ordinary Python file handles do not share deletion. The exception is swallowed below and the temporary file is removed, so set() silently loses the update and leaves a stale cache entry; the new concurrency test finishes its read before allowing execution to reach this line and therefore does not exercise this overlap. Retry sharing violations or otherwise coordinate publication with readers on this supported platform.

Useful? React with 👍 / 👎.

return None

paths = [os.path.join(self._dir, f) for f in os.listdir(self._dir)]
paths = [os.path.join(self._dir, f) for f in os.listdir(self._dir) if not f.startswith(_TEMP_PREFIX)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reap temporary files left by interrupted writers

If the process is killed or the host crashes after NamedTemporaryFile(delete=False) is created, the finally block cannot unlink it. Every later eviction filters out all paths with this prefix, and constructing a new cache performs no cleanup, so repeated interrupted writes accumulate partial or complete cache payloads outside max_size and can eventually fill a persistent cache volume. Distinguish or reap stale temporary files instead of excluding them forever.

Useful? React with 👍 / 👎.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant