From 2416eef4a74015f51f320c61a69bac4b49752681 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Feb 2026 19:20:19 +1300 Subject: [PATCH 1/5] Fix stats leak --- src/Appwrite/Platform/Workers/StatsUsage.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 018c192647..6b62da8f80 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -538,13 +538,11 @@ class StatsUsage extends Action $this->statDocuments ); Console::success('Usage logs pushed to Logs DB'); - - /** - * todo: Do we need to unset $this->statDocuments? - */ - } catch (Throwable $th) { Console::error($th->getMessage()); + } finally { + // Clear statDocuments to prevent memory accumulation across batches + $this->statDocuments = []; } } } From 5733972218306cfe75ed4c64a6950025c6470f10 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Feb 2026 19:20:42 +1300 Subject: [PATCH 2/5] Fix webhook leak --- src/Appwrite/Platform/Workers/Webhooks.php | 81 +++++++++++----------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 394eae5fb8..dbfbe591a6 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -104,48 +104,51 @@ class Webhooks extends Action $httpPass = $webhook->getAttribute('httpPass'); $ch = \curl_init($webhook->getAttribute('url')); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - \curl_setopt($ch, CURLOPT_HEADER, 0); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_TIMEOUT, 15); - \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); - \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( - APP_USERAGENT, - System::getEnv('_APP_VERSION', 'UNKNOWN'), - System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) - )); - \curl_setopt( - $ch, - CURLOPT_HTTPHEADER, - [ - 'Content-Type: application/json', - 'Content-Length: ' . \strlen($payload), - 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), - 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), - 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), - 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), - 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), - 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, - ] - ); - curl_setopt($ch, CURLOPT_MAXREDIRS, 5); + try { + \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); + \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + \curl_setopt($ch, CURLOPT_HEADER, 0); + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, CURLOPT_TIMEOUT, 15); + \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); + \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( + APP_USERAGENT, + System::getEnv('_APP_VERSION', 'UNKNOWN'), + System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) + )); + \curl_setopt( + $ch, + CURLOPT_HTTPHEADER, + [ + 'Content-Type: application/json', + 'Content-Length: ' . \strlen($payload), + 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), + 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), + 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), + 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), + 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), + 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, + ] + ); + \curl_setopt($ch, CURLOPT_MAXREDIRS, 5); - if (!$webhook->getAttribute('security', true)) { - \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + if (!$webhook->getAttribute('security', true)) { + \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + } + + if (!empty($httpUser) && !empty($httpPass)) { + \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); + \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + } + + $responseBody = \curl_exec($ch); + $curlError = \curl_error($ch); + $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + } finally { + \curl_close($ch); } - if (!empty($httpUser) && !empty($httpPass)) { - \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); - \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - } - - $responseBody = \curl_exec($ch); - $curlError = \curl_error($ch); - $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - \curl_close($ch); - if (!empty($curlError) || $statusCode >= 400) { $dbForPlatform->increaseDocumentAttribute('webhooks', $webhook->getId(), 'attempts', 1); $webhook = $dbForPlatform->getDocument('webhooks', $webhook->getId()); From 2c790ecd84ad8e64c13ae04b1f22bc391799a96b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Feb 2026 19:22:00 +1300 Subject: [PATCH 3/5] Fix potential depth leak --- .../Http/Databases/Collections/Documents/Action.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 3159eed5e3..39146508fb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -293,8 +293,8 @@ abstract class Action extends DatabasesAction array &$collectionsCache, Authorization $authorization, ?int &$operations = null, + int $depth = 0, ): bool { - if ($operations !== null && $document->isEmpty()) { return false; } @@ -308,6 +308,11 @@ abstract class Action extends DatabasesAction $document->setAttribute('$databaseId', $database->getId()); $document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collectionId); + // Stop processing relationships if max depth reached + if ($depth >= Database::RELATION_MAX_DEPTH) { + return true; + } + $relationships = $collectionsCache[$collectionId] ??= \array_filter( $collection->getAttribute('attributes', []), fn ($attr) => $attr->getAttribute('type') === Database::VAR_RELATIONSHIP @@ -354,8 +359,9 @@ abstract class Action extends DatabasesAction document: $relation, dbForProject: $dbForProject, collectionsCache: $collectionsCache, + authorization: $authorization, operations: $operations, - authorization: $authorization + depth: $depth + 1 ); } } From 2f5e5d384f050fc0daa397d80471efac020816e3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Feb 2026 19:27:18 +1300 Subject: [PATCH 4/5] Array push on hot loop --- src/Appwrite/Messaging/Adapter/Realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 3bfa58a1da..e5f2fe9fe3 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -433,7 +433,7 @@ class Realtime extends MessagingAdapter } if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR], true)) { - $stack = array_merge($stack, $query->getValues()); + \array_push($stack, ...$query->getValues()); } } From cdd2be175f86a9bfd599e060f1aa5e0af52834fd Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 4 Feb 2026 19:27:57 +1300 Subject: [PATCH 5/5] Pre-parse queries --- src/Appwrite/Messaging/Adapter/Realtime.php | 41 ++++++++++++--------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index e5f2fe9fe3..6dfa19c9e3 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -31,9 +31,9 @@ class Realtime extends MessagingAdapter * [ROLE_X] -> * [CHANNEL_NAME_X] -> * [CONNECTION_ID] -> - * [SUB_ID] -> [query1, query2, ...] // Subscription with queries (AND logic) + * [SUB_ID] -> ['strings' => [...], 'parsed' => [...]] * - * Each subscription ID maps to an array of query strings. + * Each subscription ID maps to query strings (for metadata) and pre-parsed Query objects (for filtering). * Within a subscription: AND logic (all queries must match) * Across subscriptions: OR logic (any subscription matching = send event) */ @@ -64,18 +64,27 @@ class Realtime extends MessagingAdapter $this->subscriptions[$projectId] = []; } - // Convert Query objects to strings for this subscription + // Convert Query objects to strings and store both for this subscription $queryStrings = []; + $parsedQueries = []; if (empty($queryGroup)) { // No queries means "listen to all events" - use select("*") - $queryStrings[] = Query::select(['*'])->toString(); + $selectAll = Query::select(['*']); + $queryStrings[] = $selectAll->toString(); + $parsedQueries[] = $selectAll; } else { foreach ($queryGroup as $query) { /** @var Query $query */ $queryStrings[] = $query->toString(); + $parsedQueries[] = $query; } } + $subscriptionData = [ + 'strings' => $queryStrings, + 'parsed' => $parsedQueries, + ]; + foreach ($roles as $role) { if (!isset($this->subscriptions[$projectId][$role])) { $this->subscriptions[$projectId][$role] = []; @@ -88,8 +97,7 @@ class Realtime extends MessagingAdapter if (!isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { $this->subscriptions[$projectId][$role][$channel][$identifier] = []; } - // Store subscription under subscription ID - $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $queryStrings; + $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $subscriptionData; } } @@ -131,14 +139,14 @@ class Realtime extends MessagingAdapter continue; } - foreach ($this->subscriptions[$projectId][$role][$channel][$connection] as $subId => $queryStrings) { + foreach ($this->subscriptions[$projectId][$role][$channel][$connection] as $subId => $subscriptionData) { if (!isset($subscriptions[$subId])) { $subscriptions[$subId] = [ 'channels' => [], - 'queries' => $queryStrings + 'queries' => $subscriptionData['strings'] ?? [] ]; } - if (!in_array($channel, $subscriptions[$subId]['channels'])) { + if (!\in_array($channel, $subscriptions[$subId]['channels'])) { $subscriptions[$subId]['channels'][] = $channel; } } @@ -282,15 +290,14 @@ class Realtime extends MessagingAdapter $matchedSubscriptions = []; // Process each subscription (OR logic across subscriptions) - foreach ($subscriptions as $subId => $queryStrings) { - $parsedQueries = []; - foreach ($queryStrings as $queryString) { - $parsed = Query::parseQueries([$queryString]); - $parsedQueries = array_merge($parsedQueries, $parsed); - } + foreach ($subscriptions as $subId => $subscriptionData) { + // Use pre-parsed queries instead of re-parsing on every event + $parsedQueries = $subscriptionData['parsed'] ?? []; + $queryStrings = $subscriptionData['strings'] ?? []; + // Check if this subscription matches (AND logic within subscription) // Or if empty payload and select all as filter will return empty payload out of it even if it passed - $isEmptyPayloadAndSelectAll = RuntimeQuery::isSelectAll($parsedQueries[0]) && empty($payload); + $isEmptyPayloadAndSelectAll = !empty($parsedQueries) && RuntimeQuery::isSelectAll($parsedQueries[0]) && empty($payload); if ($isEmptyPayloadAndSelectAll || !empty(RuntimeQuery::filter($parsedQueries, $payload))) { $matchedSubscriptions[$subId] = $queryStrings; } @@ -301,7 +308,7 @@ class Realtime extends MessagingAdapter if (!isset($receivers[$id])) { $receivers[$id] = []; } - $receivers[$id] = array_merge($receivers[$id], $matchedSubscriptions); + $receivers[$id] += $matchedSubscriptions; } } break;