Skip to content

fix(mongo): honour query timeout on count() and sum() aggregate paths#913

Merged
abnegate merged 3 commits into
mainfrom
fix/mongo-aggregate-maxtimems
Jul 2, 2026
Merged

fix(mongo): honour query timeout on count() and sum() aggregate paths#913
abnegate merged 3 commits into
mainfrom
fix/mongo-aggregate-maxtimems

Conversation

@abnegate

@abnegate abnegate commented Jul 1, 2026

Copy link
Copy Markdown
Member

Problem

The Mongo adapter drops maxTimeMS on the count() and sum() aggregation paths, so those operations run unbounded even when a query timeout is set via setTimeout():

  • count() sets $options['maxTimeMS'] early, but then reassigns $options = $this->getTransactionOptions(); before building the aggregate pipeline, discarding the timeout.
  • sum() never sets maxTimeMS at all.

find() is unaffected (it sets maxTimeMS on the options it actually passes), which is why this went unnoticed.

Impact: a total=true document list issues count(), so a large/slow Mongo read hangs until the client gives up instead of failing fast with a Timeout. Downstream (Appwrite Cloud DocumentsDB) this surfaced as a request stuck ~90s → 503, tying up a worker the whole time.

Fix

Set maxTimeMS on the options actually passed to aggregate() in both count() and sum(), and remove the now-dead early assignment in count().

Test

testCountTimeout (shared adapter scope): builds a large collection, sets a 1ms timeout, and asserts count() throws Timeout. Fails without this change on Mongo (count completes unbounded); passes with it. count() is type-agnostic so it is safe across all timeout-supporting adapters (Mongo maxTimeMS, MariaDB MAX_EXECUTION_TIME, Postgres statement_timeout).

Summary by CodeRabbit

  • Bug Fixes
    • Improved timeout handling for aggregation-based count and sum operations, applying very short limits more consistently.
    • When clearing timeouts, the adapter now fully resets its internal timeout state to avoid carrying limits into later statements.
  • Tests
    • Added an end-to-end test covering count behavior under extremely short timeouts, validating that timeout exceptions are raised when supported.

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.
Copilot AI review requested due to automatic review settings July 1, 2026 16:00
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@abnegate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: beb55b1a-874f-4c77-8328-a71bc1f51538

📥 Commits

Reviewing files that changed from the base of the PR and between faeb5bf and f35de82.

📒 Files selected for processing (1)
  • tests/e2e/Adapter/Scopes/GeneralTests.php
📝 Walkthrough

Walkthrough

The adapter now resets timeout state when clearing a timeout, applies Mongo query timeouts after transaction options are resolved, and adds an e2e test that verifies count() surfaces a timeout exception.

Changes

Timeout handling in adapter and Mongo queries

Layer / File(s) Summary
Clear timeout state
src/Database/Adapter.php
clearTimeout() now removes the callback and resets the stored timeout value to 0.
Update Mongo count() and sum()
src/Database/Adapter/Mongo.php
count() applies maxTimeMS after getTransactionOptions() and rethrows processed timeout exceptions; sum() applies maxTimeMS after getTransactionOptions() and throws processed Mongo exceptions from aggregation.
Count timeout e2e coverage
tests/e2e/Adapter/Scopes/GeneralTests.php
testCountTimeout() sets up timeout-sensitive data, runs count() with a short timeout, asserts TimeoutException, and cleans up afterward.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Test as testCountTimeout()
    participant Adapter as Mongo Adapter
    participant MongoDB

    Test->>Adapter: setTimeout(1ms)
    Test->>Adapter: count(query)
    Adapter->>Adapter: getTransactionOptions()
    Adapter->>MongoDB: aggregate with maxTimeMS
    MongoDB-->>Adapter: timeout error
    Adapter-->>Test: TimeoutException
    Test->>Adapter: clearTimeout()
    Test->>Adapter: delete collection
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed It clearly summarizes the main Mongo timeout fix for count() and sum() aggregate paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mongo-aggregate-maxtimems

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two aggregate paths in the MongoDB adapter where query timeouts were silently ignored: count() was discarding its early maxTimeMS assignment by overwriting $options, and sum() never set it at all. It also extends Adapter::clearTimeout() to reset $this->timeout so adapters that read the property directly don't carry stale limits forward.

  • count() fix: removes the dead early $options assignment, sets maxTimeMS on the options passed to aggregate(), and propagates TimeoutException from the catch block instead of silently returning 0.
  • sum() fix: adds maxTimeMS to the aggregate options and wraps the call in a try/catch that forwards all processed exceptions (not just timeouts).
  • Test: testCountTimeout inserts 20 large documents and runs a contains query (full-scan) under a 1 ms timeout, asserting a TimeoutException is raised; cleanup is guarded with finally.

Confidence Score: 5/5

Safe to merge — the fix is targeted, the logic is straightforward, and both count() and sum() now correctly honour the configured timeout.

The changes are narrowly scoped to moving maxTimeMS onto the right options object and adding the missing exception propagation. The core logic of each method is unchanged, the test exercises the exact failure mode described in the PR, and cleanup is correctly guarded with finally.

No files require special attention.

Important Files Changed

Filename Overview
src/Database/Adapter.php Adds $this->timeout = 0 to clearTimeout() so adapters that read the property directly (like Mongo) see a clean state after clearing — correct and unambiguous fix.
src/Database/Adapter/Mongo.php Fixes two aggregate paths: count() now sets maxTimeMS on the options actually passed to aggregate() and propagates TimeoutException; sum() gains both maxTimeMS and full exception handling. Minor: Mongo::clearTimeout now redundantly resets $this->timeout after the parent already does it.
tests/e2e/Adapter/Scopes/GeneralTests.php New testCountTimeout test properly uses a finally block for cleanup, gates on getSupportForTimeouts(), and forces a full substring scan to reliably exhaust a 1 ms window.

Reviews (3): Last reviewed commit: "test: force real scan work in testCountT..." | Re-trigger Greptile

Comment thread tests/e2e/Adapter/Scopes/GeneralTests.php

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Mongo adapter timeout handling for aggregation-based operations so setTimeout() is honored consistently across find(), count(), and sum() paths, and adds an e2e regression test to ensure count() fails fast with a timeout.

Changes:

  • Mongo adapter: apply maxTimeMS to the actual options passed to aggregate() in count() and sum().
  • Mongo adapter: remove the earlier (now-dead) maxTimeMS assignment in count() before $options is reassigned.
  • E2E tests: add testCountTimeout() to validate count() throws TimeoutException when timeouts are supported.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
tests/e2e/Adapter/Scopes/GeneralTests.php Adds an e2e regression test asserting count() respects setTimeout() and throws TimeoutException.
src/Database/Adapter/Mongo.php Ensures maxTimeMS is set on the aggregate() options for count() and sum() aggregation pipelines.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/e2e/Adapter/Scopes/GeneralTests.php
Comment thread src/Database/Adapter/Mongo.php Outdated
Comment on lines 2714 to 2718
@@ -2718,10 +2718,6 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
$options['limit'] = $max;
Comment thread src/Database/Adapter/Mongo.php
Comment thread src/Database/Adapter/Mongo.php Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Database/Adapter/Mongo.php (2)

2811-2857: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

sum() never converts driver exceptions into TimeoutException.

Unlike find()/getDocument()/count(), sum() has no try/catch around $this->client->aggregate(...). Even after adding maxTimeMS here, a timeout raised by the driver propagates as a raw MongoException rather than being converted via processException() into TimeoutException. Callers that specifically catch TimeoutException around sum() (e.g. mirroring testCountTimeout) won't see it.

🐛 Proposed fix
-        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);
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Adapter/Mongo.php` around lines 2811 - 2857, The `sum()` method
in `Mongo` currently calls `client->aggregate()` without the same exception
handling used by `find()`, `getDocument()`, and `count()`, so timeout errors
bypass `processException()` and never become `TimeoutException`. Wrap the
aggregate call (and result access) in the same try/catch pattern as the other
read methods, and on any driver exception pass it through `processException()`
so callers of `sum()` get consistent timeout behavior.

2778-2795: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

count() should rethrow mapped Mongo exceptions instead of swallowing them. processException() already turns Mongo codes 50/262 into TimeoutException, but this catch (MongoException $e) { return 0; } bypasses that and converts a timeout into a false 0 result. Re-throw processException($e) here so count() can surface timeouts as expected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Adapter/Mongo.php` around lines 2778 - 2795, The count() method
in Mongo.php is swallowing MongoException and returning 0, which prevents mapped
exceptions from surfacing. Update the catch block in count() to rethrow the
result of processException($e) instead of returning a fallback value, so
timeout-related Mongo codes are converted and propagated as TimeoutException as
intended. Keep the fix localized to count() and reuse the existing
processException() helper.
🧹 Nitpick comments (1)
src/Database/Adapter/Mongo.php (1)

2714-2722: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pre-existing dead $options['limit'] is discarded by the later getTransactionOptions() reassignment.

$options['limit'] set here (used only for the legacy count command, not the aggregation pipeline) is unconditionally overwritten by $options = $this->getTransactionOptions(); at line 2743, making these lines effectively dead code. Not introduced by this PR, but adjacent to the exact "options get replaced" bug being fixed here — worth cleaning up to avoid future confusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Adapter/Mongo.php` around lines 2714 - 2722, The
$options['limit'] assignment in Mongo::buildFilters/count logic is dead because
$options is later replaced by getTransactionOptions(), so clean up the
pre-existing unused limit handling. Remove the obsolete limit setup or move any
legacy count-specific option building into the code path that actually sends the
count command, and keep the buildFilters/getTransactionOptions flow consistent
so the final $options used for the query is the only one that matters.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/Database/Adapter/Mongo.php`:
- Around line 2811-2857: The `sum()` method in `Mongo` currently calls
`client->aggregate()` without the same exception handling used by `find()`,
`getDocument()`, and `count()`, so timeout errors bypass `processException()`
and never become `TimeoutException`. Wrap the aggregate call (and result access)
in the same try/catch pattern as the other read methods, and on any driver
exception pass it through `processException()` so callers of `sum()` get
consistent timeout behavior.
- Around line 2778-2795: The count() method in Mongo.php is swallowing
MongoException and returning 0, which prevents mapped exceptions from surfacing.
Update the catch block in count() to rethrow the result of processException($e)
instead of returning a fallback value, so timeout-related Mongo codes are
converted and propagated as TimeoutException as intended. Keep the fix localized
to count() and reuse the existing processException() helper.

---

Nitpick comments:
In `@src/Database/Adapter/Mongo.php`:
- Around line 2714-2722: The $options['limit'] assignment in
Mongo::buildFilters/count logic is dead because $options is later replaced by
getTransactionOptions(), so clean up the pre-existing unused limit handling.
Remove the obsolete limit setup or move any legacy count-specific option
building into the code path that actually sends the count command, and keep the
buildFilters/getTransactionOptions flow consistent so the final $options used
for the query is the only one that matters.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0b34a94a-2bed-4f96-80ce-2c90c7854161

📥 Commits

Reviewing files that changed from the base of the PR and between c279b24 and a7b99ed.

📒 Files selected for processing (2)
  • src/Database/Adapter/Mongo.php
  • tests/e2e/Adapter/Scopes/GeneralTests.php

…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.
@abnegate

abnegate commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

All review comments addressed in faeb5bf4, plus the root causes of the adapter-test failures:

  • count() swallowing timeouts (Copilot)catch (MongoException) { return 0; } now routes through processException() and rethrows Timeout; other errors keep the lenient return 0.
  • sum() raw client exception (Copilot) — wrapped in processException() so timeouts map to Timeout.
  • Dead $options['limit'] in count() (Copilot) — removed; the limit is applied via the $limit pipeline stage.
  • Cleanup only in catch (greptile P2)clearTimeout/deleteCollection moved to finally, and the assertion moved out of the catch (previously a swallowed timeout made fail() throw inside the try and get asserted as the exception).
  • file_get_contents per iteration (Copilot) — fixture read once.

The CI failures had two distinct causes, both real bugs this PR now fixes:

  1. Mongo jobs: the count timeout was swallowed (returned 0), so the test's fail() fired.
  2. Postgres jobs: Adapter::clearTimeout() never reset the timeout property, and Postgres applies that property on every statement (SET statement_timeout) — so testQueryTimeout's 1ms timeout leaked into every subsequent statement on the connection, and the new test's heavy inserts were the first thing slow enough to die. clearTimeout() now resets the property.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/Database/Adapter/Mongo.php (2)

110-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant timeout reset after parent call.

parent::clearTimeout($event) now sets $this->timeout = 0 itself (per the Adapter.php change), so the explicit $this->timeout = 0; here is now dead/duplicate code.

♻️ Proposed cleanup
 public function clearTimeout(string $event): void
 {
     parent::clearTimeout($event);
-
-    $this->timeout = 0;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Adapter/Mongo.php` around lines 110 - 115, The clearTimeout
method in Mongo now duplicates the timeout reset already handled by
Adapter::clearTimeout($event). Remove the extra $this->timeout = 0 assignment
from Mongo::clearTimeout so the method only delegates to the parent and avoids
redundant state updates.

2773-2796: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Non-timeout aggregation errors are still silently swallowed as 0.

Only TimeoutException is rethrown; any other processed MongoException (e.g. a genuine query/connection failure) still falls through to return 0, indistinguishable from "no matching documents." This predates this change but is now more visible since the catch block was touched directly.

Consider rethrowing all processed exceptions except cases genuinely intended to yield 0 (e.g. NamespaceNotFound), so callers aren't misled by transient/real errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Adapter/Mongo.php` around lines 2773 - 2796, The aggregation
error handling in Mongo::aggregateCount (the try/catch around
$this->client->aggregate) still swallows non-timeout MongoException cases by
returning 0, which hides real failures as “no results.” Update the catch block
to rethrow processed exceptions unless they are explicitly intended to map to 0,
keeping the existing TimeoutException handling but allowing genuine
query/connection errors (after processException) to propagate to callers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/Database/Adapter/Mongo.php`:
- Around line 110-115: The clearTimeout method in Mongo now duplicates the
timeout reset already handled by Adapter::clearTimeout($event). Remove the extra
$this->timeout = 0 assignment from Mongo::clearTimeout so the method only
delegates to the parent and avoids redundant state updates.
- Around line 2773-2796: The aggregation error handling in Mongo::aggregateCount
(the try/catch around $this->client->aggregate) still swallows non-timeout
MongoException cases by returning 0, which hides real failures as “no results.”
Update the catch block to rethrow processed exceptions unless they are
explicitly intended to map to 0, keeping the existing TimeoutException handling
but allowing genuine query/connection errors (after processException) to
propagate to callers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a69d4795-6214-49d5-84a2-52f38e4a2cb9

📥 Commits

Reviewing files that changed from the base of the PR and between a7b99ed and faeb5bf.

📒 Files selected for processing (3)
  • src/Database/Adapter.php
  • src/Database/Adapter/Mongo.php
  • tests/e2e/Adapter/Scopes/GeneralTests.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/e2e/Adapter/Scopes/GeneralTests.php

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.
@abnegate abnegate merged commit efca1a6 into main Jul 2, 2026
22 checks passed
@abnegate abnegate deleted the fix/mongo-aggregate-maxtimems branch July 2, 2026 05:55
@abnegate abnegate restored the fix/mongo-aggregate-maxtimems branch July 2, 2026 05:55
@abnegate abnegate deleted the fix/mongo-aggregate-maxtimems branch July 2, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants