diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index ef05983413..289efe578a 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 a7799abc12..b23073f4f2 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2712,15 +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; - } - - if ($this->timeout) { - $options['maxTimeMS'] = $this->timeout; - } // Build filters from queries $filters = $this->buildFilters($queries); @@ -2745,6 +2736,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 @@ -2790,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; } } @@ -2848,7 +2849,16 @@ public function sum(Document $collection, string $attribute, array $queries = [] ]; $options = $this->getTransactionOptions(); - return $this->client->aggregate($name, $pipeline, $options)->cursor->firstBatch[0]->total ?? 0; + + if ($this->timeout) { + $options['maxTimeMS'] = $this->timeout; + } + + 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 6e86773150..2057197fd1 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -86,7 +86,61 @@ 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 + ) + ); + $longtext = file_get_contents(__DIR__ . '/../../../resources/longtext.txt'); + for ($i = 0; $i < 20; $i++) { + $database->createDocument('count-timeouts', new Document([ + 'longtext' => $longtext, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ] + ])); + } + + try { + $database->setTimeout(1); + + $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::contains('longtext', ['needle-that-does-not-exist']), + ]); + } catch (\Exception $e) { + $thrown = $e; + } + + $this->assertInstanceOf(TimeoutException::class, $thrown, 'count() must throw a timeout exception'); + } finally { + $database->clearTimeout(); + $database->deleteCollection('count-timeouts'); + } + } public function testPreserveDatesUpdate(): void {