From 3283b0bec0bcdeb41ea42ebf4d30667dfe1c30a5 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:31:17 +0100 Subject: [PATCH] perf: memoize request filter chain and V20 schema lookups A phpspy profile of a production databases worker showed the V20 backwards-compat request filter accounting for ~40% of in-request samples on `databases.listDocuments` traffic. Two compounding causes: 1. `Request::getParams()` re-ran the entire filter chain on every invocation. The framework and app call `getParams()` several times per request (route param binding, `cacheIdentifier()`, action injection, logging), so V20's recursive schema walk executed N times with identical inputs. 2. Inside `V20::getRelatedCollectionKeys`, the `databases/$databaseId` document was fetched at every recursion frame (up to RELATION_MAX_DEPTH = 3), and sibling relationships pointing at the same related collection each did their own `getDocument` call. This commit: - Memoizes the post-filter params on `Request`. The cache is invalidated by `addFilter`, `resetFilters`, and `setRoute`. `Request` is constructed per HTTP request (app/http.php), so the memo is naturally request-scoped. Helps every request filter version, not just V20. - Splits V20's walk into an entry point that resolves the database namespace once and a pure recursive helper. - Caches the collection `attributes` array per `(databaseNamespace, collectionId)` on the filter instance, so shared related collections collapse to one `getDocument` call. Missing or errored lookups are cached as `null` to avoid retry storms. --- src/Appwrite/Utopia/Request.php | 10 ++ src/Appwrite/Utopia/Request/Filters/V20.php | 121 ++++++++++++++------ 2 files changed, 94 insertions(+), 37 deletions(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 32f0fa89a9..66ac4ca932 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -18,6 +18,7 @@ class Request extends UtopiaRequest */ private array $filters = []; private ?Route $route = null; + private ?array $filteredParams = null; public function __construct(SwooleRequest $request) { @@ -32,6 +33,10 @@ class Request extends UtopiaRequest */ public function getParams(): array { + if ($this->filteredParams !== null) { + return $this->filteredParams; + } + $parameters = parent::getParams(); if (!$this->hasFilters() || !$this->hasRoute()) { @@ -49,6 +54,7 @@ class Request extends UtopiaRequest foreach ($this->getFilters() as $filter) { $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -79,6 +85,7 @@ class Request extends UtopiaRequest $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -92,6 +99,7 @@ class Request extends UtopiaRequest public function addFilter(Filter $filter): void { $this->filters[] = $filter; + $this->filteredParams = null; } /** @@ -112,6 +120,7 @@ class Request extends UtopiaRequest public function resetFilters(): void { $this->filters = []; + $this->filteredParams = null; } /** @@ -134,6 +143,7 @@ class Request extends UtopiaRequest public function setRoute(?Route $route): void { $this->route = $route; + $this->filteredParams = null; } /** diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index a290656b6e..6b1da2709a 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -10,6 +10,18 @@ use Utopia\Database\Query; class V20 extends Filter { + /** + * Per-instance (request-scoped) memo of the `attributes` array for a given + * `(databaseNamespace, collectionId)`. Avoids re-fetching the same collection + * document when multiple relationships in the same schema point at it, and + * when `parse()` is re-entered before `Request::getParams()` memoization warms. + * + * A `null` value means we already tried and the collection was missing or errored. + * + * @var array>|null> + */ + private array $collectionAttributesCache = []; + // Convert 1.7 params to 1.8 public function parse(array $content, string $model): array { @@ -106,36 +118,21 @@ class V20 extends Filter * Recursively includes nested relationships up to 3 levels deep. * Prevents infinite loops by tracking all visited collections in the current path. */ - private function getRelatedCollectionKeys( - ?string $databaseId = null, - ?string $collectionId = null, - ?string $prefix = null, - int $depth = 1, - array $visited = [] - ): array { - $databaseId ??= $this->getParamValue('databaseId'); - $collectionId ??= $this->getParamValue('collectionId'); + private function getRelatedCollectionKeys(): array + { + $databaseId = $this->getParamValue('databaseId'); + $collectionId = $this->getParamValue('collectionId'); - if ( - empty($databaseId) || - empty($collectionId) || - $depth > Database::RELATION_MAX_DEPTH - ) { + if (empty($databaseId) || empty($collectionId)) { return []; } - // Check if we've already visited this collection in the current path to prevent cycles - if (in_array($collectionId, $visited)) { - return []; - } - - $visited[] = $collectionId; - $dbForProject = $this->getDbForProject(); if ($dbForProject === null) { return []; } + // Resolve the database namespace once, outside the recursion. try { $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( 'databases', @@ -148,19 +145,42 @@ class V20 extends Filter return []; } - try { - $collection = $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( - 'database_' . $database->getSequence(), - $collectionId - )); - if ($collection->isEmpty()) { - return []; - } - } catch (\Throwable) { + $databaseNamespace = 'database_' . $database->getSequence(); + + return $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, + $collectionId, + null, + 1, + [] + ); + } + + private function walkRelatedCollectionKeys( + Database $dbForProject, + string $databaseNamespace, + string $collectionId, + ?string $prefix, + int $depth, + array $visited + ): array { + if ($depth > Database::RELATION_MAX_DEPTH) { return []; } - $attributes = $collection->getAttribute('attributes', []); + // Check if we've already visited this collection in the current path to prevent cycles + if (in_array($collectionId, $visited, true)) { + return []; + } + + $attributes = $this->getCollectionAttributes($dbForProject, $databaseNamespace, $collectionId); + if ($attributes === null) { + return []; + } + + $visited[] = $collectionId; + $relationshipKeys = []; foreach ($attributes as $attr) { @@ -176,27 +196,54 @@ class V20 extends Filter $relatedCollectionId = $attr['relatedCollection'] ?? null; // Skip this relationship entirely if it points to an already visited collection - if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) { + if ($relatedCollectionId && in_array($relatedCollectionId, $visited, true)) { continue; } - // Add the wildcard select for this relationship $relationshipKeys[] = $fullKey . '.*'; - // Continue recursively if we have a related collection if ($relatedCollectionId) { - $nestedKeys = $this->getRelatedCollectionKeys( - $databaseId, + $nestedKeys = $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, $relatedCollectionId, $fullKey, $depth + 1, $visited ); - $relationshipKeys = \array_merge($relationshipKeys, $nestedKeys); } } return \array_values(\array_unique($relationshipKeys)); } + + /** + * @return array>|null + */ + private function getCollectionAttributes( + Database $dbForProject, + string $databaseNamespace, + string $collectionId + ): ?array { + $cacheKey = $databaseNamespace . ':' . $collectionId; + if (\array_key_exists($cacheKey, $this->collectionAttributesCache)) { + return $this->collectionAttributesCache[$cacheKey]; + } + + try { + $collection = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( + $databaseNamespace, + $collectionId + )); + } catch (\Throwable) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + if ($collection->isEmpty()) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + return $this->collectionAttributesCache[$cacheKey] = $collection->getAttribute('attributes', []); + } }