Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AdvancedCore/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@
<dependency>
<groupId>com.bencodez</groupId>
<artifactId>simpleapi</artifactId>
<version>1.0.2-SNAPSHOT</version>
<!-- Published SQL lifecycle fix; do not resolve an older rolling snapshot. -->
<version>1.0.2-20261011.161601-40</version>
<scope>compile</scope>
<exclusions>
<exclusion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

// -------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unchecked database-selection failures

When a JDBC driver or pool throws a RuntimeException or Error from connection acquisition, statement preparation, or execution, this catch converts it into an IllegalStateException. Before this change those unchecked failures propagated unchanged, and the pinned SimpleAPI implementation's equivalent path explicitly rethrows RuntimeException and Error after cleanup, including coverage for IllegalArgumentException and LinkageError; this public constructor therefore now has incompatible exception semantics and can disguise fatal linkage failures. After disconnecting, rethrow unchecked exceptions and errors unchanged and wrap only SQLException.

AGENTS.md reference: AGENTS.md:L37-L38

Useful? React with 👍 / 👎.

}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ExecutorService> 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;
}
}
36 changes: 36 additions & 0 deletions docs/mysql-startup-failure.md
Original file line number Diff line number Diff line change
@@ -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.
Loading