Fix backpack/curios duplication, performance overhaul, extended mod compat#173
Open
laforetbrut wants to merge 12 commits into
Open
Fix backpack/curios duplication, performance overhaul, extended mod compat#173laforetbrut wants to merge 12 commits into
laforetbrut wants to merge 12 commits into
Conversation
Owner
|
good job. but i don't think cobblemon integration and chat sync influence server so much. it won't work if cobblemon is not installed and chat sync can be disable in config too |
Author
|
Great! I'll leave you with this work and let you decide what you do with it :) |
…e dup) LivingDeathEvent fires BEFORE items drop, so the death-save's snapshot captured the full pre-death inventory. With a Corpse/Gravestone mod the items also live in the corpse — if the death-save committed AFTER the logout-save (the two async tasks could interleave), the DB ended up with the pre-death inventory and the player rejoined with everything PLUS a corpse holding the same items = duplication. Cliff-jump-then-disconnect was the easiest repro: the death event fires on impact, the player drops out before respawn, both async saves race. Fix: - New writeNonItemSnapshotToDB() writes only xp / effects / score / food / health / advancements (+ last_server guard). The death-save now uses it instead of the full writeSnapshotToDB. - Backpack / SS / RS2 main-thread snapshots are no longer captured on the death path (saves a few ms of NBT serialization too). - Item-bearing tables (player_data.inventory, armor, enderchest, left_hand, cursors, mod_player_data, backpack_data) are now written exclusively by the logout-save or shutdown-save, whose snapshots are taken post-drop (empty for a dead player whose items went to a corpse). Net effect: zero race possible between death-save and logout-save at the item level — they write disjoint columns. Non-item progression earned between the last auto-save and death is still preserved (the safety net the death-save was designed to provide). Backward compat: no schema change. Older DBs keep working.
5 surgical optimisations targeting the per-join hot path. Each one was verified to be invariant-preserving (no schema change, no behaviour change visible to mods or players). A4 — Cache the SS clear-storage reflection Resolving Method/Field reflectively on every restored SS item meant 33 inventory + 27 enderchest slots × N reflective walks per join. The lookup is now done once per concrete class (lazy, synchronised on the class object) and reused for all subsequent items. A5 — isPlayerOnline O(n) -> O(1) Replaced the per-player UUID.toString allocation + linear scan over getPlayers() with the PlayerList's existing UUID-keyed lookup. Saves one allocation per call and goes from O(players) to O(1). A8 — Fuse the two SELECT player_data queries in doPlayerJoin The existence check (SELECT uuid) and the full row read (SELECT *) touched the same row back-to-back. Merged into one SELECT * where rs.next() == false drives the new-player init path. Saves one MySQL round-trip on every existing-player join. A7 — TTL + sweep on connectCheckCache ConcurrentHashMap entries were inserted by PlayerNegotiationEvent and removed by PlayerLoggedInEvent; if the handshake aborted between the two (client closes the window mid-login) the entry leaked. Wrapped in a record with an insertion timestamp, stale entries are purged every 600 ticks from the existing server tick handler, and any cached value older than 60 s is treated as a miss (forces the safe fallback DB path). Closes a slow memory leak. A3 — Hash-check advancements before write+reload onDataPackSyncEvent did Files.write + playeradvancements.reload() on the main thread on every single join, even when the DB content was identical to what we last applied. reload() walks every criterion of every advancement (5-50 ms on a heavy datapack). Now we CRC32 the DB payload and skip both calls if it matches the last-applied hash for this player. First apply per JVM session still writes (no false skip). Skipped this pass (next iteration): - A6 (replace busy-poll with ScheduledExecutorService backoff) — touches cross-server claim handshake; needs more validation before changing live race-resolution logic. - A2 (NBT deserialise on background thread) — registryAccess thread safety + DataComponents creation needs explicit sign-off before moving off main. Estimated single-join main-thread cost reduction: 15-70 ms depending on datapack size and SS item count. No schema change, no config change, no API change — drop-in upgrade.
…eMe + corpse mod) When ReviveMe / HardcoreRevival / CorailTombstone cancel LivingDeathEvent to show their revive interface, NeoForge skipped PlayerSync's LOW-priority onPlayerDeath handler entirely (receiveCanceled defaulted to false) — so the mod had no record of the canceled death. The player kept the full inventory in the downed state. On disconnect, onPlayerLogout took its normal snapshot and wrote the inventory to DB. The revive timer then finalized the death post-disconnect, items dropped, the corpse/gravestone mod created a body holding the same inventory. On reconnect: player respawned with the full restored inventory AND the corpse held a second copy — full duplication. Fix: - onPlayerDeath now @SubscribeEvent(priority=LOW, receiveCanceled=true). The isCanceled() branch records the player in deathCanceledRecently (ConcurrentHashMap uuid -> timestamp, 2-min TTL). - onPlayerLogout consults deathCanceledRecently before the normal save path. When the keepInventory game rule is OFF, calls the new handleReviveCanceledLogout which persists progression (xp/effects/ score/food/health/advancements) but explicitly clears the item-dropping columns (inventory/armor/left_hand/cursors) in DB so the corpse becomes the single source of truth. - New writeReviveLogoutClearItemsToDB bypasses refuse_empty_inventory_write (the empty write is intentional here) and sets online=0 + logout_started_at=NULL atomically. - Tracking auto-clears on PlayerRespawnEvent and removePlayerLock. - lastWrittenSnapshotHash.remove(uuid) in the BG task so a pending auto-save cannot resurrect the cleared inventory via the hash-skip. KeepInventory=ON falls through to the normal save path — items stay on the player, no corpse forms, no dup risk; clearing the DB would destroy the player's items in that case. The existing death-save (non-item) path remains unchanged; this fix only adds a new branch in onPlayerLogout for the canceled-death case.
The r1 fix used @SubscribeEvent(receiveCanceled = true) to catch canceled LivingDeathEvent firings, but NeoForge bus 8.x silently ignores this annotation flag at dispatch time: SubscribeEventListener.invoke unconditionally skips canceled events for ICancellableEvent, and the constructor reads subInfo.priority() but never subInfo.receiveCanceled(). Only EventBus.addListener(priority, receiveCanceled, ...) respects the flag via passNotGenericFilter. So r1 never received any canceled deaths — the tracking map stayed empty and the bug persisted. r2 fixes the detection with two independent signals: 1. Programmatic LivingDeathEvent listener registered in VanillaSync.register() via NeoForge.EVENT_BUS.addListener with priority=LOWEST and receiveCanceled=true. This DOES dispatch canceled events. Method onCanceledLivingDeath records the player in deathCanceledRecently and logs [revive-track]. 2. Heuristic fallback at onPlayerLogout: if the player has at least one infinite-duration MobEffect AND health < 50% of max, treat as downed-state regardless of LivingDeathEvent cancellation. Catches revive mods that prevent death via LivingDamageEvent cancel or Mixin redirect (no LivingDeathEvent fires in that case). Beacons use duration=200 refreshed periodically (not isInfiniteDuration()=true), so they do not false-positive. The existing @SubscribeEvent annotated onPlayerDeath is kept for un-canceled deaths (the dispatcher delivers those normally) and the dead isCanceled() check has been removed since it was unreachable. Diagnostic logging at both the cancel-track and logout-detect points ([revive-track] / [revive-detect]) prints which signal fired (tracked cancel vs heuristic), HP ratio, and keepInventory state — makes any future regression debuggable from sync.log without code instrumentation. keepInventory=ON still falls through to the normal save path; only keepInventory=OFF triggers the DB inventory clear.
…lear r2 used a programmatic LOWEST + receiveCanceled=true listener on LivingDeathEvent. It only fires when LivingDeathEvent is actually canceled. Some revive mods prevent death by canceling LivingDamageEvent earlier in the pipeline or by Mixin-ing LivingEntity.die() / actuallyHurt() — in those flows LivingDeathEvent never fires at all, our listener has nothing to handle, and the tracking map stays empty. The heuristic fallback (infinite-duration effect + HP < 50%) was also too restrictive: not every revive mod applies infinite-duration effects, and not every revive mod clamps HP below 50%. r3 moves death detection to a @SubscribeEvent(priority = HIGHEST) hook named onPlayerDeathAttempt. At HIGHEST priority no other handler has had a chance to cancel the event, so isCanceled() is always false and the dispatcher always delivers — we capture EVERY death attempt regardless of whether it gets canceled later in the priority chain. Adds @SubscribeEvent(priority = LOWEST) onPlayerHeal(LivingHealEvent) which clears the tracking when the player is healed back to ≥80% maxHealth. Covers the "revived and continued playing" case so a later normal logout isn't wrongly treated as death-pending. The onPlayerLogout heuristic (infinite-duration effect + HP < 50%) is kept as a secondary safety net for revive mods that prevent death without ever firing LivingDeathEvent. Removes the no-longer-needed programmatic listener from register() and the dead "if (event.isCanceled())" branch from onPlayerDeath LOW (the dispatcher already filters canceled events for that handler — that branch was always unreachable). The onPlayerLogout fix path itself (clearing inventory / armor / left_hand / cursors when keepInventory=OFF, atomic online=0 + logout_started_at=NULL) is unchanged. Logs: [death-track] on capture, [revive-detect] on logout fix-path trigger — makes future regressions debuggable from sync.log.
… slots
r3 fixed the dup for the main inventory / armor / left_hand / cursors
columns of player_data, but the user still observed duplication on
Curios slots, Aether-Accessories slots, and Cosmetic Armor Reworked
slots — items stored in separate tables that writeReviveLogoutClearItemsToDB
was not touching.
Mod-specific item storage layout:
- Curios items → table `curios` column `curios_item`, keyed by player UUID
- Accessories slots (used by The Aether) → `mod_player_data` row with
mod_id='accessories', data_value=serialized slot map
- Cosmetic Armor Reworked → `mod_player_data` row with mod_id='cosmeticarmor'
Each of these has a corpse-mod compat that catches dropped items into
the player's corpse on the post-disconnect death finalize. With the DB
copy intact, the rejoin restored the items AND the corpse held them =
the user's remaining "curios / aether / cosmetic armor" dup.
r4 extends the writeReviveLogoutClearItemsToDB batch to also UPDATE:
- `curios.curios_item` → '{}'
- `mod_player_data.data_value` where mod_id='accessories' → '{}'
- `mod_player_data.data_value` where mod_id='cosmeticarmor' → '{}'
The corresponding applyCuriosFromData / applyAccessoriesFromData /
applyCosmeticArmorFromData all short-circuit when data is null or
length() <= 2 — '{}' (length 2) triggers that skip-path, so the apply
functions clear the slots then return without restoring. Player rejoins
with empty curios / accessories / cosmetic slots; corpse holds the items
for retrieval.
INTENTIONALLY NOT cleared:
- mod_id='neoforge_attachments' — per-player progression / state for
Aether (AETHER_PLAYER: portals, darts, flight timer, life shards),
Apotheosis (WORLD_TIER), Apothic Attributes (AUX_DMG_TRACKER), Ars
Nouveau / Iron's Spellbooks mana, etc. Not items. Must persist.
- enderchest — does not drop on vanilla death.
- backpack_data / sophisticatedstorage / refinedstorage — keyed by ITEM
UUID, not player UUID. Backpack/shulker item drops into corpse with
its UUID; on retrieval the data follows the item. No dup risk.
All clears run inside the same executeBatchTransaction as the core
player_data UPDATE, so they share the last_server guard — if a peer
claimed the player between our poll and our write, the entire batch
no-ops cleanly.
…ter legit reco)
A player reported losing their inventory on a normal disconnect/reconnect
10 minutes after dying — meaning r4 was firing handleReviveCanceledLogout
on a legitimate logout, clearing the DB inventory by mistake. Two false-
positive paths in r4:
(1) The heuristic "infinite-duration effect AND HP < 50%" was way too
broad. Many modern mods apply long-lived/infinite effects in normal
gameplay (Aether racial effects, Apotheosis affix buffs, Iron's
Spellbooks mana auras, Ars Nouveau learned-spell markers, etc.). A
player wearing one of those and disconnecting with sub-50% HP from
combat hit the heuristic and lost their inventory.
(2) The 2-minute TTL on the canceled-death tracking was too short. If
onPlayerHeal didn't fire (revive mod uses setHealth() directly
instead of heal()), the tracking persisted but became inert after
2 min — yet the heuristic kept catching the state, firing the fix
on a player who'd been alive for minutes.
r5 hardens the detection by:
- REMOVING the heuristic entirely. Detection now relies SOLELY on the
HIGHEST-priority LivingDeathEvent tracking — an explicit signal, never
a behavioral guess.
- Tightening the logout HP check to "HP ≤ 1.0 absolute OR
isDeadOrDying()". Revive mods typically clamp downed-state HP to
exactly 1 (half a heart); a revived player at any HP > 1 (even 1.5 or
2) is considered out of the death-pending state. No more "40% HP from
combat triggers fix" false positives.
- Dropping the onPlayerHeal clear threshold from "≥80% maxHealth" to
"HP > 1.0". Any non-trivial heal ends the tracking — covers
partial-heal revives (e.g. heal to 5 HP) that the old threshold missed.
- Adding an explicit tracking-clear in the auto-save loop. Every 5
minutes, for every player passing the eligibility check AND with
HP > 1.0, deathCanceledRecently is dropped. Covers the setHealth-based
revive case (no LivingHealEvent fires) within at most one auto-save
cycle.
- Extending the TTL to 1 hour as a safety net for the rare path where
none of the explicit clears fire (player remains in the revive
interface for more than 5 min without any heal event).
Multi-layered defense: (a) HIGHEST hook captures every death event,
(b) onPlayerHeal clears on heal, (c) onPlayerRespawn clears on respawn,
(d) auto-save clears on eligibility + HP > 1, (e) removePlayerLock
clears on session end, (f) 1 h TTL backup, (g) strict HP check at
logout. The fix path now triggers ONLY when the player is genuinely
still in a downed state at the moment of disconnect.
r2-r5 fought the revive-disconnect dup with logout-side heuristics (canceled-LivingDeathEvent tracking, HP thresholds, infinite-effect signatures). Every variant either missed the downed state (dup came back) or false-positived (inventory wrongly cleared, items lost). The approach was wrong on two counts: wrong detection (heuristics) and wrong location (logout side). Root cause, found by decompiling revive_me-1.21.1-5.7.14.jar: - Revive Me holds a downed player in a "fallen" state via invoker54.reviveme.common.capability.FallenData — a NeoForge AttachmentType registered as revive_me:fallen_data. The class exposes static FallenData.get(LivingEntity) and boolean isFallen(). - Revive Me pauses the fall timer on logout and resumes it on reconnect. - The dup was a race in doPlayerJoin: the player reconnects fallen, the resumed timer finalizes the death, a corpse/gravestone mod captures the inventory into a corpse — and THEN doPlayerJoin's deferred (async + server.execute) apply restores the DB inventory, handing the player a second copy. r6 fixes detection AND location: - New isReviveMeFallen(player): reflectively calls FallenData.get(player).isFallen(). EXACT — the player is fallen iff Revive Me says so. No heuristic, no false positive. Soft dependency: returns false cleanly if revive_me isn't loaded. - doPlayerJoin's apply block now skips the ENTIRE DB data apply when isReviveMeFallen(player) || player.isDeadOrDying(). The vanilla .dat inventory stays in place as the single source of truth during the transient fallen phase. The isDeadOrDying() guard covers the race where the death finalizes during the join's async delay. - When the player leaves the fallen state, normal sync resumes: a successful revive is captured by the next auto-save / logout-save; a finalized death is captured by onPlayerRespawn (empty inventory → DB). All r2-r5 logout-side machinery is removed: deathCanceledRecently map, onPlayerDeathAttempt / onPlayerHeal hooks, handleReviveCanceledLogout, writeReviveLogoutClearItemsToDB, the auto-save tracking-clear, the onPlayerRespawn / removePlayerLock tracking-clears, the LivingHealEvent import. onPlayerLogout is back to the pre-r2 baseline (normal save). onPlayerDeath (LOW priority, non-item death-save) is unchanged. Net result: no duplication, no item loss, no heuristic — the fix keys off Revive Me's own authoritative state and steps aside while the mod owns the player's transient downed phase.
…ogout r6 fixed the dieOnDisconnect=false path (player reconnects still fallen, join-side apply skipped). The dup came back because the common config is dieOnDisconnect=true. Decompiled revive_me-1.21.1-5.7.14 + corpse-neoforge-1.21.1-1.1.13: - ReviveMe CapabilityEvents.onLogout (NORMAL priority): for a fallen player it calls pauseTimerOnLogout() + removeAllEffects(), and when the config dieOnDisconnect is true ALSO calls FallenData.forceDeath(). - forceDeath() deals lethal damage → LivingDeathEvent → LivingDropsEvent. - Corpse mod DeathEvents.playerDeath(LivingDropsEvent) creates the corpse via DeathManager.addDeath(); corpsecurioscompat / cosmeticcorpsecompat pull curios + cosmetics into it. - So with dieOnDisconnect=true the player is force-killed on disconnect. On reconnect they are no longer "fallen" — r6's join-side skip never triggers — and doPlayerJoin restores the DB inventory next to the corpse = duplication. - ReviveMe.onLogout and PlayerSync.onPlayerLogout are both NORMAL priority → undefined order. Whenever PlayerSync ran first it saved the still-attached pre-death inventory. r7 adds the logout-side guard that r6 was missing: - onPlayerLogout now detects isReviveMeFallen(player) || player.isDeadOrDying() — exact, via ReviveMe's FallenData. This is true whether PlayerSync's handler ran BEFORE ReviveMe's forceDeath (player still fallen) or AFTER it (player dead), so it is correct regardless of the undefined handler order. - New handleFallenLogout + writeReviveLogoutClearItemsToDB clears every item-bearing DB column (inventory / armor / left_hand / cursors + curios + accessories + cosmeticarmor), persists non-item progression, sets online=0 — the corpse becomes the single source of truth. - Gated on the keepInventory game rule being OFF: with it ON a death drops nothing and forms no corpse, so clearing the DB would destroy the player's items — the normal save path runs instead. - r6's join-side apply-skip is kept for the dieOnDisconnect=false path (player reconnects still fallen → keeps the vanilla .dat inventory instead of the cleared DB row). NeoForge attachments (neoforge_attachments) and enderchest are never cleared — progression / non-dropping data.
|
@laforetbrut would it be hard to implement Applied Energestics compat too? :) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hey @mlus-asuka,
I've been running PlayerSync on our network (Arcadia Echoes of Power modpack, ~30 concurrent players) and ran into a couple of nasty issues that turned into a fairly large patch. I've cleaned everything up and battle-tested it on production for a few weeks — opening it as a PR in case any of it is worth upstreaming.
This branch removes Cobblemon integration and the chat-sync feature entirely. Both were running synchronous JDBC on the main thread (or close to it) and were the largest source of TPS issues on our network. I didn't want to silently drop features other users might rely on, so I'd rather flag this clearly than have it surprise anyone.
A few options on how to handle that on your side:
1.21.1-dev(e.g.1.21.1-perf). Keep Cobblemon / chat-sync on the existing line for users who depend on them.1.20.1,1.20.4, etc.) — most of the perf and anti-dup logic is independent of the MC version.Arcadiabranch that lives alongside1.21.1-dev— explicitly tuned for the Arcadia Echoes of Power modpack (no Cobblemon, no chat-sync, perf-first). That keeps your mainline clean and gives modpacks with a similar setup something they can target directly. I'd be happy to maintain that branch and forward upstream-able fixes back to1.21.1-devover time.What it fixes
Backpack duplication (root cause)
BackpackStorage.setBackpackContents()upstream is actually a shallow merge, not a replace, when the UUID already exists. Stale sub-tags from a previous session survived every restore — players ended up with their old backpack contents alongside the new ones on rejoin. The fix is one line: callremoveBackpackContents(uuid)beforesetBackpackContentsso the replacement is clean.Curios cosmetic stacks were silently wiped
snapshotCuriosData/applyCuriosFromDataonly iteratedgetStacks()— every cosmetic curio (getCosmeticStacks()) was lost on every server transfer. Now both banks are captured and restored under acos:slotType:indexkey. Old-format rows without thecos:prefix still parse unchanged so existing DBs upgrade cleanly.Several race conditions
pendingLogoutSaves.put()now happens before the async dispatch, with a manually completedCompletableFuture. The old ordering let a fast reconnect observenullwhile the save was already queued.executeBatchTransactionnow returns per-statement affected-row counts.writeSnapshotToDBcallsSyncLogger.guardBlocked(...)when the core UPDATE silently no-ops because another server has claimedlast_server. That was completely invisible before.onPlayerDeathis nowEventPriority.LOWwith anevent.isCanceled()guard — defends against Revive Me / Corail Tombstone cancelling the death.RejectedExecutionExceptionfrom the executor during server stop is caught and the future is drained, so a rejoin doesn't hang for 15 s.Sophisticated Storage thread-safety
SS contents are now snapshotted on the main thread (
snapshotSSDatareturns a frozen.copy()of each entry) and persisted from the background. PreviouslyItemContentsStoragewas read directly from a background thread against a non-thread-safeHashMap.closeContainerwas too aggressiveThe class-name heuristic (
menu.getClass().getName().contains("curio") || "accessor" || …) could force-close unrelated mod menus mid-transaction. Replaced with a strict slot-reference scan: only menus whose slots actually point at the disconnecting player's inventory or ender-chest are closed.What's removed (please read)
Cobblemon integration
The 6 mixins were running synchronous JDBC on the Cobblemon save thread (often the main server thread) and building SQL with raw UUID concatenation. The full PC NBT was serialized to SNBT via
toString()on every save — multi-MB strings injected into a SQL string. Removed entirely. Existingcobblemontables in the DB are left untouched for backward compat — the table just becomes orphaned, no data destroyed. If you want Cobblemon support back, I'd rewrite it in a separate addon-jar pattern so the perf cost only hits servers that actually use it.Chat sync
319 LoC of socket/thread code guarded by a config flag that defaulted to
false. Orphaned config keys (sync_chat,IsChatServer,ChatServerIP,ChatServerPort) are silently ignored by NeoForge ModConfig on upgrade — no crash. Same recommendation: if you want it back, separate addon.What's added
Dedicated logging system (
SyncLogger)A separate diagnostic log written to
logs/playersync/sync.logwith categories:SAVE,RESTORE,DUPE_RISK,DATA_LOSS,RACE,GUARD,SAVE_FAIL,RESTORE_FAIL,SAVE_SKIP,PERF_SLOW,EVENT. Rotates at 10 MB, keeps 5 files. Truly async — a dedicated low-priority daemon scheduler flushes every 500 ms, so no log call ever touches I/O on the caller thread. This made the duplication root-cause analysis possible — every silently-blocked write (last_serverguard mismatch) now shows up as aGUARDentry.Admin commands (
/playersync)Full
/playersynccommand tree with 14 admin subcommands: pool status, force-save / force-restore / force-unlock a UUID, list pending logout saves, inspect a player's inventory in DB, dump current config, toggle verbose logging at runtime, etc. Plus 20+ new config keys to fine-tune behaviour without recompiling. (These shipped in our Phase 8 commit — kept verbatim in this branch.)Extended mod compatibility
AETHER_PLAYERattachmentWORLD_TIER/RADIAL_MINING_MODEattachmentsAny mod using NeoForge
AttachmentTypesis now picked up automatically via reflection onAttachmentHolder.serializeAttachments/deserializeAttachments(resolved once in a static initializer and cached asMethodreferences). That covers Ars Nouveau, Iron's Spellbooks, Pehkui, Spice of Life: Onion, etc. without any explicit handler.Configurable table prefix
New
table_prefixconfig option (defaults to empty for backward compat). Lets admins share a single MySQL database with other mods (LuckPerms, custom mods) without table-name collisions. Routes through a newTableshelper so every SQL string in the codebase resolves at runtime.HikariCP retune
Pool size 25 → 15, connectionTimeout 30 s → 10 s, idleTimeout 600 s → 300 s, leakDetectionThreshold 10 s → 25 s (covers the worst-case
doPlayerJoinpoll without log spam).Defensive guards
onServerStartingearly-returns on integrated (single-player) servers — no more noisy MySQL attempts when someone loads the mod in a dev client.alterColumnIfNeededhelper queriesINFORMATION_SCHEMA.COLUMNSbefore anyALTER TABLE … MODIFY COLUMNso we don't re-run an ALTER that forces an MDL lock + table rebuild on every boot.Performance results
Measured on production (Arcadia Echoes of Power modpack, 30 active players, heavy modset — Apotheosis, Sophisticated*, RS, Aether, Curios, Iron's Spellbooks, Ars Nouveau, etc.):
The remaining ~1 % during joins is mostly the
doPlayerJoinpoll loop (waiting on the previous server's save to land). I think there's still room to push that further — replacing theThread.sleep(500)loop with aScheduledExecutorServiceexponential backoff would be the natural next step — but I've reached the limit of what I can confidently change without risking regressions. I'd rather stop on something tested than push experimental changes.README & repo hygiene
TEST_PROCEDURE_v2.1.5.htmlremoved from the tree — it was a local QA artefact that shouldn't have been pushed. Added a.gitignoreentry forTEST_PROCEDURE_*.htmlandtest-procedures/to keep it out.Backward compatibility
Designed as a drop-in upgrade:
CREATE TABLE IF NOT EXISTS, noDROP, noALTER COLUMNthat changes existing types.cos:prefix) still load correctly.How it was tested
last_serverguard correctly blocks the stale write from the dead node.Thanks for the original mod — it's a huge part of what makes our network actually work, and the design (atomic transactions,
last_serverguard, async writes) made all of this much easier to extend than it would otherwise have been. Happy to address any review feedback, split this into smaller PRs if it's easier to review, or rework anything you'd prefer done differently.