@@ -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
0 commit comments