From ecfffbb800dce413205f3cda057beb2b25372a8e Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 17:13:44 +0100 Subject: [PATCH 1/4] Add cached find support --- src/Database/Database.php | 332 ++++++++++++++-- tests/e2e/Adapter/Base.php | 5 + tests/e2e/Adapter/RedisTest.php | 5 + tests/e2e/Adapter/Scopes/DocumentTests.php | 41 ++ tests/unit/CacheKeyTest.php | 94 ++++- tests/unit/FindCacheTest.php | 416 +++++++++++++++++++++ 6 files changed, 867 insertions(+), 26 deletions(-) create mode 100644 tests/unit/FindCacheTest.php diff --git a/src/Database/Database.php b/src/Database/Database.php index 5a6cd97d7f..3c621cf8ee 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8353,10 +8353,29 @@ public function purgeCachedCollection(string $collectionId): bool } $this->cache->purge($collectionKey); + $this->purgeCachedFinds($collectionId); return true; } + public function purgeCachedFinds(string $collectionId): bool + { + [$findKey] = $this->getCachedFindKeys($collectionId); + + return $this->cache->purge($findKey); + } + + /** + * @param array $queries + */ + public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = []): bool + { + $collection = $this->silent(fn () => $this->getCollection($collectionId)); + [$findKey, $findField] = $this->authorization->skip(fn () => $this->getCachedFindKeys($collectionId, $queries, $key, $collection)); + + return $this->cache->purge($findKey, $findField); + } + /** * Cleans a specific document from cache * And related document reference in the collection cache. @@ -8564,6 +8583,174 @@ public function find(string $collection, array $queries = [], string $forPermiss return $results; } + /** + * Find documents using an explicit TTL-backed cache. + * + * Results may be stale until the TTL expires or the caller purges the cached + * find entry. Use this only for read paths that can tolerate bounded + * staleness. + * + * Cache reads and writes are only used when authorization is disabled, for + * example inside Authorization::skip(). With active authorization this + * delegates to find() so cached results cannot outlive permission changes. + * Cached hits rebuild top-level Document instances from stored arrays and + * are intended for internal flat-document reads. + * + * When $key is provided, it replaces query serialization in the cache key. + * The caller-owned key must include every query dimension that can change + * the result. + * + * @param string $collection + * @param array $queries + * @param int $ttl Cache TTL in seconds. Values above TTL are clamped. Set to 0 to disable caching. + * @param string|null $key Optional caller-owned cache variation key + * @param string $forPermission + * @param bool $touchOnHit Refresh the cached entry timestamp on cache hit + * @return array + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array + { + if ($ttl <= 0) { + return $this->find($collection, $queries, $forPermission); + } + + if ($this->authorization->getStatus()) { + return $this->find($collection, $queries, $forPermission); + } + + foreach ($queries as $query) { + if (!$query instanceof Query || $query->getMethod() === Query::TYPE_ORDER_RANDOM) { + return $this->find($collection, $queries, $forPermission); + } + } + + $ttl = \min($ttl, self::TTL); + + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + + if ($collectionDocument->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + [$findKey, $findField] = $this->getCachedFindKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); + $cached = $this->loadCachedFind($findKey, $findField, $ttl); + + if (!\is_array($cached)) { + return $this->findAndCache($collectionDocument, $queries, $forPermission, $findKey, $findField); + } + + [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached); + + if ($hasExpiredDocuments) { + $this->purgeCachedFindField($findKey, $findField); + + return $this->findAndCache($collectionDocument, $queries, $forPermission, $findKey, $findField); + } + + if ($touchOnHit) { + $this->touchCachedFind($findKey, $findField); + } + + $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); + + return $documents; + } + + /** + * @param array $queries + * @return array + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + private function findAndCache(Document $collection, array $queries, string $forPermission, string $findKey, string $findField): array + { + $documents = $this->find($collection->getId(), $queries, $forPermission); + $this->saveCachedFind($findKey, $findField, $documents); + + return $documents; + } + + private function loadCachedFind(string $findKey, string $findField, int $ttl): mixed + { + try { + return $this->cache->load($findKey, $ttl, $findField); + } catch (Exception $e) { + Console::warning('Failed to get find result from cache: ' . $e->getMessage()); + + return null; + } + } + + /** + * @param array $documents + */ + private function saveCachedFind(string $findKey, string $findField, array $documents): void + { + try { + $this->cache->save($findKey, [ + 'version' => 1, + 'documents' => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents + ), + ], $findField); + } catch (Exception $e) { + Console::warning('Failed to save find result to cache: ' . $e->getMessage()); + } + } + + private function touchCachedFind(string $findKey, string $findField): void + { + try { + $this->cache->touch($findKey, $findField); + } catch (Exception $e) { + Console::warning('Failed to touch find result cache: ' . $e->getMessage()); + } + } + + private function purgeCachedFindField(string $findKey, string $findField): void + { + try { + $this->cache->purge($findKey, $findField); + } catch (Exception $e) { + Console::warning('Failed to purge expired find result cache: ' . $e->getMessage()); + } + } + + /** + * @param array $payload + * @return array{0: array, 1: bool} + */ + private function decodeCachedFindPayload(Document $collection, array $payload): array + { + $results = []; + $hasExpiredDocuments = false; + $documents = \is_array($payload['documents'] ?? null) ? $payload['documents'] : $payload; + + foreach ($documents as $document) { + if (!\is_array($document)) { + continue; + } + + $document = $this->createDocumentInstance($collection->getId(), $document); + + if ($this->isTtlExpired($collection, $document)) { + $hasExpiredDocuments = true; + continue; + } + + $results[] = $document; + } + + return [$results, $hasExpiredDocuments]; + } + /** * Helper method to iterate documents in collection using callback pattern * Alterative is @@ -9445,34 +9632,10 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $sortedSelects = $selects; \sort($sortedSelects); - $filterSignatures = []; - if ($this->filter) { - $disabled = $this->disabledFilters ?? []; - - foreach (self::$filters as $name => $callbacks) { - if (isset($disabled[$name])) { - continue; - } - if (\array_key_exists($name, $this->instanceFilters)) { - continue; - } - $filterSignatures[$name] = $callbacks['signature']; - } - - foreach ($this->instanceFilters as $name => $callbacks) { - if (isset($disabled[$name])) { - continue; - } - $filterSignatures[$name] = $callbacks['signature']; - } - - \ksort($filterSignatures); - } - $payload = \json_encode([ 'selects' => $sortedSelects, 'relationships' => $this->resolveRelationships, - 'filters' => $filterSignatures, + 'filters' => $this->getActiveFilterSignatures(), ]) ?: ''; $documentHashKey = $documentKey . ':' . \md5($payload); } @@ -9484,6 +9647,125 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ]; } + /** + * @param string $collectionId + * @param array $queries + * @param string|null $key + * @param Document|null $collection + * @return array{0: string, 1: string} + */ + public function getCachedFindKeys(string $collectionId, array $queries = [], ?string $key = null, ?Document $collection = null): array + { + [$collectionKey] = $this->getCacheKeys($collectionId); + $findKey = "{$collectionKey}:find"; + + $payload = [ + 'version' => 1, + 'database' => $this->getDatabase(), + 'schema' => $this->getCachedFindSchemaHash($collection), + 'key' => $key, + 'queries' => $key === null ? \array_map( + fn (Query $query): array => $this->serializeCachedFindQuery($query), + $queries + ) : null, + 'relationships' => $this->resolveRelationships, + 'filters' => $this->getActiveFilterSignatures(), + ]; + + return [$findKey, \md5(\json_encode($payload) ?: '')]; + } + + /** + * @return array + */ + private function serializeCachedFindQuery(Query $query): array + { + $serialized = [ + 'method' => $query->getMethod(), + ]; + + if ($query->getAttribute() !== '') { + $serialized['attribute'] = $query->getAttribute(); + } + + $values = []; + foreach ($query->getValues() as $value) { + if ($value instanceof Query) { + $values[] = $this->serializeCachedFindQuery($value); + continue; + } + + $values[] = $this->normalizeCachedFindQueryValue($value); + } + + $serialized['values'] = $values; + + return $serialized; + } + + private function normalizeCachedFindQueryValue(mixed $value): mixed + { + if ($value instanceof Document) { + $value = $value->getArrayCopy(); + } + + if (!\is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->normalizeCachedFindQueryValue($item); + } + + return $value; + } + + private function getCachedFindSchemaHash(?Document $collection): string + { + if ($collection === null || $collection->isEmpty()) { + return ''; + } + + return \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + ); + } + + /** + * @return array + */ + private function getActiveFilterSignatures(): array + { + $filterSignatures = []; + if (!$this->filter) { + return $filterSignatures; + } + + $disabled = $this->disabledFilters ?? []; + + foreach (self::$filters as $name => $callbacks) { + if (isset($disabled[$name])) { + continue; + } + if (\array_key_exists($name, $this->instanceFilters)) { + continue; + } + $filterSignatures[$name] = $callbacks['signature']; + } + + foreach ($this->instanceFilters as $name => $callbacks) { + if (isset($disabled[$name])) { + continue; + } + $filterSignatures[$name] = $callbacks['signature']; + } + + \ksort($filterSignatures); + + return $filterSignatures; + } + private static function computeCallableSignature(callable $callable): string { if (\is_string($callable)) { diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 4baeba35b0..105c1af621 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -65,6 +65,11 @@ abstract protected function deleteColumn(string $collection, string $column): bo */ abstract protected function deleteIndex(string $collection, string $index): bool; + protected function supportsCachedFind(): bool + { + return true; + } + public function setUp(): void { if (is_null(self::$authorization)) { diff --git a/tests/e2e/Adapter/RedisTest.php b/tests/e2e/Adapter/RedisTest.php index 23d779db0f..86ad85c633 100644 --- a/tests/e2e/Adapter/RedisTest.php +++ b/tests/e2e/Adapter/RedisTest.php @@ -114,6 +114,11 @@ protected function deleteIndex(string $collection, string $index): bool return true; } + protected function supportsCachedFind(): bool + { + return false; + } + /** * Inherited test exercises the case where an INTEGER column is altered * to VARCHAR. Redis stores documents as JSON; type changes do not diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 4f998372dd..6758190da1 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -103,6 +103,47 @@ public function testBigintSequence(): void } } + public function testFindCachedReturnsStaleResultUntilPurged(): void + { + if (!$this->supportsCachedFind()) { + $this->markTestSkipped('Adapter test disables the cache layer.'); + } + + /** @var Database $database */ + $database = $this->getDatabase(); + $collection = 'findCached'; + + $database->createCollection($collection); + $database->createAttribute($collection, 'name', Database::VAR_STRING, 255, true); + + $database->createDocument($collection, new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + ])); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $database->createDocument($collection, new Document([ + '$id' => 'second', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Second', + ])); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $database->purgeCachedFinds($collection); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(2, $documents); + $this->assertSame('first', $documents[0]->getId()); + $this->assertSame('second', $documents[1]->getId()); + } + public function testCreateDocumentWithBigIntType(): void { /** @var Database $database */ diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 118bf4897d..67bb82dafe 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -7,18 +7,21 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Query; class CacheKeyTest extends TestCase { /** * @param array $instanceFilters */ - private function createDatabase(array $instanceFilters = []): Database + private function createDatabase(array $instanceFilters = [], string $database = 'test'): Database { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(false); $adapter->method('getTenant')->willReturn(null); $adapter->method('getNamespace')->willReturn('test'); + $adapter->method('getDatabase')->willReturn($database); return new Database($adapter, new Cache(new None()), $instanceFilters); } @@ -131,6 +134,95 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void $this->assertNotEquals($hashEnabled, $hashDisabled); } + public function testDifferentFindQueriesProduceDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getCachedFindKeys('col', [Query::equal('status', ['active'])]); + [, $fieldB] = $db->getCachedFindKeys('col', [Query::equal('status', ['paused'])]); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testCallerFindCacheKeyIgnoresQueryVariation(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getCachedFindKeys('col', [Query::equal('status', ['active'])], 'domain-key'); + [, $fieldB] = $db->getCachedFindKeys('col', [Query::equal('status', ['paused'])], 'domain-key'); + + $this->assertEquals($fieldA, $fieldB); + } + + public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void + { + $dbPlatform = $this->createDatabase(database: 'platform'); + $dbProject = $this->createDatabase(database: 'project'); + + [, $fieldA] = $dbPlatform->getCachedFindKeys('col', [Query::limit(10)]); + [, $fieldB] = $dbProject->getCachedFindKeys('col', [Query::limit(10)]); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testDifferentFindSchemasProduceDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + $collectionA = new Document([ + '$id' => 'col', + 'attributes' => [ + new Document(['$id' => 'name', 'type' => Database::VAR_STRING]), + ], + 'indexes' => [], + ]); + $collectionB = new Document([ + '$id' => 'col', + 'attributes' => [ + new Document(['$id' => 'name', 'type' => Database::VAR_STRING]), + new Document(['$id' => 'status', 'type' => Database::VAR_STRING]), + ], + 'indexes' => [], + ]); + + [, $fieldA] = $db->getCachedFindKeys('col', [Query::limit(10)], collection: $collectionA); + [, $fieldB] = $db->getCachedFindKeys('col', [Query::limit(10)], collection: $collectionB); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testFindRelationshipModeProducesDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getCachedFindKeys('col', [Query::limit(10)]); + [, $fieldB] = $db->skipRelationships(fn () => $db->getCachedFindKeys('col', [Query::limit(10)])); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testFindCursorDocumentValuesProduceDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getCachedFindKeys('col', [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'alpha', + ])), + ]); + [, $fieldB] = $db->getCachedFindKeys('col', [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'beta', + ])), + ]); + + $this->assertNotEquals($fieldA, $fieldB); + } + public function testParseHostname(): void { $hostname = 'database_db_nyc3_self_hosted_0_0'; diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php new file mode 100644 index 0000000000..2143b28685 --- /dev/null +++ b/tests/unit/FindCacheTest.php @@ -0,0 +1,416 @@ +database = $this->createDatabase(new CacheMemory()); + } + + private function createDatabase(Adapter $cache, ?DatabaseMemory $adapter = null): Database + { + $database = new Database($adapter ?? new DatabaseMemory(), new Cache($cache)); + $database + ->setDatabase('utopiaTests') + ->setNamespace('find_cache_' . \uniqid()); + + $database->create(); + $database->createCollection('projects'); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, true); + + return $database; + } + + private function seedProject(Database $database, string $id, string $name): void + { + $database->createDocument('projects', new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'name' => $name, + ])); + } + + public function testFindCachedReturnsStaleResultUntilPurged(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $this->database->purgeCachedFinds('projects'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(2, $documents); + $this->assertSame('first', $documents[0]->getId()); + $this->assertSame('second', $documents[1]->getId()); + } + + public function testFindCachedBypassesCacheWhenTtlIsZero(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 0)); + $this->assertCount(2, $documents); + } + + public function testFindCachedUsesDefaultTtl(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')])); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')])); + $this->assertCount(1, $documents); + } + + public function testFindCachedStoresEmptyResults(): void + { + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(0, $documents); + + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(0, $documents); + + $this->database->purgeCachedFinds('projects'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + } + + public function testFindCachedDelegatesToFindWhenAuthorizationIsEnabled(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(2, $documents); + } + + public function testFindCachedBypassesCacheForRandomOrder(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderRandom()], ttl: 3600)); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderRandom()], ttl: 3600)); + $this->assertCount(2, $documents); + } + + public function testFindCachedDelegatesMalformedQueriesToFind(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query type'); + + /** @var array $queries Intentionally malformed to exercise runtime validation. */ + $queries = ['not-a-query']; + + $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', $queries, ttl: 3600)); + } + + public function testFindCachedTriggersFindEventOnCacheHit(): void + { + $events = []; + $this->database->on(Database::EVENT_DOCUMENT_FIND, 'test', function (string $event) use (&$events): void { + $events[] = $event; + }); + + $this->seedProject($this->database, 'first', 'First'); + + $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $this->assertSame([ + Database::EVENT_DOCUMENT_FIND, + Database::EVENT_DOCUMENT_FIND, + ], $events); + } + + public function testPurgeCachedFindRemovesOnlyOneCallerKey(): void + { + $database = $this->createDatabase(new HashMemoryCache()); + + $this->seedProject($database, 'first', 'First'); + + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); + + $this->seedProject($database, 'second', 'Second'); + + $database->purgeCachedFind('projects', key: 'a'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); + $this->assertCount(2, $documents); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); + $this->assertCount(1, $documents); + } + + public function testFindCachedTouchesCacheEntryOnHitWhenEnabled(): void + { + $cache = new TouchSpyCache(); + $database = $this->createDatabase($cache); + + $this->seedProject($database, 'first', 'First'); + + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); + $this->assertSame(0, $cache->touches); + + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); + $this->assertSame(1, $cache->touches); + } + + public function testFindCachedDoesNotTouchCacheEntryByDefault(): void + { + $cache = new TouchSpyCache(); + $database = $this->createDatabase($cache); + + $this->seedProject($database, 'first', 'First'); + + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + + $this->assertSame(0, $cache->touches); + } + + public function testFindCachedRefetchesExpiredCachedDocuments(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache, new TtlMemoryAdapter()); + $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); + + $database->createDocument('projects', new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + 'expiresAt' => '2999-01-01T00:00:00.000+00:00', + ])); + $this->seedProject($database, 'second', 'Second'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name'), Query::limit(1)], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getCachedFindKeys( + 'projects', + [Query::orderAsc('name'), Query::limit(1)], + collection: $database->getCollection('projects') + )); + $cache->setCachedDocumentAttribute($findKey, $findField, 'first', 'expiresAt', '2000-01-01T00:00:00.000+00:00'); + $database->getAuthorization()->skip(fn () => $database->updateDocument('projects', 'first', new Document(['name' => 'Zulu']))); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name'), Query::limit(1)], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('second', $documents[0]->getId()); + } + + public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void + { + $cache = new TouchSpyCache(); + $database = $this->createDatabase($cache, new TtlMemoryAdapter()); + $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); + + $database->createDocument('projects', new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + 'expiresAt' => '2999-01-01T00:00:00.000+00:00', + ])); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); + $this->assertCount(1, $documents); + $this->assertSame(0, $cache->touches); + + [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getCachedFindKeys( + 'projects', + [Query::orderAsc('name')], + collection: $database->getCollection('projects') + )); + $cache->setCachedDocumentAttribute($findKey, $findField, 'first', 'expiresAt', '2000-01-01T00:00:00.000+00:00'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); + $this->assertCount(1, $documents); + $this->assertSame(0, $cache->touches); + } +} + +class HashMemoryCache implements Adapter +{ + /** + * @var array|string}>> + */ + private array $store = []; + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $hash = $hash === '' ? $key : $hash; + $saved = $this->store[$key][$hash] ?? null; + if ($saved === null) { + return false; + } + + return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + if ($key === '' || empty($data)) { + return false; + } + + $hash = $hash === '' ? $key : $hash; + $this->store[$key][$hash] = [ + 'time' => \time(), + 'data' => $data, + ]; + + return $data; + } + + public function touch(string $key, string $hash = ''): bool + { + $hash = $hash === '' ? $key : $hash; + if (!isset($this->store[$key][$hash])) { + return false; + } + + $this->store[$key][$hash]['time'] = \time(); + + return true; + } + + public function setCachedDocumentAttribute(string $key, string $hash, string $documentId, string $attribute, mixed $value): void + { + $payload = $this->store[$key][$hash]['data'] ?? []; + $documents = \is_array($payload) && \is_array($payload['documents'] ?? null) ? $payload['documents'] : $payload; + if (!\is_array($documents)) { + return; + } + + foreach ($documents as $index => $document) { + if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { + continue; + } + + $documents[$index][$attribute] = $value; + if (\is_array($payload) && \array_key_exists('documents', $payload)) { + $payload['documents'] = $documents; + $this->store[$key][$hash]['data'] = $payload; + } else { + $this->store[$key][$hash]['data'] = $documents; + } + return; + } + } + + /** + * @return array + */ + public function list(string $key): array + { + return \array_keys($this->store[$key] ?? []); + } + + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '') { + unset($this->store[$key][$hash]); + return true; + } + + unset($this->store[$key]); + + return true; + } + + public function flush(): bool + { + $this->store = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->store); + } + + public function getName(?string $key = null): string + { + return 'hash-memory'; + } +} + +class TouchSpyCache extends HashMemoryCache +{ + public int $touches = 0; + + public function touch(string $key, string $hash = ''): bool + { + $touched = parent::touch($key, $hash); + + if ($touched) { + $this->touches++; + } + + return $touched; + } +} + +class TtlMemoryAdapter extends DatabaseMemory +{ + public function getSupportForTTLIndexes(): bool + { + return true; + } +} From e25a43478aeec73c0ab6e9cbdad5c9f2affe8c6c Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 18:04:22 +0100 Subject: [PATCH 2/4] Store cached finds as separate keys --- src/Database/Database.php | 52 +++++++++++++++++++++--------------- tests/unit/FindCacheTest.php | 32 ++++++++++++++++++---- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 3c621cf8ee..6b2a58c603 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8360,9 +8360,13 @@ public function purgeCachedCollection(string $collectionId): bool public function purgeCachedFinds(string $collectionId): bool { - [$findKey] = $this->getCachedFindKeys($collectionId); + [$findIndexKey] = $this->getCachedFindKeys($collectionId); - return $this->cache->purge($findKey); + foreach ($this->cache->list($findIndexKey) as $findKey) { + $this->cache->purge($findKey); + } + + return $this->cache->purge($findIndexKey); } /** @@ -8371,9 +8375,11 @@ public function purgeCachedFinds(string $collectionId): bool public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = []): bool { $collection = $this->silent(fn () => $this->getCollection($collectionId)); - [$findKey, $findField] = $this->authorization->skip(fn () => $this->getCachedFindKeys($collectionId, $queries, $key, $collection)); + [$findIndexKey, $findKey] = $this->authorization->skip(fn () => $this->getCachedFindKeys($collectionId, $queries, $key, $collection)); - return $this->cache->purge($findKey, $findField); + $this->cache->purge($findIndexKey, $findKey); + + return $this->cache->purge($findKey); } /** @@ -8636,23 +8642,23 @@ public function findCached(string $collection, array $queries = [], int $ttl = s throw new NotFoundException('Collection not found'); } - [$findKey, $findField] = $this->getCachedFindKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); - $cached = $this->loadCachedFind($findKey, $findField, $ttl); + [$findIndexKey, $findKey] = $this->getCachedFindKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); + $cached = $this->loadCachedFind($findKey, $ttl); if (!\is_array($cached)) { - return $this->findAndCache($collectionDocument, $queries, $forPermission, $findKey, $findField); + return $this->findAndCache($collectionDocument, $queries, $forPermission, $findIndexKey, $findKey); } [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached); if ($hasExpiredDocuments) { - $this->purgeCachedFindField($findKey, $findField); + $this->purgeCachedFindKey($findIndexKey, $findKey); - return $this->findAndCache($collectionDocument, $queries, $forPermission, $findKey, $findField); + return $this->findAndCache($collectionDocument, $queries, $forPermission, $findIndexKey, $findKey); } if ($touchOnHit) { - $this->touchCachedFind($findKey, $findField); + $this->touchCachedFind($findKey); } $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); @@ -8668,18 +8674,18 @@ public function findCached(string $collection, array $queries = [], int $ttl = s * @throws TimeoutException * @throws Exception */ - private function findAndCache(Document $collection, array $queries, string $forPermission, string $findKey, string $findField): array + private function findAndCache(Document $collection, array $queries, string $forPermission, string $findIndexKey, string $findKey): array { $documents = $this->find($collection->getId(), $queries, $forPermission); - $this->saveCachedFind($findKey, $findField, $documents); + $this->saveCachedFind($findIndexKey, $findKey, $documents); return $documents; } - private function loadCachedFind(string $findKey, string $findField, int $ttl): mixed + private function loadCachedFind(string $findKey, int $ttl): mixed { try { - return $this->cache->load($findKey, $ttl, $findField); + return $this->cache->load($findKey, $ttl); } catch (Exception $e) { Console::warning('Failed to get find result from cache: ' . $e->getMessage()); @@ -8690,34 +8696,36 @@ private function loadCachedFind(string $findKey, string $findField, int $ttl): m /** * @param array $documents */ - private function saveCachedFind(string $findKey, string $findField, array $documents): void + private function saveCachedFind(string $findIndexKey, string $findKey, array $documents): void { try { + $this->cache->save($findIndexKey, $findKey, $findKey); $this->cache->save($findKey, [ 'version' => 1, 'documents' => \array_map( static fn (Document $document): array => $document->getArrayCopy(), $documents ), - ], $findField); + ]); } catch (Exception $e) { Console::warning('Failed to save find result to cache: ' . $e->getMessage()); } } - private function touchCachedFind(string $findKey, string $findField): void + private function touchCachedFind(string $findKey): void { try { - $this->cache->touch($findKey, $findField); + $this->cache->touch($findKey); } catch (Exception $e) { Console::warning('Failed to touch find result cache: ' . $e->getMessage()); } } - private function purgeCachedFindField(string $findKey, string $findField): void + private function purgeCachedFindKey(string $findIndexKey, string $findKey): void { try { - $this->cache->purge($findKey, $findField); + $this->cache->purge($findIndexKey, $findKey); + $this->cache->purge($findKey); } catch (Exception $e) { Console::warning('Failed to purge expired find result cache: ' . $e->getMessage()); } @@ -9657,7 +9665,7 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a public function getCachedFindKeys(string $collectionId, array $queries = [], ?string $key = null, ?Document $collection = null): array { [$collectionKey] = $this->getCacheKeys($collectionId); - $findKey = "{$collectionKey}:find"; + $findIndexKey = "{$collectionKey}:find"; $payload = [ 'version' => 1, @@ -9672,7 +9680,7 @@ public function getCachedFindKeys(string $collectionId, array $queries = [], ?st 'filters' => $this->getActiveFilterSignatures(), ]; - return [$findKey, \md5(\json_encode($payload) ?: '')]; + return [$findIndexKey, $findIndexKey . ':' . \md5(\json_encode($payload) ?: '')]; } /** diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index 2143b28685..7c2df8b31e 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -4,7 +4,6 @@ use PHPUnit\Framework\TestCase; use Utopia\Cache\Adapter; -use Utopia\Cache\Adapter\Memory as CacheMemory; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; use Utopia\Database\Database; @@ -20,7 +19,7 @@ class FindCacheTest extends TestCase protected function setUp(): void { - $this->database = $this->createDatabase(new CacheMemory()); + $this->database = $this->createDatabase(new HashMemoryCache()); } private function createDatabase(Adapter $cache, ?DatabaseMemory $adapter = null): Database @@ -189,6 +188,26 @@ public function testPurgeCachedFindRemovesOnlyOneCallerKey(): void $this->assertCount(1, $documents); } + public function testPurgeCachedFindsRemovesAllCallerKeys(): void + { + $database = $this->createDatabase(new HashMemoryCache()); + + $this->seedProject($database, 'first', 'First'); + + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); + + $this->seedProject($database, 'second', 'Second'); + + $database->purgeCachedFinds('projects'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); + $this->assertCount(2, $documents); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); + $this->assertCount(2, $documents); + } + public function testFindCachedTouchesCacheEntryOnHitWhenEnabled(): void { $cache = new TouchSpyCache(); @@ -326,7 +345,10 @@ public function touch(string $key, string $hash = ''): bool public function setCachedDocumentAttribute(string $key, string $hash, string $documentId, string $attribute, mixed $value): void { - $payload = $this->store[$key][$hash]['data'] ?? []; + $usesEntryKey = isset($this->store[$hash][$hash]); + $cacheKey = $usesEntryKey ? $hash : $key; + $cacheHash = $hash; + $payload = $this->store[$cacheKey][$cacheHash]['data'] ?? []; $documents = \is_array($payload) && \is_array($payload['documents'] ?? null) ? $payload['documents'] : $payload; if (!\is_array($documents)) { return; @@ -340,9 +362,9 @@ public function setCachedDocumentAttribute(string $key, string $hash, string $do $documents[$index][$attribute] = $value; if (\is_array($payload) && \array_key_exists('documents', $payload)) { $payload['documents'] = $documents; - $this->store[$key][$hash]['data'] = $payload; + $this->store[$cacheKey][$cacheHash]['data'] = $payload; } else { - $this->store[$key][$hash]['data'] = $documents; + $this->store[$cacheKey][$cacheHash]['data'] = $documents; } return; } From 50c6b39e6fd2496db6689950e6053fef71cb35f2 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 18:34:28 +0100 Subject: [PATCH 3/4] Use readable caller find cache keys --- src/Database/Database.php | 4 ++++ tests/unit/CacheKeyTest.php | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 6b2a58c603..c4549cc607 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9680,6 +9680,10 @@ public function getCachedFindKeys(string $collectionId, array $queries = [], ?st 'filters' => $this->getActiveFilterSignatures(), ]; + if ($key !== null) { + return [$findIndexKey, $collectionKey . ':' . $key]; + } + return [$findIndexKey, $findIndexKey . ':' . \md5(\json_encode($payload) ?: '')]; } diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 67bb82dafe..06ddec5355 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -154,6 +154,16 @@ public function testCallerFindCacheKeyIgnoresQueryVariation(): void $this->assertEquals($fieldA, $fieldB); } + public function testCallerFindCacheKeyUsesReadableEntryKey(): void + { + $db = $this->createDatabase(); + + [$indexKey, $entryKey] = $db->getCachedFindKeys('wafrules', key: 'rules_v1'); + + $this->assertEquals('default-cache-:test::collection:wafrules:find', $indexKey); + $this->assertEquals('default-cache-:test::collection:wafrules:rules_v1', $entryKey); + } + public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void { $dbPlatform = $this->createDatabase(database: 'platform'); From ebcf777a5e6d15acc436d2aeaf34eb6168dc6e83 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 20:23:35 +0100 Subject: [PATCH 4/4] Add list cache key helper --- src/Database/Database.php | 24 +++++++++++++++++++ tests/unit/CacheKeyTest.php | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index c4549cc607..960f04f719 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9655,6 +9655,30 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ]; } + /** + * Stable cache key for cached list entries on a collection. + * + * @param string $collectionId + * @param string|null $namespace + * @param int|string|null $tenant + * @return string + */ + public function getListCacheKey(string $collectionId, ?string $namespace = null, int|string|null $tenant = null): string + { + $hostname = $this->adapter->getSupportForHostname() + ? $this->adapter->getHostname() + : ''; + + return \sprintf( + '%s-cache:%s:%s:%s:collection:%s', + $this->cacheName, + $hostname, + $namespace ?? $this->getNamespace(), + $tenant ?? $this->adapter->getTenant(), + $collectionId, + ); + } + /** * @param string $collectionId * @param array $queries diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 06ddec5355..7d1cbc3e34 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -164,6 +164,54 @@ public function testCallerFindCacheKeyUsesReadableEntryKey(): void $this->assertEquals('default-cache-:test::collection:wafrules:rules_v1', $entryKey); } + public function testCollectionCacheKeyUsesListCacheShape(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getSupportForHostname')->willReturn(true); + $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter->method('getNamespace')->willReturn('_39'); + $adapter->method('getTenant')->willReturn(null); + + $db = new Database($adapter, new Cache(new None()), []); + + $this->assertSame( + 'default-cache:mysql-console:_39::collection:ttl_cache_table', + $db->getListCacheKey('ttl_cache_table'), + ); + } + + public function testCollectionCacheKeyCanOverrideNamespaceSegment(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getSupportForHostname')->willReturn(true); + $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getTenant')->willReturn(null); + + $db = new Database($adapter, new Cache(new None()), []); + + $this->assertSame( + 'default-cache:mysql-console:_39::collection:wafrules', + $db->getListCacheKey('wafrules', '_39'), + ); + } + + public function testCollectionCacheKeyCanOverrideTenantSegment(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getSupportForHostname')->willReturn(true); + $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter->method('getNamespace')->willReturn('_39'); + $adapter->method('getTenant')->willReturn(null); + + $db = new Database($adapter, new Cache(new None()), []); + + $this->assertSame( + 'default-cache:mysql-console:_39:tenant-a:collection:wafrules', + $db->getListCacheKey('wafrules', tenant: 'tenant-a'), + ); + } + public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void { $dbPlatform = $this->createDatabase(database: 'platform');