Skip to content

datastore: fix session-locked load hang + comprehensive session-lock tests#736

Merged
Quenty merged 13 commits into
mainfrom
users/quenty/datastore-hardening
Jul 18, 2026
Merged

datastore: fix session-locked load hang + comprehensive session-lock tests#736
Quenty merged 13 commits into
mainfrom
users/quenty/datastore-hardening

Conversation

@Quenty

@Quenty Quenty commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Fixes a session-locked DataStore load that hung forever when the underlying UpdateAsync failed (e.g. a 509 Personal-RCC block): the rejection was swallowed, so the load never settled and everything downstream — save-slot loading, player load — stalled with no error surfaced. Op failures now reject the load with the preserved error and fail fast instead of grinding the ~49s acquire backoff, while genuine lock contention still retries and the stale-lock steal still works. DataStoreStage also isolates a throwing saving callback so it fails the save cleanly with its stack trace, and destroying a store mid-load no longer surfaces an uncaught rejection.

Supporting this, PromiseRetryUtils.retry gains an optional shouldRetry predicate, PromiseUtils gains race, and a shared PromiseTestUtils provides bounded, non-polling awaits for tests. A first-class in-memory DataStoreMock (with failure injection and a block/unblock gate) plus SetRobloxDataStore seams, and a matching MessagingServiceMock plus a SetRobloxMessagingService seam, let the session-locking and cross-server messaging flows be characterized end-to-end without touching real services. Comprehensive datastore, session-lock, and save-slot suites are added, along with a test place for the promise and save-slot packages.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Test Results

Package Status Try it
@quenty/datastore ✅ Passed (278/278) (24.7s) Open | Play
@quenty/chatproviderservice ✅ Passed (3/3) (1.3s) Open | Play
@quenty/saveslot ✅ Passed (20/20) (1.6s) Open | Play
@quenty/secrets ✅ Passed (2/2) (1.0s) Open | Play
@quenty/settings ✅ Passed (5/5) (1.2s) Open | Play
@quenty/settings-inputkeymap ✅ Passed (1/1) (1.2s) Open | Play
@quenty/nevermoreclimanifest ✅ Passed (6/6) (651ms) Open | Play
@quenty/promise ✅ Passed (14/14) (1.2s) Open | Play
@quenty/gameproductservice ✅ Passed (1.2s) Open | Play
@quenty/animations ✅ Passed (8/8) (851ms) Open | Play
@quenty/ik ✅ Passed (3/3) (934ms) Open | Play
@quenty/rogue-humanoid ✅ Passed (1.2s) Open | Play
@quenty/rogue-properties ✅ Passed (153/153) (2.3s) Open | Play
@quenty/soundgroup ✅ Passed (4/4) (917ms) Open | Play
@quenty/conditions ✅ Passed (900ms) Open | Play
@quenty/gameconfig ✅ Passed (1.1s) Open | Play
@quenty/tie ✅ Passed (5/5) (1.1s) Open | Play
@quenty/binder ✅ Passed (3/3) (834ms) Open | Play
@quenty/deathreport ✅ Passed (4/4) (868ms) Open | Play
@quenty/blend ✅ Passed (3/3) (801ms) Open | Play
@quenty/snackbar ✅ Passed (3/3) (851ms) Open | Play
@quenty/camera ✅ Passed (8/8) (918ms) Open | Play
@quenty/brine ✅ Passed (59/61) (1.0s) Open | Play
@quenty/clienttranslator ✅ Passed (23/23) (1.1s) Open | Play
@quenty/inputkeymaputils ✅ Passed (6/6) (1.1s) Open | Play
@quenty/fakeskybox ✅ Passed (3/3) (901ms) Open | Play
@quenty/elo ✅ Passed (5/5) (951ms) Open | Play
@quenty/observablecollection ✅ Passed (204/204) (1.8s) Open | Play
@quenty/cmdrservice ✅ Passed (734ms) Open | Play
@quenty/lipsum ✅ Passed (4/4) (684ms) Open | Play
@quenty/rx ✅ Passed (3/3) (802ms) Open | Play
@quenty/brio ✅ Passed (35/35) (784ms) Open | Play
@quenty/characterutils ✅ Passed (11/11) (851ms) Open | Play
@quenty/clipcharacters ✅ Passed (1/1) (834ms) Open | Play
@quenty/permissionprovider ✅ Passed (2/2) (884ms) Open | Play
@quenty/instanceutils ✅ Passed (15/15) (884ms) Open | Play
@quenty/roblox-api-dump ✅ Passed (32/33) (1.4s) Open | Play
@quenty/screenshothudservice ✅ Passed (720ms) Open | Play
@quenty/influxdbclient ✅ Passed (9/9) (901ms) Open | Play

39 tested, 39 passed, 0 failed in 1m22s · View logs

Deploy Results

Package Status Try it
@quenty/integration ✅ Deployed (v109) (1.8s) Open | Play

1 deployed, 1 passed, 0 failed in 2.5s · View logs

Comment thread src/datastore/src/Server/DataStore.spec.lua Outdated
Comment thread src/datastore/src/Server/DataStoreHookErrors.spec.lua Outdated
Comment thread src/datastore/src/Server/DataStoreHookErrors.spec.lua Outdated
Comment thread src/datastore/src/Server/PlayerDataStoreManager.spec.lua Outdated
Comment thread src/datastore/src/Server/PlayerDataStoreService.spec.lua Outdated
Comment thread src/datastore/src/Server/PrivateServerDataStoreService.spec.lua
Comment thread src/saveslot/src/Server/SaveSlotLoadFlow.spec.lua Outdated
Comment thread src/saveslot/src/Server/SaveSlotService.spec.lua Outdated
Comment thread src/datastore/src/Server/Modules/DataStoreSnapshotUtils.spec.lua
Comment thread src/datastore/src/Server/Modules/DataStoreStage.lua Outdated
Comment thread src/datastore/src/Server/Modules/DataStoreStage.lua
Comment thread src/datastore/src/Server/Mocks/DataStoreMock.lua Outdated
Comment thread src/datastore/src/Server/DataStoreSessionMessaging.spec.lua Outdated
Comment thread src/datastore/src/Server/GameDataStoreService.spec.lua Outdated
Comment thread src/datastore/src/Server/GameDataStoreService.spec.lua
@Quenty
Quenty force-pushed the users/quenty/datastore-hardening branch from 7dd64a7 to 1fc183c Compare July 17, 2026 18:10
Quenty added 4 commits July 17, 2026 12:31
PromiseRetryUtils.retry gains an optional shouldRetry predicate. When it returns false after a
failure, the retry stops immediately and rejects with that error, so failures that will not
recover by retrying can fail fast instead of grinding the backoff.

Also adds PromiseUtils.race (settles with the first input promise to settle, resolving or
rejecting) and a first-class PromiseTestUtils (awaitSettled/awaitValue/awaitOutcome) that awaits
by racing a timeout rather than polling with wait(), so a hung promise fails a test instead of
freezing the runner. Adds a test place for the promise package with specs covering retry and the
new helpers.
DataStoreMock is a strict, in-memory GlobalDataStore stand-in: deep-copy round-tripping (so
aliasing bugs surface like the real service), failure injection including the 509 Personal-RCC
block, and a block/unblock gate for exercising in-flight requests. DataStorePromises.isDataStore
accepts it anywhere a real datastore Instance is expected.

PlayerDataStoreService, GameDataStoreService and PrivateServerDataStoreService gain
SetRobloxDataStore(robloxDataStore) so a mock can be injected into a ServiceBag-driven flow.

Adds a matching in-memory MessagingServiceMock and a SetRobloxMessagingService seam on
PlaceMessagingService, so cross-server session messaging can be exercised in-process without
touching the real MessagingService.
…ocked load

A session-locked load previously hung forever when the underlying UpdateAsync failed (e.g. a 509
Personal-RCC block): the rejection was swallowed inside whilePromise, so the load never settled and
everything downstream (save-slot loading, player load) hung with no error surfaced.

Now a :Catch on the UpdateAsync distinguishes an op failure (UpdateAsync rejects, its transform
never runs) from lock contention (UpdateAsync resolves with a cancelled write). Op failures reject
the load with the preserved error and, via the new PromiseRetryUtils shouldRetry predicate, fail
fast rather than grinding the ~49s acquire backoff. Lock contention still retries and the stale-lock
steal still works.

DataStore also gains SetLoadRetryOptions and SetSessionMessagingCloseDelaySeconds to tune the
acquire backoff and graceful-close propagation delay.

DataStoreStage now xpcall-isolates saving callbacks: a throwing callback fails the save cleanly with
its stack trace preserved, instead of leaking an uncaught error. The fire-and-forget load/save
side-effect branches and cancelled-load rejections are also consumed, so destroying a store mid-load
no longer surfaces an uncaught exception.
…save-slot suites

Datastore: unit + integration coverage for DataStoreMock, DataStore, DataStoreStage, DataStoreWriter,
DataStoreSnapshotUtils, DataStorePromises, DataStoreLockHelper, DataStoreMessageHelper,
PlayerDataStoreManager, and the three ServiceBag services. Session locking is characterized end to
end with two DataStores over one mock: clean handoff, retry-resolved contention, stale-lock crash
recovery, a concurrent load race, the MessagingService graceful-close handshake and production
eviction, session-stolen (no duplication), and the read-before-store dupe that motivates locking.
Also covers in-flight cancellation / re-entrance, hook-error isolation, and shared-load fan-out.
Cross-server messaging runs against an injected MessagingServiceMock rather than the real service.

Saveslot: adds a test place and integration tests for SaveSlotService config, the save-slot load
flow, and the real HasSaveSlots binder (driven against a mock by binding a fake Folder player).

Waits go through the shared PromiseTestUtils helpers, throwing assertions check the error message,
and load timings are tuned via the new setters so the suite stays fast.
@Quenty
Quenty force-pushed the users/quenty/datastore-hardening branch from 1fc183c to 25f48bb Compare July 17, 2026 19:32
…ing CLI injection

The specs required the running place to have been stamped by the CLI deploy-metadata
pass, which only runs on the cloud test/deploy path. The standard Jest CI job runs
specs in a plain Roblox VM with no injection, so deployed was false and every spec
failed.

Stamp a synthetic instance the way the CLI does and assert the reader returns
reasonable, well-shaped values (hex commit/version, test target, IDs that round-trip
from strings back to numbers), plus the un-stamped Studio case. A separate
running-place section still asserts the real deploy stamp, but guards on deployed so it
only checks it on the cloud path and stays green in the injection-free CI job.
…o background tasks can't leak across packages

The CI test job runs every package's specs in one shared Roblox place, sequentially. A DataStore
starts a task.spawn auto-save loop once it loads and only cancels it on :Destroy(), so a spec that
leaves a loaded store undestroyed keeps that loop running; when it later fires Save/Sync it throws in
whatever package's window happens to be active. That flakily marked @quenty/secrets FAILED even though
its own suite passed — the leak came from the datastore specs.

DataStoreHookErrors was the one leaking on the passing path: newManager() built a session-locked,
loaded PlayerDataStoreManager and the tests only destroyed the serviceBag, never the manager (a
standalone object the bag doesn't own), so its store's auto-save loop survived every run.

Route every spec's teardown through a file-level Maid cleaned in afterEach, so no store, manager,
helper, or serviceBag can outlive a test even when an assertion throws or a promise hangs. Standalone
objects use maid:Add (DoCleaning skips an object the test already destroyed, since BaseObject.Destroy
nils the metatable); where destroy order matters the maid runs a closure that destroys the child
before the serviceBag it borrows PlaceMessagingService from. No assertions, timings, or mocks changed.
…troy() controller pattern

Replaces the file-level Maid + afterEach cleanup with the codebase-standard setup()/destroy()
controller pattern used by the rogue-properties specs and Hoarcekat stories. Each spec has a local
setup() that creates a Maid, maid:Add()s every object it builds (the ServiceBag inline, plus stores,
managers, and helpers via small factories), and returns a controller table of named fields/factories
with destroy = function() maid:DoCleaning() end. Every test does local controller = setup(), uses the
controller, and calls controller:destroy() at the end and before each hung-promise early return.

This keeps the same leak fix (nothing a test builds outlives it, so no background task leaks into a
later package's window in the shared test place) while matching the standard object-ownership idiom.
The local Maid also makes the mid-test :Destroy() tests safe for free — DoCleaning skips an object
whose metatable was already nil'd, so there is no double-destroy. No assertions, timeouts, mock
behavior, or test descriptions changed. Testing docs updated to document the pattern.
Quenty added 3 commits July 17, 2026 17:25
… teardown

PlayerDataStoreManager and GameDataStoreService tracked their DataStores
outside their maid, so destroying the manager/service (a hot-reloaded
ServiceBag, or a unit test) left the stores alive: their auto-save loops kept
running and, once the async load resolved, wrote through a now-orphaned
scheduler. On Destroy these now fire a best-effort synchronous Save() (the
UpdateAsync request is dispatched before Destroy cancels the promise, so a
live server usually honors it, though it is not guaranteed) and then tear the
store down. PrivateServerDataStoreService already destroyed its store but
never flushed first; it now does too.

Adds teardown coverage (destroys the inner store, and flushes staged data
synchronously) across all four services/managers.

Claude-Session: https://claude.ai/code/session_01JgaGXK6J76zChNKPGmmW5b
…ery HasSaveSlots test binds

The HasSaveSlots binder spec re-spied PlayerDataStoreManager._toPlayerUserIdOrError
in each test's setup() and restored it with jest.restoreAllMocks() in afterEach.
In Jest-Lua, jest.spyOn is a no-op the second time it targets a method after a
restore: it hands back the stale, already-restored spy without re-patching the
object, so only the first test's spy took effect. Every later test ran the real
method against the fake Folder, threw "Bad playerOrUserId", and binder:Bind()
returned nil -- failing all downstream assertions and emitting uncaught-error
noise that fails the package.

Monkeypatch the class field directly and restore it in afterEach instead, which
re-patches deterministically each setup. All 20 saveslot tests pass.

Claude-Session: https://claude.ai/code/session_01JgaGXK6J76zChNKPGmmW5b
@Quenty
Quenty merged commit 58fba2f into main Jul 18, 2026
11 checks passed
@Quenty
Quenty deleted the users/quenty/datastore-hardening branch July 18, 2026 03:53
@Quenty

Quenty commented Jul 18, 2026

Copy link
Copy Markdown
Owner Author

🚀 PR was released in @quenty/actionmanager@13.35.0, @quenty/adorneeboundingbox@8.40.0, @quenty/adorneedata@7.35.0, @quenty/adorneevalue@10.39.0, @quenty/aggregator@1.17.0, @quenty/animationprovider@11.40.0, @quenty/animations@8.34.0, @quenty/assetserviceutils@5.35.0, @quenty/attributeutils@14.31.0, @quenty/avatareditorutils@7.41.0, @quenty/badgeutils@10.19.0, @quenty/basicpane@13.32.0, @quenty/binder@14.38.0, @quenty/bindtocloseservice@8.34.0, @quenty/blend@12.38.0, @quenty/bodycolorsutils@7.38.0, @quenty/boundlinkutils@14.39.0, @quenty/brine@1.7.0, @quenty/brio@14.31.0, @quenty/buttondragmodel@1.31.0, @quenty/buttonhighlightmodel@14.38.0, @quenty/camera@14.44.0, @quenty/camerastoryutils@10.23.0, @quenty/canceltoken@11.20.0, @quenty/characterutils@12.34.0, @quenty/chatproviderservice@9.56.0, @quenty/clienttranslator@14.40.0, @quenty/clipcharacters@12.43.0, @quenty/cmdrservice@13.48.0, @quenty/collectionserviceutils@8.32.0, @quenty/color3utils@11.38.0, @quenty/colorpalette@10.41.0, @quenty/colorpicker@10.39.0, @quenty/conditions@10.42.0, @quenty/contentproviderutils@12.33.0, @quenty/cooldown@11.40.0, @quenty/coreguienabler@12.35.0, @quenty/coreguiutils@10.19.0, @quenty/counter@7.32.0, @quenty/datastore@13.41.0, @quenty/deathreport@10.48.0, @quenty/depthoffield@11.40.0, @quenty/elo@7.39.0, @quenty/enabledmixin@11.28.0, @quenty/equippedtracker@13.36.0, @quenty/fakeskybox@11.20.0, @quenty/firstpersoncharactertransparency@14.36.0, @quenty/flipbook@9.34.0, @quenty/friendutils@12.31.0, @quenty/funnels@1.26.0, @quenty/gameconfig@12.52.0, @quenty/gameproductservice@14.55.0, @quenty/gamescalingutils@13.38.0, @quenty/genericscreenguiprovider@13.41.0, @quenty/grouputils@10.21.0, @quenty/guivisiblemanager@12.24.0, @quenty/hide@11.40.0, @quenty/highlight@10.42.0, @quenty/hintscoringutils@14.45.0, @quenty/httppromise@10.19.0, @quenty/humanoiddescriptionutils@10.19.0, @quenty/humanoidspeed@12.52.0, @quenty/humanoidtracker@13.32.0, @quenty/humanoidutils@2.13.0, @quenty/idleservice@13.55.0, @quenty/ik@15.56.0, @quenty/influxdbclient@7.36.0, @quenty/inputkeymaputils@14.46.0, @quenty/inputmode@13.36.0, @quenty/inputobjectutils@4.31.0, @quenty/insertserviceutils@10.19.0, @quenty/instanceutils@13.31.0, @quenty/jsonutils@10.19.0, @quenty/linkutils@13.32.0, @quenty/lipsum@14.38.0, @quenty/localizedtextutils@12.32.0, @quenty/marketplaceutils@11.22.0, @quenty/memorystoreutils@6.19.0, @quenty/messagingserviceutils@7.23.0, @quenty/modeltransparencyeffect@11.19.0, @quenty/motor6d@7.44.0, @quenty/mouseshiftlockservice@11.20.0, @quenty/multipleclickutils@13.29.0, @quenty/nevermoreclimanifest@1.2.0, @quenty/observablecollection@12.40.0, @quenty/pagesutils@5.20.0, @quenty/particleengine@13.35.0, @quenty/parttouchingcalculator@14.43.0, @quenty/pathfindingutils@10.20.0, @quenty/permissionprovider@14.41.0, @quenty/physicsutils@8.29.0, @quenty/playerbinder@14.38.0, @quenty/playerhumanoidbinder@14.39.0, @quenty/playerinputmode@9.41.0, @quenty/playersservicepromises@10.19.0, @quenty/playerthumbnailutils@10.19.0, @quenty/playerutils@8.34.0, @quenty/policyserviceutils@6.19.0, @quenty/promise@10.19.0, @quenty/promisemaid@5.19.0, @quenty/promptqueue@1.39.0, @quenty/propertyvalue@7.31.0, @quenty/qframe@10.24.0, @quenty/r15utils@13.32.0, @quenty/racketingropeconstraint@12.45.0, @quenty/radial-image@9.39.0, @quenty/ragdoll@15.55.0, @quenty/receiptprocessing@7.35.0, @quenty/remotefunctionutils@10.19.0, @quenty/remoting@12.33.0, @quenty/resetservice@11.38.0, @quenty/rigbuilderutils@10.36.0, @quenty/roblox-api-dump@8.23.0, @quenty/rogue-humanoid@10.52.0, @quenty/rogue-properties@11.48.0, @quenty/rx@13.29.0, @quenty/rxbinderutils@14.38.0, @quenty/rxsignal@7.29.0, @quenty/saveslot@1.5.0, @quenty/scoredactionservice@16.47.0, @quenty/screenshothudservice@7.36.0, @quenty/seatutils@7.36.0, @quenty/secrets@7.53.0, @quenty/selectionutils@8.32.0, @quenty/settings-inputkeymap@10.59.0, @quenty/settings@11.56.0, @quenty/snackbar@11.44.0, @quenty/socialserviceutils@10.19.0, @quenty/softshutdown@9.51.0, @quenty/soundgroup@1.43.0, @quenty/soundplayer@7.44.0, @quenty/sounds@10.23.0, @quenty/spawning@10.50.0, @quenty/sprites@13.31.0, @quenty/statestack@14.33.0, @quenty/streamingutils@10.19.0, @quenty/sunpositionutils@2.9.0, @quenty/teamtracker@13.32.0, @quenty/teamutils@10.32.0, @quenty/teleportserviceutils@9.32.0, @quenty/templateprovider@11.40.0, @quenty/textboxutils@7.29.0, @quenty/textfilterservice@13.33.0, @quenty/textfilterutils@10.19.0, @quenty/textserviceutils@13.38.0, @quenty/tie@10.42.0, @quenty/timedtween@7.38.0, @quenty/timesyncservice@13.34.0, @quenty/toolutils@1.18.0, @quenty/transitionmodel@7.40.0, @quenty/ugcsanitize@1.18.0, @quenty/uiobjectutils@6.30.0, @quenty/undostack@7.32.0, @quenty/userserviceutils@9.33.0, @quenty/valuebaseutils@13.31.0, @quenty/valueobject@13.32.0, @quenty/viewport@11.45.0, @quenty/voicechat@5.19.0 🚀

@Quenty Quenty added the released This issue/pull request has been released. label Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released This issue/pull request has been released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant