From a7b99edcf0b38afbe5ba453b6a27e7203b55890b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Jul 2026 03:59:56 +1200 Subject: [PATCH 1/3] fix(mongo): honour query timeout on count() and sum() aggregate paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit count() set maxTimeMS on $options but then reassigned $options from getTransactionOptions() before the aggregate, discarding it; sum() never set it at all. A total=true document list issues count(), so its aggregation ran unbounded — a slow/large mongo read hung until the caller gave up (surfacing downstream as a ~90s 503) instead of failing fast with a Timeout. Set maxTimeMS on the options actually passed to aggregate() in both paths. Regression: testCountTimeout. --- src/Database/Adapter/Mongo.php | 14 +++++-- tests/e2e/Adapter/Scopes/GeneralTests.php | 46 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index a7799abc1..46cf82c42 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2718,10 +2718,6 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $options['limit'] = $max; } - if ($this->timeout) { - $options['maxTimeMS'] = $this->timeout; - } - // Build filters from queries $filters = $this->buildFilters($queries); @@ -2745,6 +2741,11 @@ public function count(Document $collection, array $queries = [], ?int $max = nul **/ $options = $this->getTransactionOptions(); + + if ($this->timeout) { + $options['maxTimeMS'] = $this->timeout; + } + $pipeline = []; // Add match stage if filters are provided @@ -2848,6 +2849,11 @@ public function sum(Document $collection, string $attribute, array $queries = [] ]; $options = $this->getTransactionOptions(); + + if ($this->timeout) { + $options['maxTimeMS'] = $this->timeout; + } + return $this->client->aggregate($name, $pipeline, $options)->cursor->firstBatch[0]->total ?? 0; } diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 6e8677315..ef67c63b5 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -86,7 +86,53 @@ public function testQueryTimeout(): void } } + public function testCountTimeout(): void + { + if (!$this->getDatabase()->getAdapter()->getSupportForTimeouts()) { + $this->expectNotToPerformAssertions(); + return; + } + + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection('count-timeouts'); + + $this->assertEquals( + true, + $database->createAttribute( + collection: 'count-timeouts', + id: 'longtext', + type: Database::VAR_STRING, + size: 100000000, + required: true + ) + ); + for ($i = 0; $i < 20; $i++) { + $database->createDocument('count-timeouts', new Document([ + 'longtext' => file_get_contents(__DIR__ . '/../../../resources/longtext.txt'), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ] + ])); + } + + $database->setTimeout(1); + + try { + $database->count('count-timeouts', [ + Query::notEqual('longtext', 'appwrite'), + ]); + $this->fail('count() failed to throw a timeout exception'); + } catch (\Exception $e) { + $database->clearTimeout(); + $database->deleteCollection('count-timeouts'); + $this->assertInstanceOf(TimeoutException::class, $e); + } + } public function testPreserveDatesUpdate(): void { From faeb5bf431c7169ae1b92a9a69c86afdfe2f9b4d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Jul 2026 16:36:25 +1200 Subject: [PATCH 2/3] fix: surface count()/sum() timeouts and stop clearTimeout leaking on SQL adapters - Mongo count() swallowed every client exception and returned 0, so a maxTimeMS expiry never surfaced; rethrow timeouts via processException. - Mongo sum() threw the raw client exception; route it through processException so timeouts map to the Timeout exception. - Remove count()'s dead early $options['limit'] assignment (the limit is applied via the $limit pipeline stage). - Adapter::clearTimeout() now resets the timeout property. Postgres applies the property on every statement (SET statement_timeout), so a cleared timeout kept cancelling all subsequent heavy statements on the connection. - Harden testCountTimeout: cleanup in finally, fixture read once, and the assertion moved out of the catch so a swallowed timeout fails clearly. --- src/Database/Adapter.php | 5 +++++ src/Database/Adapter/Mongo.php | 16 +++++++++------ tests/e2e/Adapter/Scopes/GeneralTests.php | 24 ++++++++++++++--------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index ef0598341..289efe578 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -349,6 +349,11 @@ public function clearTimeout(string $event): void { // Clear existing callback $this->before($event, 'timeout'); + + // Adapters that apply the timeout from this property on every statement + // (e.g. Postgres SET statement_timeout) would otherwise keep enforcing a + // cleared timeout on all subsequent queries. + $this->timeout = 0; } /** diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 46cf82c42..b23073f4f 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2712,11 +2712,6 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $this->escapeQueryAttributes($collection, $queries); $filters = []; - $options = []; - - if (!\is_null($max) && $max > 0) { - $options['limit'] = $max; - } // Build filters from queries $filters = $this->buildFilters($queries); @@ -2791,6 +2786,11 @@ public function count(Document $collection, array $queries = [], ?int $max = nul return 0; } catch (MongoException $e) { + $processed = $this->processException($e); + if ($processed instanceof TimeoutException) { + throw $processed; + } + return 0; } } @@ -2854,7 +2854,11 @@ public function sum(Document $collection, string $attribute, array $queries = [] $options['maxTimeMS'] = $this->timeout; } - return $this->client->aggregate($name, $pipeline, $options)->cursor->firstBatch[0]->total ?? 0; + try { + return $this->client->aggregate($name, $pipeline, $options)->cursor->firstBatch[0]->total ?? 0; + } catch (MongoException $e) { + throw $this->processException($e); + } } /** diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index ef67c63b5..2dd3fdccb 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -109,9 +109,10 @@ public function testCountTimeout(): void ) ); + $longtext = file_get_contents(__DIR__ . '/../../../resources/longtext.txt'); for ($i = 0; $i < 20; $i++) { $database->createDocument('count-timeouts', new Document([ - 'longtext' => file_get_contents(__DIR__ . '/../../../resources/longtext.txt'), + 'longtext' => $longtext, '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -120,17 +121,22 @@ public function testCountTimeout(): void ])); } - $database->setTimeout(1); - try { - $database->count('count-timeouts', [ - Query::notEqual('longtext', 'appwrite'), - ]); - $this->fail('count() failed to throw a timeout exception'); - } catch (\Exception $e) { + $database->setTimeout(1); + + $thrown = null; + try { + $database->count('count-timeouts', [ + Query::notEqual('longtext', 'appwrite'), + ]); + } catch (\Exception $e) { + $thrown = $e; + } + + $this->assertInstanceOf(TimeoutException::class, $thrown, 'count() must throw a timeout exception'); + } finally { $database->clearTimeout(); $database->deleteCollection('count-timeouts'); - $this->assertInstanceOf(TimeoutException::class, $e); } } From f35de82919b77561f631841429fb12e66e6366ae Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Jul 2026 16:56:29 +1200 Subject: [PATCH 3/3] test: force real scan work in testCountTimeout so the 1ms bound trips COUNT with a notEqual filter finishes in microseconds on Postgres (no row serialization, length-first text comparison), so statement_timeout never fired. A substring scan walks every 14MB value on all engines. Verified locally on Postgres, MongoDB, MariaDB and SharedTables/Postgres. --- tests/e2e/Adapter/Scopes/GeneralTests.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 2dd3fdccb..2057197fd 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -126,8 +126,10 @@ public function testCountTimeout(): void $thrown = null; try { + // A substring scan forces the engine to walk every huge value; a + // cheap filter (e.g. notEqual) lets COUNT finish inside the timeout. $database->count('count-timeouts', [ - Query::notEqual('longtext', 'appwrite'), + Query::contains('longtext', ['needle-that-does-not-exist']), ]); } catch (\Exception $e) { $thrown = $e;