@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 +22 -0
- package/README.md +10 -0
- package/docs/shutdown-and-session-locks.md +183 -0
- package/package.json +3 -2
- package/src/Client/Cmdr/DataStoreCmdrServiceClient.lua +48 -0
- package/src/Client/DataStoreServiceClient.lua +35 -0
- package/src/Server/Cmdr/DataStoreCmdrService.lua +454 -0
- package/src/Server/Cmdr/DataStoreCmdrService.spec.lua +370 -0
- package/src/Server/DataStore.GracefulClose.spec.lua +12 -10
- package/src/Server/DataStore.lua +21 -0
- package/src/Server/DataStoreLockHelper.lua +8 -86
- package/src/Server/DataStoreService.lua +40 -0
- package/src/Server/DataStoreTestUtils.lua +79 -3
- package/src/Server/Modules/DataStoreLockUtils.lua +139 -0
- package/src/Server/PlayerDataStoreHandle.lua +103 -0
- package/src/Server/PlayerDataStoreManager.Handles.spec.lua +136 -0
- package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +5 -0
- package/src/Server/PlayerDataStoreManager.SessionLockTools.spec.lua +228 -0
- package/src/Server/PlayerDataStoreManager.lua +306 -34
- package/src/Server/PlayerDataStoreManager.spec.lua +155 -44
- package/src/Server/PlayerDataStoreService.lua +112 -0
- package/src/Server/PlayerDataStoreService.spec.lua +85 -15
- package/src/Shared/Cmdr/DataStoreCmdrUtils.lua +57 -0
|
@@ -8,6 +8,7 @@ local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
|
8
8
|
local Jest = require("Jest")
|
|
9
9
|
local PlayerMock = require("PlayerMock")
|
|
10
10
|
local PromiseTestUtils = require("PromiseTestUtils")
|
|
11
|
+
local PromiseUtils = require("PromiseUtils")
|
|
11
12
|
|
|
12
13
|
local describe = Jest.Globals.describe
|
|
13
14
|
local expect = Jest.Globals.expect
|
|
@@ -189,94 +190,115 @@ describe("PlayerDataStoreManager.PromiseAllSaves", function()
|
|
|
189
190
|
end)
|
|
190
191
|
end)
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
-- Models a closing server the way Roblox actually behaves: PlayerRemoving fires for everyone still in
|
|
194
|
+
-- the server and does the save-and-close, and the close is held open until those removals flush.
|
|
195
|
+
describe("PlayerDataStoreManager server shutdown", function()
|
|
196
|
+
it("saves the staged data and releases the lock for the leaving player", function()
|
|
194
197
|
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
195
198
|
|
|
196
|
-
|
|
197
|
-
|
|
199
|
+
if not controller.storeAndAwaitLock() then
|
|
200
|
+
expect("lock was never acquired").toEqual("lock was acquired")
|
|
198
201
|
controller:destroy()
|
|
199
202
|
return
|
|
200
203
|
end
|
|
201
204
|
|
|
202
|
-
controller.
|
|
205
|
+
if not expectSettled(controller.promiseShutdown({ 1 }), 10) then
|
|
206
|
+
controller:destroy()
|
|
207
|
+
return
|
|
208
|
+
end
|
|
203
209
|
|
|
204
|
-
|
|
210
|
+
local raw = controller.mock:GetRaw("user_1")
|
|
211
|
+
expect(raw.coins).toEqual(5)
|
|
212
|
+
expect(raw.lock).toEqual(nil)
|
|
205
213
|
|
|
206
214
|
controller:destroy()
|
|
207
215
|
end)
|
|
208
216
|
|
|
209
|
-
it("
|
|
217
|
+
it("releases the lock for every player still in the server", function()
|
|
210
218
|
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
211
219
|
|
|
212
|
-
|
|
213
|
-
|
|
220
|
+
controller.manager:GetDataStore(1):Store("coins", 1)
|
|
221
|
+
controller.manager:GetDataStore(2):Store("coins", 2)
|
|
222
|
+
|
|
223
|
+
local locked = PromiseTestUtils.awaitValue(function()
|
|
224
|
+
local rawOne = controller.mock:GetRaw("user_1")
|
|
225
|
+
local rawTwo = controller.mock:GetRaw("user_2")
|
|
226
|
+
return rawOne ~= nil and rawOne.lock ~= nil and rawTwo ~= nil and rawTwo.lock ~= nil
|
|
227
|
+
end, 10)
|
|
228
|
+
if not locked then
|
|
229
|
+
expect("both locks were never acquired").toEqual("both locks were acquired")
|
|
214
230
|
controller:destroy()
|
|
215
231
|
return
|
|
216
232
|
end
|
|
217
233
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
234
|
+
if not expectSettled(controller.promiseShutdown({ 1, 2 }), 10) then
|
|
235
|
+
controller:destroy()
|
|
236
|
+
return
|
|
237
|
+
end
|
|
221
238
|
|
|
222
|
-
|
|
223
|
-
expect(
|
|
224
|
-
expect(
|
|
239
|
+
expect(controller.mock:GetRaw("user_1").lock).toEqual(nil)
|
|
240
|
+
expect(controller.mock:GetRaw("user_2").lock).toEqual(nil)
|
|
241
|
+
expect(controller.mock:GetRaw("user_1").coins).toEqual(1)
|
|
242
|
+
expect(controller.mock:GetRaw("user_2").coins).toEqual(2)
|
|
225
243
|
|
|
226
244
|
controller:destroy()
|
|
227
245
|
end)
|
|
228
246
|
|
|
229
|
-
it("
|
|
247
|
+
it("destroys each store once its removal has flushed", function()
|
|
230
248
|
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
231
249
|
|
|
232
|
-
|
|
233
|
-
|
|
250
|
+
local dataStore = controller.manager:GetDataStore(1)
|
|
251
|
+
if not expectSettled(dataStore:PromiseLoadSuccessful(), 10) then
|
|
234
252
|
controller:destroy()
|
|
235
253
|
return
|
|
236
254
|
end
|
|
237
255
|
|
|
238
|
-
controller.
|
|
256
|
+
if not expectSettled(controller.promiseShutdown({ 1 }), 10) then
|
|
257
|
+
controller:destroy()
|
|
258
|
+
return
|
|
259
|
+
end
|
|
239
260
|
|
|
240
|
-
expect(
|
|
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)
|
|
261
|
+
expect(getmetatable(dataStore)).toBeNil()
|
|
245
262
|
|
|
246
263
|
controller:destroy()
|
|
247
264
|
end)
|
|
248
265
|
|
|
249
|
-
it("
|
|
266
|
+
it("does not resolve the close while a PlayerRemoving save is still in flight", function()
|
|
250
267
|
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
251
268
|
|
|
252
|
-
|
|
253
|
-
|
|
269
|
+
-- An async removing callback pushes the write past the moment the close is requested, which is
|
|
270
|
+
-- exactly the window where waiting on pending saves alone finds nothing to wait for.
|
|
271
|
+
controller.manager:AddRemovingCallback(function()
|
|
272
|
+
return PromiseUtils.delayed(0.5)
|
|
273
|
+
end)
|
|
254
274
|
|
|
255
|
-
|
|
256
|
-
|
|
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")
|
|
275
|
+
if not controller.storeAndAwaitLock() then
|
|
276
|
+
expect("lock was never acquired").toEqual("lock was acquired")
|
|
262
277
|
controller:destroy()
|
|
263
278
|
return
|
|
264
279
|
end
|
|
265
280
|
|
|
266
|
-
|
|
281
|
+
-- PlayerRemoving lands first, then the server begins closing.
|
|
282
|
+
controller.manager:RemovePlayerDataStore(1)
|
|
267
283
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
284
|
+
local closePromise = controller.manager:PromiseAllSaves()
|
|
285
|
+
expect(closePromise:IsPending()).toEqual(true)
|
|
286
|
+
|
|
287
|
+
if not expectSettled(closePromise, 10) then
|
|
288
|
+
controller:destroy()
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
-- The close resolving has to mean the write landed. If it can resolve first, the real server
|
|
293
|
+
-- dies here with the session still locked and no other server able to release it.
|
|
294
|
+
local raw = controller.mock:GetRaw("user_1")
|
|
295
|
+
expect(raw.coins).toEqual(5)
|
|
296
|
+
expect(raw.lock).toEqual(nil)
|
|
275
297
|
|
|
276
298
|
controller:destroy()
|
|
277
299
|
end)
|
|
278
300
|
|
|
279
|
-
it("
|
|
301
|
+
it("closes cleanly when a store's load failed, leaking no rejection and writing no lock", function()
|
|
280
302
|
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
281
303
|
|
|
282
304
|
controller.mock:FailAllRequests()
|
|
@@ -290,10 +312,99 @@ describe("PlayerDataStoreManager teardown", function()
|
|
|
290
312
|
end
|
|
291
313
|
expect((loaded:Wait())).toEqual(false)
|
|
292
314
|
|
|
293
|
-
controller.
|
|
315
|
+
if not expectSettled(controller.promiseShutdown({ 1 }), 10) then
|
|
316
|
+
controller:destroy()
|
|
317
|
+
return
|
|
318
|
+
end
|
|
294
319
|
|
|
295
320
|
expect(controller.mock:GetRaw("user_1")).toBeNil()
|
|
296
321
|
|
|
297
322
|
controller:destroy()
|
|
298
323
|
end)
|
|
299
324
|
end)
|
|
325
|
+
|
|
326
|
+
describe("PlayerDataStoreManager datastore configuration", function()
|
|
327
|
+
it("applies the configured autosave interval to created datastores", function()
|
|
328
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
329
|
+
|
|
330
|
+
controller.manager:SetAutoSaveTimeSeconds(14)
|
|
331
|
+
|
|
332
|
+
local dataStore = controller.manager:GetDataStore(1)
|
|
333
|
+
expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(14)
|
|
334
|
+
|
|
335
|
+
controller:destroy()
|
|
336
|
+
end)
|
|
337
|
+
|
|
338
|
+
it("leaves the datastore default alone when unconfigured", function()
|
|
339
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
340
|
+
|
|
341
|
+
local dataStore = controller.manager:GetDataStore(1)
|
|
342
|
+
expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(60 * 5)
|
|
343
|
+
|
|
344
|
+
controller:destroy()
|
|
345
|
+
end)
|
|
346
|
+
|
|
347
|
+
it("applies nil autosave (syncing disabled) rather than treating it as unconfigured", function()
|
|
348
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
349
|
+
|
|
350
|
+
controller.manager:SetAutoSaveTimeSeconds(nil)
|
|
351
|
+
|
|
352
|
+
local dataStore = controller.manager:GetDataStore(1)
|
|
353
|
+
expect(dataStore:GetAutoSaveTimeSeconds()).toBeNil()
|
|
354
|
+
|
|
355
|
+
controller:destroy()
|
|
356
|
+
end)
|
|
357
|
+
|
|
358
|
+
it("configures every datastore it creates, not just the first", function()
|
|
359
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
360
|
+
|
|
361
|
+
controller.manager:SetAutoSaveTimeSeconds(14)
|
|
362
|
+
|
|
363
|
+
controller.manager:GetDataStore(1)
|
|
364
|
+
local second = controller.manager:GetDataStore(2)
|
|
365
|
+
expect((second:GetAutoSaveTimeSeconds())).toEqual(14)
|
|
366
|
+
|
|
367
|
+
controller:destroy()
|
|
368
|
+
end)
|
|
369
|
+
|
|
370
|
+
it("rejects configuration once a datastore has been created", function()
|
|
371
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
372
|
+
|
|
373
|
+
controller.manager:GetDataStore(1)
|
|
374
|
+
|
|
375
|
+
expect(function()
|
|
376
|
+
controller.manager:SetAutoSaveTimeSeconds(14)
|
|
377
|
+
end).toThrow()
|
|
378
|
+
expect(function()
|
|
379
|
+
controller.manager:SetLoadRetryOptions({ initialWaitTime = 1, maxAttempts = 2, printWarning = false })
|
|
380
|
+
end).toThrow()
|
|
381
|
+
expect(function()
|
|
382
|
+
controller.manager:SetSessionMessagingCloseDelaySeconds(0.5)
|
|
383
|
+
end).toThrow()
|
|
384
|
+
|
|
385
|
+
controller:destroy()
|
|
386
|
+
end)
|
|
387
|
+
|
|
388
|
+
it("forwards load retry options to created datastores", function()
|
|
389
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
390
|
+
|
|
391
|
+
local retryOptions = { exponential = 1, initialWaitTime = 0.1, maxAttempts = 2, printWarning = false }
|
|
392
|
+
controller.manager:SetLoadRetryOptions(retryOptions)
|
|
393
|
+
|
|
394
|
+
local dataStore = controller.manager:GetDataStore(1)
|
|
395
|
+
expect((dataStore:GetLoadRetryOptions())).toEqual(retryOptions)
|
|
396
|
+
|
|
397
|
+
controller:destroy()
|
|
398
|
+
end)
|
|
399
|
+
|
|
400
|
+
it("forwards the session messaging close delay to created datastores", function()
|
|
401
|
+
local controller = DataStoreTestUtils.setupDataStoreManager()
|
|
402
|
+
|
|
403
|
+
controller.manager:SetSessionMessagingCloseDelaySeconds(0.5)
|
|
404
|
+
|
|
405
|
+
local dataStore = controller.manager:GetDataStore(1)
|
|
406
|
+
expect((dataStore:GetSessionMessagingCloseDelaySeconds())).toEqual(0.5)
|
|
407
|
+
|
|
408
|
+
controller:destroy()
|
|
409
|
+
end)
|
|
410
|
+
end)
|
|
@@ -11,8 +11,10 @@ local require = require(script.Parent.loader).load(script)
|
|
|
11
11
|
local DataStore = require("DataStore")
|
|
12
12
|
local DataStorePromises = require("DataStorePromises")
|
|
13
13
|
local Maid = require("Maid")
|
|
14
|
+
local PlayerDataStoreHandle = require("PlayerDataStoreHandle")
|
|
14
15
|
local PlayerDataStoreManager = require("PlayerDataStoreManager")
|
|
15
16
|
local Promise = require("Promise")
|
|
17
|
+
local PromiseRetryUtils = require("PromiseRetryUtils")
|
|
16
18
|
local ServiceBag = require("ServiceBag")
|
|
17
19
|
|
|
18
20
|
local PlayerDataStoreService = {}
|
|
@@ -28,6 +30,10 @@ export type PlayerDataStoreService = typeof(setmetatable(
|
|
|
28
30
|
_bindToCloseService: any,
|
|
29
31
|
_promiseStarted: Promise.Promise<()>,
|
|
30
32
|
_robloxDataStoreOverride: any?,
|
|
33
|
+
_loadRetryOptions: PromiseRetryUtils.RetryOptions?,
|
|
34
|
+
_autoSaveTimeSeconds: number?,
|
|
35
|
+
_autoSaveTimeSecondsSet: boolean,
|
|
36
|
+
_sessionMessagingCloseDelaySeconds: number?,
|
|
31
37
|
},
|
|
32
38
|
{} :: typeof({ __index = PlayerDataStoreService })
|
|
33
39
|
))
|
|
@@ -44,10 +50,14 @@ function PlayerDataStoreService.Init(self: PlayerDataStoreService, serviceBag: S
|
|
|
44
50
|
self._bindToCloseService = self._serviceBag:GetService(require("BindToCloseService"))
|
|
45
51
|
self._serviceBag:GetService(require("PlaceMessagingService"))
|
|
46
52
|
|
|
53
|
+
-- Internal
|
|
54
|
+
self._serviceBag:GetService(require("DataStoreCmdrService"))
|
|
55
|
+
|
|
47
56
|
-- State
|
|
48
57
|
self._promiseStarted = self._maid:Add(Promise.new())
|
|
49
58
|
self._dataStoreName = "PlayerData"
|
|
50
59
|
self._dataStoreScope = "SaveData"
|
|
60
|
+
self._autoSaveTimeSecondsSet = false
|
|
51
61
|
end
|
|
52
62
|
|
|
53
63
|
--[=[
|
|
@@ -92,6 +102,65 @@ function PlayerDataStoreService.SetDataStoreScope(self: PlayerDataStoreService,
|
|
|
92
102
|
self._dataStoreScope = dataStoreScope
|
|
93
103
|
end
|
|
94
104
|
|
|
105
|
+
--[=[
|
|
106
|
+
Overrides the load retry backoff on every player datastore. See
|
|
107
|
+
[PlayerDataStoreManager.SetLoadRetryOptions] -- this is what decides how long a player waits on a
|
|
108
|
+
lock held by a dead server before it is stolen.
|
|
109
|
+
|
|
110
|
+
:::info
|
|
111
|
+
Must be done before start and after init.
|
|
112
|
+
:::
|
|
113
|
+
|
|
114
|
+
@param options RetryOptions
|
|
115
|
+
]=]
|
|
116
|
+
function PlayerDataStoreService.SetLoadRetryOptions(
|
|
117
|
+
self: PlayerDataStoreService,
|
|
118
|
+
options: PromiseRetryUtils.RetryOptions
|
|
119
|
+
): ()
|
|
120
|
+
assert(type(options) == "table", "Bad options")
|
|
121
|
+
assert(self._promiseStarted, "Not initialized")
|
|
122
|
+
assert(self._promiseStarted:IsPending(), "Already started, cannot configure")
|
|
123
|
+
|
|
124
|
+
self._loadRetryOptions = options
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
--[=[
|
|
128
|
+
Sets the autosave interval on every player datastore. See
|
|
129
|
+
[PlayerDataStoreManager.SetAutoSaveTimeSeconds].
|
|
130
|
+
|
|
131
|
+
:::info
|
|
132
|
+
Must be done before start and after init.
|
|
133
|
+
:::
|
|
134
|
+
|
|
135
|
+
@param autoSaveTimeSeconds number?
|
|
136
|
+
]=]
|
|
137
|
+
function PlayerDataStoreService.SetAutoSaveTimeSeconds(self: PlayerDataStoreService, autoSaveTimeSeconds: number?): ()
|
|
138
|
+
assert(type(autoSaveTimeSeconds) == "number" or autoSaveTimeSeconds == nil, "Bad autoSaveTimeSeconds")
|
|
139
|
+
assert(self._promiseStarted, "Not initialized")
|
|
140
|
+
assert(self._promiseStarted:IsPending(), "Already started, cannot configure")
|
|
141
|
+
|
|
142
|
+
self._autoSaveTimeSeconds = autoSaveTimeSeconds
|
|
143
|
+
self._autoSaveTimeSecondsSet = true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
--[=[
|
|
147
|
+
Sets the post-graceful-close replication delay on every player datastore. See
|
|
148
|
+
[PlayerDataStoreManager.SetSessionMessagingCloseDelaySeconds].
|
|
149
|
+
|
|
150
|
+
:::info
|
|
151
|
+
Must be done before start and after init.
|
|
152
|
+
:::
|
|
153
|
+
|
|
154
|
+
@param seconds number
|
|
155
|
+
]=]
|
|
156
|
+
function PlayerDataStoreService.SetSessionMessagingCloseDelaySeconds(self: PlayerDataStoreService, seconds: number): ()
|
|
157
|
+
assert(type(seconds) == "number" and seconds >= 0, "Bad seconds")
|
|
158
|
+
assert(self._promiseStarted, "Not initialized")
|
|
159
|
+
assert(self._promiseStarted:IsPending(), "Already started, cannot configure")
|
|
160
|
+
|
|
161
|
+
self._sessionMessagingCloseDelaySeconds = seconds
|
|
162
|
+
end
|
|
163
|
+
|
|
95
164
|
--[=[
|
|
96
165
|
Injects the underlying datastore the manager wraps, instead of resolving a real one. Accepts
|
|
97
166
|
a real datastore or a [DataStoreMock]. Intended for testing; must be called before the manager
|
|
@@ -126,6 +195,39 @@ function PlayerDataStoreService.PromiseDataStore(
|
|
|
126
195
|
end)
|
|
127
196
|
end
|
|
128
197
|
|
|
198
|
+
--[=[
|
|
199
|
+
Borrows the player's [DataStore] as a [PlayerDataStoreHandle], which releases the session when
|
|
200
|
+
destroyed. Prefer this over [PlayerDataStoreService.PromiseDataStore] when acting on a player by
|
|
201
|
+
userId, since it is what makes the release hard to forget -- see [PlayerDataStoreHandle].
|
|
202
|
+
|
|
203
|
+
@param playerOrUserId Player | number
|
|
204
|
+
@return Promise<PlayerDataStoreHandle>
|
|
205
|
+
]=]
|
|
206
|
+
function PlayerDataStoreService.PromiseDataStoreHandle(
|
|
207
|
+
self: PlayerDataStoreService,
|
|
208
|
+
playerOrUserId: Player | number
|
|
209
|
+
): Promise.Promise<PlayerDataStoreHandle.PlayerDataStoreHandle>
|
|
210
|
+
return self:PromiseManager():Then(function(manager)
|
|
211
|
+
return manager:PromiseDataStoreHandle(playerOrUserId)
|
|
212
|
+
end)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
--[=[
|
|
216
|
+
Resolves once any removal in flight for this player has saved and closed their session -- see
|
|
217
|
+
[PlayerDataStoreManager.PromiseSessionClosed].
|
|
218
|
+
|
|
219
|
+
@param playerOrUserId Player | number
|
|
220
|
+
@return Promise<()>
|
|
221
|
+
]=]
|
|
222
|
+
function PlayerDataStoreService.PromiseSessionClosed(
|
|
223
|
+
self: PlayerDataStoreService,
|
|
224
|
+
playerOrUserId: Player | number
|
|
225
|
+
): Promise.Promise<()>
|
|
226
|
+
return self:PromiseManager():Then(function(manager)
|
|
227
|
+
return manager:PromiseSessionClosed(playerOrUserId)
|
|
228
|
+
end)
|
|
229
|
+
end
|
|
230
|
+
|
|
129
231
|
--[=[
|
|
130
232
|
Adds a removing callback to the manager.
|
|
131
233
|
@param callback function -- May return a promise
|
|
@@ -167,6 +269,16 @@ function PlayerDataStoreService.PromiseManager(
|
|
|
167
269
|
end
|
|
168
270
|
end, true))
|
|
169
271
|
|
|
272
|
+
if self._loadRetryOptions then
|
|
273
|
+
manager:SetLoadRetryOptions(self._loadRetryOptions)
|
|
274
|
+
end
|
|
275
|
+
if self._autoSaveTimeSecondsSet then
|
|
276
|
+
manager:SetAutoSaveTimeSeconds(self._autoSaveTimeSeconds)
|
|
277
|
+
end
|
|
278
|
+
if self._sessionMessagingCloseDelaySeconds then
|
|
279
|
+
manager:SetSessionMessagingCloseDelaySeconds(self._sessionMessagingCloseDelaySeconds)
|
|
280
|
+
end
|
|
281
|
+
|
|
170
282
|
-- A lot safer if we're hot reloading or need to monitor bind to close calls
|
|
171
283
|
self._maid:GiveTask(self._bindToCloseService:RegisterPromiseOnCloseCallback(function()
|
|
172
284
|
return manager:PromiseAllSaves()
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
local require = require(script.Parent.loader).load(script)
|
|
6
6
|
|
|
7
7
|
local DataStoreMock = require("DataStoreMock")
|
|
8
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
8
9
|
local Jest = require("Jest")
|
|
9
10
|
local Maid = require("Maid")
|
|
11
|
+
local Promise = require("Promise")
|
|
10
12
|
local PromiseTestUtils = require("PromiseTestUtils")
|
|
11
13
|
local ServiceBag = require("ServiceBag")
|
|
12
14
|
|
|
@@ -26,10 +28,26 @@ local function setup(mock)
|
|
|
26
28
|
serviceBag:Start()
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
-- Drives the real close path: the manager the service owns, shut down the way Roblox does. Without a
|
|
32
|
+
-- mock the bag was never started, so there is no manager to reach and nothing to shut down.
|
|
33
|
+
local function promiseShutdown(userIds)
|
|
34
|
+
if not mock then
|
|
35
|
+
return Promise.resolved()
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return service:PromiseManager():Then(function(manager)
|
|
39
|
+
return DataStoreTestUtils.promiseSimulatedShutdown(manager, userIds)
|
|
40
|
+
end)
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
return {
|
|
30
44
|
service = service,
|
|
31
45
|
mock = mock,
|
|
46
|
+
promiseShutdown = promiseShutdown,
|
|
32
47
|
destroy = function()
|
|
48
|
+
-- Otherwise a store the spec loaded outlives it with its auto-save loop running, and fires
|
|
49
|
+
-- inside a later package's window in the shared test place.
|
|
50
|
+
PromiseTestUtils.awaitSettled(promiseShutdown(), 5)
|
|
33
51
|
maid:DoCleaning()
|
|
34
52
|
end,
|
|
35
53
|
}
|
|
@@ -157,8 +175,10 @@ describe("PlayerDataStoreService failure handling", function()
|
|
|
157
175
|
end)
|
|
158
176
|
end)
|
|
159
177
|
|
|
160
|
-
|
|
161
|
-
|
|
178
|
+
-- The service registers manager:PromiseAllSaves() as its BindToClose callback, so a closing server is
|
|
179
|
+
-- PlayerRemoving doing the save-and-close with that callback held open until it flushes.
|
|
180
|
+
describe("PlayerDataStoreService server shutdown", function()
|
|
181
|
+
it("saves the staged data and destroys the store when the server closes", function()
|
|
162
182
|
local controller = setup(DataStoreMock.new())
|
|
163
183
|
|
|
164
184
|
local promise = controller.service:PromiseDataStore(1)
|
|
@@ -175,13 +195,55 @@ describe("PlayerDataStoreService teardown", function()
|
|
|
175
195
|
return
|
|
176
196
|
end
|
|
177
197
|
|
|
178
|
-
|
|
198
|
+
dataStore:Store("coins", 7)
|
|
179
199
|
|
|
200
|
+
if not PromiseTestUtils.awaitSettled(controller.promiseShutdown({ 1 }), 10) then
|
|
201
|
+
expect("shutdown never flushed").toEqual("shutdown flushed")
|
|
202
|
+
controller:destroy()
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
local raw = controller.mock:GetRaw("1")
|
|
207
|
+
expect(raw).never.toBeNil()
|
|
208
|
+
expect(raw.coins).toEqual(7)
|
|
180
209
|
expect(getmetatable(dataStore)).toBeNil()
|
|
210
|
+
|
|
211
|
+
controller:destroy()
|
|
181
212
|
end)
|
|
213
|
+
end)
|
|
182
214
|
|
|
183
|
-
|
|
184
|
-
|
|
215
|
+
describe("PlayerDataStoreService datastore configuration", function()
|
|
216
|
+
-- The shared setup() starts the bag as soon as it has a mock, and these setters must land between
|
|
217
|
+
-- Init and Start -- the same window SetDataStoreName uses.
|
|
218
|
+
local function setupConfigured(configure)
|
|
219
|
+
local maid = Maid.new()
|
|
220
|
+
|
|
221
|
+
local mock = DataStoreMock.new()
|
|
222
|
+
local serviceBag = maid:Add(ServiceBag.new())
|
|
223
|
+
local service = serviceBag:GetService(require("PlayerDataStoreService"))
|
|
224
|
+
serviceBag:Init()
|
|
225
|
+
|
|
226
|
+
service:SetRobloxDataStore(mock)
|
|
227
|
+
configure(service)
|
|
228
|
+
serviceBag:Start()
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
service = service,
|
|
232
|
+
mock = mock,
|
|
233
|
+
destroy = function()
|
|
234
|
+
DataStoreTestUtils.awaitServiceShutdown(service)
|
|
235
|
+
maid:DoCleaning()
|
|
236
|
+
end,
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it("forwards configuration through to the datastores the manager creates", function()
|
|
241
|
+
local retryOptions = { exponential = 1, initialWaitTime = 0.1, maxAttempts = 2, printWarning = false }
|
|
242
|
+
local controller = setupConfigured(function(service)
|
|
243
|
+
service:SetAutoSaveTimeSeconds(14)
|
|
244
|
+
service:SetLoadRetryOptions(retryOptions)
|
|
245
|
+
service:SetSessionMessagingCloseDelaySeconds(0.5)
|
|
246
|
+
end)
|
|
185
247
|
|
|
186
248
|
local promise = controller.service:PromiseDataStore(1)
|
|
187
249
|
if not PromiseTestUtils.awaitSettled(promise, 10) then
|
|
@@ -191,18 +253,26 @@ describe("PlayerDataStoreService teardown", function()
|
|
|
191
253
|
end
|
|
192
254
|
local _ok, dataStore = promise:Yield()
|
|
193
255
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
dataStore:Store("coins", 7)
|
|
256
|
+
expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(14)
|
|
257
|
+
expect((dataStore:GetLoadRetryOptions())).toEqual(retryOptions)
|
|
258
|
+
expect((dataStore:GetSessionMessagingCloseDelaySeconds())).toEqual(0.5)
|
|
201
259
|
|
|
202
260
|
controller:destroy()
|
|
261
|
+
end)
|
|
203
262
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
263
|
+
it("rejects configuration after start", function()
|
|
264
|
+
local controller = setupConfigured(function() end)
|
|
265
|
+
|
|
266
|
+
expect(function()
|
|
267
|
+
controller.service:SetAutoSaveTimeSeconds(14)
|
|
268
|
+
end).toThrow()
|
|
269
|
+
expect(function()
|
|
270
|
+
controller.service:SetLoadRetryOptions({ initialWaitTime = 1, maxAttempts = 2, printWarning = false })
|
|
271
|
+
end).toThrow()
|
|
272
|
+
expect(function()
|
|
273
|
+
controller.service:SetSessionMessagingCloseDelaySeconds(0.5)
|
|
274
|
+
end).toThrow()
|
|
275
|
+
|
|
276
|
+
controller:destroy()
|
|
207
277
|
end)
|
|
208
278
|
end)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Shared plumbing for the datastore Cmdr commands. Runs on both realms, so nothing here may reach
|
|
4
|
+
for a store -- those live on the server.
|
|
5
|
+
|
|
6
|
+
@class DataStoreCmdrUtils
|
|
7
|
+
]=]
|
|
8
|
+
|
|
9
|
+
local DataStoreCmdrUtils = {}
|
|
10
|
+
|
|
11
|
+
--[=[
|
|
12
|
+
Splits a slash-delimited sub-store path into its segments, dropping empty ones so a stray or
|
|
13
|
+
trailing slash is forgiving rather than an error. An empty path means the root store.
|
|
14
|
+
|
|
15
|
+
@param text string
|
|
16
|
+
@return { string }
|
|
17
|
+
]=]
|
|
18
|
+
function DataStoreCmdrUtils.parsePath(text: string): { string }
|
|
19
|
+
assert(type(text) == "string", "Bad text")
|
|
20
|
+
|
|
21
|
+
local path = {}
|
|
22
|
+
for segment in string.gmatch(text, "[^/]+") do
|
|
23
|
+
table.insert(path, segment)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
--[=[
|
|
30
|
+
Registers the sub-store path type.
|
|
31
|
+
|
|
32
|
+
Cmdr resolves types against the executor, who cannot see another player's stores, so a path is
|
|
33
|
+
accepted as typed rather than completed against anything.
|
|
34
|
+
|
|
35
|
+
@param cmdr Cmdr
|
|
36
|
+
]=]
|
|
37
|
+
function DataStoreCmdrUtils.registerSubStoreType(cmdr: any): ()
|
|
38
|
+
local subStore = {
|
|
39
|
+
Transform = function(text: string)
|
|
40
|
+
if text ~= "" then
|
|
41
|
+
return { text }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return {}
|
|
45
|
+
end,
|
|
46
|
+
Validate = function(keys: { string })
|
|
47
|
+
return #keys > 0, "Not a sub-store path."
|
|
48
|
+
end,
|
|
49
|
+
Parse = function(keys: { string })
|
|
50
|
+
return DataStoreCmdrUtils.parsePath(keys[1])
|
|
51
|
+
end,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
cmdr.Registry:RegisterType("dataStoreSubStore", subStore)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return DataStoreCmdrUtils
|