Fix MySQL/MariaDB round-stats SQL (GREATEST vs MAX) and throttle DB failure logging
Build / build (push) Successful in 1m14s

This commit is contained in:
Michael Burgess
2026-08-07 09:37:34 -04:00
parent 3a87afd3c8
commit d1d61c5d68
2 changed files with 38 additions and 4 deletions
+6
View File
@@ -226,6 +226,12 @@ Common values:
- `sslMode=VERIFY_IDENTITY` — TLS mandatory and the server certificate is validated - `sslMode=VERIFY_IDENTITY` — TLS mandatory and the server certificate is validated
against a trust store (production-grade; requires a properly signed/trusted cert). against a trust store (production-grade; requires a properly signed/trusted cert).
MySQL and MariaDB are both supported through the same `mysql` driver/config. If a stats
query ever fails (bad credentials, dropped connection, etc.), BlockParty logs the full error
once for diagnosis and then throttles repeats of that same failure to once every 5 minutes —
it never spams a stack trace on every round, and gameplay is unaffected either way since all
stats I/O is async and best-effort.
The MySQL JDBC driver (`mysql-connector-j`) is bundled in the plugin jar, so no extra The MySQL JDBC driver (`mysql-connector-j`) is bundled in the plugin jar, so no extra
download is required. The stats table is created automatically on startup if it doesn't download is required. The stats table is created automatically on startup if it doesn't
exist. All queries run asynchronously off the main thread regardless of storage type. exist. All queries run asynchronously off the main thread regardless of storage type.
@@ -10,7 +10,10 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level; import java.util.logging.Level;
/** SQLite- or MySQL-backed statistics storage, selected via storage.type in config.yml. /** SQLite- or MySQL-backed statistics storage, selected via storage.type in config.yml.
@@ -18,17 +21,39 @@ import java.util.logging.Level;
* them off the main thread (see StatsManager). */ * them off the main thread (see StatsManager). */
public class StatsDatabase { public class StatsDatabase {
private static final long ERROR_LOG_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5);
private final Plugin plugin; private final Plugin plugin;
private final StorageSettings settings; private final StorageSettings settings;
private final String table; private final String table;
private Connection connection; private Connection connection;
/** operation name -> last time we actually logged a failure for it, so a persistently
* broken database (bad SQL, dropped connection, etc.) logs once immediately for
* diagnosis and then at most once every {@link #ERROR_LOG_INTERVAL_MS} after that,
* instead of spamming a full stack trace on every round/query. */
private final Map<String, Long> lastLoggedAt = new ConcurrentHashMap<>();
public StatsDatabase(Plugin plugin, StorageSettings settings) { public StatsDatabase(Plugin plugin, StorageSettings settings) {
this.plugin = plugin; this.plugin = plugin;
this.settings = settings; this.settings = settings;
this.table = settings.isMysql() ? settings.tablePrefix() + "player_stats" : "player_stats"; this.table = settings.isMysql() ? settings.tablePrefix() + "player_stats" : "player_stats";
} }
private void logFailure(String operation, UUID uuid, SQLException ex) {
long now = System.currentTimeMillis();
Long last = lastLoggedAt.get(operation);
if (last == null) {
plugin.getLogger().log(Level.WARNING, "Failed to " + operation + " for " + uuid
+ " (further repeats of this error are throttled to once per 5 minutes)", ex);
lastLoggedAt.put(operation, now);
} else if (now - last >= ERROR_LOG_INTERVAL_MS) {
plugin.getLogger().warning("Still failing to " + operation + ": "
+ ex.getClass().getSimpleName() + ": " + ex.getMessage());
lastLoggedAt.put(operation, now);
}
}
public synchronized void connect() { public synchronized void connect() {
try { try {
if (settings.isMysql()) { if (settings.isMysql()) {
@@ -119,7 +144,7 @@ public class StatsDatabase {
} }
} }
} catch (SQLException ex) { } catch (SQLException ex) {
plugin.getLogger().log(Level.WARNING, "Failed to read stats for " + uuid, ex); logFailure("read stats", uuid, ex);
} }
return PlayerStats.empty(uuid); return PlayerStats.empty(uuid);
} }
@@ -142,14 +167,17 @@ public class StatsDatabase {
} }
try { try {
ensureRow(uuid.toString()); ensureRow(uuid.toString());
// MySQL/MariaDB's MAX() is aggregate-only; the two-argument "greatest of these
// values" form used by SQLite must be GREATEST() on that dialect instead.
String greatestFn = settings.isMysql() ? "GREATEST" : "MAX";
try (PreparedStatement ps = connection.prepareStatement( try (PreparedStatement ps = connection.prepareStatement(
"UPDATE " + table + " SET best_round = MAX(best_round, ?), total_rounds_survived = total_rounds_survived + 1 WHERE uuid=?")) { "UPDATE " + table + " SET best_round = " + greatestFn + "(best_round, ?), total_rounds_survived = total_rounds_survived + 1 WHERE uuid=?")) {
ps.setInt(1, round); ps.setInt(1, round);
ps.setString(2, uuid.toString()); ps.setString(2, uuid.toString());
ps.executeUpdate(); ps.executeUpdate();
} }
} catch (SQLException ex) { } catch (SQLException ex) {
plugin.getLogger().log(Level.WARNING, "Failed to update round stats for " + uuid, ex); logFailure("update round stats", uuid, ex);
} }
} }
@@ -165,7 +193,7 @@ public class StatsDatabase {
ps.executeUpdate(); ps.executeUpdate();
} }
} catch (SQLException ex) { } catch (SQLException ex) {
plugin.getLogger().log(Level.WARNING, "Failed to update stats for " + uuid, ex); logFailure("update stats (" + setClause + ")", uuid, ex);
} }
} }
} }