Skip to content

feat(database): use cache lease in getDocument to fix read-after-write staleness#904

Open
abnegate wants to merge 1 commit into
mainfrom
feat/leasable-getdocument
Open

feat(database): use cache lease in getDocument to fix read-after-write staleness#904
abnegate wants to merge 1 commit into
mainfrom
feat/leasable-getdocument

Conversation

@abnegate

@abnegate abnegate commented Jun 22, 2026

Copy link
Copy Markdown
Member

Problem

getDocument() re-populates the cache after a miss. Under concurrency, a reader that fetched a row from the database just before a concurrent updateDocument() 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's save() 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():

$generation = $forUpdate ? '0' : $this->cache->getGeneration($documentKey);
$document = $this->adapter->getDocument(...);
...
$this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation);

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. forUpdate reads still bypass the cache entirely.

Non-leasable cache adapters fall back to an unconditional save() via the Cache facade, so behaviour is unchanged for them.

Dependency / ordering

Requires the Leasable capability added in utopia-php/cache#75 (getGeneration() / saveWithLease()).

⚠️ composer.json is temporarily pinned to the cache feature branch (dev-feat/leasable-cache) so CI can resolve the new methods. Revert to the released ^3.1 once cache#75 is merged and tagged, then this can merge.

Verification

  • Pint: clean. PHPStan (level 7): no errors.
  • End-to-end against appwrite: a service-disable read-after-write that flaked 20/1200 (~1.7%) under 64 concurrent readers drops to 0/1200 with this change + cache#75. The CAS/rejection behaviour itself is unit-tested in cache#75.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved database caching to prevent stale data from being stored during concurrent document updates.
  • Chores

    • Updated cache library dependency to version 3.1.0.

…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>
Copilot AI review requested due to automatic review settings June 22, 2026 16:08

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

composer.json adds a VCS repository entry for utopia-php/cache and pins its requirement to the dev-feat/leasable-cache branch aliased as 3.1.0. Database::getDocument() is updated to capture the cache generation before the DB read and call cache->saveWithLease(...) with that generation instead of cache->save(...) when persisting an unlocked, non-relationship document.

Changes

Generation-aware cache writes in getDocument()

Layer / File(s) Summary
VCS dependency on leasable-cache branch
composer.json
Adds a repositories entry pointing to the utopia-php/cache GitHub VCS source and updates the require constraint from ^3.0 to dev-feat/leasable-cache as 3.1.0.
Generation capture and saveWithLease in getDocument()
src/Database/Database.php
Before the DB row is fetched, captures the current cache generation for the document key (using a constant for forUpdate reads). On the cache write path, replaces cache->save(...) with cache->saveWithLease(..., generation) to prevent stale-row poisoning under concurrent updateDocument() calls.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • utopia-php/database#887: Modifies the same getDocument() cache-population path to prevent race-driven stale-row cache poisoning, using purge tombstones as the mechanism instead of generation leases.
  • utopia-php/database#888: Modifies getDocument()'s cache logic overlapping the same forUpdate bypass and conditional cache-population pathway that this PR also touches.

Poem

🐇 Hop! I caught the stale row mid-air,
A generation key beyond compare,
saveWithLease seals the cache with care,
No poisoned data lurking there —
The rabbit guards what's fresh and fair! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and accurately describes the main change: using cache lease in getDocument to fix read-after-write staleness caused by concurrent workloads.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 feat/leasable-getdocument

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.

@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

🧹 Nitpick comments (1)
src/Database/Database.php (1)

4940-4941: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Only update the collection cache index after a successful lease save.

saveWithLease() returns false when a concurrent purge invalidates the generation, but Line 4941 still writes the collection reference. Gate that follow-up write on !== false so 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

📥 Commits

Reviewing files that changed from the base of the PR and between 83f78ec and 911ea94.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • composer.json
  • src/Database/Database.php

Comment thread composer.json
"utopia-php/validators": "0.2.*",
"utopia-php/console": "0.1.*",
"utopia-php/cache": "^3.0",
"utopia-php/cache": "dev-feat/leasable-cache as 3.1.0",

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Current constraint in composer.json:"
jq -r '.require["utopia-php/cache"]' composer.json

echo
echo "Check if stable 3.1 versions are available on Packagist:"
curl -s https://repo.packagist.org/p2/utopia-php/cache.json \
  | jq -r '.packages["utopia-php/cache"][].version' \
  | grep -E '^3\.1(\.|$)' || true

echo
echo "If no stable 3.1 appears above, keep temporary branch but pin an explicit commit SHA."

Repository: utopia-php/database

Length of output: 278


Pin this dependency to a commit hash until 3.1 releases.

Line 49 references a moving branch (dev-feat/leasable-cache as 3.1.0). Since no stable 3.1 release exists on Packagist yet, downstream consumers of this library could resolve different commits over time, introducing non-deterministic builds. Add an explicit commit SHA to stabilize the dependency:

-        "utopia-php/cache": "dev-feat/leasable-cache as 3.1.0",
+        "utopia-php/cache": "dev-feat/leasable-cache#<commit-sha> as 3.1.0",

Switch to ^3.1 once the stable version is released.

🤖 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 `@composer.json` at 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.

Comment thread src/Database/Database.php
Comment on lines +4882 to +4886
// Capture the cache generation BEFORE reading the row. If a concurrent
// updateDocument purges (and so advances the generation) while we read,
// saveWithLease() below rejects this now-stale value instead of
// re-poisoning the cache. See Cache\Feature\Leasable.
$generation = $forUpdate ? '0' : $this->cache->getGeneration($documentKey);

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

🧩 Analysis chain

🏁 Script executed:

# First, let's check the file structure and locate the specific lines
wc -l src/Database/Database.php

Repository: utopia-php/database

Length of output: 95


🏁 Script executed:

# Read the lines mentioned in the review: 4882-4887 for the generation capture
sed -n '4880,4890p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 547


🏁 Script executed:

# Read the load() error handling section: 4848-4852
sed -n '4845,4860p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 802


🏁 Script executed:

# Read the saveWithLease() section: around line 4940
sed -n '4930,4950p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 996


🏁 Script executed:

# Also check the broader context to understand $forUpdate and the full flow
sed -n '4840,4860p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 995


🏁 Script executed:

# Find the Cache class and examine getGeneration() method
fd -t f 'Cache\.php' src/

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Search for getGeneration method definition
rg 'function getGeneration' -A 10 src/

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Also check if there's a Leasable feature that might affect this
fd -t f 'Leasable' src/

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Search for Cache class definition
rg 'class Cache' src/

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for the Leasable feature mentioned in the comment
rg 'Leasable' src/ -A 5 -B 5

Repository: utopia-php/database

Length of output: 833


🏁 Script executed:

# Search for getGeneration more broadly
rg 'getGeneration' src/ -B 3 -A 3

Repository: utopia-php/database

Length of output: 621


🏁 Script executed:

# Get a broader view of the cache save logic and how $generation is used
sed -n '4930,4950p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 996


🏁 Script executed:

# Check if there's a composer.json to see the cache dependency
cat composer.json 2>/dev/null || echo "No composer.json in root"

Repository: utopia-php/database

Length of output: 2491


🏁 Script executed:

# Look at the full function signature around the generation capture
sed -n '4820,4895p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 3187


🏁 Script executed:

# Look at the function name and any other cache initialization
sed -n '4700,4750p' src/Database/Database.php | head -40

Repository: utopia-php/database

Length of output: 1330


🏁 Script executed:

# Search for the function definition to understand the signature
rg 'public function getDocument' -A 15 src/Database/Database.php | head -30

Repository: utopia-php/database

Length of output: 932


🏁 Script executed:

# Verify the composer.json shows cache dependency and version
grep -A 1 "utopia-php/cache" composer.json

Repository: utopia-php/database

Length of output: 232


Wrap getGeneration() in try/catch to match cache operation error handling.

Line 4886 calls getGeneration() without error handling, while load() (lines 4848–4852) and saveWithLease() (lines 4940–4945) are both wrapped in try/catch blocks as best-effort cache operations. If getGeneration() throws during a cache outage, the document read fails before the database fallback occurs, degrading availability.

Gate the cache save on successful generation capture by wrapping the call in try/catch and initializing to null, then checking $generation !== null before passing it to saveWithLease().

Proposed fix
-        $generation = $forUpdate ? '0' : $this->cache->getGeneration($documentKey);
+        $generation = null;
+        if (!$forUpdate) {
+            try {
+                $generation = $this->cache->getGeneration($documentKey);
+            } catch (Exception $e) {
+                Console::warning('Warning: Failed to get document cache generation: ' . $e->getMessage());
+            }
+        }

Then gate the later save:

-        if (!$forUpdate && empty($relationships)) {
+        if (!$forUpdate && empty($relationships) && $generation !== null) {
🤖 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 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.

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR addresses a read-after-write cache staleness bug in getDocument(): a reader that fetches from the DB just before a concurrent updateDocument() purge could re-populate the cache with a stale row after the purge completes. The fix captures the cache generation before the DB read and uses a CAS-style saveWithLease() to reject the write if a concurrent purge advanced the generation in the meantime.

  • Database.php: captures $generation before the adapter read and replaces save() with saveWithLease() so a stale row is discarded rather than cached when a concurrent purge is detected.
  • composer.json / composer.lock: temporarily pins utopia-php/cache to the unreleased dev-feat/leasable-cache branch (acknowledged in the PR as needing reversion to ^3.1 once cache#75 is tagged).

Confidence Score: 3/5

Not safe to merge: the cache pin must be replaced with a stable tag, and an unguarded cache call can turn transient backend failures into broken document reads.

The getGeneration() call on line 4886 is the only cache interaction in getDocument() without a try/catch. A Redis blip that previously produced a logged warning and a transparent DB fallback will now propagate an unhandled exception to the caller. Additionally, composer.json is explicitly pinned to an unreleased feature branch; merging in this state would expose every downstream install to an unstable dependency.

src/Database/Database.php (unguarded getGeneration call) and composer.json (branch pin that must be reverted before merge)

Important Files Changed

Filename Overview
src/Database/Database.php Adds cache-generation capture before DB read and replaces save() with saveWithLease() to prevent stale cache writes; getGeneration() call is not wrapped in a try/catch, creating a new unhandled exception path.
composer.json Pins utopia-php/cache to the unreleased dev-feat/leasable-cache branch (aliased as 3.1.0) as an explicitly temporary measure until cache PR #75 is merged and tagged.
composer.lock Lock file updated to track the feature-branch commit of utopia-php/cache; no other dependency changes.

Reviews (1): Last reviewed commit: "feat(database): use the cache lease in g..." | Re-trigger Greptile

Comment thread src/Database/Database.php
Comment on lines +4882 to +4886
// Capture the cache generation BEFORE reading the row. If a concurrent
// updateDocument purges (and so advances the generation) while we read,
// saveWithLease() below rejects this now-stale value instead of
// re-poisoning the cache. See Cache\Feature\Leasable.
$generation = $forUpdate ? '0' : $this->cache->getGeneration($documentKey);

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.

P1 getGeneration() is called outside any try/catch block. Every other cache interaction in this method (load() on line 4849, saveWithLease()/save() on lines 4940–4941) is guarded. If the cache backend is temporarily unavailable (e.g., Redis connection error) and getGeneration() throws, the exception propagates out of getDocument() entirely — a regression from the pre-PR behaviour where cache failures were logged and reads fell through to the database.

Suggested change
// Capture the cache generation BEFORE reading the row. If a concurrent
// updateDocument purges (and so advances the generation) while we read,
// saveWithLease() below rejects this now-stale value instead of
// re-poisoning the cache. See Cache\Feature\Leasable.
$generation = $forUpdate ? '0' : $this->cache->getGeneration($documentKey);
// Capture the cache generation BEFORE reading the row. If a concurrent
// updateDocument purges (and so advances the generation) while we read,
// saveWithLease() below rejects this now-stale value instead of
// re-poisoning the cache. See Cache\Feature\Leasable.
$generation = '0';
if (!$forUpdate) {
try {
$generation = $this->cache->getGeneration($documentKey);
} catch (Exception $e) {
Console::warning('Warning: Failed to get cache generation: ' . $e->getMessage());
}
}

Comment thread src/Database/Database.php
Comment on lines +4940 to 4941
$this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation);
$this->cache->save($collectionKey, 'empty', $documentKey);

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 When saveWithLease() is rejected (CAS mismatch — the stale-data case this PR targets), it returns false rather than throwing, so the subsequent save($collectionKey, …) still executes and registers the document key in the collection's invalidation index even though the document itself was never written to the cache. On the next collection-level purge this creates a phantom entry that is purged harmlessly, but it makes the tracking index transiently inconsistent. Gating the collection-key save on a truthy return from saveWithLease keeps the two in sync.

Suggested change
$this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation);
$this->cache->save($collectionKey, 'empty', $documentKey);
if ($this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation)) {
$this->cache->save($collectionKey, 'empty', $documentKey);
}

Comment thread composer.json
@@ -40,7 +46,7 @@
"ext-redis": "*",
"utopia-php/validators": "0.2.*",

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.

P1 Temporary branch pin must not reach maincomposer.json still points at dev-feat/leasable-cache as 3.1.0. As noted in the PR description, this needs to be reverted to ^3.1 (or whatever tag utopia-php/cache#75 ships as) before this PR can merge, otherwise any environment running composer install will pull from an unversioned feature branch rather than a stable release.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

2 participants