@quenty/datastore 13.40.0 → 13.42.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 +12 -12
- package/src/Server/DataStore.HookErrors.spec.lua +92 -0
- package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
- package/src/Server/DataStore.Reentrance.spec.lua +119 -0
- package/src/Server/DataStore.SessionLock.spec.lua +478 -0
- package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
- package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
- package/src/Server/DataStore.lua +89 -18
- package/src/Server/DataStore.spec.lua +237 -0
- package/src/Server/DataStoreLockHelper.spec.lua +239 -0
- package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
- package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
- package/src/Server/DataStoreTestUtils.lua +206 -0
- package/src/Server/GameDataStoreService.lua +26 -0
- package/src/Server/GameDataStoreService.spec.lua +215 -0
- package/src/Server/Mocks/DataStoreMock.lua +405 -0
- package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
- package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
- package/src/Server/Modules/DataStoreStage.lua +8 -2
- package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
- package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
- package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
- package/src/Server/PlayerDataStoreManager.lua +36 -4
- package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
- package/src/Server/PlayerDataStoreService.lua +18 -0
- package/src/Server/PlayerDataStoreService.spec.lua +218 -0
- package/src/Server/PrivateServerDataStoreService.lua +23 -1
- package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
- package/src/Server/Utility/DataStorePromises.lua +18 -5
- package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
- package/src/Shared/Utility/DataStoreStringUtils.spec.lua +1 -1
- package/src/jest.config.lua +3 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
Two servers fighting over one player's session-locked key: two DataStore objects (server A and
|
|
4
|
+
server B) share one DataStoreMock with distinct SessionIds, exactly like two live game servers.
|
|
5
|
+
This exercises the full cross-server lock lifecycle -- clean handoff via close, crash recovery via
|
|
6
|
+
stale-lock steal, a concurrent-load race, the MessagingService graceful-close handshake, and
|
|
7
|
+
session-stolen data integrity.
|
|
8
|
+
|
|
9
|
+
@class DataStoreTwoServerLock.spec.lua
|
|
10
|
+
]]
|
|
11
|
+
local require = require(script.Parent.loader).load(script)
|
|
12
|
+
|
|
13
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
14
|
+
local Jest = require("Jest")
|
|
15
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
16
|
+
|
|
17
|
+
local describe = Jest.Globals.describe
|
|
18
|
+
local expect = Jest.Globals.expect
|
|
19
|
+
local it = Jest.Globals.it
|
|
20
|
+
|
|
21
|
+
local KEY = "player_1"
|
|
22
|
+
|
|
23
|
+
describe("two servers: clean handoff and crash recovery", function()
|
|
24
|
+
it("hands a player off cleanly: A saves and closes, B loads A's data and owns the lock", function()
|
|
25
|
+
local controller = DataStoreTestUtils.setup()
|
|
26
|
+
|
|
27
|
+
local serverA = controller.newServer()
|
|
28
|
+
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
29
|
+
serverA:Store("coins", 42)
|
|
30
|
+
if not PromiseTestUtils.awaitSettled(serverA:SaveAndCloseSession(), 10) then
|
|
31
|
+
expect("A close hung").toEqual("settled")
|
|
32
|
+
controller:destroy()
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local serverB = controller.newServer()
|
|
37
|
+
local coins = serverB:Load("coins")
|
|
38
|
+
if not PromiseTestUtils.awaitSettled(coins, 10) then
|
|
39
|
+
expect("B load hung").toEqual("settled")
|
|
40
|
+
controller:destroy()
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
expect((coins:Wait())).toEqual(42)
|
|
44
|
+
expect(controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
|
|
45
|
+
|
|
46
|
+
controller:destroy()
|
|
47
|
+
end)
|
|
48
|
+
|
|
49
|
+
it("recovers a crashed server's saved data by stealing its stale lock", function()
|
|
50
|
+
local controller = DataStoreTestUtils.setup()
|
|
51
|
+
|
|
52
|
+
-- A acquires, saves coins under its lock, then "crashes" (no clean close).
|
|
53
|
+
local serverA = controller.newServer()
|
|
54
|
+
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
55
|
+
serverA:Store("coins", 7)
|
|
56
|
+
if not PromiseTestUtils.awaitSettled(serverA:Save(), 10) then
|
|
57
|
+
expect("A save hung").toEqual("settled")
|
|
58
|
+
controller:destroy()
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- Age A's lock so it looks like a long-dead (crashed) server, then abandon A.
|
|
63
|
+
local raw = controller.mock:GetRaw(KEY)
|
|
64
|
+
raw.lock.LastUpdateTime = os.time() - 1000000
|
|
65
|
+
controller.mock:SetRaw(KEY, raw)
|
|
66
|
+
|
|
67
|
+
-- B loads: steals the stale lock and recovers A's saved coins.
|
|
68
|
+
local serverB = controller.newServer()
|
|
69
|
+
local coins = serverB:Load("coins")
|
|
70
|
+
if not PromiseTestUtils.awaitSettled(coins, 10) then
|
|
71
|
+
expect("B load hung").toEqual("settled")
|
|
72
|
+
controller:destroy()
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
expect((coins:Wait())).toEqual(7)
|
|
76
|
+
expect(controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
|
|
77
|
+
|
|
78
|
+
controller:destroy()
|
|
79
|
+
end)
|
|
80
|
+
|
|
81
|
+
it("lets exactly one of two concurrent loads acquire the lock; the other stays blocked", function()
|
|
82
|
+
local controller = DataStoreTestUtils.setup()
|
|
83
|
+
|
|
84
|
+
local serverA = controller.newServer()
|
|
85
|
+
local serverB = controller.newServer()
|
|
86
|
+
|
|
87
|
+
local loadA = serverA:PromiseLoadSuccessful()
|
|
88
|
+
local loadB = serverB:PromiseLoadSuccessful()
|
|
89
|
+
|
|
90
|
+
-- One acquires quickly; the other is blocked (retrying against the winner's fresh lock).
|
|
91
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
92
|
+
return not loadA:IsPending() or not loadB:IsPending()
|
|
93
|
+
end, 5)).toEqual(true)
|
|
94
|
+
|
|
95
|
+
local aSettled = not loadA:IsPending()
|
|
96
|
+
local bSettled = not loadB:IsPending()
|
|
97
|
+
expect(aSettled ~= bSettled).toEqual(true) -- exactly one
|
|
98
|
+
|
|
99
|
+
local owner = controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId
|
|
100
|
+
expect(owner == serverA:GetSessionId() or owner == serverB:GetSessionId()).toEqual(true)
|
|
101
|
+
|
|
102
|
+
controller:destroy()
|
|
103
|
+
end)
|
|
104
|
+
|
|
105
|
+
it("cancels the loser's write when a session is stolen (no duplication)", function()
|
|
106
|
+
local controller = DataStoreTestUtils.setup()
|
|
107
|
+
|
|
108
|
+
local serverA = controller.newServer()
|
|
109
|
+
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
110
|
+
|
|
111
|
+
-- B takes over by writing its own lock + data directly (as if it stole the session).
|
|
112
|
+
controller.mock:SetRaw(KEY, {
|
|
113
|
+
coins = 100,
|
|
114
|
+
lock = {
|
|
115
|
+
LastUpdateTime = os.time(),
|
|
116
|
+
ActiveSession = { SessionId = "server-b", PlaceId = 222, JobId = "job-b" },
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
-- A tries to save its own value; the write is cancelled (SessionStolen), B's data survives.
|
|
121
|
+
local stolen = nil
|
|
122
|
+
serverA.SessionStolen:Connect(function(session)
|
|
123
|
+
stolen = session
|
|
124
|
+
end)
|
|
125
|
+
serverA:Store("coins", 5)
|
|
126
|
+
if not PromiseTestUtils.awaitSettled(serverA:Save(), 10) then
|
|
127
|
+
expect("A save hung").toEqual("settled")
|
|
128
|
+
controller:destroy()
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
expect(stolen).never.toBeNil()
|
|
133
|
+
expect(controller.mock:GetRaw(KEY).coins).toEqual(100)
|
|
134
|
+
|
|
135
|
+
controller:destroy()
|
|
136
|
+
end)
|
|
137
|
+
end)
|
|
138
|
+
|
|
139
|
+
describe("two servers: MessagingService graceful close", function()
|
|
140
|
+
it("completes the close handshake: B asks A to close, A closes and releases the lock", function()
|
|
141
|
+
local controller = DataStoreTestUtils.setup()
|
|
142
|
+
|
|
143
|
+
local serverA = controller.newServer({ messaging = true, autoCloseOnRequest = true })
|
|
144
|
+
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
145
|
+
|
|
146
|
+
local _serverB, helperB = controller.newServer({ messaging = true })
|
|
147
|
+
|
|
148
|
+
-- B gracefully asks A (the lock holder) to close.
|
|
149
|
+
local graceful = helperB:PromiseCloseSessionGraceful(game.PlaceId, game.JobId, serverA:GetSessionId())
|
|
150
|
+
if not PromiseTestUtils.awaitSettled(graceful, 15) then
|
|
151
|
+
expect("graceful close hung").toEqual("resolved")
|
|
152
|
+
controller:destroy()
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
expect((graceful:Yield())).toEqual(true)
|
|
156
|
+
|
|
157
|
+
-- A honored the request and released its lock.
|
|
158
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
159
|
+
local raw = controller.mock:GetRaw(KEY)
|
|
160
|
+
return raw ~= nil and raw.lock == nil
|
|
161
|
+
end, 5)).toEqual(true)
|
|
162
|
+
|
|
163
|
+
controller:destroy()
|
|
164
|
+
end, 30000) -- MessagingService round-trip, beyond jest's 5s default
|
|
165
|
+
|
|
166
|
+
it("evicts the holder during a messaging-enabled load and then acquires (production flow)", function()
|
|
167
|
+
local controller = DataStoreTestUtils.setup()
|
|
168
|
+
|
|
169
|
+
-- A holds the lock and will honor a graceful close request.
|
|
170
|
+
local serverA = controller.newServer({ messaging = true, autoCloseOnRequest = true })
|
|
171
|
+
expect(controller.awaitOwn(serverA)).toEqual(true)
|
|
172
|
+
|
|
173
|
+
-- B loads with messaging enabled: blocked by A's fresh lock, its load asks A to close, then
|
|
174
|
+
-- (after the propagation delay) retries and acquires. This is the real cross-server handoff.
|
|
175
|
+
-- Use a tiny propagation delay so the test does not wait the production 5s.
|
|
176
|
+
local serverB = controller.newServer({ messaging = true })
|
|
177
|
+
serverB:SetSessionMessagingCloseDelaySeconds(0.1)
|
|
178
|
+
local loadB = serverB:PromiseLoadSuccessful()
|
|
179
|
+
|
|
180
|
+
if not PromiseTestUtils.awaitSettled(loadB, 8) then
|
|
181
|
+
expect("B messaging load hung").toEqual("settled")
|
|
182
|
+
controller:destroy()
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
expect((loadB:Wait())).toEqual(true)
|
|
186
|
+
expect(controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
|
|
187
|
+
|
|
188
|
+
controller:destroy()
|
|
189
|
+
end)
|
|
190
|
+
end)
|
package/src/Server/DataStore.lua
CHANGED
|
@@ -72,6 +72,7 @@ local HttpService = game:GetService("HttpService")
|
|
|
72
72
|
local DataStoreDeleteToken = require("DataStoreDeleteToken")
|
|
73
73
|
local DataStoreLockHelper = require("DataStoreLockHelper")
|
|
74
74
|
local DataStoreMessageHelper = require("DataStoreMessageHelper")
|
|
75
|
+
local DataStoreNonRetryableLoadError = require("DataStoreNonRetryableLoadError")
|
|
75
76
|
local DataStorePromises = require("DataStorePromises")
|
|
76
77
|
local DataStoreStage = require("DataStoreStage")
|
|
77
78
|
local Maid = require("Maid")
|
|
@@ -92,6 +93,18 @@ local DEFAULT_DEBUG_WRITING = false
|
|
|
92
93
|
local DEFAULT_AUTO_SAVE_TIME_SECONDS = 60 * 5
|
|
93
94
|
local DEFAULT_JITTER_PROPORTION = 0.1 -- Randomly assign jitter so if a ton of players join at once we don't hit the datastore at once
|
|
94
95
|
|
|
96
|
+
-- Retry backoff for the session-locked load (~49s total); tunable so tests need not wait it out.
|
|
97
|
+
-- https://exponentialbackoffcalculator.com/
|
|
98
|
+
local DEFAULT_LOAD_RETRY_OPTIONS = {
|
|
99
|
+
exponential = 1.25,
|
|
100
|
+
initialWaitTime = 6,
|
|
101
|
+
maxAttempts = 6,
|
|
102
|
+
printWarning = true,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
-- After a graceful session-close request we wait for Roblox to replicate the release before retrying.
|
|
106
|
+
local DEFAULT_SESSION_MESSAGING_CLOSE_DELAY_SECONDS = 5
|
|
107
|
+
|
|
95
108
|
local DataStore = setmetatable({}, DataStoreStage)
|
|
96
109
|
DataStore.ClassName = "DataStore"
|
|
97
110
|
DataStore.__index = DataStore
|
|
@@ -113,6 +126,8 @@ export type DataStore =
|
|
|
113
126
|
_loadedOk: ValueObject.ValueObject<boolean>,
|
|
114
127
|
_firstLoadPromise: Promise.Promise<()>,
|
|
115
128
|
_promiseSessionLockingFailed: Promise.Promise<()>,
|
|
129
|
+
_loadRetryOptions: PromiseRetryUtils.RetryOptions,
|
|
130
|
+
_sessionMessagingCloseDelaySeconds: number,
|
|
116
131
|
|
|
117
132
|
-- Events
|
|
118
133
|
Saving: Signal.Signal<Promise.Promise<()>>,
|
|
@@ -147,6 +162,8 @@ function DataStore.new(robloxDataStore: DataStorePromises.RobloxDataStore, key:
|
|
|
147
162
|
self._syncOnSave = self._maid:Add(ValueObject.new(false, "boolean"))
|
|
148
163
|
self._loadedOk = self._maid:Add(ValueObject.new(false, "boolean"))
|
|
149
164
|
self._promiseSessionLockingFailed = self._maid:Add(Promise.new())
|
|
165
|
+
self._loadRetryOptions = DEFAULT_LOAD_RETRY_OPTIONS
|
|
166
|
+
self._sessionMessagingCloseDelaySeconds = DEFAULT_SESSION_MESSAGING_CLOSE_DELAY_SECONDS
|
|
150
167
|
|
|
151
168
|
self._userIdList = nil
|
|
152
169
|
|
|
@@ -314,6 +331,32 @@ function DataStore.SetSyncOnSave(self: DataStore, syncEnabled: boolean)
|
|
|
314
331
|
self._syncOnSave.Value = syncEnabled
|
|
315
332
|
end
|
|
316
333
|
|
|
334
|
+
--[=[
|
|
335
|
+
Overrides the retry backoff used while acquiring a session-locked load. Defaults to ~49s of
|
|
336
|
+
exponential backoff. Mainly useful for tests, which set a tiny backoff so they need not wait it
|
|
337
|
+
out. Must be set before the datastore loads.
|
|
338
|
+
|
|
339
|
+
@param options { exponential: number?, initialWaitTime: number, maxAttempts: number, printWarning: boolean }
|
|
340
|
+
]=]
|
|
341
|
+
function DataStore.SetLoadRetryOptions(self: DataStore, options: PromiseRetryUtils.RetryOptions): ()
|
|
342
|
+
assert(not self._firstLoadPromise, "Must set load retry options before the datastore is loaded")
|
|
343
|
+
assert(type(options) == "table", "Bad options")
|
|
344
|
+
|
|
345
|
+
self._loadRetryOptions = options
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
--[=[
|
|
349
|
+
Overrides how long to wait for Roblox to replicate a released lock after a graceful session-close
|
|
350
|
+
request before retrying the load. Defaults to 5 seconds. Mainly useful for tests.
|
|
351
|
+
|
|
352
|
+
@param seconds number
|
|
353
|
+
]=]
|
|
354
|
+
function DataStore.SetSessionMessagingCloseDelaySeconds(self: DataStore, seconds: number): ()
|
|
355
|
+
assert(type(seconds) == "number" and seconds >= 0, "Bad seconds")
|
|
356
|
+
|
|
357
|
+
self._sessionMessagingCloseDelaySeconds = seconds
|
|
358
|
+
end
|
|
359
|
+
|
|
317
360
|
--[=[
|
|
318
361
|
Returns whether the datastore failed.
|
|
319
362
|
@return boolean
|
|
@@ -405,9 +448,13 @@ function DataStore.PromiseViewUpToDate(self: DataStore): Promise.Promise<()>
|
|
|
405
448
|
local promise = self:_promiseGetAsyncNoCache()
|
|
406
449
|
self._firstLoadPromise = promise
|
|
407
450
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
451
|
+
-- Fire-and-forget side effect; Catch so a failed/cancelled load is not surfaced as an uncaught
|
|
452
|
+
-- rejection on this discarded branch (the load result itself is handled by callers).
|
|
453
|
+
promise
|
|
454
|
+
:Tap(function()
|
|
455
|
+
self._loadedOk.Value = true
|
|
456
|
+
end)
|
|
457
|
+
:Catch(function() end)
|
|
411
458
|
|
|
412
459
|
return promise
|
|
413
460
|
end
|
|
@@ -599,10 +646,12 @@ function DataStore._doDataSync(
|
|
|
599
646
|
)
|
|
600
647
|
end
|
|
601
648
|
|
|
602
|
-
promise
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
649
|
+
promise
|
|
650
|
+
:Tap(nil, function(err)
|
|
651
|
+
-- Might be caused by Maid rejecting state
|
|
652
|
+
warn("[DataStore] - Failed to sync data", err)
|
|
653
|
+
end)
|
|
654
|
+
:Catch(function() end)
|
|
606
655
|
|
|
607
656
|
self._maid._saveMaid = maid
|
|
608
657
|
|
|
@@ -653,7 +702,9 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
|
|
|
653
702
|
:Then(function()
|
|
654
703
|
-- Give enough time for Roblox to replicate changes
|
|
655
704
|
-- We probably could bump back to the loop but this has slightly better error messages
|
|
656
|
-
return maid:GivePromise(
|
|
705
|
+
return maid:GivePromise(
|
|
706
|
+
PromiseUtils.delayed(self._sessionMessagingCloseDelaySeconds)
|
|
707
|
+
)
|
|
657
708
|
end)
|
|
658
709
|
:Then(function()
|
|
659
710
|
return maid:GivePromise(promiseLoadUnlockedProfile(canStealLock, false))
|
|
@@ -682,7 +733,13 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
|
|
|
682
733
|
|
|
683
734
|
return lockResult.lockedProfile, userIdList, metadata
|
|
684
735
|
end)
|
|
685
|
-
)
|
|
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)
|
|
686
743
|
end)
|
|
687
744
|
|
|
688
745
|
loadPromise:Finally(function()
|
|
@@ -692,18 +749,27 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
|
|
|
692
749
|
return loadPromise
|
|
693
750
|
end
|
|
694
751
|
|
|
752
|
+
local retryOptions = {
|
|
753
|
+
exponential = self._loadRetryOptions.exponential,
|
|
754
|
+
initialWaitTime = self._loadRetryOptions.initialWaitTime,
|
|
755
|
+
maxAttempts = self._loadRetryOptions.maxAttempts,
|
|
756
|
+
printWarning = self._loadRetryOptions.printWarning,
|
|
757
|
+
-- Retry lock contention (the holder may release); fail fast on a fatal op failure.
|
|
758
|
+
shouldRetry = function(err)
|
|
759
|
+
return not DataStoreNonRetryableLoadError.isNonRetryableLoadError(err)
|
|
760
|
+
end,
|
|
761
|
+
}
|
|
762
|
+
|
|
695
763
|
promise = self._maid
|
|
696
764
|
:Add(PromiseRetryUtils.retry(function()
|
|
697
765
|
return promiseLoadUnlockedProfile(false, true)
|
|
698
|
-
end,
|
|
699
|
-
-- https://exponentialbackoffcalculator.com/
|
|
700
|
-
-- 49.242 seconds
|
|
701
|
-
exponential = 1.25,
|
|
702
|
-
initialWaitTime = 6,
|
|
703
|
-
maxAttempts = 6,
|
|
704
|
-
printWarning = true,
|
|
705
|
-
}))
|
|
766
|
+
end, retryOptions))
|
|
706
767
|
:Catch(function(err)
|
|
768
|
+
-- A fatal op failure is not made loadable by stealing the lock; propagate it.
|
|
769
|
+
if DataStoreNonRetryableLoadError.isNonRetryableLoadError(err) then
|
|
770
|
+
return Promise.rejected(err)
|
|
771
|
+
end
|
|
772
|
+
|
|
707
773
|
warn(
|
|
708
774
|
string.format(
|
|
709
775
|
"DataStorePromises.updateAsync(%q) (will steal session) -> warning - %s",
|
|
@@ -724,7 +790,7 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
|
|
|
724
790
|
)
|
|
725
791
|
|
|
726
792
|
self._promiseSessionLockingFailed:Resolve()
|
|
727
|
-
return Promise.rejected(err)
|
|
793
|
+
return Promise.rejected(DataStoreNonRetryableLoadError.unwrapLoadError(err))
|
|
728
794
|
end)
|
|
729
795
|
else
|
|
730
796
|
promise = self._maid
|
|
@@ -758,6 +824,11 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
|
|
|
758
824
|
)
|
|
759
825
|
-- print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data)
|
|
760
826
|
end
|
|
827
|
+
end, function(err)
|
|
828
|
+
-- Propagate the load failure. The explicit onRejected also marks the upstream promise as
|
|
829
|
+
-- consumed, so a cancellation rejection (e.g. the store destroyed mid-load) is not surfaced
|
|
830
|
+
-- as an uncaught exception on this branch.
|
|
831
|
+
return Promise.rejected(err)
|
|
761
832
|
end)
|
|
762
833
|
end
|
|
763
834
|
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
Integration coverage for DataStore against a mocked Roblox datastore: the load/save round-trip
|
|
4
|
+
and how it behaves when datastore operations fail (e.g. the 509 Personal-RCC block).
|
|
5
|
+
|
|
6
|
+
@class DataStore.spec.lua
|
|
7
|
+
]]
|
|
8
|
+
local require = require(script.Parent.loader).load(script)
|
|
9
|
+
|
|
10
|
+
local DataStoreMock = require("DataStoreMock")
|
|
11
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
12
|
+
local Jest = require("Jest")
|
|
13
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
14
|
+
|
|
15
|
+
local describe = Jest.Globals.describe
|
|
16
|
+
local expect = Jest.Globals.expect
|
|
17
|
+
local it = Jest.Globals.it
|
|
18
|
+
|
|
19
|
+
-- Asserts the promise settled within the timeout, so a hung promise fails the test (here) instead of
|
|
20
|
+
-- freezing the runner on the following :Yield().
|
|
21
|
+
local function expectSettled(promise, timeout: number?)
|
|
22
|
+
expect(PromiseTestUtils.awaitSettled(promise, timeout)).toEqual(true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe("DataStore without session locking", function()
|
|
26
|
+
it("should load the default value when the key is empty", function()
|
|
27
|
+
local controller = DataStoreTestUtils.setup()
|
|
28
|
+
local dataStore = controller.newDataStore()
|
|
29
|
+
|
|
30
|
+
local promise = dataStore:Load("coins", 99)
|
|
31
|
+
expectSettled(promise)
|
|
32
|
+
|
|
33
|
+
local ok, value = promise:Yield()
|
|
34
|
+
expect(ok).toEqual(true)
|
|
35
|
+
expect(value).toEqual(99)
|
|
36
|
+
|
|
37
|
+
controller:destroy()
|
|
38
|
+
end)
|
|
39
|
+
|
|
40
|
+
it("should round-trip a stored value through the datastore", function()
|
|
41
|
+
local controller = DataStoreTestUtils.setup()
|
|
42
|
+
|
|
43
|
+
local writer = controller.newDataStore()
|
|
44
|
+
writer:Store("coins", 5)
|
|
45
|
+
|
|
46
|
+
local savePromise = writer:Save()
|
|
47
|
+
expectSettled(savePromise)
|
|
48
|
+
expect((savePromise:Yield())).toEqual(true)
|
|
49
|
+
|
|
50
|
+
local reader = controller.newDataStore()
|
|
51
|
+
local loadPromise = reader:Load("coins")
|
|
52
|
+
expectSettled(loadPromise)
|
|
53
|
+
|
|
54
|
+
local ok, value = loadPromise:Yield()
|
|
55
|
+
expect(ok).toEqual(true)
|
|
56
|
+
expect(value).toEqual(5)
|
|
57
|
+
|
|
58
|
+
controller:destroy()
|
|
59
|
+
end)
|
|
60
|
+
|
|
61
|
+
it("should round-trip multiple keys and load defaults for missing ones", function()
|
|
62
|
+
local controller = DataStoreTestUtils.setup()
|
|
63
|
+
|
|
64
|
+
local writer = controller.newDataStore()
|
|
65
|
+
writer:Store("coins", 5)
|
|
66
|
+
writer:Store("gems", 10)
|
|
67
|
+
expectSettled(writer:Save())
|
|
68
|
+
|
|
69
|
+
local reader = controller.newDataStore()
|
|
70
|
+
local promise = reader:LoadAll()
|
|
71
|
+
expectSettled(promise)
|
|
72
|
+
|
|
73
|
+
local ok, all = promise:Yield()
|
|
74
|
+
expect(ok).toEqual(true)
|
|
75
|
+
expect(all.coins).toEqual(5)
|
|
76
|
+
expect(all.gems).toEqual(10)
|
|
77
|
+
|
|
78
|
+
local missingPromise = reader:Load("missing", "default")
|
|
79
|
+
expectSettled(missingPromise)
|
|
80
|
+
expect((missingPromise:Wait())).toEqual("default")
|
|
81
|
+
|
|
82
|
+
controller:destroy()
|
|
83
|
+
end)
|
|
84
|
+
|
|
85
|
+
it("should round-trip substore values", function()
|
|
86
|
+
local controller = DataStoreTestUtils.setup()
|
|
87
|
+
|
|
88
|
+
local writer = controller.newDataStore()
|
|
89
|
+
writer:GetSubStore("inventory"):Store("sword", true)
|
|
90
|
+
expectSettled(writer:Save())
|
|
91
|
+
|
|
92
|
+
local reader = controller.newDataStore()
|
|
93
|
+
local promise = reader:GetSubStore("inventory"):Load("sword")
|
|
94
|
+
expectSettled(promise)
|
|
95
|
+
|
|
96
|
+
local ok, value = promise:Yield()
|
|
97
|
+
expect(ok).toEqual(true)
|
|
98
|
+
expect(value).toEqual(true)
|
|
99
|
+
|
|
100
|
+
controller:destroy()
|
|
101
|
+
end)
|
|
102
|
+
|
|
103
|
+
it("should delete a key so it no longer loads", function()
|
|
104
|
+
local controller = DataStoreTestUtils.setup()
|
|
105
|
+
|
|
106
|
+
local writer = controller.newDataStore()
|
|
107
|
+
writer:Store("a", 1)
|
|
108
|
+
writer:Store("b", 2)
|
|
109
|
+
expectSettled(writer:Save())
|
|
110
|
+
|
|
111
|
+
writer:Delete("a")
|
|
112
|
+
expectSettled(writer:Save())
|
|
113
|
+
|
|
114
|
+
local reader = controller.newDataStore()
|
|
115
|
+
local promise = reader:LoadAll()
|
|
116
|
+
expectSettled(promise)
|
|
117
|
+
|
|
118
|
+
local ok, all = promise:Yield()
|
|
119
|
+
expect(ok).toEqual(true)
|
|
120
|
+
expect(all.a).toEqual(nil)
|
|
121
|
+
expect(all.b).toEqual(2)
|
|
122
|
+
|
|
123
|
+
controller:destroy()
|
|
124
|
+
end)
|
|
125
|
+
|
|
126
|
+
it("should resolve a save when nothing is staged", function()
|
|
127
|
+
local controller = DataStoreTestUtils.setup()
|
|
128
|
+
local dataStore = controller.newDataStore()
|
|
129
|
+
|
|
130
|
+
expectSettled(dataStore:Load("x"))
|
|
131
|
+
|
|
132
|
+
local savePromise = dataStore:Save()
|
|
133
|
+
expectSettled(savePromise)
|
|
134
|
+
expect((savePromise:Yield())).toEqual(true)
|
|
135
|
+
|
|
136
|
+
controller:destroy()
|
|
137
|
+
end)
|
|
138
|
+
|
|
139
|
+
it("should report load failure (not hang) when the datastore is unavailable", function()
|
|
140
|
+
local controller = DataStoreTestUtils.setup()
|
|
141
|
+
controller.mock:FailAllRequests()
|
|
142
|
+
local dataStore = controller.newDataStore()
|
|
143
|
+
|
|
144
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
145
|
+
expectSettled(promise, 5)
|
|
146
|
+
|
|
147
|
+
local ok, loadedOk = promise:Yield()
|
|
148
|
+
expect(ok).toEqual(true)
|
|
149
|
+
expect(loadedOk).toEqual(false)
|
|
150
|
+
|
|
151
|
+
controller:destroy()
|
|
152
|
+
end)
|
|
153
|
+
|
|
154
|
+
it("should reject a save when the datastore goes down after loading", function()
|
|
155
|
+
local controller = DataStoreTestUtils.setup()
|
|
156
|
+
local dataStore = controller.newDataStore()
|
|
157
|
+
|
|
158
|
+
expectSettled(dataStore:Load("x"))
|
|
159
|
+
|
|
160
|
+
controller.mock:FailAllRequests()
|
|
161
|
+
dataStore:Store("x", 1)
|
|
162
|
+
|
|
163
|
+
local savePromise = dataStore:Save()
|
|
164
|
+
expectSettled(savePromise, 5)
|
|
165
|
+
expect((savePromise:Yield())).toEqual(false)
|
|
166
|
+
|
|
167
|
+
controller:destroy()
|
|
168
|
+
end)
|
|
169
|
+
|
|
170
|
+
it("should mark DidLoadFail after a failed load", function()
|
|
171
|
+
local controller = DataStoreTestUtils.setup()
|
|
172
|
+
controller.mock:FailAllRequests()
|
|
173
|
+
local dataStore = controller.newDataStore()
|
|
174
|
+
|
|
175
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
176
|
+
expectSettled(promise, 5)
|
|
177
|
+
|
|
178
|
+
expect(dataStore:DidLoadFail()).toEqual(true)
|
|
179
|
+
|
|
180
|
+
controller:destroy()
|
|
181
|
+
end)
|
|
182
|
+
end)
|
|
183
|
+
|
|
184
|
+
describe("DataStore with session locking", function()
|
|
185
|
+
it("should load successfully against a healthy datastore", function()
|
|
186
|
+
local controller = DataStoreTestUtils.setup()
|
|
187
|
+
|
|
188
|
+
local dataStore = controller.newDataStore()
|
|
189
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
190
|
+
dataStore:SetUserIdList({ 1 })
|
|
191
|
+
|
|
192
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
193
|
+
expectSettled(promise, 10)
|
|
194
|
+
|
|
195
|
+
local ok, loadedOk = promise:Yield()
|
|
196
|
+
expect(ok).toEqual(true)
|
|
197
|
+
expect(loadedOk).toEqual(true)
|
|
198
|
+
|
|
199
|
+
controller:destroy()
|
|
200
|
+
end)
|
|
201
|
+
|
|
202
|
+
it("surfaces a persistent datastore failure fast instead of hanging", function()
|
|
203
|
+
local controller = DataStoreTestUtils.setup()
|
|
204
|
+
controller.mock:FailAllRequests()
|
|
205
|
+
|
|
206
|
+
local dataStore = controller.newDataStore()
|
|
207
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
208
|
+
dataStore:SetUserIdList({ 1 })
|
|
209
|
+
|
|
210
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
211
|
+
expectSettled(promise, 5)
|
|
212
|
+
|
|
213
|
+
local ok, loadedOk = promise:Yield()
|
|
214
|
+
expect(ok).toEqual(true)
|
|
215
|
+
expect(loadedOk).toEqual(false)
|
|
216
|
+
|
|
217
|
+
controller:destroy()
|
|
218
|
+
end)
|
|
219
|
+
|
|
220
|
+
it("rejects a failed session-locked load with the preserved datastore error", function()
|
|
221
|
+
local controller = DataStoreTestUtils.setup()
|
|
222
|
+
controller.mock:FailAllRequests(DataStoreMock.OPERATION_NOT_ALLOWED_509)
|
|
223
|
+
|
|
224
|
+
local dataStore = controller.newDataStore()
|
|
225
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
226
|
+
dataStore:SetUserIdList({ 1 })
|
|
227
|
+
|
|
228
|
+
-- PromiseLoadSuccessful maps the error to a boolean, so read the rejection from
|
|
229
|
+
-- PromiseViewUpToDate to assert the underlying datastore error propagates.
|
|
230
|
+
local outcome, err = PromiseTestUtils.awaitOutcome(dataStore:PromiseViewUpToDate())
|
|
231
|
+
|
|
232
|
+
expect(outcome).toEqual("rejected")
|
|
233
|
+
expect(string.find(tostring(err), "509", 1, true) ~= nil).toEqual(true)
|
|
234
|
+
|
|
235
|
+
controller:destroy()
|
|
236
|
+
end)
|
|
237
|
+
end)
|