@quenty/datastore 13.22.1 → 13.23.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 +11 -0
- package/package.json +8 -7
- package/src/Server/DataStore.lua +182 -30
- package/src/Server/PlayerDataStoreManager.lua +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [13.23.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.22.1...@quenty/datastore@13.23.0) (2025-08-29)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* Add datastore session locking system ([#580](https://github.com/Quenty/NevermoreEngine/issues/580)) ([06ecef6](https://github.com/Quenty/NevermoreEngine/commit/06ecef60eab81ac0b44d9e408313fb4cc4d59488))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
## [13.22.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/datastore@13.22.0...@quenty/datastore@13.22.1) (2025-08-12)
|
|
7
18
|
|
|
8
19
|
**Note:** Version bump only for package @quenty/datastore
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/datastore",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.23.0",
|
|
4
4
|
"description": "Quenty's Datastore implementation for Roblox",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -27,21 +27,22 @@
|
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@quenty/baseobject": "^10.9.0",
|
|
30
|
-
"@quenty/bindtocloseservice": "^8.
|
|
30
|
+
"@quenty/bindtocloseservice": "^8.19.0",
|
|
31
31
|
"@quenty/loader": "^10.9.0",
|
|
32
32
|
"@quenty/maid": "^3.5.0",
|
|
33
33
|
"@quenty/math": "^2.7.3",
|
|
34
|
-
"@quenty/pagesutils": "^5.
|
|
35
|
-
"@quenty/promise": "^10.
|
|
36
|
-
"@quenty/
|
|
34
|
+
"@quenty/pagesutils": "^5.13.0",
|
|
35
|
+
"@quenty/promise": "^10.12.0",
|
|
36
|
+
"@quenty/promisemaid": "^5.12.0",
|
|
37
|
+
"@quenty/rx": "^13.19.0",
|
|
37
38
|
"@quenty/servicebag": "^11.13.1",
|
|
38
39
|
"@quenty/signal": "^7.11.1",
|
|
39
40
|
"@quenty/symbol": "^3.5.0",
|
|
40
41
|
"@quenty/table": "^3.8.0",
|
|
41
|
-
"@quenty/valueobject": "^13.
|
|
42
|
+
"@quenty/valueobject": "^13.19.0"
|
|
42
43
|
},
|
|
43
44
|
"publishConfig": {
|
|
44
45
|
"access": "public"
|
|
45
46
|
},
|
|
46
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "5f62fff9bdc4089be64a8380e8deafa77647c85a"
|
|
47
48
|
}
|
package/src/Server/DataStore.lua
CHANGED
|
@@ -66,12 +66,16 @@
|
|
|
66
66
|
|
|
67
67
|
local require = require(script.Parent.loader).load(script)
|
|
68
68
|
|
|
69
|
+
local RunService = game:GetService("RunService")
|
|
70
|
+
|
|
69
71
|
local DataStoreDeleteToken = require("DataStoreDeleteToken")
|
|
70
72
|
local DataStorePromises = require("DataStorePromises")
|
|
71
73
|
local DataStoreStage = require("DataStoreStage")
|
|
72
74
|
local Maid = require("Maid")
|
|
73
75
|
local Math = require("Math")
|
|
74
76
|
local Promise = require("Promise")
|
|
77
|
+
local PromiseMaidUtils = require("PromiseMaidUtils")
|
|
78
|
+
local PromiseRetryUtils = require("PromiseRetryUtils")
|
|
75
79
|
local Rx = require("Rx")
|
|
76
80
|
local Signal = require("Signal")
|
|
77
81
|
local Symbol = require("Symbol")
|
|
@@ -81,6 +85,7 @@ local DEFAULT_DEBUG_WRITING = false
|
|
|
81
85
|
|
|
82
86
|
local DEFAULT_AUTO_SAVE_TIME_SECONDS = 60 * 5
|
|
83
87
|
local DEFAULT_JITTER_PROPORTION = 0.1 -- Randomly assign jitter so if a ton of players join at once we don't hit the datastore at once
|
|
88
|
+
local UNLOCK_BY_DEFAULT_TIME_MULTIPLIER = 2.1
|
|
84
89
|
|
|
85
90
|
local DataStore = setmetatable({}, DataStoreStage)
|
|
86
91
|
DataStore.ClassName = "DataStore"
|
|
@@ -92,11 +97,13 @@ export type DataStore = typeof(setmetatable(
|
|
|
92
97
|
_userIdList: { number }?,
|
|
93
98
|
_robloxDataStore: DataStorePromises.RobloxDataStore,
|
|
94
99
|
_debugWriting: boolean,
|
|
100
|
+
_sessionLockingEnabled: boolean,
|
|
95
101
|
_autoSaveTimeSeconds: ValueObject.ValueObject<number?>,
|
|
96
102
|
_jitterProportion: ValueObject.ValueObject<number>,
|
|
97
103
|
_syncOnSave: ValueObject.ValueObject<boolean>,
|
|
98
104
|
_loadedOk: ValueObject.ValueObject<boolean>,
|
|
99
105
|
_firstLoadPromise: Promise.Promise<()>,
|
|
106
|
+
_promiseSessionLockingFailed: Promise.Promise<()>,
|
|
100
107
|
Saving: Signal.Signal<Promise.Promise<()>>,
|
|
101
108
|
},
|
|
102
109
|
{} :: typeof({ __index = DataStore })
|
|
@@ -124,6 +131,7 @@ function DataStore.new(robloxDataStore: DataStorePromises.RobloxDataStore, key:
|
|
|
124
131
|
self._jitterProportion = self._maid:Add(ValueObject.new(DEFAULT_JITTER_PROPORTION, "number"))
|
|
125
132
|
self._syncOnSave = self._maid:Add(ValueObject.new(false, "boolean"))
|
|
126
133
|
self._loadedOk = self._maid:Add(ValueObject.new(false, "boolean"))
|
|
134
|
+
self._promiseSessionLockingFailed = self._maid:Add(Promise.new())
|
|
127
135
|
|
|
128
136
|
self._userIdList = nil
|
|
129
137
|
|
|
@@ -143,6 +151,27 @@ function DataStore.new(robloxDataStore: DataStorePromises.RobloxDataStore, key:
|
|
|
143
151
|
return self
|
|
144
152
|
end
|
|
145
153
|
|
|
154
|
+
--[=[
|
|
155
|
+
Sets session locking enabled
|
|
156
|
+
|
|
157
|
+
@param sessionLockingEnabled boolean
|
|
158
|
+
]=]
|
|
159
|
+
function DataStore.SetSessionLockingEnabled(self: DataStore, sessionLockingEnabled: boolean)
|
|
160
|
+
assert(not self._firstLoadPromise, "Must set session locking before datastore is loaded")
|
|
161
|
+
|
|
162
|
+
self._sessionLockingEnabled = sessionLockingEnabled
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
--[=[
|
|
166
|
+
Returns a promise that rejects on datastore cleanup, and resolves only when the session locking
|
|
167
|
+
code completely fails
|
|
168
|
+
|
|
169
|
+
@return Promise<>
|
|
170
|
+
]=]
|
|
171
|
+
function DataStore.PromiseSessionLockingFailed(self: DataStore)
|
|
172
|
+
return self._promiseSessionLockingFailed
|
|
173
|
+
end
|
|
174
|
+
|
|
146
175
|
--[=[
|
|
147
176
|
Set to true to debug writing this data store
|
|
148
177
|
|
|
@@ -222,6 +251,16 @@ function DataStore.Save(self: DataStore): Promise.Promise<()>
|
|
|
222
251
|
return self:_syncData(false)
|
|
223
252
|
end
|
|
224
253
|
|
|
254
|
+
--[=[
|
|
255
|
+
Saves all stored data.
|
|
256
|
+
@return Promise
|
|
257
|
+
]=]
|
|
258
|
+
function DataStore.SaveAndCloseSession(self: DataStore): Promise.Promise<()>
|
|
259
|
+
assert(self._sessionLockingEnabled, "Cannot invoke unless session locking is enabled")
|
|
260
|
+
|
|
261
|
+
return self:_syncData(false, true)
|
|
262
|
+
end
|
|
263
|
+
|
|
225
264
|
--[=[
|
|
226
265
|
Same as saving the data but it also loads fresh data from the datastore, which may consume
|
|
227
266
|
additional data-store query calls.
|
|
@@ -323,7 +362,7 @@ function DataStore._setupAutoSaving(self: DataStore)
|
|
|
323
362
|
end))
|
|
324
363
|
end
|
|
325
364
|
|
|
326
|
-
function DataStore._syncData(self: DataStore, doMergeNewData: boolean)
|
|
365
|
+
function DataStore._syncData(self: DataStore, doMergeNewData: boolean, doCloseSession: boolean?)
|
|
327
366
|
if self:DidLoadFail() then
|
|
328
367
|
warn("[DataStore] - Not syncing, failed to load")
|
|
329
368
|
return Promise.rejected("Load not successful, not syncing")
|
|
@@ -335,7 +374,7 @@ function DataStore._syncData(self: DataStore, doMergeNewData: boolean)
|
|
|
335
374
|
return self._maid:GivePromise(self:PromiseInvokeSavingCallbacks())
|
|
336
375
|
end)
|
|
337
376
|
:Then(function(): Promise.Promise<()>?
|
|
338
|
-
if not self:HasWritableData() then
|
|
377
|
+
if not self:HasWritableData() and not doCloseSession then
|
|
339
378
|
if doMergeNewData then
|
|
340
379
|
-- Reads are cheaper than update async calls
|
|
341
380
|
return self:_promiseGetAsyncNoCache()
|
|
@@ -348,12 +387,17 @@ function DataStore._syncData(self: DataStore, doMergeNewData: boolean)
|
|
|
348
387
|
|
|
349
388
|
return nil
|
|
350
389
|
else
|
|
351
|
-
return self:_doDataSync(self:GetNewWriter(), doMergeNewData)
|
|
390
|
+
return self:_doDataSync(self:GetNewWriter(), doMergeNewData, doCloseSession)
|
|
352
391
|
end
|
|
353
392
|
end)
|
|
354
393
|
end
|
|
355
394
|
|
|
356
|
-
function DataStore._doDataSync(
|
|
395
|
+
function DataStore._doDataSync(
|
|
396
|
+
self: DataStore,
|
|
397
|
+
writer,
|
|
398
|
+
doMergeNewData: boolean,
|
|
399
|
+
doCloseSession: boolean?
|
|
400
|
+
): Promise.Promise<()>
|
|
357
401
|
assert(type(doMergeNewData) == "boolean", "Bad doMergeNewData")
|
|
358
402
|
|
|
359
403
|
-- Cache user id list
|
|
@@ -396,6 +440,11 @@ function DataStore._doDataSync(self: DataStore, writer, doMergeNewData: boolean)
|
|
|
396
440
|
promise:Resolve(
|
|
397
441
|
maid:GivePromise(
|
|
398
442
|
DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(original, datastoreKeyInfo)
|
|
443
|
+
if self._sessionLockingEnabled then
|
|
444
|
+
original = table.clone(original)
|
|
445
|
+
original.lock = nil
|
|
446
|
+
end
|
|
447
|
+
|
|
399
448
|
if promise:IsRejected() then
|
|
400
449
|
-- Cancel if we have another request
|
|
401
450
|
return nil
|
|
@@ -434,6 +483,17 @@ function DataStore._doDataSync(self: DataStore, writer, doMergeNewData: boolean)
|
|
|
434
483
|
metadata = datastoreKeyInfo:GetMetadata()
|
|
435
484
|
end
|
|
436
485
|
|
|
486
|
+
if doCloseSession then
|
|
487
|
+
result = table.clone(result)
|
|
488
|
+
result.lock = nil
|
|
489
|
+
elseif self._sessionLockingEnabled then
|
|
490
|
+
-- Maintain the lock with the latest time
|
|
491
|
+
result = table.clone(result)
|
|
492
|
+
if result.lock then
|
|
493
|
+
result.lock = os.time()
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
437
497
|
return result, userIdList, metadata
|
|
438
498
|
end)
|
|
439
499
|
)
|
|
@@ -455,36 +515,128 @@ function DataStore._doDataSync(self: DataStore, writer, doMergeNewData: boolean)
|
|
|
455
515
|
end
|
|
456
516
|
|
|
457
517
|
function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
518
|
+
local promise
|
|
519
|
+
if self._sessionLockingEnabled then
|
|
520
|
+
local function promiseLoadUnlockedProfile()
|
|
521
|
+
local loadPromise = Promise.new()
|
|
522
|
+
self._maid[loadPromise] = loadPromise
|
|
523
|
+
|
|
524
|
+
PromiseMaidUtils.whilePromise(loadPromise, function(maid)
|
|
525
|
+
maid:GivePromise(
|
|
526
|
+
DataStorePromises.updateAsync(self._robloxDataStore, self._key, function(data, datastoreKeyInfo)
|
|
527
|
+
local userIdList = self._userIdList
|
|
528
|
+
if datastoreKeyInfo then
|
|
529
|
+
userIdList = datastoreKeyInfo:GetUserIds()
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
local metadata = nil
|
|
533
|
+
if datastoreKeyInfo then
|
|
534
|
+
metadata = datastoreKeyInfo:GetMetadata()
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
if self._debugWriting then
|
|
538
|
+
print(string.format("DataStorePromises.updateAsync(%q) -> Got ", tostring(self._key)), data)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
if data.lock then
|
|
542
|
+
local isInvalidLock = false
|
|
543
|
+
if type(data.lock) == "number" then
|
|
544
|
+
local timeElapsed = os.time() - data.lock
|
|
545
|
+
local autoSaveSeconds = self._autoSaveTimeSeconds.Value
|
|
546
|
+
if
|
|
547
|
+
autoSaveSeconds
|
|
548
|
+
and timeElapsed > (autoSaveSeconds * UNLOCK_BY_DEFAULT_TIME_MULTIPLIER)
|
|
549
|
+
then
|
|
550
|
+
isInvalidLock = true
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
-- Allow data locked to load in studio because otherwise testing gets really messy
|
|
555
|
+
if RunService:IsStudio() then
|
|
556
|
+
isInvalidLock = true
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
if not isInvalidLock then
|
|
560
|
+
loadPromise:Reject(string.format("Profile is locked (%s)", tostring(data.lock)))
|
|
561
|
+
|
|
562
|
+
-- Cancel write to avoid maintaining lock
|
|
563
|
+
return nil
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
-- Be sure to cleanup stuff
|
|
567
|
+
data.lock = nil :: number?
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
-- TODO: Retry
|
|
571
|
+
loadPromise:Resolve(data)
|
|
572
|
+
|
|
573
|
+
data.lock = os.time()
|
|
574
|
+
|
|
575
|
+
return data, userIdList, metadata
|
|
576
|
+
end)
|
|
466
577
|
)
|
|
467
|
-
)
|
|
468
|
-
return Promise.rejected(err)
|
|
469
|
-
end)
|
|
470
|
-
:Then(function(data)
|
|
471
|
-
local writer = self:GetNewWriter()
|
|
472
|
-
local diffSnapshot = writer:ComputeDiffSnapshot(data)
|
|
578
|
+
end)
|
|
473
579
|
|
|
474
|
-
|
|
580
|
+
loadPromise:Finally(function()
|
|
581
|
+
self._maid[loadPromise] = nil
|
|
582
|
+
end)
|
|
475
583
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
584
|
+
return loadPromise
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
promise = self._maid
|
|
588
|
+
:Add(PromiseRetryUtils.retry(promiseLoadUnlockedProfile, {
|
|
589
|
+
-- https://exponentialbackoffcalculator.com/
|
|
590
|
+
-- ~10 minutes
|
|
591
|
+
exponential = 1.5,
|
|
592
|
+
initialWaitTime = 1,
|
|
593
|
+
maxAttempts = 15,
|
|
594
|
+
printWarning = true,
|
|
595
|
+
}))
|
|
596
|
+
:Catch(function(err)
|
|
597
|
+
warn(
|
|
598
|
+
string.format(
|
|
599
|
+
"DataStorePromises.updateAsync(%q) -> warning - %s",
|
|
600
|
+
tostring(self._key),
|
|
601
|
+
tostring(err or "empty error")
|
|
602
|
+
)
|
|
484
603
|
)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
604
|
+
self._promiseSessionLockingFailed:Resolve()
|
|
605
|
+
return Promise.rejected(err)
|
|
606
|
+
end)
|
|
607
|
+
else
|
|
608
|
+
promise = self._maid
|
|
609
|
+
:GivePromise(DataStorePromises.getAsync(self._robloxDataStore, self._key))
|
|
610
|
+
:Catch(function(err)
|
|
611
|
+
warn(
|
|
612
|
+
string.format(
|
|
613
|
+
"DataStorePromises.getAsync(%q) -> warning - %s",
|
|
614
|
+
tostring(self._key),
|
|
615
|
+
tostring(err or "empty error")
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
return Promise.rejected(err)
|
|
619
|
+
end)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
return promise:Then(function(data)
|
|
623
|
+
local writer = self:GetNewWriter()
|
|
624
|
+
local diffSnapshot = writer:ComputeDiffSnapshot(data)
|
|
625
|
+
|
|
626
|
+
self:MergeDiffSnapshot(diffSnapshot)
|
|
627
|
+
|
|
628
|
+
if self._debugWriting then
|
|
629
|
+
print(
|
|
630
|
+
string.format("DataStorePromises.getAsync(%q) -> Got ", tostring(self._key)),
|
|
631
|
+
data,
|
|
632
|
+
"with diff snapshot",
|
|
633
|
+
diffSnapshot,
|
|
634
|
+
"to view",
|
|
635
|
+
self._viewSnapshot
|
|
636
|
+
)
|
|
637
|
+
-- print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data)
|
|
638
|
+
end
|
|
639
|
+
end)
|
|
488
640
|
end
|
|
489
641
|
|
|
490
642
|
return DataStore
|
|
@@ -131,7 +131,7 @@ end
|
|
|
131
131
|
For if you want to disable saving in studio for faster close time!
|
|
132
132
|
]=]
|
|
133
133
|
function PlayerDataStoreManager.DisableSaveOnCloseStudio(self: PlayerDataStoreManager): ()
|
|
134
|
-
assert(RunService:IsStudio())
|
|
134
|
+
assert(RunService:IsStudio(), "Must invoke in studio")
|
|
135
135
|
|
|
136
136
|
self._disableSavingInStudio = true
|
|
137
137
|
end
|
|
@@ -196,8 +196,13 @@ function PlayerDataStoreManager._createDataStore(self: PlayerDataStoreManager, p
|
|
|
196
196
|
assert(not self._datastores[player], "Bad player")
|
|
197
197
|
|
|
198
198
|
local datastore = DataStore.new(self._robloxDataStore, self:_getKey(player))
|
|
199
|
+
datastore:SetSessionLockingEnabled(true)
|
|
199
200
|
datastore:SetUserIdList({ player.UserId })
|
|
200
201
|
|
|
202
|
+
datastore:PromiseSessionLockingFailed():Then(function()
|
|
203
|
+
player:Kick("DataStore session lock failed to load. Please message developers.")
|
|
204
|
+
end)
|
|
205
|
+
|
|
201
206
|
self._maid._savingConns[player] = datastore.Saving:Connect(function(promise)
|
|
202
207
|
self._pendingSaves:Add(promise)
|
|
203
208
|
end)
|
|
@@ -228,7 +233,7 @@ function PlayerDataStoreManager._removePlayerDataStore(self: PlayerDataStoreMana
|
|
|
228
233
|
|
|
229
234
|
PromiseUtils.all(removingPromises)
|
|
230
235
|
:Then(function()
|
|
231
|
-
return datastore:
|
|
236
|
+
return datastore:SaveAndCloseSession()
|
|
232
237
|
end)
|
|
233
238
|
:Finally(function()
|
|
234
239
|
datastore:Destroy()
|