Skip to content

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

Open
Quenty wants to merge 4 commits into
mainfrom
users/quenty/datastore-hardening
Open

datastore: fix session-locked load hang + comprehensive session-lock tests#736
Quenty wants to merge 4 commits into
mainfrom
users/quenty/datastore-hardening

Conversation

@Quenty

@Quenty Quenty commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Problem

A session-locked DataStore load hangs forever when the underlying UpdateAsync fails (e.g. a 509 "Data Store operations blocked while running on a Personal RCC"). The rejection is swallowed inside whilePromise in DataStore._promiseGetAsyncNoCache, so the load promise never settles. Everything downstream hangs with no error surfaced — most visibly, save-slot loading (HasSaveSlots:PromiseSlotsLoaded()) and player load never complete or report failure.

Fix

The key insight: during a session-locked load, lock contention makes UpdateAsync resolve (the transform runs and cancels the write by returning nil), whereas an op failure makes UpdateAsync reject (the transform never runs). So a single :Catch on the UpdateAsync promise cleanly distinguishes them:

  • Op failure → reject the load with the preserved error, and — via a new optional shouldRetry predicate on PromiseRetryUtils.retryfail fast instead of grinding the ~49s acquire backoff (Roblox already retries internally; an initial op failure won't recover).
  • Lock contention → still retries (waits for the holder to release), and the stale-lock steal still works.

Production defaults are unchanged; only the previously-hanging path changes behavior.

Also included

  • DataStoreStage now xpcall-isolates saving callbacks: a throwing callback fails the save cleanly with its stack trace preserved, instead of leaking an uncaught error.
  • Tunable timings: DataStore:SetLoadRetryOptions(...) and :SetSessionMessagingCloseDelaySeconds(...) (keep the suite fast; production defaults intact).
  • A first-class DataStoreMock (in-memory GlobalDataStore stand-in with deep-copy round-tripping, failure injection incl. the 509, and a block/unblock gate) accepted via DataStorePromises.isDataStore, plus SetRobloxDataStore seams on the three ServiceBag services for mock-injected integration tests.

Tests

Comprehensive new coverage (all green): datastore 267/267, saveslot 20/20.

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. Plus in-flight cancellation / re-entrance, a not-settling lock command, hook-error isolation, and shared-load fan-out. Save-slot side adds a test place and integration tests including the real HasSaveSlots binder (bound to a fake Folder player).

Run with nevermore test --cloud from each package.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WJ7WBBpACmLFdAoNhZ5drz

Quenty and others added 4 commits July 16, 2026 23:41
…s.retry

Callers can pass `shouldRetry` in RetryOptions. 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJ7WBBpACmLFdAoNhZ5drz
DataStoreMock is an 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 for testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJ7WBBpACmLFdAoNhZ5drz
…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 (Roblox already retries internally). 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.

Separately, DataStoreStage now xpcall-isolates saving callbacks: a throwing
callback fails the save cleanly with its stack trace preserved, instead of
leaking an uncaught error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJ7WBBpACmLFdAoNhZ5drz
…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. Load timings are tuned via the new setters so the suite
stays fast; jest testTimeout raised to 10s.

Saveslot: adds a test place (jest.config, deploy target, bootstrap wiring, test
deps) 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 and intercepting the single UserId read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJ7WBBpACmLFdAoNhZ5drz
@Quenty
Quenty deployed to integration July 17, 2026 06:43 — with GitHub Actions Active
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Test Results

Package Status Error Try it
@quenty/datastore Failed (25.4s) Test failed Open | Play
@quenty/chatproviderservice ✅ Passed (3/3) (1.8s) Open | Play
@quenty/saveslot Failed (2.3s) Test failed Open | Play
@quenty/secrets ✅ Passed (2/2) (1.5s) Open | Play
@quenty/settings ✅ Passed (5/5) (1.9s) Open | Play
@quenty/settings-inputkeymap ✅ Passed (1/1) (1.6s) Open | Play
@quenty/gameproductservice ✅ Passed (1.6s) Open | Play
@quenty/animations ✅ Passed (8/8) (1.2s) Open | Play
@quenty/ik ✅ Passed (3/3) (1.3s) Open | Play
@quenty/rogue-humanoid ✅ Passed (1.8s) Open | Play
@quenty/rogue-properties ✅ Passed (153/153) (3.0s) Open | Play
@quenty/soundgroup ✅ Passed (4/4) (1.4s) Open | Play
@quenty/conditions ✅ Passed (1.3s) Open | Play
@quenty/gameconfig ✅ Passed (1.7s) Open | Play
@quenty/tie ✅ Passed (5/5) (1.5s) Open | Play
@quenty/binder ✅ Passed (3/3) (1.2s) Open | Play
@quenty/deathreport ✅ Passed (4/4) (1.2s) Open | Play
@quenty/blend ✅ Passed (3/3) (1.1s) Open | Play
@quenty/snackbar ✅ Passed (3/3) (1.3s) Open | Play
@quenty/camera ✅ Passed (8/8) (1.3s) Open | Play
@quenty/brine ✅ Passed (59/61) (1.4s) Open | Play
@quenty/clienttranslator ✅ Passed (23/23) (1.6s) Open | Play
@quenty/inputkeymaputils ✅ Passed (6/6) (1.5s) Open | Play
@quenty/fakeskybox ✅ Passed (3/3) (1.3s) Open | Play
@quenty/elo ✅ Passed (5/5) (1.4s) Open | Play
@quenty/observablecollection ✅ Passed (204/204) (2.4s) Open | Play
@quenty/cmdrservice ✅ Passed (997ms) Open | Play
@quenty/lipsum ✅ Passed (4/4) (1.1s) Open | Play
@quenty/rx ✅ Passed (3/3) (1.1s) Open | Play
@quenty/brio ✅ Passed (35/35) (1.1s) Open | Play
@quenty/characterutils ✅ Passed (11/11) (1.2s) Open | Play
@quenty/clipcharacters ✅ Passed (1/1) (1.3s) Open | Play
@quenty/permissionprovider ✅ Passed (2/2) (1.3s) Open | Play
@quenty/instanceutils ✅ Passed (15/15) (1.2s) Open | Play
@quenty/roblox-api-dump ✅ Passed (32/33) (1.7s) Open | Play
@quenty/screenshothudservice ✅ Passed (1.1s) Open | Play
@quenty/influxdbclient ✅ Passed (9/9) (1.3s) Open | Play
@quenty/nevermoreclimanifest Failed (852ms) Test failed Open | Play

38 tested, 35 passed, 3 failed in 1m36s · View logs

Deploy Results

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant