Skip to content

Commit 39851ac

Browse files
abnegateclaude
andcommitted
perf(collections): per-instance memo cache for getCollection
Every find/getDocument call hits Database::getCollection, which in turn does getDocument(METADATA, id). With a no-op cache adapter that's a SQL round trip per call — the dominant non-PDO cost on read-heavy workloads. Add a small per-Database memo of resolved collection metadata, keyed by database/namespace/tenant/id so multi-tenant and shared-table setups stay isolated. Cap at 256 entries with a hard flush on overflow to keep long-lived workers bounded. Hand callers a clone of the cached Document — createIndex, updateAttribute, deleteIndex, etc. mutate the returned Document in place during normal operation, so a shared reference would let those mutations leak between calls and corrupt subsequent reads. Invalidation is wired into purgeCachedCollection, purgeCachedDocumentInternal (METADATA writes), and Database::delete to keep the memo coherent with every existing schema-mutation path. On the focus bench complex_query_nested drops a further 17% (1794 ms vs 2150 ms) and order_cursor 6% (3661 ms vs 3900 ms) relative to the previous round. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0367c6 commit 39851ac

4 files changed

Lines changed: 70 additions & 0 deletions

File tree

src/Database/Database.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,25 @@ class Database
312312
*/
313313
protected bool $eventsSilenced = false;
314314

315+
/**
316+
* Per-instance memoisation of getCollection() results. Avoids the
317+
* METADATA SELECT that would otherwise repeat for every find/getDocument
318+
* call when the user-provided cache is the No-op adapter.
319+
*
320+
* Keyed by "{database}::{namespace}::{tenant}::{id}" so multi-tenant /
321+
* multi-database/multi-namespace contexts cannot leak across each other.
322+
* Entries are invalidated by purgeCachedCollection() and by any code path
323+
* that reads or writes the METADATA collection itself (createCollection,
324+
* createAttribute, createIndex, deleteAttribute, deleteIndex, etc.) so
325+
* the cached metadata never goes stale relative to actual schema.
326+
*
327+
* @var array<string, Document>
328+
*/
329+
protected array $collectionMetadataCache = [];
330+
331+
/** Soft cap to prevent unbounded growth in long-lived workers. */
332+
protected const COLLECTION_METADATA_CACHE_LIMIT = 256;
333+
315334
protected ?NativeDateTime $timestamp = null;
316335

317336
protected ?Relationships $relationshipHook = null;

src/Database/Traits/Collections.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,24 @@ public function updateCollection(string $id, array $permissions, bool $documentS
274274
*/
275275
public function getCollection(string $id): Document
276276
{
277+
// Memoise within this Database instance so repeated find/getDocument
278+
// calls don't replay the METADATA SELECT — especially important when
279+
// the user has wired in a no-op cache adapter. The cache key includes
280+
// the active database, namespace and tenant so multi-tenant or
281+
// shared-table setups stay isolated.
282+
$cacheKey = $this->adapter->getDatabase() . '::' . $this->adapter->getNamespace()
283+
. '::' . ($this->adapter->getTenant() ?? '') . '::' . $id;
284+
285+
if (isset($this->collectionMetadataCache[$cacheKey])) {
286+
// Always hand callers an independent copy: createIndex,
287+
// updateAttribute, etc. mutate the returned Document in-place,
288+
// and we cannot allow those mutations to leak between calls.
289+
$cached = clone $this->collectionMetadataCache[$cacheKey];
290+
$this->trigger(Event::CollectionRead, $cached);
291+
292+
return $cached;
293+
}
294+
277295
// Inline silent() to avoid the per-call Closure allocation. The
278296
// outer event suppression scope is preserved via try/finally so any
279297
// ambient silent context still wins.
@@ -294,6 +312,15 @@ public function getCollection(string $id): Document
294312
return new Document();
295313
}
296314

315+
if (! $collection->isEmpty()) {
316+
if (\count($this->collectionMetadataCache) >= self::COLLECTION_METADATA_CACHE_LIMIT) {
317+
$this->collectionMetadataCache = [];
318+
}
319+
// Cache a clone so future hits return independent copies even
320+
// before the first caller is done mutating their handle.
321+
$this->collectionMetadataCache[$cacheKey] = clone $collection;
322+
}
323+
297324
$this->trigger(Event::CollectionRead, $collection);
298325

299326
return $collection;

src/Database/Traits/Databases.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ public function delete(?string $database = null): bool
8787

8888
$this->cache->flush();
8989

90+
// Drop the in-process metadata memo entirely — the entire database
91+
// is gone, so nothing in there is still valid.
92+
$this->collectionMetadataCache = [];
93+
9094
return $deleted;
9195
}
9296
}

src/Database/Traits/Documents.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,6 +2055,14 @@ public function purgeCachedCollection(string $collectionId): bool
20552055

20562056
$this->cache->purge($collectionKey);
20572057

2058+
// Invalidate any in-process metadata memo — schema changed.
2059+
$metadataSuffix = '::'.$collectionId;
2060+
foreach (\array_keys($this->collectionMetadataCache) as $cachedKey) {
2061+
if (\str_ends_with((string) $cachedKey, $metadataSuffix)) {
2062+
unset($this->collectionMetadataCache[$cachedKey]);
2063+
}
2064+
}
2065+
20582066
// Drop every cached validator scoped to this collection id, regardless
20592067
// of the namespace/tenant/maxQueryValues prefix that was active when
20602068
// the entry was built.
@@ -2085,6 +2093,18 @@ protected function purgeCachedDocumentInternal(string $collectionId, ?string $id
20852093
$this->cache->purge($collectionKey, $documentKey);
20862094
$this->cache->purge($documentKey);
20872095

2096+
// When the underlying document is a collection definition (i.e. it
2097+
// lives in the METADATA collection), drop the in-process metadata
2098+
// memo for that collection too — its schema may have shifted.
2099+
if ($collectionId === self::METADATA) {
2100+
$metadataSuffix = '::'.$id;
2101+
foreach (\array_keys($this->collectionMetadataCache) as $cachedKey) {
2102+
if (\str_ends_with((string) $cachedKey, $metadataSuffix)) {
2103+
unset($this->collectionMetadataCache[$cachedKey]);
2104+
}
2105+
}
2106+
}
2107+
20882108
return true;
20892109
}
20902110

0 commit comments

Comments
 (0)