@rbxts/dataforge 0.1.0 → 0.2.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/index.d.ts +218 -26
- package/package.json +1 -1
- package/src/errors.luau +195 -0
- package/src/hooks/memory.luau +1 -12
- package/src/hooks/roblox.luau +10 -1
- package/src/init.luau +6 -2
- package/src/mutex.luau +5 -5
- package/src/profile/common.luau +41 -8
- package/src/profile/lockless.luau +102 -50
- package/src/profile/peek.luau +22 -16
- package/src/profile/profile.luau +156 -95
- package/src/result.luau +61 -0
- package/src/store.luau +89 -41
- package/src/tx/handle.luau +82 -59
- package/src/tx/operation.luau +6 -3
- package/src/types.luau +187 -16
- package/src/utils.luau +37 -3
package/src/profile/profile.luau
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
local errors = require "../errors"
|
|
1
2
|
local mutex = require "../mutex"
|
|
3
|
+
local result = require "../result"
|
|
2
4
|
local types = require "../types"
|
|
3
5
|
local utils = require "../utils"
|
|
4
6
|
local common = require "./common"
|
|
@@ -23,11 +25,24 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
|
23
25
|
local scheduler = config._scheduler
|
|
24
26
|
|
|
25
27
|
local deadline = scheduler.now() + config.load_timeout
|
|
28
|
+
-- the last lock seen on the record, reported when the load times out
|
|
29
|
+
local last_lock: types.Lock? = nil
|
|
30
|
+
-- set by the transform: the record lists more migrations than this server
|
|
31
|
+
-- declares, so the lock must not be taken (the write is cancelled)
|
|
32
|
+
local outdated_migrations: { string }? = nil
|
|
26
33
|
|
|
27
34
|
while true do
|
|
28
35
|
local now = scheduler.now()
|
|
29
|
-
local lock_applied = utils.
|
|
30
|
-
|
|
36
|
+
local lock_applied = utils.update_async(
|
|
37
|
+
config,
|
|
38
|
+
datastore,
|
|
39
|
+
key,
|
|
40
|
+
function(old: types.DataRecord?): types.DataRecord?
|
|
41
|
+
outdated_migrations = nil
|
|
42
|
+
if common.is_outdated(old, config) then
|
|
43
|
+
outdated_migrations = (old :: types.DataRecord).migrations
|
|
44
|
+
return nil
|
|
45
|
+
end
|
|
31
46
|
if common.is_locked_for(old :: types.DataRecord, job_id, now) then
|
|
32
47
|
return nil
|
|
33
48
|
end
|
|
@@ -42,22 +57,26 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
|
42
57
|
expires = now + config.lock_ttl,
|
|
43
58
|
},
|
|
44
59
|
}
|
|
45
|
-
end,
|
|
46
|
-
|
|
60
|
+
end,
|
|
61
|
+
user_ids
|
|
62
|
+
) :: types.DataRecord
|
|
47
63
|
|
|
64
|
+
if outdated_migrations ~= nil then
|
|
65
|
+
error(errors.outdated(key, outdated_migrations, common.all_migration_names(config.migrations)), 0)
|
|
66
|
+
end
|
|
48
67
|
if common.is_lock_owned(lock_applied, job_id) then
|
|
49
68
|
return lock_applied
|
|
50
69
|
end
|
|
70
|
+
last_lock = if lock_applied then lock_applied.lock else nil
|
|
51
71
|
|
|
52
72
|
while true do
|
|
53
73
|
if scheduler.now() >= deadline then
|
|
54
|
-
error(
|
|
74
|
+
error(errors.timeout(key, last_lock), 0)
|
|
55
75
|
end
|
|
56
76
|
scheduler.wait(config.load_poll)
|
|
57
77
|
|
|
58
|
-
local current = utils.
|
|
59
|
-
|
|
60
|
-
end) :: types.DataRecord?
|
|
78
|
+
local current = utils.get_async(config, datastore, key) :: types.DataRecord?
|
|
79
|
+
last_lock = if current then current.lock else nil
|
|
61
80
|
|
|
62
81
|
if not common.is_locked_for(current, job_id, scheduler.now()) then
|
|
63
82
|
break
|
|
@@ -85,9 +104,11 @@ local function run_autosave_loop(self: Profile)
|
|
|
85
104
|
|
|
86
105
|
-- a failed write must not kill the loop: the next tick retries, and
|
|
87
106
|
-- `write_data_record` gives the lock up after a full ttl of failures
|
|
88
|
-
local ok,
|
|
107
|
+
local ok, res = utils.try(self.save, self)
|
|
89
108
|
if not ok then
|
|
90
|
-
config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(
|
|
109
|
+
config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(res)}`)
|
|
110
|
+
elseif not res.success then
|
|
111
|
+
config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(res.error)}`)
|
|
91
112
|
end
|
|
92
113
|
end
|
|
93
114
|
end)
|
|
@@ -117,7 +138,7 @@ local function process_lock_lost(self: Profile)
|
|
|
117
138
|
return
|
|
118
139
|
end
|
|
119
140
|
self.open = false
|
|
120
|
-
self._mutex:close(
|
|
141
|
+
self._mutex:close(errors.profile_closed(self, "lock_lost"))
|
|
121
142
|
fire_hooks(self, self._hooks.lock_lost)
|
|
122
143
|
fire_hooks(self, self._hooks.closed)
|
|
123
144
|
end
|
|
@@ -127,7 +148,11 @@ type WriteOptions = {
|
|
|
127
148
|
pending: types.Pending?,
|
|
128
149
|
}
|
|
129
150
|
|
|
130
|
-
|
|
151
|
+
--- Writes the data (and `pending`, if any) under our session lock, refreshing
|
|
152
|
+
--- or releasing it. Returns true when written; false and the lock now on the
|
|
153
|
+
--- record (nil when none) when the lock is not ours any more, in which case
|
|
154
|
+
--- the profile is closed. Throws a `roblox` error when storage fails.
|
|
155
|
+
local function write_data_record(self: Profile, options: WriteOptions): (boolean, types.Lock?)
|
|
131
156
|
local config = self.config
|
|
132
157
|
local scheduler = config._scheduler
|
|
133
158
|
local datastore = self._datastore
|
|
@@ -135,11 +160,13 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
|
|
|
135
160
|
local job_id = config._hook.job_id()
|
|
136
161
|
|
|
137
162
|
-- set by the transform: whether the write went through (the transform may
|
|
138
|
-
-- run more than once when the storage retries it)
|
|
163
|
+
-- run more than once when the storage retries it), and the lock it found
|
|
139
164
|
local applied = false
|
|
165
|
+
local seen_lock: types.Lock? = nil
|
|
140
166
|
|
|
141
167
|
local function transform(old: any?): DataRecord?
|
|
142
168
|
applied = false
|
|
169
|
+
seen_lock = if old then old.lock else nil
|
|
143
170
|
if not common.is_lock_owned(old, job_id) then
|
|
144
171
|
return nil
|
|
145
172
|
end
|
|
@@ -163,9 +190,7 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
|
|
|
163
190
|
end
|
|
164
191
|
|
|
165
192
|
local success, err = pcall(function()
|
|
166
|
-
utils.
|
|
167
|
-
return datastore.update_async(self.key, transform, self.user_ids)
|
|
168
|
-
end)
|
|
193
|
+
utils.update_async(config, datastore, self.key, transform, self.user_ids)
|
|
169
194
|
end)
|
|
170
195
|
|
|
171
196
|
if not success then
|
|
@@ -180,7 +205,7 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
|
|
|
180
205
|
if not applied then
|
|
181
206
|
-- the transform bailed out: the lock is not ours any more
|
|
182
207
|
process_lock_lost(self)
|
|
183
|
-
return false
|
|
208
|
+
return false, seen_lock
|
|
184
209
|
end
|
|
185
210
|
|
|
186
211
|
self.last_write = now
|
|
@@ -205,23 +230,25 @@ local function release_lock(load_options: LoadOptions)
|
|
|
205
230
|
local job_id = config._hook.job_id()
|
|
206
231
|
|
|
207
232
|
local ok, err = utils.try(function()
|
|
208
|
-
utils.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
end, load_options.user_ids)
|
|
221
|
-
end)
|
|
233
|
+
utils.update_async(config, load_options._datastore, load_options.key, function(old: DataRecord?): DataRecord?
|
|
234
|
+
if not common.is_lock_owned(old, job_id) then
|
|
235
|
+
return nil
|
|
236
|
+
end
|
|
237
|
+
local record = old :: DataRecord
|
|
238
|
+
return {
|
|
239
|
+
data = record.data,
|
|
240
|
+
pending = record.pending,
|
|
241
|
+
migrations = record.migrations,
|
|
242
|
+
lock = nil,
|
|
243
|
+
}
|
|
244
|
+
end, load_options.user_ids)
|
|
222
245
|
end)
|
|
223
246
|
if not ok then
|
|
224
|
-
config._hook.warn(
|
|
247
|
+
config._hook.warn(
|
|
248
|
+
`datastore: could not release the lock of "{load_options.key}" after a failed load (it will expire): {tostring(
|
|
249
|
+
err
|
|
250
|
+
)}`
|
|
251
|
+
)
|
|
225
252
|
end
|
|
226
253
|
end
|
|
227
254
|
|
|
@@ -253,15 +280,15 @@ local function resolve_loaded(load_options: LoadOptions, record: DataRecord): (a
|
|
|
253
280
|
end
|
|
254
281
|
dirty = true
|
|
255
282
|
else
|
|
256
|
-
local
|
|
257
|
-
if not
|
|
258
|
-
error(
|
|
283
|
+
local migrated = apply_migrations(load_options.key, data, record.migrations or {}, migrations)
|
|
284
|
+
if not migrated.success then
|
|
285
|
+
error(migrated.err, 0)
|
|
259
286
|
end
|
|
260
287
|
|
|
261
|
-
data =
|
|
262
|
-
new_migrations =
|
|
288
|
+
data = migrated.result
|
|
289
|
+
new_migrations = migrated.new_migrations
|
|
263
290
|
|
|
264
|
-
if
|
|
291
|
+
if migrated.applied then
|
|
265
292
|
dirty = true
|
|
266
293
|
end
|
|
267
294
|
end
|
|
@@ -279,7 +306,9 @@ local function adquire_and_resolve(load_options: LoadOptions): (any, { string },
|
|
|
279
306
|
local ok, data, new_migrations, dirty = pcall(resolve_loaded, load_options, record)
|
|
280
307
|
if not ok then
|
|
281
308
|
local err = data
|
|
282
|
-
config._hook.warn(
|
|
309
|
+
config._hook.warn(
|
|
310
|
+
`datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`
|
|
311
|
+
)
|
|
283
312
|
release_lock(load_options)
|
|
284
313
|
error(err, 0)
|
|
285
314
|
end
|
|
@@ -297,7 +326,7 @@ end
|
|
|
297
326
|
|
|
298
327
|
local function assert_locked(self: Profile)
|
|
299
328
|
if not self.is_locked then
|
|
300
|
-
error(
|
|
329
|
+
error(errors.released(self), 0)
|
|
301
330
|
end
|
|
302
331
|
end
|
|
303
332
|
|
|
@@ -343,22 +372,26 @@ function profile.get_data(self: Profile)
|
|
|
343
372
|
end
|
|
344
373
|
|
|
345
374
|
--- Applies `dispatcher` to the current data. Returning nil or false leaves the
|
|
346
|
-
--- data untouched (
|
|
347
|
-
--- persisted on the next save. Waits for any in-flight save on the
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
-- a release may have run while this call waited on the mutex
|
|
375
|
+
--- data untouched (Ok(false)); anything else becomes the new data and is
|
|
376
|
+
--- persisted on the next save (Ok(true)). Waits for any in-flight save on the
|
|
377
|
+
--- mutex. Fails with `profile_closed` / `released`; an error thrown by the
|
|
378
|
+
--- dispatcher propagates as is.
|
|
379
|
+
function profile.update(self: Profile, dispatcher: (any) -> any): types.Result<boolean>
|
|
380
|
+
return result.capture(function()
|
|
381
|
+
common.assert_open(self)
|
|
354
382
|
assert_locked(self)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
383
|
+
|
|
384
|
+
return self._mutex:with(function()
|
|
385
|
+
-- a release may have run while this call waited on the mutex
|
|
386
|
+
assert_locked(self)
|
|
387
|
+
local new = dispatcher(self._data)
|
|
388
|
+
if new == nil or new == false then
|
|
389
|
+
return false
|
|
390
|
+
end
|
|
391
|
+
set_data(self, new)
|
|
392
|
+
self._dirty = true
|
|
393
|
+
return true
|
|
394
|
+
end)
|
|
362
395
|
end)
|
|
363
396
|
end
|
|
364
397
|
|
|
@@ -382,61 +415,88 @@ function profile.on_lock_lost(self: Profile, callback: () -> ())
|
|
|
382
415
|
table.insert(self._hooks.lock_lost.hooks, callback)
|
|
383
416
|
end
|
|
384
417
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
418
|
+
--- Writes the data, refreshing the session lock. Fails with `profile_closed`,
|
|
419
|
+
--- `released`, `roblox`, or `lock_lost` (another server took the key; the
|
|
420
|
+
--- profile is closed).
|
|
421
|
+
function profile.save(self: Profile): types.Result<nil>
|
|
422
|
+
return result.capture(function()
|
|
423
|
+
common.assert_open(self)
|
|
424
|
+
assert_locked(self)
|
|
425
|
+
-- before the mutex so hooks can still run updates that land in this save
|
|
426
|
+
fire_hooks(self, self._hooks.save)
|
|
427
|
+
common.assert_open(self)
|
|
394
428
|
assert_locked(self)
|
|
395
|
-
|
|
429
|
+
self._mutex:with(function()
|
|
430
|
+
-- without the lock the write would be cancelled and wrongly read as lost
|
|
431
|
+
assert_locked(self)
|
|
432
|
+
local written, lock = write_data_record(self, { release = false })
|
|
433
|
+
if not written then
|
|
434
|
+
error(errors.lock_lost(self, lock), 0)
|
|
435
|
+
end
|
|
436
|
+
end)
|
|
437
|
+
return nil
|
|
396
438
|
end)
|
|
397
439
|
end
|
|
398
440
|
|
|
399
441
|
--- Writes the data and gives the session lock back. The profile stays open
|
|
400
|
-
--- (another server may load the key meanwhile); `update` and `save`
|
|
401
|
-
--- until `readquire`. No-op when already released.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return
|
|
442
|
+
--- (another server may load the key meanwhile); `update` and `save` fail with
|
|
443
|
+
--- `released` until `readquire`. No-op when already released. Fails with
|
|
444
|
+
--- `profile_closed`, `roblox` (the lock stays on the record, `is_locked`
|
|
445
|
+
--- stays true) or `lock_lost` (the profile is closed).
|
|
446
|
+
function profile.release(self: Profile): types.Result<nil>
|
|
447
|
+
return result.capture(function()
|
|
448
|
+
common.assert_open(self)
|
|
449
|
+
if not self.is_locked then
|
|
450
|
+
return nil
|
|
410
451
|
end
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
452
|
+
self._mutex:with(function()
|
|
453
|
+
-- an unload queued behind this call has flipped `open`: its own write
|
|
454
|
+
-- gives the lock back
|
|
455
|
+
if not self.open or not self.is_locked then
|
|
456
|
+
return
|
|
457
|
+
end
|
|
458
|
+
local written, lock = write_data_record(self, { release = true })
|
|
459
|
+
if not written then
|
|
460
|
+
error(errors.lock_lost(self, lock), 0)
|
|
461
|
+
end
|
|
414
462
|
self.is_locked = false
|
|
415
|
-
end
|
|
463
|
+
end)
|
|
464
|
+
return nil
|
|
416
465
|
end)
|
|
417
466
|
end
|
|
418
467
|
|
|
419
468
|
--- Takes the session lock again, waiting for a holder like `load` does, and
|
|
420
469
|
--- adopts the stored record (resolved pending, migrations) as the current
|
|
421
|
-
--- data.
|
|
470
|
+
--- data. Fails with `timeout` (the profile stays open and released),
|
|
471
|
+
--- `profile_closed`, `roblox`, `outdated`, `migration_mismatch` or `tx_marker_invalid`. No-op when
|
|
422
472
|
--- already locked.
|
|
423
|
-
function profile.readquire(self: Profile)
|
|
424
|
-
|
|
425
|
-
if self.is_locked then
|
|
426
|
-
return
|
|
427
|
-
end
|
|
428
|
-
self._mutex:with(function()
|
|
473
|
+
function profile.readquire(self: Profile): types.Result<nil>
|
|
474
|
+
return result.capture(function()
|
|
429
475
|
common.assert_open(self)
|
|
430
476
|
if self.is_locked then
|
|
431
|
-
return
|
|
477
|
+
return nil
|
|
432
478
|
end
|
|
433
|
-
|
|
479
|
+
self._mutex:with(function()
|
|
480
|
+
common.assert_open(self)
|
|
481
|
+
if self.is_locked then
|
|
482
|
+
return
|
|
483
|
+
end
|
|
484
|
+
local ok, data, new_migrations, dirty = pcall(adquire_and_resolve, load_options_of(self))
|
|
485
|
+
if not ok then
|
|
486
|
+
local err: any = data
|
|
487
|
+
if errors.is(err) and err.type == "outdated" then
|
|
488
|
+
err.profile = self
|
|
489
|
+
end
|
|
490
|
+
error(err, 0)
|
|
491
|
+
end
|
|
434
492
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
493
|
+
self.migrations = new_migrations
|
|
494
|
+
set_data(self, data)
|
|
495
|
+
self._dirty = dirty
|
|
496
|
+
self.last_write = self.config._scheduler.now()
|
|
497
|
+
self.is_locked = true
|
|
498
|
+
end)
|
|
499
|
+
return nil
|
|
440
500
|
end)
|
|
441
501
|
end
|
|
442
502
|
|
|
@@ -452,9 +512,10 @@ function profile.unload(self: Profile)
|
|
|
452
512
|
|
|
453
513
|
self.open = false
|
|
454
514
|
self._mutex:lock()
|
|
455
|
-
self._mutex:close(
|
|
515
|
+
self._mutex:close(errors.profile_closed(self, "unloaded"))
|
|
456
516
|
local ok, err = utils.try(function()
|
|
457
|
-
-- an already released profile has nothing on the record to give back
|
|
517
|
+
-- an already released profile has nothing on the record to give back;
|
|
518
|
+
-- a lost lock (false) has nothing to give back either
|
|
458
519
|
if self.is_locked then
|
|
459
520
|
write_data_record(self, { release = true })
|
|
460
521
|
end
|
package/src/result.luau
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
|
|
3
|
+
local errors = require "./errors"
|
|
4
|
+
local types = require "./types"
|
|
5
|
+
local utils = require "./utils"
|
|
6
|
+
|
|
7
|
+
--[[
|
|
8
|
+
`Result<T>`: what every fallible library call returns instead of throwing.
|
|
9
|
+
|
|
10
|
+
Internally the library keeps throwing, but it throws `Error` tables (see
|
|
11
|
+
`errors.luau`). `result.capture` at each public entry point turns an
|
|
12
|
+
`Error` into `Err`; anything else (a string thrown by a user callback, an
|
|
13
|
+
internal invariant) is rethrown untouched.
|
|
14
|
+
]]
|
|
15
|
+
|
|
16
|
+
export type Result<T> = types.Result<T>
|
|
17
|
+
export type Error = types.Error
|
|
18
|
+
|
|
19
|
+
local result_mt = {}
|
|
20
|
+
result_mt.__index = result_mt
|
|
21
|
+
|
|
22
|
+
function result_mt.unwrap(self: any): any
|
|
23
|
+
if self.success then
|
|
24
|
+
return self.value
|
|
25
|
+
end
|
|
26
|
+
error(self.error, 0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
local result = {}
|
|
30
|
+
|
|
31
|
+
function result.ok<T>(value: T): Result<T>
|
|
32
|
+
return setmetatable({ success = true, value = value }, result_mt) :: any
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
function result.err(err: Error): Result<any>
|
|
36
|
+
assert(errors.is(err), "result.err expects an Error table")
|
|
37
|
+
return setmetatable({ success = false, error = err }, result_mt) :: any
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
function result.is(value: any): boolean
|
|
41
|
+
return type(value) == "table" and getmetatable(value) == result_mt
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
--- Turns a `pcall` outcome into a Result: `Ok(value)` on success, `Err` when
|
|
45
|
+
--- the thrown value is an `Error` table, rethrown raw otherwise.
|
|
46
|
+
function result.of<T>(ok: boolean, value: any): Result<T>
|
|
47
|
+
if ok then
|
|
48
|
+
return result.ok(value)
|
|
49
|
+
end
|
|
50
|
+
if errors.is(value) then
|
|
51
|
+
return result.err(value)
|
|
52
|
+
end
|
|
53
|
+
error(value, 0)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
--- `pcall(fn, ...)` folded through `result.of`.
|
|
57
|
+
function result.capture<T>(fn: (...any) -> T, ...: any): Result<T>
|
|
58
|
+
return result.of(utils.try(fn, ...))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return result
|