mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', []);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user