added shared database access and file based migrations to core
This commit is contained in:
1 parent
d08fd927dc
commit
e3de258124
4 files changed
+289
No files matched your search
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.api.client.RepositoryLoader;
|
|||||||
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
|
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
|
||||||
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
|
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
|
||||||
import eu.mhsl.craftattack.spawn.core.config.Configuration;
|
import eu.mhsl.craftattack.spawn.core.config.Configuration;
|
||||||
|
import eu.mhsl.craftattack.spawn.core.database.Database;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.event.HandlerList;
|
import org.bukkit.event.HandlerList;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
@@ -23,6 +24,7 @@ public final class Main extends JavaPlugin {
|
|||||||
|
|
||||||
private List<Appliance> appliances;
|
private List<Appliance> appliances;
|
||||||
private RepositoryLoader repositoryLoader;
|
private RepositoryLoader repositoryLoader;
|
||||||
|
private Database database;
|
||||||
private Reflections reflections;
|
private Reflections reflections;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -42,6 +44,8 @@ public final class Main extends JavaPlugin {
|
|||||||
Configuration.readConfig();
|
Configuration.readConfig();
|
||||||
List<String> disabledAppliances = Configuration.pluginConfig.getStringList("disabledAppliances");
|
List<String> disabledAppliances = Configuration.pluginConfig.getStringList("disabledAppliances");
|
||||||
|
|
||||||
|
this.database = new Database(Configuration.cfg.getConfigurationSection("database"));
|
||||||
|
|
||||||
Main.logger().info("Initializing reflections...");
|
Main.logger().info("Initializing reflections...");
|
||||||
this.reflections = new Reflections(projectPackage);
|
this.reflections = new Reflections(projectPackage);
|
||||||
|
|
||||||
@@ -94,6 +98,8 @@ public final class Main extends JavaPlugin {
|
|||||||
HandlerList.unregisterAll(this);
|
HandlerList.unregisterAll(this);
|
||||||
Bukkit.getScheduler().cancelTasks(this);
|
Bukkit.getScheduler().cancelTasks(this);
|
||||||
Main.logger().info("Disabled " + this.appliances.size() + " appliances!");
|
Main.logger().info("Disabled " + this.appliances.size() + " appliances!");
|
||||||
|
|
||||||
|
if(this.database != null) this.database.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Appliance restartAppliance(Class<? extends Appliance> applianceClass) {
|
public Appliance restartAppliance(Class<? extends Appliance> applianceClass) {
|
||||||
@@ -138,6 +144,10 @@ public final class Main extends JavaPlugin {
|
|||||||
return this.appliances;
|
return this.appliances;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Database getDatabase() {
|
||||||
|
return this.database;
|
||||||
|
}
|
||||||
|
|
||||||
public RepositoryLoader getRepositoryLoader() {
|
public RepositoryLoader getRepositoryLoader() {
|
||||||
return this.repositoryLoader;
|
return this.repositoryLoader;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.Main;
|
|||||||
import eu.mhsl.craftattack.spawn.core.api.client.Repository;
|
import eu.mhsl.craftattack.spawn.core.api.client.Repository;
|
||||||
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
|
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
|
||||||
import eu.mhsl.craftattack.spawn.core.config.Configuration;
|
import eu.mhsl.craftattack.spawn.core.config.Configuration;
|
||||||
|
import eu.mhsl.craftattack.spawn.core.database.Migrations;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.command.PluginCommand;
|
import org.bukkit.command.PluginCommand;
|
||||||
import org.bukkit.configuration.ConfigurationSection;
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
@@ -107,6 +108,7 @@ public abstract class Appliance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void enableSequence(JavaPlugin plugin) {
|
public void enableSequence(JavaPlugin plugin) {
|
||||||
|
Migrations.apply(Main.instance().getDatabase(), this.migrationsName());
|
||||||
this.onEnable();
|
this.onEnable();
|
||||||
this.initialize(plugin);
|
this.initialize(plugin);
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,14 @@ public abstract class Appliance {
|
|||||||
this.destruct(plugin);
|
this.destruct(plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the folder containing this appliance's SQL migrations: {@code migrations/<name>/} in the resources.
|
||||||
|
*/
|
||||||
|
private String migrationsName() {
|
||||||
|
String name = this.getClass().getSimpleName();
|
||||||
|
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
protected <T extends Appliance> T queryAppliance(Class<T> clazz) {
|
protected <T extends Appliance> T queryAppliance(Class<T> clazz) {
|
||||||
return Main.instance().getAppliance(clazz);
|
return Main.instance().getAppliance(clazz);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package eu.mhsl.craftattack.spawn.core.database;
|
||||||
|
|
||||||
|
import eu.mhsl.craftattack.spawn.core.Main;
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared MariaDB access for all appliances. Configured in the "database" section of the config.
|
||||||
|
* All statements run on a single background thread in submission order, so callers (e.g. login events)
|
||||||
|
* are never blocked. Without configuration the database is unavailable and all calls are ignored.
|
||||||
|
*/
|
||||||
|
public class Database {
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface SqlAction {
|
||||||
|
void run(Connection connection) throws SQLException;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface SqlQuery<T> {
|
||||||
|
T run(Connection connection) throws SQLException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final @Nullable String jdbcUrl;
|
||||||
|
private final String user;
|
||||||
|
private final String password;
|
||||||
|
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
|
1, 1, 0, TimeUnit.MILLISECONDS,
|
||||||
|
new ArrayBlockingQueue<>(1000),
|
||||||
|
runnable -> new Thread(runnable, "Spawn-Database"),
|
||||||
|
(runnable, executor) -> Main.logger().warning("Database queue full, dropping statement")
|
||||||
|
);
|
||||||
|
private @Nullable Connection connection;
|
||||||
|
|
||||||
|
public Database(@Nullable ConfigurationSection config) {
|
||||||
|
String host = config == null ? "" : config.getString("host", "");
|
||||||
|
if(host.isBlank()) {
|
||||||
|
this.jdbcUrl = null;
|
||||||
|
this.user = "";
|
||||||
|
this.password = "";
|
||||||
|
Main.logger().info("No database configured, database features are disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jdbcUrl = String.format(
|
||||||
|
"jdbc:mysql://%s:%d/%s?connectTimeout=3000&socketTimeout=10000&allowMultiQueries=true",
|
||||||
|
host,
|
||||||
|
config.getInt("port", 3306),
|
||||||
|
config.getString("name", "craftattack")
|
||||||
|
);
|
||||||
|
this.user = config.getString("user", "");
|
||||||
|
this.password = config.getString("password", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAvailable() {
|
||||||
|
return this.jdbcUrl != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the action in the background without waiting for it. Errors are logged.
|
||||||
|
*/
|
||||||
|
public void execute(String description, SqlAction action) {
|
||||||
|
if(!this.isAvailable()) return;
|
||||||
|
this.executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
action.run(this.getConnection());
|
||||||
|
} catch(SQLException e) {
|
||||||
|
this.handleError(description, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the query in the background. The future fails if the database is unavailable or the query fails.
|
||||||
|
*/
|
||||||
|
public <T> CompletableFuture<T> query(SqlQuery<T> query) {
|
||||||
|
CompletableFuture<T> future = new CompletableFuture<>();
|
||||||
|
if(!this.isAvailable()) {
|
||||||
|
future.completeExceptionally(new IllegalStateException("No database configured"));
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
this.executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
future.complete(query.run(this.getConnection()));
|
||||||
|
} catch(SQLException e) {
|
||||||
|
this.closeConnection();
|
||||||
|
future.completeExceptionally(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
this.executor.shutdown();
|
||||||
|
try {
|
||||||
|
if(!this.executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||||
|
Main.logger().warning("Database: pending statements were not finished before shutdown");
|
||||||
|
} catch(InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
this.closeConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Connection getConnection() throws SQLException {
|
||||||
|
if(this.connection == null || !this.connection.isValid(2))
|
||||||
|
this.connection = DriverManager.getConnection(this.jdbcUrl, this.user, this.password);
|
||||||
|
return this.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeConnection() {
|
||||||
|
if(this.connection == null) return;
|
||||||
|
try {
|
||||||
|
this.connection.close();
|
||||||
|
} catch(SQLException ignored) {
|
||||||
|
}
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleError(String description, SQLException e) {
|
||||||
|
Main.logger().log(Level.WARNING, String.format("Database: %s failed: %s", description, e.getMessage()));
|
||||||
|
this.closeConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package eu.mhsl.craftattack.spawn.core.database;
|
||||||
|
|
||||||
|
import eu.mhsl.craftattack.spawn.core.Main;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies SQL migration files shipped in the plugin jar.
|
||||||
|
* Files live in {@code migrations/<appliance>/NNN_description.sql} and run in ascending order of NNN.
|
||||||
|
* Applied versions are tracked per appliance in the table {@code schema_migrations}.
|
||||||
|
*/
|
||||||
|
public class Migrations {
|
||||||
|
private static final String DIRECTORY = "migrations/";
|
||||||
|
private static final Pattern FILE_PATTERN = Pattern.compile("^([^/]+)/(\\d+)_[^/]+\\.sql$");
|
||||||
|
private static Map<String, List<Migration>> migrationsByAppliance;
|
||||||
|
|
||||||
|
private record Migration(int version, String fileName, String path) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues all pending migrations of the appliance on the database thread,
|
||||||
|
* so they run before any statement the appliance submits afterwards.
|
||||||
|
*/
|
||||||
|
public static void apply(Database database, String appliance) {
|
||||||
|
List<Migration> migrations = findMigrations().getOrDefault(appliance, List.of());
|
||||||
|
if(migrations.isEmpty()) return;
|
||||||
|
|
||||||
|
if(!database.isAvailable()) {
|
||||||
|
Main.logger().warning(String.format("Appliance '%s' has migrations, but no database is configured", appliance));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
database.execute("migrations of " + appliance, connection -> {
|
||||||
|
createMigrationTable(connection);
|
||||||
|
Set<Integer> applied = appliedVersions(connection, appliance);
|
||||||
|
for(Migration migration : migrations) {
|
||||||
|
if(applied.contains(migration.version())) continue;
|
||||||
|
Main.logger().info(String.format("Applying migration %s/%s", appliance, migration.fileName()));
|
||||||
|
try(Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute(readResource(migration.path()));
|
||||||
|
}
|
||||||
|
markApplied(connection, appliance, migration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static synchronized Map<String, List<Migration>> findMigrations() {
|
||||||
|
if(migrationsByAppliance != null) return migrationsByAppliance;
|
||||||
|
Map<String, List<Migration>> migrations = new HashMap<>();
|
||||||
|
try(JarFile jar = new JarFile(pluginJar())) {
|
||||||
|
jar.stream()
|
||||||
|
.map(JarEntry::getName)
|
||||||
|
.filter(name -> name.startsWith(DIRECTORY))
|
||||||
|
.forEach(path -> {
|
||||||
|
Matcher matcher = FILE_PATTERN.matcher(path.substring(DIRECTORY.length()));
|
||||||
|
if(!matcher.matches()) return;
|
||||||
|
String fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||||
|
migrations.computeIfAbsent(matcher.group(1), appliance -> new ArrayList<>())
|
||||||
|
.add(new Migration(Integer.parseInt(matcher.group(2)), fileName, path));
|
||||||
|
});
|
||||||
|
} catch(IOException e) {
|
||||||
|
throw new IllegalStateException("Could not read migrations from plugin jar", e);
|
||||||
|
}
|
||||||
|
migrations.values().forEach(list -> list.sort(Comparator.comparingInt(Migration::version)));
|
||||||
|
migrationsByAppliance = migrations;
|
||||||
|
return migrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void createMigrationTable(Connection connection) throws SQLException {
|
||||||
|
try(Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
appliance VARCHAR(64) NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
applied_at DATETIME NOT NULL DEFAULT UTC_TIMESTAMP(),
|
||||||
|
PRIMARY KEY (appliance, version)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<Integer> appliedVersions(Connection connection, String appliance) throws SQLException {
|
||||||
|
Set<Integer> versions = new HashSet<>();
|
||||||
|
try(PreparedStatement statement = connection.prepareStatement("SELECT version FROM schema_migrations WHERE appliance = ?")) {
|
||||||
|
statement.setString(1, appliance);
|
||||||
|
try(ResultSet result = statement.executeQuery()) {
|
||||||
|
while(result.next()) versions.add(result.getInt(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void markApplied(Connection connection, String appliance, Migration migration) throws SQLException {
|
||||||
|
try(PreparedStatement statement = connection.prepareStatement("INSERT INTO schema_migrations (appliance, version, file_name) VALUES (?, ?, ?)")) {
|
||||||
|
statement.setString(1, appliance);
|
||||||
|
statement.setInt(2, migration.version());
|
||||||
|
statement.setString(3, migration.fileName());
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readResource(String path) throws SQLException {
|
||||||
|
try(InputStream stream = Main.class.getClassLoader().getResourceAsStream(path)) {
|
||||||
|
if(stream == null) throw new SQLException("Migration file not found: " + path);
|
||||||
|
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
} catch(IOException e) {
|
||||||
|
throw new SQLException("Could not read migration file " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File pluginJar() {
|
||||||
|
try {
|
||||||
|
return new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||||
|
} catch(Exception e) {
|
||||||
|
throw new IllegalStateException("Could not locate plugin jar", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user