@quenty/datastore 13.51.1 → 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 +15 -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.lua +21 -0
- package/src/Server/DataStoreLockHelper.lua +8 -86
- package/src/Server/DataStoreService.lua +40 -0
- 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.SessionLockTools.spec.lua +228 -0
- package/src/Server/PlayerDataStoreManager.lua +282 -0
- package/src/Server/PlayerDataStoreManager.spec.lua +86 -0
- package/src/Server/PlayerDataStoreService.lua +112 -0
- package/src/Server/PlayerDataStoreService.spec.lua +65 -0
- package/src/Shared/Cmdr/DataStoreCmdrUtils.lua +57 -0
|
@@ -51,16 +51,21 @@
|
|
|
51
51
|
|
|
52
52
|
local require = require(script.Parent.loader).load(script)
|
|
53
53
|
|
|
54
|
+
local HttpService = game:GetService("HttpService")
|
|
54
55
|
local Players = game:GetService("Players")
|
|
55
56
|
local RunService = game:GetService("RunService")
|
|
56
57
|
|
|
57
58
|
local BaseObject = require("BaseObject")
|
|
58
59
|
local BindToCloseService = require("BindToCloseService")
|
|
59
60
|
local DataStore = require("DataStore")
|
|
61
|
+
local DataStoreLockUtils = require("DataStoreLockUtils")
|
|
62
|
+
local DataStorePromises = require("DataStorePromises")
|
|
60
63
|
local Maid = require("Maid")
|
|
61
64
|
local PendingPromiseTracker = require("PendingPromiseTracker")
|
|
65
|
+
local PlayerDataStoreHandle = require("PlayerDataStoreHandle")
|
|
62
66
|
local PlayerMock = require("PlayerMock")
|
|
63
67
|
local Promise = require("Promise")
|
|
68
|
+
local PromiseRetryUtils = require("PromiseRetryUtils")
|
|
64
69
|
local PromiseUtils = require("PromiseUtils")
|
|
65
70
|
local ServiceBag = require("ServiceBag")
|
|
66
71
|
|
|
@@ -81,9 +86,15 @@ export type PlayerDataStoreManager =
|
|
|
81
86
|
_datastores: { [PlayerUserId]: DataStore.DataStore },
|
|
82
87
|
_removing: { [PlayerUserId]: boolean },
|
|
83
88
|
_removingPromises: { [PlayerUserId]: Promise.Promise<any> },
|
|
89
|
+
_handleCounts: { [PlayerUserId]: number },
|
|
84
90
|
_pendingSaves: PendingPromiseTracker.PendingPromiseTracker<any>,
|
|
85
91
|
_removingCallbacks: { RemovingCallback },
|
|
86
92
|
_disableSavingInStudio: boolean?,
|
|
93
|
+
_hasCreatedDataStore: boolean,
|
|
94
|
+
_loadRetryOptions: PromiseRetryUtils.RetryOptions?,
|
|
95
|
+
_autoSaveTimeSeconds: number?,
|
|
96
|
+
_autoSaveTimeSecondsSet: boolean,
|
|
97
|
+
_sessionMessagingCloseDelaySeconds: number?,
|
|
87
98
|
},
|
|
88
99
|
{} :: typeof({ __index = PlayerDataStoreManager })
|
|
89
100
|
))
|
|
@@ -119,8 +130,11 @@ function PlayerDataStoreManager.new(
|
|
|
119
130
|
self._datastores = {} -- [userId] = datastore
|
|
120
131
|
self._removing = {} -- [player] = true
|
|
121
132
|
self._removingPromises = {} -- [player] = removal promise
|
|
133
|
+
self._handleCounts = {} -- [userId] = outstanding PlayerDataStoreHandle count
|
|
122
134
|
self._pendingSaves = PendingPromiseTracker.new()
|
|
123
135
|
self._removingCallbacks = {} -- [func, ...]
|
|
136
|
+
self._hasCreatedDataStore = false
|
|
137
|
+
self._autoSaveTimeSecondsSet = false
|
|
124
138
|
|
|
125
139
|
self._maid:GiveTask(Players.PlayerRemoving:Connect(function(player)
|
|
126
140
|
if self._disableSavingInStudio then
|
|
@@ -155,6 +169,64 @@ function PlayerDataStoreManager.DisableSaveOnCloseStudio(self: PlayerDataStoreMa
|
|
|
155
169
|
self._disableSavingInStudio = true
|
|
156
170
|
end
|
|
157
171
|
|
|
172
|
+
--[=[
|
|
173
|
+
Overrides the load retry backoff on every datastore this manager creates. See
|
|
174
|
+
[DataStore.SetLoadRetryOptions].
|
|
175
|
+
|
|
176
|
+
This is the knob that decides how long a player waits on a lock held by a dead server: the ladder
|
|
177
|
+
runs, and only once it is exhausted is the lock stolen unconditionally. Defaults to ~49s.
|
|
178
|
+
|
|
179
|
+
:::info
|
|
180
|
+
Must be set before the first datastore is created.
|
|
181
|
+
:::
|
|
182
|
+
|
|
183
|
+
@param options RetryOptions
|
|
184
|
+
]=]
|
|
185
|
+
function PlayerDataStoreManager.SetLoadRetryOptions(
|
|
186
|
+
self: PlayerDataStoreManager,
|
|
187
|
+
options: PromiseRetryUtils.RetryOptions
|
|
188
|
+
): ()
|
|
189
|
+
assert(not self._hasCreatedDataStore, "Must configure before the first datastore is created")
|
|
190
|
+
assert(type(options) == "table", "Bad options")
|
|
191
|
+
|
|
192
|
+
self._loadRetryOptions = options
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
--[=[
|
|
196
|
+
Sets the autosave interval on every datastore this manager creates. See
|
|
197
|
+
[DataStore.SetAutoSaveTimeSeconds]. Passing nil disables syncing entirely.
|
|
198
|
+
|
|
199
|
+
:::info
|
|
200
|
+
Must be set before the first datastore is created.
|
|
201
|
+
:::
|
|
202
|
+
|
|
203
|
+
@param autoSaveTimeSeconds number?
|
|
204
|
+
]=]
|
|
205
|
+
function PlayerDataStoreManager.SetAutoSaveTimeSeconds(self: PlayerDataStoreManager, autoSaveTimeSeconds: number?): ()
|
|
206
|
+
assert(not self._hasCreatedDataStore, "Must configure before the first datastore is created")
|
|
207
|
+
assert(type(autoSaveTimeSeconds) == "number" or autoSaveTimeSeconds == nil, "Bad autoSaveTimeSeconds")
|
|
208
|
+
|
|
209
|
+
self._autoSaveTimeSeconds = autoSaveTimeSeconds
|
|
210
|
+
self._autoSaveTimeSecondsSet = true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
--[=[
|
|
214
|
+
Sets the post-graceful-close replication delay on every datastore this manager creates. See
|
|
215
|
+
[DataStore.SetSessionMessagingCloseDelaySeconds].
|
|
216
|
+
|
|
217
|
+
:::info
|
|
218
|
+
Must be set before the first datastore is created.
|
|
219
|
+
:::
|
|
220
|
+
|
|
221
|
+
@param seconds number
|
|
222
|
+
]=]
|
|
223
|
+
function PlayerDataStoreManager.SetSessionMessagingCloseDelaySeconds(self: PlayerDataStoreManager, seconds: number): ()
|
|
224
|
+
assert(not self._hasCreatedDataStore, "Must configure before the first datastore is created")
|
|
225
|
+
assert(type(seconds) == "number" and seconds >= 0, "Bad seconds")
|
|
226
|
+
|
|
227
|
+
self._sessionMessagingCloseDelaySeconds = seconds
|
|
228
|
+
end
|
|
229
|
+
|
|
158
230
|
--[=[
|
|
159
231
|
Adds a callback to be called before save on removal
|
|
160
232
|
@param callback function -- May return a promise
|
|
@@ -177,6 +249,93 @@ function PlayerDataStoreManager.RemovePlayerDataStore(
|
|
|
177
249
|
self:_removePlayerDataStore(userId)
|
|
178
250
|
end
|
|
179
251
|
|
|
252
|
+
--[=[
|
|
253
|
+
Gets the datastore for a player as a counted handle, opening a session if none is live.
|
|
254
|
+
|
|
255
|
+
Prefer this over [PlayerDataStoreManager.PromiseDataStore] for anything acting on a player who may
|
|
256
|
+
not be in this server. Opening their store takes the session lock, which kicks them from wherever
|
|
257
|
+
they were and keeps them from rejoining until it is dropped -- and destroying the handle is what
|
|
258
|
+
drops it.
|
|
259
|
+
|
|
260
|
+
Handles are counted, so several systems can hold the same player's store at once and the session
|
|
261
|
+
survives until the last handle is destroyed.
|
|
262
|
+
|
|
263
|
+
:::note
|
|
264
|
+
The join/leave path deliberately does *not* run through handles. Making a player's presence just
|
|
265
|
+
another reference would be tidier, but removal is reached from several directions already -- a
|
|
266
|
+
stolen session, a close request, a failed lock, PlayerRemoving, server shutdown -- and a handle
|
|
267
|
+
leaked on any of them would hold a player's save open instead of closing it, which is worse than
|
|
268
|
+
the asymmetry. So a handle never removes a store belonging to a player who is in this server;
|
|
269
|
+
their own path owns that.
|
|
270
|
+
:::
|
|
271
|
+
|
|
272
|
+
@param playerOrUserId Player | number
|
|
273
|
+
@return Promise<PlayerDataStoreHandle>
|
|
274
|
+
]=]
|
|
275
|
+
function PlayerDataStoreManager.PromiseDataStoreHandle(
|
|
276
|
+
self: PlayerDataStoreManager,
|
|
277
|
+
playerOrUserId: Player | PlayerUserId
|
|
278
|
+
): Promise.Promise<PlayerDataStoreHandle.PlayerDataStoreHandle>
|
|
279
|
+
local userId = self:_toPlayerUserIdOrError(playerOrUserId)
|
|
280
|
+
|
|
281
|
+
-- Counted before the open rather than after, so a second caller arriving while this one is still
|
|
282
|
+
-- loading cannot see a count of zero and release the store out from under it.
|
|
283
|
+
self._handleCounts[userId] = (self._handleCounts[userId] or 0) + 1
|
|
284
|
+
|
|
285
|
+
return self:_promiseDataStoreByUserId(userId):Then(function(dataStore)
|
|
286
|
+
return PlayerDataStoreHandle.new(dataStore, function()
|
|
287
|
+
self:_releaseDataStoreHandle(userId)
|
|
288
|
+
end)
|
|
289
|
+
end, function(err)
|
|
290
|
+
-- The open failed, so there is no handle to be destroyed later. Give the count back.
|
|
291
|
+
self:_releaseDataStoreHandle(userId)
|
|
292
|
+
return Promise.rejected(err)
|
|
293
|
+
end)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
function PlayerDataStoreManager._releaseDataStoreHandle(self: PlayerDataStoreManager, userId: PlayerUserId): ()
|
|
297
|
+
local count = self._handleCounts[userId]
|
|
298
|
+
if not count then
|
|
299
|
+
return
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
count -= 1
|
|
303
|
+
if count > 0 then
|
|
304
|
+
self._handleCounts[userId] = count
|
|
305
|
+
return
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
self._handleCounts[userId] = nil
|
|
309
|
+
|
|
310
|
+
-- A player in this server owns their own session through the join/leave path. Only a store opened
|
|
311
|
+
-- on behalf of someone absent is ours to close.
|
|
312
|
+
if Players:GetPlayerByUserId(userId) then
|
|
313
|
+
return
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
self:_removePlayerDataStore(userId)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
--[=[
|
|
320
|
+
Resolves once any removal in flight for this player has saved and closed their session, and
|
|
321
|
+
immediately when there is nothing being removed.
|
|
322
|
+
|
|
323
|
+
Destroying the last handle for an absent player *starts* the save-and-close; it does not wait for
|
|
324
|
+
it. Tooling that reports back to an operator waits here first, so it says the lock is released
|
|
325
|
+
only once the write that releases it has actually landed.
|
|
326
|
+
|
|
327
|
+
@param playerOrUserId Player | number
|
|
328
|
+
@return Promise<()>
|
|
329
|
+
]=]
|
|
330
|
+
function PlayerDataStoreManager.PromiseSessionClosed(
|
|
331
|
+
self: PlayerDataStoreManager,
|
|
332
|
+
playerOrUserId: Player | PlayerUserId
|
|
333
|
+
): Promise.Promise<()>
|
|
334
|
+
local userId = self:_toPlayerUserIdOrError(playerOrUserId)
|
|
335
|
+
|
|
336
|
+
return self:_promiseWaitForRemoving(userId)
|
|
337
|
+
end
|
|
338
|
+
|
|
180
339
|
--[=[
|
|
181
340
|
Gets the datastore for a player. If it does not exist, it will create one.
|
|
182
341
|
|
|
@@ -279,6 +438,116 @@ function PlayerDataStoreManager:_toPlayerUserIdOrError(playerOrUserId: Player |
|
|
|
279
438
|
) :: PlayerUserId
|
|
280
439
|
end
|
|
281
440
|
|
|
441
|
+
--[=[
|
|
442
|
+
Reads the session lock on a player's key without opening a session on it.
|
|
443
|
+
|
|
444
|
+
This is the read side of the tooling path: it answers "who holds this key, and how stale is that
|
|
445
|
+
claim", whether or not the player is in this server. Resolves nil when the key is unlocked or
|
|
446
|
+
absent. Reads the stored key, so for a player in this server it reflects their last save rather
|
|
447
|
+
than unsaved in-memory state.
|
|
448
|
+
|
|
449
|
+
@param playerOrUserId Player | number
|
|
450
|
+
@return Promise<LockData?>
|
|
451
|
+
]=]
|
|
452
|
+
function PlayerDataStoreManager.PromiseReadSessionLock(
|
|
453
|
+
self: PlayerDataStoreManager,
|
|
454
|
+
playerOrUserId: Player | PlayerUserId
|
|
455
|
+
): Promise.Promise<DataStoreLockUtils.LockData?>
|
|
456
|
+
local userId = self:_toPlayerUserIdOrError(playerOrUserId)
|
|
457
|
+
|
|
458
|
+
return DataStorePromises.getAsync(self._robloxDataStore, self:_getKey(userId)):Then(function(data)
|
|
459
|
+
return DataStoreLockUtils.readLock(data)
|
|
460
|
+
end)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
--[=[
|
|
464
|
+
Clears the session lock on a player's key with a raw write, releasing a claim left behind by a
|
|
465
|
+
server that died without closing its session.
|
|
466
|
+
|
|
467
|
+
:::warning
|
|
468
|
+
This is a soft lock. A loading session steals it anyway once its retry ladder is exhausted (see
|
|
469
|
+
[PlayerDataStoreManager.SetLoadRetryOptions]) -- clearing it early only saves the player that wait.
|
|
470
|
+
:::
|
|
471
|
+
|
|
472
|
+
:::danger
|
|
473
|
+
Permitted against a session this server holds, which desynchronizes that session from the key --
|
|
474
|
+
its next save either re-writes the lock or reads this as a theft and kicks the player. That is a
|
|
475
|
+
debug/stress-test capability, not a normal one.
|
|
476
|
+
:::
|
|
477
|
+
|
|
478
|
+
@param playerOrUserId Player | number
|
|
479
|
+
@return Promise<LockData?> -- the lock that was cleared, or nil if it was already unlocked
|
|
480
|
+
]=]
|
|
481
|
+
function PlayerDataStoreManager.PromiseUnlockSession(
|
|
482
|
+
self: PlayerDataStoreManager,
|
|
483
|
+
playerOrUserId: Player | PlayerUserId
|
|
484
|
+
): Promise.Promise<DataStoreLockUtils.LockData?>
|
|
485
|
+
return self:_promiseWriteRawSessionLock(self:_toPlayerUserIdOrError(playerOrUserId), nil)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
--[=[
|
|
489
|
+
Claims a player's key with a raw write, under a session this server will never answer for. Parks
|
|
490
|
+
the key so an inspection is not racing a live server.
|
|
491
|
+
|
|
492
|
+
:::warning
|
|
493
|
+
This is a soft lock, and holds only for as long as a loading session's retry ladder. It is not a
|
|
494
|
+
way to keep a player out of their data.
|
|
495
|
+
:::
|
|
496
|
+
|
|
497
|
+
:::danger
|
|
498
|
+
Permitted against a session this server holds, with the same desynchronizing effect described on
|
|
499
|
+
[PlayerDataStoreManager.PromiseUnlockSession].
|
|
500
|
+
:::
|
|
501
|
+
|
|
502
|
+
@param playerOrUserId Player | number
|
|
503
|
+
@return Promise<LockData?> -- the lock that was replaced, or nil if it was unlocked
|
|
504
|
+
]=]
|
|
505
|
+
function PlayerDataStoreManager.PromiseLockSession(
|
|
506
|
+
self: PlayerDataStoreManager,
|
|
507
|
+
playerOrUserId: Player | PlayerUserId
|
|
508
|
+
): Promise.Promise<DataStoreLockUtils.LockData?>
|
|
509
|
+
local userId = self:_toPlayerUserIdOrError(playerOrUserId)
|
|
510
|
+
|
|
511
|
+
return self:_promiseWriteRawSessionLock(
|
|
512
|
+
userId,
|
|
513
|
+
DataStoreLockUtils.createLockData({
|
|
514
|
+
SessionId = HttpService:GenerateGUID(false),
|
|
515
|
+
PlaceId = game.PlaceId,
|
|
516
|
+
JobId = game.JobId,
|
|
517
|
+
})
|
|
518
|
+
)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
function PlayerDataStoreManager._promiseWriteRawSessionLock(
|
|
522
|
+
self: PlayerDataStoreManager,
|
|
523
|
+
userId: PlayerUserId,
|
|
524
|
+
lockData: DataStoreLockUtils.LockData?
|
|
525
|
+
): Promise.Promise<DataStoreLockUtils.LockData?>
|
|
526
|
+
-- Deliberately unguarded against a session this server owns. A raw write underneath one
|
|
527
|
+
-- desynchronizes that session from the key: on its next save it either re-writes this lock, or
|
|
528
|
+
-- reads it as a theft and kicks the player. That is precisely the failure the lock/unlock tools
|
|
529
|
+
-- exist to provoke, so stress-testing against a live local session is allowed rather than refused.
|
|
530
|
+
-- Callers reaching for this outside of debug tooling want the live [DataStore] instead.
|
|
531
|
+
local previousLock: DataStoreLockUtils.LockData? = nil
|
|
532
|
+
|
|
533
|
+
return DataStorePromises.updateAsync(self._robloxDataStore, self:_getKey(userId), function(data, datastoreKeyInfo)
|
|
534
|
+
previousLock = DataStoreLockUtils.readLock(data)
|
|
535
|
+
|
|
536
|
+
-- Nothing stored and nothing to clear, so cancel rather than create an empty entry.
|
|
537
|
+
if data == nil and lockData == nil then
|
|
538
|
+
return nil
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
-- UpdateAsync drops both when the transform omits them, so carry them through untouched.
|
|
542
|
+
local userIdList = if datastoreKeyInfo then datastoreKeyInfo:GetUserIds() else { userId }
|
|
543
|
+
local metadata = if datastoreKeyInfo then datastoreKeyInfo:GetMetadata() else nil
|
|
544
|
+
|
|
545
|
+
return DataStoreLockUtils.withLock(data, lockData), userIdList, metadata
|
|
546
|
+
end):Then(function()
|
|
547
|
+
return previousLock
|
|
548
|
+
end)
|
|
549
|
+
end
|
|
550
|
+
|
|
282
551
|
--[=[
|
|
283
552
|
Removes all player data stores, and returns a promise that
|
|
284
553
|
resolves when all pending saves are saved.
|
|
@@ -322,8 +591,21 @@ function PlayerDataStoreManager._createDataStore(
|
|
|
322
591
|
|
|
323
592
|
local maid = Maid.new()
|
|
324
593
|
|
|
594
|
+
self._hasCreatedDataStore = true
|
|
595
|
+
|
|
325
596
|
-- DataStore is cleaned up very carefully in _removePlayerDataStore
|
|
326
597
|
local datastore = DataStore.new(self._robloxDataStore, self:_getKey(userId))
|
|
598
|
+
|
|
599
|
+
if self._loadRetryOptions then
|
|
600
|
+
datastore:SetLoadRetryOptions(self._loadRetryOptions)
|
|
601
|
+
end
|
|
602
|
+
if self._autoSaveTimeSecondsSet then
|
|
603
|
+
datastore:SetAutoSaveTimeSeconds(self._autoSaveTimeSeconds)
|
|
604
|
+
end
|
|
605
|
+
if self._sessionMessagingCloseDelaySeconds then
|
|
606
|
+
datastore:SetSessionMessagingCloseDelaySeconds(self._sessionMessagingCloseDelaySeconds)
|
|
607
|
+
end
|
|
608
|
+
|
|
327
609
|
datastore:SetSessionLockingEnabled(true)
|
|
328
610
|
datastore:SetSessionMessagingEnabled(true, self._serviceBag)
|
|
329
611
|
datastore:SetUserIdList({ userId })
|
|
@@ -322,3 +322,89 @@ describe("PlayerDataStoreManager server shutdown", function()
|
|
|
322
322
|
controller:destroy()
|
|
323
323
|
end)
|
|
324
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()
|
|
@@ -211,3 +211,68 @@ describe("PlayerDataStoreService server shutdown", function()
|
|
|
211
211
|
controller:destroy()
|
|
212
212
|
end)
|
|
213
213
|
end)
|
|
214
|
+
|
|
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)
|
|
247
|
+
|
|
248
|
+
local promise = controller.service:PromiseDataStore(1)
|
|
249
|
+
if not PromiseTestUtils.awaitSettled(promise, 10) then
|
|
250
|
+
expect("hung").toEqual("settled")
|
|
251
|
+
controller:destroy()
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
local _ok, dataStore = promise:Yield()
|
|
255
|
+
|
|
256
|
+
expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(14)
|
|
257
|
+
expect((dataStore:GetLoadRetryOptions())).toEqual(retryOptions)
|
|
258
|
+
expect((dataStore:GetSessionMessagingCloseDelaySeconds())).toEqual(0.5)
|
|
259
|
+
|
|
260
|
+
controller:destroy()
|
|
261
|
+
end)
|
|
262
|
+
|
|
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()
|
|
277
|
+
end)
|
|
278
|
+
end)
|