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.
This commit is contained in:
loks0n
2026-04-22 14:49:13 +01:00
parent 49f8d6e89c
commit 3283b0bec0
2 changed files with 94 additions and 37 deletions
+10
View File
@@ -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;
}
/**
+84 -37
View File
@@ -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<string, array<int, array<string, mixed>>|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<int, array<string, mixed>>|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', []);
}
}