diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 000000000..69c82ec21 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [6.0.0] - 2026-03-25 + +### Added +- `Database::EVENT_CACHE_READ_FAILURE` event constant, emitted when a cache load fails during a document read. Previously these failures incorrectly emitted `EVENT_CACHE_PURGE_FAILURE`, making it impossible to distinguish a read-side cache miss from a write-side purge failure. + +### Changed +- Cache purge in `updateDocument` is now performed **outside** the database transaction. Previously a cache failure inside the transaction would roll back the committed write; now the DB write is always committed first and the cache is invalidated afterward (half-open / fail-open pattern). +- Cache purge in `deleteDocument` follows the same transactional ordering fix: the row is deleted inside the transaction and the cache entry is evicted only after the transaction commits. +- All event-listener invocations for cache-related events (`EVENT_CACHE_PURGE_FAILURE`, `EVENT_CACHE_READ_FAILURE`, `EVENT_DOCUMENT_PURGE`) are now wrapped in an inner `try/catch`. A listener that throws no longer propagates the exception up to the caller — the error is logged via `Console::error` and execution continues. + +### Fixed +- `getDocument` no longer emits `EVENT_CACHE_PURGE_FAILURE` when the cache is unavailable for a read. It now correctly emits `EVENT_CACHE_READ_FAILURE`, so callers that distinguish the two events receive the right signal. +- A broken or unavailable cache can no longer cause `updateDocument` or `deleteDocument` to surface an exception to the caller. Both operations are now fully fail-open with respect to the cache layer. +- A throwing `EVENT_CACHE_PURGE_FAILURE` or `EVENT_CACHE_READ_FAILURE` listener can no longer abort an in-progress database operation. +- Fixed 6 `MariaDBTest` E2E tests that failed with `Incorrect table name` due to collection IDs derived from long method names exceeding MariaDB's 64-character identifier limit. + +[Unreleased]: https://github.com/utopia-php/database/compare/6.0.0...HEAD +[6.0.0]: https://github.com/utopia-php/database/compare/5.3.17...6.0.0 diff --git a/README.md b/README.md index 309966b1d..b7c80336b 100644 --- a/README.md +++ b/README.md @@ -315,28 +315,41 @@ $database->exists( // Listen to events // Event Types -Database::EVENT_ALL -Database::EVENT_DATABASE_CREATE, +Database::EVENT_ALL, Database::EVENT_DATABASE_LIST, -Database::EVENT_COLLECTION_CREATE, +Database::EVENT_DATABASE_CREATE, +Database::EVENT_DATABASE_DELETE, Database::EVENT_COLLECTION_LIST, +Database::EVENT_COLLECTION_CREATE, +Database::EVENT_COLLECTION_UPDATE, Database::EVENT_COLLECTION_READ, -Database::EVENT_ATTRIBUTE_CREATE, -Database::EVENT_ATTRIBUTE_UPDATE, -Database::EVENT_INDEX_CREATE, +Database::EVENT_COLLECTION_DELETE, +Database::EVENT_DOCUMENT_FIND, +Database::EVENT_DOCUMENT_PURGE, Database::EVENT_DOCUMENT_CREATE, -Database::EVENT_DOCUMENT_UPDATE, +Database::EVENT_DOCUMENTS_CREATE, Database::EVENT_DOCUMENT_READ, -Database::EVENT_DOCUMENT_FIND, +Database::EVENT_DOCUMENT_UPDATE, +Database::EVENT_DOCUMENTS_UPDATE, +Database::EVENT_DOCUMENTS_UPSERT, +Database::EVENT_DOCUMENT_DELETE, +Database::EVENT_DOCUMENTS_DELETE, Database::EVENT_DOCUMENT_COUNT, Database::EVENT_DOCUMENT_SUM, Database::EVENT_DOCUMENT_INCREASE, Database::EVENT_DOCUMENT_DECREASE, -Database::EVENT_INDEX_DELETE, -Database::EVENT_DOCUMENT_DELETE, +Database::EVENT_PERMISSIONS_CREATE, +Database::EVENT_PERMISSIONS_READ, +Database::EVENT_PERMISSIONS_DELETE, +Database::EVENT_ATTRIBUTE_CREATE, +Database::EVENT_ATTRIBUTES_CREATE, +Database::EVENT_ATTRIBUTE_UPDATE, Database::EVENT_ATTRIBUTE_DELETE, -Database::EVENT_COLLECTION_DELETE, -Database::EVENT_DATABASE_DELETE, +Database::EVENT_INDEX_RENAME, +Database::EVENT_INDEX_CREATE, +Database::EVENT_INDEX_DELETE, +Database::EVENT_CACHE_PURGE_FAILURE, +Database::EVENT_CACHE_READ_FAILURE, $database->on( Database::EVENT_ALL, @@ -881,6 +894,28 @@ $database->purgeCachedDocument( Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. +## Testing + +### Setup + +`docker-compose up --detach` + +### E2E + +`docker compose exec tests vendor/bin/phpunit /usr/src/code/tests/e2e` + +### Resources + +`docker compose exec tests vendor/bin/phpunit /usr/src/code/tests/resources` + +### Unit + +`docker compose exec tests vendor/bin/phpunit /usr/src/code/tests/unit` + +### Teardown + +`docker-compose down` + ## Contributing Thank you for considering contributing to the Utopia Framework! diff --git a/bin/cli.php b/bin/cli.php index 77f462eab..3558d81de 100644 --- a/bin/cli.php +++ b/bin/cli.php @@ -9,11 +9,11 @@ $cli = new CLI(); -include 'tasks/load.php'; include 'tasks/index.php'; +include 'tasks/load.php'; +include 'tasks/operators.php'; include 'tasks/query.php'; include 'tasks/relationships.php'; -include 'tasks/operators.php'; $cli ->error() diff --git a/dev/xdebug.ini b/dev/xdebug.ini index 9fdfa2d76..f640f0b72 100644 --- a/dev/xdebug.ini +++ b/dev/xdebug.ini @@ -1,13 +1,12 @@ zend_extension = xdebug.so [xdebug] -xdebug.mode = develop,debug,profile -xdebug.start_with_request = yes -xdebug.use_compression=false xdebug.client_host=host.docker.internal xdebug.client_port = 9003 xdebug.log = /tmp/xdebug.log - -xdebug.var_display_max_depth = 10 +xdebug.mode = develop,debug,profile +xdebug.start_with_request = yes +xdebug.use_compression=false xdebug.var_display_max_children = 256 xdebug.var_display_max_data = 4096 +xdebug.var_display_max_depth = 10 diff --git a/src/Database/Database.php b/src/Database/Database.php index ac58d72f0..fa5936886 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -213,6 +213,9 @@ class Database public const EVENT_INDEX_CREATE = 'index_create'; public const EVENT_INDEX_DELETE = 'index_delete'; + public const EVENT_CACHE_PURGE_FAILURE = 'cache_purge_failure'; + public const EVENT_CACHE_READ_FAILURE = 'cache_read_failure'; + public const INSERT_BATCH_SIZE = 1_000; public const DELETE_BATCH_SIZE = 1_000; @@ -1636,7 +1639,16 @@ public function delete(?string $database = null): bool // Ignore } - $this->cache->flush(); + try { + $this->cache->flush(); + } catch (\Throwable $e) { + Console::warning('Failed to flush cache on database delete: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + } return $deleted; } @@ -2228,8 +2240,8 @@ public function createAttribute(string $collection, string $id, string $type, in operationDescription: "attribute creation '{$id}'" ); - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); try { $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ @@ -2432,8 +2444,8 @@ public function createAttributes(string $collection, array $attributes): bool rollbackReturnsErrors: true ); - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); try { $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ @@ -3149,9 +3161,9 @@ public function updateAttribute(string $collection, string $id, ?string $type = ); if ($altering) { - $this->withRetries(fn () => $this->purgeCachedCollection($collection)); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection)); } - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection)); + $this->withRetriesOrWarn(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection)); try { $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ @@ -3291,8 +3303,8 @@ public function deleteAttribute(string $collection, string $id): bool silentRollback: true ); - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); try { $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ @@ -3418,7 +3430,7 @@ public function renameAttribute(string $collection, string $old, string $new): b operationDescription: "attribute rename '{$old}' to '{$new}'" ); - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection->getId())); try { $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); @@ -3995,7 +4007,7 @@ public function updateRelationship( $junctionAttribute->setAttribute('key', $actualNewTwoWayKey); }); - $this->withRetries(fn () => $this->purgeCachedCollection($junction)); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($junction)); } } catch (\Throwable $e) { if ($adapterUpdated) { @@ -4164,8 +4176,8 @@ function ($index) use ($newKey) { throw new DatabaseException("Failed to update relationship indexes for '{$id}': " . $e->getMessage(), previous: $e); } - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($relatedCollection->getId())); return true; } @@ -4364,8 +4376,8 @@ public function deleteRelationship(string $collection, string $id): bool ); } - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); - $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($relatedCollection->getId())); try { $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); @@ -4447,7 +4459,7 @@ public function renameIndex(string $collection, string $old, string $new): bool operationDescription: "index rename '{$old}' to '{$new}'" ); - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetriesOrWarn(fn () => $this->purgeCachedCollection($collection->getId())); try { $this->trigger(self::EVENT_INDEX_RENAME, $indexNew); @@ -4785,8 +4797,13 @@ public function getDocument(string $collection, string $id, array $queries = [], try { $cached = $this->cache->load($documentKey, self::TTL, $hashKey); - } catch (Exception $e) { + } catch (\Throwable $e) { Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_READ_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_READ_FAILURE listener threw: ' . $innerException->getMessage()); + } $cached = null; } @@ -4864,8 +4881,13 @@ public function getDocument(string $collection, string $id, array $queries = [], try { $this->cache->save($documentKey, $document->getArrayCopy(), $hashKey); $this->cache->save($collectionKey, 'empty', $documentKey); - } catch (Exception $e) { + } catch (\Throwable $e) { Console::warning('Failed to save document to cache: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } } } @@ -6274,12 +6296,6 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->adapter->castingAfter($collection, $document); - $this->purgeCachedDocument($collection->getId(), $id); - - if ($document->getId() !== $id) { - $this->purgeCachedDocument($collection->getId(), $document->getId()); - } - // If operators were used, refetch document to get computed values $hasOperators = false; foreach ($document->getArrayCopy() as $value) { @@ -6301,6 +6317,13 @@ public function updateDocument(string $collection, string $id, Document $documen return $document; } + // Purge cache outside the transaction so cache failures cannot roll back DB writes (half-open) + $this->purgeCachedDocument($collection->getId(), $id); + + if ($document->getId() !== $id) { + $this->purgeCachedDocument($collection->getId(), $document->getId()); + } + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth)); $document = $documents[0]; @@ -7637,12 +7660,12 @@ public function deleteDocument(string $collection, string $id): bool $result = $this->adapter->deleteDocument($collection->getId(), $id); - $this->purgeCachedDocument($collection->getId(), $id); - return $result; }); if ($deleted) { + // Purge cache outside the transaction so cache failures cannot roll back DB writes (half-open) + $this->purgeCachedDocument($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); } @@ -8214,12 +8237,42 @@ public function purgeCachedCollection(string $collectionId): bool { [$collectionKey] = $this->getCacheKeys($collectionId); - $documentKeys = $this->cache->list($collectionKey); + try { + $documentKeys = $this->cache->list($collectionKey); + } catch (\Throwable $e) { + Console::warning('Failed to list collection cache keys: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + return false; + } + foreach ($documentKeys as $documentKey) { - $this->cache->purge($documentKey); + try { + $this->cache->purge($documentKey); + } catch (\Throwable $e) { + Console::warning('Failed to purge document cache key: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + } } - $this->cache->purge($collectionKey); + try { + $this->cache->purge($collectionKey); + } catch (\Throwable $e) { + Console::warning('Failed to purge collection cache key: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + return false; + } return true; } @@ -8241,10 +8294,33 @@ protected function purgeCachedDocumentInternal(string $collectionId, ?string $id [$collectionKey, $documentKey] = $this->getCacheKeys($collectionId, $id); - $this->cache->purge($collectionKey, $documentKey); - $this->cache->purge($documentKey); + $success = true; - return true; + try { + $this->cache->purge($collectionKey, $documentKey); + } catch (\Throwable $e) { + Console::warning('Failed to purge document reference from collection cache: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + $success = false; + } + + try { + $this->cache->purge($documentKey); + } catch (\Throwable $e) { + Console::warning('Failed to purge document cache key: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + $success = false; + } + + return $success; } /** @@ -8260,13 +8336,27 @@ protected function purgeCachedDocumentInternal(string $collectionId, ?string $id */ public function purgeCachedDocument(string $collectionId, ?string $id): bool { - $result = $this->purgeCachedDocumentInternal($collectionId, $id); + try { + $result = $this->purgeCachedDocumentInternal($collectionId, $id); + } catch (\Throwable $e) { + Console::warning('Failed to purge document cache: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + return false; + } if ($id !== null) { - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $id, - '$collection' => $collectionId - ])); + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $id, + '$collection' => $collectionId + ])); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_DOCUMENT_PURGE listener threw: ' . $innerException->getMessage()); + } } return $result; @@ -10060,6 +10150,27 @@ private function withRetries( throw $lastException; } + /** + * Retries an operation up to maxAttempts times, and on final failure emits + * EVENT_CACHE_PURGE_FAILURE and logs a warning instead of throwing. + * + * @param callable $operation The cache purge/invalidation operation to retry + * @return void + */ + private function withRetriesOrWarn(callable $operation): void + { + try { + $this->withRetries($operation); + } catch (\Throwable $e) { + Console::warning('Failed to purge cache after retries: ' . $e->getMessage()); + try { + $this->trigger(self::EVENT_CACHE_PURGE_FAILURE, $e); + } catch (\Throwable $innerException) { + Console::error('Cache unavailable: EVENT_CACHE_PURGE_FAILURE listener threw: ' . $innerException->getMessage()); + } + } + } + /** * Generic cleanup operation with retry logic * diff --git a/tests/e2e/Adapter/MariaDBTest.php b/tests/e2e/Adapter/MariaDBTest.php index 923de242e..d70bb4f0d 100644 --- a/tests/e2e/Adapter/MariaDBTest.php +++ b/tests/e2e/Adapter/MariaDBTest.php @@ -3,10 +3,15 @@ namespace Tests\E2E\Adapter; use Redis; +use RuntimeException; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; use Utopia\Database\PDO; class MariaDBTest extends Base @@ -71,4 +76,356 @@ protected function deleteIndex(string $collection, string $index): bool return true; } + + /** + * Build a Cache mock where every method throws, simulating a lost connection. + */ + private function buildBrokenCache(): Cache + { + $broken = $this->createMock(Cache::class); + $broken->method('load')->willThrowException(new RuntimeException('cache unavailable')); + $broken->method('save')->willThrowException(new RuntimeException('cache unavailable')); + $broken->method('purge')->willThrowException(new RuntimeException('cache unavailable')); + $broken->method('list')->willThrowException(new RuntimeException('cache unavailable')); + $broken->method('flush')->willThrowException(new RuntimeException('cache unavailable')); + return $broken; + } + + /** + * Scaffold a collection used by all cache fail-open tests. + * Returns the Database with a working cache so data is seeded properly. + */ + private function seedCacheFailOpenCollection(string $collection): Database + { + $database = $this->getDatabase(); + $database->getAuthorization()->addRole(Role::any()->toString()); + + $database->createCollection($collection, attributes: [ + new Document([ + '$id' => ID::custom('title'), + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => true, + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + $database->createDocument($collection, new Document([ + '$id' => ID::custom('seed'), + 'title' => 'original', + ])); + + // Prime the read cache so the next read would normally come from cache. + $database->getDocument($collection, 'seed'); + + return $database; + } + + public function testCacheFailOpenOnRead(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // getDocument must fall back to the database and return the document. + $doc = $database->getDocument($collection, 'seed'); + $this->assertFalse($doc->isEmpty()); + $this->assertEquals('original', $doc->getAttribute('title')); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testCacheFailOpenOnCreate(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // createDocument must persist to the database even if the cache save fails. + $doc = $database->createDocument($collection, new Document([ + '$id' => ID::custom('new'), + 'title' => 'created', + ])); + $this->assertFalse($doc->isEmpty()); + $this->assertEquals('created', $doc->getAttribute('title')); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testCacheFailOpenOnUpdate(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // updateDocument must persist to the database even if the cache purge fails. + $doc = $database->updateDocument($collection, 'seed', new Document([ + '$id' => 'seed', + 'title' => 'updated', + ])); + $this->assertEquals('updated', $doc->getAttribute('title')); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testCacheFailOpenOnDelete(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // deleteDocument must remove the row from the database even if the cache purge fails. + $result = $database->deleteDocument($collection, 'seed'); + $this->assertTrue($result); + + // Restore working cache, evict the stale entry that couldn't be purged + // while the cache was broken, then confirm the row is gone in the DB. + $database->setCache($originalCache); + $database->purgeCachedDocument($collection, 'seed'); + $this->assertTrue($database->getDocument($collection, 'seed')->isEmpty()); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testCacheFailOpenPurgeCachedDocument(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // purgeCachedDocument must return false and must not throw. + $result = $database->purgeCachedDocument($collection, 'seed'); + $this->assertFalse($result); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testCacheFailOpenPurgeCachedCollection(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // purgeCachedCollection must return false and must not throw. + $result = $database->purgeCachedCollection($collection); + $this->assertFalse($result); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testCacheFailureEmitsPurgeFailureEvent(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + $failures = 0; + $database->on(Database::EVENT_CACHE_PURGE_FAILURE, 'test-listener', function () use (&$failures) { + $failures++; + }); + + try { + $database->setCache($this->buildBrokenCache()); + + // These operations touch the write-path cache and should fire EVENT_CACHE_PURGE_FAILURE. + $database->updateDocument($collection, 'seed', new Document(['$id' => 'seed', 'title' => 'x'])); // purge fails + $database->purgeCachedDocument($collection, 'seed'); // purge fails + + $this->assertGreaterThan(0, $failures, 'EVENT_CACHE_PURGE_FAILURE was never emitted'); + } finally { + $database->on(Database::EVENT_CACHE_PURGE_FAILURE, 'test-listener', null); + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testCacheReadFailureEmitsReadFailureEvent(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + $readFailures = 0; + $purgeFailures = 0; + + $database->on(Database::EVENT_CACHE_READ_FAILURE, 'test-read-listener', function () use (&$readFailures) { + $readFailures++; + }); + $database->on(Database::EVENT_CACHE_PURGE_FAILURE, 'test-purge-listener', function () use (&$purgeFailures) { + $purgeFailures++; + }); + + try { + $database->setCache($this->buildBrokenCache()); + + // getDocument with a broken cache must emit EVENT_CACHE_READ_FAILURE, not EVENT_CACHE_PURGE_FAILURE. + $doc = $database->getDocument($collection, 'seed'); + $this->assertFalse($doc->isEmpty(), 'getDocument must fall back to DB when cache is broken'); + $this->assertGreaterThan(0, $readFailures, 'EVENT_CACHE_READ_FAILURE was never emitted'); + $this->assertEquals(0, $purgeFailures, 'getDocument must not emit EVENT_CACHE_PURGE_FAILURE on a read miss'); + } finally { + $database->on(Database::EVENT_CACHE_READ_FAILURE, 'test-read-listener', null); + $database->on(Database::EVENT_CACHE_PURGE_FAILURE, 'test-purge-listener', null); + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testThrowingCachePurgeFailureListenerDoesNotPropagate(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + $database->on(Database::EVENT_CACHE_PURGE_FAILURE, 'throwing-listener', function () { + throw new RuntimeException('Listener exploded'); + }); + + try { + $database->setCache($this->buildBrokenCache()); + + // The listener throws, but the operation must still complete without propagating the exception. + $doc = $database->updateDocument($collection, 'seed', new Document(['$id' => 'seed', 'title' => 'updated'])); + $this->assertEquals('updated', $doc->getAttribute('title')); + + $result = $database->purgeCachedDocument($collection, 'seed'); + $this->assertFalse($result); // returns false when cache is broken, but does not throw + + $deleted = $database->deleteDocument($collection, 'seed'); + $this->assertTrue($deleted); + } finally { + $database->on(Database::EVENT_CACHE_PURGE_FAILURE, 'throwing-listener', null); + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testThrowingCacheReadFailureListenerDoesNotPropagate(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + $database->on(Database::EVENT_CACHE_READ_FAILURE, 'throwing-listener', function () { + throw new RuntimeException('Read listener exploded'); + }); + + try { + $database->setCache($this->buildBrokenCache()); + + // The listener throws, but getDocument must still fall back to DB without propagating. + $doc = $database->getDocument($collection, 'seed'); + $this->assertFalse($doc->isEmpty()); + $this->assertEquals('original', $doc->getAttribute('title')); + } finally { + $database->on(Database::EVENT_CACHE_READ_FAILURE, 'throwing-listener', null); + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testThrowingDocumentPurgeListenerDoesNotPropagate(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + + $database->on(Database::EVENT_DOCUMENT_PURGE, 'throwing-listener', function () { + throw new RuntimeException('Purge listener exploded'); + }); + + try { + // purgeCachedDocument must succeed even when the EVENT_DOCUMENT_PURGE listener throws. + $result = $database->purgeCachedDocument($collection, 'seed'); + $this->assertTrue($result); + } finally { + $database->on(Database::EVENT_DOCUMENT_PURGE, 'throwing-listener', null); + $database->deleteCollection($collection); + } + } + + public function testUpdateDocumentPersistsDespiteBrokenCachePurge(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // The cache purge now happens outside the DB transaction. + // A broken cache must never roll back the write. + $doc = $database->updateDocument($collection, 'seed', new Document([ + '$id' => 'seed', + 'title' => 'persisted', + ])); + $this->assertEquals('persisted', $doc->getAttribute('title')); + + // Restore cache and confirm the DB row was actually written. + $database->setCache($originalCache); + $database->purgeCachedDocument($collection, 'seed'); + $fresh = $database->getDocument($collection, 'seed'); + $this->assertEquals('persisted', $fresh->getAttribute('title')); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } + + public function testDeleteDocumentPersistsDespiteBrokenCachePurge(): void + { + $collection = substr(__FUNCTION__, 4, 34); + $database = $this->seedCacheFailOpenCollection($collection); + $originalCache = $database->getCache(); + + try { + $database->setCache($this->buildBrokenCache()); + + // The cache purge now happens outside the DB transaction. + // A broken cache must never prevent the delete from committing. + $deleted = $database->deleteDocument($collection, 'seed'); + $this->assertTrue($deleted); + + // Restore cache, evict any stale entry, then confirm the row is gone. + $database->setCache($originalCache); + $database->purgeCachedDocument($collection, 'seed'); + $this->assertTrue($database->getDocument($collection, 'seed')->isEmpty()); + } finally { + $database->setCache($originalCache); + $database->deleteCollection($collection); + } + } } diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 6e8677315..f62033e80 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -676,28 +676,20 @@ public function testCacheFallback(): void $this->assertCount(1, $database->find('testRedisFallback', [Query::equal('string', ['text📝'])])); $this->assertFalse(($database->getDocument('testRedisFallback', 'doc1'))->isEmpty()); - // Check we cannot modify data - try { - $database->updateDocument('testRedisFallback', 'doc1', new Document([ - 'string' => 'text📝 updated', - ])); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertEquals('Redis server redis:6379 went away', $e->getMessage()); - } + // Writes must succeed even when the cache is unavailable (fail-open) + $updated = $database->updateDocument('testRedisFallback', 'doc1', new Document([ + 'string' => 'text📝 updated', + ])); + $this->assertEquals('text📝 updated', $updated->getAttribute('string')); - try { - $database->deleteDocument('testRedisFallback', 'doc1'); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertEquals('Redis server redis:6379 went away', $e->getMessage()); - } + $deleted = $database->deleteDocument('testRedisFallback', 'doc1'); + $this->assertTrue($deleted); // Bring backup Redis Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker start', "", $stdout, $stderr); sleep(5); - $this->assertCount(1, $database->find('testRedisFallback', [Query::equal('string', ['text📝'])])); + $this->assertCount(0, $database->find('testRedisFallback', [Query::equal('string', ['text📝 updated'])])); } public function testCacheReconnect(): void