Fix concurrent corruption of PlayerLoader account cache (#1795) - #1800
Fix concurrent corruption of PlayerLoader account cache (#1795)#1800claude[bot] wants to merge 2 commits into
Conversation
cachedPlayers was a plain TreeMap mutated from three threads with no
synchronisation:
- the server main thread, on join, via MainListener#onJoin ->
Fabled#getData -> PlayerLoader#getPlayerAccounts;
- an async Bukkit thread, on quit, because Fabled#unloadPlayerData
dispatches PlayerLoader#unloadPlayer through runTaskAsynchronously;
- Fabled's own MainThread, running SaveTask when auto-save is on.
When a put and a remove interleaved, the red-black tree could be
corrupted into a cycle. A later lookup then span forever inside
TreeMap#getEntry on the main thread and the server stopped ticking
permanently - Paper's watchdog fired at 180s and shutdown deadlocked.
Corruption also surfaced earlier as NullPointerException inside
TreeMap#fixAfterDeletion on the async thread.
Swapping in a ConcurrentHashMap is not enough on its own. The sequences
here are compound - check/load/put on the join path, get/save/remove on
the quit path - and unloadPlayer performs a YAML file write between
reading the map and removing the entry, which is what makes the window
wide enough to hit. A single lock around everything would serialise all
saves behind one monitor and stall the main thread across disk I/O, so
this uses per-player lock striping instead: one of 128 stripes chosen by
UUID, held only for that player's load or save. A join and a quit for the
same player can no longer interleave - which also stops a fast rejoin
from reading a data file the unload is still writing - while unrelated
players continue to load and save in parallel and no global lock is ever
held across I/O.
unloadPlayer keeps saving before removing. Under the per-player lock
either order is safe for the cache; removing first would slightly narrow
the window in which a bulk save could pick the same player up, but it
would turn a failed write into lost data, so the existing durability
behaviour is preserved.
getAllPlayerAccounts returned the backing map directly, and five call
sites iterated it while the async unloader mutated it (ConfigIO#saveAll,
FabledPlayersSQL#saveAllPlayerAccounts, IOManager#saveAll, Fabled#onEnable
and the deprecated SkillAPI#getPlayerAccountData). It now returns an
unmodifiable snapshot. None of those callers mutate the map, and this
also removes a latent NPE in ConfigIO#saveAll, which snapshotted the key
set and then re-read each key from the live map, so a key unloaded in
between yielded a null passed straight into saveData.
Loads that return null are now left out of the cache rather than stored.
FabledPlayersSQL#loadPlayerAccounts returns null for a player with no
known name, and ConcurrentHashMap rejects null values, so without this
the type change would have thrown from inside the map.
Diagnosis, stack traces and reproduction by @qhashofficial.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sjM6hU8QB8oxUzaQXL95q
|
I'm the one who filed #1795. This is a better fix than the patch I described there, so I'd rather see this go in than mine. Two things it does that mine didn't: the per-player stripes mean no lock is held across the actual file or database write, and Three things I noticed reading it. The for (UUID id : ids) {
synchronized (lockFor(id)) { ... }
}
if (clearCache) cachedPlayers.clear();If someone joins after their stripe has already been visited,
The last one is a question rather than a finding. Does anything under On numbers, to be clear about what they cover: these are from my own single-lock patch, not from this branch.
The repro is fast rejoins across a Velocity network, roughly 1.5 s per hub-to-game cycle, on fresh accounts so The harness is still set up here. Happy to run it against this branch if that's useful. |
…cquisition Three points raised by @qhashofficial on the PR. 1. saveAllPlayerAccounts(true) cleared the whole map after the loop, outside any stripe. A player who joined after their stripe had been visited was put into the map by load() and then wiped by clear() while still online. unloadPlayer skips players it finds nothing cached for, so their session was lost outright on quit rather than merely going stale. Each entry is now dropped under the same stripe that saved it, and anything that appears after its stripe has been visited stays cached for the next pass. No in-tree caller passes true - the periodic SaveTask path passes false - so this was latent, but it is public API. 2. hasPlayerAccounts still reads without a stripe, which looked inconsistent with getPlayerAccounts taking one on every call. It is not: that method hands back a reference, and an unguarded read there can return an instance a concurrent unload is about to save and evict, leaving the caller holding an object that is no longer the cached one. hasPlayerAccounts returns a boolean, so there is nothing to detach, and every caller is doing check-then-act, which is racy whether or not the read is guarded. Both javadocs now say which of the two applies and why, so the asymmetry reads as deliberate. 3. The question about nested stripe acquisition was the real find, and the answer is that it deadlocks. Nothing in Fabled re-enters PlayerLoader from the I/O layer - the whole data/ package makes no call to Fabled#getData or #getPlayerAccounts, and the only PlayerLoader method it reaches is getAllPlayerAccounts, which takes no stripe. But the I/O layer is not a leaf: IOManager#load calls PlayerData#setClass, which calls updatePlayerStat, which fires PlayerMaxManaChangeEvent. Any third-party listener that asks for another player's data from inside one of those events acquires a second stripe while the first is held, and two threads doing that in opposite orders deadlock. A global lock hid this by being reentrant; striping does not. A thread now never holds more than one stripe. A nested request runs without taking a second one, which gives up the compound-sequence guarantee for that nested call - no worse than the behaviour before striping existed, and it cannot corrupt a ConcurrentHashMap - in exchange for making lock-ordering deadlock structurally impossible. Both new behaviours are covered by tests that fail against the previous commit: the bulk-clear test by assertion, and the nested-lookup test by deadlocking until its 60s timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sjM6hU8QB8oxUzaQXL95q
|
Thanks — all three are worth having, and one of them was a live bug rather than a nitpick. Pushed as a283560. 1. The
|
|
One thing I found while working on this and should have noted in the description: there is a second, unrelated data-loss path in the quit sequence that this PR does not fix.
Written up separately as #1809 with the two orderings that produce it and two suggested fixes. It is pre-existing, predates #1795, and is narrower than the hang — but it is real, and it is adjacent enough to this change that it should not stay buried in a review thread. Not proposing to fold it in here. This PR is already doing one thing; that one needs a maintainer decision about what "unload" should mean for a player who has come back. Generated by Claude Code |
Fixes #1795
The race
PlayerLoader.cachedPlayerswas a plainTreeMapmutated from three threads with no synchronisation:MainListener#onJoin→Fabled#getData→PlayerLoader#getPlayerAccountsFabled#unloadPlayerDatadispatchesPlayerLoader#unloadPlayerviarunTaskAsynchronouslyMainThread, runningSaveTaskwhenauto-save: trueWhen a
putand aremoveinterleaved, the red-black tree could be corrupted into a cycle. A later lookup then span forever insideTreeMap#getEntryon the main thread, and the server stopped ticking permanently — the watchdog fired at 180s and shutdown deadlocked. The same corruption surfaced earlier asNullPointerExceptioninsideTreeMap#fixAfterDeletionon the async thread.Both mutating paths are compound, not single operations —
containsKey→loadPlayer→geton join, andcontainsKey→get→saveData→removeon quit — and thatsaveDatais a YAML file write sitting between the read and theremove, which is what makes the window wide enough to hit in practice.The fix
ConcurrentHashMapalone would not be enough: it makes each operation atomic but not the sequences above. And a singlesynchronizedaround everything would serialise every save behind one monitor and stall the main thread across disk I/O — trading a hang for a different hang.So:
ConcurrentHashMapplus per-player lock striping. 128 stripes, chosen by UUID, each held only for the duration of that player's load or save. That makes load-then-put and get-save-remove mutually exclusive for the same player — which also stops a fast rejoin from reading a data file that the unload is still writing — while unrelated players keep loading and saving in parallel. Only one stripe is ever held at a time, so there is no lock-ordering deadlock, and no global lock is ever held across I/O. The worst case for a caller that collides on a stripe is waiting out one file write.computeIfAbsentwas deliberately avoided: its mapping function runs while holding a bin lock, and here that function performs disk I/O.unloadPlayerorderingKept as save-then-remove, under the per-player lock. Under a correct lock either order is safe for the cache itself. Remove-then-save would slightly narrow the window in which a bulk save could pick the same player up — but the bulk savers that write files (
IOManager#saveAll,ConfigIO#saveAll) only run fromFabled#onDisable, and by thendisablingis alreadytrue, which makesFabled#unloadPlayerDatashort-circuit — so no async unload can be in flight alongside them. Remove-then-save would, on the other hand, turn a failed write into data lost from memory, where save-then-remove leaves the entry cached for a later save pass to retry. Existing durability behaviour preserved.Second defect, not in the original report
getAllPlayerAccounts()returned the backing map directly, and five call sites iterated it while the async unloader could be mutating it:data/io/ConfigIO.java:114data/sql/tables/FabledPlayersSQL.java:142data/io/IOManager.java:117Fabled.java:696com/sucy/skill/SkillAPI.java:382(deprecated)It now returns an unmodifiable snapshot. All five callers are read-only, so nothing breaks. This also removes a latent NPE in
ConfigIO#saveAll, which snapshotted the key set and then re-read each key from the live map — a key unloaded in between yielded anullpassed straight intosaveData.PlayerLoader#saveAllPlayerAccountslikewise now iterates a snapshot and takes each player's stripe individually, releasing it before moving to the next, rather than iterating the live map'svalues().One behaviour change worth flagging
ConcurrentHashMaprejects null values, andFabledPlayersSQL#loadPlayerAccountsreturnsnullfor a player with no known name (FabledPlayersSQL.java:43). A straight type swap would therefore have thrown an NPE from inside the map on SQL setups. Loads that returnnullare now simply left out of the cache, so callers see the samenullthey saw before.Ordering is not relied on anywhere — the audit that no code casts this map to
SortedMap/NavigableMapholds, so droppingTreeMapis safe.What I verified, and what I could not
Could not: run
mvn clean install, or the existing test suite. This environment's egress policy returns 403 for every Minecraft-ecosystem Maven repository —repo.papermc.io,hub.spigotmc.org,repo.codemc.io,jitpack.io,mvn.lib.co.nz,repo.md-5.net,maven.enginehub.org,mvn.lumine.ioand others. Only Maven Central andrepo.travja.devare reachable, and neither carriespaper-api/spigot-api, so not a single project dependency resolves and nothing can be compiled through Maven. I am not claiming a green build. CI on this PR is the real check.Did verify, by compiling the unmodified
PlayerLoader.javaand the unmodified new test against minimal stand-ins for the handful of types they touch (OfflinePlayer,Player,Bukkit,Fabled,PlayerAccounts,IOManager), with JUnit 5.13.4 and Mockito 5.19.0 from Central — the same versions the parent POM pins:javac -Xlint:all, no warnings;PlayerLoader, so it is a real regression test rather than a test written to match the new code.The observed pre-fix failures are exactly the compound-sequence race:
getPlayerAccountsreturningnullfor a player it had just seen viacontainsKey, andunloadPlayercallingsaveDatawith accounts that had already been removed.The gap in this method is that the stubs stand in for the real classes; that they match the real signatures was checked by reading the source, but only a real build proves it.
The test
src/test/java/studio/magemonkey/fabled/data/io/PlayerLoaderConcurrencyTest.java. No live Bukkit server:Fabled.singletonis replaced reflectively with a mock whoseIOManageris a fake that spends measurable time "on disk" and records whether two calls were ever inside the IO layer for the same player at once; players arejava.lang.reflect.Proxyinstances that only answergetUniqueId().Twenty threads — 8 loading, 8 unloading, 4 iterating and running full save passes — hammer 16 overlapping UUIDs for 1500 rounds each, asserting no exceptions, that every lookup returns the accounts belonging to the player asked for, that no load and save overlap for a single player, and (via
@Timeout) that no thread spins forever. A second, deterministic test covers the snapshot semantics ofgetAllPlayerAccounts.Credit
Diagnosis, watchdog stack traces, the live
fixAfterDeletionNPE capture, and the ~1364-rejoin reproduction are all @qhashofficial's work in #1795. Their patch was referenced in the report as an attachment but no file or link came through on the issue, so this is an independent implementation of the fix they described.Generated by Claude Code