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
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ public UserDataManager(AdvancedCorePlugin plugin) {
}, 60 * 3, 60 * 60, TimeUnit.SECONDS);
}

public void addKey(UserDataKey userDataKey) {
public synchronized void addKey(UserDataKey userDataKey) {
keys.add(userDataKey);
if (userDataKey instanceof UserDataKeyInt) intColumns.add(userDataKey.getKey());
else if (userDataKey instanceof UserDataKeyBoolean) booleanColumns.add(userDataKey.getKey());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@

import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.advancedcore.api.user.UserStorage;
import com.bencodez.advancedcore.api.user.usercache.UserDataManager;
import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey;
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable;
import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
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.SqlUserBackend;
import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackendFactory;
Expand All @@ -34,7 +37,9 @@ public final class BukkitSqlUserBackend implements SqlUserBackend {
private final MySQL mysql;
private final UserTable table;
private final Object sqliteOperations = new Object();
private final Object mysqlSchemaOperations = new Object();
private final AtomicBoolean open = new AtomicBoolean(true);
private volatile String reconciledMysqlSchemaSignature = "";

public BukkitSqlUserBackend(AdvancedCorePlugin plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
Expand Down Expand Up @@ -151,8 +156,10 @@ private void write(UserStorage storage, UUID uuid, String key, DataValue value)
Objects.requireNonNull(value, "value");
requireOpen();
requireStorage(storage);
if (storage == UserStorage.MYSQL) mysql().update(uuid.toString(), key, value);
else synchronized (sqliteOperations) { table().update(primary(uuid), new ArrayList<>(List.of(new Column(key, value)))); }
if (storage == UserStorage.MYSQL) {
reconcileMysqlSchema(registeredSchema(), sqlLogger());
mysql().update(uuid.toString(), key, value);
} else synchronized (sqliteOperations) { table().update(primary(uuid), new ArrayList<>(List.of(new Column(key, value)))); }
}

private void writeValues(UserStorage storage, UUID uuid, HashMap<String, DataValue> values) {
Expand Down Expand Up @@ -204,7 +211,7 @@ private static boolean containsPlayerName(java.util.Map<String, DataValue> value

private <T> T withSqlUser(UserStorage storage, UUID uuid, java.util.Map<String, DataValue> updates,
java.util.function.Function<SqlUserStorage, T> work) {
SqlUserSchema registered = SqlUserSchema.fromKeys(plugin.getUserManager().getDataManager().getKeys());
SqlUserSchema registered = registeredSchema();
SqlUserSchema.Builder builder = SqlUserSchema.builder();
for (SqlUserSchema.ColumnDefinition column : registered.columns()) {
if (!"uuid".equalsIgnoreCase(column.name())) builder.column(column.name(), column.sqlType(), column.dataType());
Expand All @@ -218,13 +225,9 @@ private <T> T withSqlUser(UserStorage storage, UUID uuid, java.util.Map<String,
dynamic.add(new Column(entry.getKey(), type));
}
SqlUserSchema schema = builder.build();
SqlBackendLogger logger = new SqlBackendLogger() {
@Override public void info(String message) { plugin.getLogger().info(message); }
@Override public void warn(String message, Throwable error) {
plugin.getLogger().warning(message + (error == null ? "" : ": " + error.getMessage()));
}
};
SqlBackendLogger logger = sqlLogger();
if (storage == UserStorage.MYSQL) {
reconcileMysqlSchema(registered, logger);
for (Column column : dynamic) mysql().checkColumn(column.getName(), column.getDataType());
var manager = mysql().getMysql().getConnectionManager();
return work.apply(SqlUserBackendFactory.existingUser(storage, uuid, mysql().getTableName(), schema,
Expand All @@ -243,6 +246,43 @@ private <T> T withSqlUser(UserStorage storage, UUID uuid, java.util.Map<String,
}
}

private SqlUserSchema registeredSchema() {
UserDataManager dataManager = plugin.getUserManager().getDataManager();
List<UserDataKey> keys;
synchronized (dataManager) {
keys = new ArrayList<>(dataManager.getKeys());
}
return SqlUserSchema.fromKeys(keys);
}

private SqlBackendLogger sqlLogger() {
return new SqlBackendLogger() {
@Override public void info(String message) { plugin.getLogger().info(message); }
@Override public void warn(String message, Throwable error) {
plugin.getLogger().warning(message + (error == null ? "" : ": " + error.getMessage()));
}
};
}

private void reconcileMysqlSchema(SqlUserSchema schema, SqlBackendLogger logger) {
String signature = schemaSignature(schema);
if (signature.equals(reconciledMysqlSchemaSignature)) return;
synchronized (mysqlSchemaOperations) {
if (signature.equals(reconciledMysqlSchemaSignature)) return;
MysqlUserBackend.reconcileExistingTable(mysql().getTableName(), mysql().getMysql(), schema, logger);
reconciledMysqlSchemaSignature = signature;
}
}

private static String schemaSignature(SqlUserSchema schema) {
StringBuilder signature = new StringBuilder();
for (SqlUserSchema.ColumnDefinition column : schema.columns()) {
signature.append(column.name().toLowerCase(java.util.Locale.ROOT)).append('\u0000')
.append(column.sqlType()).append('\u0000').append(column.dataType().name()).append('\u0001');
}
return signature.toString();
}

private Column primary(UUID uuid) { return new Column("uuid", new DataValueString(uuid.toString())); }
private MySQL mysql() { return requireMysql(); }
private UserTable table() { return requireTable(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,25 @@ private <T> T withOperation(Supplier<T> operation) {
} finally { operations.readLock().unlock(); }
}

/**
* Reconcile a platform-owned MySQL/MariaDB/PostgreSQL user table without
* taking ownership of its connection pool.
*/
public static void reconcileExistingTable(String tableName,
com.bencodez.simpleapi.sql.mysql.MySQL mysql, SqlUserSchema schema, SqlBackendLogger logger) {
Objects.requireNonNull(tableName, "tableName");
Objects.requireNonNull(mysql, "mysql");
Objects.requireNonNull(schema, "schema");
HeadlessUserTable table = new HeadlessUserTable(tableName, mysql, schema,
logger == null ? SqlBackendLogger.NO_OP : logger);
ensureRegisteredColumns(table, schema);
}

private void ensureRegisteredColumns() {
ensureRegisteredColumns(table, schema);
}

private static void ensureRegisteredColumns(HeadlessUserTable table, SqlUserSchema schema) {
table.ensureUuidType();
table.ensureUuidUnique();
for (SqlUserSchema.ColumnDefinition column : schema.columns()) if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column);
Expand All @@ -178,6 +196,18 @@ private static final class HeadlessUserTable extends AbstractSqlTable {
}
}

/** Borrow an existing pool; reconciliation must never close or replace it. */
HeadlessUserTable(String tableName, com.bencodez.simpleapi.sql.mysql.MySQL mysql,
SqlUserSchema schema, SqlBackendLogger logger) {
super(tableName, mysql, true);
this.schema = schema;
this.logger = logger;
// Reconciliation needs table DDL and live schema inspection only. Calling
// init() here would also load the borrowed table's complete primary-key
// cache, needlessly enumerating every user during a schema check.
ensureTable();
}

@Override public String getPrimaryKeyColumn() { return SqlUserSchema.UUID_COLUMN; }
@Override public String buildCreateTableSql(DbType dbType) {
StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS ").append(quote(tableName)).append(" (");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package com.bencodez.advancedcore.core.user.storage.sql;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

import org.junit.jupiter.api.Test;

import com.bencodez.simpleapi.sql.DataType;
import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
import com.bencodez.simpleapi.sql.mysql.DbType;

class MysqlBorrowedSchemaReconciliationTest {
@Test
void proxyFirstUuidOnlyTableGetsVotingPluginColumnsWithRegisteredDefinitions() throws Exception {
Fixture fixture = new Fixture();
SqlUserSchema schema = votingPluginSchema();

MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, schema, SqlBackendLogger.NO_OP);

assertTrue(fixture.sql.contains(
"CREATE TABLE IF NOT EXISTS `VotingPlugin_Users` (`uuid` VARCHAR(37), `LastVotes` TEXT, `VoteRemindersMap` LONGTEXT, PRIMARY KEY (`uuid`));"));
assertTrue(fixture.sql.contains(
"ALTER TABLE `VotingPlugin_Users` ADD COLUMN `LastVotes` TEXT;"));
assertTrue(fixture.sql.contains(
"ALTER TABLE `VotingPlugin_Users` ADD COLUMN `VoteRemindersMap` LONGTEXT;"));
assertEquals(List.of("uuid", "LastVotes", "VoteRemindersMap"), fixture.existing);
assertFalse(fixture.sql.stream().anyMatch(sql ->
sql.startsWith("SELECT `uuid` FROM `VotingPlugin_Users`")),
"schema-only reconciliation must not enumerate every user row");

long alters = fixture.sql.stream().filter(sql -> sql.startsWith("ALTER TABLE")).count();
MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, schema, SqlBackendLogger.NO_OP);
assertEquals(alters, fixture.sql.stream().filter(sql -> sql.startsWith("ALTER TABLE")).count(),
"restarting against an already-correct schema must be idempotent");

fixture.assertBorrowedPoolOpen();
fixture.assertJdbcClosed();
}

@Test
void partiallyExistingRegisteredSchemaAddsOnlyTheMissingColumn() throws Exception {
Fixture fixture = new Fixture();
fixture.existing.add("LastVotes");

MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql,
votingPluginSchema(), SqlBackendLogger.NO_OP);

assertFalse(fixture.sql.contains(
"ALTER TABLE `VotingPlugin_Users` ADD COLUMN `LastVotes` TEXT;"));
assertTrue(fixture.sql.contains(
"ALTER TABLE `VotingPlugin_Users` ADD COLUMN `VoteRemindersMap` LONGTEXT;"));
assertEquals(List.of("uuid", "LastVotes", "VoteRemindersMap"), fixture.existing);
fixture.assertBorrowedPoolOpen();
fixture.assertJdbcClosed();
}

@Test
void duplicateColumnRaceIsAcceptedOnlyAfterPhysicalRecheck() throws Exception {
Fixture fixture = new Fixture();
fixture.addFailure = new SQLException("duplicate column", "42S21", 1060);
fixture.columnAppearsOnFailure = true;

assertDoesNotThrow(() -> MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql,
SqlUserSchema.builder().column("VoteRemindersMap", "LONGTEXT", DataType.STRING).build(),
SqlBackendLogger.NO_OP));
assertTrue(fixture.existing.contains("VoteRemindersMap"));
fixture.assertBorrowedPoolOpen();
fixture.assertJdbcClosed();
}

@Test
void duplicateColumnResponseWithoutPhysicalColumnStillFails() throws Exception {
Fixture fixture = new Fixture();
fixture.addFailure = new SQLException("duplicate column", "42S21", 1060);

IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql,
SqlUserSchema.builder().column("VoteRemindersMap", "LONGTEXT", DataType.STRING).build(),
SqlBackendLogger.NO_OP));

assertSame(fixture.addFailure, failure.getCause());
fixture.assertBorrowedPoolOpen();
fixture.assertJdbcClosed();
}

@Test
void unrelatedDdlFailureRemainsFatalEvenIfColumnAppears() throws Exception {
Fixture fixture = new Fixture();
fixture.addFailure = new SQLException("permission denied", "42000", 1142);
fixture.columnAppearsOnFailure = true;

IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql,
SqlUserSchema.builder().column("VoteRemindersMap", "LONGTEXT", DataType.STRING).build(),
SqlBackendLogger.NO_OP));

assertSame(fixture.addFailure, failure.getCause());
fixture.assertBorrowedPoolOpen();
fixture.assertJdbcClosed();
}

private static SqlUserSchema votingPluginSchema() {
return SqlUserSchema.builder()
.column("LastVotes", "TEXT", DataType.STRING)
.column("VoteRemindersMap", "LONGTEXT", DataType.STRING)
.build();
}

private static final class Fixture {
final com.bencodez.simpleapi.sql.mysql.MySQL mysql =
mock(com.bencodez.simpleapi.sql.mysql.MySQL.class);
final ConnectionManager manager = mock(ConnectionManager.class);
final List<String> existing = new ArrayList<>(List.of("uuid"));
final List<String> sql = new ArrayList<>();
final List<Connection> connections = new ArrayList<>();
final List<PreparedStatement> statements = new ArrayList<>();
final List<ResultSet> results = new ArrayList<>();
SQLException addFailure;
boolean columnAppearsOnFailure;

Fixture() throws SQLException {
when(mysql.getConnectionManager()).thenReturn(manager);
when(manager.getDbType()).thenReturn(DbType.MARIADB);
when(manager.getConnection()).thenAnswer(ignored -> connection());
}

private Connection connection() throws SQLException {
Connection connection = mock(Connection.class);
connections.add(connection);
when(connection.prepareStatement(anyString())).thenAnswer(call -> {
String query = call.getArgument(0, String.class);
sql.add(query);
PreparedStatement statement = mock(PreparedStatement.class);
statements.add(statement);
when(statement.executeUpdate()).thenAnswer(ignored -> {
if (query.contains(" ADD COLUMN ")) {
String column = addedColumn(query);
if (addFailure != null) {
if (columnAppearsOnFailure && existing.stream().noneMatch(column::equalsIgnoreCase)) {
existing.add(column);
}
throw addFailure;
}
if (existing.stream().noneMatch(column::equalsIgnoreCase)) existing.add(column);
}
return 0;
});
when(statement.executeQuery()).thenAnswer(ignored -> resultFor(query));
return statement;
});
return connection;
}

private ResultSet resultFor(String query) throws SQLException {
ResultSet result = mock(ResultSet.class);
results.add(result);
if (query.toLowerCase(Locale.ROOT).startsWith("select data_type,")) {
when(result.next()).thenReturn(true, false);
when(result.getString(1)).thenReturn("varchar");
when(result.getString("DATA_TYPE")).thenReturn("varchar");
when(result.getObject("CHARACTER_MAXIMUM_LENGTH")).thenReturn(37L);
when(result.getString("COLUMN_DEFAULT")).thenReturn(null);
} else if (query.endsWith("WHERE 1=0")) {
ResultSetMetaData metadata = mock(ResultSetMetaData.class);
when(result.getMetaData()).thenReturn(metadata);
when(metadata.getColumnCount()).thenAnswer(ignored -> existing.size());
for (int i = 0; i < existing.size(); i++) {
int column = i + 1;
when(metadata.getColumnName(column)).thenAnswer(ignored -> existing.get(column - 1));
when(metadata.getColumnType(column)).thenReturn(Types.VARCHAR);
}
}
return result;
}

private String addedColumn(String query) {
String marker = " ADD COLUMN `";
int start = query.indexOf(marker);
if (start < 0) throw new AssertionError("Missing ADD COLUMN marker: " + query);
start += marker.length();
int end = query.indexOf('`', start);
if (end < 0) throw new AssertionError("Missing closing identifier quote: " + query);
return query.substring(start, end);
}

void assertBorrowedPoolOpen() {
verify(mysql, never()).disconnect();
verify(manager, never()).close();
}

void assertJdbcClosed() throws SQLException {
for (Connection connection : connections) verify(connection, atLeastOnce()).close();
for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close();
for (ResultSet result : results) verify(result, atLeastOnce()).close();
}
}
}
Loading
Loading