@quenty/datastore 13.46.1 → 13.47.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 +10 -0
- package/package.json +10 -10
- package/src/Server/DataStore.GracefulClose.spec.lua +133 -0
- package/src/Server/DataStore.lua +62 -57
- package/src/Server/DataStoreMessageHelper.lua +7 -0
- package/src/Server/Mocks/DataStoreMock.lua +55 -0
- package/src/Server/Mocks/DataStoreMock.spec.lua +120 -1
- package/src/Server/PlayerDataStoreManager.lua +12 -8
- package/src/Server/PlayerDataStoreManager.spec.lua +72 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.47.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.46.1...@quenty/datastore@13.47.0) (2026-07-23)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- Data store tear down fixes that are graceful ([79152d0](https://github.com/Quenty/NevermoreEngine/commit/79152d03156f2328ab2a4a07bc68e27b5b666775))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- Even more stuff ([fea7e95](https://github.com/Quenty/NevermoreEngine/commit/fea7e9587e7195bbfa7f4753c130a3d597f9a34b))
|
|
15
|
+
|
|
6
16
|
## [13.46.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.46.0...@quenty/datastore@13.46.1) (2026-07-23)
|
|
7
17
|
|
|
8
18
|
**Note:** Version bump only for package @quenty/datastore
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/datastore",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.47.0",
|
|
4
4
|
"description": "Quenty's Datastore implementation for Roblox",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -30,26 +30,26 @@
|
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@quenty/baseobject": "10.15.0",
|
|
33
|
-
"@quenty/bindtocloseservice": "8.
|
|
33
|
+
"@quenty/bindtocloseservice": "8.37.0",
|
|
34
34
|
"@quenty/loader": "10.11.0",
|
|
35
35
|
"@quenty/maid": "3.11.0",
|
|
36
36
|
"@quenty/math": "2.7.5",
|
|
37
|
-
"@quenty/messagingserviceutils": "7.
|
|
37
|
+
"@quenty/messagingserviceutils": "7.26.0",
|
|
38
38
|
"@quenty/nevermore-test-runner": "1.5.0",
|
|
39
|
-
"@quenty/pagesutils": "5.
|
|
40
|
-
"@quenty/playermock": "1.
|
|
41
|
-
"@quenty/promise": "10.
|
|
42
|
-
"@quenty/promisemaid": "5.
|
|
43
|
-
"@quenty/rx": "13.
|
|
39
|
+
"@quenty/pagesutils": "5.23.0",
|
|
40
|
+
"@quenty/playermock": "1.2.0",
|
|
41
|
+
"@quenty/promise": "10.22.0",
|
|
42
|
+
"@quenty/promisemaid": "5.22.0",
|
|
43
|
+
"@quenty/rx": "13.32.0",
|
|
44
44
|
"@quenty/servicebag": "11.20.0",
|
|
45
45
|
"@quenty/signal": "7.13.1",
|
|
46
46
|
"@quenty/symbol": "3.5.2",
|
|
47
47
|
"@quenty/table": "3.9.2",
|
|
48
|
-
"@quenty/valueobject": "13.
|
|
48
|
+
"@quenty/valueobject": "13.35.0",
|
|
49
49
|
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "88b03743718172ff79cd62ade64a86565fcac17d"
|
|
55
55
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Covers the graceful session close handoff between two sequential "server sessions" over one
|
|
4
|
+
DataStoreMock: a session that ends cleanly must release its session lock, so the next server's
|
|
5
|
+
load is an immediate clean takeover (no graceful-close wait, no retry ladder). Also pins the
|
|
6
|
+
crash-safety semantics that must NOT change: a live holder still blocks, and a dead holder's
|
|
7
|
+
fresh lock is only stolen through the retry ladder -- without leaking uncaught rejections.
|
|
8
|
+
|
|
9
|
+
@class DataStoreGracefulClose.spec.lua
|
|
10
|
+
]]
|
|
11
|
+
local require = require(script.Parent.loader).load(script)
|
|
12
|
+
|
|
13
|
+
local DataStoreMock = require("DataStoreMock")
|
|
14
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
15
|
+
local Jest = require("Jest")
|
|
16
|
+
local Maid = require("Maid")
|
|
17
|
+
local MessagingServiceMock = require("MessagingServiceMock")
|
|
18
|
+
local PlayerDataStoreManager = require("PlayerDataStoreManager")
|
|
19
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
20
|
+
|
|
21
|
+
local describe = Jest.Globals.describe
|
|
22
|
+
local expect = Jest.Globals.expect
|
|
23
|
+
local it = Jest.Globals.it
|
|
24
|
+
|
|
25
|
+
describe("clean session end releases the lock (server-shutdown teardown path)", function()
|
|
26
|
+
it("manager teardown closes the session so the next server loads immediately", function()
|
|
27
|
+
local mock = DataStoreMock.new()
|
|
28
|
+
|
|
29
|
+
-- Server A: the manager still owns the store at teardown (nothing removed the player
|
|
30
|
+
-- first), which is exactly what a ServiceBag destroy after a clean session looks like.
|
|
31
|
+
local maidA = Maid.new()
|
|
32
|
+
local serviceBagA = DataStoreTestUtils.newServiceBag(maidA, MessagingServiceMock.new())
|
|
33
|
+
local managerA = maidA:Add(PlayerDataStoreManager.new(serviceBagA, mock :: any, function(userId)
|
|
34
|
+
return "user_" .. tostring(userId)
|
|
35
|
+
end, true))
|
|
36
|
+
|
|
37
|
+
local storeA = assert(managerA:GetDataStore(1), "No storeA")
|
|
38
|
+
storeA:Store("coins", 42)
|
|
39
|
+
if
|
|
40
|
+
not PromiseTestUtils.awaitValue(function()
|
|
41
|
+
local raw = mock:GetRaw("user_1")
|
|
42
|
+
return raw ~= nil and raw.lock ~= nil
|
|
43
|
+
end, 10)
|
|
44
|
+
then
|
|
45
|
+
expect("A never acquired the lock").toEqual("A acquired the lock")
|
|
46
|
+
maidA:DoCleaning()
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
-- Clean shutdown: the whole session tears down without the player ever "removing".
|
|
51
|
+
maidA:DoCleaning()
|
|
52
|
+
|
|
53
|
+
-- The teardown flush must write the graceful close: data saved, lock released.
|
|
54
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
55
|
+
local raw = mock:GetRaw("user_1")
|
|
56
|
+
return raw ~= nil and raw.lock == nil
|
|
57
|
+
end, 5)).toEqual(true)
|
|
58
|
+
expect(mock:GetRaw("user_1").coins).toEqual(42)
|
|
59
|
+
|
|
60
|
+
-- Server B: an immediate clean takeover -- bounded well under the 5s graceful-close
|
|
61
|
+
-- wait (and nowhere near the ~49s retry ladder).
|
|
62
|
+
local maidB = Maid.new()
|
|
63
|
+
local storeB = DataStoreTestUtils.newSessionLockedStore(maidB, mock, "user_1")
|
|
64
|
+
local loadB = storeB:Load("coins")
|
|
65
|
+
if not PromiseTestUtils.awaitSettled(loadB, 1) then
|
|
66
|
+
expect("B load was not immediate").toEqual("B load was immediate")
|
|
67
|
+
maidB:DoCleaning()
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
expect((loadB:Wait())).toEqual(42)
|
|
71
|
+
expect(mock:GetRaw("user_1").lock.ActiveSession.SessionId).toEqual(storeB:GetSessionId())
|
|
72
|
+
|
|
73
|
+
maidB:DoCleaning()
|
|
74
|
+
end)
|
|
75
|
+
end)
|
|
76
|
+
|
|
77
|
+
describe("crash-safety semantics preserved", function()
|
|
78
|
+
it("still blocks on a live holder that does not close (ask-and-wait, no immediate steal)", function()
|
|
79
|
+
local controller = DataStoreTestUtils.setup()
|
|
80
|
+
|
|
81
|
+
local serverA = controller.newServer({ messaging = true })
|
|
82
|
+
if not controller.awaitOwn(serverA) then
|
|
83
|
+
expect("A load hung").toEqual("A load settled")
|
|
84
|
+
controller:destroy()
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
local serverB = controller.newServer({ messaging = true })
|
|
89
|
+
serverB:SetSessionMessagingCloseDelaySeconds(0.1)
|
|
90
|
+
local loadB = serverB:PromiseLoadSuccessful()
|
|
91
|
+
|
|
92
|
+
-- The holder is alive but never closes; B must still be waiting on the graceful
|
|
93
|
+
-- protocol, not stealing a fresh lock.
|
|
94
|
+
expect(PromiseTestUtils.awaitSettled(loadB, 2)).toEqual(false)
|
|
95
|
+
expect(controller.mock:GetRaw("player_1").lock.ActiveSession.SessionId).toEqual(serverA:GetSessionId())
|
|
96
|
+
|
|
97
|
+
controller:destroy()
|
|
98
|
+
end)
|
|
99
|
+
|
|
100
|
+
it("steals a dead holder's fresh lock only through the retry ladder, leaking no rejections", function()
|
|
101
|
+
local controller = DataStoreTestUtils.setup()
|
|
102
|
+
|
|
103
|
+
-- A holder that died without closing: fresh lock, no live session behind it. Every
|
|
104
|
+
-- graceful-close request times out, so the load must grind through the (shortened)
|
|
105
|
+
-- retry ladder and then steal -- with every rejection along the way consumed (the
|
|
106
|
+
-- test runner fails the suite on stray uncaught rejections).
|
|
107
|
+
controller.mock:SetRaw("player_1", {
|
|
108
|
+
coins = 9,
|
|
109
|
+
lock = {
|
|
110
|
+
LastUpdateTime = os.time(),
|
|
111
|
+
ActiveSession = { SessionId = "dead-session", PlaceId = 123, JobId = "dead-job" },
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
local serverB = controller.newServer({ messaging = true })
|
|
116
|
+
serverB:SetLoadRetryOptions({ exponential = 1, initialWaitTime = 0.2, maxAttempts = 2, printWarning = false })
|
|
117
|
+
serverB:SetSessionMessagingCloseDelaySeconds(0.05)
|
|
118
|
+
|
|
119
|
+
local loadB = serverB:PromiseLoadSuccessful()
|
|
120
|
+
if not PromiseTestUtils.awaitSettled(loadB, 20) then
|
|
121
|
+
expect("B never stole the dead session's lock").toEqual("B stole the dead session's lock")
|
|
122
|
+
controller:destroy()
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
expect((loadB:Wait())).toEqual(true)
|
|
126
|
+
|
|
127
|
+
local raw = controller.mock:GetRaw("player_1")
|
|
128
|
+
expect(raw.coins).toEqual(9)
|
|
129
|
+
expect(raw.lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
|
|
130
|
+
|
|
131
|
+
controller:destroy()
|
|
132
|
+
end, 30000) -- Two full 5s graceful-close timeouts plus backoff, beyond jest's 5s default
|
|
133
|
+
end)
|
package/src/Server/DataStore.lua
CHANGED
|
@@ -673,73 +673,78 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
|
|
|
673
673
|
self._maid[loadPromise] = loadPromise
|
|
674
674
|
|
|
675
675
|
PromiseMaidUtils.whilePromise(loadPromise, function(maid)
|
|
676
|
-
maid
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
676
|
+
maid
|
|
677
|
+
:GivePromise(
|
|
678
|
+
DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(data, datastoreKeyInfo)
|
|
679
|
+
local userIdList = self._userIdList
|
|
680
|
+
if datastoreKeyInfo then
|
|
681
|
+
userIdList = datastoreKeyInfo:GetUserIds()
|
|
682
|
+
end
|
|
682
683
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
684
|
+
local metadata = nil
|
|
685
|
+
if datastoreKeyInfo then
|
|
686
|
+
metadata = datastoreKeyInfo:GetMetadata()
|
|
687
|
+
end
|
|
687
688
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
689
|
+
if self._debugWriting then
|
|
690
|
+
print(
|
|
691
|
+
string.format("DataStorePromises.updateAsync(%q) -> Got ", tostring(self._key)),
|
|
692
|
+
data
|
|
693
|
+
)
|
|
694
|
+
end
|
|
691
695
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
)
|
|
702
|
-
:Then(function()
|
|
703
|
-
-- Give enough time for Roblox to replicate changes
|
|
704
|
-
-- We probably could bump back to the loop but this has slightly better error messages
|
|
705
|
-
return maid:GivePromise(
|
|
706
|
-
PromiseUtils.delayed(self._sessionMessagingCloseDelaySeconds)
|
|
696
|
+
local lockResult = self._sessionLockingEnabledHelper:AcquireLock(data, canStealLock)
|
|
697
|
+
if not lockResult.isValid then
|
|
698
|
+
if self._sessionMessagingEnabledHelper and tryMessagingServiceSessionClose then
|
|
699
|
+
-- Gracefully kick to avoid losing memory
|
|
700
|
+
self._sessionMessagingEnabledHelper
|
|
701
|
+
:PromiseCloseSessionGraceful(
|
|
702
|
+
lockResult.blockingSession.PlaceId,
|
|
703
|
+
lockResult.blockingSession.JobId,
|
|
704
|
+
lockResult.blockingSession.SessionId
|
|
707
705
|
)
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
706
|
+
:Then(function()
|
|
707
|
+
-- Give enough time for Roblox to replicate changes
|
|
708
|
+
-- We probably could bump back to the loop but this has slightly better error messages
|
|
709
|
+
return maid:GivePromise(
|
|
710
|
+
PromiseUtils.delayed(self._sessionMessagingCloseDelaySeconds)
|
|
711
|
+
)
|
|
712
|
+
end)
|
|
713
|
+
:Then(function()
|
|
714
|
+
return maid:GivePromise(promiseLoadUnlockedProfile(canStealLock, false))
|
|
715
|
+
end)
|
|
716
|
+
:Then(function(unlockedProfile)
|
|
717
|
+
loadPromise:Resolve(unlockedProfile)
|
|
718
|
+
end, function(err)
|
|
719
|
+
loadPromise:Reject(
|
|
720
|
+
`Profile is locked and the graceful session-close request did not release it. Failed to load with {err}`
|
|
721
|
+
)
|
|
722
|
+
end)
|
|
723
|
+
else
|
|
724
|
+
loadPromise:Reject(
|
|
725
|
+
string.format(
|
|
726
|
+
"Profile is locked (%s)",
|
|
727
|
+
MessagingServiceUtils.toHumanReadable(data.lock)
|
|
717
728
|
)
|
|
718
|
-
end)
|
|
719
|
-
else
|
|
720
|
-
loadPromise:Reject(
|
|
721
|
-
string.format(
|
|
722
|
-
"Profile is locked (%s)",
|
|
723
|
-
MessagingServiceUtils.toHumanReadable(data.lock)
|
|
724
729
|
)
|
|
725
|
-
|
|
726
|
-
end
|
|
730
|
+
end
|
|
727
731
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
732
|
+
-- Cancel write to avoid maintaining lock
|
|
733
|
+
return nil
|
|
734
|
+
end
|
|
731
735
|
|
|
732
|
-
|
|
736
|
+
loadPromise:Resolve(lockResult.unlockedProfile)
|
|
733
737
|
|
|
734
|
-
|
|
738
|
+
return lockResult.lockedProfile, userIdList, metadata
|
|
739
|
+
end)
|
|
740
|
+
)
|
|
741
|
+
:Catch(function(opError)
|
|
742
|
+
-- The datastore operation itself failed (e.g. 509), which is NOT lock contention and
|
|
743
|
+
-- will not resolve by retrying. Fail the load fast, preserving the original error.
|
|
744
|
+
if loadPromise:IsPending() then
|
|
745
|
+
loadPromise:Reject(DataStoreNonRetryableLoadError.new(opError))
|
|
746
|
+
end
|
|
735
747
|
end)
|
|
736
|
-
):Catch(function(opError)
|
|
737
|
-
-- The datastore operation itself failed (e.g. 509), which is NOT lock contention and
|
|
738
|
-
-- will not resolve by retrying. Fail the load fast, preserving the original error.
|
|
739
|
-
if loadPromise:IsPending() then
|
|
740
|
-
loadPromise:Reject(DataStoreNonRetryableLoadError.new(opError))
|
|
741
|
-
end
|
|
742
|
-
end)
|
|
743
748
|
end)
|
|
744
749
|
|
|
745
750
|
loadPromise:Finally(function()
|
|
@@ -94,6 +94,13 @@ function DataStoreMessageHelper.PromiseCloseSessionGraceful(
|
|
|
94
94
|
self._maid[promise] = promise
|
|
95
95
|
promise:Finally(function()
|
|
96
96
|
self._maid[promise] = nil
|
|
97
|
+
|
|
98
|
+
-- Never cache a settled notification: a timed-out (rejected) promise left in the map would
|
|
99
|
+
-- make every later request for this session fail instantly instead of sending a fresh
|
|
100
|
+
-- close request with its own timeout window.
|
|
101
|
+
if self._sessionClosedNotifications[sessionId] == promise then
|
|
102
|
+
self._sessionClosedNotifications[sessionId] = nil
|
|
103
|
+
end
|
|
97
104
|
end)
|
|
98
105
|
|
|
99
106
|
PromiseMaidUtils.whilePromise(promise, function(maid)
|
|
@@ -276,6 +276,61 @@ function DataStoreMock.GetRaw(self: DataStoreMock, key: string): any
|
|
|
276
276
|
return deepCopy(self._store[key])
|
|
277
277
|
end
|
|
278
278
|
|
|
279
|
+
--[=[
|
|
280
|
+
Serializes the full raw key -> value store to a JSON string, so a mock's contents can
|
|
281
|
+
survive between two in-process "server sessions" (e.g. an integration test simulating a
|
|
282
|
+
cross-place teleport) or be written out as an inspectable, diffable checkpoint artifact.
|
|
283
|
+
Datastore values are JSON-safe by contract, so the export is lossless; hydrate a fresh
|
|
284
|
+
mock from it with [DataStoreMock.ImportRaw].
|
|
285
|
+
|
|
286
|
+
Errors when the store holds a value that cannot be JSON-encoded (which a real datastore
|
|
287
|
+
would have refused to store in the first place).
|
|
288
|
+
|
|
289
|
+
@return string
|
|
290
|
+
]=]
|
|
291
|
+
function DataStoreMock.ExportRaw(self: DataStoreMock): string
|
|
292
|
+
local ok, encoded = pcall(function()
|
|
293
|
+
return HttpService:JSONEncode(self._store)
|
|
294
|
+
end)
|
|
295
|
+
if not ok then
|
|
296
|
+
error(string.format("[DataStoreMock.ExportRaw] - Store contents are not JSON-encodable: %s", tostring(encoded)))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
return encoded
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
--[=[
|
|
303
|
+
Decodes a JSON string produced by [DataStoreMock.ExportRaw] and replaces the store
|
|
304
|
+
contents with it. This replaces rather than merges: every existing key is discarded,
|
|
305
|
+
along with its version/userId/metadata bookkeeping. Each imported key is then seeded
|
|
306
|
+
exactly like [DataStoreMock.SetRaw] (no version bump, no failure injection), so a
|
|
307
|
+
hydrated mock is indistinguishable from a fresh one seeded key-by-key.
|
|
308
|
+
|
|
309
|
+
@param json string
|
|
310
|
+
]=]
|
|
311
|
+
function DataStoreMock.ImportRaw(self: DataStoreMock, json: string): ()
|
|
312
|
+
assert(type(json) == "string", "Bad json")
|
|
313
|
+
|
|
314
|
+
local ok, decoded = pcall(function()
|
|
315
|
+
return HttpService:JSONDecode(json)
|
|
316
|
+
end)
|
|
317
|
+
if not ok then
|
|
318
|
+
error(string.format("[DataStoreMock.ImportRaw] - Could not decode json: %s", tostring(decoded)))
|
|
319
|
+
end
|
|
320
|
+
assert(type(decoded) == "table", "Bad json - expected an encoded key -> value object")
|
|
321
|
+
|
|
322
|
+
local store: { [string]: any } = {}
|
|
323
|
+
for key, value in decoded do
|
|
324
|
+
assert(type(key) == "string", "Bad json - datastore keys must be strings")
|
|
325
|
+
store[key] = value
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
self._store = store
|
|
329
|
+
self._userIds = {}
|
|
330
|
+
self._metadata = {}
|
|
331
|
+
self._versions = {}
|
|
332
|
+
end
|
|
333
|
+
|
|
279
334
|
function DataStoreMock._beginRequest(self: DataStoreMock, method: string, key: string): ()
|
|
280
335
|
self._callCounts[method] = (self._callCounts[method] or 0) + 1
|
|
281
336
|
self._totalCalls += 1
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
--!
|
|
1
|
+
--!strict
|
|
2
2
|
--[[
|
|
3
3
|
Sanity coverage for the DataStoreMock itself, so tests that rely on it can trust its
|
|
4
4
|
datastore-faithful behavior (deep-copy round-tripping, UpdateAsync transform semantics,
|
|
@@ -436,6 +436,125 @@ describe("DataStoreMock serialized-size overflow", function()
|
|
|
436
436
|
end)
|
|
437
437
|
end)
|
|
438
438
|
|
|
439
|
+
describe("DataStoreMock:ExportRaw / ImportRaw", function()
|
|
440
|
+
it("should round-trip values written through the real datastore APIs", function()
|
|
441
|
+
local sessionA = DataStoreMock.new()
|
|
442
|
+
sessionA:SetAsync("player_1", { coins = 5, inventory = { "sword", "shield" } })
|
|
443
|
+
sessionA:UpdateAsync("player_2", function()
|
|
444
|
+
return { coins = 12, quests = { active = { "eggHunt" }, completed = {} } }
|
|
445
|
+
end)
|
|
446
|
+
sessionA:SetAsync("motd", "Welcome!")
|
|
447
|
+
|
|
448
|
+
local json = sessionA:ExportRaw()
|
|
449
|
+
expect(type(json)).toEqual("string")
|
|
450
|
+
|
|
451
|
+
local sessionB = DataStoreMock.new()
|
|
452
|
+
sessionB:ImportRaw(json)
|
|
453
|
+
|
|
454
|
+
expect((sessionB:GetAsync("player_1"))).toEqual({ coins = 5, inventory = { "sword", "shield" } })
|
|
455
|
+
expect((sessionB:GetAsync("player_2"))).toEqual({
|
|
456
|
+
coins = 12,
|
|
457
|
+
quests = { active = { "eggHunt" }, completed = {} },
|
|
458
|
+
})
|
|
459
|
+
expect((sessionB:GetAsync("motd"))).toEqual("Welcome!")
|
|
460
|
+
end)
|
|
461
|
+
|
|
462
|
+
it("should hand out a sane keyInfo for imported keys, like a mock seeded via SetRaw", function()
|
|
463
|
+
local sessionA = DataStoreMock.new()
|
|
464
|
+
sessionA:SetAsync("key", { coins = 5 })
|
|
465
|
+
sessionA:SetAsync("key", { coins = 6 }) -- version bumps stay behind, like SetRaw
|
|
466
|
+
|
|
467
|
+
local sessionB = DataStoreMock.new()
|
|
468
|
+
sessionB:ImportRaw(sessionA:ExportRaw())
|
|
469
|
+
|
|
470
|
+
local _, keyInfo = sessionB:GetAsync("key")
|
|
471
|
+
expect(keyInfo).never.toBeNil()
|
|
472
|
+
expect(keyInfo.Version).toEqual("0")
|
|
473
|
+
expect(keyInfo:GetUserIds()).toEqual({})
|
|
474
|
+
expect(keyInfo:GetMetadata()).toEqual({})
|
|
475
|
+
end)
|
|
476
|
+
|
|
477
|
+
it("should replace, not merge, the existing contents on import", function()
|
|
478
|
+
local source = DataStoreMock.new()
|
|
479
|
+
source:SetAsync("imported", 1)
|
|
480
|
+
|
|
481
|
+
local target = DataStoreMock.new()
|
|
482
|
+
target:SetAsync("preexisting", "should be discarded")
|
|
483
|
+
target:SetAsync("imported", "stale value")
|
|
484
|
+
|
|
485
|
+
target:ImportRaw(source:ExportRaw())
|
|
486
|
+
|
|
487
|
+
expect((target:GetAsync("preexisting"))).toEqual(nil)
|
|
488
|
+
expect((target:GetAsync("imported"))).toEqual(1)
|
|
489
|
+
end)
|
|
490
|
+
|
|
491
|
+
it("should discard bookkeeping for keys that survive a replace", function()
|
|
492
|
+
local source = DataStoreMock.new()
|
|
493
|
+
source:SetAsync("key", 1)
|
|
494
|
+
|
|
495
|
+
local target = DataStoreMock.new()
|
|
496
|
+
target:SetAsync("key", "old", { 111 })
|
|
497
|
+
target:ImportRaw(source:ExportRaw())
|
|
498
|
+
|
|
499
|
+
local _, keyInfo = target:GetAsync("key")
|
|
500
|
+
expect(keyInfo.Version).toEqual("0")
|
|
501
|
+
expect(keyInfo:GetUserIds()).toEqual({})
|
|
502
|
+
end)
|
|
503
|
+
|
|
504
|
+
it("should round-trip an empty store", function()
|
|
505
|
+
local empty = DataStoreMock.new()
|
|
506
|
+
|
|
507
|
+
local target = DataStoreMock.new()
|
|
508
|
+
target:SetAsync("key", "should be discarded")
|
|
509
|
+
target:ImportRaw(empty:ExportRaw())
|
|
510
|
+
|
|
511
|
+
expect((target:GetAsync("key"))).toEqual(nil)
|
|
512
|
+
end)
|
|
513
|
+
|
|
514
|
+
it("should export stably across an import cycle", function()
|
|
515
|
+
local sessionA = DataStoreMock.new()
|
|
516
|
+
sessionA:SetAsync("player_1", { coins = 5, nested = { list = { 1, 2, 3 } } })
|
|
517
|
+
sessionA:SetAsync("player_2", true)
|
|
518
|
+
|
|
519
|
+
local firstExport = sessionA:ExportRaw()
|
|
520
|
+
|
|
521
|
+
local sessionB = DataStoreMock.new()
|
|
522
|
+
sessionB:ImportRaw(firstExport)
|
|
523
|
+
local secondExport = sessionB:ExportRaw()
|
|
524
|
+
|
|
525
|
+
-- Key order inside the JSON is not guaranteed, so compare decoded contents.
|
|
526
|
+
local HttpService = game:GetService("HttpService")
|
|
527
|
+
expect(HttpService:JSONDecode(secondExport)).toEqual(HttpService:JSONDecode(firstExport))
|
|
528
|
+
end)
|
|
529
|
+
|
|
530
|
+
it("should not alias imported values to later reads", function()
|
|
531
|
+
local source = DataStoreMock.new()
|
|
532
|
+
source:SetAsync("key", { coins = 5 })
|
|
533
|
+
|
|
534
|
+
local target = DataStoreMock.new()
|
|
535
|
+
target:ImportRaw(source:ExportRaw())
|
|
536
|
+
|
|
537
|
+
local first = target:GetAsync("key")
|
|
538
|
+
first.coins = 999
|
|
539
|
+
|
|
540
|
+
expect((target:GetAsync("key")).coins).toEqual(5)
|
|
541
|
+
end)
|
|
542
|
+
|
|
543
|
+
it("should throw a clear error for malformed json", function()
|
|
544
|
+
local store = DataStoreMock.new()
|
|
545
|
+
expect(function()
|
|
546
|
+
store:ImportRaw("not json {")
|
|
547
|
+
end).toThrow("Could not decode json")
|
|
548
|
+
end)
|
|
549
|
+
|
|
550
|
+
it("should throw when the json is not an object", function()
|
|
551
|
+
local store = DataStoreMock.new()
|
|
552
|
+
expect(function()
|
|
553
|
+
store:ImportRaw('"just a string"')
|
|
554
|
+
end).toThrow()
|
|
555
|
+
end)
|
|
556
|
+
end)
|
|
557
|
+
|
|
439
558
|
describe("DataStoreMock:SetRaw / GetRaw", function()
|
|
440
559
|
it("should seed and read without triggering failures", function()
|
|
441
560
|
local store = DataStoreMock.new()
|
|
@@ -154,21 +154,25 @@ end
|
|
|
154
154
|
|
|
155
155
|
--[=[
|
|
156
156
|
Flushes and tears down every datastore we still own. Runs on manager teardown (a hot-reloaded
|
|
157
|
-
ServiceBag, or a unit test).
|
|
158
|
-
request is dispatched before Destroy() cancels the promise, so a live
|
|
159
|
-
it is not guaranteed. A store whose load failed rejects, so the
|
|
160
|
-
off gracefully via _removePlayerDataStore have already been
|
|
161
|
-
covers the ones nothing else cleaned up.
|
|
157
|
+
ServiceBag, or a unit test). SaveAndCloseSession() is a best-effort synchronous write: the
|
|
158
|
+
underlying UpdateAsync request is dispatched before Destroy() cancels the promise, so a live
|
|
159
|
+
server usually honors it, but it is not guaranteed. A store whose load failed rejects, so the
|
|
160
|
+
rejection is swallowed. Stores handed off gracefully via _removePlayerDataStore have already been
|
|
161
|
+
pulled out of _datastores, so this only covers the ones nothing else cleaned up.
|
|
162
162
|
]=]
|
|
163
163
|
function PlayerDataStoreManager._flushAndDestroyAll(self: PlayerDataStoreManager): ()
|
|
164
164
|
for userId, datastore in self._datastores do
|
|
165
165
|
-- Cast past the DataStore intersection type: the solver otherwise blows up ("code too complex")
|
|
166
|
-
-- resolving :
|
|
166
|
+
-- resolving :SaveAndCloseSession()/:Destroy() through it.
|
|
167
167
|
local store = datastore :: any
|
|
168
|
-
-- A failed load makes
|
|
168
|
+
-- A failed load makes the save reject unconditionally; skip it so teardown does not
|
|
169
169
|
-- manufacture a guaranteed rejection.
|
|
170
170
|
if not store:DidLoadFail() then
|
|
171
|
-
|
|
171
|
+
-- Close the session, don't just save: this teardown ends the session, so it must also
|
|
172
|
+
-- release the session lock. A lock left held here reads as a live foreign session to the
|
|
173
|
+
-- next server that loads the key, which then grinds through the whole graceful-close/steal
|
|
174
|
+
-- retry ladder against a holder that no longer exists before it can load.
|
|
175
|
+
store:SaveAndCloseSession()
|
|
172
176
|
end
|
|
173
177
|
store:Destroy()
|
|
174
178
|
self._datastores[userId] = nil
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
--!
|
|
1
|
+
--!strict
|
|
2
2
|
--[[
|
|
3
3
|
@class PlayerDataStoreManager.spec.lua
|
|
4
4
|
]]
|
|
@@ -225,4 +225,75 @@ describe("PlayerDataStoreManager teardown", function()
|
|
|
225
225
|
|
|
226
226
|
controller:destroy()
|
|
227
227
|
end)
|
|
228
|
+
|
|
229
|
+
it("releases the session lock when destroyed, not just the staged data", function()
|
|
230
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
231
|
+
|
|
232
|
+
if not controller.storeAndAwaitLock() then
|
|
233
|
+
expect("lock was never acquired").toEqual("lock was acquired")
|
|
234
|
+
controller:destroy()
|
|
235
|
+
return
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
controller.manager:Destroy()
|
|
239
|
+
|
|
240
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
241
|
+
local raw = controller.mock:GetRaw("user_1")
|
|
242
|
+
return raw ~= nil and raw.lock == nil
|
|
243
|
+
end, 5)).toEqual(true)
|
|
244
|
+
expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
|
|
245
|
+
|
|
246
|
+
controller:destroy()
|
|
247
|
+
end)
|
|
248
|
+
|
|
249
|
+
it("releases the lock for every store it still owns", function()
|
|
250
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
251
|
+
|
|
252
|
+
controller.manager:GetDataStore(1):Store("coins", 1)
|
|
253
|
+
controller.manager:GetDataStore(2):Store("coins", 2)
|
|
254
|
+
|
|
255
|
+
local locked = PromiseTestUtils.awaitValue(function()
|
|
256
|
+
local rawOne = controller.mock:GetRaw("user_1")
|
|
257
|
+
local rawTwo = controller.mock:GetRaw("user_2")
|
|
258
|
+
return rawOne ~= nil and rawOne.lock ~= nil and rawTwo ~= nil and rawTwo.lock ~= nil
|
|
259
|
+
end, 10)
|
|
260
|
+
if not locked then
|
|
261
|
+
expect("both locks were never acquired").toEqual("both locks were acquired")
|
|
262
|
+
controller:destroy()
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
controller.manager:Destroy()
|
|
267
|
+
|
|
268
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
269
|
+
local rawOne = controller.mock:GetRaw("user_1")
|
|
270
|
+
local rawTwo = controller.mock:GetRaw("user_2")
|
|
271
|
+
return rawOne ~= nil and rawOne.lock == nil and rawTwo ~= nil and rawTwo.lock == nil
|
|
272
|
+
end, 5)).toEqual(true)
|
|
273
|
+
expect(controller.mock:GetRaw("user_1").coins).toEqual(1)
|
|
274
|
+
expect(controller.mock:GetRaw("user_2").coins).toEqual(2)
|
|
275
|
+
|
|
276
|
+
controller:destroy()
|
|
277
|
+
end)
|
|
278
|
+
|
|
279
|
+
it("tears down cleanly when a store's load failed, leaking no rejection and writing no lock", function()
|
|
280
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
281
|
+
|
|
282
|
+
controller.mock:FailAllRequests()
|
|
283
|
+
|
|
284
|
+
local dataStore = controller.manager:GetDataStore(1)
|
|
285
|
+
|
|
286
|
+
local loaded = dataStore:PromiseLoadSuccessful()
|
|
287
|
+
if not expectSettled(loaded, 10) then
|
|
288
|
+
controller:destroy()
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
expect((loaded:Wait())).toEqual(false)
|
|
292
|
+
|
|
293
|
+
controller.manager:Destroy()
|
|
294
|
+
|
|
295
|
+
expect(controller.mock:GetRaw("user_1")).toBeNil()
|
|
296
|
+
|
|
297
|
+
controller:destroy()
|
|
298
|
+
end)
|
|
228
299
|
end)
|