Class ChestSessionManager
sessions map, and it does so only on the
onGlobal(java.lang.Runnable) bookkeeping thread. Every chest open funnels through open(org.bukkit.entity.Player, java.util.UUID, int, org.bukkit.Location) (the sole funnel
point); concurrent admin operations chain behind forceCloseAll(java.util.UUID, int) + runExclusive(java.util.UUID, int, java.util.function.Supplier<T>).
Shared live inventory (concurrent-edit) model
Every open chest is backed by a single sharedInventory held in sessions, keyed by
(owner, index). Owner and admin openInventory() the same object, so Bukkit serialises
all item moves on one ItemStack[] — making item-level duping between concurrent viewers
structurally impossible on a single-threaded platform.
Folia caveat: two viewers may live on different region threads, where a shared inventory is unsafe. On Folia we therefore allow only one live viewer per chest (a second opener is denied); on Paper concurrent editing is fully supported.
All session bookkeeping (the sessions map, viewer sets, attach/detach/persist decisions)
runs on a single thread via onGlobal(java.lang.Runnable): the main thread on Paper, the global region thread on
Folia. This removes registry-level races on both. The DB read and the byte→ItemStack decode both
happen on the async executor (the stored bytes are immutable and the decoded stacks are handed to the
global thread through the future's happens-before edge, untouched by any other thread) — only the cheap
inventory build runs on the global thread. Encoding on save stays synchronous on the global thread and
only ever happens once all viewers have closed (no concurrent edit during encode) — that half of the
contract is load-bearing; do not move it off-thread.
Dupe-safety contract (preserved, now per shared session):
- the first open of a chest waits for any in-flight async save of that same chest, then loads fresh from the DB; subsequent opens attach to the live session and never re-read the DB while it is open (the live inventory is authoritative).
- the chest is persisted when its last viewer closes (or a force-close fires), encoding the shared contents synchronously on the global thread then flushing to the DB on a daemon thread, keyed by (owner, index).
- flushPendingSaves() in onDisable() blocks until all writes finish before the pool closes.
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionstatic final recordA public (owner, index) reference for multi-chest exclusive operations (seerunExclusiveAcross(java.util.List<com.enhancedechest.service.ChestSessionManager.ChestRef>, java.util.function.Supplier<T>)). -
Constructor Summary
ConstructorsConstructorDescriptionChestSessionManager(LanguageManager lang, ContainerCodec codec, EnderChestStorage storage, org.slf4j.Logger logger, Scheduler scheduler, DbExecutor db, Telemetry telemetry, ChestActivityLogger activityLog) -
Method Summary
Modifier and TypeMethodDescriptionvoiddetach(org.bukkit.entity.Player player, EnderChestHolder holder) Detaches a viewer when they close the shared GUI (called from the GUI close and quit listeners on the player's entity thread).forceCloseAll(UUID owner, int index) Force-closes the GUI of every viewer of(owner, index), then persists the shared contents and tears down the session — returning a future that completes once the save has been registered inpendingSaves.booleanhasActivity(UUID owner) True while any chest ofownerhas a live session or an in-flight save/exclusive op.booleanMarks a chest as edited during its current session, from the click/drag listener on the viewer's own thread.voidmarkTouched(UUID owner, int index) voidopen(org.bukkit.entity.Player player, UUID owner, int index, @Nullable org.bukkit.Location sourceBlock) The single funnel through which every chest open passes.<T> CompletableFuture<T> runExclusive(UUID owner, int index, Supplier<T> dbWork) Serializes arbitrary DB work for one (owner, index) behind any in-flight save/op for that key, registering it inpendingSavesso a concurrentopenwaits for it.<T> CompletableFuture<T> runExclusiveAcross(List<ChestSessionManager.ChestRef> refs, Supplier<T> dbWork) LikerunExclusive(java.util.UUID, int, java.util.function.Supplier<T>)but spanning several chests at once: chainsdbWorkbehind the in-flight save/op of everyref, and registers a marker for each so a concurrentopenof any of them waits for the work to finish.voidshutdown()Flushes all live sessions and waits for their writes before the plugin disables.
-
Constructor Details
-
ChestSessionManager
public ChestSessionManager(LanguageManager lang, ContainerCodec codec, EnderChestStorage storage, org.slf4j.Logger logger, Scheduler scheduler, DbExecutor db, Telemetry telemetry, ChestActivityLogger activityLog)
-
-
Method Details
-
open
public void open(org.bukkit.entity.Player player, UUID owner, int index, @Nullable @Nullable org.bukkit.Location sourceBlock) The single funnel through which every chest open passes. On the player's entity thread: a request for the chest they are already viewing is dropped (it is a stale duplicate — closing and reopening would churn a save/load cycle and replay the lid sound); a different chest GUI is closed first (flushing its session). Then hands off todecideOpen(org.bukkit.entity.Player, java.util.UUID, int, org.bukkit.Location)on the global bookkeeping thread to attach to — or create — the live session for(owner, index). -
detach
Detaches a viewer when they close the shared GUI (called from the GUI close and quit listeners on the player's entity thread). Removes them from the session on the global thread and, if they were the last viewer, persists the shared contents. A no-op if the session was already torn down by a force-close (which persists itself) — so the same close never double-saves. -
hasActivity
True while any chest ofownerhas a live session or an in-flight save/exclusive op. Read-only scan of the two concurrent maps, safe from any thread. The cross-server handover handler consults this before flushing + releasing an owner another server asked for: while a close-save is still in flight the handover simply waits for the requester's next ask, so the flush can never miss the final contents of a chest that was open at quit time. -
runExclusive
Serializes arbitrary DB work for one (owner, index) behind any in-flight save/op for that key, registering it inpendingSavesso a concurrentopenwaits for it. The work runs on the async executor; the returned future completes with its result (or its failure). -
runExclusiveAcross
public <T> CompletableFuture<T> runExclusiveAcross(List<ChestSessionManager.ChestRef> refs, Supplier<T> dbWork) LikerunExclusive(java.util.UUID, int, java.util.function.Supplier<T>)but spanning several chests at once: chainsdbWorkbehind the in-flight save/op of everyref, and registers a marker for each so a concurrentopenof any of them waits for the work to finish. Used by the multi-chest, two-player/ee transferso its whole DB transaction is one dupe-safe critical section.Callers must
forceCloseAll(java.util.UUID, int)every ref first (to flush live edits); this only serialises the DB work behind the resulting saves. The work runs on the async executor; the returned future completes with its result (or its failure). -
forceCloseAll
Force-closes the GUI of every viewer of(owner, index), then persists the shared contents and tears down the session — returning a future that completes once the save has been registered inpendingSaves. The caller can then chain an exclusive op that serialises behind that save, keeping admin resize/delete dupe-safe even with multiple concurrent viewers.The persist runs only after all viewer screens have actually closed (their close handlers see
closingand skip their own save), so the shared inventory is read with no viewer still editing it — safe to encode on the global thread even on Folia. -
isActivityRecording
public boolean isActivityRecording()Marks a chest as edited during its current session, from the click/drag listener on the viewer's own thread. Only the activity log reads this, and only to decide whether capturing the closing contents is worth the work: a chest nobody touched cannot have changed, so its visit is dropped without building a snapshot at all. A no-op when the log is off or there is no live session. -
markTouched
-
shutdown
public void shutdown()Flushes all live sessions and waits for their writes before the plugin disables. Does not close the async executor — that pool is owned byDbExecutorand shut down separately, after this returns, so the flush above can still dispatch onto it.
-