Bypass cache for locking document reads#888
Conversation
A forUpdate read is meant to observe the current row under lock, but it was served from the document cache when an entry existed. updateDocument merges its changes into that read and writes the result back, so a stale cache entry durably reverted attributes that were updated since the entry was cached — the lost-update behind flaky project config E2E tests. A locking read now always reads through to the adapter, and skips the cache save: it runs inside an open transaction, and caching the pre-commit row would poison the cache for other readers. Co-Authored-By: Claude Fable 5 <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 selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughCache load/save in Database::getDocument() is conditioned on non-locking reads so that locking reads (forUpdate) bypass cache and do not populate it; cached rows are cast before use. New tests validate stale-cache bypass, no cache pollution from locking reads, and safe merges during updates. ChangesCache coherency during locking reads
Sequence DiagramsequenceDiagram
participant Reader
participant getDocument
participant Cache
participant Database
Reader->>getDocument: getDocument(id, forUpdate: true)
alt forUpdate true
getDocument->>Database: read fresh row (locking read)
Database-->>getDocument: fresh row
getDocument-->>Reader: return fresh row
Note over Cache: Cache not loaded, not saved
else forUpdate false
getDocument->>Cache: cache->load(id)
alt cache hit
Cache-->>getDocument: cached row
getDocument->>getDocument: casting(cached row)
else cache miss
getDocument->>Database: read fresh row
Database-->>getDocument: fresh row
getDocument->>Cache: cache->save(fresh row) // only if no relationships
end
getDocument-->>Reader: return row
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 |
Greptile SummaryThis PR fixes a durable lost-update bug where
Confidence Score: 5/5Safe to merge — the change is minimal, directly addresses a well-understood lost-update bug, and is covered by three dedicated regression tests that were failing before the fix. Both changed lines in Database.php are straightforward guard additions that skip existing operations under a single, well-understood condition. The three regression tests in ForUpdateCacheTest cover the read-bypass, the write-suppress, and the end-to-end updateDocument scenario that originally caused the CI flake. No existing behaviour changes outside the forUpdate code path. No files require special attention. Important Files Changed
Reviews (2): Last reviewed commit: "fix: re-cast cache hits so cached and fr..." | Re-trigger Greptile |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/unit/ForUpdateCacheTest.php (1)
70-81: 💤 Low valueConsider asserting the forUpdate read result for test completeness.
The test correctly verifies that the forUpdate read does not populate the cache (line 80 asserts the cached value remains 'stale'). For completeness, consider also asserting that the forUpdate read itself returned 'fresh', confirming it bypassed the cache on read as well.
📋 Suggested enhancement
- $this->database->getDocument('projects', 'project', forUpdate: true); + $fresh = $this->database->getDocument('projects', 'project', forUpdate: true); + $this->assertSame('fresh', $fresh->getAttribute('name')); // The locking read happens inside an open transaction; caching what it // saw would publish a pre-commit row. The cached copy must be untouched.🤖 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 `@tests/unit/ForUpdateCacheTest.php` around lines 70 - 81, In testForUpdateReadDoesNotPopulateCache, capture the result of the forUpdate read (the call to $this->database->getDocument('projects', 'project', forUpdate: true)) into a variable and add an assertion that its name attribute equals 'fresh' (e.g. assertSame('fresh', $result->getAttribute('name'))), so the test verifies the for-update read returned the fresh value while subsequent non-forUpdate reads still return the stale cached value; keep the existing staleCache('name','fresh') and final assertSame('stale', $cached->getAttribute('name')) intact.
🤖 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.
Nitpick comments:
In `@tests/unit/ForUpdateCacheTest.php`:
- Around line 70-81: In testForUpdateReadDoesNotPopulateCache, capture the
result of the forUpdate read (the call to
$this->database->getDocument('projects', 'project', forUpdate: true)) into a
variable and add an assertion that its name attribute equals 'fresh' (e.g.
assertSame('fresh', $result->getAttribute('name'))), so the test verifies the
for-update read returned the fresh value while subsequent non-forUpdate reads
still return the stale cached value; keep the existing
staleCache('name','fresh') and final assertSame('stale',
$cached->getAttribute('name')) intact.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a4068665-a760-46ad-85cb-c04f268fd4c6
📒 Files selected for processing (2)
src/Database/Database.phptests/unit/ForUpdateCacheTest.php
The Redis cache envelope serializes through JSON, which collapses floats with zero fractions to ints. With forUpdate reads now coming from the adapter, updateDocument compared a caller document built from a cached read (int) against a freshly-typed old document (float): strict inequality flipped the no-op detection into requiring update permission on read-only documents (testEmptyTenant). Re-running casting on the cache-hit path restores type fidelity for both sources. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What does this PR do?
getDocument(..., forUpdate: true)served the document from the cache when an entry existed. A locking read is meant to observe the current row under lock —updateDocumentmerges its changes into that read and writes the merged result back, so a stale cache entry durably reverted attributes updated since the entry was cached:serviceUsers = false, purges the cache.forUpdateread is served from the poisoned cache, the merge resurrectsserviceUsers = true, and the adapter writes it back — the staleness is now durable in the database, surviving every later purge.This is the mechanism behind the recurring
ProjectsConsoleClientTest::testGetProjectCI flake in Appwrite (projectservices/auths/smtpmap fields reverting after a 200 PATCH; failing ~50–70% of runs in appwrite-labs/cloud since the race window widened).The fix: a
forUpdateread always reads through to the adapter, and skips the cache save — it runs inside an open transaction, so caching what it saw would publish a pre-commit row to other readers (the same poisoning #887 closed for the write path's own purge).Replaces the core of #885 without the unrelated scope that stalled it.
Test plan
tests/unit/ForUpdateCacheTest.php— three regression tests, all failing before the fix:forUpdateread bypasses a stale cache entry,forUpdateread does not (re)populate the cache,updateDocumentdoes not resurrect stale cached attributes into the database (the durable lost-update).Cache|ForUpdateandtestUpdateDocument|testCreateDocumentslices green locally (incl.testCacheFallback).🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests