feat(database): use cache lease in getDocument to fix read-after-write staleness#904
Conversation
…write staleness getDocument() re-populates the cache after a miss. Under concurrency a reader that fetched a row just before a concurrent updateDocument purge could write that now-stale row back into the cache *after* the purge ran, so later reads served stale data until the next write. Capture the cache generation before the adapter read and persist the result via saveWithLease(): the write only lands if no purge advanced the generation in the meantime, otherwise it is rejected and the next read re-fetches. forUpdate reads still bypass the cache entirely. Requires utopia-php/cache's Leasable capability (getGeneration/saveWithLease, utopia-php/cache#75); non-leasable cache adapters fall back to an unconditional save, so behaviour is unchanged for them. NOTE: composer is temporarily pinned to the cache feature branch so CI can resolve the new methods; revert to the released ^3.1 once cache#75 ships. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthrough
ChangesGeneration-aware cache writes in getDocument()
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/Database/Database.php (1)
4940-4941: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winOnly update the collection cache index after a successful lease save.
saveWithLease()returnsfalsewhen a concurrent purge invalidates the generation, but Line 4941 still writes the collection reference. Gate that follow-up write on!== falseso a rejected lease does not repopulate cache bookkeeping after the purge.Proposed fix
- $this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation); - $this->cache->save($collectionKey, 'empty', $documentKey); + $saved = $this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation); + if ($saved !== false) { + $this->cache->save($collectionKey, 'empty', $documentKey); + }🤖 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 4940 - 4941, The cache update for the collection index on line 4941 executes unconditionally, but it should only execute when the preceding saveWithLease() call on line 4940 succeeds. Capture the return value of saveWithLease() into a variable and wrap the subsequent cache->save() call in a condition that only executes when saveWithLease() does not return false, ensuring that rejected leases due to concurrent purges do not repopulate the collection cache bookkeeping.
🤖 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 `@composer.json`:
- Line 49: The "utopia-php/cache" dependency on line 49 is pinned to a moving
development branch (dev-feat/leasable-cache as 3.1.0) which creates
non-deterministic builds since the branch can move over time. Replace the branch
alias with a specific commit SHA to lock the exact version being used. Once the
stable 3.1 release is published on Packagist, update the dependency to use the
version constraint "^3.1" instead of the commit hash.
In `@src/Database/Database.php`:
- Around line 4882-4886: Wrap the $generation assignment that calls
getGeneration() on line 4886 in a try/catch block to match the error handling
pattern used in load() and saveWithLease() methods. Initialize $generation to
null in the catch block so that when getGeneration() throws during a cache
outage, the code gracefully falls back to database operations. Then, before
passing $generation to saveWithLease(), add a check to ensure $generation !==
null so that the cache save operation is only executed when generation was
successfully captured.
---
Nitpick comments:
In `@src/Database/Database.php`:
- Around line 4940-4941: The cache update for the collection index on line 4941
executes unconditionally, but it should only execute when the preceding
saveWithLease() call on line 4940 succeeds. Capture the return value of
saveWithLease() into a variable and wrap the subsequent cache->save() call in a
condition that only executes when saveWithLease() does not return false,
ensuring that rejected leases due to concurrent purges do not repopulate the
collection cache bookkeeping.
🪄 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: 4201ccf9-ef4a-4312-9faa-88734fc4abc2
⛔ Files ignored due to path filters (1)
composer.lockis excluded by!**/*.lock
📒 Files selected for processing (2)
composer.jsonsrc/Database/Database.php
Greptile SummaryThis PR fixes a read-after-write cache-staleness race in
Confidence Score: 5/5Safe to merge — the CAS-based cache write is a targeted, well-guarded addition with an explicit safe fallback, and all three issues from the prior review round have been addressed. The logic change is small and correctly scoped: generation capture is guarded by try/catch with a documented no-lease fallback, the collection-key invalidation write is gated on the lease save succeeding, and composer.json points to the tagged 3.1.0 release. No new defects were found. No files require special attention. Important Files Changed
Reviews (3): Last reviewed commit: "chore(deps): use released cache 3.1.0 an..." | Re-trigger Greptile |
… save on lease success Addresses review + the failing cache-fallback/transaction adapter tests on #904: - getGeneration() is now wrapped in try/catch in getDocument() and degrades to '0' (no lease) on a cache error, mirroring the load() handling above it. The cache-skip-on-failure adapter tests bring Redis down and expect getDocument() to fall back to the DB; the unguarded getGeneration() was letting a RedisException escape and break them. - Only register the document in the collection invalidation index when saveWithLease() actually cached it (truthy return). A lease rejection (concurrent purge) caches nothing, so skipping the registry write avoids a phantom entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lease primitive shipped in utopia-php/cache 3.1.0, so drop the temporary VCS branch pin (and its repositories entry) and require the released 3.1.* line. Compact the getDocument lease comments to the load-bearing why. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem
getDocument()re-populates the cache after a miss. Under concurrency, a reader that fetched a row from the database just before a concurrentupdateDocument()purge can write that now-stale row back into the cache after the purge runs — so subsequent reads serve stale data until the next write. The post-commit purge can't prevent it: the stale reader'ssave()lands after the purge.Observed downstream as an intermittent read-after-write failure (a just-disabled project service still appearing enabled ~1.7% of the time under concurrent load).
Fix
Capture the cache generation before the adapter read, and persist via
saveWithLease():If a purge advanced the generation while we were reading, the write is rejected (CAS) and the next read re-fetches — instead of caching stale data.
forUpdatereads still bypass the cache entirely.Non-leasable cache adapters fall back to an unconditional
save()via theCachefacade, so behaviour is unchanged for them.Dependency / ordering
Requires the
Leasablecapability added in utopia-php/cache#75 (getGeneration()/saveWithLease()).composer.jsonis temporarily pinned to the cache feature branch (dev-feat/leasable-cache) so CI can resolve the new methods. Revert to the released^3.1once cache#75 is merged and tagged, then this can merge.Verification
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Chores