diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index 67e96d3b91..0a3e8209fc 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -238,7 +238,8 @@ com.bencodez simpleapi - 1.0.2-SNAPSHOT + + 1.0.2-20261011.161601-40 compile diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java index dfda1db2b5..9e7a880b7c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java @@ -49,13 +49,19 @@ public MySQL(AdvancedCorePlugin plugin, String tableName, ConfigurationSection s super(tableName, new MysqlConfigSpigot(section), plugin.getOptions().getDebug().isDebug(), true); this.plugin = plugin; - init(); - - loadData(); - plugin.debug("UseBatchUpdates: " + isUseBatchUpdates()); - - // old behavior: ensure uuid type is best-case for db - alterColumnType("uuid", bestUuidType()); + // Superclass connection setup must not invoke our hooks before plugin is assigned. + // All subclass-dependent initialization belongs here, with ownership of cleanup. + try { + init(); + loadData(); + plugin.debug("UseBatchUpdates: " + isUseBatchUpdates()); + // Preserve the existing UUID schema reconciliation. + alterColumnType("uuid", bestUuidType()); + } catch (RuntimeException | Error failure) { + try { mysql.disconnect(); } + catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + throw failure; + } } // ------------------------- diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/globaldata/GlobalMySQL.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/globaldata/GlobalMySQL.java index 0093e3f0df..f59598ca92 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/globaldata/GlobalMySQL.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/globaldata/GlobalMySQL.java @@ -106,18 +106,28 @@ public void debug(String msg) { } }; - if (!mysql.connect(config)) { - warning("Failed to connect to database (type=" + config.getDbType() + ")"); + if (!mysql.connect(config, false)) { + IllegalStateException failure = new IllegalStateException("Failed to initialize global SQL connection pool", + mysql.getLastConnectFailure()); + try { mysql.disconnect(); } + catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + throw failure; } // MySQL has a "USE db" command; Postgres does NOT. if (config.getDbType() != DbType.POSTGRESQL) { - try { - new Query(mysql, "USE " + quoteIdent(config.getDbType(), config.getDatabase()) + ";").executeUpdate(); - } catch (SQLException e) { - logSevere("Failed to send use database query: " + config.getDatabase() + " Error: " + e.getMessage() - + ", DB might still work"); - debugEx(e); + try (Connection connection = mysql.getConnectionManager().getConnectionChecked(); + PreparedStatement statement = connection.prepareStatement( + "USE " + quoteIdent(config.getDbType(), config.getDatabase()) + ";")) { + statement.executeUpdate(); + } catch (SQLException | RuntimeException | Error failure) { + try { mysql.disconnect(); } + catch (RuntimeException | Error cleanupFailure) { + if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); + } + if (failure instanceof RuntimeException runtime) throw runtime; + if (failure instanceof Error error) throw error; + throw new IllegalStateException("Failed to initialize global SQL database selection", failure); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQLInitializationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQLInitializationTest.java new file mode 100644 index 0000000000..2b2dba8cad --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQLInitializationTest.java @@ -0,0 +1,183 @@ +package com.bencodez.advancedcore.api.user.userstorage.mysql; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.core.user.storage.sql.MysqlUserBackend; +import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema; +import com.bencodez.simpleapi.sql.mysql.ConnectionManager; + +class MySQLInitializationTest { + @Test void rejectedAuthenticationAbortsBeforeUninitializedPluginHooksAndReleasesWorkers() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getOptions().getDebug().isDebug()).thenReturn(true); + SQLException authentication = new SQLException("Access denied for test account", "28000", 1045); + ArrayList workers = new ArrayList<>(); + try (var executors = mockStatic(Executors.class); + var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(false); + when(manager.getLastOpenFailure()).thenReturn(authentication); + })) { + executors.when(() -> Executors.newFixedThreadPool(anyInt())).thenAnswer(call -> { + ExecutorService worker = mock(ExecutorService.class); workers.add(worker); return worker; + }); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new MySQL(plugin, "Users", config())); + assertSame(authentication, failure.getCause()); + verify(plugin, never()).getLogger(); + verify(plugin, never()).getUserManager(); + assertEquals(2, workers.size()); + workers.forEach(worker -> verify(worker).shutdown()); + verify(managers.constructed().get(0)).close(); + verify(managers.constructed().get(0), never()).getConnectionChecked(); + } + } + + @Test void sharedSqlBackendAlsoRejectsRatherThanPublishingAnUnusableProvider() throws Exception { + SQLException authentication = new SQLException("controlled authentication rejection", "28000"); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(false); + when(manager.getLastOpenFailure()).thenReturn(authentication); + })) { + var settings = new com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot(config()); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new MysqlUserBackend("Users", settings, SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP)); + assertSame(authentication, failure.getCause()); + verify(managers.constructed().get(0)).close(); + verify(managers.constructed().get(0), never()).getConnectionChecked(); + } + } + + @Test void subclassSetupFailureClosesAlreadyOpenedPoolAndPreservesPrimaryFailure() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + RuntimeException setup = new IllegalStateException("controlled schema setup failure"); + when(plugin.getUserManager()).thenThrow(setup); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); + var connection = mock(java.sql.Connection.class); + when(manager.getConnectionChecked()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(mock(java.sql.PreparedStatement.class)); + })) { + assertSame(setup, assertThrows(IllegalStateException.class, () -> new MySQL(plugin, "Users", config()))); + verify(managers.constructed().get(0)).close(); + } + } + + @Test void globalSqlConstructorDoesNotCallUninitializedDiagnosticsAfterRejection() throws Exception { + SQLException authentication = new SQLException("controlled global authentication rejection", "28000"); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(false); + when(manager.getLastOpenFailure()).thenReturn(authentication); + })) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> + new com.bencodez.advancedcore.bungeeapi.globaldata.GlobalMySQL("Global", + new com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot(config())) { + public void debugEx(Exception e) { fail("constructor callback"); } + public void debugLog(String text) { fail("constructor callback"); } + public void info(String text) { fail("constructor callback"); } + public void logSevere(String text) { fail("constructor callback"); } + public void warning(String text) { fail("constructor callback"); } + }); + assertSame(authentication, failure.getCause()); + verify(managers.constructed().get(0)).close(); + verify(managers.constructed().get(0), never()).getConnectionChecked(); + } + } + + @Test void successfulConstructionInitializesCachesAfterPluginAssignmentAndClosesNormally() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getOptions().getDebug().isDebug()).thenReturn(true); + when(plugin.getUserManager().getDataManager().getKeys()).thenReturn(new ArrayList<>()); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); + when(manager.getDbType()).thenReturn(com.bencodez.simpleapi.sql.mysql.DbType.MYSQL); + var connection = mock(java.sql.Connection.class); + var statement = mock(java.sql.PreparedStatement.class); + when(manager.getConnectionChecked()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenAnswer(call -> mock(java.sql.ResultSet.class)); + var metadata = mock(java.sql.DatabaseMetaData.class); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.supportsBatchUpdates()).thenReturn(true); + })) { + MySQL table = new MySQL(plugin, "Users", config()); + try { + assertTrue(table.isUseBatchUpdates()); + assertEquals("Users", table.getTableName()); + assertNotNull(table.getUuids()); + verify(plugin, atLeastOnce()).getUserManager(); + verify(managers.constructed().get(0), atLeastOnce()).getConnectionChecked(); + } finally { table.close(); } + assertTrue(table.getMysql().getThreadPool().isShutdown()); + verify(managers.constructed().get(0)).close(); + } + } + + @Test void globalConnectionAcquisitionPreservesUncheckedFailures() throws Exception { + assertGlobalSelectionFailures(0); + } + + @Test void globalStatementPreparationPreservesUncheckedFailures() throws Exception { + assertGlobalSelectionFailures(1); + } + + @Test void globalStatementExecutionPreservesUncheckedFailures() throws Exception { + assertGlobalSelectionFailures(2); + } + + private void assertGlobalSelectionFailures(int phase) throws Exception { + for (Throwable original : new Throwable[] { new IllegalArgumentException("driver failure"), + new LinkageError("driver linkage failure"), new SQLException("database selection failure") }) { + RuntimeException cleanup = new IllegalStateException("pool cleanup failure"); + try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); + when(manager.getDbType()).thenReturn(com.bencodez.simpleapi.sql.mysql.DbType.MYSQL); + var connection = mock(java.sql.Connection.class); + var statement = mock(java.sql.PreparedStatement.class); + if (phase == 0) when(manager.getConnectionChecked()).thenThrow(original); + else { + when(manager.getConnectionChecked()).thenReturn(connection); + if (phase == 1) when(connection.prepareStatement(anyString())).thenThrow(original); + else { + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenThrow(original); + } + } + doThrow(cleanup).when(manager).close(); + })) { + Throwable actual = assertThrows(Throwable.class, () -> + new com.bencodez.advancedcore.bungeeapi.globaldata.GlobalMySQL("Global", + new com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot(config())) { + public void debugEx(Exception e) { fail("constructor callback"); } + public void debugLog(String text) { fail("constructor callback"); } + public void info(String text) { fail("constructor callback"); } + public void logSevere(String text) { fail("constructor callback"); } + public void warning(String text) { fail("constructor callback"); } + }); + if (original instanceof SQLException) { + assertInstanceOf(IllegalStateException.class, actual); + assertSame(original, actual.getCause()); + } else assertSame(original, actual); + assertArrayEquals(new Throwable[] { cleanup }, original.getSuppressed()); + verify(managers.constructed().get(0)).close(); + } + } + } + + private static YamlConfiguration config() { + YamlConfiguration result = new YamlConfiguration(); + result.set("Host", "localhost"); result.set("Port", 3306); + result.set("Database", "test"); result.set("Username", "test"); + result.set("Password", "fixture"); result.set("MaxConnections", 1); + result.set("Debug", true); + return result; + } +} diff --git a/docs/mysql-startup-failure.md b/docs/mysql-startup-failure.md new file mode 100644 index 0000000000..40046ccaae --- /dev/null +++ b/docs/mysql-startup-failure.md @@ -0,0 +1,36 @@ +# MySQL startup failures + +User storage remains fail-closed: a rejected MySQL connection aborts startup. It +never selects SQLite as a fallback. Correct the configured database credentials +or server permissions before restarting; existing tables and user data remain +unchanged. + +This fix requires the accompanying SimpleAPI SQL initialization fix. Its table +constructor retains the original connection failure and avoids invoking subclass +logging hooks before AdvancedCore assigns its plugin reference. AdvancedCore +continues to initialize its table, caches and UUID schema after that assignment. +If subclass initialization subsequently throws, it closes the owned SQL pool and +workers and preserves the primary failure, attaching cleanup failures as suppressed +exceptions. The platform-neutral MySQL backend already uses the same deferred +initialization boundary and owns equivalent failure cleanup. + +No configuration or schema migration is required. Pool initialization remains the +existing startup operation; this change does not move startup to a background +thread or change successful storage or reward processing. + +The proxy/global-data SQL constructor had the same rejected-connection continuation +pattern. It now aborts before invoking its subclass diagnostics or issuing queries, +and closes the pool if initial database selection fails. + + +## Building against the published dependency + +The POM pins published SimpleAPI `1.0.2-20261011.161601-40`, which includes the +merged SQL lifecycle fix from PR #111 (`fdbf26eec885ec3f1c1dbd937a243a0acfaa0a5e`). +A normal clean checkout can run `mvn -B -f AdvancedCore/pom.xml clean package` +without checking out or installing SimpleAPI manually. CI uses the same dependency +resolution path. The immutable timestamp avoids older rolling snapshot cache entries. + +Normal server installation uses the shaded AdvancedCore JAR; no separate runtime +SimpleAPI installation, configuration change, schema migration or storage fallback +is added. The dependency must be available from the configured Maven repository.