From 86aa5f5e54e0940da7fcf0a0758a7b65262f269e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:09:46 -0600 Subject: [PATCH 1/3] Handle SQL connection initialization failures safely --- .../simpleapi/sql/mysql/AbstractSqlTable.java | 23 ++- .../sql/mysql/ConnectionManager.java | 11 +- .../bencodez/simpleapi/sql/mysql/MySQL.java | 29 ++-- .../AbstractSqlTableInitializationTest.java | 146 ++++++++++++++++++ .../sql/mysql/ConnectionManagerPoolTest.java | 66 +++++++- docs/sql-initialization-lifecycle.md | 19 +++ 6 files changed, 273 insertions(+), 21 deletions(-) create mode 100644 SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java create mode 100644 docs/sql-initialization-lifecycle.md diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java index 36a96c2..06e7cda 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java @@ -129,19 +129,26 @@ public void severe(String string) { } }; - boolean ok = this.mysql.connect(config); + boolean ok = this.mysql.connect(config, false); if (!ok) { - logSevere("Failed to connect to database. host=" + config.getHostName() + " db=" + config.getDatabase()); + IllegalStateException failure = new IllegalStateException("Failed to initialize SQL connection pool", + this.mysql.getLastConnectFailure()); + try { mysql.disconnect(); } + catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + throw failure; } // Keep the old behavior: USE db for MySQL/MariaDB if (this.dbType == DbType.MYSQL || this.dbType == DbType.MARIADB) { - try { - new Query(mysql, "USE `" + config.getDatabase() + "`;").executeUpdate(); - } catch (SQLException e) { - logSevere("Failed to send USE database query: " + config.getDatabase() - + " (DB may still work depending on JDBC URL). Error: " + e.getMessage()); - debug(e); + // Query.executeUpdate retains legacy logging/zero-result behavior on SQL + // failure. Constructor setup needs a checked path without subclass hooks. + try (Connection connection = mysql.getConnectionManager().getConnectionChecked(); + PreparedStatement statement = connection.prepareStatement("USE " + qi(config.getDatabase()) + ";")) { + statement.executeUpdate(); + } catch (SQLException | RuntimeException | Error failure) { + try { mysql.disconnect(); } + catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + throw new IllegalStateException("Failed to initialize SQL database selection", failure); } } diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java index e2cbe94..df94652 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java @@ -31,6 +31,12 @@ public class ConnectionManager { @Getter private volatile HikariDataSource dataSource; + /** The original initialization failure from the most recent open attempt. */ + private volatile Exception lastOpenFailure; + + public Exception getLastOpenFailure() { + return lastOpenFailure; + } @Getter @Setter @@ -193,7 +199,7 @@ public Connection getConnectionChecked() throws SQLException { HikariDataSource current; synchronized (this) { if (isClosed() && !open()) { - throw new SQLException("Unable to initialize SQL connection pool"); + throw new SQLException("Unable to initialize SQL connection pool", lastOpenFailure); } current = dataSource; } @@ -384,6 +390,7 @@ private void rejectConflictingPostgreSqlTlsOptions(String extra) { // --- Pool Configuration --- public synchronized boolean open() { + lastOpenFailure = null; try { // If someone only set legacy flag but not dbType explicitly, keep it consistent if (dbType == null) { @@ -438,7 +445,7 @@ public synchronized boolean open() { replaceDataSource(replacement); return true; } catch (Exception e) { - e.printStackTrace(); + lastOpenFailure = e; return false; } } diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java index 57753d4..ddd21ac 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java @@ -11,6 +11,11 @@ public abstract class MySQL { private ConnectionManager connectionManager; private int maxConnections; private ExecutorService threadPool; + private volatile Exception lastConnectFailure; + + public Exception getLastConnectFailure() { + return lastConnectFailure; + } /** * Create a new MySQL object with a default of 10 maximum threads. @@ -31,6 +36,12 @@ public MySQL(int maxConnections) { } public boolean connect(MysqlConfig config) { + return connect(config, true); + } + + /** Connect without subclass diagnostics when called by a superclass constructor. */ + public boolean connect(MysqlConfig config, boolean emitDiagnostics) { + lastConnectFailure = null; // (Re)size thread pool to match MaxConnections from config this.maxConnections = Math.max(1, config.getMaxThreads()); if (threadPool != null) { @@ -89,18 +100,16 @@ public boolean connect(MysqlConfig config) { } boolean ok = connectionManager.open(); - - // Optional debug output - if (config.isDebug()) { - if (ok) { - debug("DB connected. type=" + connectionManager.getDbType() + " host=" + config.getHostName() + " db=" - + config.getDatabase() + " maxPool=" + maxConnections); - } else { - debug("DB connection failed. type=" + connectionManager.getDbType() - + " Check host/port/credentials and timeouts."); - } + if (!ok) { + lastConnectFailure = connectionManager.getLastOpenFailure(); + // Legacy callers can retry through the connection manager. The owner + // disconnects explicitly when construction is aborted instead. } + if (ok && emitDiagnostics && config.isDebug()) { + debug("DB connected. type=" + connectionManager.getDbType() + " host=" + config.getHostName() + " db=" + + config.getDatabase() + " maxPool=" + maxConnections); + } return ok; } diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java new file mode 100644 index 0000000..1a4bf55 --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java @@ -0,0 +1,146 @@ +package com.bencodez.simpleapi.sql.mysql; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.sql.Connection; +import java.sql.Driver; +import java.sql.DriverPropertyInfo; +import java.sql.SQLException; +import java.util.Properties; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; + +class AbstractSqlTableInitializationTest { + @Test + void deferredExistingPoolConstructorDoesNotNeedSubclassState() { + MySQL mysql = mock(MySQL.class); + ConnectionManager manager = mock(ConnectionManager.class); + when(mysql.getConnectionManager()).thenReturn(manager); + when(manager.getDbType()).thenReturn(DbType.MYSQL); + DeferredTable table = new DeferredTable("votes", mysql); + assertFalse(table.initialized); + table.initialized = true; + assertEquals("votes", table.getTableName()); + } + @Test + void authenticationFailureStopsConstructorBeforeSubclassHooksOrQueries() { + RejectingTable.hookCalls = 0; + RejectingDriver.connectionAttempts = 0; + MysqlConfig config = config(); + config.setDriver(RejectingDriver.class.getName()); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new RejectingTable("votes", config, true)); + assertTrue(hasCause(failure, AUTH_FAILURE)); + assertEquals(0, RejectingTable.hookCalls); + assertTrue(RejectingDriver.connectionAttempts > 0); + } + + @Test void databaseSelectionFailureClosesPoolWithoutCallingSubclassHooks() throws Exception { + SQLException denied = new SQLException("controlled database selection rejected", "42000"); + RejectingTable.hookCalls = 0; + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); + Connection connection = mock(Connection.class); + when(manager.getConnectionChecked()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenThrow(denied); + })) { + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new RejectingTable("votes", config(), true)); + assertSame(denied, failure.getCause()); + assertEquals(0, RejectingTable.hookCalls); + verify(managers.constructed().get(0)).close(); + } + } + + @Test void databaseSelectionEscapesQuotedDatabaseNames() throws Exception { + MysqlConfig config = config(); + config.setDatabase("vote`data"); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); + Connection connection = mock(Connection.class); + when(manager.getConnectionChecked()).thenReturn(connection); + when(connection.prepareStatement("USE `vote``data`;")) + .thenReturn(mock(java.sql.PreparedStatement.class)); + })) { + AbstractSqlTable table = new AbstractSqlTable("votes", config, true, true) { + public String getPrimaryKeyColumn() { return "id"; } + public String buildCreateTableSql(DbType type) { return "CREATE TABLE votes (id TEXT)"; } + public void logSevere(String message) { fail("early hook"); } + public void logInfo(String message) { fail("early hook"); } + public void debug(Throwable failure) { fail("early hook"); } + public void debug(String message) { fail("early hook"); } + }; + verify(managers.constructed().get(0).getConnectionChecked()) + .prepareStatement("USE `vote``data`;"); + table.close(); + } + } + + private static MysqlConfig config() { + MysqlConfig config = new MysqlConfig(); + config.setHostName("127.0.0.1"); + config.setPort(3306); + config.setUser("user"); + config.setPass("password"); + config.setDatabase("database"); + config.setMinimumIdle(0); + config.setMaxThreads(1); + config.setConnectionTimeout(1000); + return config; + } + + private static boolean hasCause(Throwable failure, String message) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (message.equals(current.getMessage())) return true; + } + return false; + } + + private static final String AUTH_FAILURE = "controlled authentication rejected"; + + private static final class DeferredTable extends AbstractSqlTable { + boolean initialized; + DeferredTable(String name, MySQL mysql) { super(name, mysql, true); } + public String getPrimaryKeyColumn() { return "id"; } + public String buildCreateTableSql(DbType type) { return "CREATE TABLE votes (id TEXT)"; } + public void logSevere(String message) { if (!initialized) fail("early hook"); } + public void logInfo(String message) { if (!initialized) fail("early hook"); } + public void debug(Throwable failure) { if (!initialized) fail("early hook"); } + public void debug(String message) { if (!initialized) fail("early hook"); } + } + + private static final class RejectingTable extends AbstractSqlTable { + static int hookCalls; + + RejectingTable(String name, MysqlConfig config, boolean debug) { + super(name, config, debug); + } + + public String getPrimaryKeyColumn() { return "id"; } + public String buildCreateTableSql(DbType type) { hookCalls++; return "CREATE TABLE votes (id TEXT)"; } + public void logSevere(String message) { hookCalls++; } + public void logInfo(String message) { hookCalls++; } + public void debug(Throwable failure) { hookCalls++; } + public void debug(String message) { hookCalls++; } + } + + public static final class RejectingDriver implements Driver { + static int connectionAttempts; + + public Connection connect(String url, Properties properties) throws SQLException { + connectionAttempts++; + throw new SQLException(AUTH_FAILURE); + } + public boolean acceptsURL(String url) { return url != null && url.startsWith("jdbc:"); } + public DriverPropertyInfo[] getPropertyInfo(String url, Properties properties) { return new DriverPropertyInfo[0]; } + public int getMajorVersion() { return 1; } + public int getMinorVersion() { return 0; } + public boolean jdbcCompliant() { return false; } + public Logger getParentLogger() { return Logger.getGlobal(); } + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java index 1ead066..27f3fb1 100644 --- a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java @@ -5,6 +5,7 @@ import java.sql.*; import java.util.Properties; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.Test; @@ -78,11 +79,74 @@ private void verifyBorrowRecovery(DbType type) throws Exception { @Test void initializationFailurePreservesLegacyNullAndCheckedFailure() throws Exception { ConnectionManager manager = manager(DbType.MYSQL); manager.setMysqlDriver("missing.probe.Driver"); - assertThrows(SQLException.class, manager::getConnectionChecked); + SQLException failure = assertThrows(SQLException.class, manager::getConnectionChecked); + assertNotNull(failure.getCause()); + assertInstanceOf(ClassNotFoundException.class, failure.getCause()); + assertSame(failure.getCause(), manager.getLastOpenFailure()); assertNull(manager.getConnection()); assertNull(manager.getDataSource()); } + @Test void failedLegacyConnectRetainsCauseAndAllowsAsyncRecovery() throws Exception { + MySQL mysql = new MySQL(1) { + public void debug(SQLException e) { fail("failure diagnostics must not call subclass hooks"); } + public void debug(String message) { fail("failure diagnostics must not call subclass hooks"); } + public void severe(String message) { fail("failure diagnostics must not call subclass hooks"); } + }; + var config = new com.bencodez.simpleapi.sql.mysql.config.MysqlConfig(); + config.setHostName("localhost"); + config.setPort(3306); + config.setUser("test"); + config.setPass("secret"); + config.setDatabase("test"); + config.setDriver("missing.probe.Driver"); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(false); + when(manager.getLastOpenFailure()).thenReturn(new SQLException("controlled outage")); + Connection recovered = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(manager.getConnectionChecked()).thenReturn(recovered); + when(recovered.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenReturn(1); + when(statement.executeBatch()).thenReturn(new int[] { 2 }); + })) { + assertFalse(mysql.connect(config)); + assertNotNull(mysql.getLastConnectFailure()); + assertFalse(mysql.getThreadPool().isShutdown()); + var update = new CompletableFuture(); + var batch = new CompletableFuture(); + var query = new com.bencodez.simpleapi.sql.mysql.queries.Query(mysql, "UPDATE votes SET total=1"); + query.executeUpdateAsync((result, failure) -> { + if (failure != null) update.completeExceptionally(failure); else update.complete(result); + }); + query.executeBatchAsync((result, failure) -> { + if (failure != null) batch.completeExceptionally(failure); else batch.complete(result); + }); + assertEquals(1, update.get(2, TimeUnit.SECONDS)); + assertArrayEquals(new int[] { 2 }, batch.get(2, TimeUnit.SECONDS)); + verify(managers.constructed().get(0), times(2)).getConnectionChecked(); + } finally { + mysql.disconnect(); + assertTrue(mysql.getThreadPool().awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test void externalConnectRetainsSuccessfulDebugCallback() { + AtomicInteger callbacks = new AtomicInteger(); + MySQL mysql = new MySQL(1) { + public void debug(SQLException e) { } + public void debug(String message) { callbacks.incrementAndGet(); } + public void severe(String message) { } + }; + var config = new com.bencodez.simpleapi.sql.mysql.config.MysqlConfig(); + config.setDriver(TestDriver.class.getName()); + config.setDebug(true); + try (var pools = mockConstruction(HikariDataSource.class)) { + assertTrue(mysql.connect(config)); + } + assertEquals(1, callbacks.get()); + } + @Test void closedPoolIsReopenedAndConnectionIsReturned() throws Exception { ConnectionManager manager = manager(DbType.MYSQL); HikariDataSource closed = mock(HikariDataSource.class); diff --git a/docs/sql-initialization-lifecycle.md b/docs/sql-initialization-lifecycle.md new file mode 100644 index 0000000..700e704 --- /dev/null +++ b/docs/sql-initialization-lifecycle.md @@ -0,0 +1,19 @@ +# SQL initialization lifecycle + +SimpleAPI SQL startup is fail-closed. `ConnectionManager.open()` retains the +original initialization exception without printing credentials, JDBC URLs, or +stack traces. `MySQL.connect()` exposes that cause through +`getLastConnectFailure()`. Legacy `connect(config)` callers retain their query +executor so a transient failure can recover through the existing connection-manager +retry path. Owners must call `disconnect()` when abandoning the SQL wrapper. + +`AbstractSqlTable` connection and database-selection setup do not invoke +subclass logging callbacks. A failed pool or MySQL/MariaDB database-selection +(`USE`) query throws an `IllegalStateException` carrying the original cause; +table initialization and subsequent SQL queries are skipped, and the aborted table +constructor disconnects its owned pool and query executor. Deferred table +construction keeps its existing contract: callers may assign subclass state +and invoke `init()` after construction. The legacy non-deferred constructors +still initialize the table using subclass schema hooks; subclasses whose hooks +need instance fields should use the existing deferred constructor. Successful +external `MySQL.connect(config)` calls retain their debug diagnostics. From 9ac328646068503cfb0c409f45b6dd8491344d77 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:25:47 -0600 Subject: [PATCH 2/3] Preserve SQL failure diagnostics and unchecked exception identity --- .../simpleapi/sql/mysql/AbstractSqlTable.java | 2 ++ .../bencodez/simpleapi/sql/mysql/MySQL.java | 11 ++++++--- .../AbstractSqlTableInitializationTest.java | 23 ++++++++++++++++++ .../sql/mysql/ConnectionManagerPoolTest.java | 24 +++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java index 06e7cda..4a359d4 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java @@ -148,6 +148,8 @@ public void severe(String string) { } catch (SQLException | RuntimeException | Error failure) { try { mysql.disconnect(); } catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + if (failure instanceof RuntimeException unchecked) { throw unchecked; } + if (failure instanceof Error fatal) { throw fatal; } throw new IllegalStateException("Failed to initialize SQL database selection", failure); } } diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java index ddd21ac..3ad1d1f 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java @@ -106,9 +106,14 @@ public boolean connect(MysqlConfig config, boolean emitDiagnostics) { // disconnects explicitly when construction is aborted instead. } - if (ok && emitDiagnostics && config.isDebug()) { - debug("DB connected. type=" + connectionManager.getDbType() + " host=" + config.getHostName() + " db=" - + config.getDatabase() + " maxPool=" + maxConnections); + if (emitDiagnostics && config.isDebug()) { + if (ok) { + debug("DB connected. type=" + connectionManager.getDbType() + " host=" + config.getHostName() + " db=" + + config.getDatabase() + " maxPool=" + maxConnections); + } else { + debug("DB connection failed. type=" + connectionManager.getDbType() + + " Check host/port/credentials and timeouts."); + } } return ok; } diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java index 1a4bf55..3859700 100644 --- a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/AbstractSqlTableInitializationTest.java @@ -57,6 +57,29 @@ void authenticationFailureStopsConstructorBeforeSubclassHooksOrQueries() { } } + @Test void uncheckedDatabaseSelectionFailuresKeepIdentityAndCleanupFailures() throws Exception { + for (Throwable failure : new Throwable[] {new IllegalArgumentException("driver rejected selection"), + new LinkageError("driver linkage failure")}) { + RuntimeException cleanupFailure = new IllegalStateException("controlled cleanup failure"); + RejectingTable.hookCalls = 0; + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); + Connection connection = mock(Connection.class); + when(manager.getConnectionChecked()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenThrow(failure); + doThrow(cleanupFailure).when(manager).close(); + })) { + Throwable thrown = assertThrows(failure.getClass(), + () -> new RejectingTable("votes", config(), true)); + assertSame(failure, thrown); + assertArrayEquals(new Throwable[] {cleanupFailure}, thrown.getSuppressed()); + assertEquals(0, RejectingTable.hookCalls); + verify(managers.constructed().get(0)).close(); + verify(managers.constructed().get(0).getConnectionChecked()).close(); + } + } + } + @Test void databaseSelectionEscapesQuotedDatabaseNames() throws Exception { MysqlConfig config = config(); config.setDatabase("vote`data"); diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java index 27f3fb1..747933e 100644 --- a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/ConnectionManagerPoolTest.java @@ -147,6 +147,30 @@ public void severe(String message) { } assertEquals(1, callbacks.get()); } + @Test void externalConnectRetainsFailureDebugWithoutCredentials() { + java.util.List messages = new java.util.ArrayList<>(); + MySQL mysql = new MySQL(1) { + public void debug(SQLException e) { fail("unexpected exception callback"); } + public void debug(String message) { messages.add(message); } + public void severe(String message) { fail("unexpected severe callback"); } + }; + var config = new com.bencodez.simpleapi.sql.mysql.config.MysqlConfig(); + config.setDebug(true); + config.setPass("private-password"); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(false); + when(manager.getDbType()).thenReturn(DbType.MYSQL); + })) { + assertFalse(mysql.connect(config)); + assertEquals(java.util.List.of("DB connection failed. type=MYSQL Check host/port/credentials and timeouts."), messages); + messages.clear(); + assertFalse(mysql.connect(config, false)); + assertTrue(messages.isEmpty()); + } finally { + mysql.disconnect(); + } + } + @Test void closedPoolIsReopenedAndConnectionIsReturned() throws Exception { ConnectionManager manager = manager(DbType.MYSQL); HikariDataSource closed = mock(HikariDataSource.class); From cd3f65023d683955ae1227c2f962010dc332d098 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:17:12 -0600 Subject: [PATCH 3/3] Retire SQL workers even when connection-pool cleanup fails --- .../bencodez/simpleapi/sql/mysql/MySQL.java | 17 ++++-- .../sql/mysql/MySQLDisconnectTest.java | 59 +++++++++++++++++++ docs/sql-initialization-lifecycle.md | 6 ++ 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MySQLDisconnectTest.java diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java index 3ad1d1f..1ad652f 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/MySQL.java @@ -126,12 +126,17 @@ public boolean connect(MysqlConfig config, boolean emitDiagnostics) { * Close all connections and the data source. */ public void disconnect() { - if (connectionManager != null) { - connectionManager.close(); - } - if (threadPool != null) { - threadPool.shutdown(); - } + try { + if (connectionManager != null) connectionManager.close(); + } catch (RuntimeException | Error failure) { + // Pool cleanup and worker retirement are independent responsibilities. + try { if (threadPool != null) threadPool.shutdown(); } + catch (RuntimeException | Error cleanup) { + if (cleanup != failure) failure.addSuppressed(cleanup); + } + throw failure; + } + if (threadPool != null) threadPool.shutdown(); } /** diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MySQLDisconnectTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MySQLDisconnectTest.java new file mode 100644 index 0000000..e03a4b8 --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MySQLDisconnectTest.java @@ -0,0 +1,59 @@ +package com.bencodez.simpleapi.sql.mysql; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.sql.SQLException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +class MySQLDisconnectTest { + static Stream failures() { + return Stream.of( + pair(null, null), + pair(new IllegalStateException("pool close"), null), + pair(new LinkageError("pool close"), null), + pair(null, new IllegalStateException("worker shutdown")), + pair(null, new LinkageError("worker shutdown")), + pair(new IllegalStateException("pool close"), new LinkageError("worker shutdown")), + pair(new LinkageError("pool close"), new IllegalStateException("worker shutdown"))); + } + private static Throwable[] pair(Throwable pool, Throwable worker) { return new Throwable[] {pool, worker}; } + @Test void everyCleanupIsAttemptedAndPrimaryFailureIsPreserved() throws Exception { + for (Throwable[] failures : failures().toList()) checkCleanup(failures[0], failures[1]); + } + private void checkCleanup(Throwable poolFailure, Throwable workerFailure) throws Exception { + var worker = mock(ExecutorService.class); var manager = mock(ConnectionManager.class); + if (poolFailure != null) doThrow(poolFailure).when(manager).close(); + if (workerFailure != null) doThrow(workerFailure).when(worker).shutdown(); + try (var executors = mockStatic(Executors.class)) { + executors.when(() -> Executors.newFixedThreadPool(anyInt())).thenReturn(worker); + var mysql = mysql(); + var field = MySQL.class.getDeclaredField("connectionManager"); field.setAccessible(true); field.set(mysql, manager); + Throwable primary = poolFailure == null ? workerFailure : poolFailure; + if (primary == null) assertDoesNotThrow(mysql::disconnect); + else { + Throwable thrown = assertThrows(primary.getClass(), mysql::disconnect); + assertSame(primary, thrown); + if (poolFailure != null && workerFailure != null) + assertArrayEquals(new Throwable[] { workerFailure }, thrown.getSuppressed()); + else assertEquals(0, thrown.getSuppressed().length); + } + verify(manager).close(); verify(worker).shutdown(); + } + } + @Test void disconnectBeforeConnectAndRepeatedDisconnectAreSafe() { + var mysql = mysql(); + assertDoesNotThrow(mysql::disconnect); assertDoesNotThrow(mysql::disconnect); + assertTrue(mysql.getThreadPool().isShutdown()); + } + private static MySQL mysql() { + return new MySQL(1) { + public void debug(SQLException failure) { fail("Unexpected diagnostics"); } + public void debug(String message) { fail("Unexpected diagnostics"); } + public void severe(String message) { fail("Unexpected diagnostics"); } + }; + } +} diff --git a/docs/sql-initialization-lifecycle.md b/docs/sql-initialization-lifecycle.md index 700e704..9e421f5 100644 --- a/docs/sql-initialization-lifecycle.md +++ b/docs/sql-initialization-lifecycle.md @@ -17,3 +17,9 @@ and invoke `init()` after construction. The legacy non-deferred constructors still initialize the table using subclass schema hooks; subclasses whose hooks need instance fields should use the existing deferred constructor. Successful external `MySQL.connect(config)` calls retain their debug diagnostics. + +Disconnect attempts both connection-pool closure and worker shutdown. A pool-close +failure remains the primary exception; any independent worker-shutdown failure is +attached as suppressed evidence. Worker retirement is still attempted after failed +pool cleanup, including during aborted table construction. Normal shutdown remains +graceful and does not interrupt accepted worker tasks.