diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f24e4164..1cc3295d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,12 @@ updates: directory: "/" schedule: interval: "monthly" + ignore: + # The SQLite JDBC is provided by Spigot and, for now, Paper. + # To ensure it is still present even if Paper stops providing it, + # we need to declare it as a library. + # To prevent needlessly downloading (and loading) a second copy, + # we want to ensure that we are using the provided version. + - dependency-name: "org.xerial:sqlite-jdbc" + # Spigot and Spigot API updates are manual. + - dependency-name: "org.spigotmc:*" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ddfe1660..75d4afb3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ folia-scheduler-wrapper = "v0.0.3" errorprone-core = "2.45.0" errorprone-gradle = "4.2.0" slf4j = "2.0.17" +sqlite-jdbc = "3.49.1.0" [libraries] spigotapi = { module = "org.spigotmc:spigot-api", version.ref = "spigotapi" } @@ -19,6 +20,7 @@ folia-scheduler-wrapper = { module = "com.github.NahuLD.folia-scheduler-wrapper: errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone-core" } errorprone-gradle = { module = "net.ltgt.gradle:gradle-errorprone-plugin", version.ref = "errorprone-gradle" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } [plugins] paperweight = { id = "io.papermc.paperweight.userdev", version.ref = "paperweight" } diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index d00a60a6..637ea8df 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -22,10 +22,14 @@ dependencies { implementation(project(":openinvadapterspigot", configuration = SpigotReobf.ARTIFACT_CONFIG)) implementation(libs.planarwrappers) implementation(libs.folia.scheduler.wrapper) + compileOnly(libs.sqlite.jdbc) } tasks.processResources { - expand("version" to version) + expand( + "version" to version, + "sqlite" to libs.sqlite.jdbc.get().version + ) } tasks.jar { diff --git a/plugin/src/main/java/com/lishid/openinv/OpenInv.java b/plugin/src/main/java/com/lishid/openinv/OpenInv.java index e56d9d22..2175de17 100644 --- a/plugin/src/main/java/com/lishid/openinv/OpenInv.java +++ b/plugin/src/main/java/com/lishid/openinv/OpenInv.java @@ -56,6 +56,7 @@ import java.util.Locale; import java.util.UUID; import java.util.function.Consumer; +import java.util.logging.Level; /** * The main class for OpenInv. @@ -69,6 +70,10 @@ public class OpenInv extends FoliaWrappedJavaPlugin implements IOpenInv { private PlayerLoader playerLoader; private boolean isSpigot = false; + public PlayerLoader getPlayerLoader() { + return playerLoader; + } + @Override public void reloadConfig() { super.reloadConfig(); @@ -96,6 +101,11 @@ public boolean onCommand( @Override public void onDisable() { inventoryManager.evictAll(); + try { + playerLoader.getProfileStore().shutdown(); + } catch (Exception e) { + getLogger().log(Level.WARNING, "Failed to shut down profile store correctly", e); + } } @Override diff --git a/plugin/src/main/java/com/lishid/openinv/util/PlayerLoader.java b/plugin/src/main/java/com/lishid/openinv/util/PlayerLoader.java index 0930c589..ba651b7b 100644 --- a/plugin/src/main/java/com/lishid/openinv/util/PlayerLoader.java +++ b/plugin/src/main/java/com/lishid/openinv/util/PlayerLoader.java @@ -5,13 +5,16 @@ import com.google.errorprone.annotations.Keep; import com.lishid.openinv.OpenInv; import com.lishid.openinv.util.config.Config; +import com.lishid.openinv.util.profile.OfflinePlayerProfileStore; +import com.lishid.openinv.util.profile.Profile; +import com.lishid.openinv.util.profile.ProfileStore; +import com.lishid.openinv.util.profile.sqlite.SqliteProfileStore; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.profile.PlayerProfile; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -33,7 +36,8 @@ public class PlayerLoader implements Listener { private final @NotNull InventoryManager inventoryManager; private final @NotNull InternalAccessor internalAccessor; private final @NotNull Logger logger; - private final @NotNull Cache lookupCache; + private final @NotNull Cache lookupCache; + private @NotNull ProfileStore profileStore; public PlayerLoader( @NotNull OpenInv plugin, @@ -46,10 +50,30 @@ public PlayerLoader( this.config = config; this.inventoryManager = inventoryManager; this.internalAccessor = internalAccessor; + try { + SqliteProfileStore sqliteStore = new SqliteProfileStore(plugin); + sqliteStore.setup(); + sqliteStore.tryImport(); + this.profileStore = sqliteStore; + } catch (Exception e) { + this.profileStore = new OfflinePlayerProfileStore(logger); + } this.logger = logger; this.lookupCache = CacheBuilder.newBuilder().maximumSize(20).build(); } + public @NotNull ProfileStore getProfileStore() { + return profileStore; + } + + public void setProfileStore(@NotNull ProfileStore profileStore) { + plugin.getLogger().log( + Level.INFO, + () -> "Setting profile store implementation to " + profileStore.getClass().getName() + ); + this.profileStore = profileStore; + } + /** * Load a {@link Player} from an {@link OfflinePlayer}. If the user has not played before or the default world for * the server is not loaded, this will return {@code null}. @@ -59,22 +83,20 @@ public PlayerLoader( * @throws IllegalStateException if the server version is unsupported */ public @Nullable Player load(@NotNull OfflinePlayer offline) { - UUID key = offline.getUniqueId(); - Player player = offline.getPlayer(); if (player != null) { return player; } - player = inventoryManager.getLoadedPlayer(key); - if (player != null) { - return player; - } - if (config.isOfflineDisabled() || !internalAccessor.isSupported()) { return null; } + player = inventoryManager.getLoadedPlayer(offline.getUniqueId()); + if (player != null) { + return player; + } + if (Bukkit.isPrimaryThread()) { return internalAccessor.getPlayerDataManager().loadPlayer(offline); } @@ -93,13 +115,6 @@ public PlayerLoader( } public @Nullable OfflinePlayer matchExact(@NotNull String name) { - // Warn if called on the main thread - if we resort to searching offline players, this may take several seconds. - if (Bukkit.getServer().isPrimaryThread()) { - logger.warning("Call to PlayerSearchCache#matchPlayer made on the main thread!"); - logger.warning("This can cause the server to hang, potentially severely."); - logger.log(Level.WARNING, "Current stack trace", new Throwable("Current stack trace")); - } - OfflinePlayer player; try { @@ -123,9 +138,9 @@ public PlayerLoader( } // Cached offline match. - PlayerProfile cachedResult = lookupCache.getIfPresent(name); - if (cachedResult != null && cachedResult.getUniqueId() != null) { - player = Bukkit.getOfflinePlayer(cachedResult.getUniqueId()); + Profile cachedResult = lookupCache.getIfPresent(name); + if (cachedResult != null) { + player = Bukkit.getOfflinePlayer(cachedResult.id()); // Ensure player is an existing player. if (player.hasPlayedBefore() || player.isOnline()) { return player; @@ -135,10 +150,15 @@ public PlayerLoader( } // Exact offline match second - ensure offline access works when matchable users are online. - player = Bukkit.getServer().getOfflinePlayer(name); + Profile profile = profileStore.getProfileExact(name); + if (profile == null) { + return null; + } + + player = Bukkit.getOfflinePlayer(profile.id()); if (player.hasPlayedBefore()) { - lookupCache.put(name, player.getPlayerProfile()); + lookupCache.put(name, profile); return player; } @@ -160,40 +180,35 @@ public PlayerLoader( } // Finally, inexact offline match. - float bestMatch = 0; - for (OfflinePlayer offline : Bukkit.getServer().getOfflinePlayers()) { - if (offline.getName() == null) { - // Loaded by UUID only, name has never been looked up. - continue; - } - - float currentMatch = StringMetric.compareJaroWinkler(name, offline.getName()); + Profile profile = getProfileStore().getProfileInexact(name); - if (currentMatch == 1.0F) { - return offline; - } - - if (currentMatch > bestMatch) { - bestMatch = currentMatch; - player = offline; - } + if (profile == null) { + // No match found. + return null; } - if (player != null) { - // If a match was found, store it. - lookupCache.put(name, player.getPlayerProfile()); + // Get associated player and store match. + player = Bukkit.getOfflinePlayer(profile.id()); + if (player.hasPlayedBefore()) { + lookupCache.put(name, profile); return player; } - // No players have ever joined the server. return null; } @Keep @EventHandler + private void onPlayerJoin(@NotNull PlayerJoinEvent event) { + plugin.getScheduler().runTaskLaterAsynchronously(() -> updateMatches(event), 7L); + } + private void updateMatches(@NotNull PlayerJoinEvent event) { + // Update profile store. + profileStore.addProfile(new Profile(event.getPlayer())); + // If player is not new, any cached values are valid. - if (event.getPlayer().hasPlayedBefore()) { + if (event.getPlayer().hasPlayedBefore() || lookupCache.size() == 0) { return; } @@ -201,36 +216,19 @@ private void updateMatches(@NotNull PlayerJoinEvent event) { String name = event.getPlayer().getName(); lookupCache.invalidate(name); - // If the cache is empty, nothing to do. Don't hit scheduler. - if (lookupCache.size() == 0) { - return; + Iterator> iterator = lookupCache.asMap().entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + String oldMatch = entry.getValue().name(); + String lookup = entry.getKey(); + float oldMatchScore = StringMetric.compareJaroWinkler(lookup, oldMatch); + float newMatchScore = StringMetric.compareJaroWinkler(lookup, name); + + // If new match exceeds old match, delete old match. + if (newMatchScore > oldMatchScore) { + iterator.remove(); + } } - - plugin.getScheduler().runTaskLaterAsynchronously( - () -> { - Iterator> iterator = lookupCache.asMap().entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - String oldMatch = entry.getValue().getName(); - - // Shouldn't be possible - all profiles should be complete. - if (oldMatch == null) { - iterator.remove(); - continue; - } - - String lookup = entry.getKey(); - float oldMatchScore = StringMetric.compareJaroWinkler(lookup, oldMatch); - float newMatchScore = StringMetric.compareJaroWinkler(lookup, name); - - // If new match exceeds old match, delete old match. - if (newMatchScore > oldMatchScore) { - iterator.remove(); - } - } - }, - 7L - ); } } diff --git a/plugin/src/main/java/com/lishid/openinv/util/profile/BatchProfileStore.java b/plugin/src/main/java/com/lishid/openinv/util/profile/BatchProfileStore.java new file mode 100644 index 00000000..18cf05a6 --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/profile/BatchProfileStore.java @@ -0,0 +1,78 @@ +package com.lishid.openinv.util.profile; + +import com.github.jikoo.planarwrappers.scheduler.TickTimeUnit; +import me.nahu.scheduler.wrapper.WrappedJavaPlugin; +import me.nahu.scheduler.wrapper.runnable.WrappedRunnable; +import org.jetbrains.annotations.NotNull; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public abstract class BatchProfileStore implements ProfileStore { + + private final Set pending = Collections.synchronizedSet(new HashSet<>()); + private final AtomicReference insertTask = new AtomicReference<>(); + protected final @NotNull WrappedJavaPlugin plugin; + + protected BatchProfileStore(@NotNull WrappedJavaPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void addProfile(@NotNull Profile profile) { + pending.add(profile); + buildBatch(); + } + + private void buildBatch() { + if (insertTask.compareAndSet(null, new WrappedRunnable() { + @Override + public void run() { + pushBatch(); + } + })) { + try { + // Wait 5 seconds to accumulate other player data to reduce scheduler load on larger servers. + insertTask.get().runTaskLaterAsynchronously(plugin, TickTimeUnit.toTicks(5, TimeUnit.SECONDS)); + } catch (IllegalStateException e) { + // If scheduling task fails, server is most likely shutting down. + insertTask.set(null); + } + } + } + + private void pushBatch() { + Set batch = new HashSet<>(pending); + // This is a bit roundabout but removes the risk of data loss. + pending.removeAll(batch); + + // Push current batch. + pushBatch(batch); + + WrappedRunnable running = insertTask.getAndSet(null); + if (running != null) { + running.cancel(); + } + + // If more profiles have been added, build another batch. + if (!pending.isEmpty()) { + buildBatch(); + } + } + + @Override + public void shutdown() { + WrappedRunnable wrappedRunnable = insertTask.get(); + if (wrappedRunnable != null) { + wrappedRunnable.cancel(); + } + pushBatch(); + insertTask.set(null); + } + + protected abstract void pushBatch(@NotNull Set batch); + +} diff --git a/plugin/src/main/java/com/lishid/openinv/util/profile/OfflinePlayerImporter.java b/plugin/src/main/java/com/lishid/openinv/util/profile/OfflinePlayerImporter.java new file mode 100644 index 00000000..da5551b0 --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/profile/OfflinePlayerImporter.java @@ -0,0 +1,48 @@ +package com.lishid.openinv.util.profile; + +import me.nahu.scheduler.wrapper.runnable.WrappedRunnable; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; + +import java.util.HashSet; +import java.util.Set; + +public abstract class OfflinePlayerImporter extends WrappedRunnable { + + private final @NotNull BatchProfileStore profileStore; + private final int batchSize; + + public OfflinePlayerImporter(@NotNull BatchProfileStore profileStore, int batchSize) { + this.profileStore = profileStore; + this.batchSize = batchSize; + } + + @Override + public void run() { + Set batch = new HashSet<>(); + for (OfflinePlayer offline : Bukkit.getOfflinePlayers()) { + String name = offline.getName(); + + if (name == null) { + continue; + } + + batch.add(new Profile(name, offline.getUniqueId())); + + if (batch.size() >= batchSize) { + profileStore.pushBatch(batch); + batch.clear(); + } + } + + if (!batch.isEmpty()) { + profileStore.pushBatch(batch); + } + + onComplete(); + } + + public abstract void onComplete(); + +} diff --git a/plugin/src/main/java/com/lishid/openinv/util/profile/OfflinePlayerProfileStore.java b/plugin/src/main/java/com/lishid/openinv/util/profile/OfflinePlayerProfileStore.java new file mode 100644 index 00000000..6fdb6284 --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/profile/OfflinePlayerProfileStore.java @@ -0,0 +1,86 @@ +package com.lishid.openinv.util.profile; + +import com.lishid.openinv.util.StringMetric; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.logging.Logger; + +public class OfflinePlayerProfileStore implements ProfileStore { + + private final @NotNull Logger logger; + + public OfflinePlayerProfileStore(@NotNull Logger logger) { + this.logger = logger; + } + + @Override + public void addProfile(@NotNull Profile profile) { + // No-op. Server handles profile creation and storage. + } + + @Override + public void setup() { + // No-op. + } + + @Override + public void shutdown() { + // No-op. Nothing to push. + } + + @Override + public void tryImport() { + // No-op. + } + + @Override + public @Nullable Profile getProfileExact(@NotNull String name) { + ProfileStore.warnMainThread(logger); + @SuppressWarnings("deprecation") + OfflinePlayer offline = Bukkit.getOfflinePlayer(name); + + if (!offline.hasPlayedBefore()) { + return null; + } + + String realName = offline.getName(); + if (realName == null) { + realName = name; + } + + return new Profile(realName, offline.getUniqueId()); + } + + @Override + public @Nullable Profile getProfileInexact(@NotNull String search) { + ProfileStore.warnMainThread(logger); + + float bestMatch = 0.0F; + Profile bestProfile = null; + for (OfflinePlayer player : Bukkit.getOfflinePlayers()) { + String name = player.getName(); + if (name == null) { + // Discount UUID-only profiles; Direct UUID lookup should already be done. + return null; + } + + float currentMatch = StringMetric.compareJaroWinkler(name, name); + + if (currentMatch > bestMatch) { + bestMatch = currentMatch; + bestProfile = new Profile(name, player.getUniqueId()); + } + + // A score of 1 is an exact match. Don't bother checking the rest. + if (currentMatch == 1.0F) { + break; + } + } + + return bestProfile; + } + +} diff --git a/plugin/src/main/java/com/lishid/openinv/util/profile/Profile.java b/plugin/src/main/java/com/lishid/openinv/util/profile/Profile.java new file mode 100644 index 00000000..f7665611 --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/profile/Profile.java @@ -0,0 +1,20 @@ +package com.lishid.openinv.util.profile; + +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public record Profile(@NotNull String name, @NotNull UUID id) { + + public Profile(@NotNull Player player) { + this(player.getName(), player.getUniqueId()); + } + + @Override + public int hashCode() { + // As names are the unique key, profiles with the same name should collide. + return name.hashCode(); + } + +} diff --git a/plugin/src/main/java/com/lishid/openinv/util/profile/ProfileStore.java b/plugin/src/main/java/com/lishid/openinv/util/profile/ProfileStore.java new file mode 100644 index 00000000..27331fea --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/profile/ProfileStore.java @@ -0,0 +1,51 @@ +package com.lishid.openinv.util.profile; + +import org.bukkit.Bukkit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public interface ProfileStore { + + void addProfile(@NotNull Profile profile); + + void setup() throws Exception; + + void shutdown() throws Exception; + + void tryImport() throws Exception; + + @Nullable Profile getProfileExact(@NotNull String name); + + @Nullable Profile getProfileInexact(@NotNull String search); + + static void warnMainThread(@NotNull Logger logger) { + if (!Bukkit.getServer().isPrimaryThread()) { + return; + } + + Throwable throwable = new Throwable("Current stack trace"); + StackTraceElement[] stackTrace = throwable.getStackTrace(); + + if (stackTrace.length < 2) { + // Not possible. + return; + } + + StackTraceElement caller = stackTrace[1]; + + logger.warning(() -> + String.format( + "Call to %s#%s made on the main thread!", + caller.getClassName(), + caller.getMethodName() + ) + ); + logger.warning("This can cause the server to hang, potentially severely."); + logger.log(Level.WARNING, "Current stack trace", throwable); + + } + +} diff --git a/plugin/src/main/java/com/lishid/openinv/util/profile/sqlite/JaroWinklerFunction.java b/plugin/src/main/java/com/lishid/openinv/util/profile/sqlite/JaroWinklerFunction.java new file mode 100644 index 00000000..8974d48f --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/profile/sqlite/JaroWinklerFunction.java @@ -0,0 +1,21 @@ +package com.lishid.openinv.util.profile.sqlite; + +import com.lishid.openinv.util.StringMetric; +import org.sqlite.Function; + +import java.sql.SQLException; + +public class JaroWinklerFunction extends Function { + + @Override + protected void xFunc() throws SQLException { + if (args() != 2) { + throw new SQLException("JaroWinkler(str, str) requires 2 arguments but got " + args()); + } + String val1 = value_text(0); + String val2 = value_text(1); + + result(StringMetric.compareJaroWinkler(val1 == null ? "" : val1, val2 == null ? "" : val2)); + } + +} diff --git a/plugin/src/main/java/com/lishid/openinv/util/profile/sqlite/SqliteProfileStore.java b/plugin/src/main/java/com/lishid/openinv/util/profile/sqlite/SqliteProfileStore.java new file mode 100644 index 00000000..52c6b684 --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/profile/sqlite/SqliteProfileStore.java @@ -0,0 +1,257 @@ +package com.lishid.openinv.util.profile.sqlite; + +import com.github.jikoo.planarwrappers.function.ThrowingFunction; +import com.lishid.openinv.util.profile.BatchProfileStore; +import com.lishid.openinv.util.profile.OfflinePlayerImporter; +import com.lishid.openinv.util.profile.Profile; +import me.nahu.scheduler.wrapper.WrappedJavaPlugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.sqlite.Function; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Iterator; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; + +public class SqliteProfileStore extends BatchProfileStore { + + private static final int BATCH_SIZE = 1_000; + private static final int MAX_BATCHES = 20; + + private Connection connection; + + public SqliteProfileStore(@NotNull WrappedJavaPlugin plugin) { + super(plugin); + } + + @Override + public void setup() throws Exception { + // Touch implementation to ensure it is available, then create connection. + Class.forName("org.sqlite.JDBC"); + connection = DriverManager.getConnection("jdbc:sqlite:" + plugin.getDataFolder().getAbsolutePath() + "/profiles.db"); + + try (Statement statement = connection.createStatement()) { + // Create main profile table. + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS profiles( + name TEXT PRIMARY KEY, + uuid_least INTEGER NOT NULL, + uuid_most INTEGER NOT NULL, + last_update TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) WITHOUT ROWID + """ + ); + // Create meta table. + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS openinv_meta( + name TEXT PRIMARY KEY, + value INTEGER NOT NULL + ) + """ + ); + } + + // Add JaroWinkler function to the connection. + Function.create(connection, "JaroWinkler", new JaroWinklerFunction()); + + } + + @Override + public void shutdown() { + super.shutdown(); + try { + if (!connection.getAutoCommit()) { + connection.commit(); + } + connection.close(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Exception closing database: " + e.getMessage(), e); + } + } + + @Override + public void tryImport() throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT value FROM openinv_meta WHERE name = 'imported'" + )) { + ResultSet resultSet = statement.executeQuery(); + if (resultSet.next() && resultSet.getInt("value") == 1) { + return; + } + } + + plugin.getLogger().info("Indexing player names."); + new OfflinePlayerImporter(this, BATCH_SIZE) { + @Override + public void onComplete() { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO openinv_meta(name, value) VALUES ('imported', 1)" + )) { + statement.executeUpdate(); + plugin.getLogger().info("Indexed player names successfully."); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Exception marking import complete: " + e.getMessage(), e); + } + } + }.runTaskAsynchronously(plugin); + } + + @Override + protected void pushBatch(@NotNull Set batch) { + if (batch.isEmpty()) { + return; + } + + int batchSize = Math.min(batch.size(), BATCH_SIZE); + int rem = batch.size() % batchSize; + + Iterator iterator = batch.iterator(); + + try { + boolean autoCommit = connection.getAutoCommit(); + if (autoCommit) { + connection.setAutoCommit(false); + } + + pushBatch(iterator, batch.size(), batchSize); + + if (rem > 0) { + pushBatch(iterator, rem, rem); + } + + connection.commit(); + + if (autoCommit) { + connection.setAutoCommit(true); + } + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Encountered an exception updating profiles", e); + } + + } + + private void pushBatch(Iterator iterator, int iteratorSize, int batchSize) throws SQLException { + try (PreparedStatement upsert = createBulkUpsertProfile(batchSize)) { + for (int batchIndex = 0; batchIndex < iteratorSize / batchSize; ++batchIndex) { + for (int entryIndex = 0; entryIndex < batchSize; ++entryIndex) { + int startIndex = entryIndex * 3; + Profile profile = iterator.next(); + upsert.setString(startIndex + 1, profile.name()); + upsert.setLong(startIndex + 2, profile.id().getLeastSignificantBits()); + upsert.setLong(startIndex + 3, profile.id().getMostSignificantBits()); + } + upsert.addBatch(); + + // If we're at the maximum number of batches allowed, commit. + if ((batchIndex + 1) % MAX_BATCHES == 0) { + upsert.executeBatch(); + connection.commit(); + } + } + upsert.executeBatch(); + } + } + + private @NotNull PreparedStatement createBulkUpsertProfile(int count) throws SQLException { + return connection.prepareStatement( + """ + INSERT INTO profiles(name, uuid_least, uuid_most) + VALUES (?, ?, ?) + """ + ", (?, ?, ?)".repeat(count - 1) + + """ + ON CONFLICT(name) DO UPDATE SET + uuid_least=excluded.uuid_least, + uuid_most=excluded.uuid_most, + last_update=CURRENT_TIMESTAMP + """ + ); + } + + @Override + public @Nullable Profile getProfileExact(@NotNull String name) { + return getProfile( + name, + text -> { + PreparedStatement statement = connection.prepareStatement( + "SELECT name, uuid_least, uuid_most FROM `profiles` WHERE name = ?" + ); + statement.setString(1, text); + return statement; + } + ); + } + + @Override + public @Nullable Profile getProfileInexact(@NotNull String search) { + return getProfile( + search, + text -> { + PreparedStatement statement = connection.prepareStatement( + """ + SELECT name, uuid_least, uuid_most FROM `profiles` + WHERE name LIKE ? + ORDER BY JaroWinkler(?, name) DESC + LIMIT 1 + """ + ); + statement.setString(1, getLikePrefix(text)); + statement.setString(2, text); + return statement; + } + ); + } + + private @NotNull String getLikePrefix(@NotNull String search) { + StringBuilder prefix = new StringBuilder(); + int searchLen = search.length(); + if (searchLen > 0) { + char first = search.charAt(0); + + // Add first character of the search to LIKE prefix. + prefix.append(first); + + // If the first character appears to be Geyser-like, append another. + if (searchLen > 1 && (first == '.' || first == '*')) { + prefix.append(search.charAt(1)); + } + } + + // Add wildcard. + prefix.append('%'); + return prefix.toString(); + } + + private @Nullable Profile getProfile( + @NotNull String text, + @NotNull ThrowingFunction create + ) { + try (PreparedStatement statement = create.apply(text)) { + ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + // Fetch profile data. + String name = resultSet.getString(1); + + // Ignore illegal data. + if (name == null || name.isEmpty()) { + return null; + } + + UUID uuid = new UUID(resultSet.getLong(3), resultSet.getLong(2)); + return new Profile(name, uuid); + } + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Encountered an exception retrieving profile", e); + } + return null; + } + +} diff --git a/plugin/src/main/resources/plugin.yml b/plugin/src/main/resources/plugin.yml index 6ec9d2ef..48c30c31 100644 --- a/plugin/src/main/resources/plugin.yml +++ b/plugin/src/main/resources/plugin.yml @@ -7,6 +7,9 @@ description: Open a player's inventory as a chest and interact with it in real t api-version: "1.13" folia-supported: true +libraries: + - "org.xerial:sqlite-jdbc:${sqlite}" + permissions: openinv: