/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.server.backends.jdbc; import org.opends.server.DirectoryServerTestCase; import org.testng.annotations.AfterClass; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; import java.net.InetAddress; import java.net.ServerSocket; import java.sql.Connection; import java.sql.Driver; import java.sql.DriverManager; import java.sql.DriverPropertyInfo; import java.sql.SQLException; import java.sql.SQLTimeoutException; import java.util.ArrayDeque; import java.util.Collections; import java.util.Deque; import java.util.IdentityHashMap; import java.util.Properties; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import org.mockito.InOrder; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertNotSame; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertSame; import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; /** * The pool every operation of the JDBC backend borrows from must bound both of its phases and * report a connect it cannot make, rather than retrying it out of sight of the caller (#872). * Needs no database: the dialects are exercised against a socket that never answers and against a * driver of this test, so a regression fails the build wherever it runs. */ @SuppressWarnings("javadoc") @Test(groups = { "precommit", "jdbc" }, sequential = true) public class CachedConnectionTestCase extends DirectoryServerTestCase { /** A connect attempt of a bounded dialect must give up in about this long, plus room for a slow machine. */ private static final long BOUND_SECONDS = 2; /** * Room for a slow machine on top of a bound, and no more than that. A minute of it turned * every assertion below into "it does not run forever": a connect that has to be reported at * once and a borrow that has to give up at its two second deadline both passed at 59 s. */ private static final long BOUND_MARGIN_MS = 10000; private final StubDriver stub = new StubDriver(); /** The window as this JVM was started with it, put back after every test that varies it. */ private static final long CONFIGURED_ALIVE_BYPASS_NANOS = CachedConnection.aliveBypassNanos; @BeforeClass public void registerStubDriver() throws Exception { DriverManager.registerDriver(stub); } @AfterClass public void deregisterStubDriver() throws Exception { DriverManager.deregisterDriver(stub); } /** * Most of the tests below seed the pool by hand, and a connection built a moment ago is inside * the alive window - they are about what the validation of a borrow does, so the window is off * unless the test at hand is one of the window's own. */ @BeforeMethod public void validateEveryBorrow() { CachedConnection.aliveBypassNanos = 0; } @AfterMethod public void clearProperties() { System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); System.clearProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); System.clearProperty(CachedConnection.TTL_PROPERTY); System.clearProperty(CachedConnection.ALIVE_BYPASS_PROPERTY); // what has been reported once is remembered for the life of the jvm: left standing, the key // of one test is what the next one finds when it asserts that it reported something itself CachedConnection.warnedOnce.clear(); CachedConnection.aliveBypassNanos = CONFIGURED_ALIVE_BYPASS_NANOS; } /** * A driver that is not on the classpath - the JDBC backend needs one dropped into * lib/extensions by hand - is a configuration error the caller has to see. Retried, it is * indistinguishable from a database that hangs. */ @Test(timeOut = 120000) public void testMissingDriverIsReportedAtOnce() throws Exception { final long startedAt = System.currentTimeMillis(); try { CachedConnection.getConnection("jdbc:nosuchengine://127.0.0.1:5432/opendj"); fail("a connection string no registered driver accepts must be reported"); } catch (SQLException expected) { assertTrue(expected.getMessage().contains("No suitable driver"), expected.getMessage()); } assertElapsedWithinBound(startedAt, 0); } /** * ... and the report of it carries no password. This is the path the credentials leave by: the * jdk itself builds "No suitable driver found for " + url, a missing driver jar is the ordinary * oracle misconfiguration, and what this class throws reaches the server error log in full - * JDBCStorage.open() hands it to RootContainer, which makes the message of the cause the * message of what it throws, and BackendConfigManager logs that at ERROR and answers a config * change with it. Every link of the chain is asserted, not only the message on top: everything * that prints a failure prints its causes along with it. */ @Test(timeOut = 120000) public void testAReportedConnectCarriesNoCredentials() throws Exception { final String url = "jdbc:nosuchengine://opendj:S3cretOfTheBackend@127.0.0.1:5432/opendj"; try { CachedConnection.getConnection(url); fail("a connection string no registered driver accepts must be reported"); } catch (SQLException expected) { assertNoCredentials(expected); assertTrue(expected.getMessage().contains("No suitable driver"), "the failure has to stay recognizable: " + expected.getMessage()); assertTrue(expected.getMessage().contains("127.0.0.1:5432"), "the host is what a report of a connect is read for: " + expected.getMessage()); } } /** * A url this class knows no timeout properties for is reported once. The properties bounding a * connect are the ones of a driver, so a driver outside the four - an admin-added mariadb, an * h2 - leaves every attempt unbounded, and the deadline of the borrow cannot reach into a * connect already under way: the driver is the only thing holding the socket. Silently, that * is #872 again, for a backend nobody thinks of as unbounded. */ @Test(timeOut = 120000) public void testAUrlThisBackendCannotBoundIsReportedOnce() throws Exception { final String url = "jdbc:nosuchengine-unbounded://127.0.0.1:5432/opendj"; final String key = CachedConnection.safeUrl(url) + "|unknown-dialect"; // the set is the gate warnOnce() logs behind, so it has to start empty for this to be a // test of what this borrow reported rather than of what some earlier one left behind CachedConnection.warnedOnce.clear(); borrowExpectingFailure(url); assertEquals(CachedConnection.warnedOnce, Collections.singleton(key), "a connection string no bound of this class can reach was not reported"); // ... and once: every operation of the backend borrows through here, so a report per borrow // is one nobody reads. A second borrow that would log again is one that adds a key again. CachedConnection.warnedOnce.remove(key); borrowExpectingFailure(url); assertEquals(CachedConnection.warnedOnce, Collections.singleton(key), "the report is not the one warnOnce() gates: " + CachedConnection.warnedOnce); borrowExpectingFailure(url); assertEquals(CachedConnection.warnedOnce, Collections.singleton(key), "the same url was reported a second time"); } /** A borrow that has to fail: what the test is after is what was logged on the way. */ private static void borrowExpectingFailure(String url) throws Exception { try { CachedConnection.getConnection(url); fail("a connection string no registered driver accepts must be reported"); } catch (SQLException expected) { // the point of the test is what was logged on the way, not what came back } } /** * ... and so is a postgresql url that turns the read bound off: a parameter of one outranks the * property this class supplies, so a "socketTimeout=0" there cannot be replaced. Nothing is * left to end a borrow that reaches a database accepting the connection and answering nothing, * and an administrator who wrote that zero has to be able to find it in the log. */ @Test public void testAReadBoundTurnedOffInAPostgresUrlIsReportedOnce() throws Exception { final String url = "jdbc:postgresql://reported:5432/db?socketTimeout=0"; final String key = CachedConnection.safeUrl(url) + "|unbounded|socketTimeout"; CachedConnection.warnedOnce.clear(); assertFalse(CachedConnection.ConnectDialect.POSTGRES.bound(url, new Properties(), 7)); assertEquals(CachedConnection.warnedOnce, Collections.singleton(key), "a url leaving the reads of its login unbounded was not reported"); assertFalse(CachedConnection.ConnectDialect.POSTGRES.bound(url, new Properties(), 7)); assertEquals(CachedConnection.warnedOnce, Collections.singleton(key), "the same url was reported a second time"); } /** * ... and every parameter of it that is turned off, not only the first one. A url is free to * turn off the read bound and the login bound both, and the login bound is the per-host budget * of a failover url - safeUrl() keeps neither parameter, so a key of the url alone would name * the first offender, remember the url as reported, and leave the rest of them unmentionable. */ @Test public void testEveryBoundTurnedOffInAPostgresUrlIsReported() throws Exception { final String url = "jdbc:postgresql://reported-twice:5432/db?socketTimeout=0&loginTimeout=0"; final String safe = CachedConnection.safeUrl(url); CachedConnection.warnedOnce.clear(); CachedConnection.ConnectDialect.POSTGRES.bound(url, new Properties(), 7); assertTrue(CachedConnection.warnedOnce.contains(safe + "|unbounded|socketTimeout"), "the read bound left at 0 was not reported: " + CachedConnection.warnedOnce); assertTrue(CachedConnection.warnedOnce.contains(safe + "|unbounded|loginTimeout"), "the login bound left at 0 was not reported: " + CachedConnection.warnedOnce); } /** * Nothing in the chain of a failure names the password of the backend, however deep it stands. * Walked by identity rather than link by link: a driver is free to make the cause and the next * exception of a link the same failure, which is the very shape the sibling test builds, and a * helper looping on it would hang the run it is checking for exactly that. */ private static void assertNoCredentials(Throwable failure) { final Set seen = Collections.newSetFromMap(new IdentityHashMap()); final Deque pending = new ArrayDeque<>(); enqueue(pending, seen, failure); while (!pending.isEmpty()) { final Throwable t = pending.poll(); assertFalse(String.valueOf(t.getMessage()).contains("S3cretOfTheBackend"), "the password of the backend reached a message: " + t); if (t instanceof SQLException) { enqueue(pending, seen, ((SQLException) t).getNextException()); } enqueue(pending, seen, t.getCause()); } } private static void enqueue(Deque pending, Set seen, Throwable t) { if (t != null && seen.add(t)) { pending.add(t); } } /** * A link of a chain carries a cause and a next exception both, and a driver is free to make the * two the same failure. Rebuilt under a bound on the depth alone, a chain of those is copied * twice over at every step - 2^32 links for one reaching the bound, which is a report of a * failed connect that never comes back. What bounds the redaction is the number of links it * rebuilds, the way the walk looking for credentials is bounded by the ones it visits. */ @Test(timeOut = 60000) public void testAFailureWhoseCauseIsItsNextExceptionIsRedactedInBoundedTime() throws Exception { final String url = "jdbc:postgresql://opendj:S3cretOfTheBackend@127.0.0.1:5432/opendj"; SQLException chain = new SQLException("connect to " + url + " failed", "08006", 1); for (int i = 0; i < 64; i++) { final SQLException link = new SQLException("link " + i + " of " + url, "08006", i); link.setNextException(chain); link.initCause(chain); chain = link; } final SQLException reported = CachedConnection.reported(chain, url); assertNoCredentials(reported); assertEquals(reported.getSQLState(), "08006", "the SQLState of a link has to survive its redaction"); } /** * ... and the chain of one is walked link by link rather than copy by copy. Enqueued twice, a * failure whose cause is its own next exception fans out into a level twice the size of the one * above it, so five levels of it are enough to spend the whole budget of the walk: the link * that names the url stands at level six of seven and was never reached - and a walk that ends * without finding credentials is one that reports the failure as it stands, password and all. */ @Test(timeOut = 60000) public void testACredentialBehindADuplicatedChainIsStillRedacted() throws Exception { final String url = "jdbc:postgresql://opendj:S3cretOfTheBackend@127.0.0.1:5432/opendj"; SQLException chain = new SQLException("connect to " + url + " failed", "08006", 1); for (int i = 0; i < 6; i++) { final SQLException link = new SQLException("wrapper " + i, "08006", i); link.setNextException(chain); link.initCause(chain); chain = link; } assertNoCredentials(CachedConnection.reported(chain, url)); } /** * The tail a rebuild has no budget left for is named rather than dropped. Without it the same * failure keeps its root cause where the url of the backend carries no password and loses it * without a word where it does - and the root cause is what a report of a failed connect is * read for. */ @Test(timeOut = 60000) public void testTheTailOfALongChainIsNamedRatherThanDropped() throws Exception { final String url = "jdbc:postgresql://opendj:S3cretOfTheBackend@127.0.0.1:5432/opendj"; SQLException chain = new SQLException("Connection to 127.0.0.1:5432 refused", "08006", 1); for (int i = 0; i < 40; i++) { final SQLException link = new SQLException("wrapper " + i + " of " + url, "08006", i); link.initCause(chain); chain = link; } final SQLException reported = CachedConnection.reported(chain, url); assertNoCredentials(reported); Throwable last = reported; while (last.getCause() != null) { last = last.getCause(); } assertTrue(String.valueOf(last.getMessage()).contains("left out"), "the tail a rebuild had no budget for has to say so: " + last.getMessage()); } /** * A database that is not listening at all: every dialect reports it instead of retrying the * refused connect until the caller gives up on the operation. */ @Test(timeOut = 120000) public void testRefusedConnectIsReportedAtOnce() throws Exception { final int closedPort = closedPort(); System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, Long.toString(BOUND_SECONDS)); for (final String url : urlsOf(closedPort)) { final long startedAt = System.currentTimeMillis(); try { CachedConnection.getConnection(url); fail("a refused connect must be reported: " + CachedConnection.safeUrl(url)); } catch (SQLException expected) { // the failure of the moment, reported rather than retried } assertElapsedWithinBound(startedAt, BOUND_SECONDS * 1000); } } /** * The failure this bound exists for: a database that completes the TCP connection and then * says nothing - a moved VIP, a proxy at its connection limit, a host that lost its answer - * leaving the login of the driver, and with it the operation, without an end. The connection * of the accept queue is never answered here, so every dialect has to give up on its own. */ @Test(timeOut = 300000) public void testLoginIsBoundedWhenTheDatabaseNeverAnswers() throws Exception { System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, Long.toString(BOUND_SECONDS)); // a socket that is bound and never accepted: the kernel completes the handshake, so the // connect of the driver succeeds and every read of the login that follows hangs try (final ServerSocket blackhole = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) { for (final String url : urlsOf(blackhole.getLocalPort())) { // borrowed on a thread of its own: a bound that a driver does not honour has to // fail this test at once, and not by hanging the run it is part of final FutureTask borrow = new FutureTask<>(() -> CachedConnection.getConnection(url)); final Thread thread = new Thread(borrow, "borrow-" + CachedConnection.safeUrl(url)); thread.setDaemon(true); thread.start(); try { final Connection con = borrow.get(BOUND_SECONDS * 1000 + BOUND_MARGIN_MS, TimeUnit.MILLISECONDS); fail("a database that never answers must not hand out a connection: " + con); } catch (TimeoutException e) { fail("the login of " + CachedConnection.safeUrl(url) + " is not bounded: it never gave up"); } catch (ExecutionException expected) { assertTrue(expected.getCause() instanceof SQLException, String.valueOf(expected.getCause())); } } } } /** * The deadline of the borrow bounds the attempt inside it as well: the pool timeout stands for * the whole borrow, and an attempt left to run out its own bound would overrun it by that bound. */ @Test(timeOut = 120000) public void testTheAttemptIsBoundedByTheDeadlineOfTheBorrow() throws Exception { System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, "600"); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "2"); try (final ServerSocket blackhole = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) { final String url = "jdbc:postgresql://127.0.0.1:" + blackhole.getLocalPort() + "/opendj?user=opendj&password=opendj"; final long startedAt = System.currentTimeMillis(); try { CachedConnection.getConnection(url); fail("a database that never answers must not hand out a connection"); } catch (SQLException expected) { // reported, and within the borrow it was given rather than the 600 s of the attempt } final long elapsed = System.currentTimeMillis() - startedAt; assertTrue(elapsed < 2000 + BOUND_MARGIN_MS, "the attempt outlived the deadline of the borrow: " + elapsed + " ms"); } } /** Pool exhaustion stays a retry - one of our own connections is on its way back to the pool. */ @Test(timeOut = 120000) public void testConnectionLimitIsRetried() throws Exception { final String url = StubDriver.PREFIX + "retried"; stub.failWith(tooManyConnections(), 2); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "30"); final Connection con = CachedConnection.getConnection(url); assertNotNull(con); assertEquals(stub.attempts.get(), 3, "the connect must be retried while the database is at its limit"); } /** ... but under a deadline: the retry used to double its wait from 1 ms with no end to it. */ @Test(timeOut = 120000) public void testConnectionLimitGivesUpAtTheDeadline() throws Exception { final String url = StubDriver.PREFIX + "deadline"; stub.failWith(tooManyConnections(), StubDriver.ALWAYS); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "2"); final long startedAt = System.currentTimeMillis(); try { CachedConnection.getConnection(url); fail("a database that stays at its connection limit must be reported, not waited out forever"); } catch (SQLTimeoutException expected) { assertTrue(expected.getMessage().contains("2s"), expected.getMessage()); assertEquals(((SQLException) expected.getCause()).getSQLState(), "53300"); // the state of a connect that did not happen, rather than none at all: this is the // failure of a borrow, and monitoring reading the state off what it caught would // otherwise see null where the driver's own exception carried one assertEquals(expected.getSQLState(), "08001"); } final long elapsed = System.currentTimeMillis() - startedAt; assertTrue(elapsed >= 2000, "gave up after " + elapsed + " ms, before the deadline it was given"); assertElapsedWithinBound(startedAt, 2000); assertTrue(stub.attempts.get() > 1, "the connect must be retried while the deadline lasts"); } /** * A database on its way up - starting, recovering, shutting down - says so, and says it for * seconds: the backend it belongs to would otherwise stay locked down until the next restart * of the server, since nothing above JDBCStorage.open() attempts it a second time. */ @Test(timeOut = 120000) public void testDatabaseOnItsWayUpIsRetried() throws Exception { final String url = StubDriver.PREFIX + "starting-up"; stub.failWith(new SQLException("the database system is starting up", "57P03"), 2); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "30"); final Connection con = CachedConnection.getConnection(url); assertNotNull(con); assertEquals(stub.attempts.get(), 3, "a database that is starting up must be waited out"); } /** ... and it is recognized however the driver wrapped it: a SQLException carries two chains. */ @Test(timeOut = 120000) public void testTheWholeChainOfTheFailureIsLookedAt() throws Exception { final String url = StubDriver.PREFIX + "wrapped"; final SQLException wrapped = new SQLException("could not connect to the server", "08006"); wrapped.setNextException(tooManyConnections()); stub.failWith(wrapped, 1); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "30"); assertNotNull(CachedConnection.getConnection(url)); assertEquals(stub.attempts.get(), 2, "the failure behind the one reported must be looked at"); } /** * The rest of the insufficient_resources class is not worth waiting out: a server out of disk * is not made whole by a connection of ours coming back to the pool. */ @Test(timeOut = 120000) public void testDiskFullIsNotRetried() throws Exception { final String url = StubDriver.PREFIX + "disk-full"; stub.failWith(new SQLException("could not extend file: No space left on device", "53100"), StubDriver.ALWAYS); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "30"); try { CachedConnection.getConnection(url); fail("a database out of disk must be reported to the caller"); } catch (SQLException expected) { assertEquals(expected.getSQLState(), "53100"); } assertEquals(stub.attempts.get(), 1, "a failure that waiting cannot clear must be attempted once"); } /** * Every other failure is the caller's to report. A password the database does not accept is * never going to be accepted by waiting, and the retry that swallowed it left the operation * hanging with nothing in the log. */ @Test(timeOut = 120000) public void testRejectedLoginIsNotRetried() throws Exception { final String url = StubDriver.PREFIX + "rejected"; stub.failWith(new SQLException("password authentication failed", "28P01"), StubDriver.ALWAYS); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "30"); try { CachedConnection.getConnection(url); fail("a rejected login must be reported to the caller"); } catch (SQLException expected) { assertEquals(expected.getSQLState(), "28P01"); } assertEquals(stub.attempts.get(), 1, "a rejected login must be attempted once"); } /** A connection the setup of which failed belongs to nobody: it has to be closed, not leaked. */ @Test(timeOut = 120000) public void testConnectionIsClosedWhenItsSetupFails() throws Exception { final String url = StubDriver.PREFIX + "setup-failure"; final Connection broken = mock(Connection.class); doThrow(new SQLException("read only")).when(broken).setAutoCommit(false); stub.answerWith(broken); try { CachedConnection.getConnection(url); fail("a connection that cannot be set up must be reported"); } catch (SQLException expected) { assertEquals(expected.getMessage(), "read only"); } verify(broken).close(); } /** The same, for a driver whose failure in the setup is not a SQLException but an unchecked one. */ @Test(timeOut = 120000) public void testConnectionIsClosedWhenItsSetupFailsWithAnUncheckedError() throws Exception { final String url = StubDriver.PREFIX + "setup-unchecked"; final Connection broken = mock(Connection.class); doThrow(new IllegalStateException("driver internal")).when(broken).setTransactionIsolation(anyInt()); stub.answerWith(broken); try { CachedConnection.getConnection(url); fail("a connection that cannot be set up must be reported"); } catch (IllegalStateException expected) { assertEquals(expected.getMessage(), "driver internal"); } verify(broken).close(); } /** * isValid(n) is not a bound at the socket on every driver - the SQL Server driver turns it * into a query timeout, which needs an answer from the server to fire - and the read bound of * the login was lifted the moment the connection was established, so the socket carries the * bound of the validation, for the length of the validation only. */ @Test(timeOut = 120000) public void testValidationOfAPooledConnectionIsBoundedAtTheSocket() throws Exception { final String url = StubDriver.PREFIX + "validation-bound"; final Connection pooled = mock(Connection.class); when(pooled.isValid(anyInt())).thenReturn(true); when(pooled.getNetworkTimeout()).thenReturn(0); seedPool(url, pooled); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, pooled); final InOrder inOrder = inOrder(pooled); inOrder.verify(pooled).setNetworkTimeout(any(Executor.class), eq(CachedConnection.VALIDATION_TIMEOUT_SECONDS * 1000)); inOrder.verify(pooled).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); inOrder.verify(pooled).setNetworkTimeout(any(Executor.class), eq(0)); } /** * A driver that takes the call bounding the validation and then fails inside it is told apart * from one that never takes a network timeout at all: it is free to have applied the bound * before failing, so the connection is discarded rather than validated unbounded and handed * out - pooled, it would carry five seconds of ours into every statement for the rest of its * life, and the import batch of a backend open is the first thing to die of that. */ @Test(timeOut = 120000) public void testAPooledConnectionThatCannotBeBoundedForItsValidationIsDiscarded() throws Exception { final String url = StubDriver.PREFIX + "validation-bound-fails"; final Connection pooled = mock(Connection.class); when(pooled.getNetworkTimeout()).thenReturn(0); when(pooled.isValid(anyInt())).thenReturn(true); doThrow(new SQLException("the driver took the bound and then failed")) .when(pooled).setNetworkTimeout(any(Executor.class), anyInt()); seedPool(url, pooled); final Connection fresh = mock(Connection.class); stub.answerWith(fresh); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, fresh, "a connection this could not bound was handed out"); verify(pooled, never()).isValid(anyInt()); verify(pooled).close(); } /** * A driver answering a negative network timeout is outside the contract of the call - 0 is no * limit and nothing below it stands for anything - and taken back as it is, it is one of the * two sentinels this class tells its own outcomes apart by: the bound of the validation would * be read as a bound that was never set, and the connection would go back into the pool still * carrying five seconds of ours into every statement of the next borrower. */ @Test(timeOut = 120000) public void testAPooledConnectionWhoseDriverAnswersANegativeBoundIsPutBackUnbounded() throws Exception { final String url = StubDriver.PREFIX + "validation-negative-bound"; final Connection pooled = mock(Connection.class); when(pooled.isValid(anyInt())).thenReturn(true); when(pooled.getNetworkTimeout()).thenReturn(-1); CachedConnection.cached.get(url).add(new CachedConnection(url, pooled)); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, pooled); final InOrder inOrder = inOrder(pooled); inOrder.verify(pooled) .setNetworkTimeout(any(Executor.class), eq(CachedConnection.VALIDATION_TIMEOUT_SECONDS * 1000)); inOrder.verify(pooled).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); inOrder.verify(pooled).setNetworkTimeout(any(Executor.class), eq(0)); } /** A read bound of the connection string is tighter than ours and stays untouched. */ @Test(timeOut = 120000) public void testValidationLeavesTheBoundOfTheConnectionStringAlone() throws Exception { final String url = StubDriver.PREFIX + "validation-tighter"; final Connection pooled = mock(Connection.class); when(pooled.isValid(anyInt())).thenReturn(true); when(pooled.getNetworkTimeout()).thenReturn(2000); seedPool(url, pooled); assertNotNull(CachedConnection.getConnection(url)); verify(pooled, never()).setNetworkTimeout(any(Executor.class), anyInt()); } /** * The pool has no upper bound on the number of connections it holds, and a validation is a * round trip: after a failover that left them half-open, draining the pool must not outlive * the deadline of the borrow - establishing a connection is the faster answer past it. */ @Test(timeOut = 120000) public void testDrainOfThePoolStopsAtTheDeadline() throws Exception { final String url = StubDriver.PREFIX + "drain-deadline"; final int pooled = 8; final AtomicInteger validated = new AtomicInteger(); for (int i = 0; i < pooled; i++) { final Connection stale = mock(Connection.class); when(stale.isValid(anyInt())).thenAnswer(invocation -> { validated.incrementAndGet(); Thread.sleep(500); // a database that no longer answers: every validation waits out its bound return false; }); seedPool(url, stale); } final Connection fresh = mock(Connection.class); stub.answerWith(fresh); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "1"); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, fresh); assertTrue(validated.get() > 0 && validated.get() < pooled, "the drain has to start and to stop at the deadline: " + validated.get() + " of " + pooled); } /** A pooled connection that no longer validates is closed and replaced, not handed out. */ @Test(timeOut = 120000) public void testBrokenPooledConnectionIsDiscarded() throws Exception { final String url = StubDriver.PREFIX + "broken-pooled"; final Connection stale = mock(Connection.class); when(stale.isValid(anyInt())).thenReturn(false); seedPool(url, stale); final Connection fresh = mock(Connection.class); when(fresh.isValid(anyInt())).thenReturn(true); stub.answerWith(fresh); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, fresh); verify(stale).close(); // the validation of a pooled connection needs a bound of its own as well verify(stale, never()).isValid(0); verify(stale).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); } /** * The same for a driver whose answer to the validation is an unchecked failure: it unwinds * through poll(), which stands outside every try of the borrow, so the connection it was * raised over is already out of the pool and would be held by nobody. */ @Test(timeOut = 120000) public void testAPooledConnectionWhoseValidationThrowsIsDiscarded() throws Exception { final String url = StubDriver.PREFIX + "validation-unchecked"; final Connection broken = mock(Connection.class); when(broken.isValid(anyInt())).thenThrow(new IllegalStateException("driver internal")); seedPool(url, broken); final Connection fresh = mock(Connection.class); stub.answerWith(fresh); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, fresh); verify(broken).close(); } /** A connection that cannot be rolled back must not go back into the pool - nor be dropped. */ @Test(timeOut = 120000) public void testConnectionThatCannotBeRolledBackIsClosed() throws Exception { final String url = StubDriver.PREFIX + "rollback-failure"; final Connection parent = mock(Connection.class); doThrow(new SQLException("connection is closed")).when(parent).rollback(); try { new CachedConnection(url, parent).close(); fail("a failed rollback must be reported"); } catch (SQLException expected) { assertEquals(expected.getMessage(), "connection is closed"); } verify(parent).close(); assertTrue(CachedConnection.cached.get(url).isEmpty(), "a connection that cannot be rolled back was pooled"); } @Test public void testDialectIsRecognizedByTheConnectionString() throws Exception { assertEquals(CachedConnection.ConnectDialect.of("jdbc:postgresql://h:5432/db"), CachedConnection.ConnectDialect.POSTGRES); assertEquals(CachedConnection.ConnectDialect.of("jdbc:mysql://h:3306/db"), CachedConnection.ConnectDialect.MYSQL); assertEquals(CachedConnection.ConnectDialect.of("jdbc:oracle:thin:@//h:1521/svc"), CachedConnection.ConnectDialect.ORACLE); assertEquals(CachedConnection.ConnectDialect.of("jdbc:sqlserver://h:1433;databaseName=db"), CachedConnection.ConnectDialect.MICROSOFT); assertNull(CachedConnection.ConnectDialect.of("jdbc:h2:mem:db"), "an unknown engine must not be fed the properties of another"); } /** Both phases are bounded, in the units of the driver: the connect alone leaves the login open. */ @Test public void testBothPhasesOfTheLoginAreBounded() throws Exception { // pgjdbc puts an SO_TIMEOUT on the login socket only where socketTimeout is set: without it // loginTimeout bounds the caller alone, and the thread the driver runs the login on stays // parked in the read it abandoned final Properties postgres = new Properties(); assertTrue(CachedConnection.ConnectDialect.POSTGRES.bound("jdbc:postgresql://h:5432/db", postgres, 7), "the read bound of postgresql outlives the login and has to be lifted"); assertEquals(postgres.getProperty("connectTimeout"), "7"); assertEquals(postgres.getProperty("socketTimeout"), "7"); assertEquals(postgres.getProperty("loginTimeout"), "7", "the bound of a url naming more than one host"); final Properties mysql = new Properties(); assertTrue(CachedConnection.ConnectDialect.MYSQL.bound("jdbc:mysql://h:3306/db", mysql, 7), "the read bound of mysql outlives the login and has to be lifted"); assertEquals(mysql.getProperty("connectTimeout"), "7000"); assertEquals(mysql.getProperty("socketTimeout"), "7000"); final Properties oracle = new Properties(); assertTrue(CachedConnection.ConnectDialect.ORACLE.bound("jdbc:oracle:thin:@//h:1521/svc", oracle, 7)); assertEquals(oracle.getProperty("oracle.net.CONNECT_TIMEOUT"), "7000"); assertEquals(oracle.getProperty("oracle.jdbc.ReadTimeout"), "7000"); // the loginTimeout of the sql server driver leaves the read of the prelogin answer open final Properties microsoft = new Properties(); assertTrue(CachedConnection.ConnectDialect.MICROSOFT.bound("jdbc:sqlserver://h:1433;databaseName=db", microsoft, 7)); assertEquals(microsoft.getProperty("loginTimeout"), "7"); assertEquals(microsoft.getProperty("socketTimeout"), "7000"); } /** * A driver with a range of its own for its connect property is never handed a value beyond it: * SQLServerDriverIntProperty.LOGIN_TIMEOUT is validated against [0, 65535], so a bound past * that would not widen the connect, it would fail every one of them. */ @Test public void testConnectBoundStaysInTheRangeTheDriverTakes() throws Exception { final Properties microsoft = new Properties(); CachedConnection.ConnectDialect.MICROSOFT.bound("jdbc:sqlserver://h:1433;databaseName=db", microsoft, 100000); assertEquals(microsoft.getProperty("loginTimeout"), "65535"); assertEquals(microsoft.getProperty("socketTimeout"), "100000000", "the read bound takes any value"); } /** * A bound the administrator put into the connection string by hand - the only workaround this * backend had - keeps precedence, property by property. */ @Test public void testConnectionStringKeepsPrecedence() throws Exception { final Properties postgres = new Properties(); CachedConnection.ConnectDialect.POSTGRES.bound( "jdbc:postgresql://h:5432/db?user=u&password=p&loginTimeout=30&socketTimeout=300", postgres, 7); assertNull(postgres.getProperty("loginTimeout"), "the setting of the connection string was overridden"); assertNull(postgres.getProperty("socketTimeout"), "the setting of the connection string was overridden"); assertNull(postgres.getProperty("connectTimeout"), "the connect side is one budget: a bound of the administrator under either of its names is theirs"); // the sql server driver gives a supplied property precedence over the one of the url final Properties microsoft = new Properties(); CachedConnection.ConnectDialect.MICROSOFT.bound("jdbc:sqlserver://h:1433;loginTimeout=45;databaseName=db", microsoft, 7); assertNull(microsoft.getProperty("loginTimeout"), "the setting of the connection string was overridden"); assertEquals(microsoft.getProperty("socketTimeout"), "7000"); // inside the descriptor of an oracle tns url the property goes by the last segment of its name final Properties oracle = new Properties(); CachedConnection.ConnectDialect.ORACLE.bound( "jdbc:oracle:thin:@(DESCRIPTION=(CONNECT_TIMEOUT=3)(ADDRESS=(HOST=h)(PORT=1521)))", oracle, 7); assertNull(oracle.getProperty("oracle.net.CONNECT_TIMEOUT")); assertEquals(oracle.getProperty("oracle.jdbc.ReadTimeout"), "7000"); // inside the descriptor the read bound goes by READ_TIMEOUT, and one of the administrator // is never lifted after the login, because ours is not set on top of it final Properties read = new Properties(); assertFalse(CachedConnection.ConnectDialect.ORACLE.bound( "jdbc:oracle:thin:@(DESCRIPTION=(READ_TIMEOUT=30)(ADDRESS=(HOST=h)(PORT=1521)))", read, 7), "a read bound of the connection string must not be lifted once the login is through"); assertNull(read.getProperty("oracle.jdbc.ReadTimeout")); // ... while RECV_TIMEOUT is a parameter of sqlnet.ora and of the listener that ojdbc8 does // not read - the name appears nowhere in the driver - so a descriptor carrying one is not // a read bound of the connection and must not take ours off it final Properties recv = new Properties(); assertTrue(CachedConnection.ConnectDialect.ORACLE.bound( "jdbc:oracle:thin:@(DESCRIPTION=(RECV_TIMEOUT=30)(ADDRESS=(HOST=h)(PORT=1521)))", recv, 7), "a parameter no driver reads left the login of this connection unbounded"); assertEquals(recv.getProperty("oracle.jdbc.ReadTimeout"), "7000"); // a name that only appears as the tail of another parameter is not a setting of its own final Properties mysql = new Properties(); CachedConnection.ConnectDialect.MYSQL.bound("jdbc:mysql://h:3306/db?xconnectTimeout=1&socketTimeoutX=2", mysql, 7); assertEquals(mysql.getProperty("connectTimeout"), "7000"); assertEquals(mysql.getProperty("socketTimeout"), "7000"); } /** * A parameter is recognized the way the driver of its dialect recognizes it: pgjdbc and * Connector/J look their properties up by their exact name, so a name of another case is a * parameter of nobody - neither side bounds anything by it - and must not pass for a bound the * administrator set, while the other two match either way. */ @Test public void testTheCaseOfAParameterIsTheOneOfItsDriver() throws Exception { final Properties postgres = new Properties(); CachedConnection.ConnectDialect.POSTGRES.bound("jdbc:postgresql://h:5432/db?ConnectTimeout=5", postgres, 7); assertEquals(postgres.getProperty("connectTimeout"), "7", "pgjdbc ignores a parameter of another case"); // PropertyKey.fromValue("SocketTimeout") answers null, and Connector/J then reads no bound // out of the url either: a mis-cased parameter left the borrower parked on a host that // completes the handshake and says nothing final Properties mysql = new Properties(); CachedConnection.ConnectDialect.MYSQL.bound("jdbc:mysql://h:3306/db?SocketTimeout=1", mysql, 7); assertEquals(mysql.getProperty("socketTimeout"), "7000", "Connector/J ignores a parameter of another case"); final Properties microsoft = new Properties(); CachedConnection.ConnectDialect.MICROSOFT.bound("jdbc:sqlserver://h:1433;LoginTimeout=45", microsoft, 7); assertNull(microsoft.getProperty("loginTimeout"), "the sql server driver normalizes the name of a property"); // the keywords of an oracle descriptor are matched without regard to case as well final Properties oracle = new Properties(); CachedConnection.ConnectDialect.ORACLE.bound( "jdbc:oracle:thin:@(description=(connect_timeout=3)(address=(host=h)(port=1521)))", oracle, 7); assertNull(oracle.getProperty("oracle.net.CONNECT_TIMEOUT"), "an oracle descriptor is read without case"); } /** * The connection string is not the only channel of the administrator: the oracle driver reads * some of its properties out of the system properties as well, which is how a whole jvm is * bounded with -Doracle.jdbc.ReadTimeout. A property supplied to the driver outranks that one * without a word, and this class would then lift it once the login is through as if it were * its own - leaving a connection with no read bound at all where the administrator set one. */ @Test public void testASystemPropertyOfTheAdministratorKeepsPrecedence() throws Exception { System.setProperty("oracle.jdbc.ReadTimeout", "30000"); try { final Properties oracle = new Properties(); assertFalse(CachedConnection.ConnectDialect.ORACLE.bound("jdbc:oracle:thin:@//h:1521/svc", oracle, 7), "a read bound of the administrator must not be lifted once the login is through"); assertNull(oracle.getProperty("oracle.jdbc.ReadTimeout"), "the setting of the administrator was overridden"); assertEquals(oracle.getProperty("oracle.net.CONNECT_TIMEOUT"), "7000", "the property it leaves open must still be bounded"); } finally { System.clearProperty("oracle.jdbc.ReadTimeout"); } // the connect property of the same driver, over the same channel System.setProperty("oracle.net.CONNECT_TIMEOUT", "30000"); try { final Properties oracle = new Properties(); CachedConnection.ConnectDialect.ORACLE.bound("jdbc:oracle:thin:@//h:1521/svc", oracle, 7); assertNull(oracle.getProperty("oracle.net.CONNECT_TIMEOUT"), "the setting of the administrator was overridden"); } finally { System.clearProperty("oracle.net.CONNECT_TIMEOUT"); } // a plain name is common enough to be somebody else's system property: only a name a // driver of these actually reads out of them is one of the administrator's System.setProperty("socketTimeout", "30000"); try { final Properties mysql = new Properties(); assertTrue(CachedConnection.ConnectDialect.MYSQL.bound("jdbc:mysql://h:3306/db", mysql, 7)); assertEquals(mysql.getProperty("socketTimeout"), "7000"); } finally { System.clearProperty("socketTimeout"); } } /** * ... and a dotted name is not a name a driver reads out of the system properties by the shape * of it. ojdbc8 resolves oracle.jdbc.ReadTimeout and oracle.net.CONNECT_TIMEOUT in three tiers * (the properties it was supplied, then System.getProperty, then the properties of the data * source), while oracle.net.READ_TIMEOUT - a dotted name of the same driver, and the name the * socket option is finally read under - reaches the socket from the connection properties * alone: the classes carrying the literal hand it to Properties.get, none of them to * System.getProperty. Taken for a bound of the administrator, a -D of it leaves the login with * no read bound whatever: theirs is not read and ours is not set. */ @Test public void testASystemPropertyNoDriverReadsIsNoBound() throws Exception { System.setProperty("oracle.net.READ_TIMEOUT", "30000"); try { final Properties oracle = new Properties(); assertTrue(CachedConnection.ConnectDialect.ORACLE.bound("jdbc:oracle:thin:@//h:1521/svc", oracle, 7), "a -D the driver never reads left this login with no read bound at all"); assertEquals(oracle.getProperty("oracle.jdbc.ReadTimeout"), "7000"); } finally { System.clearProperty("oracle.net.READ_TIMEOUT"); } // ... while the same name written into the connection string is one the driver does read final Properties declared = new Properties(); assertFalse(CachedConnection.ConnectDialect.ORACLE.bound( "jdbc:oracle:thin:@(DESCRIPTION=(READ_TIMEOUT=30)(ADDRESS=(HOST=h)(PORT=1521)))", declared, 7)); assertNull(declared.getProperty("oracle.jdbc.ReadTimeout")); } /** * A property the administrator set to 0 is not a bound of theirs: every one of these drivers * reads 0 as "wait as long as it takes", which is the default this class exists to replace - * and on the three whose driver lets a supplied property win, ours is set on top of it. * Postgresql is the one where it cannot be, and is covered on its own below. */ @Test public void testAZeroIsNotABoundOfTheAdministrator() throws Exception { final Properties mysql = new Properties(); CachedConnection.ConnectDialect.MYSQL.bound("jdbc:mysql://h:3306/db?connectTimeout=0&socketTimeout=0", mysql, 7); assertEquals(mysql.getProperty("connectTimeout"), "7000"); assertEquals(mysql.getProperty("socketTimeout"), "7000"); // ... and neither is a property left without a value final Properties microsoft = new Properties(); CachedConnection.ConnectDialect.MICROSOFT.bound("jdbc:sqlserver://h:1433;socketTimeout=;databaseName=db", microsoft, 7); assertEquals(microsoft.getProperty("socketTimeout"), "7000"); // the same of a system property, and of the descriptor of an oracle url System.setProperty("oracle.jdbc.ReadTimeout", "0"); try { final Properties oracle = new Properties(); assertTrue(CachedConnection.ConnectDialect.ORACLE.bound( "jdbc:oracle:thin:@(DESCRIPTION=(READ_TIMEOUT=0)(ADDRESS=(HOST=h)(PORT=1521)))", oracle, 7)); assertEquals(oracle.getProperty("oracle.jdbc.ReadTimeout"), "7000"); } finally { System.clearProperty("oracle.jdbc.ReadTimeout"); } // a value that is no number is left to the driver it belongs to: it is not this class's to read final Properties unreadable = new Properties(); assertFalse(CachedConnection.ConnectDialect.MYSQL.bound( "jdbc:mysql://h:3306/db?socketTimeout=PT30S", unreadable, 7)); assertNull(unreadable.getProperty("socketTimeout")); } /** * On postgresql a parameter of the url outranks the property this class supplies: Driver * .connect copies what it was handed into a flat map and parseURL then writes the parameters of * the url on top of it. So a "socketTimeout=0" there cannot be replaced, and setting ours * regardless would leave this class believing it bounded a login that carries no bound - and * lifting a read bound after it that was never in force. The effective values are read back * through the parser of the driver itself, since asserting on the map handed to it is * asserting on the half of the story this bug lived in. */ @Test public void testAParameterOfAPostgresUrlOutranksTheBoundOfThisClass() throws Exception { final String url = "jdbc:postgresql://h:5432/db?connectTimeout=0&socketTimeout=0&loginTimeout=0"; final Properties supplied = new Properties(); assertFalse(CachedConnection.ConnectDialect.POSTGRES.bound(url, supplied, 7), "a read bound that never reaches the driver must not be reported as one to lift"); assertNull(supplied.getProperty("socketTimeout")); assertNull(supplied.getProperty("connectTimeout")); assertNull(supplied.getProperty("loginTimeout")); final Properties effective = org.postgresql.Driver.parseURL(url, supplied); assertEquals(effective.getProperty("socketTimeout"), "0", "the url is what the driver ends up reading"); assertEquals(effective.getProperty("connectTimeout"), "0"); assertEquals(effective.getProperty("loginTimeout"), "0"); } /** * The connect side of a dialect is one budget rather than a set of independent knobs, so a * bound of the administrator under any of its names leaves all of them alone. On postgresql * connectTimeout bounds the socket connect and loginTimeout the login behind it: filling in the * one they left out caps the one they set, since Driver.connect hands the login to a thread of * its own as soon as loginTimeout is anything but 0 and gives up on it there. */ @Test public void testAConnectBudgetOfTheAdministratorIsNotCappedByThisClass() throws Exception { final String url = "jdbc:postgresql://h:5432/db?connectTimeout=300"; final Properties supplied = new Properties(); assertTrue(CachedConnection.ConnectDialect.POSTGRES.bound(url, supplied, 30), "the read bound is a budget of its own and is still set"); assertNull(supplied.getProperty("loginTimeout"), "a loginTimeout of ours caps the connectTimeout the administrator set"); assertNull(supplied.getProperty("connectTimeout")); assertEquals(supplied.getProperty("socketTimeout"), "30"); final Properties effective = org.postgresql.Driver.parseURL(url, supplied); assertEquals(effective.getProperty("connectTimeout"), "300", "the budget of the administrator, in full"); assertNull(effective.getProperty("loginTimeout"), "nothing of ours hands this login to a thread to abandon"); } /** * The bound handed to a driver stays inside the range an int of milliseconds takes. Where the * per-attempt property is off, the attempt takes what is left of the deadline of the borrow, * and the pool timeout has no upper bound of its own - while the SQL Server driver rejects a * socketTimeout past Integer.MAX_VALUE outright, failing every connect of that backend with * the name of a property nobody typed. */ @Test(timeOut = 120000) public void testTheBoundHandedToADriverStaysInTheRangeAnIntTakes() throws Exception { final long deadline = System.currentTimeMillis() + 3000000L * 1000; // 34 days: past 2^31 ms assertEquals(CachedConnection.attemptSeconds(0, deadline), Integer.MAX_VALUE / 1000); System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, "0"); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "3000000"); // a socket that answers the connect and closes it, rather than a port bound and released: // with every bound of this borrow turned off, anything taking that port in between would // leave the test hanging on the timeOut instead of failing try (final ServerSocket rejecting = rejectingSocket()) { final String url = "jdbc:sqlserver://127.0.0.1:" + rejecting.getLocalPort() + ";databaseName=opendj;user=opendj;password=opendj;encrypt=false"; final long startedAt = System.currentTimeMillis(); try { CachedConnection.getConnection(url); fail("a connect the database closes must be reported"); } catch (SQLException expected) { assertFalse(expected.getMessage().contains("socketTimeout"), "the driver was handed a bound it does not take: " + expected.getMessage()); } assertElapsedWithinBound(startedAt, 0); } } /** The clamp of the range holds where the borrow has no deadline to take it from either. */ @Test public void testTheBoundOfAnAttemptStaysInRangeWithoutADeadline() throws Exception { assertEquals(CachedConnection.attemptSeconds(Long.MAX_VALUE, Long.MAX_VALUE), Integer.MAX_VALUE / 1000); assertEquals(CachedConnection.attemptSeconds(0, Long.MAX_VALUE), 0, "0 stands for an attempt with no bound"); assertEquals(CachedConnection.attemptSeconds(30, Long.MAX_VALUE), 30); } /** The connection string holds the credentials of the backend: a stall report must not carry them. */ @Test public void testLoggedConnectionStringCarriesNoCredentials() throws Exception { assertEquals(CachedConnection.safeUrl("jdbc:postgresql://h:5432/db?user=u&password=secret"), "jdbc:postgresql://h:5432/db"); // the parameter naming the database stays: two backends of one sql server host answer to // the same url up to it, and a stall report that cannot tell them apart is one of neither assertEquals(CachedConnection.safeUrl("jdbc:sqlserver://h:1433;databaseName=db;password=secret"), "jdbc:sqlserver://h:1433;databaseName=db"); // ... and the token naming the kind of oracle driver stays as well: thin against oci is a // first question of an oracle connect, and it stands in front of the credentials assertEquals(CachedConnection.safeUrl("jdbc:oracle:thin:scott/secret@//h:1521/svc"), "jdbc:oracle:thin:@//h:1521/svc"); assertEquals(CachedConnection.safeUrl("jdbc:mysql://u:secret@h:3306/db"), "jdbc:mysql://h:3306/db"); assertEquals(CachedConnection.safeUrl("jdbc:oracle:thin:@//h:1521/svc"), "jdbc:oracle:thin:@//h:1521/svc"); // a password holding the parameter separator of another dialect: ";" separates nothing on // an oracle url, so the credentials are cut in front of the "@" rather than inside them assertEquals(CachedConnection.safeUrl("jdbc:oracle:thin:scott/pa;ss@//h:1521/svc"), "jdbc:oracle:thin:@//h:1521/svc"); // ... and one holding a ":" is cut in front of it too: the token of the driver is the one // behind the subprotocol, not the last one standing in front of the "@" assertEquals(CachedConnection.safeUrl("jdbc:oracle:thin:scott/pa:ss@//h:1521/svc"), "jdbc:oracle:thin:@//h:1521/svc"); // ... and an "@" that stands inside a parameter is not the end of credentials: the host survives assertEquals(CachedConnection.safeUrl("jdbc:postgresql://h:5432/db?user=u@example.com&password=secret"), "jdbc:postgresql://h:5432/db"); // a password holding the parameter separator of its own dialect: on an oracle url the // parameters stand behind the descriptor, so a "?" in front of the "@" is part of the // password and cutting there would leave the start of it in the log assertEquals(CachedConnection.safeUrl("jdbc:oracle:thin:scott/pa?ss@//h:1521/svc"), "jdbc:oracle:thin:@//h:1521/svc"); // the same inside an authority, where the credentials end at the path rather than at a "?" assertEquals(CachedConnection.safeUrl("jdbc:mysql://u:sec?ret@h:3306/db"), "jdbc:mysql://h:3306/db"); // a descriptor of an oracle url carries no credentials and survives whole assertEquals(CachedConnection.safeUrl("jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(HOST=h)(PORT=1521)))"), "jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(HOST=h)(PORT=1521)))"); // a first host with nothing in front of the comma keeps the comma: what is left is the // hosts of a url, not one host of it assertEquals(CachedConnection.safeUrl("jdbc:mysql://,h2:3306/db"), "jdbc:mysql://,h2:3306/db"); // a password under a name of its own, and one numbered by the factor it belongs to assertEquals(CachedConnection.safeUrl("jdbc:mysql://(host=h,user=u,password2=secret)/db"), "jdbc:mysql://(host=h,user=u,password2=***)/db"); // a url of Connector/J gives every host of it credentials of its own, and every one of // them goes: the second used to stay in the message with the password of the failover host assertEquals(CachedConnection.safeUrl("jdbc:mysql://u:p@h1:3306,u2:p2@h2:3306/db"), "jdbc:mysql://h1:3306,h2:3306/db"); // ... including a url whose subprotocol names the kind of connection in front of the hosts assertEquals(CachedConnection.safeUrl("jdbc:mysql:replication://master:p1@h1:3306,slave:p2@h2:3306/db"), "jdbc:mysql:replication://h1:3306,h2:3306/db"); // the key-value host syntax of Connector/J puts the credentials inside the authority, where // neither the userinfo nor the parameters of a url stand assertEquals(CachedConnection.safeUrl("jdbc:mysql://address=(host=h)(port=3306)(user=u)(password=secret)/db"), "jdbc:mysql://address=(host=h)(port=3306)(user=u)(password=***)/db"); assertEquals(CachedConnection.safeUrl("jdbc:mysql://(host=h,port=3306,user=u,password=secret)/db"), "jdbc:mysql://(host=h,port=3306,user=u,password=***)/db"); assertEquals(CachedConnection.safeUrl("jdbc:sqlserver://h:1433;databaseName=db;PWD=secret"), "jdbc:sqlserver://h:1433;databaseName=db"); // a shape none of this took apart is not logged past its subprotocol: a password holding a // "/" ends the authority in front of the "@" that would have given the credentials away assertEquals(CachedConnection.safeUrl("jdbc:mysql://u:pa/ss@h:3306/db"), "jdbc:mysql:" + CachedConnection.CREDENTIALS_HIDDEN); // ... and so does a password that holds an "@" and was quoted for the driver assertEquals(CachedConnection.safeUrl("jdbc:oracle:thin:scott/\"pa@ss\"@//h:1521/svc"), "jdbc:oracle:" + CachedConnection.CREDENTIALS_HIDDEN); // a string that is no connection string of any driver carries nothing to the log either assertEquals(CachedConnection.safeUrl("h:5432/db?password=secret"), CachedConnection.CREDENTIALS_HIDDEN); } /** * A driver is free to quote the connection string it was handed back into the message of its * failure, and that message travels: RootContainer makes it the message of what it throws, * BackendConfigManager logs it at ERROR and answers a config change with it. So the message is * redacted rather than the two call sites that happen to log a url. */ @Test public void testAMessageOfADriverCarriesNoCredentials() throws Exception { final String url = "jdbc:postgresql://opendj:S3cret@h:5432/db"; assertEquals(CachedConnection.redact("No suitable driver found for " + url, url), "No suitable driver found for jdbc:postgresql://h:5432/db"); // a driver naming the credentials alone, without the url around them assertEquals(CachedConnection.redact("authentication of opendj:S3cret failed", url), "authentication of " + CachedConnection.CREDENTIALS_HIDDEN + " failed"); // ... and naming the password alone assertEquals(CachedConnection.redact("the password S3cret was not accepted", url), "the password " + CachedConnection.CREDENTIALS_HIDDEN + " was not accepted"); // the credentials of an oracle url stand in front of its descriptor final String oracle = "jdbc:oracle:thin:scott/S3cret@//h:1521/svc"; assertEquals(CachedConnection.redact("IO Error connecting to " + oracle, oracle), "IO Error connecting to jdbc:oracle:thin:@//h:1521/svc"); // a password of a parameter is blanked wherever the message carries it assertEquals(CachedConnection.redact("bad url jdbc:sqlserver://h:1433;password=S3cret", "jdbc:sqlserver://h:1433;password=S3cret"), "bad url jdbc:sqlserver://h:1433"); // a message naming nothing of the connection string is left as it stands assertEquals(CachedConnection.redact("Connection to h:5432 refused", url), "Connection to h:5432 refused"); assertNull(CachedConnection.redact(null, url)); // the stall report is the other way a driver's message reaches the log, and it carries the // url of the backend alongside it final String stall = CachedConnection.stallMessage(url, 3, 4000, new SQLException("FATAL: too many connections for " + url)); assertFalse(stall.contains("S3cret"), stall); assertTrue(stall.contains("jdbc:postgresql://h:5432/db"), stall); assertTrue(stall.contains("4000 ms") && stall.contains("(3 attempts)"), stall); } /** * A password is free to be one character long, and a bare one of those stands inside half the * lines a driver writes. Replaced wherever it is found, it takes the diagnostic apart along * with the credential - and, since the walk looking for credentials asks the redaction whether * it changed anything, it makes every failure of that backend one whose chain is rebuilt. */ @Test public void testAShortPasswordDoesNotRewriteTheMessageOfADriver() throws Exception { final String url = "jdbc:oracle:thin:opendj/1@//h:1521/svc"; // the "1" of an ORA number is part of a number, not a credential of anybody assertEquals(CachedConnection.redact("ORA-12541: TNS:no listener", url), "ORA-12541: TNS:no listener"); // ... while the same password quoted back on its own is still taken out assertEquals(CachedConnection.redact("the password 1 was not accepted", url), "the password " + CachedConnection.CREDENTIALS_HIDDEN + " was not accepted"); assertEquals(CachedConnection.redact("IO Error connecting to " + url, url), "IO Error connecting to jdbc:oracle:thin:@//h:1521/svc"); } /** * A driver is free to name a parameter in the middle of a sentence. The value of one ends at * the first space: reaching to the end of the string, it would take the host, the port and the * cause of the failure into the blank along with the password - the very information the * redaction of a connection string goes out of its way to keep. */ @Test public void testAPasswordParameterDoesNotSwallowTheRestOfTheMessage() throws Exception { final String url = "jdbc:postgresql://h:5432/db?user=u&password=hunter2"; assertEquals(CachedConnection.redact("Connection refused: password=hunter2 for user u at h:5432", url), "Connection refused: password=*** for user u at h:5432"); } /** * The credentials of an oracle url are separated by a "/", so a password holding a "//" of * its own used to start an authority inside itself: what was taken off as a userinfo was the * tail of the password, the "@" the last guard of safeUrl() looks for went with it, and the * user name and the head of the password stayed in the message of a stall. */ @Test public void testAPasswordHoldingASlashPairIsNotLeftInTheLog() throws Exception { final String easyConnect = "jdbc:oracle:thin:opendj/pa//ss@//h:1521/svc"; assertEquals(CachedConnection.safeUrl(easyConnect), "jdbc:oracle:thin:@//h:1521/svc"); // ... and the same password in front of a host that names no "//" of its own final String sid = "jdbc:oracle:thin:opendj/pa//ss@h:1521:svc"; assertEquals(CachedConnection.safeUrl(sid), "jdbc:oracle:thin:@h:1521:svc"); // the message of a driver quoting the url back carries no more of it than the log does assertEquals(CachedConnection.redact("IO Error connecting to " + easyConnect, easyConnect), "IO Error connecting to jdbc:oracle:thin:@//h:1521/svc"); } /** * pgjdbc enforces loginTimeout out of process: Driver.connect hands the login to a daemon * thread of its own and gives up on the thread rather than on the login. Against the database * this bound exists for - one that completes the handshake and then says nothing - an * unbounded read there leaves that thread, and the socket it holds, behind on every borrow; * a few operations a second are enough to run the server out of threads and file descriptors. */ @Test(timeOut = 300000) public void testTheLoginThreadOfPostgresDoesNotOutliveTheBorrow() throws Exception { System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, Long.toString(BOUND_SECONDS)); // counted as a delta of this borrow rather than as a count of the jvm: three tests of this // file open a pgjdbc login against a socket that never answers, and the order they run in // is not contractual - a thread left by any of them would be reported here final int before = loginThreadsOfPostgres(); try (final ServerSocket blackhole = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) { final String url = "jdbc:postgresql://127.0.0.1:" + blackhole.getLocalPort() + "/opendj?user=opendj&password=opendj"; try { CachedConnection.getConnection(url); fail("a database that never answers must not hand out a connection"); } catch (SQLException expected) { // reported to the caller, as the bound of the attempt promises } final long giveUpAt = System.currentTimeMillis() + BOUND_SECONDS * 1000 + BOUND_MARGIN_MS; while (loginThreadsOfPostgres() > before && System.currentTimeMillis() < giveUpAt) { Thread.sleep(100); } assertTrue(loginThreadsOfPostgres() <= before, "the login thread pgjdbc abandoned outlived the borrow: the read of the login is not bounded"); } } private static int loginThreadsOfPostgres() { int alive = 0; for (final Thread thread : Thread.getAllStackTraces().keySet()) { if (thread.isAlive() && thread.getName().startsWith("PostgreSQL JDBC driver connection thread")) { alive++; } } return alive; } /** * The deadline of the borrow stands for the whole borrow, so it bounds the attempt inside it * even where the per-attempt property gives it no bound of its own: turning that property off * must not turn the bound of the borrow off with it. */ @Test(timeOut = 300000) public void testTheDeadlineBoundsAnAttemptTheConnectPropertyDoesNot() throws Exception { System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, "0"); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "2"); try (final ServerSocket blackhole = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) { final String url = "jdbc:postgresql://127.0.0.1:" + blackhole.getLocalPort() + "/opendj?user=opendj&password=opendj"; final long startedAt = System.currentTimeMillis(); try { CachedConnection.getConnection(url); fail("a database that never answers must not hand out a connection"); } catch (SQLException expected) { // bounded by what is left of the deadline of the borrow } // the wait is the point of this one as much as its end is: a borrow against a socket that // never answers cannot be over before the deadline unless something else ended it final long elapsed = System.currentTimeMillis() - startedAt; assertTrue(elapsed >= 1000, "the borrow was over after " + elapsed + " ms, before its deadline"); assertElapsedWithinBound(startedAt, 2000); } } /** * The deadline stops the drain of the pool; it does not throw away the connection in hand. A * database at its connection limit has no other source of connections than the ones coming * back to the pool, and closing one unvalidated takes it out of that source for good - while * the borrow that closed it fails with a timeout anyway. */ @Test(timeOut = 120000) public void testAPooledConnectionIsNotDiscardedUnvalidatedAtTheDeadline() throws Exception { final String url = StubDriver.PREFIX + "unvalidated-at-deadline"; final Connection stale = mock(Connection.class); when(stale.isValid(anyInt())).thenAnswer(invocation -> { Thread.sleep(1500); // a database that no longer answers: the validation waits out its bound return false; }); final Connection good = mock(Connection.class); when(good.isValid(anyInt())).thenReturn(true); seedPool(url, stale, good); final Connection fresh = mock(Connection.class); stub.answerWith(fresh); System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "1"); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, fresh, "the drain must stop at the deadline"); verify(stale).close(); verify(good, never()).close(); assertFalse(CachedConnection.cached.get(url).isEmpty(), "a connection the deadline was reached in front of was lost"); } /** * A connection the validation of which failed is on its way out, and its driver knows it: * Connector/J answers a failed validation by aborting the connection and the SQL Server driver * by terminating it. Putting the previous bound back on it fails, and warns about statements * of a connection that is being closed - over an idle connection the server reaped, which is * nobody's problem. */ @Test(timeOut = 120000) public void testAConnectionOnItsWayOutIsNotGivenItsBoundBack() throws Exception { final String url = StubDriver.PREFIX + "reaped-idle"; final Connection reaped = mock(Connection.class); when(reaped.getNetworkTimeout()).thenReturn(0); when(reaped.isValid(anyInt())).thenReturn(false); seedPool(url, reaped); final Connection fresh = mock(Connection.class); stub.answerWith(fresh); assertSame(((CachedConnection) CachedConnection.getConnection(url)).parent, fresh); verify(reaped).setNetworkTimeout(any(Executor.class), eq(CachedConnection.VALIDATION_TIMEOUT_SECONDS * 1000)); verify(reaped, never()).setNetworkTimeout(any(Executor.class), eq(0)); verify(reaped).close(); } /** * The read bound of the login is lifted once the login is through, because left in place it * fails every statement slower than it. A driver that will not take it back leaves a * connection that must not be pooled: it would carry that bound into every borrow the pool * hands it to, an import batch among them. */ @Test(timeOut = 120000) public void testAConnectionStillCarryingTheBoundOfItsLoginIsNotPooled() throws Exception { final String url = StubDriver.PREFIX + "unliftable-bound"; final Connection parent = mock(Connection.class); doThrow(new SQLException("setNetworkTimeout is not supported")) .when(parent).setNetworkTimeout(any(Executor.class), eq(0)); stub.answerWith(parent); final CachedConnection borrowed = CachedConnection.connect(url, CachedConnection.ConnectDialect.MYSQL, 30); borrowed.close(); verify(parent).close(); assertTrue(CachedConnection.cached.get(url).isEmpty(), "a connection still carrying the read bound of its login went back into the pool"); } /** * The case the window exists for: the connection this borrow takes out answered the database a * moment ago, and asking it again costs the round trip the operation came to make. */ @Test(timeOut = 120000) public void testAConnectionProvenAliveIsNotValidatedAgainWithinTheWindow() throws Exception { final String url = StubDriver.PREFIX + "within-the-window"; CachedConnection.aliveBypassNanos = TimeUnit.HOURS.toNanos(1); final Connection parent = mock(Connection.class); when(parent.isValid(anyInt())).thenReturn(true); stub.answerWith(parent); final Connection first = CachedConnection.getConnection(url); // established: it has just answered first.close(); final Connection second = CachedConnection.getConnection(url); assertSame(second, first, "the pooled connection was not the one handed back"); assertEquals(stub.attempts.get(), 1, "the pool established a second connection"); verify(parent, never()).isValid(anyInt()); } /** Past the window it is the connection the database or a firewall may have dropped meanwhile. */ @Test(timeOut = 120000) public void testAConnectionIsValidatedAgainOnceTheWindowHasPassed() throws Exception { final String url = StubDriver.PREFIX + "past-the-window"; CachedConnection.aliveBypassNanos = TimeUnit.MILLISECONDS.toNanos(1); final Connection parent = mock(Connection.class); when(parent.isValid(anyInt())).thenReturn(true); stub.answerWith(parent); final Connection first = CachedConnection.getConnection(url); first.close(); Thread.sleep(20); final Connection second = CachedConnection.getConnection(url); assertSame(second, first); verify(parent).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); } /** The window switched off validates every borrow, the way this pool did before it existed. */ @Test(timeOut = 120000) public void testAWindowOfZeroValidatesEveryBorrow() throws Exception { final String url = StubDriver.PREFIX + "window-of-zero"; CachedConnection.aliveBypassNanos = 0; final Connection parent = mock(Connection.class); when(parent.isValid(anyInt())).thenReturn(true); stub.answerWith(parent); final Connection first = CachedConnection.getConnection(url); first.close(); final Connection second = CachedConnection.getConnection(url); assertSame(second, first); verify(parent).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); } /** * A connection is trusted for the window that follows the last answer it gave, never for the * window that follows its return to the pool. pgjdbc short-circuits both rollback() and * commit() when the transaction state is IDLE, so a borrow that issued no statement - the open * of a backend, a configuration change that leaves the base DNs alone, an import of nothing - * puts a connection back without a byte reaching the server: stamping the return would mark a * connection the database dropped meanwhile as the freshest one in the pool. */ @Test(timeOut = 120000) public void testTheReturnToThePoolIsNotTakenForProofOfLife() throws Exception { final String url = StubDriver.PREFIX + "silent-return"; CachedConnection.aliveBypassNanos = TimeUnit.MILLISECONDS.toNanos(50); final Connection dropped = mock(Connection.class); when(dropped.isValid(anyInt())).thenReturn(false); // dropped while it was out of the pool stub.answerWith(dropped); final Connection borrowed = CachedConnection.getConnection(url); Thread.sleep(80); // the answer of the login ages out of the window borrowed.close(); // and the rollback of this return never leaves the driver final Connection fresh = mock(Connection.class); when(fresh.isValid(anyInt())).thenReturn(true); stub.answerWith(fresh); final Connection next = CachedConnection.getConnection(url); verify(dropped).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); verify(dropped).close(); assertSame(((CachedConnection) next).parent, fresh, "a connection the database dropped was handed out on the strength of its return to the pool"); } /** * A proof taken while the database was going away does not outlive the distrust that reported it. *

* The stamp stands for the moment the connection was asked, not the moment its answer was filed: * a validation is given {@link CachedConnection#VALIDATION_TIMEOUT_SECONDS}, and one that started * before another operation reported a drop and returned after it would otherwise be the younger of * the two. The connection would then be handed out unvalidated for the rest of the window - and * LIFO puts it at the head of the deque, so it is the very one the next borrow takes - by the * check that exists to stop exactly that. */ @Test(timeOut = 120000) public void testAProofTakenWhileTheDatabaseWentAwayIsNotTrusted() throws Exception { final String url = StubDriver.PREFIX + "proof-across-a-drop"; CachedConnection.aliveBypassNanos = TimeUnit.HOURS.toNanos(1); // nothing here ages out of the window final AtomicInteger validations = new AtomicInteger(); final Connection parent = mock(Connection.class); // the database goes away while this validation is in flight: another operation of the backend // reports the drop of its own connection before this one has answered when(parent.isValid(anyInt())).thenAnswer(invocation -> { CachedConnection.distrustPool(url); validations.incrementAndGet(); return true; }); stub.answerWith(parent); CachedConnection.getConnection(url).close(); // established and returned, proven by its login CachedConnection.distrustPool(url); // an operation reports a drop: what the pool holds predates it CachedConnection.getConnection(url).close(); // validated, and a second drop is reported while it is final Connection borrowed = CachedConnection.getConnection(url); assertEquals(validations.get(), 2, "a connection was trusted on a proof that started before the drop it is compared against"); assertSame(((CachedConnection) borrowed).parent, parent, "the connection answered and was still discarded"); } /** * The pool hands out the connection returned last. Without it the window would rarely apply: a * connection reached only after a whole cycle of the pool has been idle far longer than it. */ @Test(timeOut = 120000) public void testTheConnectionReturnedLastIsBorrowedFirst() throws Exception { final String url = StubDriver.PREFIX + "returned-last"; CachedConnection.aliveBypassNanos = TimeUnit.HOURS.toNanos(1); final Connection older = mock(Connection.class); when(older.isValid(anyInt())).thenReturn(true); stub.answerWith(older); final Connection first = CachedConnection.getConnection(url); final Connection newer = mock(Connection.class); when(newer.isValid(anyInt())).thenReturn(true); stub.answerWith(newer); final Connection second = CachedConnection.getConnection(url); assertNotSame(second, first); first.close(); second.close(); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, newer, "the pool cycled round to its coldest connection"); } /** * Whatever dropped one connection - a restart, a failover, a network that went away - dropped * every connection established before it, and a borrow inside the window asks the database * nothing: so the operation that saw the failure tells the pool, and the rest of that * generation is validated once before it is trusted again. */ @Test(timeOut = 120000) public void testThePoolIsValidatedAgainAfterTheDatabaseDroppedAConnection() throws Exception { final String url = StubDriver.PREFIX + "distrusted-generation"; CachedConnection.aliveBypassNanos = TimeUnit.HOURS.toNanos(1); final Connection parent = mock(Connection.class); when(parent.isValid(anyInt())).thenReturn(true); stub.answerWith(parent); CachedConnection.getConnection(url).close(); CachedConnection.distrustPool(url); final Connection next = CachedConnection.getConnection(url); next.close(); CachedConnection.getConnection(url).close(); // once for the generation the drop condemned, and not again for the borrow behind it verify(parent, times(1)).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); } /** * The whole of the trade the window makes, end to end: a connection the database dropped * inside the window is handed out unvalidated - that is the cost - the operation it broke * reports the drop, and from there the pool validates the generation the drop condemned * instead of handing out the rest of it the same way. */ @Test(timeOut = 120000) public void testAConnectionDroppedInsideTheWindowIsHandedOutOnceAndThenValidated() throws Exception { final String url = StubDriver.PREFIX + "dropped-inside-the-window"; CachedConnection.aliveBypassNanos = TimeUnit.HOURS.toNanos(1); final Connection parent = mock(Connection.class); when(parent.isValid(anyInt())).thenReturn(true); stub.answerWith(parent); CachedConnection.getConnection(url).close(); when(parent.isValid(anyInt())).thenReturn(false); // the database dropped it where it lay final Connection dropped = CachedConnection.getConnection(url); assertSame(((CachedConnection) dropped).parent, parent, "the pooled connection was not the one handed back"); verify(parent, never()).isValid(anyInt()); // handed out on the strength of its last answer // the statement of the caller is where the drop surfaces, and the caller reports it CachedConnection.distrustPool(url); dropped.close(); final Connection fresh = mock(Connection.class); when(fresh.isValid(anyInt())).thenReturn(true); stub.answerWith(fresh); final Connection next = CachedConnection.getConnection(url); verify(parent).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); verify(parent).close(); assertSame(((CachedConnection) next).parent, fresh, "the rest of the generation was handed out unvalidated"); } /** * Seeds the pool the way {@link CachedConnection#close()} fills it - at the end a borrow takes * from - so that the connection named first here is the one the next borrow gets. */ private static void seedPool(String url, Connection... parents) { for (int i = parents.length - 1; i >= 0; i--) { CachedConnection.cached.get(url).addFirst(new CachedConnection(url, parents[i])); } } /** * The borrows nothing compensates a dropped connection on - the open of a backend, the removal * of its files, the start of an import - ask for a connection the pool validates whatever the * window says. Each of them is one borrow of a cold path, and the one that opens a backend * issues no statement at all: a connection dropped inside the window would surface there out of * the rollback that releases it, with no statement to replay and nothing to tell the pool. */ @Test(timeOut = 120000) public void testTheBorrowsNothingCompensatesAreValidated() throws Exception { final String url = StubDriver.PREFIX + "validated-borrow"; CachedConnection.aliveBypassNanos = TimeUnit.HOURS.toNanos(1); final Connection parent = mock(Connection.class); when(parent.isValid(anyInt())).thenReturn(true); stub.answerWith(parent); CachedConnection.getConnection(url).close(); final Connection borrowed = CachedConnection.getConnection(url, false); assertSame(((CachedConnection) borrowed).parent, parent, "the pooled connection was not the one handed back"); verify(parent).isValid(CachedConnection.VALIDATION_TIMEOUT_SECONDS); } /** * A connection closed under the borrow is not handed out on the strength of its last answer: * the removal listener of the pool closes every connection it finds in the deque when the pool * expires, and it iterates a weakly consistent view. The validation the window replaces * answered that as well, out of a flag of the driver rather than out of a round trip. */ @Test(timeOut = 120000) public void testAConnectionThePoolClosedIsNotHandedOut() throws Exception { final String url = StubDriver.PREFIX + "closed-inside-the-window"; CachedConnection.aliveBypassNanos = TimeUnit.HOURS.toNanos(1); final Connection parent = mock(Connection.class); when(parent.isValid(anyInt())).thenReturn(true); stub.answerWith(parent); CachedConnection.getConnection(url).close(); // closed where it lay, by the expiry of the pool: a closed connection answers isValid() with // false as well, which is what discards it once the window stops trusting it when(parent.isClosed()).thenReturn(true); when(parent.isValid(anyInt())).thenReturn(false); final Connection fresh = mock(Connection.class); when(fresh.isValid(anyInt())).thenReturn(true); stub.answerWith(fresh); final Connection borrowed = CachedConnection.getConnection(url); assertSame(((CachedConnection) borrowed).parent, fresh, "a closed connection was handed out on its last answer"); } /** * The window is clamped twice: to the idle time the pool keeps a connection for, since a window * longer than that is one the pool can never back - the connection it was meant for is gone * before it closes - and to {@link CachedConnection#MAX_ALIVE_BYPASS_MS} behind it, since the * ttl has no upper bound of its own and a value the unit conversion saturates on would leave * every connection of the pool trusted for the life of the server. *

* About the value the class settles on at initialization: the field the pool reads is assigned * once, so a ttl set after that changes neither it nor the idle time it was clamped to. */ @Test(timeOut = 120000) public void testTheWindowIsClampedToTheIdleTimeOfThePool() { System.setProperty(CachedConnection.TTL_PROPERTY, "15000"); System.setProperty(CachedConnection.ALIVE_BYPASS_PROPERTY, "500"); assertEquals(CachedConnection.getAliveBypassMillis(), 500L, "a window inside the ttl was not left alone"); System.setProperty(CachedConnection.ALIVE_BYPASS_PROPERTY, Long.toString(Long.MAX_VALUE)); assertEquals(CachedConnection.getAliveBypassMillis(), 15000L, "a window of Long.MAX_VALUE was not clamped"); System.setProperty(CachedConnection.TTL_PROPERTY, "100"); assertEquals(CachedConnection.getAliveBypassMillis(), 100L, "the clamp is the configured ttl, not the default"); // the ttl has no upper bound of its own, so the clamp to it does not bound the window either: both set // to a value the conversion to nanoseconds saturates on would leave every connection of the pool // trusted for the life of the server, which is the outcome this javadoc says the clamp rules out System.setProperty(CachedConnection.TTL_PROPERTY, Long.toString(Long.MAX_VALUE)); System.setProperty(CachedConnection.ALIVE_BYPASS_PROPERTY, Long.toString(Long.MAX_VALUE)); assertEquals(CachedConnection.getAliveBypassMillis(), CachedConnection.MAX_ALIVE_BYPASS_MS, "a window the ttl did not bound was left to saturate"); } /** * A setting worth warning about must leave the class usable. Both properties are read by the * initializer of {@code aliveBypassNanos}, and both report an unusable value through the set of * what has been said once already - which, declared below that initializer, would still be null * when the initializer reaches it (JLS 12.4.2). A window longer than the ttl is exactly the * tuning the javadoc of the property invites, and it would turn a log line into an * ExceptionInInitializerError on the first borrow and a causeless NoClassDefFoundError on every * one after it: no connection can be borrowed, so the backend cannot open at all. *

* Asserted on the class loaded afresh rather than on this one, which was initialized long * before the property was set. */ @Test(timeOut = 120000, dataProvider = "settingsWorthWarningAbout") public void testASettingWorthWarningAboutStillInitializesTheClass(String ttl, String window, long expectedMillis) throws Exception { System.setProperty(CachedConnection.TTL_PROPERTY, ttl); System.setProperty(CachedConnection.ALIVE_BYPASS_PROPERTY, window); final Class reloaded = loadedAfresh(CachedConnection.class); assertNotSame(reloaded, CachedConnection.class, "the class under test was not loaded afresh"); final Field field = reloaded.getDeclaredField("aliveBypassNanos"); field.setAccessible(true); assertEquals(field.getLong(null), TimeUnit.MILLISECONDS.toNanos(expectedMillis), "the window the reloaded class settled on"); } @DataProvider public Object[][] settingsWorthWarningAbout() { return new Object[][]{ // a window longer than the ttl: reported once and used as the ttl {"15000", "60000", 15000L}, // not a number, and negative: reported once and ignored in favour of the default {"15000", "half a second", CachedConnection.DEFAULT_ALIVE_BYPASS_MS}, {"15000", "-1", CachedConnection.DEFAULT_ALIVE_BYPASS_MS}, // the ttl is read by the same initializer, and reports its own value the same way {"30s", "500", 500L} }; } /** * The class again, defined by a loader of this test rather than taken from the one that has * already initialized it: a static initializer runs once per loader, and this is about what it * does. Every other class is delegated to the parent, so the reloaded one shares the types it * is written against. */ private static Class loadedAfresh(Class type) throws Exception { final String name = type.getName(); final ClassLoader parent = type.getClassLoader(); final ClassLoader loader = new ClassLoader(parent) { @Override protected Class loadClass(String candidate, boolean resolve) throws ClassNotFoundException { if (!name.equals(candidate)) { return super.loadClass(candidate, resolve); } Class defined = findLoadedClass(candidate); if (defined == null) { final byte[] bytecode = bytecodeOf(candidate, parent); defined = defineClass(candidate, bytecode, 0, bytecode.length); } if (resolve) { resolveClass(defined); } return defined; } }; return Class.forName(name, true, loader); } private static byte[] bytecodeOf(String name, ClassLoader from) throws ClassNotFoundException { try (final InputStream in = from.getResourceAsStream(name.replace('.', '/') + ".class")) { if (in == null) { throw new ClassNotFoundException(name); } final ByteArrayOutputStream bytecode = new ByteArrayOutputStream(); final byte[] chunk = new byte[8192]; for (int read; (read = in.read(chunk)) >= 0; ) { bytecode.write(chunk, 0, read); } return bytecode.toByteArray(); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } private static SQLException tooManyConnections() { // 53300, too_many_connections, of the insufficient_resources class return new SQLException("sorry, too many clients already", "53300"); } /** A connection string of every dialect pointing at one host and port. */ private static String[] urlsOf(int port) { return new String[]{ "jdbc:postgresql://127.0.0.1:" + port + "/opendj?user=opendj&password=opendj", "jdbc:mysql://127.0.0.1:" + port + "/opendj?user=opendj&password=opendj", "jdbc:oracle:thin:opendj/opendj@//127.0.0.1:" + port + "/free", "jdbc:sqlserver://127.0.0.1:" + port + ";databaseName=opendj;user=opendj;password=opendj;encrypt=false" }; } private static int closedPort() throws Exception { try (final ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { return socket.getLocalPort(); } // closed again: nothing listens there any more } /** * A socket that answers a connect and closes it at once. A port bound and released is the * shape of a refused connect a test would reach for, but it races whatever else on the host * may take that port; this one is the failure it stands for and belongs to nobody else. */ private static ServerSocket rejectingSocket() throws Exception { final ServerSocket socket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); final Thread accepting = new Thread(() -> { while (!socket.isClosed()) { try { socket.accept().close(); } catch (Exception closed) { return; } } }, "opendj-test-rejecting-socket"); accepting.setDaemon(true); accepting.start(); return socket; } // The margin grows with the bound instead of dwarfing it: what this has to catch is a bound // that is not in force, and a bound of 2 s that is not in force is not a wait of 12 s - it is // the 30 s of the connect property, the 600 s of a driver, or no end at all. private static void assertElapsedWithinBound(long startedAt, long boundMs) { final long elapsed = System.currentTimeMillis() - startedAt; final long margin = Math.max(BOUND_MARGIN_MS, boundMs); assertTrue(elapsed < boundMs + margin, "gave up only after " + elapsed + " ms, past the " + boundMs + " ms it was bounded by"); } /** * Stands in for a database whose answer to a connect is the point of the test: the vendor * codes and SQL states below are what the retry has to tell apart, and no engine is needed to * produce them. */ private static final class StubDriver implements Driver { static final String PREFIX = "jdbc:opendj-stub:"; static final int ALWAYS = -1; final AtomicInteger attempts = new AtomicInteger(); private volatile SQLException failure; private volatile int failuresLeft; private volatile Connection answer; void failWith(SQLException failure, int times) { this.failure = failure; this.failuresLeft = times; this.answer = null; this.attempts.set(0); } void answerWith(Connection answer) { this.failure = null; this.failuresLeft = 0; this.answer = answer; this.attempts.set(0); } @Override public Connection connect(String url, Properties info) throws SQLException { if (!acceptsURL(url)) { return null; } attempts.incrementAndGet(); if (failuresLeft != 0) { if (failuresLeft > 0) { failuresLeft--; } throw failure; } if (answer != null) { return answer; } final Connection con = mock(Connection.class); when(con.isValid(anyInt())).thenReturn(true); return con; } @Override public boolean acceptsURL(String url) { return url != null && url.startsWith(PREFIX); } @Override public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { return new DriverPropertyInfo[0]; } @Override public int getMajorVersion() { return 1; } @Override public int getMinorVersion() { return 0; } @Override public boolean jdbcCompliant() { return false; } @Override public Logger getParentLogger() { return Logger.getLogger(StubDriver.class.getName()); } } }