Skip to content

Commit efca1a6

Browse files
authored
Merge pull request #913 from utopia-php/fix/mongo-aggregate-maxtimems
fix(mongo): honour query timeout on count() and sum() aggregate paths
2 parents c279b24 + f35de82 commit efca1a6

3 files changed

Lines changed: 79 additions & 10 deletions

File tree

‎src/Database/Adapter.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ public function clearTimeout(string $event): void
349349
{
350350
// Clear existing callback
351351
$this->before($event, 'timeout');
352+
353+
// Adapters that apply the timeout from this property on every statement
354+
// (e.g. Postgres SET statement_timeout) would otherwise keep enforcing a
355+
// cleared timeout on all subsequent queries.
356+
$this->timeout = 0;
352357
}
353358

354359
/**

‎src/Database/Adapter/Mongo.php‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,15 +2712,6 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
27122712
$this->escapeQueryAttributes($collection, $queries);
27132713

27142714
$filters = [];
2715-
$options = [];
2716-
2717-
if (!\is_null($max) && $max > 0) {
2718-
$options['limit'] = $max;
2719-
}
2720-
2721-
if ($this->timeout) {
2722-
$options['maxTimeMS'] = $this->timeout;
2723-
}
27242715

27252716
// Build filters from queries
27262717
$filters = $this->buildFilters($queries);
@@ -2745,6 +2736,11 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
27452736
**/
27462737

27472738
$options = $this->getTransactionOptions();
2739+
2740+
if ($this->timeout) {
2741+
$options['maxTimeMS'] = $this->timeout;
2742+
}
2743+
27482744
$pipeline = [];
27492745

27502746
// Add match stage if filters are provided
@@ -2790,6 +2786,11 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
27902786

27912787
return 0;
27922788
} catch (MongoException $e) {
2789+
$processed = $this->processException($e);
2790+
if ($processed instanceof TimeoutException) {
2791+
throw $processed;
2792+
}
2793+
27932794
return 0;
27942795
}
27952796
}
@@ -2848,7 +2849,16 @@ public function sum(Document $collection, string $attribute, array $queries = []
28482849
];
28492850

28502851
$options = $this->getTransactionOptions();
2851-
return $this->client->aggregate($name, $pipeline, $options)->cursor->firstBatch[0]->total ?? 0;
2852+
2853+
if ($this->timeout) {
2854+
$options['maxTimeMS'] = $this->timeout;
2855+
}
2856+
2857+
try {
2858+
return $this->client->aggregate($name, $pipeline, $options)->cursor->firstBatch[0]->total ?? 0;
2859+
} catch (MongoException $e) {
2860+
throw $this->processException($e);
2861+
}
28522862
}
28532863

28542864
/**

‎tests/e2e/Adapter/Scopes/GeneralTests.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,61 @@ public function testQueryTimeout(): void
8686
}
8787
}
8888

89+
public function testCountTimeout(): void
90+
{
91+
if (!$this->getDatabase()->getAdapter()->getSupportForTimeouts()) {
92+
$this->expectNotToPerformAssertions();
93+
return;
94+
}
95+
96+
/** @var Database $database */
97+
$database = $this->getDatabase();
98+
99+
$database->createCollection('count-timeouts');
100+
101+
$this->assertEquals(
102+
true,
103+
$database->createAttribute(
104+
collection: 'count-timeouts',
105+
id: 'longtext',
106+
type: Database::VAR_STRING,
107+
size: 100000000,
108+
required: true
109+
)
110+
);
89111

112+
$longtext = file_get_contents(__DIR__ . '/../../../resources/longtext.txt');
113+
for ($i = 0; $i < 20; $i++) {
114+
$database->createDocument('count-timeouts', new Document([
115+
'longtext' => $longtext,
116+
'$permissions' => [
117+
Permission::read(Role::any()),
118+
Permission::update(Role::any()),
119+
Permission::delete(Role::any())
120+
]
121+
]));
122+
}
123+
124+
try {
125+
$database->setTimeout(1);
126+
127+
$thrown = null;
128+
try {
129+
// A substring scan forces the engine to walk every huge value; a
130+
// cheap filter (e.g. notEqual) lets COUNT finish inside the timeout.
131+
$database->count('count-timeouts', [
132+
Query::contains('longtext', ['needle-that-does-not-exist']),
133+
]);
134+
} catch (\Exception $e) {
135+
$thrown = $e;
136+
}
137+
138+
$this->assertInstanceOf(TimeoutException::class, $thrown, 'count() must throw a timeout exception');
139+
} finally {
140+
$database->clearTimeout();
141+
$database->deleteCollection('count-timeouts');
142+
}
143+
}
90144

91145
public function testPreserveDatesUpdate(): void
92146
{

0 commit comments

Comments
 (0)