Skip to content

Reject stale cached snapshot after write#902

Open
fogelito wants to merge 2 commits into
mainfrom
fix/reject-stale-cached-snapshot-after-write
Open

Reject stale cached snapshot after write#902
fogelito wants to merge 2 commits into
mainfrom
fix/reject-stale-cached-snapshot-after-write

Conversation

@fogelito

@fogelito fogelito commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a cache coherency issue that could cause stale data to be served after document updates, resolving intermittent test failures.
  • Tests

    • Added regression test to verify proper handling of stale cached snapshots.

fogelito and others added 2 commits June 21, 2026 17:23
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Fixes a cache coherency race in getDocument/updateDocument/deleteDocument by introducing a per-document version marker key (:__ver) written after each committed write. On reads, cached snapshots whose $updatedAt stamp predates the marker are discarded and reloaded from the adapter. A deterministic regression test is added.

Changes

Cache version marker for stale snapshot rejection

Layer / File(s) Summary
Version marker constant and stamp helpers
src/Database/Database.php
Adds CACHE_VERSION_SUFFIX = '__ver' constant and two new private helpers: recordCachedDocumentVersion (writes a stamp to the sibling cache key, tolerating failures) and cacheVersionStamp (normalizes DateTime, string, and UTCDateTime values into a microsecond UNIX timestamp string).
Write-side marker recording
src/Database/Database.php
After the post-commit cache purge in updateDocument, calls recordCachedDocumentVersion with the committed document's $updatedAt; deleteDocument similarly records DateTime::now() into the marker after its purge.
Read-side staleness check in getDocument
src/Database/Database.php
In the cache-read path, loads the sibling marker and compares it to the cached document's $updatedAt stamp; discards the cached snapshot and reloads from the adapter when the cached stamp is older than the marker.
Regression test
tests/unit/ForUpdateCacheTest.php
setUp stores the Cache instance in $this->cache for direct manipulation; new testReadRejectsStaleCacheSnapshotReCachedAfterUpdate primes cache, updates the document, re-inserts the old snapshot to simulate a concurrent reader, and asserts getDocument returns the fresh committed value.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • utopia-php/database#887: Addresses the same reader-vs-purge cache poisoning race in getDocument/updateDocument/deleteDocument via a different mechanism (tombstone vs. version marker).
  • utopia-php/database#888: Modifies the same Database::getDocument()/updateDocument() cache path and ForUpdateCacheTest to prevent stale cache resurrection, using cache bypass on forUpdate reads instead of a version marker.
  • utopia-php/database#802: Modifies updateDocument() cache invalidation in Database.php at the same post-commit purge site that this PR extends with the version marker write.

Suggested reviewers

  • abnegate

🐇 A cache once lied — a ghost from before,
The writer had changed what the reader ignored.
Now a marker is stamped with the time of the write,
Old snapshots are cast from the cache into night.
No stale doc survives when the version says "nope!"
Hop on, little reader — fresh data, fresh hope. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Reject stale cached snapshot after write' directly describes the main change: a fix preventing stale cached document snapshots from being served after write operations by introducing a cache version marker validation mechanism.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/reject-stale-cached-snapshot-after-write

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

  • Adds a cache version marker key for document cache entries.
  • Updates getDocument() to reject cached snapshots older than the recorded marker.
  • Records markers after single-document update and delete operations.
  • Adds a regression test covering stale snapshot rejection after updateDocument().

Confidence Score: 4/5

Merge safety is limited by several write paths that still purge cached documents without recording the marker needed to reject stale snapshots.

The changed cache guard is well scoped, and focused runtime checks reproduced the marker gap for counter writes, bulk deletes, and upserts; other analogous paths remain code-supported but were not executable in this environment.

src/Database/Database.php needs attention around bulk update/delete, counter, upsert, delete marker timestamp, and ID rename cache-marker handling.

T-Rex T-Rex Logs

What T-Rex did

  • Created a focused PHPUnit repro that uses the real Database, Memory adapter, and Memory cache to reproduce stale cache after updateDocuments.
  • Reproduced a counter cache marker gap by priming an old cached document, updating the counter, and observing getDocument returning the stale value.
  • Reproduced the bulk delete marker race by deleting a document, re-saving the pre-delete snapshot into the cache, and seeing getDocument return the pre-delete snapshot.
  • Attempted to run a focused PHPUnit test for stale cached document after delete with a future updatedAt, but the run was blocked by missing runtime tools (phpunit, composer, php, docker).
  • Attempted environment setup for renamed IDs staying unguarded, but container lacks composer, PHP runtime, and docker, blocking reproduction.
  • Reproduced upsert marker gap by updating an existing row via upsertDocumentsWithIncrease and observing the cache marker was NULL while the adapter had a newer value, then reinserting the pre-upsert snapshot showed the stale cached value.
  • Observed a marker transition where the marker changed from false to present, with cache serving the updated value.
  • Observed cache-guard logs showing marker_after_delete transitions from false to populated and final_outcome shifting from STALE_EXISTING to deleted_empty.
  • Documented runtime blockers in the environment: no php, no composer, no docker, and missing vendor, blocking reproduction.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (4)

  1. src/Database/Database.php, line 6648 (link)

    P1 Bulk updates miss markers

    updateDocuments() writes each document and then only purges its cached body. It does not record the committed $updatedAt marker that getDocument() now uses to reject re-cached stale snapshots. When a reader caches the pre-update row after this purge, later reads have no marker to compare against and can serve the stale bulk-updated document until TTL.

  2. src/Database/Database.php, line 7637-7638 (link)

    P1 Counter writes miss markers

    increaseDocumentAttribute() commits a new value and $updatedAt, but after the transaction it only purges the cached document. If a reader re-saves the old snapshot after this purge, there is no version marker for getDocument() to reject it, so callers can observe the pre-increment value until the cache expires. The same purge-only marker gap exists in decreaseDocumentAttribute().

    Artifacts

    Repro: focused counter cache marker gap script

    • Contains supporting evidence from the run (text/x-php; charset=utf-8).

    Repro: runtime output showing stale cached counter returned after increaseDocumentAttribute

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

  3. src/Database/Database.php, line 8340-8343 (link)

    P1 Bulk deletes miss markers

    deleteDocuments() deletes rows in bulk and then only purges each cached document. Unlike deleteDocument(), it does not write a deletion marker, so a reader can re-cache a pre-delete snapshot after this purge and later getDocument() calls can still return a deleted document until TTL.

    Artifacts

    Repro: focused PHP harness that simulates the stale cache race after deleteDocuments()

    • Contains supporting evidence from the run (text/x-php; charset=utf-8).

    Repro: runtime output showing missing marker and deleted document returned from cache

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

  4. src/Database/Database.php, line 7518 (link)

    P1 Upserts miss marker

    Updated documents from upsertDocumentsWithIncrease() are purged but never get a version marker. For existing rows, a concurrent reader can re-cache the old snapshot after this purge, and subsequent getDocument() calls will accept it because the new stale-cache guard was only wired into single-document updates and deletes.

    Artifacts

    Repro: focused PHP script exercising upsertDocumentsWithIncrease stale cache race

    • Contains supporting evidence from the run (text/x-php; charset=utf-8).

    Repro: runtime output showing missing marker and stale getDocument result

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "push" | Re-trigger Greptile

Comment thread src/Database/Database.php
$this->purgeCachedDocumentInternal($collection->getId(), $id);
// Advance the version marker past any snapshot so a stale "exists" copy
// re-cached by a racing reader is rejected on subsequent reads.
$this->recordCachedDocumentVersion($collection->getId(), $id, DateTime::now());

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.

P2 Deletion marker can lag

The delete path records DateTime::now() as the marker. For documents whose $updatedAt was preserved as a future timestamp, a stale pre-delete snapshot can have an $updatedAt greater than this marker. Because the read guard only rejects cached versions that are strictly older than the marker, that stale existing document can survive a delete and be served from cache.

Comment thread src/Database/Database.php
Comment on lines 6414 to +6417
$this->purgeCachedDocumentInternal($collection->getId(), $id);
// Record the committed version so a stale snapshot re-cached by a racing
// reader is rejected on subsequent reads (see getDocument).
$this->recordCachedDocumentVersion($collection->getId(), $id, $document->getUpdatedAt());

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.

P2 Renamed IDs stay unguarded

When updateDocument() changes a document ID, the transaction purges both the old and new keys, but the new post-commit purge and marker are only written for the old $id. The post-commit marker is the part that protects against a reader re-caching around the commit window, so a stale entry under the new ID can still be accepted by getDocument().

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Database/Database.php`:
- Around line 6415-6417: The recordCachedDocumentVersion() call that prevents
stale cached snapshot resurrection is only applied in updateDocument() and
deleteDocument(), but the same race condition exists in updateDocuments(),
upsertDocuments(), increaseDocumentAttribute(), decreaseDocumentAttribute(), and
deleteDocuments(). Add a recordCachedDocumentVersion() call after each
successful document purge in all these methods to record the version marker
consistently across all document write paths, ensuring stale cached snapshots
are rejected regardless of which write method is used.
- Around line 4873-4876: The cache rejection logic in the comparison at
cacheVersionStamp($cached['$updatedAt']) only checks if cachedVersion is
strictly less than marker, but this fails when update writes preserve $updatedAt
(creating equality) or when deletes use DateTime::now() which can be older than
preserved values, allowing stale snapshots to remain cached. Replace the loose
comparison with a strictly monotonic cache version token that guarantees the
marker is always strictly newer than any pre-write snapshot, ensuring that the
condition properly rejects all stale cached data. This comparison issue exists
in multiple locations (also at lines 6415-6417 and 7807-7809) and should be
consistently addressed across all occurrences.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 67999732-e315-445e-8fa0-5c31f2ac084d

📥 Commits

Reviewing files that changed from the base of the PR and between fff9f0e and cd7d63e.

📒 Files selected for processing (3)
  • PR_BODY.md
  • src/Database/Database.php
  • tests/unit/ForUpdateCacheTest.php

Comment thread src/Database/Database.php
Comment on lines +4873 to +4876
if (\is_string($marker) && $marker !== '') {
$cachedVersion = $this->cacheVersionStamp($cached['$updatedAt']);
if ($cachedVersion !== null && (float) $cachedVersion < (float) $marker) {
$cached = null;

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the cache marker strictly dominate stale snapshots.

The guard only rejects cachedVersion < marker, but update writes can preserve/tie $updatedAt, and deletes use DateTime::now() which can be older than a preserved/future $updatedAt. In those cases, a stale re-cached snapshot is not rejected. Use a truly monotonic cache-only version token, or otherwise guarantee the marker is strictly newer than any pre-write snapshot before relying on this comparison.

Also applies to: 6415-6417, 7807-7809

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Database.php` around lines 4873 - 4876, The cache rejection
logic in the comparison at cacheVersionStamp($cached['$updatedAt']) only checks
if cachedVersion is strictly less than marker, but this fails when update writes
preserve $updatedAt (creating equality) or when deletes use DateTime::now()
which can be older than preserved values, allowing stale snapshots to remain
cached. Replace the loose comparison with a strictly monotonic cache version
token that guarantees the marker is always strictly newer than any pre-write
snapshot, ensuring that the condition properly rejects all stale cached data.
This comparison issue exists in multiple locations (also at lines 6415-6417 and
7807-7809) and should be consistently addressed across all occurrences.

Comment thread src/Database/Database.php
Comment on lines +6415 to +6417
// Record the committed version so a stale snapshot re-cached by a racing
// reader is rejected on subsequent reads (see getDocument).
$this->recordCachedDocumentVersion($collection->getId(), $id, $document->getUpdatedAt());

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Apply the version marker to every document write path.

This only covers updateDocument() and deleteDocument(). The same purge-only race remains in updateDocuments(), upsertDocuments(), increaseDocumentAttribute(), decreaseDocumentAttribute(), and deleteDocuments(), so those writes can still resurrect stale cached snapshots after their purge. Record the marker next to each successful post-commit document purge. This follows the PR objective to reject stale cached snapshots after writes.

Also applies to: 7807-7809

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Database.php` around lines 6415 - 6417, The
recordCachedDocumentVersion() call that prevents stale cached snapshot
resurrection is only applied in updateDocument() and deleteDocument(), but the
same race condition exists in updateDocuments(), upsertDocuments(),
increaseDocumentAttribute(), decreaseDocumentAttribute(), and deleteDocuments().
Add a recordCachedDocumentVersion() call after each successful document purge in
all these methods to record the version marker consistently across all document
write paths, ensuring stale cached snapshots are rejected regardless of which
write method is used.

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