-
Type:
Bug
-
Resolution: Unresolved
-
Priority:
Major - P3
-
None
-
Affects Version/s: 5.12.0
-
None
-
None
-
None
-
None
-
None
-
None
Environment: laravel-mongodb 5.12.0, mongodb/mongodb 2.4.2, ext-mongodb 2.5.3, PHP 8.4.15, Laravel 13.38.0
Related: PHPLARA-307 (caused by), https://github.com/mongodb/laravel-mongodb/pull/3646
Summary
Since PHPLARA-307 (PR #3646), Connection::syncCommandSubscriber() attaches a CommandSubscriber whenever anything listens to QueryExecuted. Telescope, Debugbar, Ignition's query recorder and Sentry's breadcrumb/tracing handlers all do, so most applications now have the subscriber attached.
The subscriber is added to the driver client, and the client outlives the Connection. Only disconnect() removes it (its own comment: "The client outlives the connection, so detach the subscriber explicitly"). A Connection dropped without disconnect() leaves its subscriber registered. The subscriber keeps the Connection alive, and with it the event dispatcher and the whole application container.
Regression: 5.11.0 is not affected. There the subscriber was attached only by enableQueryLog().
Impact
Laravel's test teardown never calls disconnect(). Every test builds a new application and a new Connection, so every test leaves one subscriber behind. After N tests in one process, each MongoDB command:
- is JSON-encoded N times (Document::fromPHP($command)->toCanonicalExtendedJSON() in CommandSubscriber::logQuery());
- is dispatched into N dead applications, whose listeners keep their own copies (Ignition's recorder keeps the last 200 queries per application).
Observed: a suite of about 6,400 tests run with --parallel. Workers died at the 512 MB memory limit in CommandSubscriber.php line 55. Each test file passed on its own. The same suite runs clean on 5.11.0.
The automated review on PR #3646 flagged this: "disconnecting can leave a stale subscriber attached".
Steps to Reproduce
Needs only a MongoDB server on localhost. Each loop iteration stands in for one test: a new dispatcher with one listener, and a new connection.
<?php require __DIR__ . '/vendor/autoload.php'; use Illuminate\Database\Events\QueryExecuted; use Illuminate\Events\Dispatcher; use MongoDB\Laravel\Connection; $calls = []; for ($i = 1; $i <= 5; $i++) { $events = new Dispatcher(); $calls[$i] = 0; $events->listen(QueryExecuted::class, function () use ($i, &$calls) { $calls[$i]++; }); $connection = new Connection(['dsn' => 'mongodb://127.0.0.1:27017', 'database' => 'repro']); $connection->setEventDispatcher($events); $connection->table('probe')->first(); // Uncomment to detach the subscriber; then each query reaches only its own listener. // $connection->disconnect(); unset($connection, $events); gc_collect_cycles(); } print_r($calls);
Expected Result
Each listener receives the one query its own connection ran:
Array ( [1] => 1 [2] => 1 [3] => 1 [4] => 1 [5] => 1 )
This is what 5.12.0 prints with the disconnect() line uncommented. (5.11.0 prints zeros: it attaches no subscriber without enableQueryLog().)
Actual Result
The first listener receives all five queries, the second four, and so on. Every old subscriber is still attached and still dispatching:
Array ( [1] => 5 [2] => 4 [3] => 3 [4] => 2 [5] => 1 )
Memory grows with every connection created. Each command costs one JSON encoding per stale subscriber.
Workaround
Disconnect the connections before the test application is destroyed, in the base test case:
protected function setUp(): void { parent::setUp(); $this->beforeApplicationDestroyed(function (): void { foreach (array_keys($this->app['db']->getConnections()) as $name) { $this->app['db']->disconnect($name); } }); }
Suggested Fix
The subscriber could hold the connection through a WeakReference and remove itself from the client the first time it is called after the connection is gone. While the client's subscriber list holds a strong reference, the connection can never be collected, so a __destruct() on Connection would not run either.