(fix): replace Metadata decorator with processDocument() in endpoint actions

The Metadata decorator required a collection ID mapping query that added
overhead and caused Redis OOM in CI. Instead, stamp $databaseId and
$collectionId/$tableId directly in endpoint actions where user-facing
IDs are available from request parameters — matching the 1.9.x approach.

- Remove Metadata decorator from getDatabasesDB hooks
- Restore processDocument() on Documents/Action base class
- Add processDocument() calls in Get, Create, XList, Update, Upsert
- Simplify Metadata.php to stateless map-only decorator (no queries)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-04-01 19:58:07 +13:00
co-authored by Claude Opus 4.6
parent 33b971a2c1
commit 1eb9c386f6
7 changed files with 164 additions and 42 deletions
+1 -36
View File
@@ -30,7 +30,6 @@ use Appwrite\Usage\Context as UsageContext;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Database\Hooks\DocumentUsage;
use Appwrite\Utopia\Database\Hooks\FunctionCache;
use Appwrite\Utopia\Database\Hooks\Metadata;
use Appwrite\Utopia\Database\Hooks\Usage;
use Appwrite\Utopia\Database\Hooks\UserEvents;
use Appwrite\Utopia\Request;
@@ -769,39 +768,6 @@ Http::setResource('getDatabasesDB', function (Group $pools, Database $dbForProje
$databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric;
}
// Pre-compute collection ID mapping (internal name → user-facing ID)
$collectionIdMap = [];
try {
$databaseKey = 'database_' . $originalDatabase->getSequence();
$dbPrefix = $databaseKey . '_';
$collections = $authorization->skip(
fn () => $dbForProject->silent(
fn () => $dbForProject->find($databaseKey, [
\Utopia\Database\Query::select(['$id', '$sequence']),
\Utopia\Database\Query::limit(5000),
])
)
);
foreach ($collections as $col) {
$seq = $col->getSequence();
if ($seq !== null) {
$key = 'collection_' . $seq;
$collectionIdMap[$key] = $col->getId();
$collectionIdMap[$dbPrefix . $key] = $col->getId();
}
}
} catch (\Throwable) {
// Database may not have collections yet
}
$metadata = new Metadata(
database: $originalDatabase,
context: $context,
);
foreach ($collectionIdMap as $internal => $external) {
$metadata->setCollectionId($internal, $external);
}
$database
->addHook(new DocumentUsage(
$usage,
@@ -810,8 +776,7 @@ Http::setResource('getDatabasesDB', function (Group $pools, Database $dbForProje
$databaseIdCollectionIdDocumentsMetric,
))
->addHook(new Permissions())
->addHook(new Relationships($database))
->addHook($metadata);
->addHook(new Relationships($database));
if ($database->getSharedTables() && ($database->getTenant() !== null)) {
$database->addHook(new Tenancy($database->getTenant()));
@@ -401,4 +401,103 @@ abstract class Action extends DatabasesAction
$queueForFunctions->reset();
$queueForWebhooks->reset();
}
/**
* Stamp database/collection metadata onto a document and recursively
* process relationship documents. Called from endpoint actions where
* the user-facing collection ID is available.
*
* @param array<string, array<Document>> $collectionsCache
*/
protected function processDocument(
Document $database,
Document $collection,
Document $document,
Database $dbForProject,
array &$collectionsCache,
\Utopia\Database\Validator\Authorization $authorization,
?int &$operations = null,
int $depth = 0,
): bool {
if ($operations !== null && $document->isEmpty()) {
return false;
}
if ($operations !== null) {
$operations++;
}
$collectionId = $collection->getId();
$document->removeAttribute('$collection');
$document->setAttribute('$databaseId', $database->getId());
$document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collectionId);
if ($depth >= Database::RELATION_MAX_DEPTH) {
return true;
}
$relationships = $collectionsCache[$collectionId] ??= \array_filter(
$collection->getAttribute('attributes', []),
fn ($attr) => $attr->getAttribute('type') === \Utopia\Query\Schema\ColumnType::Relationship->value
);
foreach ($relationships as $relationship) {
$key = $relationship->getAttribute('key');
$related = $document->getAttribute($key);
if (empty($related)) {
if (\in_array(\gettype($related), ['array', 'object']) && $operations !== null) {
$operations++;
}
continue;
}
$relations = \is_array($related) ? $related : [$related];
$options = $relationship->getAttribute('options', []);
$relatedCollectionId = (\is_array($options) ? ($options['relatedCollection'] ?? null) : null)
?? $relationship->getAttribute('relatedCollection');
if (!isset($collectionsCache[$relatedCollectionId])) {
$relatedCollectionDoc = $authorization->skip(
fn () => $dbForProject->getDocument(
'database_' . $database->getSequence(),
$relatedCollectionId
)
);
$collectionsCache[$relatedCollectionId] = \array_filter(
$relatedCollectionDoc->getAttribute('attributes', []),
fn ($attr) => $attr->getAttribute('type') === \Utopia\Query\Schema\ColumnType::Relationship->value
);
}
foreach ($relations as $relation) {
if ($relation instanceof Document) {
$relatedCollection = new Document([
'$id' => $relatedCollectionId,
'attributes' => $collectionsCache[$relatedCollectionId],
]);
$this->processDocument(
database: $database,
collection: $relatedCollection,
document: $relation,
dbForProject: $dbForProject,
collectionsCache: $collectionsCache,
authorization: $authorization,
operations: $operations,
depth: $depth + 1
);
}
}
if (\is_array($related)) {
$document->setAttribute($relationship->getAttribute('key'), \array_values($relations));
} elseif (empty($relations)) {
$document->setAttribute($relationship->getAttribute('key'), null);
}
}
return true;
}
}
@@ -389,6 +389,18 @@ class Create extends Action
->setParam('tableId', $collection->getId())
->setContext($this->getCollectionsEventsContext(), $collection);
$collectionsCache = [];
foreach ($created as $document) {
$this->processDocument(
database: $database,
collection: $collection,
document: $document,
dbForProject: $dbForProject,
collectionsCache: $collectionsCache,
authorization: $authorization
);
}
$usage
->addMetric($this->getDatabasesOperationWriteMetric(), 1)
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); // per collection
@@ -122,11 +122,23 @@ class Get extends Action
throw new Exception($this->getNotFoundException(), params: [$documentId]);
}
$usage
->addMetric($this->getDatabasesOperationReadMetric(), 1)
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), 1);
$operations = 0;
$collectionsCache = [];
$this->processDocument(
database: $database,
collection: $collection,
document: $document,
dbForProject: $dbForProject,
collectionsCache: $collectionsCache,
authorization: $authorization,
operations: $operations
);
$response->addHeader('X-Debug-Operations', 1);
$usage
->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations);
$response->addHeader('X-Debug-Operations', $operations);
$response->dynamic($document, $this->getResponseModel());
}
@@ -262,6 +262,16 @@ class Update extends Action
throw new Exception($this->getStructureException(), $e->getMessage());
}
$collectionsCache = [];
$this->processDocument(
database: $database,
collection: $collection,
document: $document,
dbForProject: $dbForProject,
collectionsCache: $collectionsCache,
authorization: $authorization,
);
$usage
->addMetric($this->getDatabasesOperationWriteMetric(), 1)
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1);
@@ -283,6 +283,16 @@ class Upsert extends Action
$document = $upserted[0];
$collectionsCache = [];
$this->processDocument(
database: $database,
collection: $collection,
document: $document,
dbForProject: $dbForProject,
collectionsCache: $collectionsCache,
authorization: $authorization
);
$usage
->addMetric($this->getDatabasesOperationWriteMetric(), 1)
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1);
@@ -218,9 +218,23 @@ class XList extends Action
throw new Exception(Exception::DATABASE_TIMEOUT);
}
$operations = 0;
$collectionsCache = [];
foreach ($documents as $document) {
$this->processDocument(
database: $database,
collection: $collection,
document: $document,
dbForProject: $dbForProject,
collectionsCache: $collectionsCache,
authorization: $authorization,
operations: $operations
);
}
$usage
->addMetric($this->getDatabasesOperationReadMetric(), 1)
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), 1);
->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1))
->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations);
$response->dynamic(new Document([
'total' => $total,