diff --git a/CHANGELOG.md b/CHANGELOG.md index 822c3aede..bf0047315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -327,6 +327,9 @@ not change anything for an upgrade from 7.x. dropped, as the SQL adapters do, instead of throwing `Exception\Structure`. - The Redis adapter throws `Exception\Unique`, not a plain `Duplicate`, for unique index violations. - Custom types stay on the handles that share their `TypeRegistry` instead of replacing global filters. + - A write no longer throws `Failed to finish document cache invalidation` after it commits when the cache is + flushed while the write invalidates its collection's cached documents, for example by `delete()` of another + database that shares the cache. - **SQL adapters:** - A transparent reconnect of `Utopia\Database\PDO` keeps MariaDB and MySQL statement timeouts; the timeout also applies to the statement retried after the reconnect. diff --git a/src/Database/Traits/Documents.php b/src/Database/Traits/Documents.php index c977dd998..d5daf0130 100644 --- a/src/Database/Traits/Documents.php +++ b/src/Database/Traits/Documents.php @@ -2669,25 +2669,23 @@ private function activateDocumentCacheEpoch(string $collectionKey, string $token return; } - $active = \bin2hex(\random_bytes(16)); - if ($this->cache->save($epochKey, $active) === false) { - throw new RuntimeException("Failed to activate document cache epoch '{$epochKey}'"); - } + if ($started !== $finished) { + $this->cache->purge($finishedKey); - if ($started === $finished) { - return; - } - - $this->cache->purge($finishedKey); - if ($this->cache->getGeneration($finishedKey) === $finished) { - $nextStarted = $this->cache->getGeneration($startedKey); - $nextFinished = $this->cache->getGeneration($finishedKey); - $nextEpoch = $this->cache->load($epochKey, self::TTL); - if ($nextStarted === $nextFinished || $nextEpoch !== $active) { - return; + // A cache flush restarts generations, so an unchanged #finished + // only proves this purge was lost while the epoch read with it is + // still in place; publish the new epoch after this check, not before. + if ( + $this->cache->getGeneration($finishedKey) === $finished + && \is_string($epoch) + && $this->cache->load($epochKey, self::TTL) === $epoch + ) { + throw new RuntimeException("Failed to finish document cache invalidation '{$epochKey}'"); } + } - throw new RuntimeException("Failed to finish document cache invalidation '{$epochKey}'"); + if ($this->cache->save($epochKey, \bin2hex(\random_bytes(16))) === false) { + throw new RuntimeException("Failed to activate document cache epoch '{$epochKey}'"); } } diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 4323cad70..02177a0c4 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -1293,12 +1293,16 @@ public function testTransformations(): void $database->setMetadata('scope', 'api.users'); - $hook = new class () implements Transform { + $hook = new class ($database->getNamespace().'_docs') implements Transform { public string $query = ''; + public function __construct(private readonly string $table) + { + } + public function transform(Event $event, string $query): string { - if ($event !== Event::DocumentRead) { + if ($event !== Event::DocumentRead || ! \str_contains($query, $this->table)) { return $query; } diff --git a/tests/unit/Documents/DocumentCacheEpochTest.php b/tests/unit/Documents/DocumentCacheEpochTest.php index 81cd5b7d8..0699f1a43 100644 --- a/tests/unit/Documents/DocumentCacheEpochTest.php +++ b/tests/unit/Documents/DocumentCacheEpochTest.php @@ -213,6 +213,18 @@ public function testCacheFlushDuringActivationDoesNotFailTheCommittedMutation(): $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); } + public function testCacheFlushAfterActivationReadsGenerationsDoesNotFailTheCommittedMutation(): void + { + $cache = new FlushDuringActivationMemory(); + $database = $this->createDatabaseWithCache($cache); + $this->assertTrue($cache->flush()); + $this->assertTrue($database->purgeCachedCollection('webhooks')); + $cache->flushAfterReading('collection:webhooks#finished'); + + $this->assertTrue($database->deleteCollection('webhooks')); + $this->assertTrue($database->getCollection('webhooks')->isEmpty()); + } + public function testActivationPurgeFailureStillPropagates(): void { $cache = new FlushDuringActivationMemory(); @@ -406,6 +418,8 @@ final class FlushDuringActivationMemory extends MemoryCache implements Leasable private bool $failDuringActivation = false; + private ?string $flushAfterReading = null; + public function flushDuringActivation(): void { $this->flushDuringActivation = true; @@ -416,9 +430,20 @@ public function failDuringActivation(): void $this->failDuringActivation = true; } + public function flushAfterReading(string $key): void + { + $this->flushAfterReading = $key; + } + public function getGeneration(string $key): string { - return (string) ($this->generations[$key] ?? 0); + $generation = (string) ($this->generations[$key] ?? 0); + if ($this->flushAfterReading !== null && \str_ends_with($key, $this->flushAfterReading)) { + $this->flushAfterReading = null; + $this->flush(); + } + + return $generation; } #[\Override]