package us.tss3.blockparty.persistence; import org.bukkit.plugin.Plugin; import us.tss3.blockparty.config.ConfigManager.StorageSettings; import java.io.File; 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.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.logging.Level; /** SQLite- or MySQL-backed statistics storage, selected via storage.type in config.yml. * All public methods are safe to call from any thread; callers are expected to invoke * them off the main thread (see StatsManager). */ public class StatsDatabase { private static final long ERROR_LOG_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5); private final Plugin plugin; private final StorageSettings settings; private final String table; 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 lastLoggedAt = new ConcurrentHashMap<>(); public StatsDatabase(Plugin plugin, StorageSettings settings) { this.plugin = plugin; this.settings = settings; 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() { try { if (settings.isMysql()) { connectMysql(); } else { connectSqlite(); } try (Statement st = connection.createStatement()) { st.execute(createTableStatement()); } } catch (Exception ex) { plugin.getLogger().log(Level.SEVERE, "Failed to initialize " + (settings.isMysql() ? "MySQL" : "SQLite") + " stats database", ex); } } private void connectSqlite() throws Exception { File dbFile = new File(plugin.getDataFolder(), "blockparty.db"); Class.forName("org.sqlite.JDBC"); connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath()); } private void connectMysql() throws Exception { Class.forName("com.mysql.cj.jdbc.Driver"); String extra = settings.connectionParameters() == null ? "" : settings.connectionParameters().trim(); StringBuilder url = new StringBuilder("jdbc:mysql://") .append(settings.host()).append(':').append(settings.port()).append('/').append(settings.database()) .append("?autoReconnect=true&characterEncoding=utf8"); if (!extra.isEmpty()) { url.append('&').append(extra); } connection = DriverManager.getConnection(url.toString(), settings.username(), settings.password()); } private String createTableStatement() { if (settings.isMysql()) { return "CREATE TABLE IF NOT EXISTS " + table + " (" + "uuid VARCHAR(36) PRIMARY KEY," + "games_played INT NOT NULL DEFAULT 0," + "wins INT NOT NULL DEFAULT 0," + "eliminations INT NOT NULL DEFAULT 0," + "best_round INT NOT NULL DEFAULT 0," + "total_rounds_survived INT NOT NULL DEFAULT 0" + ")"; } return "CREATE TABLE IF NOT EXISTS " + table + " (" + "uuid TEXT PRIMARY KEY," + "games_played INTEGER NOT NULL DEFAULT 0," + "wins INTEGER NOT NULL DEFAULT 0," + "eliminations INTEGER NOT NULL DEFAULT 0," + "best_round INTEGER NOT NULL DEFAULT 0," + "total_rounds_survived INTEGER NOT NULL DEFAULT 0" + ")"; } public synchronized void close() { try { if (connection != null && !connection.isClosed()) { connection.close(); } } catch (SQLException ignored) { } } private synchronized void ensureRow(String uuid) throws SQLException { String sql = settings.isMysql() ? "INSERT IGNORE INTO " + table + "(uuid) VALUES (?)" : "INSERT OR IGNORE INTO " + table + "(uuid) VALUES (?)"; try (PreparedStatement ps = connection.prepareStatement(sql)) { ps.setString(1, uuid); ps.executeUpdate(); } } public synchronized PlayerStats getStats(UUID uuid) { if (connection == null) { return PlayerStats.empty(uuid); } try { ensureRow(uuid.toString()); try (PreparedStatement ps = connection.prepareStatement( "SELECT games_played, wins, eliminations, best_round, total_rounds_survived FROM " + table + " WHERE uuid=?")) { ps.setString(1, uuid.toString()); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return new PlayerStats(uuid, rs.getInt(1), rs.getInt(2), rs.getInt(3), rs.getInt(4), rs.getInt(5)); } } } } catch (SQLException ex) { logFailure("read stats", uuid, ex); } return PlayerStats.empty(uuid); } public synchronized void incrementGamesPlayed(UUID uuid) { update(uuid, "games_played = games_played + 1"); } public synchronized void incrementWins(UUID uuid) { update(uuid, "wins = wins + 1"); } public synchronized void incrementEliminations(UUID uuid) { update(uuid, "eliminations = eliminations + 1"); } public synchronized void recordRoundReached(UUID uuid, int round) { if (connection == null) { return; } try { 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( "UPDATE " + table + " SET best_round = " + greatestFn + "(best_round, ?), total_rounds_survived = total_rounds_survived + 1 WHERE uuid=?")) { ps.setInt(1, round); ps.setString(2, uuid.toString()); ps.executeUpdate(); } } catch (SQLException ex) { logFailure("update round stats", uuid, ex); } } private void update(UUID uuid, String setClause) { if (connection == null) { return; } try { ensureRow(uuid.toString()); try (PreparedStatement ps = connection.prepareStatement( "UPDATE " + table + " SET " + setClause + " WHERE uuid=?")) { ps.setString(1, uuid.toString()); ps.executeUpdate(); } } catch (SQLException ex) { logFailure("update stats (" + setClause + ")", uuid, ex); } } }