Retry failed player saves instead of draining them to SafeStorage - #1262
Merged
Merged
Conversation
GregHib
approved these changes
Sep 3, 2026
Fixes GregHib#1250. The exception handler closed over `pending` rather than the batch that failed, so one failed write dumped every account queued at that moment -- including ones storage was never asked to write -- into `SafeStorage` and removed them from `pending`. `SafeStorage.load` returns null and `exists` returns false, so nothing ever reads those files back: the next login served whatever the account file held before the session. Replace the handler with a try/catch inside the save coroutine so the failed batch is the one that gets handled. A failure now leaves the accounts in `pending` and the next tick retries real storage, which self-heals a transient error. `storage.save.retryMinutes` (default 5) bounds that; past it the account is written to the failed saves directory and dropped, so a permanently broken backend doesn't wedge the queue forever. While an account is pending it still can't log in, which is what stops a stale file being served under a save that hasn't landed. `direct()` passes retry = false. At shutdown there is no next tick, and the fallback write now happens inside the job the caller joins rather than in a sibling coroutine the process could exit before running. Known limitation: `run()` submits all of `pending` as one batch, so an account whose data reliably breaks serialisation takes the batch with it for the whole retry window. Isolating that needs per-account writes and is a larger change.
HarleyGilpin
force-pushed
the
fix/save-queue-fallback-drain
branch
from
September 3, 2026 15:06
f263361 to
ed83c4e
Compare
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.
Fixes #1250. Rebased onto
mainnow that #1249 has merged — the diff is the three files below and nothing else.The bug
SaveQueue's exception handler closed overpendingrather than the batch that failed:run()snapshotspending.values.toList()and hands that tostorage.save. If the write throws, the handler re-readspending, which by then also holds accounts queued after the snapshot, ones storage was never asked to write. All of them go tofallback, which writes them out and clears them.The fallback is
SafeStorage, and it's write-only:loadreturns null,existsreturns false,namesreturns an empty map, and the files are named"$timestamp-$name.toml"so nothing would find them anyway. So a failed batch meant those accounts were removed frompending, never retried, and invisible to every laterload. The next login served whatever the account file held before the session, with no signal to the player and oneError saving players!line as the only trace.Blast radius wider than the failure: a disk momentarily full took out every account queued at that instant, not the batch that failed.
The fix
The handler goes away in favour of a try/catch inside the coroutine, so the failed batch is the one that gets handled.
On failure the accounts stay in
pendingand the next tick retries real storage. A transient error self-heals.storage.save.retryMinutes(default 5) bounds the retry per account; past it the account is written to the failed saves directory and dropped, so a permanently broken backend doesn't wedge the queue forever. Five minutes covers the #1212 shape, a host stall that pushed a tick to 25918ms, without being an indefinite hang.While an account is pending it still can't log in (
PlayerAccountLoadercheckssaveQueue.saving). That's the point: refusing the login is better than serving a stale file under a save that hasn't landed.direct()passesretry = false. At shutdown there's no next tick, so a failure should dump immediately, and the dump now happens inside the job the caller joins, rather than in a sibling coroutine the process could exit before it runs. That second half was a real hole:AutoSave'sworldDespawnjoins the save job, and the old handler'sscope.fallback(...)was not that job.A
CoroutineExceptionHandlerstays on the scope purely as a last-resort log, so anErrorescaping thecatch (e: Exception)isn't silent.Tests
Three in
SaveQueueTest, each checked by reverting the corresponding production change and confirming the right test fails:Failure only touches the accounts that were attempted— blocks a save on a latch, queues a second account while the first is in flight, then fails the write. The second account must not be dumped and must stay pending. Fails whenfailed()is fedpending.values.toList().Failed save retries against real storage and doesn't kill the queue— replaces the oldFailed save falls back and doesn't kill the queue, which asserted the drain behaviour this PR removes. A transient failure must be retried and must not reach the fallback. Fails when the retry window is dropped.Shutdown save writes to the fallback before the job completes—direct().join()must return with the account already on disk somewhere. Fails whendirect()retries instead.Full suite green.
Known limitation
run()submits all ofpendingas one batch, so an account whose data reliably breaks serialisation takes the whole batch with it for the retry window before everything gets dumped together. Isolating a poison account needs per-account writes, which is a bigger change than this one and would tradeDatabaseStorage's batched transaction for N of them. Left alone deliberately.