From 911ea94c4e4a902d456b23d2b254a40bed66f283 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 23 Jun 2026 04:07:45 +1200 Subject: [PATCH] feat(database): use the cache lease in getDocument to fix read-after-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 --- composer.json | 8 ++++++- composer.lock | 46 +++++++++++++++++++++++++++++---------- src/Database/Database.php | 8 ++++++- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index 24226eb97..0f32398a6 100755 --- a/composer.json +++ b/composer.json @@ -32,6 +32,12 @@ "check": "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G", "coverage": "./vendor/bin/coverage-check ./tmp/clover.xml 90" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/cache" + } + ], "require": { "php": ">=8.4", "ext-pdo": "*", @@ -40,7 +46,7 @@ "ext-redis": "*", "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", "utopia-php/pools": "1.*", "utopia-php/mongo": "1.*" }, diff --git a/composer.lock b/composer.lock index 67a8878a0..450eb8e52 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f5fb1745555c5cd00d14474561bf0095", + "content-hash": "fc51d9ecd53cedaae713460b39785509", "packages": [ { "name": "brick/math", @@ -2034,16 +2034,16 @@ }, { "name": "utopia-php/cache", - "version": "3.0.0", + "version": "dev-feat/leasable-cache", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "ece1f4d11ec2804cd7e05b9717dc7a2bc66e4176" + "reference": "b30259d5d74b4dbc9721337b8d5db6b7c57d09cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/ece1f4d11ec2804cd7e05b9717dc7a2bc66e4176", - "reference": "ece1f4d11ec2804cd7e05b9717dc7a2bc66e4176", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/b30259d5d74b4dbc9721337b8d5db6b7c57d09cd", + "reference": "b30259d5d74b4dbc9721337b8d5db6b7c57d09cd", "shasum": "" }, "require": { @@ -2068,7 +2068,22 @@ "Utopia\\Cache\\": "src/Cache" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Utopia\\Tests\\": "tests/Cache" + } + }, + "scripts": { + "check": [ + "./vendor/bin/phpstan analyse --level max src tests" + ], + "lint": [ + "./vendor/bin/pint --test" + ], + "format": [ + "./vendor/bin/pint" + ] + }, "license": [ "MIT" ], @@ -2081,10 +2096,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/3.0.0" + "source": "https://github.com/utopia-php/cache/tree/feat/leasable-cache", + "issues": "https://github.com/utopia-php/cache/issues" }, - "time": "2026-05-14T14:13:17+00:00" + "time": "2026-06-22T16:01:56+00:00" }, { "name": "utopia-php/circuit-breaker", @@ -4651,9 +4666,18 @@ "time": "2026-02-10T04:21:53+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/cache", + "version": "dev-feat/leasable-cache", + "alias": "3.1.0", + "alias_normalized": "3.1.0.0" + } + ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/cache": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/src/Database/Database.php b/src/Database/Database.php index 5a6cd97d7..366b5aeb5 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4879,6 +4879,12 @@ public function getDocument(string $collection, string $id, array $queries = [], return $document; } + // 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); + $document = $this->adapter->getDocument( $collection, $id, @@ -4931,7 +4937,7 @@ public function getDocument(string $collection, string $id, array $queries = [], // caching the pre-commit row would poison the cache for other readers. if (!$forUpdate && empty($relationships)) { try { - $this->cache->save($documentKey, $document->getArrayCopy(), $hashKey); + $this->cache->saveWithLease($documentKey, $document->getArrayCopy(), $hashKey, $generation); $this->cache->save($collectionKey, 'empty', $documentKey); } catch (Exception $e) { Console::warning('Failed to save document to cache: ' . $e->getMessage());