Skip to content

Commit 11095f8

Browse files
authored
Merge pull request #1043 from utopia-php/fix/retry-write-cache-purge
fix(documents): retry the cache invalidation a write waits on before refusing it
2 parents 3859954 + b02ac7c commit 11095f8

4 files changed

Lines changed: 68 additions & 12 deletions

File tree

‎src/Database/Trait/Documents.php‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3575,7 +3575,7 @@ protected function purgeCachedDocumentInternal(string $collectionId, ?string $id
35753575
);
35763576
}
35773577

3578-
$this->cache->purge($documentKey);
3578+
$this->withRetries(fn () => $this->cache->purge($documentKey));
35793579

35803580
return true;
35813581
}
@@ -3757,26 +3757,29 @@ private function advanceDocumentCacheEpoch(string $collectionKey, string $defini
37573757
* epoch, so readers refill it with the tombstone. A write whose registration keeps failing
37583758
* still blocks with its tombstone: its activation finishes it while the tombstone is its own,
37593759
* and otherwise retires whatever readers filled while it ran. An epoch that cannot be read is
3760-
* purged all the same, so a cache that cannot confirm the purge fails the write.
3760+
* purged all the same, so a cache that cannot confirm the purge fails the write. Each step is
3761+
* retried first, so a blip shorter than the retries does not cost the write.
37613762
*/
37623763
private function blockDocumentCacheEpoch(string $collectionKey, string $token, string $definitionKey): bool
37633764
{
37643765
$epochKey = $collectionKey.'#epoch';
37653766
if (! $this->registerDocumentCacheOwner($collectionKey, $token)) {
37663767
$epoch = $this->cache->load($epochKey, self::DOCUMENT_CACHE_PERMANENT);
37673768
if ($epoch === false || $epoch === null) {
3768-
$this->cache->purge($epochKey);
3769+
$this->withRetries(fn () => $this->cache->purge($epochKey));
37693770

37703771
return false;
37713772
}
37723773
}
37733774

3774-
if ($this->cache->save($epochKey, self::DOCUMENT_CACHE_BLOCKED_PREFIX.$token.self::DOCUMENT_CACHE_SEPARATOR.\time()) === false) {
3775-
throw new RuntimeException("Failed to block document cache epoch '{$epochKey}'");
3776-
}
3775+
$this->withRetries(function () use ($epochKey, $token): void {
3776+
if ($this->cache->save($epochKey, self::DOCUMENT_CACHE_BLOCKED_PREFIX.$token.self::DOCUMENT_CACHE_SEPARATOR.\time()) === false) {
3777+
throw new RuntimeException("Failed to block document cache epoch '{$epochKey}'");
3778+
}
3779+
});
37773780

3778-
$this->cache->purge($collectionKey.'#started');
3779-
$this->purgeCachedDefinition($definitionKey);
3781+
$this->withRetries(fn () => $this->cache->purge($collectionKey.'#started'));
3782+
$this->withRetries(fn () => $this->purgeCachedDefinition($definitionKey));
37803783

37813784
return true;
37823785
}

‎tests/unit/Cache/UnavailableCache.php‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ final class UnavailableCache implements CacheAdapter, Leasable
1919

2020
private bool $failingWrites = false;
2121

22+
private int $failingPurges = 0;
23+
2224
public function __construct()
2325
{
2426
$this->cache = new RedisLeasableCache();
@@ -34,6 +36,14 @@ public function failWrites(): void
3436
$this->failingWrites = true;
3537
}
3638

39+
/**
40+
* A blip rather than an outage: the next purges cannot be confirmed, later ones can.
41+
*/
42+
public function failNextPurges(int $count): void
43+
{
44+
$this->failingPurges = $count;
45+
}
46+
3747
public function recover(): void
3848
{
3949
$this->open = false;
@@ -47,6 +57,12 @@ private function writable(): bool
4757

4858
private function confirmPurge(): void
4959
{
60+
if ($this->failingPurges > 0) {
61+
$this->failingPurges--;
62+
63+
throw new RuntimeException('Cache purge could not be confirmed');
64+
}
65+
5066
if (! $this->writable()) {
5167
throw new RuntimeException('Cache purge could not be confirmed');
5268
}

‎tests/unit/Documents/CacheUnavailableTest.php‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,41 @@ public function testAWriteTheCacheCannotInvalidateIsRolledBack(string $outage, C
6969
$this->assertSame('original', $this->cached($database));
7070
}
7171

72+
/**
73+
* One Redis timeout used to refuse the write outright, surfacing to the client as a 500 for a
74+
* cache that answered the very next call. A blip shorter than the retries must not cost the write.
75+
*
76+
* @param Closure(Database): mixed $write
77+
*/
78+
#[DataProvider('blips')]
79+
public function testAWriteRidesOutAPurgeBlip(Closure $write): void
80+
{
81+
$cache = new UnavailableCache();
82+
$database = $this->createDatabase($cache);
83+
$cache->failNextPurges(2);
84+
85+
$write($database);
86+
87+
$this->assertSame('updated', $this->stored($database));
88+
$this->assertSame('updated', $this->cached($database), 'The document must not be served from the cache as it was before the write');
89+
}
90+
91+
/** @return array<string, array{Closure(Database): mixed}> */
92+
public static function blips(): array
93+
{
94+
return [
95+
'update' => [static fn (Database $database): Document => $database->updateDocument('webhooks', 'hook', new Document(['name' => 'updated']))],
96+
'update in a transaction' => [static fn (Database $database): mixed => $database->withTransaction(
97+
static fn (): Document => $database->updateDocument('webhooks', 'hook', new Document(['name' => 'updated']))
98+
)],
99+
'batch update' => [static fn (Database $database): int => $database->updateDocuments(
100+
'webhooks',
101+
new Document(['name' => 'updated']),
102+
[Query::equal('$id', ['hook'])],
103+
)],
104+
];
105+
}
106+
72107
public function testAPurgeFailingAfterTheCommitRetiresTheCollectionInstead(): void
73108
{
74109
$cache = new UnavailableCache();

‎tests/unit/Documents/DocumentCacheInvalidationTest.php‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,16 +165,17 @@ public function testACollectionDefinitionWriteKeepsTheOtherDefinitionsCached():
165165
$this->assertTrue($database->getCollection('logs')->getAttribute('documentSecurity'), 'The written definition must be read again');
166166
}
167167

168+
/**
169+
* A purge refused once is retried, so the cache refuses for as long as the write runs: an
170+
* outage longer than the retries must still roll the write back.
171+
*/
168172
public function testAFailedPurgeInsideTheTransactionRollsTheWriteBack(): void
169173
{
170174
/** @var bool $refusing */
171175
$refusing = false;
172176
$database = $this->createDatabase(new CountingMemory(), $this->purgeRefusingCache(
173177
static function () use (&$refusing): bool {
174-
$refused = $refusing;
175-
$refusing = false;
176-
177-
return $refused;
178+
return $refusing;
178179
}
179180
));
180181
$database->createDocument('webhooks', $this->hook('hook'));
@@ -187,6 +188,7 @@ static function () use (&$refusing): bool {
187188
} catch (Throwable $error) {
188189
$failure = $error->getMessage();
189190
}
191+
$refusing = false;
190192

191193
$this->assertSame(self::PURGE_FAILURE, $failure, 'A failed purge inside the transaction must reach the caller');
192194

0 commit comments

Comments
 (0)