Skip to content

JDBC connection pool is unbounded and its TTL never closes an idle connection under traffic #878

Description

@vharseko

Describe the bug

The pool of the JDBC backend has no upper bound on the number of connections it holds, and the TTL that is supposed to close the idle ones does not do what its name says.

// opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:38-51
static LoadingCache<String, BlockingQueue<CachedConnection>> cached = Caffeine.newBuilder()
    .expireAfterAccess(Duration.ofMillis(getCacheTtlMillis()))
    .removalListener((String key, BlockingQueue<CachedConnection> value, RemovalCause cause) -> {
        for (CachedConnection con : value) {
            try {
                if (!con.isClosed()) {
                    con.parent.close();
                }
            } catch (SQLException e) {
                // ignore
            }
        }
    })
    .build(conStr -> new LinkedBlockingQueue<>());

Four things follow from that shape.

1. No upper bound. The queue is an unbounded LinkedBlockingQueue and the cache has no maximumSize, so nothing in OpenDJ limits how many connections a backend opens: as many as there are threads asking at once. The only ceiling is the max_connections of the database itself — which the backend then treats as a retry condition, so a burst of concurrent operations turns into a burst of connect attempts against a server already at its limit.

2. The TTL sits on the URL, not on the connection. expireAfterAccess is keyed by the connection string, and the key is touched by every borrow (getConnection) and every return (close()). Under continuous traffic the entry never expires, so the connections in the queue never expire either: a burst that opened 200 connections keeps all 200 open for as long as the backend sees any traffic at all. The documented behaviour of org.openidentityplatform.opendj.jdbc.ttl — "the time after which an idle pooled connection is closed" — only holds when the whole backend is idle, not when a connection is.

3. Expiry is lazy, and the case it is for is the one it misses. The cache is built without a scheduler(), so Caffeine expires an entry during a later cache operation. On a backend that has gone completely idle — the only situation in which the entry can expire at all, per (2) — there is no later operation to trigger it, so the connections stay open on the database side until its own idle reaper takes them.

4. A closed backend does not release its connections. JDBCStorage.close() only flips the status:

// opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:112-114
public void close() {
    storageStatus = StorageStatus.lockedDown(LocalizableMessage.raw("closed"));
}

The pool entry for its URL is left alone, so disabling or removing a JDBC backend leaves its connections open, and by (3) possibly indefinitely.

There is also a narrow race in the return path: close() does cached.get(connectionString).add(this), and if the entry is evicted between the get and the add, the connection lands in a queue nothing refers to any more — never handed out again, never closed.

Impact

A deployment sized for its steady-state connection count can hold its peak count open indefinitely, and a database shared with other applications sees a backend that never gives connections back. Restarting or disabling a backend does not release them either. On a database with a modest max_connections, the unbounded pool is also what makes the "too many connections" path reachable in the first place.

Expected behavior

  • an upper bound on the pooled connections of one backend, configurable, with a borrow above it waiting for a returned connection rather than opening another (the deadline of org.openidentityplatform.opendj.jdbc.pool.timeout added in [#872] Bound the connect of the JDBC pool and report a connect it cannot make #876 already covers the wait);
  • the TTL applied per connection — closing one that has been idle for it — rather than to the pool as a whole;
  • expiry that happens without a subsequent borrow, so an idle backend really does release its connections;
  • JDBCStorage.close() releasing the pool of its connection string;
  • the return path leaving no connection in an evicted queue.

Environment

master (5.2.x), all four JDBC dialects. The line numbers above are of master as it stands; #876 rewrites getConnection but touches none of the cache shape.

Noticed while fixing #872 (PR #876).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugjdbcperformancePerformance / concurrency / lock-contention work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions