@quenty/datastore 13.51.0 → 13.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [13.52.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.51.1...@quenty/datastore@13.52.0) (2026-08-13)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **datastore:** break the cmdr/service require cycle ([b7ece57](https://github.com/Quenty/NevermoreEngine/commit/b7ece577b509e2a394b864e4a538070fbe94ffc9))
11
+ - **datastore:** release a borrowed session even when the caller is torn down ([2537a0b](https://github.com/Quenty/NevermoreEngine/commit/2537a0b8e90a658cca96f83a515b5ffac8662518))
12
+
13
+ ### Features
14
+
15
+ - **datastore:** add Cmdr commands for session locks and stored data ([adc36ac](https://github.com/Quenty/NevermoreEngine/commit/adc36ac2a25dc5aa0c481b6746b78fec331951e4))
16
+ - **datastore:** read and write a key's session lock without opening a session ([21da4a6](https://github.com/Quenty/NevermoreEngine/commit/21da4a6b9fa4ce7752a41a386385033a785becef))
17
+ - **datastore:** release borrowed sessions through a counted handle ([8c3d2f8](https://github.com/Quenty/NevermoreEngine/commit/8c3d2f8f32595be89f6297e4a223d858c3634822))
18
+ - Fix a lot of cmdr issues ([05b6025](https://github.com/Quenty/NevermoreEngine/commit/05b60255c6f436872895e8d9398fed12dd3ab1d5))
19
+ - **saveslot:** make every save slot command work on players who are not in this server ([795549c](https://github.com/Quenty/NevermoreEngine/commit/795549cdcb4c8584a4acd22eb7377c7aea149182))
20
+
21
+ ## [13.51.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.51.0...@quenty/datastore@13.51.1) (2026-07-30)
22
+
23
+ ### Bug Fixes
24
+
25
+ - **datastore:** stop manager teardown from destroying stores before PlayerRemoving saves them ([44d786e](https://github.com/Quenty/NevermoreEngine/commit/44d786edb1501c37d540d6a3226084f4d0859f8b))
26
+ - **datastore:** stop the spec shutdown helper stalling on a bag that was never started ([0696638](https://github.com/Quenty/NevermoreEngine/commit/0696638700a33cbcedc85f4c75099f54cb952a6c))
27
+
6
28
  # [13.51.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.50.2...@quenty/datastore@13.51.0) (2026-07-28)
7
29
 
8
30
  **Note:** Version bump only for package @quenty/datastore
package/README.md CHANGED
@@ -18,6 +18,16 @@ This system is a reliable datastore system designed with promises and asyncronio
18
18
  ## Executive overiew
19
19
  This datastore prevents data loss by being explicit about what we're writing to, and only modifying the data that exists there instead of modifying the whole structure.
20
20
 
21
+ ## Working on this package
22
+
23
+ Read this before changing how player data is saved, and before adding any cleanup, teardown, or
24
+ flush to that path. It records design intent that cannot live in the code, because the shape is
25
+ driven by Roblox shutdown and cross-server session-lock behavior that is not visible from reading it:
26
+
27
+ - [`docs/shutdown-and-session-locks.md`](docs/shutdown-and-session-locks.md) — why every save routes
28
+ through one function, what `PromiseAllSaves` has to wait on, and why flushing stores on manager
29
+ teardown was tried twice and removed.
30
+
21
31
  ## Comparison to other solutions
22
32
 
23
33
  * Not specifically locked to players
@@ -0,0 +1,183 @@
1
+ # Shutdown and session locks
2
+
3
+ Why [PlayerDataStoreManager] saves the way it does. Written for whoever changes this package next.
4
+ Consumers do not need any of it — using the manager correctly requires nothing from this file.
5
+
6
+ Read it before adding any cleanup, teardown, or flush to the save path. Two attempts at exactly that
7
+ regressed player data in production, in opposite directions, and both looked obviously correct.
8
+
9
+ ## The engine behavior this rests on
10
+
11
+ Two facts about Roblox, neither of them discoverable from this package's code:
12
+
13
+ **A closing server fires `PlayerRemoving` for every player still in it**, and holds the shutdown open
14
+ for those handlers the same way it does for `BindToClose`. A restart is not a case where players
15
+ "never leave" — they all leave, then the server closes. So `PlayerRemoving` is the save path during a
16
+ shutdown, not a peacetime-only path that something else has to stand in for.
17
+
18
+ **A session lock can only be released by the server holding it.** Nothing gives one server the
19
+ authority to unlock another's key. If a server dies still holding a lock, the next server that loads
20
+ that key sees a lock that still looks live and takes the graceful route: it messages the holder's
21
+ JobId asking it to close, and that JobId is gone, so nothing answers.
22
+
23
+ The cost of that is worth knowing precisely, because the obvious knob is the wrong one. Each attempt
24
+ dies on the **hardcoded 5s timeout in `DataStoreMessageHelper.PromiseCloseSessionGraceful`**, not on
25
+ `SetSessionMessagingCloseDelaySeconds` — that delay sits in the *fulfilled* branch of
26
+ `_promiseGetAsyncNoCache` and is never reached when the holder is dead. Six attempts of that is 30s,
27
+ and `PromiseRetryUtils` adds five jittered inter-attempt waits totalling ~49s, so a player waits
28
+ roughly **80 seconds** before the lock is finally stolen. Turning `SetSessionMessagingCloseDelaySeconds`
29
+ down does not shorten it; the lever is that hardcoded 5.
30
+
31
+ ## The shape
32
+
33
+ Everything converges on one function:
34
+
35
+ ```
36
+ Players.PlayerRemoving ─┐
37
+ SessionStolen ──────────┤
38
+ SessionCloseRequested ──┼──► _removePlayerDataStore(userId)
39
+ PromiseSessionLockingFailed ─┤ removing callbacks
40
+ RemovePlayerDataStore ──┘ └─► SaveAndCloseSession() -- writes data AND releases the lock
41
+ └─► Destroy() -- only after that write settles
42
+
43
+ BindToCloseService ────────► PromiseAllSaves()
44
+ removes anything left, then WAITS
45
+ ```
46
+
47
+ `_removePlayerDataStore` is idempotent and order-independent: it returns early if the store is already
48
+ gone from `_datastores` or already `_removing`. So on a shutdown, whichever entry point reaches a given
49
+ player first performs the complete sequence and the others no-op. There is deliberately **no path that
50
+ destroys a store before its save-and-close has been attempted and settled** — note "attempted": the
51
+ `Destroy()` sits in a `Finally`, so a save that rejects still tears the store down.
52
+
53
+ That `Finally` has a second consequence worth knowing. `Promise.Finally` is `Then(f, f)` and `f`
54
+ returns nothing, so the derived promise *fulfills* even when the save rejected. `removalPromise`
55
+ therefore never rejects, and `PromiseAllSaves` resolving means every removal **settled**, not that
56
+ every write succeeded. It is the right shape for a shutdown — one player's failed write must not
57
+ abandon everyone else's — but do not read a resolved close as proof of a successful save.
58
+
59
+ `PromiseAllSaves` is a *waiter*, not a saver. `BindToCloseService` yields on it, which is the only
60
+ thing keeping the server alive long enough for the writes to land.
61
+
62
+ ## What `PromiseAllSaves` has to wait on, and why it isn't obvious
63
+
64
+ It waits on the in-flight removal chains in `_removingPromises`, not only on `_pendingSaves`. This is
65
+ the part that actually loses live-server data, so it is worth following exactly.
66
+
67
+ `_pendingSaves` is fed by a `datastore.Saving:Connect` that lives on the per-user maid at
68
+ `self._maid._savingConns[userId]`. The last thing `_removePlayerDataStore` does is
69
+ `self._maid._savingConns[userId] = nil`, which cleans that maid and **disconnects `Saving`**. And
70
+ `DataStore` fires `Saving` at the very *end* of `_doDataSync` — after `PromiseViewUpToDate()` and after
71
+ every saving callback has resolved.
72
+
73
+ So the two only line up when the whole removal chain runs synchronously. The moment anything in it
74
+ yields — an async removing callback, an async saving callback, or a session-locked load still in flight
75
+ because the player left seconds after joining — `SaveAndCloseSession` reaches `Saving` *after* the
76
+ connection is already gone, and that save can **never** enter `_pendingSaves` at all. It is not a race
77
+ that usually goes the right way; it is a permanent miss.
78
+
79
+ The old `PromiseAllSaves` then had literally nothing to wait on: `PromiseUtils.all({})` returns an
80
+ already-fulfilled promise, `BindToCloseService` stops yielding, and Roblox kills the server mid-write —
81
+ leaving the session locked, with nobody able to release it, and the next server paying the ~80 seconds.
82
+ Any consumer with an async removing or saving callback hits this, which is most of them.
83
+
84
+ Waiting on `_removingPromises` fixes that, because the entry is inserted synchronously and cleared in an
85
+ identity-guarded `Finally`, so a fast leave/rejoin cannot clobber the entry the close is waiting on.
86
+
87
+ It does not close the hole completely, and the remaining sliver is worth knowing rather than
88
+ rediscovering. `_removePlayerDataStore` latches `_removing[userId] = true` *before* it invokes the
89
+ removing callbacks, and only inserts into `_removingPromises` *after* that loop returns. A callback that
90
+ yields inside the loop — or throws out of it — leaves a window where the removal is latched but untracked,
91
+ and a close landing in that window early-returns on the `_removing` guard and again finds nothing to wait
92
+ on. A throwing callback makes it permanent: `_removing` stays true, so that store can never be removed.
93
+
94
+ This is pre-existing, not introduced by the change that added the wait, and the window is far smaller
95
+ than what it replaced: previously the miss lasted the whole removal for *any* non-synchronous chain,
96
+ where now it lasts only while a consumer callback is on the stack inside one loop.
97
+ `RemovalCallbacks.spec.lua` characterizes both callback behaviours, though neither test drives a close
98
+ across the window, so the sliver itself is uncovered.
99
+
100
+ Closing it means publishing the tracking entry before any consumer code runs — a pending promise
101
+ inserted at latch time and resolved to the real chain afterwards. That is a change to
102
+ `_removePlayerDataStore` itself, so it wants its own review, and note it only fixes the yielding case:
103
+ after a *throw* the placeholder is never resolved, so the close would hang on it instead of resolving
104
+ early. Arguably the better failure, but it needs the callback loop isolated to actually be closed.
105
+
106
+ ## Rejected: flushing the stores on manager teardown
107
+
108
+ `_flushAndDestroyAll` existed for twelve days (added 2026-07-17, removed 2026-07-29) and was removed. It
109
+ ran from the manager's Maid and, for every store still in `_datastores`, saved and then destroyed it
110
+ synchronously.
111
+
112
+ It was added for a real reason — a `DataStore` starts a `task.spawn` auto-save loop once loaded and
113
+ only cancels it on `Destroy()`, so a manager torn down without destroying its stores leaks those loops
114
+ (which matters in the shared test place, where a leaked loop fires inside a later package's window).
115
+
116
+ It was wrong anyway, because **destroying the manager is not a shutdown.** No path in `ServiceBag`,
117
+ `BindToCloseService`, or any game in this repo destroys it on close; only a hot reload, a Studio stop, or
118
+ a test does. But wherever something did, the teardown got there before `PlayerRemoving`, cleared
119
+ `_datastores`, and destroyed the stores — so the removal that would have saved and closed the session
120
+ found nothing and returned early. Both flavors failed:
121
+
122
+ - With `Save()`, the data was flushed but the lock was never released, handing the next server the
123
+ ~80-second ladder.
124
+ - With `SaveAndCloseSession()`, the lock was released, but the store was destroyed synchronously
125
+ against its own in-flight write, and the session was closed while players were still in the server
126
+ and still writing. Everything still holding the store wrote into a destroyed object:
127
+ `attempt to call missing method 'GetSubStore'`, and consumers' own "datastore already cleaned up"
128
+ guards firing on a loop for the rest of the shutdown window.
129
+
130
+ The lesson is narrow and worth stating plainly: **the leak was a test-harness problem and belonged in
131
+ the test harness.** `DataStoreTestUtils` now shuts a manager down the way Roblox does before tearing it
132
+ down (`promiseSimulatedShutdown`), which removes the stores through the real path and cancels their
133
+ loops as a side effect. Production code did not need a second save path, and could not safely have one.
134
+
135
+ ## Deliberate: a removing callback that never resolves blocks its removal forever
136
+
137
+ There is no timeout on the removing callbacks, and that is the intended behavior, not an oversight. A
138
+ callback that never settles holds its removal open, so the save never happens and the lock stays held,
139
+ and Roblox tears the thread down at the shutdown cap. A timeout here would convert that into a silent
140
+ partial save on a cadence nobody chose — better to let the consumer's broken callback take the blame.
141
+
142
+ Be honest about the diagnostic, though: nothing here names the callback that hung. By the time the
143
+ removal is stuck, the callback has already *returned* (it handed back a promise that never settles), so
144
+ its frame is gone; the thread Roblox kills is `BindToCloseService`'s `:Yield()`. If chasing one of these
145
+ gets painful, the missing piece is a `task.delay` warn naming the still-pending userIds — not a timeout.
146
+
147
+ `PlayerDataStoreManager.RemovalCallbacks.spec.lua` characterizes it under "failure modes" so the
148
+ behavior is pinned rather than accidental.
149
+
150
+ Since `PromiseAllSaves` now awaits the removal chains, this is also a new source of shutdown latency,
151
+ and it is bounded and benign: `PromiseUtils.all` never short-circuits, so one stuck member delays the
152
+ close but cannot cancel or skip the others, and every other player's removal was already dispatched and
153
+ completes concurrently. Roblox's ~30s cap ends it either way. The likely trigger in practice is not a
154
+ hung callback but a contended session-locked load — a player who joined seconds before the shutdown can
155
+ hold the close for as long as the acquire ladder runs.
156
+
157
+ ## Testing this
158
+
159
+ One consequence of dropping the flush: the `DataStore` instances are not owned by any maid (only
160
+ `_removePlayerDataStore`'s `Finally` destroys them), so **`manager:Destroy()` now destroys no stores.**
161
+ A harness that tears down by destroying a ServiceBag therefore leaves every loaded store alive with its
162
+ auto-save loop running — and a `PlayerMock` never fires the real `Players.PlayerRemoving`, so nothing
163
+ else removes them either. Any spec built that way has to shut down explicitly before it tears down.
164
+
165
+ Resist the urge to fix that by re-adding a destroy on the manager's maid. A destroy-only teardown is
166
+ worse than the leak: it would leave `_datastores` populated with destroyed stores, and clearing
167
+ `_datastores` too would silently drop the save instead.
168
+
169
+ Not via a later `PlayerRemoving`, though — `Maid.DoCleaning` disconnects every `RBXScriptConnection` in a
170
+ dedicated first pass before it runs any function task, so the manager's `PlayerRemoving` handler is
171
+ already gone by then. The reachable case is a removal *already in flight*: a removing callback that
172
+ yielded resumes to find the store destroyed under it, and `SaveAndCloseSession()` hits a nil metatable.
173
+ The leak is a harness problem; keep the fix in the harness.
174
+
175
+ `manager:Destroy()` is not a shutdown and specs must not use it as one — several did, which is how the
176
+ teardown flush looked well covered while being wrong. Drive
177
+ `DataStoreTestUtils.promiseSimulatedShutdown(manager, userIds)` instead: it fires the removals, then
178
+ returns the promise `BindToCloseService` would yield on.
179
+
180
+ The assertion that matters is not "the data was saved" but "the data was saved *by the time the close
181
+ resolved*" — that is the difference between a server that shuts down cleanly and one that dies holding
182
+ a lock. `PlayerDataStoreManager.spec.lua`'s "does not resolve the close while a PlayerRemoving save is
183
+ still in flight" pins it, using an async removing callback to open the window.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/datastore",
3
- "version": "13.51.0",
3
+ "version": "13.52.0",
4
4
  "description": "Quenty's Datastore implementation for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -31,6 +31,7 @@
31
31
  "dependencies": {
32
32
  "@quenty/baseobject": "10.15.0",
33
33
  "@quenty/bindtocloseservice": "8.41.0",
34
+ "@quenty/cmdrservice": "13.57.0",
34
35
  "@quenty/loader": "10.11.0",
35
36
  "@quenty/maid": "3.11.0",
36
37
  "@quenty/math": "2.7.5",
@@ -51,5 +52,5 @@
51
52
  "publishConfig": {
52
53
  "access": "public"
53
54
  },
54
- "gitHead": "65232b0907168a4733c7930268142fb86ef75da4"
55
+ "gitHead": "e69be5d5744cafda1f21b26c37845a8173506fc6"
55
56
  }
@@ -0,0 +1,48 @@
1
+ --!strict
2
+ --[=[
3
+ Client half of the datastore Cmdr commands. Cmdr parses arguments on the executor, so the types
4
+ [DataStoreCmdrService]'s commands take have to be registered here too.
5
+
6
+ @client
7
+ @class DataStoreCmdrServiceClient
8
+ ]=]
9
+
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local CmdrServiceClient = require("CmdrServiceClient")
13
+ local DataStoreCmdrUtils = require("DataStoreCmdrUtils")
14
+ local Maid = require("Maid")
15
+ local ServiceBag = require("ServiceBag")
16
+
17
+ local DataStoreCmdrServiceClient = {}
18
+ DataStoreCmdrServiceClient.ServiceName = "DataStoreCmdrServiceClient"
19
+
20
+ export type DataStoreCmdrServiceClient = typeof(setmetatable(
21
+ {} :: {
22
+ _serviceBag: ServiceBag.ServiceBag,
23
+ _maid: Maid.Maid,
24
+ _cmdrServiceClient: any,
25
+ },
26
+ {} :: typeof({ __index = DataStoreCmdrServiceClient })
27
+ ))
28
+
29
+ function DataStoreCmdrServiceClient.Init(self: DataStoreCmdrServiceClient, serviceBag: ServiceBag.ServiceBag): ()
30
+ assert(not (self :: any)._serviceBag, "Already initialized")
31
+ self._serviceBag = assert(serviceBag, "No serviceBag")
32
+ self._maid = Maid.new()
33
+
34
+ -- External
35
+ self._cmdrServiceClient = self._serviceBag:GetService(CmdrServiceClient)
36
+ end
37
+
38
+ function DataStoreCmdrServiceClient.Start(self: DataStoreCmdrServiceClient): ()
39
+ self._maid:GivePromise(self._cmdrServiceClient:PromiseCmdr()):Then(function(cmdr)
40
+ DataStoreCmdrUtils.registerSubStoreType(cmdr)
41
+ end)
42
+ end
43
+
44
+ function DataStoreCmdrServiceClient.Destroy(self: DataStoreCmdrServiceClient): ()
45
+ self._maid:DoCleaning()
46
+ end
47
+
48
+ return DataStoreCmdrServiceClient
@@ -0,0 +1,35 @@
1
+ --!strict
2
+ --[=[
3
+ Entry point for the client half of the datastore package. Registers every client-side datastore
4
+ service, so a consumer gets the package in one [ServiceBag.GetService].
5
+
6
+ @client
7
+ @class DataStoreServiceClient
8
+ ]=]
9
+
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local ServiceBag = require("ServiceBag")
13
+
14
+ local DataStoreServiceClient = {}
15
+ DataStoreServiceClient.ServiceName = "DataStoreServiceClient"
16
+
17
+ export type DataStoreServiceClient = typeof(setmetatable(
18
+ {} :: {
19
+ _serviceBag: ServiceBag.ServiceBag,
20
+ },
21
+ {} :: typeof({ __index = DataStoreServiceClient })
22
+ ))
23
+
24
+ function DataStoreServiceClient.Init(self: DataStoreServiceClient, serviceBag: ServiceBag.ServiceBag)
25
+ assert(not (self :: any)._serviceBag, "Already initialized")
26
+ self._serviceBag = assert(serviceBag, "No serviceBag")
27
+
28
+ -- External
29
+ self._serviceBag:GetService(require("CmdrServiceClient"))
30
+
31
+ -- Internal
32
+ self._serviceBag:GetService(require("DataStoreCmdrServiceClient"))
33
+ end
34
+
35
+ return DataStoreServiceClient