fix: do not discard database writes queued during shutdown - #3044
Merged
Conversation
tastybento
force-pushed
the
feat/synchronous-save-api
branch
from
July 28, 2026 06:07
91865c6 to
9ae1275
Compare
Queued writes are drained by an asynchronous repeating task. That task stops once BentoBox is disabled, and `store(...)` refuses to run a queued write when the plugin is disabled, so anything still in the queue at that point is thrown away silently. Pladdons are disabled by the server *before* BentoBox, which puts every one of them in exactly that position: whatever they persist from `onDisable()` is queued into something that is about to be abandoned. Nine addons currently save state there - Boxed, AOneBlock, Raft, Challenges, Limits, DragonFights, CheckMeOut, ControlPanel and InvSwitcher - so on most restarts each of them loses whatever changed since its last write. It presents as intermittent data loss with nothing in the log, because whether the async task gets a tick in first is a race. Adds a drain on the way out: - `AbstractDatabaseHandler.flush()` runs this handler's outstanding writes on the calling thread; `flushAll()` does it for every handler. - Handlers are now tracked in a weak static registry. One is created per `Database` instance, each with its own queue, and nothing else kept a list of them, so there was no way to reach them all. - A `draining` flag exempts flushed writes from the disabled-plugin guard in the JSON and SQL handlers - without it the flush would discard exactly what it is meant to save. - `BentoBox.onDisable()` calls `flushAll()` after `disableAddons()` and before any database closes, which covers both Pladdons (already disabled by then) and ordinary addons (disabled a line earlier). Also adds an explicit synchronous save for callers that need a guarantee rather than a rescue: - `AbstractDatabaseHandler.saveObjectNow(T)` and `Database.saveObjectNow(T)`, overridden in every handler that queues (JSON, SQL, SQLite, PostgreSQL, Yaml, and Transition delegating onward). Each shares a private `save(instance, forceSync)` with `saveObject` so the two paths cannot drift. MongoDB needed no override - it already writes synchronously. - The base method is non-abstract and defaults to `saveObject(T)`, so third-party handlers still compile. And deprecates `Database.saveObject(T)`, which is the reason this was hard to find: it is named as though the write has completed on return, but it delegates to `saveObjectAsync` and always returns `true` without waiting for or checking the result. It now points at `saveObjectAsync(T)` for normal saves, and warns that `saveObjectNow(T)` is not a drop-in replacement because it blocks. The nine internal call sites move to `saveObjectAsync`, so the deprecation does not arrive pre-suppressed by warnings in our own code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YXnYDGiASdSUZFtNyS4jbc
tastybento
force-pushed
the
feat/synchronous-save-api
branch
from
July 28, 2026 06:13
9ae1275 to
31187cc
Compare
|
Merged
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.



The bug
Queued writes are drained by an asynchronous repeating task. That task stops once BentoBox is disabled, and
store(...)then refuses to run a queued write:Pladdons are disabled by the server before BentoBox, so everything a Pladdon persists from
onDisable()is queued into something about to be abandoned. From a real shutdown log:Whether the async task manages a tick in first is a race, which is why this presents as intermittent data loss with nothing in the log.
Scope
Nine addons save state in
onDisable, and all nine are Pladdons: Boxed, AOneBlock, Raft, Challenges, Limits, DragonFights, CheckMeOut, ControlPanel, InvSwitcher. Third-party Pladdons doing the same are affected identically and have no way to know.Reproduced end to end on a live server with InvSwitcher: pick up items on an island, restart while still connected, and the items are gone — the shutdown save never lands, and
onPlayerJointhen re-applies the stale stored inventory over the player's real one, so it is an active rollback rather than merely a missed write.Changes
Drain on the way out — fixes existing addons with no addon changes at all:
AbstractDatabaseHandler.flush()runs this handler's outstanding writes on the calling thread;flushAll()does it for every handler. Per-writetry/catchso one bad write cannot abandon the rest.getHandler()creates one perDatabaseinstance, each with its own queue, and nothing else kept a list of them — there was no way to reach them all. Weak so handlers from a discardedDatabasestay collectable.volatile boolean drainingexempts flushed writes from the disabled-plugin guard in the JSON and SQL handlers. Without it the flush would discard exactly what it exists to save.BentoBox.onDisable()callsflushAll()afterdisableAddons()and before anything closes. That covers Pladdons (already disabled by the server) and ordinary addons (disabled a line earlier), whileplayersManager/islandsManagershutdown saves that run afterwards already take the synchronous path.Explicit synchronous save — for callers wanting a guarantee rather than a rescue:
AbstractDatabaseHandler.saveObjectNow(T)andDatabase.saveObjectNow(T), overridden in every queueing handler (JSON, SQL, SQLite, PostgreSQL, Yaml, Transition delegating onward). Each shares a privatesave(instance, forceSync)withsaveObjectso the paths cannot drift. MongoDB needed no override — already synchronous.saveObject(T), so third-partyAbstractDatabaseHandlersubclasses still compile.Deprecates
Database.saveObject(T)— the reason this was hard to find. It reads as though the write has completed on return, but delegates tosaveObjectAsyncand always returnstruewithout waiting for or checking the result. Now points atsaveObjectAsync(T)for normal saves, and warns thatsaveObjectNow(T)is not a drop-in replacement because it blocks — a mechanical migration to it would turn island/player/economy writes into synchronous main-thread writes. The nine internal call sites move tosaveObjectAsync, so the deprecation does not arrive pre-suppressed by warnings in our own code.Verification
Full suite: 3401 tests, 0 failures, 0 errors.
10 new tests in
SQLDatabaseHandlerTest. Verified they actually detect the regressions — with both fixes reverted:Those 4 pin the behaviour; the other 6 pass either way by design (null / not-a-DataObject early returns, the empty-queue no-op, the draining-flag lifecycle, and the plugin-disabled
saveObjectNowvariant which takes the sync path regardless) and are coverage rather than regression detection.All
CompletableFuture.get()calls in the new tests are bounded with a timeout. An earlier draft used unboundedget(), which meant that under a regression the queued write never completed the future and the test hung instead of failing — it would have stalled CI rather than reporting.Notes for review
flushAll()should bound how long it will spend draining. It is bounded by queue depth today, which in practice is small, but a pathological queue would extend shutdown.drainingexemption deliberately weakens a guard that exists to stop writes after teardown. It is only ever true insideflush(), which runs before any connector is closed, and is cleared in afinally.🤖 Generated with Claude Code
https://claude.ai/code/session_01YXnYDGiASdSUZFtNyS4jbc