diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java index 22d63d4902..d59e1f4b22 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java @@ -15,14 +15,12 @@ */ package org.opends.server.backends.jdbc; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.LoadingCache; -import com.github.benmanes.caffeine.cache.RemovalCause; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; +import org.opends.server.api.WorkQueue; +import org.opends.server.core.DirectoryServer; import java.sql.*; -import java.time.Duration; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; @@ -36,6 +34,8 @@ import java.util.Properties; import java.util.Set; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -121,6 +121,32 @@ public class CachedConnection implements Connection { /** 57P03, cannot_connect_now: postgresql starting up, shutting down or in recovery. */ private static final String NOT_ACCEPTING_YET_SQL_STATE = "57P03"; + /** + * The greatest number of connections one pool holds to one database; 0 for no bound. Read once + * per pool, when the first borrow of a connection string creates it, unlike the bounds of a + * borrow above: a pool is never removed from the map, and the permits of one already created + * are not resized, so this one takes a restart of the server to change. + */ + static final String POOL_MAX_PROPERTY = "org.openidentityplatform.opendj.jdbc.pool.max"; + /** + * Sized like the worker thread pool the server sizes for itself + * ({@code Platform.computeNumberOfThreads(16, 2)}), since an operation borrows one connection for + * its duration: the bound is there to keep a burst from opening as many connections as the + * database will accept, not to throttle steady traffic. + *
+ * That formula is only what {@code WorkQueue.computeNumWorkerThreads} falls back to. A configured
+ * {@code ds-cfg-num-worker-threads} replaces it outright, and there is no default that can follow
+ * it: this bound belongs to a database that two backends may share, while that count belongs to
+ * the server. So an installation that raised it is told at open where the two stand, by
+ * {@link #reportBoundBelowBorrowers}, rather than left to find the wait in a latency graph.
+ */
+ static final int DEFAULT_POOL_MAX = Math.max(16, Runtime.getRuntime().availableProcessors() * 2);
+
+ /** How long a borrow waits for a connection to be returned before looking at the pool again. */
+ private static final long POOL_FULL_POLL_MS = 250;
+ /** The sweep runs at half the TTL, and no more often than this. */
+ private static final long MIN_SWEEP_INTERVAL_MS = 1000;
+
static final long MAX_BACKOFF_MS = 1000;
static final long STALL_WARNING_AFTER_MS = 1000;
static final long STALL_WARNING_INTERVAL_MS = 10000;
@@ -171,36 +197,49 @@ public class CachedConnection implements Connection {
*/
private static final Map
+ * Read on every borrow and every sweep rather than once, so that it can be changed on a running
+ * server the way the bounds of a borrow can.
*/
private static long getCacheTtlMillis() {
return getNonNegativeProperty(TTL_PROPERTY, DEFAULT_TTL_MS, "ms");
@@ -215,8 +254,10 @@ private static long getCacheTtlMillis() {
* value the unit conversion saturates on from leaving every connection of the pool trusted for
* the life of the server.
*
- * Read at class initialization, like the ttl it is clamped to, so a value set after that
- * changes neither.
+ * Read at class initialization, so a value of this property set after that does not change the
+ * window. The ttl is not: {@link #getCacheTtlMillis()} is read on every borrow and every sweep,
+ * and the clamp above is not applied again - the window keeps the value it was computed with,
+ * so a ttl lowered on a running server does not lower the window with it.
*/
static long getAliveBypassMillis() {
long configured = getNonNegativeProperty(ALIVE_BYPASS_PROPERTY, DEFAULT_ALIVE_BYPASS_MS, "ms");
@@ -240,6 +281,386 @@ static long getAliveBypassMillis() {
return configured;
}
+ /** The pool of a connection string, created on first use. */
+ static Pool poolOf(String connectionString) {
+ final Pool pool = pools.computeIfAbsent(connectionString, Pool::new);
+ startSweeper();
+ return pool;
+ }
+
+ private static void startSweeper() {
+ if (sweeper != null) {
+ return;
+ }
+ synchronized (pools) {
+ if (sweeper == null) {
+ // A thread per close in flight, and none while nothing is being closed. One thread
+ // shared by all of them would only move the head of the line, which is the point
+ // of not closing on the sweeper in the first place.
+ closer = Executors.newCachedThreadPool(runnable -> {
+ final Thread thread = new Thread(runnable, "JDBC backend connection pool closer");
+ thread.setDaemon(true);
+ return thread;
+ });
+ final ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor(runnable -> {
+ final Thread thread = new Thread(runnable, "JDBC backend connection pool sweeper");
+ thread.setDaemon(true);
+ return thread;
+ });
+ sweeper = service;
+ // Rescheduled after each run rather than left at a fixed delay: the interval comes
+ // from the ttl, and the ttl is read on every borrow and every sweep so that it can
+ // be changed on a running server. A delay computed once would keep the sweeper of a
+ // lowered ttl waking as rarely as the old one, so connections would go on being
+ // reaped no sooner than the setting the operator replaced (issue #878).
+ scheduleNextSweep(service);
+ }
+ }
+ }
+
+ /** Half the ttl, and no more often than {@value #MIN_SWEEP_INTERVAL_MS} ms. */
+ private static long sweepIntervalMillis() {
+ return Math.max(MIN_SWEEP_INTERVAL_MS, getCacheTtlMillis() / 2);
+ }
+
+ /**
+ * Books the next sweep, and the one after it out of its own run. Every run books its successor
+ * in a finally: a sweep that ends in a Throwable the per-pool guard did not catch would
+ * otherwise stop the expiry of every pool in the JVM, the way a task thrown out of
+ * scheduleWithFixedDelay does.
+ */
+ private static void scheduleNextSweep(ScheduledExecutorService service) {
+ try {
+ service.schedule(() -> {
+ try {
+ sweep();
+ } finally {
+ scheduleNextSweep(service);
+ }
+ }, sweepIntervalMillis(), TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException e) {
+ // the sweeper is shutting down: there is nothing left to book a run on
+ logger.traceException(e);
+ }
+ }
+
+ // Expiry has to happen without a borrow behind it. Caffeine was left without a scheduler, so an
+ // entry was only ever expired by a later cache operation - and a backend that has gone idle,
+ // the one case the TTL exists for, performs none (issue #878).
+ static void sweep() {
+ final long ttlMillis = getCacheTtlMillis();
+ final Executor closeOn = closer;
+ for (final Pool pool : pools.values()) {
+ try {
+ pool.sweep(ttlMillis, closeOn);
+ } catch (Throwable t) {
+ // Error included: scheduleWithFixedDelay cancels a task that throws, so anything
+ // escaping here would stop the expiry of every pool in the JVM for good - and
+ // silently, which is the failure mode the hand-off of the close exists to avoid.
+ logger.traceException(t);
+ }
+ }
+ }
+
+ /**
+ * Registers a storage as a user of the pool of a connection string. Reference counted because a
+ * pool belongs to a database rather than to a backend: two backends may address one database,
+ * and closing one of them must not take the connections of the other with it.
+ */
+ static void openPool(String connectionString) {
+ final Pool pool = poolOf(connectionString);
+ pool.addUser();
+ reportBoundBelowBorrowers(connectionString, pool);
+ }
+
+ /**
+ * Reports a bound smaller than the number of worker threads. An operation borrows one connection
+ * for its duration, so the worker threads are the borrowers this default is sized against - and
+ * it is sized against the count the server computes for itself, not against a
+ * {@code ds-cfg-num-worker-threads} the operator set, which replaces that count outright.
+ *
+ * A lower bound than that is what is reported, not every way past it: the replay threads of
+ * replication default to the same count again and borrow on top of the workers, and an import or
+ * a rebuild borrows besides. So this names one difference the operator can act on rather than
+ * standing for the whole demand on the pool.
+ *
+ * Nothing fails for the difference alone: the surplus waits for a connection to be returned,
+ * which is what the bound is there for. But every one of those waits is paid on an operation,
+ * and past {@value #POOL_TIMEOUT_PROPERTY} the operation fails - on a setting whose effect on
+ * this backend the operator had no reason to expect (issue #878).
+ */
+ private static void reportBoundBelowBorrowers(String connectionString, Pool pool) {
+ final WorkQueue> workQueue = DirectoryServer.getWorkQueue();
+ if (workQueue == null) {
+ // an offline tool, or the server before its work queue is up: no borrowers to count
+ return;
+ }
+ final int borrowers = workQueue.getNumWorkerThreads();
+ if (borrowers <= pool.max()) {
+ return;
+ }
+ final long poolTimeoutSeconds = getNonNegativeProperty(POOL_TIMEOUT_PROPERTY, DEFAULT_POOL_TIMEOUT_SECONDS, "s");
+ final String wait = poolTimeoutSeconds == 0
+ ? "waits for one to be returned for as long as that takes"
+ : "waits up to " + poolTimeoutSeconds + "s for one to be returned and fails if none is";
+ warnOnce(safeUrl(connectionString) + "|bound-below-borrowers",
+ "the connection pool of %s holds at most %d connections while %d worker threads may each borrow one:"
+ + " an operation finding it at its bound %s (raise %s to allow more connections, or lower"
+ + " ds-cfg-num-worker-threads)",
+ safeUrl(connectionString), pool.max(), borrowers, wait, POOL_MAX_PROPERTY);
+ }
+
+ /** Unregisters a storage; the connections are released once the last user is gone. */
+ static void closePool(String connectionString) {
+ final Pool pool = pools.get(connectionString);
+ if (pool != null) {
+ pool.removeUser();
+ }
+ }
+
+ /**
+ * The connections of one connection string.
+ *
+ * This replaces the cache entry that used to hold them. That one carried the TTL on the pool
+ * rather than on a connection - {@code expireAfterAccess} keyed by the connection string, reset
+ * by every borrow and every return - so under continuous traffic nothing ever expired and the
+ * peak count of a burst stayed open for as long as the backend saw any traffic at all. It also
+ * had no bound, so the only ceiling on the connections of a backend was the {@code
+ * max_connections} of the database itself (issue #878).
+ */
+ static final class Pool {
+ final String connectionString;
+ /** Idle connections, most recently returned first: the ones a burst opened sink to the bottom, where the sweep finds them. */
+ private final LinkedBlockingDeque
+ * Counted per pool rather than per thread, because that deadlock only exists within one
+ * pool: a count shared by all of them would judge a thread holding a connection to one
+ * database reentrant while it borrows from another, passing the bound of a pool it holds
+ * nothing of and destroying the connection instead of pooling it, on every operation.
+ */
+ private final ThreadLocal
+ * Bounded by the deadline of the borrow, and not only by waitMs: a poll of no duration
+ * still hands out whatever the deque holds, and discarding a connection whose socket is
+ * half-open costs the validation timeout apiece. The pool holds as many of those as its
+ * bound allows, so draining the deque overran the bound the operator set - by minutes on a
+ * large pool, before the connect that follows it had even started (issue #878).
+ */
+ CachedConnection pollIdle(long waitMs, long ttlMillis, long deadline, boolean trusted)
+ throws InterruptedException {
+ long remainingWait = waitMs;
+ while (true) {
+ final long polledAt = System.currentTimeMillis();
+ final CachedConnection con = idle.pollFirst(remainingWait, TimeUnit.MILLISECONDS);
+ if (con == null) {
+ return null;
+ }
+ if (System.currentTimeMillis() - con.returnedAtMillis <= ttlMillis && isUsable(con, trusted)) {
+ return con;
+ }
+ destroy(con);
+ final long remaining = deadline - System.currentTimeMillis();
+ if (remaining <= 0) {
+ return null;
+ }
+ // one more look, since a connection may have been returned in the meantime
+ remainingWait = Math.min(Math.max(0, remainingWait - (System.currentTimeMillis() - polledAt)), remaining);
+ }
+ }
+
+ /** Takes the right to hold one more connection, or reports that the pool is full. */
+ boolean tryReserve() {
+ return permits.tryAcquire();
+ }
+
+ void cancelReservation() {
+ permits.release();
+ }
+
+ /** Hands a connection back, closing it rather than pooling it when it may not be kept. */
+ void give(CachedConnection con) {
+ // An unmetered connection holds no permit, so pooling it would put the pool one over its
+ // bound for good; and a closed pool has nobody left to hand it to.
+ if (con.metered && !closed) {
+ addIdle(con);
+ if (closed) {
+ // The last user left while this one was on its way back, so it missed the drain.
+ drainIdle();
+ }
+ } else {
+ destroy(con);
+ }
+ }
+
+ /** Puts a connection into the pool. The caller must hold the right to keep it there. */
+ void addIdle(CachedConnection con) {
+ con.returnedAtMillis = System.currentTimeMillis();
+ idle.addFirst(con);
+ }
+
+ void destroy(CachedConnection con) {
+ try {
+ closeQuietly(con.parent);
+ } finally {
+ // However the close went, the pool holds one connection fewer. A permit not given
+ // back here is given back by nothing at all: only a live connection carries one,
+ // and this one is gone (issue #878).
+ con.releasePermit();
+ }
+ }
+
+ void sweep(long ttlMillis) {
+ sweep(ttlMillis, DIRECT_EXECUTOR);
+ }
+
+ /**
+ * Closes the connections nothing has borrowed for the TTL, handing each to the executor
+ * given rather than closing it here. The sweep of every pool shares one thread and
+ * {@code scheduleWithFixedDelay} never overlaps its runs, so one close that does not
+ * return would stop the expiry of every pool in the JVM - and silently, since only a
+ * thrown exception is logged. Oracle logs off over the network, and the read bound of the
+ * login has been lifted by then (issue #878).
+ */
+ void sweep(long ttlMillis, Executor closeOn) {
+ final long deadline = System.currentTimeMillis() - ttlMillis;
+ // From the tail: the least recently returned connection is the first to have expired,
+ // and once one has not, neither has anything in front of it.
+ for (CachedConnection con = idle.peekLast(); con != null; con = idle.peekLast()) {
+ if (con.returnedAtMillis > deadline) {
+ return;
+ }
+ if (!idle.removeLastOccurrence(con)) {
+ // A borrow took it between the two. What is behind it may still have expired,
+ // and ending the cycle here would leave every one of those open until the
+ // next sweep.
+ continue;
+ }
+ if (con.returnedAtMillis > deadline) {
+ // A borrow took it between the peek and the removal and gave it back, so the
+ // reading the decision was made on is not the one it carries now: closing it
+ // would cost the next borrow a connect over a connection a moment old. Back to
+ // the end it is returned to, where its refreshed reading belongs.
+ idle.addFirst(con);
+ return;
+ }
+ final CachedConnection expired = con;
+ try {
+ closeOn.execute(() -> destroy(expired));
+ } catch (RuntimeException e) { // no thread to close it on: here rather than nowhere
+ destroy(expired);
+ }
+ }
+ }
+ }
+
/**
* Returns the value of a numeric system property, ignoring a value that is not a non-negative
* number in favor of the default. The unit is the one the property is read in, so that the
@@ -596,17 +1017,42 @@ private static boolean isBound(String value) {
*/
private volatile long lastKnownAliveNanos;
+ /**
+ * A connection outside the accounting of its pool: it holds no permit and is never pooled - the
+ * flag says so as well as the accounting does, since a connection holding no permit is closed
+ * by {@link Pool#give} rather than kept whatever the flag says.
+ *
+ * It still names a pool, because that is what closes it and what the sweep runs over, so the
+ * pool of this connection string is created here if it does not exist yet and the sweeper is
+ * started with it.
+ */
public CachedConnection(String connectionString, Connection parent) {
- this(connectionString, parent, true);
+ this(connectionString, parent, poolOf(connectionString), false, false);
}
- CachedConnection(String connectionString, Connection parent, boolean poolable) {
+ CachedConnection(String connectionString, Connection parent, Pool pool, boolean metered, boolean poolable) {
this.connectionString = connectionString;
this.parent = parent;
+ this.pool = pool;
+ this.metered = metered;
this.poolable = poolable;
this.lastKnownAliveNanos = System.nanoTime();
}
+ /** Gives back the right to hold this connection, once and only if it was taken. */
+ void releasePermit() {
+ if (metered && permitReleased.compareAndSet(false, true)) {
+ pool.cancelReservation();
+ }
+ }
+
+ /** Records that the borrowing thread holds this connection, so a borrow nested in it is recognized. */
+ private static CachedConnection borrowed(CachedConnection con) {
+ con.returned.set(false);
+ con.depth = con.pool.enter();
+ return con;
+ }
+
/**
* Borrows a connection: a usable one out of the pool, or a newly established one. Bounded in
* both phases - every operation of this backend, the open of a backend and the import
@@ -630,26 +1076,65 @@ static Connection getConnection(String connectionString) throws Exception {
* them is one borrow of a cold path, where the round trip the window saves is worth nothing.
*/
static Connection getConnection(String connectionString, boolean trusted) throws Exception {
+ final Pool pool = poolOf(connectionString);
final ConnectDialect dialect = ConnectDialect.of(connectionString);
reportUnknownDialect(connectionString, dialect);
final long connectTimeoutSeconds = Math.min(
getNonNegativeProperty(CONNECT_TIMEOUT_PROPERTY, DEFAULT_CONNECT_TIMEOUT_SECONDS, "s"),
Integer.MAX_VALUE / 1000);
final long poolTimeoutSeconds = getNonNegativeProperty(POOL_TIMEOUT_PROPERTY, DEFAULT_POOL_TIMEOUT_SECONDS, "s");
+ final long ttlMillis = getCacheTtlMillis();
final long startedAt = System.currentTimeMillis();
final long deadline = (poolTimeoutSeconds == 0 || poolTimeoutSeconds >= Long.MAX_VALUE / 1000)
? Long.MAX_VALUE : startedAt + poolTimeoutSeconds * 1000;
+ // A thread already holding a connection is not made to wait for one: the two are held at
+ // the same time, so waiting for the first to come back would wait for itself.
+ final boolean reentrant = pool.heldByCurrentThread();
long waitMs = 0;
long backoffMs = 0;
int attempts = 0;
while (true) {
- final CachedConnection pooled = poll(connectionString, waitMs, deadline, trusted);
+ final CachedConnection pooled = pool.pollIdle(waitMs, ttlMillis, deadline, trusted);
if (pooled != null) {
- return pooled;
+ return borrowed(pooled);
+ }
+ // Asked for whether this borrow is nested or not: the exemption a nested one carries is
+ // from the wait, not from the pool. A nested borrow made while the pool has room takes a
+ // permit like any other and is pooled again on return; only one that finds the pool at its
+ // bound goes on unmetered, and that one is closed rather than pooled when it comes back.
+ final boolean metered = pool.tryReserve();
+ if (!metered && !reentrant) {
+ // The pool holds as many connections as it may: only a returned one can serve this
+ // borrow now, and the deadline decides how long that is worth waiting for. This is
+ // the point of the bound - without it the borrow would open one more connection,
+ // and the only ceiling left would be the max_connections of the database itself.
+ final long remaining = deadline - System.currentTimeMillis();
+ if (remaining <= 0) {
+ // The restart is part of the remedy, so the message says so: the bound is read
+ // once, when the pool is created, and a pool is never removed from the map - so
+ // the property set on a running server changes nothing until it is read again.
+ final String message = "no connection to " + safeUrl(connectionString)
+ + " could be borrowed within " + poolTimeoutSeconds + "s: all " + pool.max()
+ + " connections of the pool are in use (raise " + POOL_MAX_PROPERTY
+ + " and restart the server to allow more)";
+ // The one failure the bound introduces has to reach the server log too: an
+ // installation whose peak sits above the default would otherwise see its
+ // operations fail with nothing in the log naming the pool behind it.
+ warnPoolFull(connectionString, message);
+ throw new SQLTimeoutException(message);
+ }
+ waitMs = Math.min(POOL_FULL_POLL_MS, remaining);
+ continue;
}
attempts++;
+ CachedConnection established = null;
+ boolean handedOff = false;
try {
- return connect(connectionString, dialect, attemptSeconds(connectTimeoutSeconds, deadline));
+ established = connect(connectionString, dialect,
+ attemptSeconds(connectTimeoutSeconds, deadline), pool, metered);
+ final CachedConnection con = borrowed(established);
+ handedOff = true;
+ return con;
} catch (SQLException e) {
// A database that takes no connection for the moment is the failure worth waiting
// out: it is at its connection limit, and one of ours is going to come back to the
@@ -680,6 +1165,20 @@ static Connection getConnection(String connectionString, boolean trusted) throws
// a driver reporting a connect it will not make as an unchecked failure carries the
// connection string of the backend in its message as readily as a SQLException does
throw reportedUnchecked(e, connectionString);
+ } finally {
+ // What the attempt took is given back on every way out of it, not only on the
+ // SQLException a driver is supposed to throw. DriverManager catches SQLException
+ // alone, so an unchecked failure of a driver reaches here - Connector/J hands a url
+ // with a "%" in it to URLDecoder, and this backend keeps its credentials in the url
+ // - and a permit left behind is left behind for good: only a live connection
+ // carries one, and a failed attempt has none to give (issue #878).
+ if (!handedOff) {
+ if (established != null) {
+ pool.destroy(established); // the permit went with it, and comes back with it
+ } else if (metered) {
+ pool.cancelReservation();
+ }
+ }
}
}
}
@@ -728,33 +1227,6 @@ static long attemptSeconds(long connectTimeoutSeconds, long deadline) {
return Math.max(1, Math.min(bound, Integer.MAX_VALUE / 1000));
}
- /**
- * Takes a usable connection out of the pool, waiting up to waitMs for one to be returned to it.
- * The validation of a connection costs a round trip, and the pool has no upper bound on the
- * number of them it holds, so draining a pool the database no longer answers is given the
- * deadline of the borrow as well: past it, establishing a connection is the faster answer.
- * The connection in hand is always looked at first - trusted or validated, see
- * {@link #isKnownAlive} - whatever the deadline says: a database at its connection limit has
- * no other source of connections than the ones coming back, and one returned to the pool a
- * moment before the deadline is the very connection this borrow waited for. Only a connection
- * the database no longer answers is closed here.
- */
- private static CachedConnection poll(String connectionString, long waitMs, long deadline, boolean trusted)
- throws InterruptedException {
- CachedConnection con = cached.get(connectionString).pollFirst(waitMs, TimeUnit.MILLISECONDS);
- while (con != null) {
- if (isUsable(con, trusted)) {
- return con;
- }
- closeQuietly(con.parent);
- if (System.currentTimeMillis() >= deadline) {
- return null;
- }
- con = cached.get(connectionString).pollFirst();
- }
- return null;
- }
-
private static boolean isUsable(CachedConnection con, boolean trusted) {
if (trusted && isKnownAlive(con)) {
return true;
@@ -833,10 +1305,14 @@ private static boolean isKnownAlive(CachedConnection con) {
if (distrusted != null && provenAt - distrusted <= 0) { // the overflow safe form of the comparison
return false;
}
- // What the validation this replaces also answered: the removalListener above closes every
- // connection it finds in the deque when the pool expires, and it iterates a weakly
- // consistent view, so a connection taken out by a borrow running at the same time can be
- // closed under it. Answered by the driver out of a flag of its own, not by a round trip.
+ // What the validation this replaces also answered, asked of the driver out of a flag of its
+ // own rather than by a round trip: a connection the driver has already given up on - the
+ // database dropped it and the driver noticed - is not one to hand out on the strength of a
+ // window. It no longer stands for a drain closing a connection under its borrower, the way
+ // it did while the pool was a cache entry whose removalListener iterated a weakly consistent
+ // view of the deque: every path that destroys an idle connection now takes it out of the
+ // deque first (pollIdle, drainIdle, and the removeLastOccurrence of the sweep), so what a
+ // borrow holds is not there to be found (issue #878).
return !isClosed(con.parent);
}
@@ -900,8 +1376,8 @@ private static int boundValidation(Connection con) {
return previous < 0 ? 0 : previous;
}
- static CachedConnection connect(String connectionString, ConnectDialect dialect, long connectTimeoutSeconds)
- throws SQLException {
+ static CachedConnection connect(String connectionString, ConnectDialect dialect, long connectTimeoutSeconds,
+ Pool pool, boolean metered) throws SQLException {
// A driver is free to write into the map it is handed, so it gets one of its own.
final Properties properties = new Properties();
final boolean readBoundSet = dialect != null && connectTimeoutSeconds > 0
@@ -926,7 +1402,7 @@ static CachedConnection connect(String connectionString, ConnectDialect dialect,
closeQuietly(conNew);
throw e;
}
- final CachedConnection established = new CachedConnection(connectionString, conNew, poolable);
+ final CachedConnection established = new CachedConnection(connectionString, conNew, pool, metered, poolable);
established.lastKnownAliveNanos = provenAt;
return established;
}
@@ -1000,6 +1476,19 @@ static boolean isWorthRetrying(SQLException e, ConnectDialect dialect) {
return false;
}
+ // The bound of the pool is a reason for an operation to fail that no version before it had,
+ // so it belongs in the server log as well as in the error the client is given. Throttled like
+ // the stall warning: every worker thread reaches it at once when the pool stands full.
+ private static void warnPoolFull(String connectionString, String message) {
+ final long now = System.currentTimeMillis();
+ final AtomicLong lastOfThisUrl =
+ lastPoolFullWarning.computeIfAbsent(safeUrl(connectionString), url -> new AtomicLong());
+ final long last = lastOfThisUrl.get();
+ if (now - last >= STALL_WARNING_INTERVAL_MS && lastOfThisUrl.compareAndSet(last, now)) {
+ logger.warn(LocalizableMessage.raw("%s", message));
+ }
+ }
+
// A stall has to reach the server log: without it a database accepting no further connection
// is indistinguishable from a hang. Throttled, since every operation of the backend borrows
// through here and would otherwise log a copy of its own.
@@ -1386,8 +1875,8 @@ private static int indexOfAny(String url, String separators, int from) {
private static void closeQuietly(Connection con) {
try {
con.close();
- } catch (SQLException e) {
- // ignore: it is on its way out anyway
+ } catch (SQLException | RuntimeException e) {
+ // ignore: it is on its way out anyway, and the caller has a permit to give back
}
}
@@ -1433,21 +1922,46 @@ public void rollback() throws SQLException {
@Override
public void close() throws SQLException {
+ // JDBC makes close() on a closed connection a no-op, and this one has to be one: a second
+ // return would put the same connection into the pool twice, to be handed to two borrowers.
+ if (!returned.compareAndSet(false, true)) {
+ return;
+ }
+ final AtomicInteger borrowerDepth = depth;
+ depth = null;
+ if (borrowerDepth != null) {
+ Pool.leave(borrowerDepth);
+ }
+ // Set before the hand-off rather than after it: from the moment give() is called the pool
+ // owns this connection, and a second destroy() of one that reached the idle deque would
+ // close a connection still waiting there to be handed out.
+ boolean handedToPool = false;
try {
rollback();
- } catch (SQLException e) {
- // A connection that cannot be rolled back must not be handed to the next borrower -
- // and must not be dropped on the floor either: nothing else holds it any more.
- closeQuietly(parent);
- throw e;
- }
- if (!poolable) {
- closeQuietly(parent);
- return;
+ if (poolable) {
+ // Straight to the pool it came from rather than through a lookup of its connection
+ // string: the entry the lookup returned could be evicted between the two, leaving
+ // the connection in a queue nothing referred to any more - never handed out, never
+ // closed (issue #878).
+ handedToPool = true;
+ pool.give(this);
+ }
+ } finally {
+ // Every way out that is not a give(): the SQLException a rollback is supposed to throw,
+ // a connection that may not be pooled, and the unchecked failure a driver throws
+ // instead of a SQLException. The CAS above has already made this the one close() of
+ // this connection, so what leaves here through neither give() nor destroy() is closed
+ // by nothing at all - and its permit is released by nothing either, since destroy() is
+ // the only caller of releasePermit(). A pool is never removed from the static map, so
+ // that place in the bound would be gone for the life of the server, and enough of them
+ // leave every borrow to fail with a SQLTimeoutException (issue #878).
+ if (!handedToPool) {
+ // destroy() rather than a bare close: the permit this connection holds has to go
+ // back to the pool with it, or the bound loses a place for every connection kept
+ // out of it.
+ pool.destroy(this);
+ }
}
- // Returned to the end the next borrow takes it from, so that the pool keeps reusing its
- // hottest connections rather than cycling through every one it ever opened.
- cached.get(connectionString).addFirst(this);
}
@Override
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
index 9f437283c2..a0059e5a07 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
@@ -697,6 +697,17 @@ Connection getConnection() throws Exception {
return getConnection(true);
}
+ /**
+ * The connection string this storage borrows on, distrusts and closes with: the one
+ * {@link #open(AccessMode)} registered with, and only failing that the one config names now. Every path
+ * that names a pool goes through here, for the reason given in {@link #getConnection(boolean)} - a
+ * db-directory changed on a running backend otherwise sends each of them to a different pool.
+ */
+ private String poolKey() {
+ final String registered=poolConnectionString;
+ return registered!=null ? registered : config.getDBDirectory();
+ }
+
/**
* Borrows a connection the pool validates whatever the alive window of
* {@link CachedConnection#ALIVE_BYPASS_PROPERTY} says, for the borrows this class compensates a dropped
@@ -713,17 +724,89 @@ Connection getValidatedConnection() throws Exception {
// for the pool stands in for every path that takes a connection. A stand-in of the trusted
// borrow alone let the open, the import and the removal - the three that ask for a validated
// one - reach a real database instead.
+ //
+ // It names the pool this storage registered with in open(), not the one config names now.
+ // Nothing keeps db-directory from being changed on a running backend - applyConfigurationChange()
+ // takes it, isConfigurationChangeAcceptable() refuses nothing, and the component-restart admin
+ // action renders a message rather than holding the change back - so re-reading it here would
+ // borrow from a pool this storage never registered with, leaving the one it did register with
+ // holding a user that never borrows: the leak of #878 back through the configuration. And an
+ // unregistered pool is drained the moment another backend that did register with it closes,
+ // with this one still borrowing from it (issue #878).
Connection getConnection(boolean trusted) throws Exception {
- return CachedConnection.getConnection(config.getDBDirectory(), trusted);
+ return CachedConnection.getConnection(poolKey(), trusted);
}
AccessMode accessMode=AccessMode.READ_ONLY;
+
+ // Whether this storage counts as a user of the pool of its connection string. The pool belongs
+ // to the database rather than to this backend - two backends may address one database - so it
+ // is reference counted, and this flag keeps an open() or a close() that comes twice from
+ // counting twice (issue #878).
+ private final AtomicBoolean poolRegistered=new AtomicBoolean();
+
+ // The connection string open() registered with. applyConfigurationChange() replaces config, so
+ // reading db-directory again at close() could give back the pool of a database this storage
+ // never registered with - leaving the one it did with a user it never loses (issue #878).
+ private volatile String poolConnectionString;
+
@Override
public void open(AccessMode accessMode) throws Exception {
- try (final Connection con=getValidatedConnection()) {
- this.accessMode = accessMode;
- storageStatus = StorageStatus.working();
+ final boolean claimedHere=poolRegistered.compareAndSet(false, true);
+ // Raised once openPool() has returned, which is when a user has actually been added. The
+ // claim alone cannot answer for that: releasePool() on a claim openPool() never made would
+ // take a user off a pool this storage never added one to - and the pool of a database two
+ // backends share would lose the user of the other one, draining connections it is still
+ // borrowing.
+ boolean registeredHere=false;
+ try {
+ // Inside the try, so that the registration this call made is given back however the open
+ // ends - the registration is taken before the pool is of any use, and a pool holding a
+ // user that never borrows keeps its connections for a borrower that is not going to come.
+ if (claimedHere) {
+ poolConnectionString=config.getDBDirectory();
+ CachedConnection.openPool(poolConnectionString);
+ registeredHere=true;
+ }
+ // The validated borrow is the whole of the open, and nothing is taken from it here: the
+ // status is set below rather than inside the block, or a throw from the implicit close()
+ // - the rollback of the return goes to the database - would leave the storage reporting
+ // working() while this method fails and the catch takes its registration back. write()
+ // and ImporterImpl both skip the re-open when the status says working, so the pool would
+ // be left with no user at all: every connection returned to it destroyed on the spot,
+ // pooling off for that database for as long as the server runs (issue #878).
+ try (final Connection con=getValidatedConnection()) {
+ }
+ } catch (Throwable e) {
+ // Throwable rather than Exception: an Error out of the borrow - a NoClassDefFoundError
+ // from the static initializer of a driver is the one to expect here - would otherwise
+ // leave the pool holding a user that never leaves.
+ // Only what this call registered is given back: an open that found the registration
+ // already made took nothing, and giving it back would release a pool still in use.
+ if (registeredHere) {
+ releasePool();
+ } else if (claimedHere) {
+ // The claim was won but no user was added. The claim goes back on its own, without
+ // touching the pool: left standing it would send the close() of this storage to
+ // releasePool() for a registration it never made.
+ poolConnectionString=null;
+ poolRegistered.set(false);
+ }
+ throw e;
+ }
+ this.accessMode = accessMode;
+ storageStatus = StorageStatus.working();
+ }
+
+ /** Gives up the registration of this storage with the pool of the database it opened. */
+ private void releasePool() {
+ if (poolRegistered.compareAndSet(true, false)) {
+ final String registered=poolConnectionString;
+ poolConnectionString=null;
+ if (registered!=null) {
+ CachedConnection.closePool(registered);
+ }
}
}
@@ -740,6 +823,10 @@ public void close() {
// that it is not reissued for every tree on every open; disabling and re-enabling the
// backend is the way to try again once the privilege has been granted
unstampableTrees.clear();
+ // A closed backend has no use for its connections. They used to stay open - close() only
+ // flipped the status - so disabling or removing a JDBC backend left them behind, and with
+ // nothing left to expire the pool entry they could stay open for good (issue #878).
+ releasePool();
}
// The trees this storage has taken an interest in, and the tables they map to. listTrees() -
@@ -972,7 +1059,12 @@ boolean isMysqlBackslashEscape(Connection con) throws SQLException {
Connection newStampConnection(Dialect dialect) throws SQLException {
final Properties properties=new Properties();
properties.putAll(dialect.connectProperties);
- final Connection con=DriverManager.getConnection(config.getDBDirectory(), properties);
+ // poolKey() rather than the configuration as it stands: this connection is not pooled, but it
+ // is a connection to the database of this storage, and db-directory may be changed on a
+ // running backend. Reading it again here would stamp the trees of this backend in whichever
+ // database the configuration names now, while every other connection of it stays with the
+ // one open() registered (issue #878).
+ final Connection con=DriverManager.getConnection(poolKey(), properties);
try {
con.setAutoCommit(false);
executeSessionStatement(con, dialect.lockTimeoutSql); // give up instead of waiting for another session
@@ -1762,7 +1854,9 @@ private static boolean saysTheConnectionIsGone(SQLException e) {
* connection established before it, and the pool has no other way of hearing about any of them.
*/
private void distrustPool() {
- CachedConnection.distrustPool(config.getDBDirectory());
+ // keyed like every other pool lookup of this storage: a drop reported against the string
+ // config names now would be filed on a pool holding none of this storage's connections
+ CachedConnection.distrustPool(poolKey());
}
/** Returns the randomized delay before the given attempt is replayed, doubling with each attempt up to a cap. */
@@ -2578,22 +2672,65 @@ final class ImporterImpl implements Importer {
* of an online import blocked by an LDAP write on the same table sat until the bound of an
* entry read and then failed the import.
*/
- ImporterImpl(Connection con, boolean isOpen) {
- // An import writes by definition, so a storage that is not writeable refuses one where the
- // importer is built - which is where it was refused until the write transaction of a read-only
- // storage became one that is granted and checks per operation (#874). Left to that check, an
- // import of such a storage would take a connection out of the pool, begin its transaction and
- // fail at the first tree it clears rather than at its start.
- // What arrives here read-only is a storage that was already open: import-ldif and
- // rebuild-index both close it first, and startImport() opens a closed one READ_WRITE - an
- // import of any storage of this server reopens it that way - so those two arrive writeable.
- if (!accessMode.isWriteable()) {
- throw new ReadOnlyStorageException();
+ public ImporterImpl() {
+ // The open belongs here with the borrow it precedes (#878): startImport() used to do both,
+ // and a failure between them had two owners to give back what each had taken.
+ isOpen=getStorageStatus().isWorking();
+ if (!isOpen) {
+ try {
+ open(AccessMode.READ_WRITE);
+ }catch (Exception e) {
+ throw new StorageRuntimeException(e);
+ }
+ }
+ // Nothing holds what this constructor takes until it returns: close() belongs to an
+ // object that was built, so a throw below would leave the connection borrowed and the
+ // storage this constructor opened open, with nobody left to give either back.
+ Connection borrowed=null;
+ try {
+ // An import writes by definition, so a storage that is not writeable refuses one where the
+ // importer is built - which is where it was refused until the write transaction of a read-only
+ // storage became one that is granted and checks per operation (#874). Left to that check, an
+ // import of such a storage would take a connection out of the pool, begin its transaction and
+ // fail at the first tree it clears rather than at its start.
+ // Inside the try and in front of the borrow: with the borrow moved in here (#878) the
+ // refusal now takes no connection at all, and the open above is still given back by the
+ // catch below - which is the half of it a storage that arrives closed and read-only needs.
+ if (!accessMode.isWriteable()) {
+ throw new ReadOnlyStorageException();
+ }
+ borrowed=getValidatedConnection();
+ txr =new ReadableTransactionImpl(borrowed, StatementBound.BULK);
+ txw =new WriteableTransactionTransactionImpl(borrowed, StatementBound.BULK);
+ con = borrowed;
+ borrowed=null;
+ }catch (Throwable e){
+ // Throwable rather than Exception, the way close() below catches it and for the same
+ // reason: the borrow is handed off to nothing until this constructor returns, and
+ // only its close() gives back the permit it took. new WriteableTransactionTransactionImpl
+ // runs a StampSession in a field initializer, so an Error out of a bulk import - an
+ // OutOfMemoryError is the one to expect - would leave the connection borrowed for the
+ // life of the server, and enough of them walk the bound of the pool down to nothing
+ // (issue #878).
+ if (borrowed!=null) {
+ try {
+ borrowed.close();
+ }catch (Throwable e2) {
+ // suppressed rather than dropped: the failure being unwound is the one the
+ // caller asked about, and a return that failed on top of it is worth reading
+ e.addSuppressed(e2);
+ }
+ }
+ if (!isOpen) {
+ JDBCStorage.this.close();
+ }
+ if (e instanceof Error) {
+ // on its way out as it is: an Error says the JVM is in no state to have this
+ // wrapped and reported as a failure of the storage
+ throw (Error) e;
+ }
+ throw e instanceof StorageRuntimeException ? (StorageRuntimeException) e : new StorageRuntimeException(e);
}
- this.con=con;
- this.isOpen=isOpen;
- txr=new ReadableTransactionImpl(con, StatementBound.BULK);
- txw=new WriteableTransactionTransactionImpl(con, StatementBound.BULK);
}
@Override
@@ -2601,6 +2738,34 @@ public void aborted() {
aborted = true;
}
+ /**
+ * Hands the connection back to the pool and closes the stamp session, whatever went before.
+ * Returns the failure the caller is to report: the return rolls back, and the rollback
+ * fails on exactly the connection whose commit just did, so the commit stays the exception
+ * the caller sees and this one rides along with it instead of replacing it.
+ */
+ private SQLException releaseConnection(SQLException failure) {
+ try {
+ con.close();
+ } catch (Throwable e) {
+ // Throwable rather than SQLException: this close() is the return to the pool, whose
+ // rollback a driver is free to fail unchecked. Reported rather than thrown, since a
+ // throw out of here would leave with the failure the caller actually came for - the
+ // commit above, and in the Throwable branch of close() the Error that branch exists
+ // to preserve - dropped on the floor (issue #878).
+ final SQLException reported=e instanceof SQLException ? (SQLException) e
+ : new SQLException("the connection of the import could not be returned to the pool", e);
+ if (failure==null) {
+ failure=reported;
+ }else {
+ failure.addSuppressed(reported);
+ }
+ } finally {
+ txw.stampSession.close();
+ }
+ return failure;
+ }
+
// The connection goes back whatever the commit does, and the storage this importer opened
// is closed whatever the connection does: an importer is closed on the way out of a failed
// import as readily as a finished one - a clearTree() that reaches the bulk bound is one
@@ -2609,6 +2774,7 @@ public void aborted() {
@Override
public void close() {
try {
+ SQLException failure=null;
try {
con.commit();
if (aborted) {
@@ -2616,15 +2782,27 @@ public void close() {
}else {
updateTableStatistics(con, writtenTrees);
}
- } finally { // the pooled connection must be returned even when the commit or a statistics statement throws
- try {
- con.close();
- } finally {
- txw.stampSession.close();
+ } catch (SQLException e) {
+ failure=e;
+ } catch (Throwable t) {
+ // Back to the pool whatever came out of the commit, not only on the SQLException
+ // a driver is supposed to throw: nothing else holds this connection, and only
+ // its close() gives back the permit it took. A pool is never removed from the
+ // map, so a permit lost to an Error out of a bulk import - or to a driver
+ // failing unchecked - is lost for the life of the server, and enough of them
+ // walk the bound down to nothing (issue #878).
+ final SQLException onTheWayOut=releaseConnection(null);
+ if (onTheWayOut!=null) {
+ t.addSuppressed(onTheWayOut);
}
+ throw t;
+ }
+ // Back to the pool even when the commit failed: nothing else holds this connection,
+ // so leaving it behind would leak it along with the failure.
+ failure=releaseConnection(failure);
+ if (failure!=null) {
+ throw new StorageRuntimeException(failure);
}
- } catch (SQLException e) {
- throw new StorageRuntimeException(e);
} finally {
if (!isOpen) {
JDBCStorage.this.close();
@@ -2663,52 +2841,11 @@ public SequentialCursor