Skip to content

idle_connection_ttl does not work as expected, ignored inside \Doctrine\DBAL\Connection::connect() #7375

Description

@ilnytskyi

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

bash ./play.sh

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions