From 8890fb0259e3f277ec0f24a1de575f031721101d Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Tue, 22 Sep 2026 09:12:00 +0000
Subject: [PATCH] [#1011] Wait out the connection limit an account is given of its own (#1018)

---
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java |  172 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 172 insertions(+), 0 deletions(-)

diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java
index 5d22954..4250deb 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java
@@ -1116,6 +1116,178 @@
 		assertEquals(stub.attempts.get(), 1, "a rejected login must be attempted once");
 	}
 
+	/**
+	 * The limit an account is given of its own is the same failure as the limit of the server, and
+	 * clears the same way: the connections this account may hold at once are held by this pool, and
+	 * one of them is on its way back to it. mysql reports it as 1226 ER_USER_LIMIT_REACHED and in
+	 * the syntax error class - 42000, where a statement the database refused lands - so the vendor
+	 * code is the whole of what tells the two apart (#1011).
+	 */
+	@Test(timeOut = 120000)
+	public void testThePerAccountConnectionLimitOfMysqlIsRetried() {
+		assertTrue(CachedConnection.isWorthRetrying(
+				new SQLException("User 'opendj' has exceeded the 'max_user_connections' resource (current value: 4)",
+					"42000", 1226),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"a per-account connection limit clears when a connection of this pool goes back to it");
+	}
+
+	/**
+	 * The same code carries the limits an account is given per hour, and those are cleared by the
+	 * top of the hour rather than by a connection coming back. Waiting one out would cost every
+	 * borrow the whole deadline of the pool - a worker thread apiece, for as long as the hour lasts
+	 * - and would hide the message naming the resource behind a timeout, so it is reported at once.
+	 * The resource is named by the server as a literal of its own, which is why it can be read out
+	 * of a message whose text is otherwise the server's to translate.
+	 */
+	@Test(timeOut = 120000)
+	public void testTheHourlyLimitOfAMysqlAccountIsNotRetried() {
+		assertFalse(CachedConnection.isWorthRetrying(
+				new SQLException("User 'opendj' has exceeded the 'max_connections_per_hour' resource (current value: 5)",
+					"42000", 1226),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"an hourly limit is not cleared by a connection of this pool coming back");
+	}
+
+	/**
+	 * ... and the resource is read by the name the server gives it rather than by the shape of that
+	 * name: the queries an account is granted per hour are named max_questions, with no _per_hour
+	 * about them (measured against mysql:9.2). This one reaches the road this gate guards - an
+	 * account whose quota is spent is refused the connect itself, Connector/J spending what is left
+	 * of it on the queries of its own login - so a wait would park a worker thread in every borrow
+	 * and every catalog connect until the hour turns, with the message naming the resource hidden
+	 * behind the timeout of the borrow.
+	 */
+	@Test(timeOut = 120000)
+	public void testTheHourlyQueryLimitOfAMysqlAccountIsNotRetried() {
+		assertFalse(CachedConnection.isWorthRetrying(
+				new SQLException("User 'opendj' has exceeded the 'max_questions' resource (current value: 5)",
+					"42000", 1226),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"the queries granted per hour are named max_questions, and no connection coming back clears them");
+	}
+
+	/**
+	 * The same for the updates granted per hour, which the server names max_updates: this one is met
+	 * by a statement that changes data rather than by a connect, so it reaches this gate only where
+	 * a driver hands the pool a failure of something else entirely - and it is no more cleared by a
+	 * connection coming back than the queries are.
+	 */
+	@Test(timeOut = 120000)
+	public void testTheHourlyUpdateLimitOfAMysqlAccountIsNotRetried() {
+		assertFalse(CachedConnection.isWorthRetrying(
+				new SQLException("User 'opendj' has exceeded the 'max_updates' resource (current value: 5)",
+					"42000", 1226),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"the updates granted per hour are named max_updates, and no connection coming back clears them");
+	}
+
+	/**
+	 * A resource this code carries that this server has yet to be given is reported rather than
+	 * waited out: what the wait clears is the connections of this pool, and a resource nobody here
+	 * has heard of is not those. That is what master did with every one of these codes, so an
+	 * unknown resource costs a borrow nothing it did not cost before (#1011).
+	 */
+	@Test(timeOut = 120000)
+	public void testAMysqlResourceThisGateDoesNotKnowIsNotRetried() {
+		assertFalse(CachedConnection.isWorthRetrying(
+				new SQLException("User 'opendj' has exceeded the 'max_statements_per_minute' resource"
+					+ " (current value: 5)", "42000", 1226),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"a resource of this code that is not the concurrent limit is the caller's to see");
+	}
+
+	/**
+	 * A 1226 that names no resource - a proxy that rewrote the message, a driver that kept the code
+	 * and not the text - is waited out rather than reported: the concurrent limit is the one an
+	 * account is given in practice, the wait it costs is bounded by the deadline of the borrow, and
+	 * reading it as permanent fails an operation a connection of ours would have served.
+	 */
+	@Test(timeOut = 120000)
+	public void testAMysqlAccountLimitWhoseResourceIsNotNamedIsRetried() {
+		assertTrue(CachedConnection.isWorthRetrying(new SQLException("connection rejected", "42000", 1226),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"a limit whose resource is not named is taken for the concurrent one");
+	}
+
+	/** ... which is the message of a driver that kept the code and dropped the text: none at all. */
+	@Test(timeOut = 120000)
+	public void testAMysqlAccountLimitWithNoMessageIsRetried() {
+		assertTrue(CachedConnection.isWorthRetrying(new SQLException(null, "42000", 1226),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"a limit arriving without a message is taken for the concurrent one rather than thrown at");
+	}
+
+	/**
+	 * A host the server blocked is no limit that clears itself: the host cache holds the block until
+	 * an administrator flushes it, so waiting the deadline of a borrow out would only hide the one
+	 * message naming the remedy behind a timeout. It belongs with the password that is not accepted.
+	 */
+	@Test(timeOut = 120000)
+	public void testAHostMysqlBlockedIsNotRetried() {
+		assertFalse(CachedConnection.isWorthRetrying(
+				new SQLException("Host 'ldap1.example.com' is blocked because of many connection errors;"
+					+ " unblock with 'mysqladmin flush-hosts'", "HY000", 1129),
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"a blocked host is cleared by an administrator, not by waiting");
+	}
+
+	/**
+	 * The resource is read off the link the code arrived on, not off the failure as a whole: a
+	 * wrapper - a proxy, a DataSource of a container - is free to carry the text of something else
+	 * entirely above the exception that carries the code, and a verdict made of the two together
+	 * belongs to neither of them.
+	 */
+	@Test(timeOut = 120000)
+	public void testTheResourceIsReadOffTheLinkCarryingTheCode() {
+		final SQLException wrapped = new SQLException("could not connect: the account has exceeded the"
+			+ " 'max_connections_per_hour' resource", "08006",
+			new SQLException("User 'opendj' has exceeded the 'max_user_connections' resource (current value: 4)",
+				"42000", 1226));
+
+		assertTrue(CachedConnection.isWorthRetrying(wrapped,
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"the resource of the link carrying the code is what the verdict is made of");
+	}
+
+	/** ... and the other way around: the text of the wrapper decides nothing for the code beneath it. */
+	@Test(timeOut = 120000)
+	public void testTheResourceOfAWrapperDecidesNothing() {
+		final SQLException wrapped = new SQLException("could not connect to the server", "08006",
+			new SQLException("User 'opendj' has exceeded the 'max_connections_per_hour' resource (current value: 5)",
+				"42000", 1226));
+
+		assertFalse(CachedConnection.isWorthRetrying(wrapped,
+				CachedConnection.ConnectDialect.of("jdbc:mysql://db.example.com:3306/opendj")),
+			"an hourly limit stays an hourly limit under a wrapper that names no resource");
+	}
+
+	/**
+	 * The sessions an oracle account may hold at once are the SESSIONS_PER_USER of its profile, and
+	 * ORA-02391 is the per-account sibling of the ORA-00020 this table knew already: both are
+	 * cleared by a session ending, which for this pool is a connection of its own going back to it.
+	 */
+	@Test(timeOut = 120000)
+	public void testTheSessionLimitOfAnOracleProfileIsRetried() {
+		// the state as ojdbc reported it against a profile with sessions_per_user 1: 61000, no
+		// connection class of its own either, so the vendor code is again the whole of the verdict
+		assertTrue(CachedConnection.isWorthRetrying(
+				new SQLException("ORA-02391: exceeded simultaneous SESSIONS_PER_USER limit", "61000", 2391),
+				CachedConnection.ConnectDialect.of("jdbc:oracle:thin:@db.example.com:1521/FREEPDB1")),
+			"the session limit of a profile is cleared by a session of this pool ending");
+	}
+
+	/** ORA-00018, the same limit as the instance keeps it: a session of somebody's has to end. */
+	@Test(timeOut = 120000)
+	public void testTheSessionLimitOfAnOracleInstanceIsRetried() {
+		// no state: an instance out of sessions is not something this test could provoke to measure
+		// one from, and the vendor code is what the verdict is made of
+		assertTrue(CachedConnection.isWorthRetrying(
+				new SQLException("ORA-00018: maximum number of sessions exceeded", null, 18),
+				CachedConnection.ConnectDialect.of("jdbc:oracle:thin:@db.example.com:1521/FREEPDB1")),
+			"the session limit of an instance is cleared by a session ending");
+	}
+
 	/** 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 {

--
Gitblit v1.10.0