fix(datastore): stop a stale lock locking a player out permanently - #784
Open
buildthomas wants to merge 2 commits into
Open
fix(datastore): stop a stale lock locking a player out permanently#784buildthomas wants to merge 2 commits into
buildthomas wants to merge 2 commits into
Conversation
…d take Lock age decided one question and not the other. AcquireLock treats a foreign lock older than GetAutoSaveTimeSeconds() * 2.1 as belonging to a crashed server and takes it. ToUnlockedProfile, guarding the save, only ever asked whether the lock is ours -- LastUpdateTime appears nowhere in it. So a foreign lock that outlives a load is stealable on load and fatal on save, at any age. The save fires SessionStolen, the manager kicks the player with "DataStore session stolen by another active session", and the write that was about to land is dropped. Nothing rewrites or releases the lock, so the next session repeats it exactly. Waiting does not help: the save path has no notion of age to wait out. Observed in production as players locked out indefinitely, recoverable only by deleting the lock from the key by hand. Both halves now ask _isLockStale, so they cannot disagree about the same lock. The rule loosened is a save by a session whose lock was taken while it was gone for longer than the protocol's own definition of dead. A fresh foreign lock still reports theft and a lock with no LastUpdateTime still does, both covered by tests. Worth a second opinion on: the alternative reading is that the save side should stay strict and the load side should guarantee it never leaves a foreign lock behind, which is the other commit on this branch.
A DataStore destroyed while an UpdateAsync is in flight raises out of its own
transform:
Transform function error ...datastore.Server.DataStore:696:
attempt to call missing method 'AcquireLock' of table
Promise.spawn hands the request to a thread it does not retain, so the promise
a teardown cancels is not the call -- Roblox invokes the transform regardless.
By then BaseObject.Destroy has run setmetatable(obj, nil) on the store AND on
its session-locking helper (the maid strips the helper first, then the store
itself), so both keep their identity and lose every method. The first method
dispatch the transform performs raises, and the raise aborts the write Roblox
was about to commit.
The abort is worse than the log line suggests. On the save path (line 591,
ToUnlockedProfile) a player's staged data is silently dropped. On the load
path it kills the steal-write that would have replaced a stale foreign lock,
so the lock survives for the next session to trip over -- invisibly, because
the load already resolved from inside the transform and the late failure lands
in a catch guarded by IsPending, which is already false.
The guard therefore cannot be a method on the store: post-Destroy, calling
self:anything() is the same class of crash. Both transforms instead read
self._sessionLockingEnabledHelper as a raw field -- safe on a metatable-less
table -- and treat a helper whose metatable is gone as proof of teardown,
cancelling the write through the transforms' existing cancel path. A store
without session locking keeps its old behavior: the save transform's
IsRejected check cancels it.
Also logs the caller when a store is removed with its first load still
outstanding, which is the window this happens in. Gated on that window rather
than logged per removal, which would be one traceback per player per shutdown.
DataStoreMock records a throwing transform so a spec can tell an aborted write
from a cancelled one; the pcall in DataStorePromises otherwise swallows it.
buildthomas
force-pushed
the
users/buildthomas/fix-datastore-teardown-during-request
branch
from
August 2, 2026 23:45
b33cc66 to
700fe11
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.
Two defects that compose into players being unable to join at all, indefinitely, with no recovery short of editing their key by hand. Confirmed in production; stripping the
lockentry from an affected key over Open Cloud fixes that player every time, which is what localised it.The kick: lock age decides one question and not the other
AcquireLock, on load, treats a foreign lock older thanGetAutoSaveTimeSeconds() * UNLOCK_BY_DEFAULT_TIME_MULTIPLIERas belonging to a crashed server and takes it.ToUnlockedProfile, guarding the save, only ever asks whether the lock is ours —LastUpdateTimeappears nowhere in it. So a foreign lock that outlives a load is stealable on load and fatal on save, at any age: the save firesSessionStolen,PlayerDataStoreManagerkicks with "DataStore session stolen by another active session", and the write that was about to land is dropped. Waiting does not help — the save path has no notion of age to wait out. We had players locked out 11+ hours, kicked within two seconds of every join.Both halves now ask
_isLockStale, so they cannot disagree about the same lock. The save that follows rewrites the lock as ours, so an affected key heals itself.The rule loosened is a save by a session whose lock was taken after going quiet past the protocol's own definition of dead. A fresh foreign lock still reports theft, and one with no
LastUpdateTimestill does — both covered by tests. The alternative reading is that the save side should stay strict and the load side should guarantee it never leaves a foreign lock behind; I went with symmetry because any other route to a surviving foreign lock reproduces the lockout, and two halves disagreeing about the same predicate is a defect regardless of the threshold chosen.The persistence: a teardown that lands mid-request aborts the write
Promise.spawnhands the request to a thread it does not retain, so the promise a teardown cancels is not the call — Roblox invokes the transform regardless. By thenBaseObject.Destroyhas runsetmetatable(obj, nil)on the store and on its session-locking helper, so the transform's first method dispatch raises, and the raise aborts the write Roblox was about to commit. On the save path (line 591,ToUnlockedProfile) that silently drops a player's staged data. On the load path it kills the steal-write that would have replaced a stale foreign lock — invisibly, because the load already resolved from inside the transform, and the late failure lands in a:Catchguarded byIsPending(), which is already false.Because a metatable-less table still serves field reads but no method calls, the guard cannot itself be a method on the store — post-Destroy,
self:anything()is the same class of crash. Both transforms readself._sessionLockingEnabledHelperas a raw field and treat a helper whose metatable is gone as proof of teardown, cancelling through the transforms' existing cancel path. Stores without session locking keep their old behavior (the save transform'sIsRejectedcheck cancels them).How the loop closed in production
SessionStolen→ kick.Commit 1 removes step 3 (and step 4's theft check); commit 2 removes step 4's abort. The exact interleaving in step 3/4 depends on per-key request scheduling that source alone can't prove, but it is the only mechanism we found consistent with a key still holding an 11-hour-old lock after many join attempts.
Not fixed here, flagged as follow-ups
IsPending()guard — success, throttle, and abort are indistinguishable to the caller. That blindness enabled the loop; resolving after the commit settles is a behavioural change I did not want to fold in uninvited.Save()/SaveAndCloseSession()resolve, and callers (receipt processors, shutdown flushes) believe data persisted that did not.Also included
IsLoadPending(), and a traceback log in_removePlayerDataStorewhen a store is removed with its first load still outstanding — the window the teardown bug lives in. Gated on that window rather than logged per removal, which would be one traceback per player per shutdown.DataStoreMockrecords a throwing transform (GetLastTransformError); without it a spec cannot tell an aborted write from a cancelled one, since both leave the store untouched and the pcall inDataStorePromisesswallows the error.Tests
DataStore.SessionLock.spec.lua: a stale foreign lock validates on the save side as it does on the load side; slightly-old andLastUpdateTime-less foreign locks still report theft.DataStore.TeardownDuringRequest.spec.lua(new): destroy mid-load, destroy mid-save, and no lock left behind afterwards. Each blocks the mock, starts the request, destroys the store, unblocks, then asserts the transform did not raise and nothing was written. The mock parks blocked requests in atask.waitloop, so the resumed thread genuinely runs the transform against an already-destroyed store — the production scenario.Verification
styluaandseleneclean; the specs were hand-traced against the mock's blocking semantics. The locallint:luau/test toolchain is broken on this machine (rojo --versionpanics on the aftman spec), and no CI checks have run on this fork PR yet — the suite needs a CI run before this merges.