diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java index a0ace2f..06d2470 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java @@ -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.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.config.Configuration; +import eu.mhsl.craftattack.spawn.core.database.Database; import org.bukkit.Bukkit; import org.bukkit.event.HandlerList; import org.bukkit.plugin.java.JavaPlugin; @@ -23,6 +24,7 @@ public final class Main extends JavaPlugin { private List appliances; private RepositoryLoader repositoryLoader; + private Database database; private Reflections reflections; @Override @@ -42,6 +44,8 @@ public final class Main extends JavaPlugin { Configuration.readConfig(); List disabledAppliances = Configuration.pluginConfig.getStringList("disabledAppliances"); + this.database = new Database(Configuration.cfg.getConfigurationSection("database")); + Main.logger().info("Initializing reflections..."); this.reflections = new Reflections(projectPackage); @@ -94,6 +98,8 @@ public final class Main extends JavaPlugin { HandlerList.unregisterAll(this); Bukkit.getScheduler().cancelTasks(this); Main.logger().info("Disabled " + this.appliances.size() + " appliances!"); + + if(this.database != null) this.database.close(); } public Appliance restartAppliance(Class applianceClass) { @@ -138,6 +144,10 @@ public final class Main extends JavaPlugin { return this.appliances; } + public Database getDatabase() { + return this.database; + } + public RepositoryLoader getRepositoryLoader() { return this.repositoryLoader; } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java index f59454b..1cc14fd 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java @@ -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.server.HttpServer; import eu.mhsl.craftattack.spawn.core.config.Configuration; +import eu.mhsl.craftattack.spawn.core.database.Migrations; import org.bukkit.Bukkit; import org.bukkit.command.PluginCommand; import org.bukkit.configuration.ConfigurationSection; @@ -107,6 +108,7 @@ public abstract class Appliance { } public void enableSequence(JavaPlugin plugin) { + Migrations.apply(Main.instance().getDatabase(), this.migrationsName()); this.onEnable(); this.initialize(plugin); } @@ -116,6 +118,14 @@ public abstract class Appliance { this.destruct(plugin); } + /** + * Name of the folder containing this appliance's SQL migrations: {@code migrations//} in the resources. + */ + private String migrationsName() { + String name = this.getClass().getSimpleName(); + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + protected T queryAppliance(Class clazz) { return Main.instance().getAppliance(clazz); } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java new file mode 100644 index 0000000..c03aafc --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java @@ -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 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 CompletableFuture query(SqlQuery query) { + CompletableFuture 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(); + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java new file mode 100644 index 0000000..ac01ed3 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java @@ -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//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> 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 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 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> findMigrations() { + if(migrationsByAppliance != null) return migrationsByAppliance; + Map> 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 appliedVersions(Connection connection, String appliance) throws SQLException { + Set 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); + } + } +}