@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
|
@@ -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
|
-
|
|
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 })
|