Skip to content

Commit 4b58edb

Browse files
committed
Harden shared user storage lifecycle
1 parent 2772087 commit 4b58edb

16 files changed

Lines changed: 929 additions & 162 deletions

‎AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java‎

Lines changed: 257 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import com.bencodez.advancedcore.api.user.UserStorage;
5252
import com.bencodez.advancedcore.api.user.usercache.UserDataManager;
5353
import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap;
54+
import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend;
5455
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
5556
import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable;
5657
import com.bencodez.advancedcore.command.CommandLoader;
@@ -189,7 +190,24 @@ public static void setInstance(AdvancedCorePlugin plugin) {
189190
@Getter
190191
private CMIHandler cmiHandle;
191192

192-
private Database database;
193+
private Database database;
194+
/**
195+
* A coherent native-owner snapshot for callers which need both the selected
196+
* storage kind and its provider. Shared-runtime replacement publishes this
197+
* as one volatile value; callers must not combine a separately observed
198+
* storage type with the mutable provider fields.
199+
*/
200+
public record UserStorageOwner(UserStorage storageType, MySQL mysql, UserTable table) {
201+
public UserStorageOwner {
202+
if (storageType == null) throw new IllegalArgumentException("storageType");
203+
if (storageType == UserStorage.MYSQL && mysql == null) throw new IllegalArgumentException("mysql");
204+
if (storageType == UserStorage.SQLITE && table == null) throw new IllegalArgumentException("table");
205+
}
206+
}
207+
private volatile UserStorageOwner nativeUserStorageOwner;
208+
/** Coalesces public storage reload requests while a replacement is being prepared. */
209+
private Object userStorageReloadLock = new Object();
210+
private CompletionStage<Void> userStorageReload;
193211

194212
/**
195213
* Handler for full inventory management.
@@ -631,7 +649,7 @@ public void extraDebug(String debug) {
631649
*
632650
* @return the user table, or null if not using SQLite
633651
*/
634-
public UserTable getSQLiteUserTable() {
652+
public UserTable getSQLiteUserTable() {
635653
if (database == null && loadUserData) {
636654
loadUserAPI(getStorageType());
637655
}
@@ -642,17 +660,38 @@ public UserTable getSQLiteUserTable() {
642660
}
643661
}
644662
}
645-
return null;
646-
}
663+
return null;
664+
}
665+
666+
/**
667+
* Returns the current native provider as one immutable observation. This is
668+
* intentionally separate from the legacy individual getters: user-facing
669+
* bulk APIs use this snapshot while a shared route is being replaced.
670+
*/
671+
public UserStorageOwner getNativeUserStorageOwner() {
672+
UserStorageOwner owner = nativeUserStorageOwner;
673+
if (owner != null) return owner;
674+
UserStorage configured = getOptions().getStorageType();
675+
if (configured == UserStorage.MYSQL && mysql != null) return new UserStorageOwner(configured, mysql, null);
676+
if (configured == UserStorage.SQLITE && database != null) {
677+
for (Table table : database.getTables()) {
678+
if (table instanceof UserTable userTable) return new UserStorageOwner(configured, null, userTable);
679+
}
680+
}
681+
return null;
682+
}
647683

648684
/**
649685
* Gets the current storage type configuration.
650686
*
651687
* @return the storage type
652688
*/
653-
public UserStorage getStorageType() {
654-
return getOptions().getStorageType();
655-
}
689+
public UserStorage getStorageType() {
690+
UserStorage configured = getOptions().getStorageType();
691+
UserManager loadedUsers = getLoadedUserManager();
692+
return loadedUsers == null ? configured
693+
: loadedUsers.getDataManager().effectiveStorageType(configured);
694+
}
656695

657696
/**
658697
* Gets the user manager instance.
@@ -1072,8 +1111,9 @@ public void loadUserAPI(UserStorage storageType) {
10721111
Column key = new Column("uuid", DataType.STRING);
10731112
columns.add(key);
10741113
UserTable table = new UserTable(this, "Users", columns, key);
1075-
database = new Database(this, "Users", table);
1076-
table.addCustomColumns();
1114+
database = new Database(this, "Users", table);
1115+
table.addCustomColumns();
1116+
nativeUserStorageOwner = new UserStorageOwner(UserStorage.SQLITE, null, table);
10771117
} else if (storageType.equals(UserStorage.MYSQL)) {
10781118
if (getOptions().getYmlConfig().getData().contains("Database")) {
10791119
setMysql(new MySQL(javaPlugin, javaPlugin.getName() + "_Users",
@@ -1256,36 +1296,212 @@ public void reloadAdvancedCore() {
12561296
reloadAdvancedCore(false);
12571297
}
12581298

1259-
/**
1260-
* Reloads AdvancedCore configuration.
1261-
*
1262-
* @param userStorage whether to reload user storage
1263-
*/
1299+
/**
1300+
* Starts an AdvancedCore configuration reload.
1301+
*
1302+
* <p>When {@code userStorage} is true and a shared runtime is active, this
1303+
* method only starts the non-blocking reload; it returns before storage has
1304+
* been flushed, replaced, or confirmed. It deliberately has no success
1305+
* signal. Call {@link #reloadAdvancedCoreAsync(boolean)} when a caller must
1306+
* continue, notify an administrator, or report status after completion.
1307+
*
1308+
* @param userStorage whether to reload user storage
1309+
*/
12641310
public void reloadAdvancedCore(boolean userStorage) {
1311+
if (userStorage && hasActiveSharedUserRuntime()) {
1312+
reloadAdvancedCoreAsync(true).whenComplete((ignored, failure) -> {
1313+
if (failure != null) {
1314+
getLogger().warning("User storage reload did not complete: " + failure.getMessage());
1315+
debug(failure);
1316+
}
1317+
});
1318+
return;
1319+
}
1320+
reloadAdvancedCoreNow(userStorage);
1321+
}
1322+
1323+
/**
1324+
* Reload AdvancedCore without making a Bukkit thread wait for a shared user
1325+
* storage flush or a database connection. Callers that need to report a
1326+
* confirmed storage reload should await this stage rather than assuming that
1327+
* the legacy void overload has completed synchronously.
1328+
*
1329+
* @param userStorage whether to reload user storage
1330+
* @return completion of the reload, including the shared backend replacement
1331+
*/
1332+
public CompletionStage<Void> reloadAdvancedCoreAsync(boolean userStorage) {
1333+
if (!userStorage || !hasActiveSharedUserRuntime()) {
1334+
CompletableFuture<Void> completion = new CompletableFuture<>();
1335+
try {
1336+
reloadAdvancedCoreNow(userStorage);
1337+
completion.complete(null);
1338+
} catch (Throwable failure) { completion.completeExceptionally(failure); }
1339+
return completion;
1340+
}
1341+
synchronized (userStorageReloadLock()) {
1342+
if (userStorageReload != null && !userStorageReload.toCompletableFuture().isDone()) return userStorageReload;
1343+
CompletableFuture<Void> completion = new CompletableFuture<>();
1344+
userStorageReload = completion;
1345+
try {
1346+
getBukkitScheduler().runTask(this, () -> beginSharedUserStorageReload(completion));
1347+
} catch (RuntimeException | Error failure) {
1348+
completion.completeExceptionally(failure);
1349+
userStorageReload = null;
1350+
}
1351+
return completion;
1352+
}
1353+
}
1354+
1355+
private boolean hasActiveSharedUserRuntime() {
1356+
UserManager users = getLoadedUserManager();
1357+
return users != null && users.getDataManager().hasSharedRuntime();
1358+
}
1359+
1360+
private void beginSharedUserStorageReload(CompletableFuture<Void> completion) {
1361+
try {
1362+
getServerDataFile().reloadData();
1363+
rewardHandler.loadRewards();
1364+
getOptions().load(this);
1365+
getBukkitScheduler().runTaskAsynchronously(this, () -> replaceSharedUserStorage(completion));
1366+
} catch (Throwable failure) {
1367+
completion.completeExceptionally(failure);
1368+
clearSharedUserStorageReload(completion);
1369+
}
1370+
}
1371+
1372+
private void replaceSharedUserStorage(CompletableFuture<Void> completion) {
1373+
UserStorageReplacement replacement = null;
1374+
try {
1375+
replacement = prepareUserStorageReplacement(getOptions().getStorageType());
1376+
UserStorageReplacement prepared = replacement;
1377+
getUserManager().getDataManager().replaceSharedSqlBackendAsync(prepared.backend(),
1378+
() -> installUserStorageReplacement(prepared)).whenComplete((ignored, failure) -> {
1379+
if (failure != null) {
1380+
// SharedUserDataRuntime reports an exception here only before it
1381+
// publishes the replacement route. A post-publication old-owner close
1382+
// is retained there for retry and deliberately completes successfully.
1383+
prepared.closeUnpublished();
1384+
completion.completeExceptionally(failure);
1385+
clearSharedUserStorageReload(completion);
1386+
return;
1387+
}
1388+
try {
1389+
getBukkitScheduler().runTask(this, () -> completeSharedUserStorageReload(completion));
1390+
} catch (Throwable completionFailure) {
1391+
completion.completeExceptionally(completionFailure);
1392+
clearSharedUserStorageReload(completion);
1393+
}
1394+
});
1395+
} catch (Throwable failure) {
1396+
if (replacement != null) replacement.closeUnpublished();
1397+
completion.completeExceptionally(failure);
1398+
clearSharedUserStorageReload(completion);
1399+
}
1400+
}
1401+
1402+
private void completeSharedUserStorageReload(CompletableFuture<Void> completion) {
1403+
try {
1404+
finishReloadAdvancedCore();
1405+
completion.complete(null);
1406+
} catch (Throwable failure) { completion.completeExceptionally(failure); }
1407+
finally { clearSharedUserStorageReload(completion); }
1408+
}
1409+
1410+
private void clearSharedUserStorageReload(CompletableFuture<Void> completion) {
1411+
synchronized (userStorageReloadLock()) {
1412+
if (userStorageReload == completion) userStorageReload = null;
1413+
}
1414+
}
1415+
1416+
private Object userStorageReloadLock() {
1417+
Object lock = userStorageReloadLock;
1418+
if (lock != null) return lock;
1419+
synchronized (this) {
1420+
if (userStorageReloadLock == null) userStorageReloadLock = new Object();
1421+
return userStorageReloadLock;
1422+
}
1423+
}
1424+
1425+
private UserStorageReplacement prepareUserStorageReplacement(UserStorage storageType) {
1426+
if (storageType == null) throw new IllegalArgumentException("User storage must be SQLITE or MYSQL");
1427+
if (storageType == UserStorage.SQLITE) {
1428+
ArrayList<Column> columns = new ArrayList<>();
1429+
Column key = new Column("uuid", DataType.STRING);
1430+
columns.add(key);
1431+
UserTable table = new UserTable(this, "Users", columns, key);
1432+
Database replacementDatabase = new Database(this, "Users", table);
1433+
table.addCustomColumns();
1434+
return new UserStorageReplacement(storageType, replacementDatabase, null,
1435+
new BukkitSqlUserBackend(this, storageType, null, table));
1436+
}
1437+
ConfigurationSection section = getOptions().getYmlConfig().getData()
1438+
.getConfigurationSection(getOptions().getYmlConfig().getData().contains("Database") ? "Database" : "MySQL");
1439+
if (section == null) throw new IllegalStateException("MySQL user storage configuration is missing");
1440+
MySQL replacementMysql = new MySQL(javaPlugin, javaPlugin.getName() + "_Users", section);
1441+
return new UserStorageReplacement(storageType, null, replacementMysql,
1442+
new BukkitSqlUserBackend(this, storageType, replacementMysql, null));
1443+
}
1444+
1445+
private void installUserStorageReplacement(UserStorageReplacement replacement) {
1446+
MySQL previousMysql = mysql;
1447+
Database previousDatabase = database;
1448+
mysql = replacement.mysql();
1449+
database = replacement.database();
1450+
// Publish only after both legacy fields have been assigned. The shared route
1451+
// picks this exact snapshot up in the same lifecycle write admission.
1452+
nativeUserStorageOwner = replacement.owner();
1453+
if (previousMysql != null && previousMysql != mysql) {
1454+
try { previousMysql.close(); }
1455+
catch (RuntimeException | Error closeFailure) { debug(closeFailure); }
1456+
}
1457+
if (previousDatabase != null && previousDatabase != database) {
1458+
try { previousDatabase.getDB().closeConnection(); }
1459+
catch (RuntimeException | Error closeFailure) { debug(closeFailure); }
1460+
}
1461+
}
1462+
1463+
private void reloadAdvancedCoreNow(boolean userStorage) {
12651464
if (userStorage) requireUserStorageMaintenanceWindow();
12661465
getServerDataFile().reloadData();
1267-
rewardHandler.loadRewards();
1268-
loadConfig(userStorage);
1269-
1270-
if (userStorage) {
1271-
getUserManager().getDataManager().clearCache();
1272-
if (getStorageType().equals(UserStorage.MYSQL) && getMysql() != null) {
1273-
getMysql().clearCacheBasic();
1274-
}
1275-
}
1276-
timeChecker.update();
1277-
TabCompleteHandler.getInstance().reload();
1278-
TabCompleteHandler.getInstance().loadTabCompleteOptions();
1279-
getRewardHandler().checkSubRewards();
1280-
1281-
if (skullCacheHandler != null) {
1282-
if (!getOptions().getSkullProfileAPIURL().isEmpty()) {
1283-
debug("Setting API profile URL to " + getOptions().getSkullProfileAPIURL());
1284-
skullCacheHandler.changeApiProfileURL(getOptions().getSkullProfileAPIURL());
1285-
}
1286-
getSkullCacheHandler().setBedrockPrefix(getOptions().getBedrockPlayerPrefix());
1287-
}
1288-
}
1466+
rewardHandler.loadRewards();
1467+
loadConfig(userStorage);
1468+
if (userStorage) {
1469+
getUserManager().getDataManager().clearCache();
1470+
if (getStorageType().equals(UserStorage.MYSQL) && getMysql() != null) getMysql().clearCacheBasic();
1471+
}
1472+
finishReloadAdvancedCore();
1473+
}
1474+
1475+
private void finishReloadAdvancedCore() {
1476+
timeChecker.update();
1477+
TabCompleteHandler.getInstance().reload();
1478+
TabCompleteHandler.getInstance().loadTabCompleteOptions();
1479+
getRewardHandler().checkSubRewards();
1480+
if (skullCacheHandler != null) {
1481+
if (!getOptions().getSkullProfileAPIURL().isEmpty()) {
1482+
debug("Setting API profile URL to " + getOptions().getSkullProfileAPIURL());
1483+
skullCacheHandler.changeApiProfileURL(getOptions().getSkullProfileAPIURL());
1484+
}
1485+
getSkullCacheHandler().setBedrockPrefix(getOptions().getBedrockPlayerPrefix());
1486+
}
1487+
}
1488+
1489+
private record UserStorageReplacement(UserStorage storageType, Database database, MySQL mysql,
1490+
BukkitSqlUserBackend backend) {
1491+
private UserStorageOwner owner() {
1492+
return new UserStorageOwner(storageType, mysql, database == null ? null : findUserTable(database));
1493+
}
1494+
1495+
private static UserTable findUserTable(Database database) {
1496+
for (Table table : database.getTables()) if (table instanceof UserTable userTable) return userTable;
1497+
throw new IllegalStateException("Replacement SQLite user table is unavailable");
1498+
}
1499+
private void closeUnpublished() {
1500+
backend.close();
1501+
if (mysql != null) mysql.close();
1502+
if (database != null) database.getDB().closeConnection();
1503+
}
1504+
}
12891505

12901506
/**
12911507
* @param configData the configData to set
@@ -1325,13 +1541,14 @@ public void setConfigData(YMLConfig ymlConfig) {
13251541
*
13261542
* @param mysql the mysql connection to set
13271543
*/
1328-
public void setMysql(MySQL mysql) {
1544+
public void setMysql(MySQL mysql) {
13291545
if (this.mysql != null) {
13301546
this.mysql.close();
13311547
this.mysql = null;
1332-
}
1333-
this.mysql = mysql;
1334-
}
1548+
}
1549+
this.mysql = mysql;
1550+
if (mysql != null) nativeUserStorageOwner = new UserStorageOwner(UserStorage.MYSQL, mysql, null);
1551+
}
13351552

13361553
/**
13371554
* Runs user startup tasks.

‎AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1741,6 +1741,10 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap<String, String> phs, R
17411741
*/
17421742
public CompletionStage<Void> giveRewardUserAsync(AdvancedCoreUser user, HashMap<String, String> phs,
17431743
RewardOptions rewardOptions) {
1744+
// This public API may hand preparation to the player's scheduler. Preserve the
1745+
// caller's values before that boundary so a reused mutable map cannot alter a
1746+
// queued reward or its durable replay checkpoint.
1747+
HashMap<String, String> placeholders = phs == null ? new HashMap<>() : new HashMap<>(phs);
17441748
ReplayState replayState = replayStateFor(rewardOptions);
17451749
List<RewardInject> orderedRewards = orderedInjectedRewards();
17461750
String registryFingerprint = injectionRegistryFingerprint(orderedRewards);
@@ -1765,7 +1769,7 @@ public CompletionStage<Void> giveRewardUserAsync(AdvancedCoreUser user, HashMap<
17651769
final String stableReplayKey = replayKey;
17661770
final String stableOccurrenceId = occurrenceId;
17671771
return requestOnServerThread(user,
1768-
() -> prepareAndGiveRewardUserAsync(user, phs, rewardOptions, replayState, stableReplayKey,
1772+
() -> prepareAndGiveRewardUserAsync(user, placeholders, rewardOptions, replayState, stableReplayKey,
17691773
stableOccurrenceId, registryFingerprint, !orderedRewards.isEmpty()));
17701774
}
17711775

0 commit comments

Comments
 (0)