Skip to content

Commit 4bfa083

Browse files
committed
Optimize SQL leaderboard queries and harden async leaderboard cache
Rework the readLeaderboard ORDER BY tiebreak onto skills.user_id and add per-skill leaderboard indexes so the query avoids a join-then-filesort on both MySQL and MariaDB (benchmarked ~350ms -> ~40ms at 300k rows). Indexes are added to the fresh CREATE TABLE schema and, for existing databases, via a new idempotent, best-effort ADD_SKILL_LEADERBOARD_INDEXES upgrade that probes INFORMATION_SCHEMA and continues startup even if an index cannot be created. Make FlatFile leaderboard state thread-safe (ConcurrentHashMap + volatile) for the async refresher, rename McTopPositionPlaceholder to McTopValuePlaceholder to reflect that it returns the value, and add cross-engine integration tests covering fresh-install, migration, and idempotent index behavior.
1 parent 28ae373 commit 4bfa083

11 files changed

Lines changed: 349 additions & 28 deletions

File tree

Changelog.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
Version 2.2.054
22
Fixed party/admin chat allowing player color code tokens without the 'mcmmo.chat.colors' permission
33
Fixed diminished returns resetting in certain situations when players reconnected
4+
Fixed potential thread-safety issues with FlatFile leaderboard data
45
Added PlaceholderAPI leaderboard rank-position placeholders for all non-child skills and overall power level (See notes)
6+
Improved SQL leaderboard query performance on large databases (See notes)
7+
Added automatic per-skill leaderboard indexes for SQL databases (See notes)
58
Added 'General.PlaceholderAPI.Leaderboards.Max_Tracked_Rank' to config.yml
69
Added 'General.PlaceholderAPI.Leaderboards.Refresh_Interval_Seconds' to config.yml
710
(Codebase) Added async leaderboard snapshot caching for rank-position PlaceholderAPI lookups
@@ -40,6 +43,13 @@ Version 2.2.054
4043
Default is 60 seconds.
4144
Placeholder requests are served from cache between refreshes.
4245

46+
-- SQL leaderboard performance --
47+
Leaderboard queries on SQL databases are now much faster on servers with large player tables.
48+
mcMMO will automatically add per-skill indexes to the skills table on startup if they are missing.
49+
On very large databases the first startup after updating may take longer while these indexes are built.
50+
This runs once, is safe to re-run, and mcMMO will continue to start normally even if an index cannot be added.
51+
Fresh SQL installs include these indexes from the start.
52+
4353
Version 2.2.053
4454
!! -- This build has important fixes for anyone using Paper (or forks of Paper), please read the notes carefully.
4555
It is completely safe to update to this version of mcMMO.

src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.Map;
2828
import java.util.TreeSet;
2929
import java.util.UUID;
30+
import java.util.concurrent.ConcurrentHashMap;
3031
import java.util.function.Consumer;
3132
import java.util.function.Function;
3233
import java.util.logging.Level;
@@ -44,11 +45,14 @@ public final class FlatFileDatabaseManager implements DatabaseManager {
4445
private static final Object fileWritingLock = new Object();
4546
private static final String LINE_ENDING = "\r\n";
4647

47-
private final @NotNull EnumMap<PrimarySkillType, List<PlayerStat>> leaderboardMap =
48-
new EnumMap<>(PrimarySkillType.class);
48+
// Concurrent because async refreshes (PlaceholderAPI cache, /mctop, /mcrank) read this while
49+
// updateLeaderboards() rebuilds it. Values are immutable snapshots swapped in atomically.
50+
private final @NotNull Map<PrimarySkillType, List<PlayerStat>> leaderboardMap =
51+
new ConcurrentHashMap<>();
4952

50-
private @NotNull List<PlayerStat> powerLevels = new ArrayList<>();
51-
private long lastUpdate = 0L;
53+
// volatile: published to / read from multiple async threads (see leaderboardMap note).
54+
private volatile @NotNull List<PlayerStat> powerLevels = new ArrayList<>();
55+
private volatile long lastUpdate = 0L;
5256

5357
private final @NotNull String usersFilePath;
5458
private final @NotNull File usersFile;

src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,11 +548,16 @@ private boolean updateHudSettings(Connection connection, int userId, PlayerProfi
548548
? ALL_QUERY_VERSION
549549
: skill.name().toLowerCase(Locale.ENGLISH);
550550

551+
// Tiebreak on skills.user_id (not users.user) so the sort can be resolved on the skills
552+
// table before the join. A tiebreak column from the joined users table forces both MySQL
553+
// and MariaDB to join every qualifying row and then filesort. Paired with a per-skill
554+
// index (see ADD_SKILL_LEADERBOARD_INDEXES), this lets the engine drive the sort from the
555+
// skills table and touch only the LIMIT window. Verified on MySQL 8 and MariaDB 10.11.
551556
String sql = "SELECT " + query + ", `user` FROM " + tablePrefix + "users " +
552557
"JOIN " + tablePrefix + "skills ON (user_id = id) " +
553558
"WHERE " + query + " > 0 " +
554559
"AND NOT `user` = '\\_INVALID\\_OLD\\_USERNAME\\_' " +
555-
"ORDER BY " + query + " DESC, `user` LIMIT ?, ?";
560+
"ORDER BY " + query + " DESC, user_id LIMIT ?, ?";
556561

557562
try (Connection connection = getConnection(PoolIdentifier.MISC);
558563
PreparedStatement statement = connection.prepareStatement(sql)) {
@@ -1308,7 +1313,28 @@ private void ensureSkillsTable(Connection connection,
13081313
+ "`maces` int(10) unsigned NOT NULL DEFAULT " + startingLevel + ","
13091314
+ "`spears` int(10) unsigned NOT NULL DEFAULT " + startingLevel + ","
13101315
+ "`total` int(10) unsigned NOT NULL DEFAULT " + totalLevel + ","
1311-
+ "PRIMARY KEY (`user_id`)) "
1316+
+ "PRIMARY KEY (`user_id`),"
1317+
// Leaderboard indexes: readLeaderboard sorts on a single column per scope.
1318+
// Fresh installs get them here; existing installs get them via the idempotent
1319+
// ADD_SKILL_LEADERBOARD_INDEXES upgrade. Required for MariaDB to avoid a filesort.
1320+
+ "INDEX `idx_taming` (`taming`),"
1321+
+ "INDEX `idx_mining` (`mining`),"
1322+
+ "INDEX `idx_woodcutting` (`woodcutting`),"
1323+
+ "INDEX `idx_repair` (`repair`),"
1324+
+ "INDEX `idx_unarmed` (`unarmed`),"
1325+
+ "INDEX `idx_herbalism` (`herbalism`),"
1326+
+ "INDEX `idx_excavation` (`excavation`),"
1327+
+ "INDEX `idx_archery` (`archery`),"
1328+
+ "INDEX `idx_swords` (`swords`),"
1329+
+ "INDEX `idx_axes` (`axes`),"
1330+
+ "INDEX `idx_acrobatics` (`acrobatics`),"
1331+
+ "INDEX `idx_fishing` (`fishing`),"
1332+
+ "INDEX `idx_alchemy` (`alchemy`),"
1333+
+ "INDEX `idx_crossbows` (`crossbows`),"
1334+
+ "INDEX `idx_tridents` (`tridents`),"
1335+
+ "INDEX `idx_maces` (`maces`),"
1336+
+ "INDEX `idx_spears` (`spears`),"
1337+
+ "INDEX `idx_total` (`total`)) "
13121338
+ "DEFAULT CHARSET=" + CHARSET_SQL + ";";
13131339

13141340
try (Statement createStatement = connection.createStatement()) {
@@ -1496,6 +1522,7 @@ private void checkDatabaseStructure(Connection connection, UpgradeType upgrade)
14961522
checkNameUniqueness(statement);
14971523
}
14981524
case ADD_SKILL_TOTAL -> checkUpgradeSkillTotal(connection);
1525+
case ADD_SKILL_LEADERBOARD_INDEXES -> checkUpgradeSkillLeaderboardIndexes(connection);
14991526
case ADD_UNIQUE_PLAYER_DATA -> checkUpgradeAddUniqueChimaeraWing(statement);
15001527
case SQL_CHARSET_UTF8MB4 -> updateCharacterSet(statement);
15011528
default -> {
@@ -1752,6 +1779,112 @@ private void checkUpgradeSkillTotal(final Connection connection) throws SQLExcep
17521779
}
17531780

17541781

1782+
/**
1783+
* Ensures a secondary index exists on each column used by leaderboard queries (every
1784+
* non-child skill plus {@code total}).
1785+
* <p>
1786+
* {@link #readLeaderboard} sorts on a single column per scope. Without an index the engine must
1787+
* scan and filesort the whole {@code skills} table. On MariaDB the query rewrite alone is not
1788+
* enough (its optimizer keeps a temporary + filesort); the index is what lets it drive the sort
1789+
* from the {@code skills} table. Verified on MySQL 8 and MariaDB 10.11.
1790+
* <p>
1791+
* Resilience: each index is attempted independently and best-effort. A failure on one column is
1792+
* logged and skipped so the remaining indexes still get created, and no failure is allowed to
1793+
* propagate and abort startup - mcMMO keeps running without the index (slower leaderboards) if
1794+
* the DDL cannot be applied. The upgrade is only marked complete once every index is present, so
1795+
* partial or failed runs are retried on the next startup.
1796+
* <p>
1797+
* Cost note: this is a one-time migration that can be slow on very large tables, and each index
1798+
* adds write amplification on {@code saveUser}. Existing indexes are skipped, so the step is
1799+
* idempotent and safe to re-run.
1800+
*/
1801+
private void checkUpgradeSkillLeaderboardIndexes(final Connection connection) {
1802+
final List<String> leaderboardColumns = List.of(
1803+
"taming", "mining", "woodcutting", "repair", "unarmed", "herbalism", "excavation",
1804+
"archery", "swords", "axes", "acrobatics", "fishing", "alchemy", "crossbows",
1805+
"tridents", "maces", "spears", "total");
1806+
1807+
boolean allIndexesEnsured = true;
1808+
1809+
try {
1810+
connection.setAutoCommit(false);
1811+
1812+
try (Statement statement = connection.createStatement()) {
1813+
for (final String column : leaderboardColumns) {
1814+
if (!ensureLeaderboardIndex(connection, statement, column)) {
1815+
allIndexesEnsured = false;
1816+
}
1817+
}
1818+
}
1819+
} catch (SQLException ex) {
1820+
// Whole-operation failure (e.g. could not toggle autocommit); log and keep running.
1821+
allIndexesEnsured = false;
1822+
logSQLException(ex);
1823+
} finally {
1824+
try {
1825+
connection.setAutoCommit(true);
1826+
} catch (SQLException ignored) {
1827+
// best effort
1828+
}
1829+
}
1830+
1831+
// Only mark complete when every index is in place, so failures retry on the next startup.
1832+
if (allIndexesEnsured) {
1833+
mcMMO.getUpgradeManager()
1834+
.setUpgradeCompleted(UpgradeType.ADD_SKILL_LEADERBOARD_INDEXES);
1835+
}
1836+
}
1837+
1838+
/**
1839+
* Best-effort creation of a single leaderboard index. Skips when one already exists on the
1840+
* column. Commits on success, rolls back and logs on failure. Never throws.
1841+
*
1842+
* @return {@code true} if the index exists (already present or newly created), {@code false} if
1843+
* the attempt failed and the column remains unindexed.
1844+
*/
1845+
private boolean ensureLeaderboardIndex(final Connection connection, final Statement statement,
1846+
final String column) {
1847+
try {
1848+
if (leaderboardIndexExists(statement, column)) {
1849+
return true;
1850+
}
1851+
1852+
logger.info("Adding leaderboard index for column: " + column);
1853+
statement.executeUpdate("ALTER TABLE `" + tablePrefix + "skills` ADD INDEX `idx_"
1854+
+ column + "` (`" + column + "`) USING BTREE");
1855+
connection.commit();
1856+
return true;
1857+
} catch (SQLException ex) {
1858+
logger.warning("Could not add leaderboard index for column '" + column
1859+
+ "', continuing without it (leaderboards for this column may be slower): "
1860+
+ ex.getMessage());
1861+
try {
1862+
connection.rollback();
1863+
} catch (SQLException ignored) {
1864+
// best effort
1865+
}
1866+
return false;
1867+
}
1868+
}
1869+
1870+
/**
1871+
* @return {@code true} if any index already exists whose column is the given column.
1872+
*/
1873+
private boolean leaderboardIndexExists(final Statement statement, final String column)
1874+
throws SQLException {
1875+
// CREATE INDEX IF NOT EXISTS is not portable (unsupported on MySQL 8), so probe the
1876+
// information schema instead. DATABASE() scopes this to the active mcMMO schema.
1877+
final String query = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS "
1878+
+ "WHERE table_schema = DATABASE() "
1879+
+ "AND table_name = '" + tablePrefix + "skills' "
1880+
+ "AND column_name = '" + column + "'";
1881+
1882+
try (ResultSet resultSet = statement.executeQuery(query)) {
1883+
return resultSet.next() && resultSet.getInt(1) > 0;
1884+
}
1885+
}
1886+
1887+
17551888
private void checkUpgradeDropSpout(final Statement statement) {
17561889
ResultSet resultSet = null;
17571890

src/main/java/com/gmail/nossr50/datatypes/database/UpgradeType.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public enum UpgradeType {
1212
ADD_SCOREBOARD_TIPS,
1313
DROP_NAME_UNIQUENESS,
1414
ADD_SKILL_TOTAL,
15+
ADD_SKILL_LEADERBOARD_INDEXES,
1516
ADD_UNIQUE_PLAYER_DATA,
1617
FIX_SPELLING_NETHERITE_SALVAGE,
1718
FIX_SPELLING_NETHERITE_REPAIR,

src/main/java/com/gmail/nossr50/placeholders/McTopPositionPlaceholder.java renamed to src/main/java/com/gmail/nossr50/placeholders/McTopValuePlaceholder.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* {@code %mcmmo_mctop_overall:<position>%}, {@code %mcmmo_mctop_all:<position>%},
1010
* and {@code %mcmmo_mctop_powerlevel:<position>%}.
1111
*/
12-
public class McTopPositionPlaceholder implements Placeholder {
12+
public class McTopValuePlaceholder implements Placeholder {
1313
private final @Nullable PrimarySkillType skill;
1414
private final String overallToken;
1515
private final LeaderboardPlaceholderCache leaderboardCache;
@@ -18,7 +18,7 @@ public class McTopPositionPlaceholder implements Placeholder {
1818
* @param skill Skill scope, or {@code null} for overall leaderboard.
1919
* @param leaderboardCache Shared leaderboard snapshot cache.
2020
*/
21-
public McTopPositionPlaceholder(@Nullable PrimarySkillType skill,
21+
public McTopValuePlaceholder(@Nullable PrimarySkillType skill,
2222
LeaderboardPlaceholderCache leaderboardCache) {
2323
this(skill, "overall", leaderboardCache);
2424
}
@@ -29,7 +29,7 @@ public McTopPositionPlaceholder(@Nullable PrimarySkillType skill,
2929
* {@code overall}, {@code all}, or {@code powerlevel}.
3030
* @param leaderboardCache Shared leaderboard snapshot cache.
3131
*/
32-
public McTopPositionPlaceholder(@Nullable PrimarySkillType skill,
32+
public McTopValuePlaceholder(@Nullable PrimarySkillType skill,
3333
String overallToken,
3434
LeaderboardPlaceholderCache leaderboardCache) {
3535
this.skill = skill;

src/main/java/com/gmail/nossr50/placeholders/PapiExpansion.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ protected void init() {
258258

259259
for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) {
260260
// %mcmmo_mctop_<skillname>:<position>%
261-
registerPlaceholder(new McTopPositionPlaceholder(skill, leaderboardPlaceholderCache));
261+
registerPlaceholder(new McTopValuePlaceholder(skill, leaderboardPlaceholderCache));
262262

263263
// %mcmmo_mctop_name_<skillname>:<position>%
264264
registerPlaceholder(new McTopNamePlaceholder(skill, leaderboardPlaceholderCache));
@@ -291,19 +291,19 @@ protected void init() {
291291
registerPlaceholder(new XpRatePlaceholder(this));
292292

293293
// %mcmmo_mctop_overall:<position>%
294-
registerPlaceholder(new McTopPositionPlaceholder(null, leaderboardPlaceholderCache));
294+
registerPlaceholder(new McTopValuePlaceholder(null, leaderboardPlaceholderCache));
295295

296296
// %mcmmo_mctop_name_overall:<position>%
297297
registerPlaceholder(new McTopNamePlaceholder(null, leaderboardPlaceholderCache));
298298

299299
// %mcmmo_mctop_all:<position>%
300-
registerPlaceholder(new McTopPositionPlaceholder(null, "all", leaderboardPlaceholderCache));
300+
registerPlaceholder(new McTopValuePlaceholder(null, "all", leaderboardPlaceholderCache));
301301

302302
// %mcmmo_mctop_name_all:<position>%
303303
registerPlaceholder(new McTopNamePlaceholder(null, "all", leaderboardPlaceholderCache));
304304

305305
// %mcmmo_mctop_powerlevel:<position>%
306-
registerPlaceholder(new McTopPositionPlaceholder(null, "powerlevel", leaderboardPlaceholderCache));
306+
registerPlaceholder(new McTopValuePlaceholder(null, "powerlevel", leaderboardPlaceholderCache));
307307

308308
// %mcmmo_mctop_name_powerlevel:<position>%
309309
registerPlaceholder(new McTopNamePlaceholder(null, "powerlevel", leaderboardPlaceholderCache));

src/main/resources/config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ General:
9090
# Do not set this setting too high as it requires storing entries in memory.
9191
Max_Tracked_Rank: 100
9292
# How often to refresh cached leaderboard data (in seconds).
93+
# Note: the FlatFile backend recomputes leaderboards at most once every 10 minutes,
94+
# so intervals shorter than that mainly affect SQL (MySQL/MariaDB) backends.
9395
Refresh_Interval_Seconds: 60
9496

9597
#

0 commit comments

Comments
 (0)