From 6e20ffd9e157d6f367107302fad1aabf6a559082 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 15 May 2026 14:01:21 +0100 Subject: [PATCH] perf(database): add in-process cache for getCollection metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every find/getDocument/cascade currently hits Redis via getDocument(METADATA, $id) for the same collection metadata, often 20-50 times per request through relationship resolution and internal helpers. This adds a per-instance cache keyed on (database, namespace, tenant, id) that stores the array form so callers can mutate the returned Document without poisoning the cache. The recursive arrayifyDocuments helper ensures nested attribute/index Documents are also fully isolated. Invalidation rides the existing EVENT_DOCUMENT_PURGE plumbing — every metadata mutation in the library already fires this event, and cloud's cross-region propagation listener (cloud/app/controllers/general.php) re-fires it on receiving regions, so the new in-process invalidator stays in sync without consumer code changes. Full cache wipes are added to the context-changing setters (setNamespace, setDatabase, setSharedTables, setTenant) and delete() since those alter the cache key shape or invalidate the underlying data. --- src/Database/Database.php | 111 +++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 0e8c7a74f..736184aa8 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -398,6 +398,18 @@ class Database */ protected ?array $silentListeners = []; + /** + * In-process cache of collection metadata, keyed by + * "database:namespace:tenant:collectionId". Stores the raw array form so + * callers always get a fresh Document and cannot corrupt the cache. + * Invalidated via the EVENT_DOCUMENT_PURGE listener registered in the + * constructor, and fully cleared on context changes (setNamespace, + * setDatabase, setSharedTables, setTenant, delete). + * + * @var array> + */ + protected array $collectionCache = []; + protected ?\DateTime $timestamp = null; protected bool $resolveRelationships = true; @@ -701,6 +713,21 @@ function (mixed $value) { return is_array($decoded) ? $decoded : $value; } ); + + // Keep the in-process collection cache in sync with the existing + // EVENT_DOCUMENT_PURGE plumbing. Every metadata mutation already + // fires this event with $collection=METADATA; cross-region purges + // (cloud) also flow through here, so this listener stays correct + // without changes to consumers. + $this->on( + self::EVENT_DOCUMENT_PURGE, + 'collection-cache-invalidator', + function (string $event, Document $payload): void { + if ($payload->getCollection() === self::METADATA) { + $this->invalidateCollectionCache($payload->getId()); + } + } + ); } /** @@ -930,6 +957,7 @@ public function withRequestTimestamp(?\DateTime $requestTimestamp, callable $cal public function setNamespace(string $namespace): static { $this->adapter->setNamespace($namespace); + $this->clearCollectionCache(); return $this; } @@ -969,6 +997,7 @@ public function getIdAttributeType(): string public function setDatabase(string $name): static { $this->adapter->setDatabase($name); + $this->clearCollectionCache(); return $this; } @@ -1253,6 +1282,7 @@ public function getSharedTables(): bool public function setSharedTables(bool $sharedTables): static { $this->adapter->setSharedTables($sharedTables); + $this->clearCollectionCache(); return $this; } @@ -1268,6 +1298,7 @@ public function setSharedTables(bool $sharedTables): static public function setTenant(int|string|null $tenant): static { $this->adapter->setTenant($tenant); + $this->clearCollectionCache(); return $this; } @@ -1664,6 +1695,7 @@ public function delete(?string $database = null): bool } $this->cache->flush(); + $this->clearCollectionCache(); return $deleted; } @@ -1923,6 +1955,11 @@ public function updateCollection(string $id, array $permissions, bool $documentS /** * Get Collection * + * Reads collection metadata. Cached in-process per (database, namespace, + * tenant, id) tuple to avoid redundant Redis lookups on hot paths like + * find()/cascade — a single request often touches the same collection + * 20-50× via relationship resolution and internal helpers. + * * @param string $id * * @return Document @@ -1930,7 +1967,20 @@ public function updateCollection(string $id, array $permissions, bool $documentS */ public function getCollection(string $id): Document { - $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); + $cacheKey = $this->collectionCacheKey($id); + + if (isset($this->collectionCache[$cacheKey])) { + // Rebuild a fresh Document on every hit so callers can mutate the + // returned collection (setAttribute on attributes/indexes, etc.) + // without poisoning the cache. + $collection = new Document($this->collectionCache[$cacheKey]); + } else { + $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); + + if (!$collection->isEmpty()) { + $this->collectionCache[$cacheKey] = $this->arrayifyDocuments($collection->getArrayCopy()); + } + } if ( $id !== self::METADATA @@ -1950,6 +2000,65 @@ public function getCollection(string $id): Document return $collection; } + /** + * Build the per-instance cache key for a collection id. + * + * @param string $id + * @return string + */ + private function collectionCacheKey(string $id): string + { + return $this->adapter->getDatabase() + . ':' . $this->adapter->getNamespace() + . ':' . ($this->adapter->getTenant() ?? '') + . ':' . $id; + } + + /** + * Drop a single collection from the in-process cache. + * Invoked by the EVENT_DOCUMENT_PURGE listener registered in __construct. + * + * @param string $id + * @return void + */ + private function invalidateCollectionCache(string $id): void + { + unset($this->collectionCache[$this->collectionCacheKey($id)]); + } + + /** + * Wipe the in-process cache entirely. Called from context-changing + * setters (namespace/database/sharedTables/tenant) since those alter the + * cache key shape, and from delete() since the underlying data is gone. + * + * @return void + */ + private function clearCollectionCache(): void + { + $this->collectionCache = []; + } + + /** + * Recursively convert nested Document instances to plain arrays so the + * cache stores fully isolated data. A caller mutating the returned + * Document (or any nested attribute/index Document) cannot reach back + * into the cache. + * + * @param array $arr + * @return array + */ + private function arrayifyDocuments(array $arr): array + { + foreach ($arr as $k => $v) { + if ($v instanceof Document) { + $arr[$k] = $this->arrayifyDocuments($v->getArrayCopy()); + } elseif (\is_array($v)) { + $arr[$k] = $this->arrayifyDocuments($v); + } + } + return $arr; + } + /** * List Collections *