From d7b61e6afc7907f6fdaeed49e3fa907d011152a2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 1 Apr 2026 23:24:46 +1300 Subject: [PATCH] (fix): move collection mapping query from decorator to getDatabasesDB with static cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decorator no longer queries dbForProject directly. Instead, getDatabasesDB pre-populates the mapping via setCollectionId() and uses a static cache (Metadata::getCachedMap) so the query runs at most ONCE per database per Swoole worker process. The decorator is now stateless — no database dependencies, just a pre-set map. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/init/resources.php | 41 +++++++-- .../Utopia/Database/Hooks/Metadata.php | 83 +++++-------------- 2 files changed, 56 insertions(+), 68 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index fd3225e9d8..f07dcfcce4 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -769,6 +769,40 @@ Http::setResource('getDatabasesDB', function (Group $pools, Database $dbForProje $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric; } + $metadata = new Metadata( + database: $originalDatabase, + context: $context, + ); + + // Pre-populate collection ID mapping from static cache or single query + $databaseSequence = $originalDatabase->getSequence(); + $cachedMap = Metadata::getCachedMap($databaseSequence); + if ($cachedMap !== null) { + foreach ($cachedMap as $k => $v) { + $metadata->setCollectionId($k, $v); + } + } else { + try { + $dbPrefix = 'database_' . $databaseSequence; + $collections = $authorization->skip( + fn () => $dbForProject->silent( + fn () => $dbForProject->find($dbPrefix, [ + \Utopia\Database\Query::limit(5000), + ]) + ) + ); + foreach ($collections as $col) { + $seq = $col->getSequence(); + if ($seq !== null) { + $metadata->setCollectionId('collection_' . $seq, $col->getId()); + $metadata->setCollectionId($dbPrefix . '_collection_' . $seq, $col->getId()); + } + } + } catch (\Throwable) { + // Database may not have collections yet + } + } + $database ->addHook(new DocumentUsage( $usage, @@ -778,12 +812,7 @@ Http::setResource('getDatabasesDB', function (Group $pools, Database $dbForProje )) ->addHook(new Permissions()) ->addHook(new Relationships($database)) - ->addHook(new Metadata( - database: $originalDatabase, - dbForProject: $dbForProject, - authorization: $authorization, - context: $context, - )); + ->addHook($metadata); if ($database->getSharedTables() && ($database->getTenant() !== null)) { $database->addHook(new Tenancy($database->getTenant())); diff --git a/src/Appwrite/Utopia/Database/Hooks/Metadata.php b/src/Appwrite/Utopia/Database/Hooks/Metadata.php index 5e4b735545..c8f869c8db 100644 --- a/src/Appwrite/Utopia/Database/Hooks/Metadata.php +++ b/src/Appwrite/Utopia/Database/Hooks/Metadata.php @@ -6,50 +6,56 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; use Utopia\Database\Hook\Decorator; -use Utopia\Database\Validator\Authorization; use Utopia\Query\Schema\ColumnType; /** * Stamps database/collection metadata onto every document returned from the database, * and recursively decorates nested relationship documents. * - * Lazily loads the collection mapping from dbForProject on first use to resolve - * internal collection names (database_N_collection_M) to user-facing collection IDs. + * Collection ID mapping is pre-populated by getDatabasesDB via setCollectionId() + * and cached statically per database sequence for the Swoole worker lifetime. */ class Metadata implements Decorator { /** @var array> */ private array $relationshipCache = []; - /** @var array> static cache keyed by database sequence */ - private static array $collectionIdMaps = []; - - /** @var bool whether this instance has initialized its map reference */ - private bool $mapInitialized = false; - - /** @var array reference to the map for this database */ + /** @var array internal collection name -> user-facing collection ID */ private array $collectionIdMap = []; + /** @var array> static cache keyed by database sequence */ + private static array $staticMaps = []; + private int $operations = 0; public function __construct( private Document $database, - private Database $dbForProject, - private Authorization $authorization, private string $context = 'collection', ) { } /** * Register a mapping from internal collection name to user-facing collection ID. + * Also updates the static cache for subsequent requests. */ public function setCollectionId(string $internalName, string $externalId): void { $this->collectionIdMap[$internalName] = $externalId; - $databaseSequence = $this->database->getSequence(); - if (isset(self::$collectionIdMaps[$databaseSequence])) { - self::$collectionIdMaps[$databaseSequence][$internalName] = $externalId; + $seq = $this->database->getSequence(); + self::$staticMaps[$seq][$internalName] = $externalId; + } + + /** + * Get the cached map for a database sequence, or null if not cached. + * + * @return array|null + */ + public static function getCachedMap(?string $sequence): ?array + { + if ($sequence === null) { + return null; } + return self::$staticMaps[$sequence] ?? null; } public function decorate(Event $event, Document $collection, Document $document): Document @@ -60,8 +66,6 @@ class Metadata implements Decorator $this->operations++; - $this->ensureMapLoaded(); - $collectionId = $this->collectionIdMap[$collection->getId()] ?? $collection->getId(); $document->setAttribute('$databaseId', $this->database->getId()); $document->setAttribute('$' . $this->context . 'Id', $collectionId); @@ -81,51 +85,6 @@ class Metadata implements Decorator $this->operations = 0; } - /** - * Lazily load collection mapping from dbForProject. - * Queries are wrapped in authorization->skip() and dbForProject->silent() - * to prevent lifecycle hooks from firing and avoid permission checks. - */ - private function ensureMapLoaded(): void - { - if ($this->mapInitialized) { - return; - } - - $this->mapInitialized = true; - $databaseSequence = $this->database->getSequence(); - - if (isset(self::$collectionIdMaps[$databaseSequence])) { - $this->collectionIdMap = self::$collectionIdMaps[$databaseSequence]; - return; - } - - $metadataCollection = 'database_' . $databaseSequence; - - try { - $collections = $this->authorization->skip( - fn () => $this->dbForProject->silent( - fn () => $this->dbForProject->find($metadataCollection) - ) - ); - - foreach ($collections as $collection) { - $externalId = $collection->getId(); - $sequence = $collection->getSequence(); - - $relativeKey = 'collection_' . $sequence; - $fullKey = 'database_' . $databaseSequence . '_collection_' . $sequence; - - $this->collectionIdMap[$relativeKey] = $externalId; - $this->collectionIdMap[$fullKey] = $externalId; - } - } catch (\Throwable) { - // Silently fail — fall back to internal names - } - - self::$collectionIdMaps[$databaseSequence] = $this->collectionIdMap; - } - private function decorateRelationships(Document $collection, Document $document, int $depth = 0): void { if ($depth >= Database::RELATION_MAX_DEPTH) {