From b7e2606b9f68dfd096cfec5cd18a82216273264e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 22 Dec 2025 18:01:00 +0530 Subject: [PATCH 01/34] Enhance Realtime functionality with query support and improve tests - Updated Realtime adapter to handle queries during subscription. - Added query filtering capabilities in RuntimeQuery class. - Modified RealtimeBase and RealtimeCustomClientTest to support query parameters in WebSocket connections. - Improved test coverage for account and database channels with queries. --- app/realtime.php | 20 +-- src/Appwrite/Messaging/Adapter/Realtime.php | 45 ++++++- .../Utopia/Database/Query/RuntimeQuery.php | 114 ++++++++++++++++++ tests/e2e/Services/Realtime/RealtimeBase.php | 4 +- .../Realtime/RealtimeCustomClientTest.php | 81 ++++++++++++- 5 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/Query/RuntimeQuery.php diff --git a/app/realtime.php b/app/realtime.php index 31e6015d92..4bd105beb1 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -471,19 +471,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $roles = $user->getRoles($database->getAuthorization()); $channels = $realtime->connections[$connection]['channels']; + $queries = $realtime->connections[$connection]['queries'] ?? []; $realtime->unsubscribe($connection); - $realtime->subscribe($projectId, $connection, $roles, $channels); + $realtime->subscribe($projectId, $connection, $roles, $channels, $queries); } } $receivers = $realtime->getSubscribers($event); - if (App::isDevelopment() && !empty($receivers)) { - Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); - Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); - Console::log("[Debug][Worker {$workerId}] Event: " . $payload); - } + // if (App::isDevelopment() && !empty($receivers)) { + // Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); + // Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); + // Console::log("[Debug][Worker {$workerId}] Event: " . $payload); + // } $server->send( $receivers, @@ -576,6 +577,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $roles = $user->getRoles($authorization); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $queries = Realtime::convertQueries($request->getQuery('queries', [])); /** * Channels Check @@ -584,7 +586,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing channels'); } - $realtime->subscribe($project->getId(), $connection, $roles, $channels); + $realtime->subscribe($project->getId(), $connection, $roles, $channels, $queries); $realtime->connections[$connection]['authorization'] = $authorization; @@ -594,6 +596,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, 'type' => 'connected', 'data' => [ 'channels' => array_keys($channels), + 'queries' => array_keys($queries), 'user' => $user ] ])); @@ -724,11 +727,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $roles = $user->getRoles($database->getAuthorization()); $channels = Realtime::convertChannels(array_flip($realtime->connections[$connection]['channels']), $user->getId()); + $queries = $realtime->connections[$connection]['queries']; // Preserve authorization before subscribe overwrites the connection array $authorization = $realtime->connections[$connection]['authorization'] ?? null; - $realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels); + $realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels, $queries); // Restore authorization after subscribe if ($authorization !== null) { diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 35b8089668..562be00e33 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -2,12 +2,16 @@ namespace Appwrite\Messaging\Adapter; +use Appwrite\Extend\Exception; use Appwrite\Messaging\Adapter as MessagingAdapter; use Appwrite\PubSub\Adapter\Pool as PubSubPool; +use Appwrite\Utopia\Database\Query\RuntimeQuery; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; class Realtime extends MessagingAdapter { @@ -51,9 +55,10 @@ class Realtime extends MessagingAdapter * @param mixed $identifier * @param array $roles * @param array $channels + * @param array $queries * @return void */ - public function subscribe(string $projectId, mixed $identifier, array $roles, array $channels): void + public function subscribe(string $projectId, mixed $identifier, array $roles, array $channels, array $queries = []): void { if (!isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; @@ -72,7 +77,8 @@ class Realtime extends MessagingAdapter $this->connections[$identifier] = [ 'projectId' => $projectId, 'roles' => $roles, - 'channels' => $channels + 'channels' => $channels, + 'queries' => $queries ]; } @@ -206,7 +212,9 @@ class Realtime extends MessagingAdapter /** * To prevent duplicates, we save the connections as array keys. */ - $receivers[$id] = 0; + if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']))) { + $receivers[$id] = 0; + } } break; } @@ -217,6 +225,19 @@ class Realtime extends MessagingAdapter return array_keys($receivers); } + public function filterEventData(array $documents, array $queries): array + { + if (empty($queries)) { + return $documents; + } + $filteredDocuments = []; + foreach ($documents as $document) { + $doc = new Document((array) $doc); + } + + return $filteredDocuments; + } + /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. @@ -245,6 +266,24 @@ class Realtime extends MessagingAdapter return $channels; } + /** + * Converts the queries from the Query Params into an array. + * @param array $queries + * @return array + */ + public static function convertQueries(array $queries): array + { + $queries = Query::parseQueries($queries); + foreach ($queries as $query) { + if (!in_array($query->getMethod(), RuntimeQuery::ALLOWED_QUERIES)) { + // TODO: add better error message with which queries are allowed + throw new QueryException(Exception::REALTIME_POLICY_VIOLATION, 'Query not supported'); + } + } + + return $queries; + } + /** * Create channels array based on the event name and payload. * diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php new file mode 100644 index 0000000000..c887ca36d6 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php @@ -0,0 +1,114 @@ + $queries + * @param array $payload + */ + public static function filter(array $queries, array $payload): array + { + if (empty($queries)) { + return $payload; + } + foreach ($queries as $query) { + if (self::evaluateFilter($query, $payload)) { + return $payload; + }; + } + return []; + } + + private static function evaluateFilter(Query $query, array $payload): bool + { + $attribute = $query->getAttribute(); + $method = $query->getMethod(); + $values = $query->getValues(); + if (!\array_key_exists($attribute, $payload)) { + return false; + } + $payloadAttributeValue = $payload[$attribute]; + switch ($method) { + case Query::TYPE_EQUAL: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); + + case Query::TYPE_NOT_EQUAL: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue !== $value); + + case Query::TYPE_LESSER: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue < $value); + + case Query::TYPE_LESSER_EQUAL: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue <= $value); + + case Query::TYPE_GREATER: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue > $value); + + case Query::TYPE_GREATER_EQUAL: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue >= $value); + + case Query::TYPE_IS_NULL: + return $payloadAttributeValue === null; + + case Query::TYPE_IS_NOT_NULL: + return $payloadAttributeValue !== null; + + case Query::TYPE_AND: + foreach ($query->getValues() as $subquery) { + // if any evaluation gets to false then whole and is false + if (!self::evaluateFilter($subquery, $payload)) { + return false; + } + return true; + } + + // no break + case Query::TYPE_OR: + foreach ($query->getValues() as $subquery) { + // if any evaluation gets to true then whole or is true + if (self::evaluateFilter($subquery, $payload)) { + return true; + } + return false; + } + + // no break + default: + throw new \InvalidArgumentException( + "Unsupported query method: {$method}" + ); + } + } + + private static function anyMatch(array $values, callable $fn): bool + { + foreach ($values as $value) { + if ($fn($value)) { + return true; + } + } + return false; + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index 89bd1898c4..ea5c3d710f 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -10,7 +10,8 @@ trait RealtimeBase private function getWebsocket( array $channels = [], array $headers = [], - string $projectId = null + string $projectId = null, + array $queries = [] ): WebSocketClient { if (is_null($projectId)) { $projectId = $this->getProject()['$id']; @@ -19,6 +20,7 @@ trait RealtimeBase $query = [ "project" => $projectId, "channels" => $channels, + "queries" => $queries ]; return new WebSocketClient( diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index c6a1686864..b15389dd2f 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -12,6 +12,7 @@ use Tests\E2E\Services\Functions\FunctionsBase; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; use WebSocket\ConnectionException; use WebSocket\TimeoutException; @@ -124,6 +125,82 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + public function testAccountChannelWithQueries() + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe to account channel with a simple query + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$userId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + + // Channels still work as usual + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('account', $response['data']['channels']); + $this->assertContains('account.' . $userId, $response['data']['channels']); + + // Queries are echoed back in the connection payload + $this->assertArrayHasKey('queries', $response['data']); + $this->assertIsArray($response['data']['queries']); + $this->assertCount(1, $response['data']['queries']); + + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($userId, $response['data']['user']['$id']); + + $client->close(); + } + + public function testDatabaseChannelWithQueries() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe to database-related channels with queries + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', ['dummy-id'])->toString(), + Query::isNotNull('payload')->toString(), + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + + // Channels as in regular database test + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + + // Queries should be present + $this->assertArrayHasKey('queries', $response['data']); + $this->assertIsArray($response['data']['queries']); + $this->assertCount(2, $response['data']['queries']); + + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + $client->close(); + } + public function testPingPong() { $client = $this->getWebsocket(['files'], [ @@ -692,8 +769,8 @@ class RealtimeCustomClientTest extends Scope $client = $this->getWebsocket(['documents', 'collections'], [ 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session - ]); + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null); $response = json_decode($client->receive(), true); From 39cf207df9a2b730cce9d70787dcf7fcf5f98280 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 18:56:55 +0530 Subject: [PATCH 02/34] re --- app/realtime.php | 10 +- .../Utopia/Database/Query/RuntimeQuery.php | 56 +- .../RealtimeCustomClientQueryTest.php | 1550 +++++++++++++++++ .../Realtime/RealtimeCustomClientTest.php | 2 +- .../Database/Query/RuntimeQueryTest.php | 589 +++++++ 5 files changed, 2179 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php create mode 100644 tests/unit/Utopia/Database/Query/RuntimeQueryTest.php diff --git a/app/realtime.php b/app/realtime.php index 4bd105beb1..b81ddf551c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -480,11 +480,11 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $receivers = $realtime->getSubscribers($event); - // if (App::isDevelopment() && !empty($receivers)) { - // Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); - // Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); - // Console::log("[Debug][Worker {$workerId}] Event: " . $payload); - // } + if (App::isDevelopment() && !empty($receivers)) { + Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); + Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); + Console::log("[Debug][Worker {$workerId}] Event: " . $payload); + } $server->send( $receivers, diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php index c887ca36d6..756245098f 100644 --- a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php @@ -46,16 +46,48 @@ class RuntimeQuery extends Query $attribute = $query->getAttribute(); $method = $query->getMethod(); $values = $query->getValues(); - if (!\array_key_exists($attribute, $payload)) { + + // during 'and' and 'or' attribute will not be present + if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR])) { + switch ($method) { + case Query::TYPE_AND: + // All subqueries must evaluate to true + foreach ($query->getValues() as $subquery) { + if (!self::evaluateFilter($subquery, $payload)) { + return false; + } + } + return true; + + case Query::TYPE_OR: + // At least one subquery must evaluate to true + foreach ($query->getValues() as $subquery) { + if (self::evaluateFilter($subquery, $payload)) { + return true; + } + } + return false; + + default: + throw new \InvalidArgumentException( + "Unsupported query method: {$method}" + ); + } + } + + $hasAttribute = \array_key_exists($attribute, $payload); + if (!$hasAttribute) { return false; } + + // null can be a value as well $payloadAttributeValue = $payload[$attribute]; switch ($method) { case Query::TYPE_EQUAL: return self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); case Query::TYPE_NOT_EQUAL: - return self::anyMatch($values, fn ($value) => $payloadAttributeValue !== $value); + return !self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); case Query::TYPE_LESSER: return self::anyMatch($values, fn ($value) => $payloadAttributeValue < $value); @@ -75,26 +107,6 @@ class RuntimeQuery extends Query case Query::TYPE_IS_NOT_NULL: return $payloadAttributeValue !== null; - case Query::TYPE_AND: - foreach ($query->getValues() as $subquery) { - // if any evaluation gets to false then whole and is false - if (!self::evaluateFilter($subquery, $payload)) { - return false; - } - return true; - } - - // no break - case Query::TYPE_OR: - foreach ($query->getValues() as $subquery) { - // if any evaluation gets to true then whole or is true - if (self::evaluateFilter($subquery, $payload)) { - return true; - } - return false; - } - - // no break default: throw new \InvalidArgumentException( "Unsupported query method: {$method}" diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php new file mode 100644 index 0000000000..303c6067be --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -0,0 +1,1550 @@ +getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that matches current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$userId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Update account name - should receive event (matches query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($name, $event['data']['payload']['name']); + + $client->close(); + + + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that does NOT match current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$userId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Update account name - should NOT receive event (doesn't match query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + // Should timeout - no event should be received + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testDatabaseChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with matching ID - should receive event + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocumentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'NotEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + $excludedDocumentId = ID::unique(); + + // Subscribe with query that excludes specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$excludedDocumentId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with different ID - should receive event + $allowedDocumentId = ID::unique(); + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $allowedDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($allowedDocumentId, $event['data']['payload']['$id']); + + // Create document with excluded ID - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $excludedDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for score > 50 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThan('score', 50)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with score > 50 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 75 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(75, $event['data']['payload']['score']); + + // Create document with score <= 50 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 30 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for age < 18 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThan('age', 18)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with age < 18 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(15, $event['data']['payload']['age']); + + // Create document with age >= 18 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 25 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for priority >= 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThanEqual('priority', 5)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with priority = 5 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 5 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(5, $event['data']['payload']['priority']); + + // Create document with priority > 5 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with priority < 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'level', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for level <= 10 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThanEqual('level', 10)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with level = 10 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 10 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(10, $event['data']['payload']['level']); + + // Create document with level < 10 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 7 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(7, $event['data']['payload']['level']); + + // Create document with level > 10 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'description', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for description IS NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNull('description')->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document without description - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => null + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + + // Create document with description - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => 'Has description' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNotNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'email', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for email IS NOT NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNotNull('email')->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with email - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'email' => 'test@example.com' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('test@example.com', $event['data']['payload']['email']); + + // Create document without email - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'And Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with AND query: status = 'active' AND priority > 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('priority', 5) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document matching both conditions - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('active', $event['data']['payload']['status']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with status = 'active' but priority <= 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with priority > 5 but status != 'active' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'inactive', + 'priority' => 9 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Or Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'type', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + // Subscribe with OR query: type = 'urgent' OR type = 'critical' + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('type', ['urgent']), + Query::equal('type', ['critical']) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with type = 'urgent' - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'urgent' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('urgent', $event['data']['payload']['type']); + + // Create document with type = 'critical' - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'critical' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('critical', $event['data']['payload']['type']); + + // Create document with type = 'normal' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'normal' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Complex Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with complex query: (category = 'premium' OR category = 'vip') AND score >= 80 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::or([ + Query::equal('category', ['premium']), + Query::equal('category', ['vip']) + ]), + Query::greaterThanEqual('score', 80) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with category = 'premium' and score >= 80 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('premium', $event['data']['payload']['category']); + $this->assertEquals(85, $event['data']['payload']['score']); + + // Create document with category = 'vip' and score >= 80 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'vip', + 'score' => 90 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('vip', $event['data']['payload']['category']); + $this->assertEquals(90, $event['data']['payload']['score']); + + // Create document with category = 'premium' but score < 80 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 70 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'standard', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testFilesChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Create bucket + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'bucketId' => ID::unique(), + 'name' => 'Query Test Bucket', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ] + ]); + $bucketId = $bucket['body']['$id']; + + $targetFileId = ID::unique(); + + // Subscribe with query for specific file ID + $client = $this->getWebsocket(['files'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetFileId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create file with matching ID - should receive event + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $targetFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetFileId, $event['data']['payload']['$id']); + + // Create file with different ID - should NOT receive event + $otherFileId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $otherFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo2.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testExecutionChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Create function + $function = $this->client->call(Client::METHOD_POST, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'functionId' => ID::unique(), + 'name' => 'Test Function', + 'execute' => ['users'], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $functionId = $function['body']['$id'] ?? ''; + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'code' => $this->packageFunction('timeout'), + 'activate' => true + ]); + $deploymentId = $deployment['body']['$id'] ?? ''; + + // Poll until deployment is built + $this->assertEventually(function () use ($function, $deploymentId, $projectId) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('ready', $deployment['body']['status']); + }); + + // Subscribe with query for execution with response (not null) + $client = $this->getWebsocket(['executions'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNotNull('response')->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Execute function - should receive event when execution completes with response + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId + ], $this->getHeaders()), [ + 'async' => true + ]); + + // Wait for execution to complete + $event = json_decode($client->receive(), true); + if ($event['type'] === 'event' && isset($event['data']['payload']['response'])) { + $this->assertEquals('event', $event['type']); + $this->assertNotNull($event['data']['payload']['response']); + } + + $client->close(); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], []); + + $targetTeamId = ID::unique(); + + // Subscribe with query for specific team ID + $client = $this->getWebsocket(['teams'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetTeamId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create team with matching ID - should receive event + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'teamId' => $targetTeamId, + 'name' => 'Query Test Team' + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetTeamId, $event['data']['payload']['$id']); + + // Create team with different ID - should NOT receive event + $otherTeamId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'teamId' => $otherTeamId, + 'name' => 'Other Team' + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testMultipleQueriesWithOrLogic() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Multiple Queries Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + $docId1 = ID::unique(); + $docId2 = ID::unique(); + + // Subscribe with multiple queries (OR logic - any query matching returns event) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$docId1])->toString(), + Query::equal('$id', [$docId2])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with first ID - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docId1, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($docId1, $event['data']['payload']['$id']); + + // Create document with second ID - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $docId2, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($docId2, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index b15389dd2f..112eed1ccd 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3039,7 +3039,7 @@ class RealtimeCustomClientTest extends Scope sleep(1); try { - $client->receive(1); // 1 second timeout + $client->receive(); $this->fail('Should not receive any event after rollback'); } catch (TimeoutException $e) { // Expected - no event should be triggered diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php new file mode 100644 index 0000000000..2156d862a5 --- /dev/null +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -0,0 +1,589 @@ + 'John', 'age' => 30]; + $result = RuntimeQuery::filter([], $payload); + $this->assertEquals($payload, $result); + } + + public function testFilterWithNoMatchingQuery(): void + { + $queries = [Query::equal('name', ['Jane'])]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals([], $result); + } + + public function testFilterWithMatchingQuery(): void + { + $queries = [Query::equal('name', ['John'])]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_EQUAL tests + public function testEqualMatch(): void + { + $query = Query::equal('name', ['John']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualNoMatch(): void + { + $query = Query::equal('name', ['Jane']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEqualMultipleValuesMatch(): void + { + $query = Query::equal('status', ['active', 'pending', 'approved']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualMultipleValuesNoMatch(): void + { + $query = Query::equal('status', ['active', 'pending', 'approved']); + $payload = ['status' => 'rejected']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEqualNumericValues(): void + { + $query = Query::equal('age', [30, 25, 35]); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualBooleanValues(): void + { + $query = Query::equal('active', [true]); + $payload = ['active' => true]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualMissingAttribute(): void + { + $query = Query::equal('missing', ['value']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_NOT_EQUAL tests + public function testNotEqualMatch(): void + { + $query = Query::notEqual('name', ['Jane']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testNotEqualNoMatch(): void + { + $query = Query::notEqual('name', ['John']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testNotEqualMultipleValues(): void + { + // generally from the client side they will pass query strings via the realtime + // and Query::parse will be done first and parse doesn't allow multiple notEqual values + $query = Query::notEqual('status', ['rejected', 'cancelled']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + + $query = Query::notEqual('status', ['active', 'pending']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_LESSER tests + public function testLesserMatch(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserNoMatch(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserEqualValue(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserMultipleValues(): void + { + // Note: Query::lessThan only accepts single value, but RuntimeQuery's anyMatch supports arrays + // This test uses a single value as Query class requires + $query = Query::lessThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserStringComparison(): void + { + $query = Query::lessThan('name', 'M'); + $payload = ['name' => 'A']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_LESSER_EQUAL tests + public function testLesserEqualMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserEqualExactMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserEqualNoMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserEqualMultipleValues(): void + { + // Note: Query::lessThanEqual only accepts single value + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_GREATER tests + public function testGreaterMatch(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterNoMatch(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterEqualValue(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterMultipleValues(): void + { + // Note: Query::greaterThan only accepts single value + $query = Query::greaterThan('age', 20); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_GREATER_EQUAL tests + public function testGreaterEqualMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterEqualExactMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterEqualNoMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterEqualMultipleValues(): void + { + // Note: Query::greaterThanEqual only accepts single value + $query = Query::greaterThanEqual('age', 20); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_IS_NULL tests + public function testIsNullMatch(): void + { + $query = Query::isNull('description'); + $payload = ['description' => null]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testIsNullNoMatch(): void + { + $query = Query::isNull('description'); + $payload = ['description' => 'Some text']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testIsNullMissingAttribute(): void + { + $query = Query::isNull('missing'); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_IS_NOT_NULL tests + public function testIsNotNullMatch(): void + { + $query = Query::isNotNull('description'); + $payload = ['description' => 'Some text']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testIsNotNullNoMatch(): void + { + $query = Query::isNotNull('description'); + $payload = ['description' => null]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testIsNotNullMissingAttribute(): void + { + $query = Query::isNotNull('missing'); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_AND tests + public function testAndAllMatch(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testAndOneFails(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testAndAllFail(): void + { + $query = Query::and([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testAndMultipleConditions(): void + { + $query = Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + Query::isNotNull('email') + ]); + $payload = ['status' => 'active', 'age' => 25, 'email' => 'test@example.com']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testAndNestedAnd(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::and([ + Query::equal('age', [30]), + Query::equal('status', ['active']) + ]) + ]); + $payload = ['name' => 'John', 'age' => 30, 'status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_OR tests + public function testOrOneMatch(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::equal('name', ['Jane']) + ]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAllMatch(): void + { + $query = Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']) + ]); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAllFail(): void + { + $query = Query::or([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testOrMultipleConditions(): void + { + $query = Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']), + Query::equal('status', ['approved']) + ]); + $payload = ['status' => 'pending']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrNestedOr(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::or([ + Query::equal('name', ['Jane']), + Query::equal('name', ['Bob']) + ]) + ]); + $payload = ['name' => 'Bob']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrWithDifferentAttributes(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::equal('email', ['john@example.com']) + ]); + $payload = ['name' => 'Jane', 'email' => 'john@example.com']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // Complex combinations + public function testAndOrCombination(): void + { + $query = Query::and([ + Query::equal('type', ['user']), + Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']) + ]) + ]); + $payload = ['type' => 'user', 'status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAndCombination(): void + { + $query = Query::or([ + Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]), + Query::and([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // Edge cases + public function testMultipleQueriesFirstMatches(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::equal('age', [25]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + public function testMultipleQueriesSecondMatches(): void + { + $queries = [ + Query::equal('name', ['Jane']), + Query::equal('age', [30]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + public function testMultipleQueriesNoneMatch(): void + { + $queries = [ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals([], $result); + } + + public function testEmptyPayload(): void + { + $query = Query::equal('name', ['John']); + $payload = []; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEmptyAndQuery(): void + { + $query = Query::and([]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + // Empty AND should return true (all conditions pass vacuously) + $this->assertEquals($payload, $result); + } + + public function testEmptyOrQuery(): void + { + $query = Query::or([]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + // Empty OR should return false (no conditions match) + $this->assertEquals([], $result); + } + + // Type-specific edge cases + public function testEqualWithZero(): void + { + $query = Query::equal('count', [0]); + $payload = ['count' => 0]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualWithEmptyString(): void + { + $query = Query::equal('name', ['']); + $payload = ['name' => '']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualWithFalse(): void + { + $query = Query::equal('active', [false]); + $payload = ['active' => false]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testComparisonWithFloat(): void + { + $query = Query::greaterThan('score', 8.5); + $payload = ['score' => 9.2]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testComparisonWithStringNumbers(): void + { + $query = Query::lessThan('version', '10'); + $payload = ['version' => '9']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } +} From 881d96a6532bf65b9fbac9b16054267630e6a80d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 19:06:34 +0530 Subject: [PATCH 03/34] linting --- .../Realtime/RealtimeCustomClientTest.php | 77 ------------------- 1 file changed, 77 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 112eed1ccd..bd746f69f8 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -12,7 +12,6 @@ use Tests\E2E\Services\Functions\FunctionsBase; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; use WebSocket\ConnectionException; use WebSocket\TimeoutException; @@ -125,82 +124,6 @@ class RealtimeCustomClientTest extends Scope $client->close(); } - public function testAccountChannelWithQueries() - { - $user = $this->getUser(); - $userId = $user['$id'] ?? ''; - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe to account channel with a simple query - $client = $this->getWebsocket(['account'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$userId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - - $this->assertArrayHasKey('type', $response); - $this->assertArrayHasKey('data', $response); - $this->assertEquals('connected', $response['type']); - $this->assertNotEmpty($response['data']); - - // Channels still work as usual - $this->assertCount(2, $response['data']['channels']); - $this->assertContains('account', $response['data']['channels']); - $this->assertContains('account.' . $userId, $response['data']['channels']); - - // Queries are echoed back in the connection payload - $this->assertArrayHasKey('queries', $response['data']); - $this->assertIsArray($response['data']['queries']); - $this->assertCount(1, $response['data']['queries']); - - $this->assertNotEmpty($response['data']['user']); - $this->assertEquals($userId, $response['data']['user']['$id']); - - $client->close(); - } - - public function testDatabaseChannelWithQueries() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe to database-related channels with queries - $client = $this->getWebsocket(['documents', 'collections'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', ['dummy-id'])->toString(), - Query::isNotNull('payload')->toString(), - ]); - - $response = json_decode($client->receive(), true); - - $this->assertArrayHasKey('type', $response); - $this->assertArrayHasKey('data', $response); - $this->assertEquals('connected', $response['type']); - $this->assertNotEmpty($response['data']); - - // Channels as in regular database test - $this->assertCount(2, $response['data']['channels']); - $this->assertContains('documents', $response['data']['channels']); - $this->assertContains('collections', $response['data']['channels']); - - // Queries should be present - $this->assertArrayHasKey('queries', $response['data']); - $this->assertIsArray($response['data']['queries']); - $this->assertCount(2, $response['data']['queries']); - - $this->assertNotEmpty($response['data']['user']); - $this->assertEquals($user['$id'], $response['data']['user']['$id']); - - $client->close(); - } - public function testPingPong() { $client = $this->getWebsocket(['files'], [ From 336bd4482672fa8bfe91414e5210654b0e75f30a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 20:10:00 +0530 Subject: [PATCH 04/34] fixed payload in adapter --- 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 562be00e33..e4acb677c6 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -212,7 +212,7 @@ class Realtime extends MessagingAdapter /** * To prevent duplicates, we save the connections as array keys. */ - if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']))) { + if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']['payload']))) { $receivers[$id] = 0; } } From 7e315f79ccc480bda8e0052c0c73640ac8d58783 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 20:50:05 +0530 Subject: [PATCH 05/34] refactor: improve query handling in Realtime adapter and update RuntimeQuery filter logic --- src/Appwrite/Messaging/Adapter/Realtime.php | 7 +- .../Utopia/Database/Query/RuntimeQuery.php | 1 + .../RealtimeCustomClientQueryTest.php | 122 ------------------ 3 files changed, 7 insertions(+), 123 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index e4acb677c6..43068a9d46 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -212,7 +212,12 @@ class Realtime extends MessagingAdapter /** * To prevent duplicates, we save the connections as array keys. */ - if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']['payload']))) { + $queries = $this->connections[$id]['queries'] ?? []; + $payload = $event['data']['payload'] ?? []; + if ( + empty($queries) || + !empty(RuntimeQuery::filter($queries, $payload)) + ) { $receivers[$id] = 0; } } diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php index 756245098f..f97ba015ca 100644 --- a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php @@ -101,6 +101,7 @@ class RuntimeQuery extends Query case Query::TYPE_GREATER_EQUAL: return self::anyMatch($values, fn ($value) => $payloadAttributeValue >= $value); + // attribute must be present and should be explicitly null case Query::TYPE_IS_NULL: return $payloadAttributeValue === null; diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 303c6067be..0272450245 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1307,128 +1307,6 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } - public function testExecutionChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Create function - $function = $this->client->call(Client::METHOD_POST, '/functions', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'functionId' => ID::unique(), - 'name' => 'Test Function', - 'execute' => ['users'], - 'runtime' => 'node-22', - 'entrypoint' => 'index.js', - 'timeout' => 10, - ]); - $functionId = $function['body']['$id'] ?? ''; - - $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'code' => $this->packageFunction('timeout'), - 'activate' => true - ]); - $deploymentId = $deployment['body']['$id'] ?? ''; - - // Poll until deployment is built - $this->assertEventually(function () use ($function, $deploymentId, $projectId) { - $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]); - $this->assertEquals('ready', $deployment['body']['status']); - }); - - // Subscribe with query for execution with response (not null) - $client = $this->getWebsocket(['executions'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::isNotNull('response')->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Execute function - should receive event when execution completes with response - $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId - ], $this->getHeaders()), [ - 'async' => true - ]); - - // Wait for execution to complete - $event = json_decode($client->receive(), true); - if ($event['type'] === 'event' && isset($event['data']['payload']['response'])) { - $this->assertEquals('event', $event['type']); - $this->assertNotNull($event['data']['payload']['response']); - } - - $client->close(); - - // Cleanup - $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], []); - - $targetTeamId = ID::unique(); - - // Subscribe with query for specific team ID - $client = $this->getWebsocket(['teams'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetTeamId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create team with matching ID - should receive event - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'teamId' => $targetTeamId, - 'name' => 'Query Test Team' - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetTeamId, $event['data']['payload']['$id']); - - // Create team with different ID - should NOT receive event - $otherTeamId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'teamId' => $otherTeamId, - 'name' => 'Other Team' - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - public function testMultipleQueriesWithOrLogic() { $user = $this->getUser(); From 3b4196735a594c92ed8a28f1fe1c7a4cb623dff4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 21:02:03 +0530 Subject: [PATCH 06/34] refactor: simplify query handling in Realtime adapter and enhance error messaging for unsupported queries --- app/realtime.php | 2 +- src/Appwrite/Messaging/Adapter/Realtime.php | 21 ++++++--------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 7774c2cc97..3a68005383 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -594,7 +594,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, 'type' => 'connected', 'data' => [ 'channels' => array_keys($channels), - 'queries' => array_keys($queries), + 'queries' => $queries, 'user' => $user ] ])); diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 43068a9d46..2b877779c2 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -230,19 +230,6 @@ class Realtime extends MessagingAdapter return array_keys($receivers); } - public function filterEventData(array $documents, array $queries): array - { - if (empty($queries)) { - return $documents; - } - $filteredDocuments = []; - foreach ($documents as $document) { - $doc = new Document((array) $doc); - } - - return $filteredDocuments; - } - /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. @@ -281,8 +268,12 @@ class Realtime extends MessagingAdapter $queries = Query::parseQueries($queries); foreach ($queries as $query) { if (!in_array($query->getMethod(), RuntimeQuery::ALLOWED_QUERIES)) { - // TODO: add better error message with which queries are allowed - throw new QueryException(Exception::REALTIME_POLICY_VIOLATION, 'Query not supported'); + $unsupportedMethod = $query->getMethod(); + $allowedMethods = implode(', ', RuntimeQuery::ALLOWED_QUERIES); + throw new QueryException( + Exception::REALTIME_POLICY_VIOLATION, + "Query method '{$unsupportedMethod}' is not supported in Realtime queries. Allowed query methods are: {$allowedMethods}" + ); } } From dc0eb5f7a7c95df6a9ba92828d24a16c1bf01d18 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 05:45:06 +0000 Subject: [PATCH 07/34] Feat: Health module --- app/config/services.php | 2 +- app/controllers/api/health.php | 1050 ----------------- src/Appwrite/Platform/Appwrite.php | 2 + .../Health/Http/Health/AntiVirus/Get.php | 78 ++ .../Modules/Health/Http/Health/Cache/Get.php | 94 ++ .../Health/Http/Health/Certificate/Get.php | 92 ++ .../Modules/Health/Http/Health/DB/Get.php | 95 ++ .../Modules/Health/Http/Health/Get.php | 57 + .../Modules/Health/Http/Health/PubSub/Get.php | 94 ++ .../Modules/Health/Http/Health/Queue/Base.php | 26 + .../Health/Http/Health/Queue/Builds/Get.php | 60 + .../Http/Health/Queue/Certificates/Get.php | 60 + .../Http/Health/Queue/Databases/Get.php | 61 + .../Health/Http/Health/Queue/Deletes/Get.php | 60 + .../Health/Http/Health/Queue/Failed/Get.php | 128 ++ .../Http/Health/Queue/Functions/Get.php | 60 + .../Health/Http/Health/Queue/Logs/Get.php | 60 + .../Health/Http/Health/Queue/Mails/Get.php | 60 + .../Http/Health/Queue/Messaging/Get.php | 60 + .../Http/Health/Queue/Migrations/Get.php | 60 + .../Http/Health/Queue/StatsResources/Get.php | 60 + .../Http/Health/Queue/StatsUsage/Get.php | 60 + .../Health/Http/Health/Queue/Webhooks/Get.php | 60 + .../Modules/Health/Http/Health/Stats/Get.php | 60 + .../Health/Http/Health/Storage/Get.php | 82 ++ .../Health/Http/Health/Storage/Local/Get.php | 79 ++ .../Modules/Health/Http/Health/Time/Get.php | 83 ++ .../Health/Http/Health/Version/Get.php | 35 + .../Platform/Modules/Health/Module.php | 14 + .../Platform/Modules/Health/Services/Http.php | 64 + 30 files changed, 1805 insertions(+), 1051 deletions(-) delete mode 100644 app/controllers/api/health.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/AntiVirus/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Webhooks/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Stats/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Version/Get.php create mode 100644 src/Appwrite/Platform/Modules/Health/Module.php create mode 100644 src/Appwrite/Platform/Modules/Health/Services/Http.php diff --git a/app/config/services.php b/app/config/services.php index e4bbf9b6f6..2e8cf34884 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -104,7 +104,7 @@ return [ 'name' => 'Health', 'subtitle' => 'The Health service allows you to both validate and monitor your Appwrite server\'s health.', 'description' => '/docs/services/health.md', - 'controller' => 'api/health.php', + 'controller' => '', // Uses modules 'sdk' => true, 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/server/health', diff --git a/app/controllers/api/health.php b/app/controllers/api/health.php deleted file mode 100644 index 97ddf8391c..0000000000 --- a/app/controllers/api/health.php +++ /dev/null @@ -1,1050 +0,0 @@ -desc('Get HTTP') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'health', - name: 'get', - description: '/docs/references/health/get.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->action(function (Response $response) { - - $output = [ - 'name' => 'http', - 'status' => 'pass', - 'ping' => 0 - ]; - - $response->dynamic(new Document($output), Response::MODEL_HEALTH_STATUS); - }); - -App::get('/v1/health/version') - ->desc('Get version') - ->groups(['api', 'health']) - ->label('scope', 'public') - ->inject('response') - ->action(function (Response $response) { - $response->dynamic(new Document([ 'version' => APP_VERSION_STABLE ]), Response::MODEL_HEALTH_VERSION); - }); - -App::get('/v1/health/db') - ->desc('Get DB') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'health', - name: 'getDB', - description: '/docs/references/health/get-db.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->inject('pools') - ->action(function (Response $response, Group $pools) { - $output = []; - $failures = []; - - $configs = [ - 'Console.DB' => Config::getParam('pools-console'), - 'Projects.DB' => Config::getParam('pools-database'), - ]; - - foreach ($configs as $key => $config) { - foreach ($config as $database) { - try { - $adapter = new DatabasePool($pools->get($database)); - - $checkStart = \microtime(true); - - if ($adapter->ping()) { - $output[] = new Document([ - 'name' => $key . " ($database)", - 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000) - ]); - } else { - $failures[] = $database; - } - } catch (\Throwable) { - $failures[] = $database; - } - } - } - - if (!empty($failures)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'DB failure on: ' . implode(", ", $failures)); - } - - $response->dynamic(new Document([ - 'statuses' => $output, - 'total' => count($output), - ]), Response::MODEL_HEALTH_STATUS_LIST); - }); - -App::get('/v1/health/cache') - ->desc('Get cache') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'health', - name: 'getCache', - description: '/docs/references/health/get-cache.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->inject('pools') - ->action(function (Response $response, Group $pools) { - $output = []; - $failures = []; - - $configs = [ - 'Cache' => Config::getParam('pools-cache'), - ]; - - foreach ($configs as $key => $config) { - foreach ($config as $cache) { - try { - $adapter = new CachePool($pools->get($cache)); - - $checkStart = \microtime(true); - - if ($adapter->ping()) { - $output[] = new Document([ - 'name' => $key . " ($cache)", - 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000) - ]); - } else { - $failures[] = $cache; - } - } catch (\Throwable) { - $failures[] = $cache; - } - } - } - - if (!empty($failures)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Cache failure on: ' . implode(", ", $failures)); - } - - $response->dynamic(new Document([ - 'statuses' => $output, - 'total' => count($output), - ]), Response::MODEL_HEALTH_STATUS_LIST); - }); - -App::get('/v1/health/pubsub') - ->desc('Get pubsub') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'health', - name: 'getPubSub', - description: '/docs/references/health/get-pubsub.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->inject('pools') - ->action(function (Response $response, Group $pools) { - $output = []; - $failures = []; - - $configs = [ - 'PubSub' => Config::getParam('pools-pubsub'), - ]; - - foreach ($configs as $key => $config) { - foreach ($config as $pubsub) { - try { - $adapter = new PubSubPool($pools->get($pubsub)); - - $checkStart = \microtime(true); - - if ($adapter->ping()) { - $output[] = new Document([ - 'name' => $key . " ($pubsub)", - 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000) - ]); - } else { - $failures[] = $pubsub; - } - } catch (\Throwable) { - $failures[] = $pubsub; - } - } - } - - if (!empty($failures)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Pubsub failure on: ' . implode(", ", $failures)); - } - - $response->dynamic(new Document([ - 'statuses' => $output, - 'total' => count($output), - ]), Response::MODEL_HEALTH_STATUS_LIST); - }); - -App::get('/v1/health/time') - ->desc('Get time') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'health', - name: 'getTime', - description: '/docs/references/health/get-time.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_TIME, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->action(function (Response $response) { - - /* - * Code from: @see https://www.beliefmedia.com.au/query-ntp-time-server - */ - $host = 'time.google.com'; // https://developers.google.com/time/ - $gap = 60; // Allow [X] seconds gap - - /* Create a socket and connect to NTP server */ - $sock = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); - - \socket_connect($sock, $host, 123); - - /* Send request */ - $msg = "\010" . \str_repeat("\0", 47); - - \socket_send($sock, $msg, \strlen($msg), 0); - - /* Receive response and close socket */ - \socket_recv($sock, $recv, 48, MSG_WAITALL); - \socket_close($sock); - - /* Interpret response */ - $data = \unpack('N12', $recv); - $timestamp = \sprintf('%u', $data[9]); - - /* NTP is number of seconds since 0000 UT on 1 January 1900 - Unix time is seconds since 0000 UT on 1 January 1970 */ - $timestamp -= 2208988800; - - $diff = ($timestamp - \time()); - - if ($diff > $gap || $diff < ($gap * -1)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Server time gaps detected'); - } - - $output = [ - 'remoteTime' => $timestamp, - 'localTime' => \time(), - 'diff' => $diff - ]; - - $response->dynamic(new Document($output), Response::MODEL_HEALTH_TIME); - }); - -App::get('/v1/health/queue/webhooks') - ->desc('Get webhooks queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueWebhooks', - description: '/docs/references/health/get-queue-webhooks.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForWebhooks') - ->inject('response') - ->action(function (int|string $threshold, Webhook $queueForWebhooks, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForWebhooks->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/logs') - ->desc('Get logs queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueLogs', - description: '/docs/references/health/get-queue-logs.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForAudits') - ->inject('response') - ->action(function (int|string $threshold, Audit $queueForAudits, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForAudits->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/certificate') - ->desc('Get the SSL certificate for a domain') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'health', - name: 'getCertificate', - description: '/docs/references/health/get-certificate.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_CERTIFICATE, - ) - ], - contentType: ContentType::JSON - )) - ->param('domain', null, new Multiple([new AnyOf([new URL(), new Domain()]), new PublicDomain()]), Multiple::TYPE_STRING, 'Domain name') - ->inject('response') - ->action(function (string $domain, Response $response) { - if (filter_var($domain, FILTER_VALIDATE_URL)) { - $domain = parse_url($domain, PHP_URL_HOST); - } - - $sslContext = stream_context_create([ - "ssl" => [ - "capture_peer_cert" => true - ] - ]); - $sslSocket = stream_socket_client("ssl://" . $domain . ":443", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $sslContext); - if (!$sslSocket) { - throw new Exception(Exception::HEALTH_INVALID_HOST); - } - - $streamContextParams = stream_context_get_params($sslSocket); - $peerCertificate = $streamContextParams['options']['ssl']['peer_certificate']; - $certificatePayload = openssl_x509_parse($peerCertificate); - - - $sslExpiration = $certificatePayload['validTo_time_t']; - $status = $sslExpiration < time() ? 'fail' : 'pass'; - - if ($status === 'fail') { - throw new Exception(Exception::HEALTH_CERTIFICATE_EXPIRED); - } - - $response->dynamic(new Document([ - 'name' => $certificatePayload['name'], - 'subjectSN' => $certificatePayload['subject']['CN'], - 'issuerOrganisation' => $certificatePayload['issuer']['O'], - 'validFrom' => $certificatePayload['validFrom_time_t'], - 'validTo' => $certificatePayload['validTo_time_t'], - 'signatureTypeSN' => $certificatePayload['signatureTypeSN'], - ]), Response::MODEL_HEALTH_CERTIFICATE); - }); - -App::get('/v1/health/queue/certificates') - ->desc('Get certificates queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueCertificates', - description: '/docs/references/health/get-queue-certificates.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForCertificates') - ->inject('response') - ->action(function (int|string $threshold, Certificate $queueForCertificates, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForCertificates->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/builds') - ->desc('Get builds queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueBuilds', - description: '/docs/references/health/get-queue-builds.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForBuilds') - ->inject('response') - ->action(function (int|string $threshold, Build $queueForBuilds, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForBuilds->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/databases') - ->desc('Get databases queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueDatabases', - description: '/docs/references/health/get-queue-databases.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('name', 'database_db_main', new Text(256), 'Queue name for which to check the queue size', true) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForDatabase') - ->inject('response') - ->action(function (string $name, int|string $threshold, Database $queueForDatabase, Response $response) { - $threshold = \intval($threshold); - $size = $queueForDatabase->setQueue($name)->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/deletes') - ->desc('Get deletes queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueDeletes', - description: '/docs/references/health/get-queue-deletes.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForDeletes') - ->inject('response') - ->action(function (int|string $threshold, Delete $queueForDeletes, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForDeletes->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/mails') - ->desc('Get mails queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueMails', - description: '/docs/references/health/get-queue-mails.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForMails') - ->inject('response') - ->action(function (int|string $threshold, Mail $queueForMails, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForMails->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/messaging') - ->desc('Get messaging queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueMessaging', - description: '/docs/references/health/get-queue-messaging.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForMessaging') - ->inject('response') - ->action(function (int|string $threshold, Messaging $queueForMessaging, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForMessaging->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/migrations') - ->desc('Get migrations queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueMigrations', - description: '/docs/references/health/get-queue-migrations.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForMigrations') - ->inject('response') - ->action(function (int|string $threshold, Migration $queueForMigrations, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForMigrations->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/functions') - ->desc('Get functions queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueFunctions', - description: '/docs/references/health/get-queue-functions.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForFunctions') - ->inject('response') - ->action(function (int|string $threshold, Func $queueForFunctions, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForFunctions->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/stats-resources') - ->desc('Get stats resources queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueStatsResources', - description: '/docs/references/health/get-queue-stats-resources.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForStatsResources') - ->inject('response') - ->action(function (int|string $threshold, StatsResources $queueForStatsResources, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForStatsResources->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/queue/stats-usage') - ->desc('Get stats usage queue') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getQueueUsage', - description: '/docs/references/health/get-queue-stats-usage.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForStatsUsage') - ->inject('response') - ->action(function (int|string $threshold, StatsUsage $queueForStatsUsage, Response $response) { - $threshold = \intval($threshold); - - $size = $queueForStatsUsage->getSize(); - - if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $size ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/storage/local') - ->desc('Get local storage') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'storage', - name: 'getStorageLocal', - description: '/docs/references/health/get-storage-local.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->action(function (Response $response) { - - $checkStart = \microtime(true); - - foreach ( - [ - 'Uploads' => APP_STORAGE_UPLOADS, - 'Cache' => APP_STORAGE_CACHE, - 'Config' => APP_STORAGE_CONFIG, - 'Certs' => APP_STORAGE_CERTIFICATES - ] as $key => $volume - ) { - $device = new Local($volume); - - if (!\is_readable($device->getRoot())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Device ' . $key . ' dir is not readable'); - } - - if (!\is_writable($device->getRoot())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Device ' . $key . ' dir is not writable'); - } - } - - $output = [ - 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000) - ]; - - $response->dynamic(new Document($output), Response::MODEL_HEALTH_STATUS); - }); - -App::get('/v1/health/storage') - ->desc('Get storage') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'storage', - name: 'getStorage', - description: '/docs/references/health/get-storage.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->inject('deviceForFiles') - ->inject('deviceForFunctions') - ->inject('deviceForSites') - ->inject('deviceForBuilds') - ->action(function (Response $response, Device $deviceForFiles, Device $deviceForFunctions, Device $deviceForSites, Device $deviceForBuilds) { - $devices = [$deviceForFiles, $deviceForFunctions, $deviceForSites, $deviceForBuilds]; - $checkStart = \microtime(true); - - foreach ($devices as $device) { - $uniqueFileName = \uniqid('health', true); - $filePath = $device->getPath($uniqueFileName); - - if (!$device->write($filePath, 'test', 'text/plain')) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed writing test file to ' . $device->getRoot()); - } - - if ($device->read($filePath) !== 'test') { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed reading test file from ' . $device->getRoot()); - } - - if (!$device->delete($filePath)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed deleting test file from ' . $device->getRoot()); - } - } - - $output = [ - 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000) - ]; - - $response->dynamic(new Document($output), Response::MODEL_HEALTH_STATUS); - }); - -App::get('/v1/health/anti-virus') - ->desc('Get antivirus') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'health', - name: 'getAntivirus', - description: '/docs/references/health/get-storage-anti-virus.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_ANTIVIRUS, - ) - ], - contentType: ContentType::JSON - )) - ->inject('response') - ->action(function (Response $response) { - - $output = [ - 'status' => '', - 'version' => '' - ]; - - if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'disabled') { // Check if scans are enabled - $output['status'] = 'disabled'; - $output['version'] = ''; - } else { - $antivirus = new Network( - System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), - (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) - ); - - try { - $output['version'] = @$antivirus->version(); - $output['status'] = (@$antivirus->ping()) ? 'pass' : 'fail'; - } catch (\Throwable $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Antivirus is not available'); - } - } - - $response->dynamic(new Document($output), Response::MODEL_HEALTH_ANTIVIRUS); - }); - -App::get('/v1/health/queue/failed/:name') - ->desc('Get number of failed queue jobs') - ->groups(['api', 'health']) - ->label('scope', 'health.read') - ->label('sdk', new Method( - namespace: 'health', - group: 'queue', - name: 'getFailedJobs', - description: '/docs/references/health/get-failed-queue-jobs.md', - auth: [AuthType::ADMIN, AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_QUEUE, - ) - ], - contentType: ContentType::JSON - )) - ->param('name', '', new WhiteList([ - System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME), - System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME), - System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME), - System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME), - System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), - System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME), - System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME), - System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME), - System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME), - System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME), - System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME), - System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) - ]), 'The name of the queue') - ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('response') - ->inject('queueForDatabase') - ->inject('queueForDeletes') - ->inject('queueForAudits') - ->inject('queueForMails') - ->inject('queueForFunctions') - ->inject('queueForStatsResources') - ->inject('queueForStatsUsage') - ->inject('queueForWebhooks') - ->inject('queueForCertificates') - ->inject('queueForBuilds') - ->inject('queueForMessaging') - ->inject('queueForMigrations') - ->action(function ( - string $name, - int|string $threshold, - Response $response, - Database $queueForDatabase, - Delete $queueForDeletes, - Audit $queueForAudits, - Mail $queueForMails, - Func $queueForFunctions, - StatsResources $queueForStatsResources, - StatsUsage $queueForStatsUsage, - Webhook $queueForWebhooks, - Certificate $queueForCertificates, - Build $queueForBuilds, - Messaging $queueForMessaging, - Migration $queueForMigrations - ) { - $threshold = \intval($threshold); - - /** @var Event $queue */ - $queue = match ($name) { - System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase, - System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes, - System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits, - System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails, - System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions, - System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $queueForStatsResources, - System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $queueForStatsUsage, - System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, - System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates, - System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, - System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, - System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $queueForMigrations, - }; - $failed = $queue->getSize(failed: true); - - if ($failed >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue failed jobs threshold hit. Current size is {$failed} and threshold is {$threshold}."); - } - - $response->dynamic(new Document([ 'size' => $failed ]), Response::MODEL_HEALTH_QUEUE); - }); - -App::get('/v1/health/stats') // Currently only used internally -->desc('Get system stats') - ->groups(['api', 'health']) - ->label('scope', 'root') - ->label('docs', false) - ->inject('response') - ->inject('register') - ->inject('deviceForFiles') - ->action(function (Response $response, Registry $register, Device $deviceForFiles) { - - $cache = $register->get('cache'); - - $cacheStats = $cache->info(); - - $response - ->json([ - 'storage' => [ - 'used' => Storage::human($deviceForFiles->getDirectorySize($deviceForFiles->getRoot() . '/')), - 'partitionTotal' => Storage::human($deviceForFiles->getPartitionTotalSpace()), - 'partitionFree' => Storage::human($deviceForFiles->getPartitionFreeSpace()), - ], - 'cache' => [ - 'uptime' => $cacheStats['uptime_in_seconds'] ?? 0, - 'clients' => $cacheStats['connected_clients'] ?? 0, - 'hits' => $cacheStats['keyspace_hits'] ?? 0, - 'misses' => $cacheStats['keyspace_misses'] ?? 0, - 'memory_used' => $cacheStats['used_memory'] ?? 0, - 'memory_used_human' => $cacheStats['used_memory_human'] ?? 0, - 'memory_used_peak' => $cacheStats['used_memory_peak'] ?? 0, - 'memory_used_peak_human' => $cacheStats['used_memory_peak_human'] ?? 0, - ], - ]); - }); diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index a34c79308a..c24980d637 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -7,6 +7,7 @@ use Appwrite\Platform\Modules\Console; use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; +use Appwrite\Platform\Modules\Health; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; @@ -23,6 +24,7 @@ class Appwrite extends Platform $this->addModule(new Databases\Module()); $this->addModule(new Projects\Module()); $this->addModule(new Functions\Module()); + $this->addModule(new Health\Module()); $this->addModule(new Sites\Module()); $this->addModule(new Console\Module()); $this->addModule(new Proxy\Module()); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/AntiVirus/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/AntiVirus/Get.php new file mode 100644 index 0000000000..1ebdff4317 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/AntiVirus/Get.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/anti-virus') + ->desc('Get antivirus') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'health', + name: 'getAntivirus', + description: '/docs/references/health/get-storage-anti-virus.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_ANTIVIRUS, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $output = [ + 'status' => '', + 'version' => '', + ]; + + if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'disabled') { + $output['status'] = 'disabled'; + $output['version'] = ''; + } else { + $antivirus = new Network( + System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'), + (int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310) + ); + + try { + $output['version'] = @$antivirus->version(); + $output['status'] = (@$antivirus->ping()) ? 'pass' : 'fail'; + } catch (\Throwable) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Antivirus is not available'); + } + } + + $response->dynamic(new Document($output), Response::MODEL_HEALTH_ANTIVIRUS); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php new file mode 100644 index 0000000000..572d449f20 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php @@ -0,0 +1,94 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/cache') + ->desc('Get cache') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'health', + name: 'getCache', + description: '/docs/references/health/get-cache.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_STATUS, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->inject('pools') + ->callback($this->action(...)); + } + + public function action(Response $response, Group $pools): void + { + $output = []; + $failures = []; + + $configs = [ + 'Cache' => Config::getParam('pools-cache'), + ]; + + foreach ($configs as $key => $config) { + foreach ($config as $cache) { + try { + $adapter = new CachePool($pools->get($cache)); + + $checkStart = \microtime(true); + + if ($adapter->ping()) { + $output[] = new Document([ + 'name' => $key . " ($cache)", + 'status' => 'pass', + 'ping' => \round((\microtime(true) - $checkStart) / 1000), + ]); + } else { + $failures[] = $cache; + } + } catch (\Throwable) { + $failures[] = $cache; + } + } + } + + if (!empty($failures)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Cache failure on: ' . \implode(', ', $failures)); + } + + $response->dynamic(new Document([ + 'statuses' => $output, + 'total' => \count($output), + ]), Response::MODEL_HEALTH_STATUS_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php new file mode 100644 index 0000000000..f7b24c19fa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -0,0 +1,92 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/certificate') + ->desc('Get the SSL certificate for a domain') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'health', + name: 'getCertificate', + description: '/docs/references/health/get-certificate.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_CERTIFICATE, + ) + ], + contentType: ContentType::JSON + )) + ->param('domain', null, new Multiple([new AnyOf([new URL(), new Domain()]), new PublicDomain()]), Multiple::TYPE_STRING, 'Domain name') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $domain, Response $response): void + { + if (filter_var($domain, FILTER_VALIDATE_URL)) { + $domain = parse_url($domain, PHP_URL_HOST); + } + + $sslContext = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + ], + ]); + $sslSocket = stream_socket_client('ssl://' . $domain . ':443', $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $sslContext); + if (!$sslSocket) { + throw new Exception(Exception::HEALTH_INVALID_HOST); + } + + $streamContextParams = stream_context_get_params($sslSocket); + $peerCertificate = $streamContextParams['options']['ssl']['peer_certificate']; + $certificatePayload = openssl_x509_parse($peerCertificate); + + $sslExpiration = $certificatePayload['validTo_time_t']; + $status = $sslExpiration < time() ? 'fail' : 'pass'; + + if ($status === 'fail') { + throw new Exception(Exception::HEALTH_CERTIFICATE_EXPIRED); + } + + $response->dynamic(new Document([ + 'name' => $certificatePayload['name'], + 'subjectSN' => $certificatePayload['subject']['CN'], + 'issuerOrganisation' => $certificatePayload['issuer']['O'], + 'validFrom' => $certificatePayload['validFrom_time_t'], + 'validTo' => $certificatePayload['validTo_time_t'], + 'signatureTypeSN' => $certificatePayload['signatureTypeSN'], + ]), Response::MODEL_HEALTH_CERTIFICATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php new file mode 100644 index 0000000000..dfd83b0273 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php @@ -0,0 +1,95 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/db') + ->desc('Get DB') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'health', + name: 'getDB', + description: '/docs/references/health/get-db.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_STATUS, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->inject('pools') + ->callback($this->action(...)); + } + + public function action(Response $response, Group $pools): void + { + $output = []; + $failures = []; + + $configs = [ + 'Console.DB' => Config::getParam('pools-console'), + 'Projects.DB' => Config::getParam('pools-database'), + ]; + + foreach ($configs as $key => $config) { + foreach ($config as $database) { + try { + $adapter = new DatabasePool($pools->get($database)); + + $checkStart = \microtime(true); + + if ($adapter->ping()) { + $output[] = new Document([ + 'name' => $key . " ($database)", + 'status' => 'pass', + 'ping' => \round((\microtime(true) - $checkStart) / 1000), + ]); + } else { + $failures[] = $database; + } + } catch (\Throwable) { + $failures[] = $database; + } + } + } + + if (!empty($failures)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'DB failure on: ' . \implode(', ', $failures)); + } + + $response->dynamic(new Document([ + 'statuses' => $output, + 'total' => \count($output), + ]), Response::MODEL_HEALTH_STATUS_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Get.php new file mode 100644 index 0000000000..818e122054 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Get.php @@ -0,0 +1,57 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health') + ->desc('Get HTTP') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'health', + name: 'get', + description: '/docs/references/health/get.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_STATUS, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $response->dynamic(new Document([ + 'name' => 'http', + 'status' => 'pass', + 'ping' => 0, + ]), Response::MODEL_HEALTH_STATUS); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php new file mode 100644 index 0000000000..c71a30bf53 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php @@ -0,0 +1,94 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/pubsub') + ->desc('Get pubsub') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'health', + name: 'getPubSub', + description: '/docs/references/health/get-pubsub.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_STATUS, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->inject('pools') + ->callback($this->action(...)); + } + + public function action(Response $response, Group $pools): void + { + $output = []; + $failures = []; + + $configs = [ + 'PubSub' => Config::getParam('pools-pubsub'), + ]; + + foreach ($configs as $key => $config) { + foreach ($config as $pubsub) { + try { + $adapter = new PubSubPool($pools->get($pubsub)); + + $checkStart = \microtime(true); + + if ($adapter->ping()) { + $output[] = new Document([ + 'name' => $key . " ($pubsub)", + 'status' => 'pass', + 'ping' => \round((\microtime(true) - $checkStart) / 1000), + ]); + } else { + $failures[] = $pubsub; + } + } catch (\Throwable) { + $failures[] = $pubsub; + } + } + } + + if (!empty($failures)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Pubsub failure on: ' . \implode(', ', $failures)); + } + + $response->dynamic(new Document([ + 'statuses' => $output, + 'total' => \count($output), + ]), Response::MODEL_HEALTH_STATUS_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php new file mode 100644 index 0000000000..72fdf801b5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php @@ -0,0 +1,26 @@ += $threshold) { + throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); + } + } + + protected function assertFailedQueueThreshold(int $failed, int $threshold): void + { + if ($failed >= $threshold) { + throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue failed jobs threshold hit. Current size is {$failed} and threshold is {$threshold}."); + } + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php new file mode 100644 index 0000000000..8ae7c8687a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/builds') + ->desc('Get builds queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueBuilds', + description: '/docs/references/health/get-queue-builds.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForBuilds') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Build $queueForBuilds, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForBuilds->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php new file mode 100644 index 0000000000..6724f25094 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/certificates') + ->desc('Get certificates queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueCertificates', + description: '/docs/references/health/get-queue-certificates.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForCertificates') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Certificate $queueForCertificates, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForCertificates->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php new file mode 100644 index 0000000000..213bd8b36c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Databases/Get.php @@ -0,0 +1,61 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/databases') + ->desc('Get databases queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueDatabases', + description: '/docs/references/health/get-queue-databases.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('name', 'database_db_main', new Text(256), 'Queue name for which to check the queue size', true) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForDatabase') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $name, int|string $threshold, Database $queueForDatabase, Response $response): void + { + $threshold = (int) $threshold; + $size = $queueForDatabase->setQueue($name)->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php new file mode 100644 index 0000000000..816583fc47 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Deletes/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/deletes') + ->desc('Get deletes queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueDeletes', + description: '/docs/references/health/get-queue-deletes.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForDeletes') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Delete $queueForDeletes, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForDeletes->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php new file mode 100644 index 0000000000..652b1a504c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -0,0 +1,128 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/failed/:name') + ->desc('Get number of failed queue jobs') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getFailedJobs', + description: '/docs/references/health/get-failed-queue-jobs.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('name', '', new WhiteList([ + System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME), + System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME), + System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME), + System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME), + System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME), + System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME), + System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME), + System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME), + System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME), + System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME), + System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME), + System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME), + ]), 'The name of the queue') + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('response') + ->inject('queueForDatabase') + ->inject('queueForDeletes') + ->inject('queueForAudits') + ->inject('queueForMails') + ->inject('queueForFunctions') + ->inject('queueForStatsResources') + ->inject('queueForStatsUsage') + ->inject('queueForWebhooks') + ->inject('queueForCertificates') + ->inject('queueForBuilds') + ->inject('queueForMessaging') + ->inject('queueForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $name, + int|string $threshold, + Response $response, + Database $queueForDatabase, + Delete $queueForDeletes, + Audit $queueForAudits, + Mail $queueForMails, + Func $queueForFunctions, + StatsResources $queueForStatsResources, + StatsUsage $queueForStatsUsage, + Webhook $queueForWebhooks, + Certificate $queueForCertificates, + Build $queueForBuilds, + Messaging $queueForMessaging, + Migration $queueForMigrations + ): void { + $threshold = (int) $threshold; + + $queue = match ($name) { + System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase, + System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes, + System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits, + System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails, + System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions, + System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $queueForStatsResources, + System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $queueForStatsUsage, + System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, + System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates, + System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, + System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, + System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $queueForMigrations, + }; + $failed = $queue->getSize(failed: true); + + $this->assertFailedQueueThreshold($failed, $threshold); + + $response->dynamic(new Document(['size' => $failed]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php new file mode 100644 index 0000000000..1d10b8d1a0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Functions/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/functions') + ->desc('Get functions queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueFunctions', + description: '/docs/references/health/get-queue-functions.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForFunctions') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Func $queueForFunctions, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForFunctions->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php new file mode 100644 index 0000000000..dd05aebc39 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/logs') + ->desc('Get logs queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueLogs', + description: '/docs/references/health/get-queue-logs.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForAudits') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForAudits->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php new file mode 100644 index 0000000000..3b9c06b5f9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/mails') + ->desc('Get mails queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueMails', + description: '/docs/references/health/get-queue-mails.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForMails') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Mail $queueForMails, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForMails->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php new file mode 100644 index 0000000000..db2d7d7172 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/messaging') + ->desc('Get messaging queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueMessaging', + description: '/docs/references/health/get-queue-messaging.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForMessaging') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Messaging $queueForMessaging, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForMessaging->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php new file mode 100644 index 0000000000..4faca7d8a4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Migrations/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/migrations') + ->desc('Get migrations queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueMigrations', + description: '/docs/references/health/get-queue-migrations.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForMigrations') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Migration $queueForMigrations, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForMigrations->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php new file mode 100644 index 0000000000..57605298fd --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsResources/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/stats-resources') + ->desc('Get stats resources queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueStatsResources', + description: '/docs/references/health/get-queue-stats-resources.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForStatsResources') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, StatsResources $queueForStatsResources, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForStatsResources->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php new file mode 100644 index 0000000000..10678efbc3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/StatsUsage/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/stats-usage') + ->desc('Get stats usage queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueUsage', + description: '/docs/references/health/get-queue-stats-usage.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForStatsUsage') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, StatsUsage $queueForStatsUsage, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForStatsUsage->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Webhooks/Get.php new file mode 100644 index 0000000000..3eef1818a7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Webhooks/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/webhooks') + ->desc('Get webhooks queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueWebhooks', + description: '/docs/references/health/get-queue-webhooks.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForWebhooks') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Webhook $queueForWebhooks, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForWebhooks->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Stats/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Stats/Get.php new file mode 100644 index 0000000000..0d845ebba0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Stats/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/stats') + ->desc('Get system stats') + ->groups(['api', 'health']) + ->label('scope', 'root') + ->label('docs', false) + ->inject('response') + ->inject('register') + ->inject('deviceForFiles') + ->callback($this->action(...)); + } + + public function action(Response $response, Registry $register, Device $deviceForFiles): void + { + $cache = $register->get('cache'); + + $cacheStats = $cache->info(); + + $response->json([ + 'storage' => [ + 'used' => Storage::human($deviceForFiles->getDirectorySize($deviceForFiles->getRoot() . '/')), + 'partitionTotal' => Storage::human($deviceForFiles->getPartitionTotalSpace()), + 'partitionFree' => Storage::human($deviceForFiles->getPartitionFreeSpace()), + ], + 'cache' => [ + 'uptime' => $cacheStats['uptime_in_seconds'] ?? 0, + 'clients' => $cacheStats['connected_clients'] ?? 0, + 'hits' => $cacheStats['keyspace_hits'] ?? 0, + 'misses' => $cacheStats['keyspace_misses'] ?? 0, + 'memory_used' => $cacheStats['used_memory'] ?? 0, + 'memory_used_human' => $cacheStats['used_memory_human'] ?? 0, + 'memory_used_peak' => $cacheStats['used_memory_peak'] ?? 0, + 'memory_used_peak_human' => $cacheStats['used_memory_peak_human'] ?? 0, + ], + ]); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php new file mode 100644 index 0000000000..975f8846c0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php @@ -0,0 +1,82 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/storage') + ->desc('Get storage') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'storage', + name: 'getStorage', + description: '/docs/references/health/get-storage.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_STATUS, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->inject('deviceForFiles') + ->inject('deviceForFunctions') + ->inject('deviceForSites') + ->inject('deviceForBuilds') + ->callback($this->action(...)); + } + + public function action(Response $response, Device $deviceForFiles, Device $deviceForFunctions, Device $deviceForSites, Device $deviceForBuilds): void + { + $devices = [$deviceForFiles, $deviceForFunctions, $deviceForSites, $deviceForBuilds]; + $checkStart = \microtime(true); + + foreach ($devices as $device) { + $uniqueFileName = \uniqid('health', true); + $filePath = $device->getPath($uniqueFileName); + + if (!$device->write($filePath, 'test', 'text/plain')) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed writing test file to ' . $device->getRoot()); + } + + if ($device->read($filePath) !== 'test') { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed reading test file from ' . $device->getRoot()); + } + + if (!$device->delete($filePath)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed deleting test file from ' . $device->getRoot()); + } + } + + $response->dynamic(new Document([ + 'status' => 'pass', + 'ping' => \round((\microtime(true) - $checkStart) / 1000), + ]), Response::MODEL_HEALTH_STATUS); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php new file mode 100644 index 0000000000..3a4fc47238 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php @@ -0,0 +1,79 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/storage/local') + ->desc('Get local storage') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'storage', + name: 'getStorageLocal', + description: '/docs/references/health/get-storage-local.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_STATUS, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $checkStart = \microtime(true); + + foreach ( + [ + 'Uploads' => APP_STORAGE_UPLOADS, + 'Cache' => APP_STORAGE_CACHE, + 'Config' => APP_STORAGE_CONFIG, + 'Certs' => APP_STORAGE_CERTIFICATES, + ] as $key => $volume + ) { + $device = new Local($volume); + + if (!\is_readable($device->getRoot())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Device ' . $key . ' dir is not readable'); + } + + if (!\is_writable($device->getRoot())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Device ' . $key . ' dir is not writable'); + } + } + + $response->dynamic(new Document([ + 'status' => 'pass', + 'ping' => \round((\microtime(true) - $checkStart) / 1000), + ]), Response::MODEL_HEALTH_STATUS); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php new file mode 100644 index 0000000000..3636515cb0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php @@ -0,0 +1,83 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/time') + ->desc('Get time') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'health', + name: 'getTime', + description: '/docs/references/health/get-time.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_TIME, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $host = 'time.google.com'; + $gap = 60; + + $sock = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + + \socket_connect($sock, $host, 123); + + $msg = "\010" . \str_repeat("\0", 47); + + \socket_send($sock, $msg, \strlen($msg), 0); + + \socket_recv($sock, $recv, 48, MSG_WAITALL); + \socket_close($sock); + + $data = \unpack('N12', $recv); + $timestamp = \sprintf('%u', $data[9]); + + $timestamp -= 2208988800; + + $diff = ($timestamp - \time()); + + if ($diff > $gap || $diff < ($gap * -1)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Server time gaps detected'); + } + + $response->dynamic(new Document([ + 'remoteTime' => $timestamp, + 'localTime' => \time(), + 'diff' => $diff, + ]), Response::MODEL_HEALTH_TIME); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Version/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Version/Get.php new file mode 100644 index 0000000000..302a953971 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Version/Get.php @@ -0,0 +1,35 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/version') + ->desc('Get version') + ->groups(['api', 'health']) + ->label('scope', 'public') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $response->dynamic(new Document(['version' => APP_VERSION_STABLE]), Response::MODEL_HEALTH_VERSION); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Module.php b/src/Appwrite/Platform/Modules/Health/Module.php new file mode 100644 index 0000000000..7aaee2ddca --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Services/Http.php b/src/Appwrite/Platform/Modules/Health/Services/Http.php new file mode 100644 index 0000000000..b0fb5573fa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Services/Http.php @@ -0,0 +1,64 @@ +type = Service::TYPE_HTTP; + + $this->addAction(GetHealth::getName(), new GetHealth()); + $this->addAction(GetHealthVersion::getName(), new GetHealthVersion()); + $this->addAction(GetDB::getName(), new GetDB()); + $this->addAction(GetCache::getName(), new GetCache()); + $this->addAction(GetPubSub::getName(), new GetPubSub()); + $this->addAction(GetTime::getName(), new GetTime()); + $this->addAction(GetCertificate::getName(), new GetCertificate()); + $this->addAction(GetStorageLocal::getName(), new GetStorageLocal()); + $this->addAction(GetStorage::getName(), new GetStorage()); + $this->addAction(GetAntivirus::getName(), new GetAntivirus()); + + $this->addAction(GetQueueWebhooks::getName(), new GetQueueWebhooks()); + $this->addAction(GetQueueLogs::getName(), new GetQueueLogs()); + $this->addAction(GetQueueCertificates::getName(), new GetQueueCertificates()); + $this->addAction(GetQueueBuilds::getName(), new GetQueueBuilds()); + $this->addAction(GetQueueDatabases::getName(), new GetQueueDatabases()); + $this->addAction(GetQueueDeletes::getName(), new GetQueueDeletes()); + $this->addAction(GetQueueMails::getName(), new GetQueueMails()); + $this->addAction(GetQueueMessaging::getName(), new GetQueueMessaging()); + $this->addAction(GetQueueMigrations::getName(), new GetQueueMigrations()); + $this->addAction(GetQueueFunctions::getName(), new GetQueueFunctions()); + $this->addAction(GetQueueStatsResources::getName(), new GetQueueStatsResources()); + $this->addAction(GetQueueUsage::getName(), new GetQueueUsage()); + $this->addAction(GetFailedJobs::getName(), new GetFailedJobs()); + + $this->addAction(GetStats::getName(), new GetStats()); + } +} From 9c6a6c265a6e3d6129d5673310c763d9415318e6 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 05:45:14 +0000 Subject: [PATCH 08/34] format --- .../Health/Http/Health/Queue/Audits/Get.php | 60 +++++++++++++++++++ .../Platform/Modules/Health/Services/Http.php | 10 ++-- 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php new file mode 100644 index 0000000000..e01e89641d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/audits') + ->desc('Get audits queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueAudits', + description: '/docs/references/health/get-queue-audits.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForAudits') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForAudits->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Services/Http.php b/src/Appwrite/Platform/Modules/Health/Services/Http.php index b0fb5573fa..f1196fa5e8 100644 --- a/src/Appwrite/Platform/Modules/Health/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Health/Services/Http.php @@ -8,11 +8,6 @@ use Appwrite\Platform\Modules\Health\Http\Health\Certificate\Get as GetCertifica use Appwrite\Platform\Modules\Health\Http\Health\DB\Get as GetDB; use Appwrite\Platform\Modules\Health\Http\Health\Get as GetHealth; use Appwrite\Platform\Modules\Health\Http\Health\PubSub\Get as GetPubSub; -use Appwrite\Platform\Modules\Health\Http\Health\Stats\Get as GetStats; -use Appwrite\Platform\Modules\Health\Http\Health\Time\Get as GetTime; -use Appwrite\Platform\Modules\Health\Http\Health\Version\Get as GetHealthVersion; -use Appwrite\Platform\Modules\Health\Http\Health\Storage\Get as GetStorage; -use Appwrite\Platform\Modules\Health\Http\Health\Storage\Local\Get as GetStorageLocal; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds\Get as GetQueueBuilds; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Certificates\Get as GetQueueCertificates; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Databases\Get as GetQueueDatabases; @@ -26,6 +21,11 @@ use Appwrite\Platform\Modules\Health\Http\Health\Queue\Migrations\Get as GetQueu use Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsResources\Get as GetQueueStatsResources; use Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsUsage\Get as GetQueueUsage; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Webhooks\Get as GetQueueWebhooks; +use Appwrite\Platform\Modules\Health\Http\Health\Stats\Get as GetStats; +use Appwrite\Platform\Modules\Health\Http\Health\Storage\Get as GetStorage; +use Appwrite\Platform\Modules\Health\Http\Health\Storage\Local\Get as GetStorageLocal; +use Appwrite\Platform\Modules\Health\Http\Health\Time\Get as GetTime; +use Appwrite\Platform\Modules\Health\Http\Health\Version\Get as GetHealthVersion; use Utopia\Platform\Service; class Http extends Service From 28aa4e8a8df727631547c90100ba768823c3c980 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 05:49:20 +0000 Subject: [PATCH 09/34] refactor and new endpoint test --- .../Modules/Health/Http/Health/Queue/Base.php | 12 +++------ .../Health/Http/Health/Queue/Failed/Get.php | 2 +- .../Platform/Modules/Health/Services/Http.php | 2 ++ .../Health/HealthCustomServerTest.php | 26 +++++++++++++++++++ 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php index 72fdf801b5..d5cf87a0cd 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Base.php @@ -10,17 +10,11 @@ abstract class Base extends Action { use HTTP; - protected function assertQueueThreshold(int $size, int $threshold): void + protected function assertQueueThreshold(int $size, int $threshold, bool $failed = false): void { if ($size >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); - } - } - - protected function assertFailedQueueThreshold(int $failed, int $threshold): void - { - if ($failed >= $threshold) { - throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue failed jobs threshold hit. Current size is {$failed} and threshold is {$threshold}."); + $context = $failed ? 'failed jobs' : 'jobs'; + throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue {$context} threshold hit. Current value is {$size} and threshold is {$threshold}."); } } } diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 652b1a504c..9832e5d89f 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -121,7 +121,7 @@ class Get extends Base }; $failed = $queue->getSize(failed: true); - $this->assertFailedQueueThreshold($failed, $threshold); + $this->assertQueueThreshold($failed, $threshold, true); $response->dynamic(new Document(['size' => $failed]), Response::MODEL_HEALTH_QUEUE); } diff --git a/src/Appwrite/Platform/Modules/Health/Services/Http.php b/src/Appwrite/Platform/Modules/Health/Services/Http.php index f1196fa5e8..54c6f9ad6d 100644 --- a/src/Appwrite/Platform/Modules/Health/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Health/Services/Http.php @@ -8,6 +8,7 @@ use Appwrite\Platform\Modules\Health\Http\Health\Certificate\Get as GetCertifica use Appwrite\Platform\Modules\Health\Http\Health\DB\Get as GetDB; use Appwrite\Platform\Modules\Health\Http\Health\Get as GetHealth; use Appwrite\Platform\Modules\Health\Http\Health\PubSub\Get as GetPubSub; +use Appwrite\Platform\Modules\Health\Http\Health\Queue\Audits\Get as GetQueueAudits; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds\Get as GetQueueBuilds; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Certificates\Get as GetQueueCertificates; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Databases\Get as GetQueueDatabases; @@ -45,6 +46,7 @@ class Http extends Service $this->addAction(GetStorage::getName(), new GetStorage()); $this->addAction(GetAntivirus::getName(), new GetAntivirus()); + $this->addAction(GetQueueAudits::getName(), new GetQueueAudits()); $this->addAction(GetQueueWebhooks::getName(), new GetQueueWebhooks()); $this->addAction(GetQueueLogs::getName(), new GetQueueLogs()); $this->addAction(GetQueueCertificates::getName(), new GetQueueCertificates()); diff --git a/tests/e2e/Services/Health/HealthCustomServerTest.php b/tests/e2e/Services/Health/HealthCustomServerTest.php index 4b7062dc22..a5a6bf29f7 100644 --- a/tests/e2e/Services/Health/HealthCustomServerTest.php +++ b/tests/e2e/Services/Health/HealthCustomServerTest.php @@ -370,6 +370,32 @@ class HealthCustomServerTest extends Scope return []; } + public function testAuditsSuccess(): array + { + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/health/queue/audits', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_GET, '/health/queue/audits?threshold=0', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + $this->assertEquals(503, $response['headers']['status-code']); + + return []; + } + public function testStorageLocalSuccess(): array { /** From 4ef906b836123b78b461de6dfd25144d4f200330 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 13:15:28 +0545 Subject: [PATCH 10/34] Update src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Health/Http/Health/Certificate/Get.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index f7b24c19fa..c2c33fe6df 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -72,6 +72,12 @@ class Get extends Action $streamContextParams = stream_context_get_params($sslSocket); $peerCertificate = $streamContextParams['options']['ssl']['peer_certificate']; $certificatePayload = openssl_x509_parse($peerCertificate); + + fclose($sslSocket); // Close the socket to prevent resource leak + + if ($certificatePayload === false) { + throw new Exception(Exception::HEALTH_INVALID_HOST); + } $sslExpiration = $certificatePayload['validTo_time_t']; $status = $sslExpiration < time() ? 'fail' : 'pass'; @@ -81,12 +87,11 @@ class Get extends Action } $response->dynamic(new Document([ - 'name' => $certificatePayload['name'], - 'subjectSN' => $certificatePayload['subject']['CN'], - 'issuerOrganisation' => $certificatePayload['issuer']['O'], + 'name' => $certificatePayload['name'] ?? '', + 'subjectCN' => $certificatePayload['subject']['CN'] ?? '', + 'issuerOrganisation' => $certificatePayload['issuer']['O'] ?? '', 'validFrom' => $certificatePayload['validFrom_time_t'], 'validTo' => $certificatePayload['validTo_time_t'], - 'signatureTypeSN' => $certificatePayload['signatureTypeSN'], + 'signatureTypeSN' => $certificatePayload['signatureTypeSN'] ?? '', ]), Response::MODEL_HEALTH_CERTIFICATE); - } } From 4b3fe0b6feac93655997fb4d1ea72a50c1c7db9d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 07:35:28 +0000 Subject: [PATCH 11/34] Fix missing brace --- .../Platform/Modules/Health/Http/Health/Certificate/Get.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index c2c33fe6df..f25666aa03 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -72,9 +72,9 @@ class Get extends Action $streamContextParams = stream_context_get_params($sslSocket); $peerCertificate = $streamContextParams['options']['ssl']['peer_certificate']; $certificatePayload = openssl_x509_parse($peerCertificate); - + fclose($sslSocket); // Close the socket to prevent resource leak - + if ($certificatePayload === false) { throw new Exception(Exception::HEALTH_INVALID_HOST); } @@ -94,4 +94,5 @@ class Get extends Action 'validTo' => $certificatePayload['validTo_time_t'], 'signatureTypeSN' => $certificatePayload['signatureTypeSN'] ?? '', ]), Response::MODEL_HEALTH_CERTIFICATE); + } } From 19895e54e3043dfd7dade12533c9bdb72107f24b Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 07:37:09 +0000 Subject: [PATCH 12/34] Fix: health status returning ping in incorrect unit --- src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php | 2 +- src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php | 2 +- src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php | 2 +- .../Platform/Modules/Health/Http/Health/Storage/Get.php | 2 +- .../Platform/Modules/Health/Http/Health/Storage/Local/Get.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php index 572d449f20..005846b5f7 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php @@ -71,7 +71,7 @@ class Get extends Action $output[] = new Document([ 'name' => $key . " ($cache)", 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000), + 'ping' => \round((\microtime(true) - $checkStart) * 1000), ]); } else { $failures[] = $cache; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php index dfd83b0273..abfd68d945 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php @@ -72,7 +72,7 @@ class Get extends Action $output[] = new Document([ 'name' => $key . " ($database)", 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000), + 'ping' => \round((\microtime(true) - $checkStart) * 1000), ]); } else { $failures[] = $database; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php index c71a30bf53..202f75d7c7 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php @@ -71,7 +71,7 @@ class Get extends Action $output[] = new Document([ 'name' => $key . " ($pubsub)", 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000), + 'ping' => \round((\microtime(true) - $checkStart) * 1000), ]); } else { $failures[] = $pubsub; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php index 975f8846c0..2787428a20 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php @@ -76,7 +76,7 @@ class Get extends Action $response->dynamic(new Document([ 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000), + 'ping' => \round((\microtime(true) - $checkStart) * 1000), ]), Response::MODEL_HEALTH_STATUS); } } diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php index 3a4fc47238..9e24d9f8ff 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Local/Get.php @@ -73,7 +73,7 @@ class Get extends Action $response->dynamic(new Document([ 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) / 1000), + 'ping' => \round((\microtime(true) - $checkStart) * 1000), ]), Response::MODEL_HEALTH_STATUS); } } From 25435aaa1181805bd5b278a2a1848d145b3fe705 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 07:58:18 +0000 Subject: [PATCH 13/34] Error handling --- .../Health/Http/Health/Storage/Get.php | 27 +++++-- .../Modules/Health/Http/Health/Time/Get.php | 70 +++++++++++++------ 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php index 2787428a20..52468cab5a 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Storage/Get.php @@ -65,12 +65,27 @@ class Get extends Action throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed writing test file to ' . $device->getRoot()); } - if ($device->read($filePath) !== 'test') { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed reading test file from ' . $device->getRoot()); - } - - if (!$device->delete($filePath)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed deleting test file from ' . $device->getRoot()); + $readError = null; + try { + if ($device->read($filePath) !== 'test') { + $readError = new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed reading test file from ' . $device->getRoot()); + } + } catch (\Throwable $e) { + $readError = $e; + } finally { + // Always attempt to clean up test file + if (!$device->delete($filePath)) { + if ($readError !== null) { + // If read already failed, wrap delete error but preserve original + \error_log('Failed deleting test file from ' . $device->getRoot() . ' during read error recovery'); + } else { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed deleting test file from ' . $device->getRoot()); + } + } + // Re-throw read error if it occurred + if ($readError !== null) { + throw $readError; + } } } diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php index 3636515cb0..b79553321f 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Time/Get.php @@ -54,30 +54,54 @@ class Get extends Action $sock = \socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); - \socket_connect($sock, $host, 123); - - $msg = "\010" . \str_repeat("\0", 47); - - \socket_send($sock, $msg, \strlen($msg), 0); - - \socket_recv($sock, $recv, 48, MSG_WAITALL); - \socket_close($sock); - - $data = \unpack('N12', $recv); - $timestamp = \sprintf('%u', $data[9]); - - $timestamp -= 2208988800; - - $diff = ($timestamp - \time()); - - if ($diff > $gap || $diff < ($gap * -1)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Server time gaps detected'); + if ($sock === false) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to create socket: ' . \socket_strerror(\socket_last_error())); } - $response->dynamic(new Document([ - 'remoteTime' => $timestamp, - 'localTime' => \time(), - 'diff' => $diff, - ]), Response::MODEL_HEALTH_TIME); + try { + if (!\socket_connect($sock, $host, 123)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to connect to time server: ' . \socket_strerror(\socket_last_error($sock))); + } + + // Set receive timeout to prevent hanging + if (!\socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 5, 'usec' => 0])) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to set socket timeout: ' . \socket_strerror(\socket_last_error($sock))); + } + + $msg = "\010" . \str_repeat("\0", 47); + + $sent = \socket_send($sock, $msg, \strlen($msg), 0); + if ($sent === false) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to send NTP request: ' . \socket_strerror(\socket_last_error($sock))); + } + + $recv = false; + if (!\socket_recv($sock, $recv, 48, MSG_WAITALL)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to receive NTP response: ' . \socket_strerror(\socket_last_error($sock))); + } + + if ($recv === false || \strlen($recv) !== 48) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid NTP response: received ' . (\is_string($recv) ? \strlen($recv) : 'no') . ' bytes instead of 48'); + } + + $data = \unpack('N12', $recv); + $timestamp = \sprintf('%u', $data[9]); + + $timestamp -= 2208988800; + + $diff = ($timestamp - \time()); + + if ($diff > $gap || $diff < ($gap * -1)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Server time gaps detected'); + } + + $response->dynamic(new Document([ + 'remoteTime' => $timestamp, + 'localTime' => \time(), + 'diff' => $diff, + ]), Response::MODEL_HEALTH_TIME); + } finally { + \socket_close($sock); + } } } From 0305790e698703681cce5134586b9d3642ad466d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 07:59:06 +0000 Subject: [PATCH 14/34] Fix: update health status model to use HEALTH_STATUS_LIST --- src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php | 2 +- src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php | 2 +- src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php index 005846b5f7..bf7c3c4889 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Cache/Get.php @@ -41,7 +41,7 @@ class Get extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, + model: Response::MODEL_HEALTH_STATUS_LIST, ) ], contentType: ContentType::JSON diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php index abfd68d945..832ff73cb6 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php @@ -41,7 +41,7 @@ class Get extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, + model: Response::MODEL_HEALTH_STATUS_LIST, ) ], contentType: ContentType::JSON diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php index 202f75d7c7..68cd36d1ba 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/PubSub/Get.php @@ -41,7 +41,7 @@ class Get extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_HEALTH_STATUS, + model: Response::MODEL_HEALTH_STATUS_LIST, ) ], contentType: ContentType::JSON From 3e403194e4f4a9e5aa2ad94d3f878a0f5eb0a8bc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 09:37:10 +0000 Subject: [PATCH 15/34] Fix tests --- .../Platform/Modules/Health/Http/Health/DB/Get.php | 6 +++--- tests/e2e/Services/GraphQL/Base.php | 14 ++++++++++---- tests/e2e/Services/GraphQL/HealthTest.php | 4 ++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php index 832ff73cb6..28cf00c8cd 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/DB/Get.php @@ -72,7 +72,7 @@ class Get extends Action $output[] = new Document([ 'name' => $key . " ($database)", 'status' => 'pass', - 'ping' => \round((\microtime(true) - $checkStart) * 1000), + 'ping' => \round((\microtime(true) - $checkStart) * 1000) ]); } else { $failures[] = $database; @@ -84,12 +84,12 @@ class Get extends Action } if (!empty($failures)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'DB failure on: ' . \implode(', ', $failures)); + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'DB failure on: ' . implode(", ", $failures)); } $response->dynamic(new Document([ 'statuses' => $output, - 'total' => \count($output), + 'total' => count($output), ]), Response::MODEL_HEALTH_STATUS_LIST); } } diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 10a6efd8e8..3234e2f65c 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -2426,15 +2426,21 @@ trait Base case self::GET_DB_HEALTH: return 'query getDbHealth { healthGetDB { - ping - status + statuses { + ping + status + } + total } }'; case self::GET_CACHE_HEALTH: return 'query getCacheHealth { healthGetCache { - ping - status + statuses { + ping + status + } + total } }'; case self::GET_TIME_HEALTH: diff --git a/tests/e2e/Services/GraphQL/HealthTest.php b/tests/e2e/Services/GraphQL/HealthTest.php index 484883f668..732f7b5b1a 100644 --- a/tests/e2e/Services/GraphQL/HealthTest.php +++ b/tests/e2e/Services/GraphQL/HealthTest.php @@ -51,6 +51,8 @@ class HealthTest extends Scope $this->assertArrayNotHasKey('errors', $dbHealth['body']); $dbHealth = $dbHealth['body']['data']['healthGetDB']; $this->assertIsArray($dbHealth); + $this->assertIsArray($dbHealth['statuses']); + $this->assertGreaterThan(0, $dbHealth['total']); return $dbHealth; } @@ -72,6 +74,8 @@ class HealthTest extends Scope $this->assertArrayNotHasKey('errors', $cacheHealth['body']); $cacheHealth = $cacheHealth['body']['data']['healthGetCache']; $this->assertIsArray($cacheHealth); + $this->assertIsArray($cacheHealth['statuses']); + $this->assertGreaterThan(0, $cacheHealth['total']); return $cacheHealth; } From 297fae8f819eb4413c893687a95431f114cf396d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 1 Jan 2026 09:54:13 +0000 Subject: [PATCH 16/34] refactor tests --- tests/e2e/Services/Health/AntiVirusTest.php | 15 + tests/e2e/Services/Health/AuditsQueueTest.php | 17 + tests/e2e/Services/Health/BuildsQueueTest.php | 17 + tests/e2e/Services/Health/CacheTest.php | 16 + tests/e2e/Services/Health/CertificateTest.php | 37 ++ .../Services/Health/CertificatesQueueTest.php | 17 + tests/e2e/Services/Health/DBTest.php | 16 + .../Services/Health/DatabasesQueueTest.php | 17 + .../e2e/Services/Health/DeletesQueueTest.php | 17 + .../Services/Health/FunctionsQueueTest.php | 17 + tests/e2e/Services/Health/HTTPTest.php | 15 + tests/e2e/Services/Health/HealthBase.php | 22 +- .../Health/HealthCustomServerTest.php | 570 ------------------ tests/e2e/Services/Health/LogsQueueTest.php | 17 + tests/e2e/Services/Health/MailsQueueTest.php | 17 + .../Services/Health/MessagingQueueTest.php | 17 + .../Services/Health/MigrationsQueueTest.php | 17 + tests/e2e/Services/Health/PubSubTest.php | 16 + .../Health/StatsResourcesQueueTest.php | 17 + .../Services/Health/StatsUsageQueueTest.php | 17 + .../e2e/Services/Health/StorageLocalTest.php | 15 + tests/e2e/Services/Health/StorageTest.php | 15 + tests/e2e/Services/Health/TimeTest.php | 17 + .../e2e/Services/Health/WebhooksQueueTest.php | 17 + 24 files changed, 404 insertions(+), 571 deletions(-) create mode 100644 tests/e2e/Services/Health/AntiVirusTest.php create mode 100644 tests/e2e/Services/Health/AuditsQueueTest.php create mode 100644 tests/e2e/Services/Health/BuildsQueueTest.php create mode 100644 tests/e2e/Services/Health/CacheTest.php create mode 100644 tests/e2e/Services/Health/CertificateTest.php create mode 100644 tests/e2e/Services/Health/CertificatesQueueTest.php create mode 100644 tests/e2e/Services/Health/DBTest.php create mode 100644 tests/e2e/Services/Health/DatabasesQueueTest.php create mode 100644 tests/e2e/Services/Health/DeletesQueueTest.php create mode 100644 tests/e2e/Services/Health/FunctionsQueueTest.php create mode 100644 tests/e2e/Services/Health/HTTPTest.php delete mode 100644 tests/e2e/Services/Health/HealthCustomServerTest.php create mode 100644 tests/e2e/Services/Health/LogsQueueTest.php create mode 100644 tests/e2e/Services/Health/MailsQueueTest.php create mode 100644 tests/e2e/Services/Health/MessagingQueueTest.php create mode 100644 tests/e2e/Services/Health/MigrationsQueueTest.php create mode 100644 tests/e2e/Services/Health/PubSubTest.php create mode 100644 tests/e2e/Services/Health/StatsResourcesQueueTest.php create mode 100644 tests/e2e/Services/Health/StatsUsageQueueTest.php create mode 100644 tests/e2e/Services/Health/StorageLocalTest.php create mode 100644 tests/e2e/Services/Health/StorageTest.php create mode 100644 tests/e2e/Services/Health/TimeTest.php create mode 100644 tests/e2e/Services/Health/WebhooksQueueTest.php diff --git a/tests/e2e/Services/Health/AntiVirusTest.php b/tests/e2e/Services/Health/AntiVirusTest.php new file mode 100644 index 0000000000..e2fc605245 --- /dev/null +++ b/tests/e2e/Services/Health/AntiVirusTest.php @@ -0,0 +1,15 @@ +callGet('/health/anti-virus'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['status']); + $this->assertIsString($response['body']['status']); + $this->assertIsString($response['body']['version']); + } +} diff --git a/tests/e2e/Services/Health/AuditsQueueTest.php b/tests/e2e/Services/Health/AuditsQueueTest.php new file mode 100644 index 0000000000..e26d9018bc --- /dev/null +++ b/tests/e2e/Services/Health/AuditsQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/audits'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/audits', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/BuildsQueueTest.php b/tests/e2e/Services/Health/BuildsQueueTest.php new file mode 100644 index 0000000000..a8f146cf77 --- /dev/null +++ b/tests/e2e/Services/Health/BuildsQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/builds'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/builds', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/CacheTest.php b/tests/e2e/Services/Health/CacheTest.php new file mode 100644 index 0000000000..0b825b2dba --- /dev/null +++ b/tests/e2e/Services/Health/CacheTest.php @@ -0,0 +1,16 @@ +callGet('/health/cache'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['statuses']); + $this->assertIsInt($response['body']['statuses'][0]['ping']); + $this->assertLessThan(100, $response['body']['statuses'][0]['ping']); + $this->assertEquals('pass', $response['body']['statuses'][0]['status']); + } +} diff --git a/tests/e2e/Services/Health/CertificateTest.php b/tests/e2e/Services/Health/CertificateTest.php new file mode 100644 index 0000000000..b8fe2147ca --- /dev/null +++ b/tests/e2e/Services/Health/CertificateTest.php @@ -0,0 +1,37 @@ +assertCertificate('www.google.com', '/CN=www.google.com', 'www.google.com'); + $this->assertCertificate('appwrite.io', '/CN=appwrite.io', 'appwrite.io'); + + $response = $this->callGet('/health/certificate', ['domain' => 'https://google.com']); + $this->assertEquals(200, $response['headers']['status-code']); + + $this->assertCertificateFailure('localhost', 400); + $this->assertCertificateFailure('doesnotexist.com', 404); + $this->assertCertificateFailure('www.google.com/usr/src/local', 400); + $this->assertCertificateFailure('', 400); + } + + private function assertCertificate(string $domain, string $expectedName, string $expectedSN): void + { + $response = $this->callGet('/health/certificate', ['domain' => $domain]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($expectedName, $response['body']['name']); + $this->assertEquals($expectedSN, $response['body']['subjectSN']); + $this->assertContains($response['body']['issuerOrganisation'], ["Let's Encrypt", 'Google Trust Services']); + $this->assertIsInt($response['body']['validFrom']); + $this->assertIsInt($response['body']['validTo']); + } + + private function assertCertificateFailure(string $domain, int $status): void + { + $response = $this->callGet('/health/certificate', ['domain' => $domain]); + $this->assertEquals($status, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/CertificatesQueueTest.php b/tests/e2e/Services/Health/CertificatesQueueTest.php new file mode 100644 index 0000000000..6738482932 --- /dev/null +++ b/tests/e2e/Services/Health/CertificatesQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/certificates'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/certificates', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/DBTest.php b/tests/e2e/Services/Health/DBTest.php new file mode 100644 index 0000000000..7b21a3224d --- /dev/null +++ b/tests/e2e/Services/Health/DBTest.php @@ -0,0 +1,16 @@ +callGet('/health/db'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['statuses']); + $this->assertIsInt($response['body']['statuses'][0]['ping']); + $this->assertLessThan(100, $response['body']['statuses'][0]['ping']); + $this->assertEquals('pass', $response['body']['statuses'][0]['status']); + } +} diff --git a/tests/e2e/Services/Health/DatabasesQueueTest.php b/tests/e2e/Services/Health/DatabasesQueueTest.php new file mode 100644 index 0000000000..27dc107cb4 --- /dev/null +++ b/tests/e2e/Services/Health/DatabasesQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/databases', ['name' => 'database_db_main']); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/databases', ['name' => 'database_db_main', 'threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/DeletesQueueTest.php b/tests/e2e/Services/Health/DeletesQueueTest.php new file mode 100644 index 0000000000..9b834fa975 --- /dev/null +++ b/tests/e2e/Services/Health/DeletesQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/deletes'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/deletes', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/FunctionsQueueTest.php b/tests/e2e/Services/Health/FunctionsQueueTest.php new file mode 100644 index 0000000000..c66f89b1cd --- /dev/null +++ b/tests/e2e/Services/Health/FunctionsQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/functions'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/functions', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/HTTPTest.php b/tests/e2e/Services/Health/HTTPTest.php new file mode 100644 index 0000000000..31ccb0b0a0 --- /dev/null +++ b/tests/e2e/Services/Health/HTTPTest.php @@ -0,0 +1,15 @@ +callGet('/health'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('pass', $response['body']['status']); + $this->assertIsInt($response['body']['ping']); + $this->assertLessThan(100, $response['body']['ping']); + } +} diff --git a/tests/e2e/Services/Health/HealthBase.php b/tests/e2e/Services/Health/HealthBase.php index 545cbc893f..fd47c19f2f 100644 --- a/tests/e2e/Services/Health/HealthBase.php +++ b/tests/e2e/Services/Health/HealthBase.php @@ -2,6 +2,26 @@ namespace Tests\E2E\Services\Health; -trait HealthBase +use Tests\E2E\Client; +use Tests\E2E\Scopes\ProjectCustom; +use Tests\E2E\Scopes\Scope; +use Tests\E2E\Scopes\SideServer; + +abstract class HealthBase extends Scope { + use ProjectCustom; + use SideServer; + + protected function getProjectId(): string + { + return $this->getProject()['$id']; + } + + protected function callGet(string $path, array $query = []): array + { + return $this->client->call(Client::METHOD_GET, $path, \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProjectId(), + ], $this->getHeaders()), $query); + } } diff --git a/tests/e2e/Services/Health/HealthCustomServerTest.php b/tests/e2e/Services/Health/HealthCustomServerTest.php deleted file mode 100644 index a5a6bf29f7..0000000000 --- a/tests/e2e/Services/Health/HealthCustomServerTest.php +++ /dev/null @@ -1,570 +0,0 @@ -client->call(Client::METHOD_GET, '/health', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('pass', $response['body']['status']); - $this->assertIsInt($response['body']['ping']); - $this->assertLessThan(100, $response['body']['ping']); - - return []; - } - - public function testDBSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/db', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('pass', $response['body']['statuses'][0]['status']); - $this->assertIsInt($response['body']['statuses'][0]['ping']); - $this->assertLessThan(100, $response['body']['statuses'][0]['ping']); - - return []; - } - - public function testCacheSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/cache', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('pass', $response['body']['statuses'][0]['status']); - $this->assertIsInt($response['body']['statuses'][0]['ping']); - $this->assertLessThan(100, $response['body']['statuses'][0]['ping']); - - return []; - } - - public function testPubSubSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/pubsub', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('pass', $response['body']['statuses'][0]['status']); - $this->assertIsInt($response['body']['statuses'][0]['ping']); - $this->assertLessThan(100, $response['body']['statuses'][0]['ping']); - - return []; - } - - public function testTimeSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/time', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['remoteTime']); - $this->assertIsInt($response['body']['localTime']); - $this->assertNotEmpty($response['body']['remoteTime']); - $this->assertNotEmpty($response['body']['localTime']); - $this->assertLessThan(10, $response['body']['diff']); - - return []; - } - - public function testWebhooksSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/webhooks', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/webhooks?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testLogsSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/logs?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testCertificatesSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/certificates', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/certificates?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testFunctionsSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/functions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/functions?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testBuildsSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/builds', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/builds?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testDatabasesSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'name' => 'database_db_main', - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/databases', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'name' => 'database_db_main', - 'threshold' => '0' - ]); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testDeletesSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/deletes', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/deletes?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testMailsSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/mails', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/mails?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testMessagingSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/messaging', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/messaging?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testMigrationsSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/migrations', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/migrations?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testAuditsSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/audits', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/audits?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - - return []; - } - - public function testStorageLocalSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/storage/local', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('pass', $response['body']['status']); - $this->assertIsInt($response['body']['ping']); - $this->assertLessThan(100, $response['body']['ping']); - - return []; - } - - public function testStorageSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/storage', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('pass', $response['body']['status']); - $this->assertIsInt($response['body']['ping']); - $this->assertLessThan(100, $response['body']['ping']); - - return []; - } - - public function testStorageAntiVirusSuccess(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/anti-virus', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['status']); - $this->assertIsString($response['body']['status']); - $this->assertIsString($response['body']['version']); - - return []; - } - - public function testCertificateValidity(): array - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/certificate?domain=www.google.com', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('/CN=www.google.com', $response['body']['name']); - $this->assertEquals('www.google.com', $response['body']['subjectSN']); - $this->assertContains($response['body']['issuerOrganisation'], ['Let\'s Encrypt', 'Google Trust Services']); - $this->assertIsInt($response['body']['validFrom']); - $this->assertIsInt($response['body']['validTo']); - - $response = $this->client->call(Client::METHOD_GET, '/health/certificate?domain=appwrite.io', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('/CN=appwrite.io', $response['body']['name']); - $this->assertEquals('appwrite.io', $response['body']['subjectSN']); - $this->assertContains($response['body']['issuerOrganisation'], ['Let\'s Encrypt', 'Google Trust Services']); - $this->assertIsInt($response['body']['validFrom']); - $this->assertIsInt($response['body']['validTo']); - - $response = $this->client->call(Client::METHOD_GET, '/health/certificate?domain=https://google.com', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/certificate?domain=localhost', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(400, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/health/certificate?domain=doesnotexist.com', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(404, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/health/certificate?domain=www.google.com/usr/src/local', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(400, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/health/certificate?domain=', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(400, $response['headers']['status-code']); - - return []; - } - - public function testStatsResources() - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/stats-resources', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/stats-resources?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - } - - public function testUsageSuccess() - { - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/stats-usage', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertIsInt($response['body']['size']); - $this->assertLessThan(100, $response['body']['size']); - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/health/queue/stats-usage?threshold=0', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), []); - $this->assertEquals(503, $response['headers']['status-code']); - } -} diff --git a/tests/e2e/Services/Health/LogsQueueTest.php b/tests/e2e/Services/Health/LogsQueueTest.php new file mode 100644 index 0000000000..bbeea90dd9 --- /dev/null +++ b/tests/e2e/Services/Health/LogsQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/logs'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/logs', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/MailsQueueTest.php b/tests/e2e/Services/Health/MailsQueueTest.php new file mode 100644 index 0000000000..d986150949 --- /dev/null +++ b/tests/e2e/Services/Health/MailsQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/mails'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/mails', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/MessagingQueueTest.php b/tests/e2e/Services/Health/MessagingQueueTest.php new file mode 100644 index 0000000000..c2aefdc8d2 --- /dev/null +++ b/tests/e2e/Services/Health/MessagingQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/messaging'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/messaging', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/MigrationsQueueTest.php b/tests/e2e/Services/Health/MigrationsQueueTest.php new file mode 100644 index 0000000000..234a1bd433 --- /dev/null +++ b/tests/e2e/Services/Health/MigrationsQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/migrations'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/migrations', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/PubSubTest.php b/tests/e2e/Services/Health/PubSubTest.php new file mode 100644 index 0000000000..f5afdba4c2 --- /dev/null +++ b/tests/e2e/Services/Health/PubSubTest.php @@ -0,0 +1,16 @@ +callGet('/health/pubsub'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['statuses']); + $this->assertIsInt($response['body']['statuses'][0]['ping']); + $this->assertLessThan(100, $response['body']['statuses'][0]['ping']); + $this->assertEquals('pass', $response['body']['statuses'][0]['status']); + } +} diff --git a/tests/e2e/Services/Health/StatsResourcesQueueTest.php b/tests/e2e/Services/Health/StatsResourcesQueueTest.php new file mode 100644 index 0000000000..827b04b6de --- /dev/null +++ b/tests/e2e/Services/Health/StatsResourcesQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/stats-resources'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/stats-resources', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/StatsUsageQueueTest.php b/tests/e2e/Services/Health/StatsUsageQueueTest.php new file mode 100644 index 0000000000..cdff8a55f5 --- /dev/null +++ b/tests/e2e/Services/Health/StatsUsageQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/stats-usage'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/stats-usage', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Health/StorageLocalTest.php b/tests/e2e/Services/Health/StorageLocalTest.php new file mode 100644 index 0000000000..be64ba4877 --- /dev/null +++ b/tests/e2e/Services/Health/StorageLocalTest.php @@ -0,0 +1,15 @@ +callGet('/health/storage/local'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('pass', $response['body']['status']); + $this->assertIsInt($response['body']['ping']); + $this->assertLessThan(100, $response['body']['ping']); + } +} diff --git a/tests/e2e/Services/Health/StorageTest.php b/tests/e2e/Services/Health/StorageTest.php new file mode 100644 index 0000000000..2a67d7376e --- /dev/null +++ b/tests/e2e/Services/Health/StorageTest.php @@ -0,0 +1,15 @@ +callGet('/health/storage'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('pass', $response['body']['status']); + $this->assertIsInt($response['body']['ping']); + $this->assertLessThan(100, $response['body']['ping']); + } +} diff --git a/tests/e2e/Services/Health/TimeTest.php b/tests/e2e/Services/Health/TimeTest.php new file mode 100644 index 0000000000..3a9fec0f00 --- /dev/null +++ b/tests/e2e/Services/Health/TimeTest.php @@ -0,0 +1,17 @@ +callGet('/health/time'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['remoteTime']); + $this->assertIsInt($response['body']['localTime']); + $this->assertNotEmpty($response['body']['remoteTime']); + $this->assertNotEmpty($response['body']['localTime']); + $this->assertLessThan(10, $response['body']['diff']); + } +} diff --git a/tests/e2e/Services/Health/WebhooksQueueTest.php b/tests/e2e/Services/Health/WebhooksQueueTest.php new file mode 100644 index 0000000000..b857b87b29 --- /dev/null +++ b/tests/e2e/Services/Health/WebhooksQueueTest.php @@ -0,0 +1,17 @@ +callGet('/health/queue/webhooks'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['size']); + $this->assertLessThan(100, $response['body']['size']); + + $failure = $this->callGet('/health/queue/webhooks', ['threshold' => '0']); + $this->assertEquals(503, $failure['headers']['status-code']); + } +} From 42bf515c8a369f1ce3453d8ac75427290609f72c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 8 Jan 2026 06:21:57 +0000 Subject: [PATCH 17/34] Fix typos --- .../Health/Http/Health/Certificate/Get.php | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index f25666aa03..8a960a545f 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -60,11 +60,11 @@ class Get extends Action } $sslContext = stream_context_create([ - 'ssl' => [ - 'capture_peer_cert' => true, - ], + "ssl" => [ + "capture_peer_cert" => true + ] ]); - $sslSocket = stream_socket_client('ssl://' . $domain . ':443', $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $sslContext); + $sslSocket = stream_socket_client("ssl://" . $domain . ":443", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $sslContext); if (!$sslSocket) { throw new Exception(Exception::HEALTH_INVALID_HOST); } @@ -73,11 +73,6 @@ class Get extends Action $peerCertificate = $streamContextParams['options']['ssl']['peer_certificate']; $certificatePayload = openssl_x509_parse($peerCertificate); - fclose($sslSocket); // Close the socket to prevent resource leak - - if ($certificatePayload === false) { - throw new Exception(Exception::HEALTH_INVALID_HOST); - } $sslExpiration = $certificatePayload['validTo_time_t']; $status = $sslExpiration < time() ? 'fail' : 'pass'; @@ -87,12 +82,12 @@ class Get extends Action } $response->dynamic(new Document([ - 'name' => $certificatePayload['name'] ?? '', - 'subjectCN' => $certificatePayload['subject']['CN'] ?? '', - 'issuerOrganisation' => $certificatePayload['issuer']['O'] ?? '', + 'name' => $certificatePayload['name'], + 'subjectSN' => $certificatePayload['subject']['CN'], + 'issuerOrganisation' => $certificatePayload['issuer']['O'], 'validFrom' => $certificatePayload['validFrom_time_t'], 'validTo' => $certificatePayload['validTo_time_t'], - 'signatureTypeSN' => $certificatePayload['signatureTypeSN'] ?? '', + 'signatureTypeSN' => $certificatePayload['signatureTypeSN'], ]), Response::MODEL_HEALTH_CERTIFICATE); } } From f4da9b54e7047efa505f141e47c37fcfd0166daa Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 8 Jan 2026 06:34:11 +0000 Subject: [PATCH 18/34] improve get certificate --- .../Health/Http/Health/Certificate/Get.php | 69 +++++++++++++------ 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index 8a960a545f..60cf5d00d4 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -60,34 +60,61 @@ class Get extends Action } $sslContext = stream_context_create([ - "ssl" => [ - "capture_peer_cert" => true - ] + 'ssl' => [ + 'capture_peer_cert' => true, + 'SNI_enabled' => true, + 'peer_name' => $domain, + ], ]); - $sslSocket = stream_socket_client("ssl://" . $domain . ":443", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $sslContext); + + $sslSocket = @stream_socket_client('ssl://' . $domain . ':443', $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $sslContext); + if (!$sslSocket) { - throw new Exception(Exception::HEALTH_INVALID_HOST); + throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to connect to host: (' . ($errno ?? 'unknown') . ') ' . ($errstr ?? 'unknown')); } - $streamContextParams = stream_context_get_params($sslSocket); - $peerCertificate = $streamContextParams['options']['ssl']['peer_certificate']; - $certificatePayload = openssl_x509_parse($peerCertificate); + try { + $streamContextParams = stream_context_get_params($sslSocket); + $peerCertificate = $streamContextParams['options']['ssl']['peer_certificate'] ?? null; + if ($peerCertificate === null) { + throw new Exception(Exception::HEALTH_INVALID_HOST, 'Peer certificate not available for ' . $domain); + } - $sslExpiration = $certificatePayload['validTo_time_t']; - $status = $sslExpiration < time() ? 'fail' : 'pass'; + $certificatePayload = @openssl_x509_parse($peerCertificate); + if ($certificatePayload === false || !\is_array($certificatePayload)) { + throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to parse peer certificate for ' . $domain); + } - if ($status === 'fail') { - throw new Exception(Exception::HEALTH_CERTIFICATE_EXPIRED); + $validFrom = $certificatePayload['validFrom_time_t'] ?? null; + $validTo = $certificatePayload['validTo_time_t'] ?? null; + + if ($validFrom === null || $validTo === null) { + throw new Exception(Exception::HEALTH_INVALID_HOST, 'Certificate missing validity period for ' . $domain); + } + + $sslExpiration = $validTo; + $status = $sslExpiration < time() ? 'fail' : 'pass'; + + if ($status === 'fail') { + throw new Exception(Exception::HEALTH_CERTIFICATE_EXPIRED); + } + + $name = $certificatePayload['name'] ?? null; + if (empty($name) && !empty($certificatePayload['subject']['CN'])) { + $name = '/CN=' . $certificatePayload['subject']['CN']; + } + + $response->dynamic(new Document([ + 'name' => $name ?? '', + 'subjectSN' => $certificatePayload['subject']['CN'] ?? '', + 'issuerOrganisation' => $certificatePayload['issuer']['O'] ?? '', + 'validFrom' => $validFrom, + 'validTo' => $validTo, + 'signatureTypeSN' => $certificatePayload['signatureTypeSN'] ?? '', + ]), Response::MODEL_HEALTH_CERTIFICATE); + } finally { + @fclose($sslSocket); } - - $response->dynamic(new Document([ - 'name' => $certificatePayload['name'], - 'subjectSN' => $certificatePayload['subject']['CN'], - 'issuerOrganisation' => $certificatePayload['issuer']['O'], - 'validFrom' => $certificatePayload['validFrom_time_t'], - 'validTo' => $certificatePayload['validTo_time_t'], - 'signatureTypeSN' => $certificatePayload['signatureTypeSN'], - ]), Response::MODEL_HEALTH_CERTIFICATE); } } From c67b77bca09317bc9c7f00f9ade6214363481360 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 15:06:35 +0530 Subject: [PATCH 19/34] update: implement proper logs cleanup! --- app/controllers/general.php | 38 +++++--- app/init/constants.php | 1 + app/init/resources.php | 8 ++ app/worker.php | 8 ++ .../Functions/Http/Executions/Create.php | 14 +++ src/Appwrite/Platform/Workers/Deletes.php | 93 ++++++++++++++++++- 6 files changed, 148 insertions(+), 14 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index e335f284b7..685ab14aea 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -6,6 +6,7 @@ use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Event\Certificate; +use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\StatsUsage; @@ -59,7 +60,7 @@ Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); -function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey) +function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $host = $request->getHostname() ?? ''; if (!empty($previewHostname)) { @@ -802,6 +803,15 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw ->setProject($project) ->trigger(); + /* cleanup */ + if ($executionsRetentionCount > 0) { + $queueForDeletes + ->setProject($project) + ->setResource($resource->getSequence()) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + return true; } elseif ($type === 'api') { return false; @@ -812,8 +822,6 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw } else { throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown resource type ' . $type, view: $errorView); } - - return false; } App::init() @@ -863,7 +871,9 @@ App::init() ->inject('apiKey') ->inject('cors') ->inject('authorization') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Func $queueForFunctions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization) { + ->inject('queueForDeletes') + ->inject('executionsRetentionCount') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Func $queueForFunctions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ @@ -871,7 +881,7 @@ App::init() $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1144,14 +1154,16 @@ App::options() ->inject('apiKey') ->inject('cors') ->inject('authorization') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization) { + ->inject('queueForDeletes') + ->inject('executionsRetentionCount') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1535,13 +1547,15 @@ App::get('/robots.txt') ->inject('previewHostname') ->inject('apiKey') ->inject('authorization') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization) { + ->inject('queueForDeletes') + ->inject('executionsRetentionCount') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/robots.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1568,13 +1582,15 @@ App::get('/humans.txt') ->inject('previewHostname') ->inject('apiKey') ->inject('authorization') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization) { + ->inject('queueForDeletes') + ->inject('executionsRetentionCount') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/humans.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } diff --git a/app/init/constants.php b/app/init/constants.php index d51cb6b7af..e6215b3a43 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -194,6 +194,7 @@ const DELETE_TYPE_DEPLOYMENTS = 'deployments'; const DELETE_TYPE_USERS = 'users'; const DELETE_TYPE_TEAM_PROJECTS = 'teams_projects'; const DELETE_TYPE_EXECUTIONS = 'executions'; +const DELETE_TYPE_EXECUTIONS_LIMIT = 'executionsLimit'; const DELETE_TYPE_AUDIT = 'audit'; const DELETE_TYPE_ABUSE = 'abuse'; const DELETE_TYPE_USAGE = 'usage'; diff --git a/app/init/resources.php b/app/init/resources.php index a9d46a17be..2f43ee008b 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1156,3 +1156,11 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request, A App::setResource('transactionState', function (Database $dbForProject, Authorization $authorization) { return new TransactionState($dbForProject, $authorization); }, ['dbForProject', 'authorization']); + +App::setResource('executionsRetentionCount', function (Document $project, array $plan) { + if ($project->getId() === 'console' || empty($plan)) { + return 0; + } + + return (int) ($plan['executionsRetentionCount'] ?? 100); +}, ['project', 'plan']); diff --git a/app/worker.php b/app/worker.php index ba8bf98568..39f0695bb3 100644 --- a/app/worker.php +++ b/app/worker.php @@ -490,6 +490,14 @@ Server::setResource('getAudit', function (Database $dbForPlatform, callable $get }; }, ['dbForPlatform', 'getProjectDB']); +Server::setResource('executionsRetentionCount', function (Document $project, array $plan) { + if ($project->getId() === 'console' || empty($plan)) { + return 0; + } + + return (int) ($plan['executionsRetentionCount'] ?? 100); +}, ['project', 'plan']); + $pools = $register->get('pools'); $platform = new Appwrite(); $args = $platform->getEnv('argv'); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 1a265298d3..5e8b6d9e02 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions; use Ahc\Jwt\JWT; +use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\StatsUsage; @@ -101,6 +102,8 @@ class Create extends Base ->inject('executor') ->inject('platform') ->inject('authorization') + ->inject('queueForDeletes') + ->inject('executionsRetentionCount') ->callback($this->action(...)); } @@ -127,6 +130,8 @@ class Create extends Base Executor $executor, array $platform, Authorization $authorization, + DeleteEvent $queueForDeletes, + int $executionsRetentionCount, ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -513,6 +518,15 @@ class Create extends Base } } + /* cleanup */ + if ($executionsRetentionCount > 0) { + $queueForDeletes + ->setProject($project) + ->setResource($function->getSequence()) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 9687f4f4bb..0bbd7b7e66 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -30,6 +30,8 @@ use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\System\System; +use function Swoole\Coroutine\batch; + class Deletes extends Action { protected array $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt']; @@ -59,6 +61,7 @@ class Deletes extends Action ->inject('certificates') ->inject('executor') ->inject('executionRetention') + ->inject('executionsRetentionCount') ->inject('auditRetention') ->inject('log') ->inject('getAudit') @@ -83,6 +86,7 @@ class Deletes extends Action CertificatesAdapter $certificates, Executor $executor, string $executionRetention, + int $executionsRetentionCount, string $auditRetention, Log $log, callable $getAudit, @@ -144,6 +148,17 @@ class Deletes extends Action case DELETE_TYPE_EXECUTIONS: $this->deleteExecutionLogs($project, $getProjectDB, $executionRetention); break; + case DELETE_TYPE_EXECUTIONS_LIMIT: + $resourceInternalId = $payload['resource'] ?? null; + if ($resourceInternalId) { + $this->deleteExecutionsByLimit( + $project, + $getProjectDB, + $executionsRetentionCount, + $resourceInternalId + ); + } + break; case DELETE_TYPE_AUDIT: if (!$project->isEmpty()) { $this->deleteAuditLogs($project, $getAudit, $auditRetention); @@ -694,14 +709,15 @@ class Deletes extends Action } /** - * @param database $dbForPlatform + * @param Document $project * @param callable $getProjectDB * @param string $datetime * @return void - * @throws Exception + * @throws Exception|DatabaseException */ private function deleteExecutionLogs(Document $project, callable $getProjectDB, string $datetime): void { + /** @var Database $dbForProject */ $dbForProject = $getProjectDB($project); // Delete Executions @@ -711,10 +727,81 @@ class Deletes extends Action Query::orderDesc('$createdAt'), Query::orderDesc(), ], $dbForProject); + + /* delete based on custom retention, if any */ + $this->deleteExecutionsByLimit($project, $getProjectDB); } /** - * @param Database $dbForPlatform + * @param Document $project + * @param callable $getProjectDB + * @param int|null $executionsRetentionCount + * @param string|null $resourceInternalId + * @return void + * @throws DatabaseException + */ + protected function deleteExecutionsByLimit( + Document $project, + callable $getProjectDB, + ?int $executionsRetentionCount = 0, + ?string $resourceInternalId = null + ): void { + if ($executionsRetentionCount <= 0) { + return; + } + + /** @var Database $dbForProject */ + $dbForProject = $getProjectDB($project); + + /* delete log for a given $resourceInternalId */ + $deleteExecDocuments = function (Database $dbForProject, string $resourceInternalId) use ($executionsRetentionCount) { + // get the execution at position `N+1` + $execution = $dbForProject->findOne('executions', [ + Query::select(['$createdAt']), + Query::equal('resourceInternalId', [$resourceInternalId]), + Query::orderDesc('$createdAt'), + Query::offset($executionsRetentionCount), + ]); + + if (!$execution->isEmpty()) { + // delete everything older + $cutoffTime = $execution->getAttribute('$createdAt'); + + $this->deleteByGroup('executions', [ + Query::select([...$this->selects, '$createdAt']), + Query::equal('resourceInternalId', [$resourceInternalId]), + Query::lessThan('$createdAt', $cutoffTime), + Query::orderDesc('$createdAt'), + Query::orderDesc(), + ], $dbForProject); + } + }; + + if (!empty($resourceInternalId)) { + // fast path, no need to list anything! + $deleteExecDocuments($dbForProject, $resourceInternalId); + } else { + $processResource = function (string $type) use ($dbForProject, $deleteExecDocuments) { + $this->listByGroup( + collection: $type, + queries: [Query::select(['$id'])], + database: $dbForProject, + callback: function (Document $resource) use ($dbForProject, $deleteExecDocuments) { + $deleteExecDocuments($dbForProject, $resource->getSequence()); + } + ); + }; + + /* perform processing in parallel */ + batch([ + fn () => $processResource('sites'), + fn () => $processResource('functions'), + ]); + } + } + + /** + * @param Document $project * @param callable $getProjectDB * @return void * @throws Exception|Throwable From 0ef4bf21cce9809cd41852dcd1576dbc20fa2bb1 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 15:48:21 +0530 Subject: [PATCH 20/34] address comments. --- app/config/collections/projects.php | 7 +++++ app/controllers/general.php | 1 + .../Functions/Http/Executions/Create.php | 2 +- src/Appwrite/Platform/Workers/Deletes.php | 29 +++++++++++-------- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index dae0337dc9..86346d2672 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2098,6 +2098,13 @@ return [ 'lengths' => [], 'orders' => [], ], + [ + '$id' => ID::custom('_key_resourceType'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['resourceType'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], ], ], diff --git a/app/controllers/general.php b/app/controllers/general.php index 685ab14aea..4222d18ff1 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -807,6 +807,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw if ($executionsRetentionCount > 0) { $queueForDeletes ->setProject($project) + ->setResourceType($type) ->setResource($resource->getSequence()) ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) ->trigger(); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 5e8b6d9e02..8c4b68edb6 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -63,7 +63,6 @@ class Create extends Base ->label('scope', 'execution.write') ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('event', 'functions.[functionId].executions.[executionId].create') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('sdk', new Method( namespace: 'functions', group: 'executions', @@ -523,6 +522,7 @@ class Create extends Base $queueForDeletes ->setProject($project) ->setResource($function->getSequence()) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) ->trigger(); } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 0bbd7b7e66..3c66eef277 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -150,12 +150,14 @@ class Deletes extends Action break; case DELETE_TYPE_EXECUTIONS_LIMIT: $resourceInternalId = $payload['resource'] ?? null; + $resourceType = $payload['resourceType'] ?? null; if ($resourceInternalId) { $this->deleteExecutionsByLimit( $project, $getProjectDB, $executionsRetentionCount, - $resourceInternalId + $resourceInternalId, + $resourceType ); } break; @@ -214,16 +216,15 @@ class Deletes extends Action * @param Database $dbForPlatform * @param callable $getProjectDB * @param string $datetime - * @param Document|null $document * @return void * @throws Conflict * @throws Restricted * @throws Structure - * @throws DatabaseException + * @throws DatabaseException|Exception */ private function deleteSchedules(Database $dbForPlatform, callable $getProjectDB, string $datetime): void { - // Temporarly accepting both 'fra' and 'default' + // Temporarily accepting both 'fra' and 'default' // When all migrated, only use _APP_REGION with 'default' as default value $regions = [System::getEnv('_APP_REGION', 'default')]; if (!in_array('default', $regions)) { @@ -737,6 +738,7 @@ class Deletes extends Action * @param callable $getProjectDB * @param int|null $executionsRetentionCount * @param string|null $resourceInternalId + * @param string|null $resourceType * @return void * @throws DatabaseException */ @@ -744,7 +746,8 @@ class Deletes extends Action Document $project, callable $getProjectDB, ?int $executionsRetentionCount = 0, - ?string $resourceInternalId = null + ?string $resourceInternalId = null, + ?string $resourceType = null ): void { if ($executionsRetentionCount <= 0) { return; @@ -754,11 +757,12 @@ class Deletes extends Action $dbForProject = $getProjectDB($project); /* delete log for a given $resourceInternalId */ - $deleteExecDocuments = function (Database $dbForProject, string $resourceInternalId) use ($executionsRetentionCount) { + $delete = function (Database $dbForProject, string $resourceInternalId, string $resourceType) use ($executionsRetentionCount) { // get the execution at position `N+1` $execution = $dbForProject->findOne('executions', [ Query::select(['$createdAt']), Query::equal('resourceInternalId', [$resourceInternalId]), + Query::equal('resourceType', [$resourceType]), Query::orderDesc('$createdAt'), Query::offset($executionsRetentionCount), ]); @@ -770,6 +774,7 @@ class Deletes extends Action $this->deleteByGroup('executions', [ Query::select([...$this->selects, '$createdAt']), Query::equal('resourceInternalId', [$resourceInternalId]), + Query::equal('resourceType', [$resourceType]), Query::lessThan('$createdAt', $cutoffTime), Query::orderDesc('$createdAt'), Query::orderDesc(), @@ -779,23 +784,23 @@ class Deletes extends Action if (!empty($resourceInternalId)) { // fast path, no need to list anything! - $deleteExecDocuments($dbForProject, $resourceInternalId); + $delete($dbForProject, $resourceInternalId, $resourceType); } else { - $processResource = function (string $type) use ($dbForProject, $deleteExecDocuments) { + $processResource = function (string $type) use ($dbForProject, $delete, $resourceType) { $this->listByGroup( collection: $type, queries: [Query::select(['$id'])], database: $dbForProject, - callback: function (Document $resource) use ($dbForProject, $deleteExecDocuments) { - $deleteExecDocuments($dbForProject, $resource->getSequence()); + callback: function (Document $resource) use ($dbForProject, $delete, $type) { + $delete($dbForProject, $resource->getSequence(), $type); } ); }; /* perform processing in parallel */ batch([ - fn () => $processResource('sites'), - fn () => $processResource('functions'), + fn () => $processResource(RESOURCE_TYPE_SITES), + fn () => $processResource(RESOURCE_TYPE_FUNCTIONS), ]); } } From beee5e721ee6de0fe77867d7844a46af6823d143 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 15:55:45 +0530 Subject: [PATCH 21/34] upate: run on maintenance as well. --- src/Appwrite/Platform/Workers/Deletes.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 3c66eef277..62230ed5c6 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -206,6 +206,7 @@ class Deletes extends Action $this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime); $this->deleteExpiredSessions($project, $getProjectDB); $this->deleteExpiredTransactions($project, $getProjectDB); + $this->deleteExecutionsByLimit($project, $getProjectDB, $executionsRetentionCount); break; default: throw new \Exception('No delete operation for type: ' . \strval($type)); From 79e150b7b29cdb588ae200b5fb4dfd9d99756266 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 16:00:59 +0530 Subject: [PATCH 22/34] fix: type. --- app/controllers/general.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 4222d18ff1..6f01e256c4 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -805,9 +805,13 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw /* cleanup */ if ($executionsRetentionCount > 0) { + $resourceType = $type === 'function' + ? RESOURCE_TYPE_FUNCTIONS + : RESOURCE_TYPE_SITES; + $queueForDeletes ->setProject($project) - ->setResourceType($type) + ->setResourceType($resourceType) ->setResource($resource->getSequence()) ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) ->trigger(); From b5e9c1786ad1c97d4d500c5d69ccc3105b339b6c Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 16:04:54 +0530 Subject: [PATCH 23/34] fix: maintenance logic. --- src/Appwrite/Platform/Workers/Deletes.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 62230ed5c6..0dae78f31d 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -201,12 +201,11 @@ class Deletes extends Action break; case DELETE_TYPE_MAINTENANCE: $this->deleteExpiredTargets($project, $getProjectDB); - $this->deleteExecutionLogs($project, $getProjectDB, $executionRetention); + $this->deleteExecutionLogs($project, $getProjectDB, $executionRetention, $executionsRetentionCount); $this->deleteAuditLogs($project, $getAudit, $auditRetention); $this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime); $this->deleteExpiredSessions($project, $getProjectDB); $this->deleteExpiredTransactions($project, $getProjectDB); - $this->deleteExecutionsByLimit($project, $getProjectDB, $executionsRetentionCount); break; default: throw new \Exception('No delete operation for type: ' . \strval($type)); @@ -714,10 +713,11 @@ class Deletes extends Action * @param Document $project * @param callable $getProjectDB * @param string $datetime + * @param int|null $executionsRetentionCount * @return void * @throws Exception|DatabaseException */ - private function deleteExecutionLogs(Document $project, callable $getProjectDB, string $datetime): void + private function deleteExecutionLogs(Document $project, callable $getProjectDB, string $datetime, ?int $executionsRetentionCount = 0): void { /** @var Database $dbForProject */ $dbForProject = $getProjectDB($project); @@ -731,7 +731,7 @@ class Deletes extends Action ], $dbForProject); /* delete based on custom retention, if any */ - $this->deleteExecutionsByLimit($project, $getProjectDB); + $this->deleteExecutionsByLimit($project, $getProjectDB, $executionsRetentionCount); } /** From ccaea5d0107c7b9a166c1427194f3adc33e3a3ec Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 16:11:38 +0530 Subject: [PATCH 24/34] add: constant. --- app/controllers/general.php | 2 +- app/init/constants.php | 4 +++- src/Appwrite/Platform/Workers/Deletes.php | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 6f01e256c4..8fc5a11503 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -804,7 +804,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw ->trigger(); /* cleanup */ - if ($executionsRetentionCount > 0) { + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { $resourceType = $type === 'function' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES; diff --git a/app/init/constants.php b/app/init/constants.php index e6215b3a43..e05f31e078 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -181,8 +181,10 @@ const BUILD_TYPE_DEPLOYMENT = 'deployment'; const BUILD_TYPE_RETRY = 'retry'; // Deletion Types -const DELETE_TYPE_DATABASES = 'databases'; +const ENABLE_EXECUTIONS_LIMIT_ON_ROUTE = false; + +const DELETE_TYPE_DATABASES = 'databases'; const DELETE_TYPE_DOCUMENT = 'document'; const DELETE_TYPE_COLLECTIONS = 'collections'; const DELETE_TYPE_TRANSACTION = 'transaction'; diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 0dae78f31d..654b083a98 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -790,7 +790,7 @@ class Deletes extends Action $processResource = function (string $type) use ($dbForProject, $delete, $resourceType) { $this->listByGroup( collection: $type, - queries: [Query::select(['$id'])], + queries: [Query::select(['$id', '$sequence'])], database: $dbForProject, callback: function (Document $resource) use ($dbForProject, $delete, $type) { $delete($dbForProject, $resource->getSequence(), $type); From da871635d9b975a5b7708f806b45fe6a1357d478 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 16 Jan 2026 16:16:03 +0530 Subject: [PATCH 25/34] Fix namespace import for RuntimeQuery class and update test file accordingly --- src/Appwrite/Messaging/Adapter/Realtime.php | 2 +- .../Database/{Query => }/RuntimeQuery.php | 39 ++++++++----------- .../Database/Query/RuntimeQueryTest.php | 2 +- 3 files changed, 18 insertions(+), 25 deletions(-) rename src/Appwrite/Utopia/Database/{Query => }/RuntimeQuery.php (74%) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 2b877779c2..1d7f3726cd 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -5,7 +5,7 @@ namespace Appwrite\Messaging\Adapter; use Appwrite\Extend\Exception; use Appwrite\Messaging\Adapter as MessagingAdapter; use Appwrite\PubSub\Adapter\Pool as PubSubPool; -use Appwrite\Utopia\Database\Query\RuntimeQuery; +use Appwrite\Utopia\Database\RuntimeQuery; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/RuntimeQuery.php similarity index 74% rename from src/Appwrite/Utopia/Database/Query/RuntimeQuery.php rename to src/Appwrite/Utopia/Database/RuntimeQuery.php index f97ba015ca..11257db21f 100644 --- a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/RuntimeQuery.php @@ -1,6 +1,6 @@ getValues(); // during 'and' and 'or' attribute will not be present - if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR])) { - switch ($method) { - case Query::TYPE_AND: - // All subqueries must evaluate to true - foreach ($query->getValues() as $subquery) { - if (!self::evaluateFilter($subquery, $payload)) { - return false; - } + switch ($method) { + case Query::TYPE_AND: + // All subqueries must evaluate to true + foreach ($query->getValues() as $subquery) { + if (!self::evaluateFilter($subquery, $payload)) { + return false; } - return true; + } + return true; - case Query::TYPE_OR: - // At least one subquery must evaluate to true - foreach ($query->getValues() as $subquery) { - if (self::evaluateFilter($subquery, $payload)) { - return true; - } + case Query::TYPE_OR: + // At least one subquery must evaluate to true + foreach ($query->getValues() as $subquery) { + if (self::evaluateFilter($subquery, $payload)) { + return true; } - return false; - - default: - throw new \InvalidArgumentException( - "Unsupported query method: {$method}" - ); - } + } + return false; } $hasAttribute = \array_key_exists($attribute, $payload); diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index 2156d862a5..35fbde04ce 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Utopia\Database\Query; -use Appwrite\Utopia\Database\Query\RuntimeQuery; +use Appwrite\Utopia\Database\RuntimeQuery; use PHPUnit\Framework\TestCase; use Utopia\Database\Query; From f5a61fb4d66d98447e722bd38e8dfdd220dbab9a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 16 Jan 2026 13:59:56 +0530 Subject: [PATCH 26/34] feat: add cleanup for stale function executions Adds a new interval task that marks executions stuck in 'processing' status for more than 30 minutes as 'failed' with a timeout error. --- .env | 1 + docker-compose.yml | 1 + src/Appwrite/Platform/Tasks/Interval.php | 53 +++++++++++++++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/.env b/.env index 88dec63b1c..c301c53123 100644 --- a/.env +++ b/.env @@ -102,6 +102,7 @@ _APP_STATS_RESOURCES_INTERVAL=30 _APP_MAINTENANCE_RETENTION_USAGE_HOURLY=8640000 _APP_MAINTENANCE_RETENTION_SCHEDULES=86400 _APP_INTERVAL_DOMAIN_VERIFICATION=60 +_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS=300 _APP_USAGE_STATS=enabled _APP_LOGGING_CONFIG= _APP_LOGGING_CONFIG_REALTIME= diff --git a/docker-compose.yml b/docker-compose.yml index 20c0ad8f79..c5b88a2174 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -880,6 +880,7 @@ services: - _APP_DB_PASS - _APP_DATABASE_SHARED_TABLES - _APP_INTERVAL_DOMAIN_VERIFICATION + - _APP_INTERVAL_CLEANUP_STALE_EXECUTIONS appwrite-task-stats-resources: container_name: appwrite-task-stats-resources diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index 9d3d782501..0985447c2e 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -24,22 +24,30 @@ class Interval extends Action $this ->desc('Schedules tasks on regular intervals by publishing them to our queues') ->inject('dbForPlatform') + ->inject('getProjectDB') ->inject('queueForCertificates') ->callback($this->action(...)); } - public function action(Database $dbForPlatform, Certificate $queueForCertificates): void + public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): void { Console::title('Interval V1'); Console::success(APP_NAME . ' interval process v1 has started'); $intervalDomainVerification = (int) System::getEnv('_APP_INTERVAL_DOMAIN_VERIFICATION', '60'); // 1 minute + $intervalCleanupStaleExecutions = (int) System::getEnv('_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS', '300'); // 5 minutes \go(function () use ($dbForPlatform, $queueForCertificates, $intervalDomainVerification) { Console::loop(function () use ($dbForPlatform, $queueForCertificates) { $this->verifyDomain($dbForPlatform, $queueForCertificates); }, $intervalDomainVerification); }); + + \go(function () use ($dbForPlatform, $getProjectDB, $intervalCleanupStaleExecutions) { + Console::loop(function () use ($dbForPlatform, $getProjectDB) { + $this->cleanupStaleExecutions($dbForPlatform, $getProjectDB); + }, $intervalCleanupStaleExecutions); + }); } private function verifyDomain(Database $dbForPlatform, Certificate $queueForCertificates): void @@ -72,4 +80,47 @@ class Interval extends Action ->trigger(); } } + + private function cleanupStaleExecutions(Database $dbForPlatform, callable $getProjectDB): void + { + $time = DatabaseDateTime::now(); + $staleThreshold = DatabaseDateTime::addSeconds(new DateTime(), -1200); // 20 minutes ago + + Console::info("[{$time}] Starting cleanup of stale executions"); + + $dbForPlatform->foreach( + 'projects', + function (Document $project) use ($getProjectDB, $time, $staleThreshold) { + try { + $dbForProject = $getProjectDB($project); + + $staleExecutions = $dbForProject->find('executions', [ + Query::equal('status', ['processing']), + Query::lessThan('$createdAt', $staleThreshold), + Query::limit(100), + ]); + + if (\count($staleExecutions) === 0) { + return; + } + + Console::info("[{$time}] Found " . \count($staleExecutions) . " stale executions in project {$project->getId()}"); + + foreach ($staleExecutions as $execution) { + $execution->setAttribute('status', 'failed'); + $execution->setAttribute('errors', 'Execution timed out'); + $dbForProject->updateDocument('executions', $execution->getId(), $execution); + } + } catch (\Throwable $th) { + Console::error("[{$time}] Failed to cleanup stale executions for project {$project->getId()}: " . $th->getMessage()); + } + }, + [ + Query::equal('region', [System::getEnv('_APP_REGION', 'default')]), + Query::limit(100), + ] + ); + + Console::info("[{$time}] Completed cleanup of stale executions"); + } } From cda03f63ab6e19274621c14ccbcf194a0ab439de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 16 Jan 2026 13:23:46 +0100 Subject: [PATCH 27/34] Support dual-writing for new schema features --- app/config/collections/common.php | 11 +++ app/config/collections/projects.php | 99 +++++++++++++++++++ app/controllers/api/teams.php | 1 + app/controllers/api/vcs.php | 1 + .../Platform/Modules/Compute/Base.php | 2 + .../Functions/Http/Deployments/Create.php | 2 + .../Http/Deployments/Duplicate/Create.php | 1 + .../Http/Deployments/Template/Create.php | 1 + .../Functions/Http/Functions/Create.php | 7 +- .../Functions/Http/Functions/Update.php | 3 + .../Modules/Sites/Http/Deployments/Create.php | 2 + .../Http/Deployments/Duplicate/Create.php | 1 + .../Http/Deployments/Template/Create.php | 1 + .../Modules/Sites/Http/Sites/Create.php | 4 + .../Modules/Sites/Http/Sites/Update.php | 4 + 15 files changed, 139 insertions(+), 1 deletion(-) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index a364a0a866..2328cd5b88 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -1288,6 +1288,17 @@ return [ 'array' => false, 'filters' => ['json'], ], + [ + '$id' => ID::custom('labels'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index dae0337dc9..da4b52a526 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -567,6 +567,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('deploymentRetention'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('deploymentInternalId'), 'type' => Database::VAR_STRING, @@ -765,6 +776,17 @@ return [ 'default' => null, 'filters' => [], ], + [ + 'array' => false, + '$id' => ID::custom('startCommand'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 20000, + 'signed' => true, + 'required' => false, + 'default' => null, + 'filters' => [], + ], [ 'array' => false, '$id' => ID::custom('specification'), @@ -776,6 +798,28 @@ return [ 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, 'filters' => [], ], + [ + 'array' => false, + '$id' => ID::custom('buildSpecification'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => false, + 'required' => false, + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'filters' => [], + ], + [ + 'array' => false, + '$id' => ID::custom('runtimeSpecification'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => false, + 'required' => false, + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'filters' => [], + ], [ '$id' => ID::custom('scopes'), 'type' => Database::VAR_STRING, @@ -1035,6 +1079,17 @@ return [ 'default' => null, 'filters' => [], ], + [ + 'array' => false, + '$id' => ID::custom('startCommand'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 20000, + 'signed' => true, + 'required' => false, + 'default' => null, + 'filters' => [], + ], [ '$id' => ID::custom('fallbackFile'), 'type' => Database::VAR_STRING, @@ -1046,6 +1101,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('deploymentRetention'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('deploymentInternalId'), 'type' => Database::VAR_STRING, @@ -1200,6 +1266,28 @@ return [ 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, 'filters' => [], ], + [ + 'array' => false, + '$id' => ID::custom('buildSpecification'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => false, + 'required' => false, + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'filters' => [], + ], + [ + 'array' => false, + '$id' => ID::custom('runtimeSpecification'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => false, + 'required' => false, + 'default' => APP_COMPUTE_SPECIFICATION_DEFAULT, + 'filters' => [], + ], [ '$id' => ID::custom('buildRuntime'), 'type' => Database::VAR_STRING, @@ -1357,6 +1445,17 @@ return [ 'default' => null, 'filters' => [], ], + [ + 'array' => false, + '$id' => ID::custom('startCommand'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 20000, + 'signed' => true, + 'required' => false, + 'default' => null, + 'filters' => [], + ], [ 'array' => false, '$id' => ID::custom('buildOutput'), diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index a68939daa3..2cee394a9c 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -103,6 +103,7 @@ App::post('/v1/teams') Permission::update(Role::team($teamId, 'owner')), Permission::delete(Role::team($teamId, 'owner')), ], + 'labels' => [], 'name' => $name, 'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1, 'prefs' => new \stdClass(), diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index 2270f4fd89..2bb9c17fd3 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -306,6 +306,7 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId 'resourceType' => $resourceCollection, 'entrypoint' => $resource->getAttribute('entrypoint', ''), 'buildCommands' => \implode(' && ', $commands), + 'startCommand' => $resource->getAttribute('startCommand', ''), 'buildOutput' => $resource->getAttribute('outputDirectory', ''), 'adapter' => $resource->getAttribute('adapter', ''), 'fallbackFile' => $resource->getAttribute('fallbackFile', ''), diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 33b69dd589..749a9fe87a 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -107,6 +107,7 @@ class Base extends Action 'resourceType' => 'functions', 'entrypoint' => $entrypoint, 'buildCommands' => $function->getAttribute('commands', ''), + 'startCommand' => $function->getAttribute('startCommand', ''), 'type' => 'vcs', 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), @@ -203,6 +204,7 @@ class Base extends Action 'resourceInternalId' => $site->getSequence(), 'resourceType' => 'sites', 'buildCommands' => implode(' && ', $commands), + 'startCommand' => $site->getAttribute('startCommand', ''), 'buildOutput' => $site->getAttribute('outputDirectory', ''), 'adapter' => $site->getAttribute('adapter', ''), 'fallbackFile' => $site->getAttribute('fallbackFile', ''), diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index c5ae08728d..97c669b9fb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -246,6 +246,7 @@ class Create extends Action 'resourceType' => 'functions', 'entrypoint' => $entrypoint, 'buildCommands' => $commands, + 'startCommand' => $function->getAttribute('startCommand', ''), 'sourcePath' => $path, 'sourceSize' => $fileSize, 'totalSize' => $fileSize, @@ -283,6 +284,7 @@ class Create extends Action 'resourceType' => 'functions', 'entrypoint' => $entrypoint, 'buildCommands' => $commands, + 'startCommand' => $function->getAttribute('startCommand', ''), 'sourcePath' => $path, 'sourceSize' => $fileSize, 'totalSize' => $fileSize, diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php index 42bf625d78..11d9c77b0e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php @@ -104,6 +104,7 @@ class Create extends Action 'totalSize' => $deployment->getAttribute('sourceSize', 0), 'entrypoint' => $function->getAttribute('entrypoint'), 'buildCommands' => $function->getAttribute('commands', ''), + 'startCommand' => $function->getAttribute('startCommand', ''), 'buildStartedAt' => null, 'buildEndedAt' => null, 'buildDuration' => null, diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php index acfaa965ac..d4bf7446fb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php @@ -159,6 +159,7 @@ class Create extends Base 'resourceType' => 'functions', 'entrypoint' => $function->getAttribute('entrypoint', ''), 'buildCommands' => $function->getAttribute('commands', ''), + 'startCommand' => $function->getAttribute('startCommand', ''), 'providerRepositoryName' => $repository, 'providerRepositoryOwner' => $owner, 'providerRepositoryUrl' => $repositoryUrl, diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 6ad488283e..79c6afb92b 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -223,6 +223,8 @@ class Create extends Base 'entrypoint' => $entrypoint, 'commands' => $commands, 'scopes' => $scopes, + 'deploymentRetention' => 0, + 'startCommand' => '', 'search' => implode(' ', [$functionId, $name, $runtime]), 'version' => 'v5', 'installationId' => $installation->getId(), @@ -233,7 +235,9 @@ class Create extends Base 'providerBranch' => $providerBranch, 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, - 'specification' => $specification + 'specification' => $specification, + 'buildSpecification' => $specification, + 'runtimeSpecification' => $specification, ])); } catch (DuplicateException) { throw new Exception(Exception::FUNCTION_ALREADY_EXISTS); @@ -343,6 +347,7 @@ class Create extends Base 'resourceType' => 'functions', 'entrypoint' => $function->getAttribute('entrypoint', ''), 'buildCommands' => $function->getAttribute('commands', ''), + 'startCommand' => $function->getAttribute('startCommand', ''), 'type' => 'manual', 'activate' => true, ])); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 55c5b30418..f2925f52be 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -261,6 +261,7 @@ class Update extends Base 'entrypoint' => $entrypoint, 'commands' => $commands, 'scopes' => $scopes, + 'deploymentRetention' => 0, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'providerRepositoryId' => $providerRepositoryId, @@ -270,6 +271,8 @@ class Update extends Base 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, 'specification' => $specification, + 'buildSpecification' => $specification, + 'runtimeSpecification' => $specification, 'search' => implode(' ', [$functionId, $name, $runtime]), ]))); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 3de0322d6e..e752e97494 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -253,6 +253,7 @@ class Create extends Action 'resourceId' => $site->getId(), 'resourceType' => 'sites', 'buildCommands' => \implode(' && ', $commands), + 'startCommand' => $site->getAttribute('startCommand', ''), 'buildOutput' => $outputDirectory, 'adapter' => $site->getAttribute('adapter', ''), 'fallbackFile' => $site->getAttribute('fallbackFile', ''), @@ -320,6 +321,7 @@ class Create extends Action 'resourceId' => $site->getId(), 'resourceType' => 'sites', 'buildCommands' => \implode(' && ', $commands), + 'startCommand' => $site->getAttribute('startCommand', ''), 'buildOutput' => $outputDirectory, 'adapter' => $site->getAttribute('adapter', ''), 'fallbackFile' => $site->getAttribute('fallbackFile', ''), diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php index 9554e2aa14..5656eb09ab 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php @@ -119,6 +119,7 @@ class Create extends Action 'sourcePath' => $destination, 'totalSize' => $deployment->getAttribute('sourceSize', 0), 'buildCommands' => \implode(' && ', $commands), + 'startCommand' => $site->getAttribute('startCommand', ''), 'buildOutput' => $site->getAttribute('outputDirectory', ''), 'adapter' => $site->getAttribute('adapter', ''), 'fallbackFile' => $site->getAttribute('fallbackFile', ''), diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php index 30d5e779c1..aa78061057 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -166,6 +166,7 @@ class Create extends Base 'resourceInternalId' => $site->getSequence(), 'resourceType' => 'sites', 'buildCommands' => \implode(' && ', $commands), + 'startCommand' => $site->getAttribute('startCommand', ''), 'buildOutput' => $site->getAttribute('outputDirectory', ''), 'providerRepositoryName' => $repository, 'providerRepositoryOwner' => $owner, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index 76a11ff736..b48cfeb73f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -150,6 +150,8 @@ class Create extends Base 'timeout' => $timeout, 'installCommand' => $installCommand, 'buildCommand' => $buildCommand, + 'deploymentRetention' => 0, + 'startCommand' => '', 'outputDirectory' => $outputDirectory, 'search' => implode(' ', [$siteId, $name, $framework]), 'fallbackFile' => $fallbackFile, @@ -162,6 +164,8 @@ class Create extends Base 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, 'specification' => $specification, + 'buildSpecification' => $specification, + 'runtimeSpecification' => $specification, 'buildRuntime' => $buildRuntime, 'adapter' => $adapter, ])); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 8c48aff586..b4b720537d 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -254,6 +254,8 @@ class Update extends Base 'timeout' => $timeout, 'installCommand' => $installCommand, 'buildCommand' => $buildCommand, + 'deploymentRetention' => 0, + 'startCommand' => '', 'outputDirectory' => $outputDirectory, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), @@ -264,6 +266,8 @@ class Update extends Base 'providerRootDirectory' => $providerRootDirectory, 'providerSilentMode' => $providerSilentMode, 'specification' => $specification, + 'buildSpecification' => $specification, + 'runtimeSpecification' => $specification, 'search' => implode(' ', [$siteId, $name, $framework]), 'buildRuntime' => $buildRuntime, 'adapter' => $adapter, From b9c7c172ad4727c8c59d932dfbb6413dd43d5d1b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 16 Jan 2026 18:18:24 +0530 Subject: [PATCH 28/34] updated query conversion for nested query --- app/realtime.php | 7 +- src/Appwrite/Messaging/Adapter/Realtime.php | 17 ++-- .../RealtimeCustomClientQueryTest.php | 94 +++++++++++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 7c0c5dafa6..eded4d79bc 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -29,6 +29,7 @@ use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; @@ -579,7 +580,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $roles = $user->getRoles($authorization); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); - $queries = Realtime::convertQueries($request->getQuery('queries', [])); + try { + $queries = Realtime::convertQueries($request->getQuery('queries', [])); + } catch (QueryException $e) { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $e->getMessage()); + } /** * Channels Check diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 1d7f3726cd..9e03a7aaf7 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -2,7 +2,6 @@ namespace Appwrite\Messaging\Adapter; -use Appwrite\Extend\Exception; use Appwrite\Messaging\Adapter as MessagingAdapter; use Appwrite\PubSub\Adapter\Pool as PubSubPool; use Appwrite\Utopia\Database\RuntimeQuery; @@ -266,15 +265,21 @@ class Realtime extends MessagingAdapter public static function convertQueries(array $queries): array { $queries = Query::parseQueries($queries); - foreach ($queries as $query) { - if (!in_array($query->getMethod(), RuntimeQuery::ALLOWED_QUERIES)) { - $unsupportedMethod = $query->getMethod(); - $allowedMethods = implode(', ', RuntimeQuery::ALLOWED_QUERIES); + $stack = $queries; + $allowedMethods = implode(', ', RuntimeQuery::ALLOWED_QUERIES); + while (!empty($stack)) { + /** `@var` Query $query */ + $query = array_pop($stack); + $method = $query->getMethod(); + if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { + $unsupportedMethod = $method; throw new QueryException( - Exception::REALTIME_POLICY_VIOLATION, "Query method '{$unsupportedMethod}' is not supported in Realtime queries. Allowed query methods are: {$allowedMethods}" ); } + if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR], true)) { + $stack = array_merge($stack, $query->getValues()); + } } return $queries; diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 0272450245..365d0d8f6b 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1425,4 +1425,98 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } + + public function testInvalidQueryShouldNotSubscribe() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test 1: Simple invalid query method (contains is not allowed) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::contains('status', ['active'])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('contains', $response['data']['message']); + + // Test 2: Invalid query method in nested AND query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::search('name', 'test') // search is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('search', $response['data']['message']); + + // Test 3: Invalid query method in nested OR query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('status', ['active']), + Query::between('score', 0, 100) // between is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('between', $response['data']['message']); + + // Test 4: Deeply nested invalid query (AND -> OR -> invalid) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('score', 50), + Query::startsWith('name', 'test') // startsWith is not allowed + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('startsWith', $response['data']['message']); + + // Test 5: Multiple invalid queries in nested structure + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::contains('tags', ['important']), // contains is not allowed + Query::or([ + Query::endsWith('email', '@example.com'), // endsWith is not allowed + Query::equal('status', ['active']) + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + // Should catch the first invalid method encountered + $this->assertTrue( + str_contains($response['data']['message'], 'contains') || + str_contains($response['data']['message'], 'endsWith') + ); + } } From 5f22022527d88dbef6202c9c12265863af373901 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 18:33:27 +0530 Subject: [PATCH 29/34] fix: async being missed. --- .../Functions/Http/Executions/Create.php | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 8c4b68edb6..cc54068b81 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -168,6 +168,7 @@ class Create extends Base throw new Exception($validator->getDescription(), 400); } + /* @var Document $function */ $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); $isAPIKey = User::isApp($authorization->getRoles()); @@ -344,6 +345,13 @@ class Create extends Base $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } + $this->enqueueDeletes( + $project, + $function->getSequence(), + $executionsRetentionCount, + $queueForDeletes + ); + return $response ->setStatusCode(Response::STATUS_CODE_ACCEPTED) ->dynamic($execution, Response::MODEL_EXECUTION); @@ -517,18 +525,33 @@ class Create extends Base } } - /* cleanup */ - if ($executionsRetentionCount > 0) { + $this->enqueueDeletes( + $project, + $function->getSequence(), + $executionsRetentionCount, $queueForDeletes - ->setProject($project) - ->setResource($function->getSequence()) - ->setResourceType(RESOURCE_TYPE_FUNCTIONS) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } + ); $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); } + + private function enqueueDeletes( + Document $project, + int $resourceId, + int $retention, + DeleteEvent $queueForDeletes + ): void + { + /* cleanup */ + if ($retention > 0) { + $queueForDeletes + ->setProject($project) + ->setResource($resourceId) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + } } From 15caa279777ab4359a98cc7de3418248238001b0 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 18:38:40 +0530 Subject: [PATCH 30/34] lint. --- .../Platform/Modules/Functions/Http/Executions/Create.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index cc54068b81..067e540ab7 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -542,8 +542,7 @@ class Create extends Base int $resourceId, int $retention, DeleteEvent $queueForDeletes - ): void - { + ): void { /* cleanup */ if ($retention > 0) { $queueForDeletes From e8ca0610eea6de69508127f02986bf05df969879 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 18:40:37 +0530 Subject: [PATCH 31/34] fix: type --- .../Platform/Modules/Functions/Http/Executions/Create.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 067e540ab7..16308760d0 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -539,12 +539,12 @@ class Create extends Base private function enqueueDeletes( Document $project, - int $resourceId, - int $retention, + string $resourceId, + int $executionsRetentionCount, DeleteEvent $queueForDeletes ): void { /* cleanup */ - if ($retention > 0) { + if ($executionsRetentionCount > 0) { $queueForDeletes ->setProject($project) ->setResource($resourceId) From 2f066a6ba8e6e10a36e40aff7fc14901cbe23a9c Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 16 Jan 2026 18:41:22 +0530 Subject: [PATCH 32/34] add: check. --- .../Platform/Modules/Functions/Http/Executions/Create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 16308760d0..6d2048b233 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -544,7 +544,7 @@ class Create extends Base DeleteEvent $queueForDeletes ): void { /* cleanup */ - if ($executionsRetentionCount > 0) { + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { $queueForDeletes ->setProject($project) ->setResource($resourceId) From b1fab79dc4d4ff12ef9ee046f1e83cf06bcdb864 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 16 Jan 2026 19:06:55 +0530 Subject: [PATCH 33/34] updated query logic in array to be of and format --- src/Appwrite/Utopia/Database/RuntimeQuery.php | 7 ++- .../RealtimeCustomClientQueryTest.php | 61 +++++++++++++------ .../Database/Query/RuntimeQueryTest.php | 17 +++++- 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/Appwrite/Utopia/Database/RuntimeQuery.php b/src/Appwrite/Utopia/Database/RuntimeQuery.php index 11257db21f..025d424768 100644 --- a/src/Appwrite/Utopia/Database/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/RuntimeQuery.php @@ -33,12 +33,13 @@ class RuntimeQuery extends Query if (empty($queries)) { return $payload; } + // multiple queries follows and condition foreach ($queries as $query) { - if (self::evaluateFilter($query, $payload)) { - return $payload; + if (!self::evaluateFilter($query, $payload)) { + return []; }; } - return []; + return $payload; } private static function evaluateFilter(Query $query, array $payload): bool diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 365d0d8f6b..068736561e 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1307,7 +1307,7 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } - public function testMultipleQueriesWithOrLogic() + public function testMultipleQueriesWithAndLogic() { $user = $this->getUser(); $session = $user['session'] ?? ''; @@ -1350,27 +1350,26 @@ class RealtimeCustomClientQueryTest extends Scope sleep(2); - $docId1 = ID::unique(); - $docId2 = ID::unique(); + $targetDocId = ID::unique(); - // Subscribe with multiple queries (OR logic - any query matching returns event) + // Subscribe with multiple queries (AND logic - ALL queries must match for event to be received) $client = $this->getWebsocket(['documents'], [ 'origin' => 'http://localhost', 'cookie' => 'a_session_' . $projectId . '=' . $session, ], null, [ - Query::equal('$id', [$docId1])->toString(), - Query::equal('$id', [$docId2])->toString(), + Query::equal('$id', [$targetDocId])->toString(), + Query::equal('status', ['active'])->toString(), ]); $response = json_decode($client->receive(), true); $this->assertEquals('connected', $response['type']); - // Create document with first ID - should receive event + // Create document matching BOTH queries - should receive event $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ], $this->getHeaders()), [ - 'documentId' => $docId1, + 'documentId' => $targetDocId, 'data' => [ 'status' => 'active' ], @@ -1381,27 +1380,31 @@ class RealtimeCustomClientQueryTest extends Scope $event = json_decode($client->receive(), true); $this->assertEquals('event', $event['type']); - $this->assertEquals($docId1, $event['data']['payload']['$id']); + $this->assertEquals($targetDocId, $event['data']['payload']['$id']); + $this->assertEquals('active', $event['data']['payload']['status']); - // Create document with second ID - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + // Create document with matching ID but wrong status - should NOT receive event (only one query matches) + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ], $this->getHeaders()), [ - 'documentId' => $docId2, + 'documentId' => $targetDocId, 'data' => [ - 'status' => 'active' + 'status' => 'inactive' ], 'permissions' => [ Permission::read(Role::any()), ], ]); - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($docId2, $event['data']['payload']['$id']); + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } - // Create document with different ID - should NOT receive event + // Create document with matching status but wrong ID - should NOT receive event (only one query matches) $otherDocId = ID::unique(); $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ 'content-type' => 'application/json', @@ -1418,7 +1421,29 @@ class RealtimeCustomClientQueryTest extends Scope try { $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); + $this->fail('Expected TimeoutException - event should be filtered (status matches but ID does not)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document matching NEITHER query - should NOT receive event + $anotherDocId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $anotherDocId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); } catch (TimeoutException $e) { $this->assertTrue(true); } diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index 35fbde04ce..7df1ca80eb 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -487,6 +487,17 @@ class RuntimeQueryTest extends TestCase } // Edge cases + public function testMultipleQueriesAllMatch(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + public function testMultipleQueriesFirstMatches(): void { $queries = [ @@ -495,7 +506,8 @@ class RuntimeQueryTest extends TestCase ]; $payload = ['name' => 'John', 'age' => 30]; $result = RuntimeQuery::filter($queries, $payload); - $this->assertEquals($payload, $result); + // With AND logic, if first matches but second doesn't, should return empty + $this->assertEquals([], $result); } public function testMultipleQueriesSecondMatches(): void @@ -506,7 +518,8 @@ class RuntimeQueryTest extends TestCase ]; $payload = ['name' => 'John', 'age' => 30]; $result = RuntimeQuery::filter($queries, $payload); - $this->assertEquals($payload, $result); + // With AND logic, if second matches but first doesn't, should return empty + $this->assertEquals([], $result); } public function testMultipleQueriesNoneMatch(): void From d56a3c1534fe05fade001bcb874f7cfb463b0663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 16 Jan 2026 14:53:05 +0100 Subject: [PATCH 34/34] Apply suggestion from @Meldiron --- .../Platform/Modules/Functions/Http/Functions/Update.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index f2925f52be..f73e7ed8b8 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -262,6 +262,7 @@ class Update extends Base 'commands' => $commands, 'scopes' => $scopes, 'deploymentRetention' => 0, + 'startCommand' => '', 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'providerRepositoryId' => $providerRepositoryId,