Bug Report
| Q |
A |
| Version |
4.4.x |
| Previous Version if the bug is a regression |
x.y.z |
Summary
Doctrine\DBAL\Connection::connect() short-circuits on $this->_conn !== null and returns the cached driver connection without any liveness or idle-time check. When a gateway sits between the app and the database (haproxy, RDS Proxy, PgBouncer, Patroni, tcp_keepalive_time) and reaps the socket due to timeout server, every subsequent op routed through the wrapper Connection (which is all of them) returns that dead reference.
Symfony's symfony/doctrine-bridge works around this externally via Symfony\Bridge\Doctrine\Middleware\IdleConnection\Driver + Symfony\Bridge\Doctrine\Middleware\IdleConnection\Listener, but the listener only fires on kernel.request (no coverage inside a single in-flight handler) and the driver stamps "time since first connect" rather than "time since last use".
Filed here but redirected from symfony/doctrine-bridge to doctrine/dbal: symfony/symfony#64314.
Current behavior
In src/Connection.php at line 217:
protected function connect(): DriverConnection
{
if ($this->_conn !== null) {
return $this->_conn; // <-- returns whatever's cached, no liveness check
}
// ... reconnect on null ...
}
A handler that does SELECT then a long external call then UPDATE keeps $this->_conn non-null the whole time, so the second call to connect() returns the cached reference. If the gateway already reaped the socket, the UPDATE throws. On PgSQL it's classified as Doctrine\DBAL\Exception\DriverException SQLSTATE[HY000] (see #6812), not Doctrine\DBAL\Exception\ConnectionLost, so even reactive catch (ConnectionLost) recovery doesn't help.
Doctrine\DBAL\Configuration has no idle-TTL setting today.
Expected behavior
Doctrine\DBAL\Connection::connect() should honor an optional idle TTL on its cached-return branch. When now - lastUsedAt >= ttl AND no transaction is active, close the cached connection so the existing reconnect path produces a fresh one. Transaction safety is trivial via the existing $this->transactionNestingLevel field. Default ttl = 0 (disabled) preserves current behavior.
Doctrine\DBAL\Connection::connect() becomes something like this:
protected function connect(): DriverConnection
{
if ($this->_conn !== null) {
$ttl = $this->_config->getIdleConnectionTtl();
if (
$ttl > 0
&& $this->transactionNestingLevel === 0
&& microtime(true) - $this->lastUsedAt >= $ttl
) {
$this->close();
// falls through to reconnect below
} else {
$this->lastUsedAt = microtime(true);
return $this->_conn;
}
}
try {
$connection = $this->_conn = $this->driver->connect($this->params);
} catch (Driver\Exception $e) {
throw $this->convertException($e);
}
if ($this->autoCommit === false) {
$this->beginTransaction();
}
$this->lastUsedAt = microtime(true);
return $connection;
}
Why this location: connect() is the funnel every wire-touching method already calls (executeQuery, executeStatement, prepare, quote, lastInsertId, getServerVersion, getNativeConnection, all transaction primitives). One site, full coverage.
A Doctrine\DBAL\Driver\Middleware cannot substitute: Doctrine\DBAL\Driver\Connection has 10 separate methods and no funnel, and Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware::$wrappedConnection is private readonly so swap-inside requires reflection or full interface reimplementation.
As a trade-off described in #6351 Doctrine\DBAL\Connection::connect can be decorated but it would conflict with other wrappers like \Doctrine\DBAL\Connections\PrimaryReadReplicaConnection that also override connect()
How to reproduce
Standalone Docker Compose reproducer at
connection-reproducer.zip
boots PostgreSQL behind haproxy with timeout server 30s, and two PHP containers running identical Symfony
Three scenarios:
| Scenario |
DATABASE_URL |
Outcome |
| A |
direct to postgres |
[OK] SELECT 1 OK at t+35s |
| B |
through haproxy, console command |
[ERROR] Doctrine\DBAL\Exception\DriverException SQLSTATE[HY000] |
| C |
through haproxy, messenger handler |
same [ERROR], messenger retries |
haproxy log shows cD (client timeout) at 30s on the failing run.
What is expected after a fix on this reproducer ?
- set haproxy
timeout server 30s
- set
doctrine.dbal.idle_connection_ttl to 25
Doctrine\DBAL\Connection::connect() reconnects after TTL as expected. Scenario B and C passes
Current workarounds for Symfony
Symfony has a few boundary-based mechanisms for recovering a stale Doctrine connection. None can fire while the main thread is blocked.
All of them operate at boundaries between units of work never in-flight.
Bug Report
Summary
Doctrine\DBAL\Connection::connect()short-circuits on$this->_conn !== nulland returns the cached driver connection without any liveness or idle-time check. When a gateway sits between the app and the database (haproxy, RDS Proxy, PgBouncer, Patroni,tcp_keepalive_time) and reaps the socket due totimeout server, every subsequent op routed through the wrapperConnection(which is all of them) returns that dead reference.Symfony's
symfony/doctrine-bridgeworks around this externally viaSymfony\Bridge\Doctrine\Middleware\IdleConnection\Driver+Symfony\Bridge\Doctrine\Middleware\IdleConnection\Listener, but the listener only fires onkernel.request(no coverage inside a single in-flight handler) and the driver stamps "time since first connect" rather than "time since last use".Filed here but redirected from
symfony/doctrine-bridgetodoctrine/dbal: symfony/symfony#64314.Current behavior
In
src/Connection.phpat line 217:A handler that does
SELECTthen a long external call thenUPDATEkeeps$this->_connnon-null the whole time, so the second call toconnect()returns the cached reference. If the gateway already reaped the socket, theUPDATEthrows. On PgSQL it's classified asDoctrine\DBAL\Exception\DriverExceptionSQLSTATE[HY000] (see #6812), notDoctrine\DBAL\Exception\ConnectionLost, so even reactivecatch (ConnectionLost)recovery doesn't help.Doctrine\DBAL\Configurationhas no idle-TTL setting today.Expected behavior
Doctrine\DBAL\Connection::connect()should honor an optional idle TTL on its cached-return branch. Whennow - lastUsedAt >= ttlAND no transaction is active, close the cached connection so the existing reconnect path produces a fresh one. Transaction safety is trivial via the existing$this->transactionNestingLevelfield. Default ttl = 0 (disabled) preserves current behavior.Doctrine\DBAL\Connection::connect()becomes something like this:Why this location:
connect()is the funnel every wire-touching method already calls (executeQuery,executeStatement,prepare,quote,lastInsertId,getServerVersion,getNativeConnection, all transaction primitives). One site, full coverage.A
Doctrine\DBAL\Driver\Middlewarecannot substitute:Doctrine\DBAL\Driver\Connectionhas 10 separate methods and no funnel, andDoctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware::$wrappedConnectionisprivate readonlyso swap-inside requires reflection or full interface reimplementation.As a trade-off described in #6351
Doctrine\DBAL\Connection::connectcan be decorated but it would conflict with other wrappers like\Doctrine\DBAL\Connections\PrimaryReadReplicaConnectionthat also overrideconnect()How to reproduce
Standalone Docker Compose reproducer at
connection-reproducer.zip
boots PostgreSQL behind haproxy with
timeout server 30s, and two PHP containers running identical SymfonyThree scenarios:
DATABASE_URL[OK] SELECT 1 OKat t+35s[ERROR] Doctrine\DBAL\Exception\DriverException SQLSTATE[HY000][ERROR], messenger retrieshaproxy log shows
cD(client timeout) at 30s on the failing run.What is expected after a fix on this reproducer ?
timeout server 30sdoctrine.dbal.idle_connection_ttlto 25Doctrine\DBAL\Connection::connect()reconnects after TTL as expected. Scenario B and C passesCurrent workarounds for Symfony
Symfony has a few boundary-based mechanisms for recovering a stale Doctrine connection. None can fire while the main thread is blocked.
Symfony\Bridge\Doctrine\Middleware\IdleConnection\Listenerfires onkernel.request. Never re-fires mid-request, no help while the handler is blocked.Symfony\Bridge\Doctrine\Messenger\DoctrinePingConnectionMiddlewarepings between messenger messages. Runs before__invoke(), never mid-handler.Symfony\Component\Console\Event\ConsoleAlarmEvent(SIGALRM, #53533). Queued by the kernel during C calls (curl_exec, libpq); dispatches only after, by which point the socket is already reaped (compounded on PgSQL by Convert driver exceptions when starting transactions #6812).Doctrine\DBAL\Connectionand overriding protectedconnect()Reaches in-flight, but leans on@internalConnection::getParams()/::getDriver()/::getConfiguration()perDoctrine\DBAL\Connection::connectand@internal#5791; every shop rolls its own.All of them operate at boundaries between units of work never in-flight.