@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.
@@ -0,0 +1,228 @@
1
+ --!strict
2
+ --[[
3
+ The raw session-lock path: reading and writing the lock on a key without opening a session on it.
4
+ Usually the target is a player who is not in this server, but the write is deliberately permitted
5
+ against a live local session too -- see the last describe block.
6
+
7
+ @class PlayerDataStoreManager.SessionLockTools.spec.lua
8
+ ]]
9
+ local require = require(script.Parent.loader).load(script)
10
+
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
+ local FOREIGN_SESSION = {
20
+ SessionId = "foreign-session",
21
+ PlaceId = 123,
22
+ JobId = "foreign-job",
23
+ }
24
+
25
+ local function seedForeignLock(mock, key: string, profile: { [string]: any }?)
26
+ local data: { [string]: any } = profile or {}
27
+ data.lock = {
28
+ LastUpdateTime = os.time(),
29
+ ActiveSession = FOREIGN_SESSION,
30
+ }
31
+ mock:SetRaw(key, data)
32
+ end
33
+
34
+ -- Returns the resolved value, or fails the spec and returns nil.
35
+ local function awaitValue(promise, label: string): any
36
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
37
+ expect(`{label} hung`).toEqual(`{label} settled`)
38
+ return nil
39
+ end
40
+
41
+ local ok, value = promise:Yield()
42
+ expect(ok).toEqual(true)
43
+ return value
44
+ end
45
+
46
+ describe("PlayerDataStoreManager.PromiseReadSessionLock", function()
47
+ it("resolves nil for a key that was never written", function()
48
+ local controller = DataStoreTestUtils.setupDataStoreManager()
49
+
50
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
51
+ expect(lock).toBeNil()
52
+
53
+ controller:destroy()
54
+ end)
55
+
56
+ it("reports the session holding a foreign lock", function()
57
+ local controller = DataStoreTestUtils.setupDataStoreManager()
58
+
59
+ seedForeignLock(controller.mock, "user_1", { coins = 5 })
60
+
61
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
62
+ expect(lock).never.toBeNil()
63
+ expect(lock.ActiveSession).toEqual(FOREIGN_SESSION)
64
+
65
+ controller:destroy()
66
+ end)
67
+
68
+ it("resolves nil for a stored profile with no lock", function()
69
+ local controller = DataStoreTestUtils.setupDataStoreManager()
70
+
71
+ controller.mock:SetRaw("user_1", { coins = 5 })
72
+
73
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
74
+ expect(lock).toBeNil()
75
+
76
+ controller:destroy()
77
+ end)
78
+ end)
79
+
80
+ describe("PlayerDataStoreManager.PromiseUnlockSession", function()
81
+ it("clears a foreign lock and reports what it cleared", function()
82
+ local controller = DataStoreTestUtils.setupDataStoreManager()
83
+
84
+ seedForeignLock(controller.mock, "user_1", { coins = 5 })
85
+
86
+ local previous = awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
87
+ expect(previous).never.toBeNil()
88
+ expect(previous.ActiveSession).toEqual(FOREIGN_SESSION)
89
+
90
+ expect(controller.mock:GetRaw("user_1").lock).toBeNil()
91
+
92
+ controller:destroy()
93
+ end)
94
+
95
+ it("leaves the rest of the profile untouched", function()
96
+ local controller = DataStoreTestUtils.setupDataStoreManager()
97
+
98
+ seedForeignLock(controller.mock, "user_1", { coins = 5, level = 3 })
99
+
100
+ awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
101
+
102
+ local raw = controller.mock:GetRaw("user_1")
103
+ expect(raw.coins).toEqual(5)
104
+ expect(raw.level).toEqual(3)
105
+
106
+ controller:destroy()
107
+ end)
108
+
109
+ it("resolves nil on an already-unlocked key", function()
110
+ local controller = DataStoreTestUtils.setupDataStoreManager()
111
+
112
+ controller.mock:SetRaw("user_1", { coins = 5 })
113
+
114
+ local previous = awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
115
+ expect(previous).toBeNil()
116
+
117
+ controller:destroy()
118
+ end)
119
+
120
+ it("does not create an entry for a key that was never written", function()
121
+ local controller = DataStoreTestUtils.setupDataStoreManager()
122
+
123
+ awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
124
+
125
+ expect(controller.mock:GetRaw("user_1")).toBeNil()
126
+
127
+ controller:destroy()
128
+ end)
129
+ end)
130
+
131
+ describe("PlayerDataStoreManager.PromiseLockSession", function()
132
+ it("claims an unlocked key", function()
133
+ local controller = DataStoreTestUtils.setupDataStoreManager()
134
+
135
+ controller.mock:SetRaw("user_1", { coins = 5 })
136
+
137
+ local previous = awaitValue(controller.manager:PromiseLockSession(1), "lock")
138
+ expect(previous).toBeNil()
139
+
140
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
141
+ expect(lock).never.toBeNil()
142
+ expect(lock.ActiveSession.PlaceId).toEqual(game.PlaceId)
143
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
144
+
145
+ controller:destroy()
146
+ end)
147
+
148
+ it("reports the lock it replaced", function()
149
+ local controller = DataStoreTestUtils.setupDataStoreManager()
150
+
151
+ seedForeignLock(controller.mock, "user_1", { coins = 5 })
152
+
153
+ local previous = awaitValue(controller.manager:PromiseLockSession(1), "lock")
154
+ expect(previous).never.toBeNil()
155
+ expect(previous.ActiveSession).toEqual(FOREIGN_SESSION)
156
+
157
+ controller:destroy()
158
+ end)
159
+
160
+ it("claims a key that was never written", function()
161
+ local controller = DataStoreTestUtils.setupDataStoreManager()
162
+
163
+ awaitValue(controller.manager:PromiseLockSession(1), "lock")
164
+
165
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
166
+ expect(lock).never.toBeNil()
167
+
168
+ controller:destroy()
169
+ end)
170
+ end)
171
+
172
+ describe("PlayerDataStoreManager raw lock writes against a live session", function()
173
+ -- Allowed on purpose: pulling the key out from under a live local session is the failure the
174
+ -- lock/unlock debug tools exist to provoke. See PlayerDataStoreManager._promiseWriteRawSessionLock.
175
+ it("writes the lock while this server holds a session for that user", function()
176
+ local controller = DataStoreTestUtils.setupDataStoreManager()
177
+
178
+ if not controller.storeAndAwaitLock() then
179
+ expect("lock was never acquired").toEqual("lock was acquired")
180
+ controller:destroy()
181
+ return
182
+ end
183
+
184
+ local previous = awaitValue(controller.manager:PromiseUnlockSession(1), "unlock")
185
+ expect(previous).never.toBeNil()
186
+ expect(previous.ActiveSession.JobId).toEqual(game.JobId)
187
+ expect(controller.mock:GetRaw("user_1").lock).toBeNil()
188
+
189
+ controller:destroy()
190
+ end)
191
+
192
+ it("still serves a read while this server holds a session", function()
193
+ local controller = DataStoreTestUtils.setupDataStoreManager()
194
+
195
+ if not controller.storeAndAwaitLock() then
196
+ expect("lock was never acquired").toEqual("lock was acquired")
197
+ controller:destroy()
198
+ return
199
+ end
200
+
201
+ local lock = awaitValue(controller.manager:PromiseReadSessionLock(1), "read")
202
+ expect(lock).never.toBeNil()
203
+ expect(lock.ActiveSession.JobId).toEqual(game.JobId)
204
+
205
+ controller:destroy()
206
+ end)
207
+
208
+ it("writes once the session has been removed", function()
209
+ local controller = DataStoreTestUtils.setupDataStoreManager()
210
+
211
+ if not controller.storeAndAwaitLock() then
212
+ expect("lock was never acquired").toEqual("lock was acquired")
213
+ controller:destroy()
214
+ return
215
+ end
216
+
217
+ if not PromiseTestUtils.awaitSettled(controller.promiseShutdown({ 1 }), 10) then
218
+ expect("shutdown hung").toEqual("shutdown settled")
219
+ controller:destroy()
220
+ return
221
+ end
222
+
223
+ awaitValue(controller.manager:PromiseLockSession(1), "lock")
224
+ expect(controller.mock:GetRaw("user_1").lock).never.toBeNil()
225
+
226
+ controller:destroy()
227
+ end)
228
+ end)
@@ -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
@@ -130,12 +144,6 @@ function PlayerDataStoreManager.new(
130
144
  self:_removePlayerDataStore(player.UserId)
131
145
  end))
132
146
 
133
- -- On teardown (e.g. a hot-reloaded ServiceBag, or unit tests) flush and destroy any datastores we
134
- -- still own. See _flushAndDestroyAll.
135
- self._maid:GiveTask(function()
136
- self:_flushAndDestroyAll()
137
- end)
138
-
139
147
  if skipBindingToClose ~= true then
140
148
  -- Route through BindToCloseService so the callback is unregistered on :Destroy()
141
149
  -- (unlike a raw game:BindToClose, which can never be unbound and would leak on hot reload).
@@ -152,33 +160,6 @@ function PlayerDataStoreManager.new(
152
160
  return self
153
161
  end
154
162
 
155
- --[=[
156
- Flushes and tears down every datastore we still own. Runs on manager teardown (a hot-reloaded
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
- ]=]
163
- function PlayerDataStoreManager._flushAndDestroyAll(self: PlayerDataStoreManager): ()
164
- for userId, datastore in self._datastores do
165
- -- Cast past the DataStore intersection type: the solver otherwise blows up ("code too complex")
166
- -- resolving :SaveAndCloseSession()/:Destroy() through it.
167
- local store = datastore :: any
168
- -- A failed load makes the save reject unconditionally; skip it so teardown does not
169
- -- manufacture a guaranteed rejection.
170
- if not store:DidLoadFail() then
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()
176
- end
177
- store:Destroy()
178
- self._datastores[userId] = nil
179
- end
180
- end
181
-
182
163
  --[=[
183
164
  For if you want to disable saving in studio for faster close time!
184
165
  ]=]
@@ -188,6 +169,64 @@ function PlayerDataStoreManager.DisableSaveOnCloseStudio(self: PlayerDataStoreMa
188
169
  self._disableSavingInStudio = true
189
170
  end
190
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
+
191
230
  --[=[
192
231
  Adds a callback to be called before save on removal
193
232
  @param callback function -- May return a promise
@@ -210,6 +249,93 @@ function PlayerDataStoreManager.RemovePlayerDataStore(
210
249
  self:_removePlayerDataStore(userId)
211
250
  end
212
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
+
213
339
  --[=[
214
340
  Gets the datastore for a player. If it does not exist, it will create one.
215
341
 
@@ -312,16 +438,149 @@ function PlayerDataStoreManager:_toPlayerUserIdOrError(playerOrUserId: Player |
312
438
  ) :: PlayerUserId
313
439
  end
314
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
+
315
551
  --[=[
316
552
  Removes all player data stores, and returns a promise that
317
553
  resolves when all pending saves are saved.
554
+
555
+ On a closing server Roblox fires PlayerRemoving for every player, so a removal is usually already
556
+ in flight by the time this runs. Those removals do the real save-and-close themselves; this waits
557
+ for them rather than starting anything of its own.
558
+
318
559
  @return Promise
319
560
  ]=]
320
561
  function PlayerDataStoreManager.PromiseAllSaves(self: PlayerDataStoreManager): Promise.Promise<()>
321
562
  for userId, _ in self._datastores do
322
563
  self:_removePlayerDataStore(userId)
323
564
  end
324
- return self._maid:GivePromise(PromiseUtils.all(self._pendingSaves:GetAll()))
565
+
566
+ local promises: { Promise.Promise<any> } = {}
567
+
568
+ -- Wait on the removals still in flight, not just on _pendingSaves. A removal only reaches its write
569
+ -- after the removing callbacks and then DataStore's own saving callbacks resolve, and Saving fires at
570
+ -- the very end of that sync -- so _pendingSaves can be empty while a PlayerRemoving triggered moments
571
+ -- earlier is still working toward its UpdateAsync. Resolving on that empty set lets BindToClose
572
+ -- return and the server die mid-write, leaving the session locked with nobody able to release it: a
573
+ -- lock belongs to the server that holds it, so the next server can only recover by grinding the
574
+ -- graceful-close handshake against a dead JobId and then stealing it.
575
+ for _, removalPromise in self._removingPromises do
576
+ table.insert(promises, removalPromise :: any)
577
+ end
578
+
579
+ for _, savePromise in self._pendingSaves:GetAll() do
580
+ table.insert(promises, savePromise :: any)
581
+ end
582
+
583
+ return self._maid:GivePromise(PromiseUtils.all(promises))
325
584
  end
326
585
 
327
586
  function PlayerDataStoreManager._createDataStore(
@@ -332,8 +591,21 @@ function PlayerDataStoreManager._createDataStore(
332
591
 
333
592
  local maid = Maid.new()
334
593
 
594
+ self._hasCreatedDataStore = true
595
+
335
596
  -- DataStore is cleaned up very carefully in _removePlayerDataStore
336
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
+
337
609
  datastore:SetSessionLockingEnabled(true)
338
610
  datastore:SetSessionMessagingEnabled(true, self._serviceBag)
339
611
  datastore:SetUserIdList({ userId })