@rbxts/dataforge 0.1.0 → 0.4.1
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/LICENSE +21 -0
- package/index.d.ts +318 -32
- package/package.json +7 -2
- package/src/errors.luau +211 -0
- package/src/hooks/memory.luau +1 -12
- package/src/hooks/roblox.luau +10 -1
- package/src/init.luau +20 -2
- package/src/mutex.luau +5 -5
- package/src/profile/common.luau +61 -12
- package/src/profile/lockless.luau +122 -51
- package/src/profile/peek.luau +30 -16
- package/src/profile/profile.luau +246 -116
- package/src/result.luau +61 -0
- package/src/store.luau +129 -44
- package/src/tx/handle.luau +82 -59
- package/src/tx/operation.luau +6 -3
- package/src/types.luau +236 -17
- package/src/utils.luau +37 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
local common = require "./common"
|
|
2
|
+
local errors = require "../errors"
|
|
2
3
|
local mutex = require "../mutex"
|
|
4
|
+
local result = require "../result"
|
|
3
5
|
local tx_handle = require "../tx/handle"
|
|
4
6
|
local types = require "../types"
|
|
5
7
|
local utils = require "../utils"
|
|
@@ -15,7 +17,7 @@ lockless_profile.__index = lockless_profile
|
|
|
15
17
|
function lockless_profile.get_data(self: LocklessProfile)
|
|
16
18
|
common.assert_open(self)
|
|
17
19
|
if not self.fetched then
|
|
18
|
-
error(
|
|
20
|
+
error(errors.not_fetched(self), 0)
|
|
19
21
|
end
|
|
20
22
|
return self._data
|
|
21
23
|
end
|
|
@@ -67,11 +69,11 @@ local function resolve_record(self: LocklessProfile, record: DataRecord?, data:
|
|
|
67
69
|
return utils.deep_copy(config.template), common.all_migration_names(config.migrations)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
local
|
|
71
|
-
if not
|
|
72
|
-
|
|
72
|
+
local migrated = common.apply_migrations(self.key, data, applied, config.migrations)
|
|
73
|
+
if not migrated.success then
|
|
74
|
+
common.raise_migrations(self, migrated)
|
|
73
75
|
end
|
|
74
|
-
return
|
|
76
|
+
return migrated.result, migrated.new_migrations
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
local function replay(queue: Array<types.DataDispatcher<any>>, data: any): any
|
|
@@ -108,13 +110,15 @@ local function process_lock_lost(self: LocklessProfile)
|
|
|
108
110
|
return
|
|
109
111
|
end
|
|
110
112
|
self.open = false
|
|
111
|
-
self._mutex:close(
|
|
113
|
+
self._mutex:close(errors.profile_closed(self, "lock_lost"))
|
|
112
114
|
fire_hooks(self, "lock_lost", self._hooks.lock_lost)
|
|
113
115
|
fire_hooks(self, "closed", self._hooks.closed)
|
|
114
116
|
end
|
|
115
117
|
|
|
116
118
|
type SaveState = {
|
|
117
119
|
applied: boolean,
|
|
120
|
+
--- the lock found on the record (session or transaction), nil when none
|
|
121
|
+
lock: types.Lock?,
|
|
118
122
|
--- a live transaction lock is on the record; `tx_lock_expires` says until when
|
|
119
123
|
tx_locked: boolean,
|
|
120
124
|
tx_lock_expires: number,
|
|
@@ -123,6 +127,9 @@ type SaveState = {
|
|
|
123
127
|
tx_lock_lost: boolean,
|
|
124
128
|
outdated: boolean,
|
|
125
129
|
unknown_pending: types.Pending?,
|
|
130
|
+
--- the transform threw (a migration or a queued dispatcher): rethrown after
|
|
131
|
+
--- the storage call instead of being retried as a storage failure
|
|
132
|
+
failure: any,
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
type WriteOptions = types.LocklessWriteOptions
|
|
@@ -135,7 +142,13 @@ type WriteOptions = types.LocklessWriteOptions
|
|
|
135
142
|
--- `self._tx_lock`). `options.old_tx_lock` is the lock this write expects to
|
|
136
143
|
--- find on the record: the write only goes through (and clears that lock) when
|
|
137
144
|
--- the stored lock has the same id. If the lock is gone or belongs to someone
|
|
138
|
-
--- else, nothing is written and the call fails with
|
|
145
|
+
--- else, nothing is written and the call fails with `tx_lock_lost`.
|
|
146
|
+
---
|
|
147
|
+
--- Throws `lockless_locked` (a live session lock; the profile is closed),
|
|
148
|
+
--- `tx_lock_lost` (closed too), `outdated` (a newer server wrote the record;
|
|
149
|
+
--- nothing written), `predicate_failed` (the store's predicate rejected the
|
|
150
|
+
--- stored data; nothing written), `migration_mismatch`, `roblox`, or whatever
|
|
151
|
+
--- a queued dispatcher / migration threw.
|
|
139
152
|
local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
140
153
|
local config = self.config
|
|
141
154
|
local scheduler = config._scheduler
|
|
@@ -146,23 +159,19 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
|
146
159
|
|
|
147
160
|
local state: SaveState = {
|
|
148
161
|
applied = false,
|
|
162
|
+
lock = nil,
|
|
149
163
|
tx_locked = false,
|
|
150
164
|
tx_lock_expires = 0,
|
|
151
165
|
tx_lock_lost = false,
|
|
152
166
|
session_locked = false,
|
|
153
167
|
outdated = false,
|
|
154
168
|
unknown_pending = nil,
|
|
169
|
+
failure = nil,
|
|
155
170
|
}
|
|
156
171
|
|
|
157
|
-
local function
|
|
158
|
-
state.applied = false
|
|
159
|
-
state.tx_locked = false
|
|
160
|
-
state.session_locked = false
|
|
161
|
-
state.tx_lock_lost = false
|
|
162
|
-
state.outdated = false
|
|
163
|
-
state.unknown_pending = nil
|
|
164
|
-
|
|
172
|
+
local function transform_body(old: DataRecord?): DataRecord?
|
|
165
173
|
local lock = if old then old.lock else nil
|
|
174
|
+
state.lock = lock
|
|
166
175
|
|
|
167
176
|
-- a live session lock means a locked profile owns this key now: the
|
|
168
177
|
-- profile lost its lockless standing, whatever else it expected on the
|
|
@@ -192,11 +201,19 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
|
192
201
|
|
|
193
202
|
-- more migrations applied than this server declares: a newer server
|
|
194
203
|
-- already touched the record, so this server must not write to it
|
|
195
|
-
if
|
|
204
|
+
if common.is_outdated(old, config) then
|
|
196
205
|
state.outdated = true
|
|
197
206
|
return nil
|
|
198
207
|
end
|
|
199
208
|
|
|
209
|
+
-- the store's predicate gates the key: a rejected record is not written
|
|
210
|
+
-- to, and no `pending` of it is resolved
|
|
211
|
+
local stored = if old then old.data else nil
|
|
212
|
+
local passed, predicate_failure = common.run_predicate(config, self.key, stored)
|
|
213
|
+
if not passed then
|
|
214
|
+
error(predicate_failure or errors.predicate_failed(self.key, "save", stored, self), 0)
|
|
215
|
+
end
|
|
216
|
+
|
|
200
217
|
-- a `pending` left by some transaction (ours after commit, or another
|
|
201
218
|
-- server's) decides which data this write builds on
|
|
202
219
|
local base: any = nil
|
|
@@ -222,22 +239,45 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
|
222
239
|
}
|
|
223
240
|
end
|
|
224
241
|
|
|
242
|
+
-- the body runs migrations and queued dispatchers (user code) and may raise
|
|
243
|
+
-- a `migration_mismatch`: none of those is a storage failure, so the
|
|
244
|
+
-- storage retry must not see them. They cancel the write and are rethrown.
|
|
245
|
+
local function transform(old: DataRecord?): DataRecord?
|
|
246
|
+
state.applied = false
|
|
247
|
+
state.lock = nil
|
|
248
|
+
state.tx_locked = false
|
|
249
|
+
state.session_locked = false
|
|
250
|
+
state.tx_lock_lost = false
|
|
251
|
+
state.outdated = false
|
|
252
|
+
state.unknown_pending = nil
|
|
253
|
+
state.failure = nil
|
|
254
|
+
|
|
255
|
+
local ok, new = pcall(transform_body, old)
|
|
256
|
+
if not ok then
|
|
257
|
+
state.failure = new
|
|
258
|
+
return nil
|
|
259
|
+
end
|
|
260
|
+
return new
|
|
261
|
+
end
|
|
262
|
+
|
|
225
263
|
while true do
|
|
226
|
-
local record = utils.
|
|
227
|
-
|
|
228
|
-
|
|
264
|
+
local record = utils.update_async(config, datastore, self.key, transform, self.user_ids) :: DataRecord
|
|
265
|
+
|
|
266
|
+
if state.failure ~= nil then
|
|
267
|
+
error(state.failure, 0)
|
|
268
|
+
end
|
|
229
269
|
|
|
230
270
|
if state.session_locked then
|
|
231
271
|
self._tx_lock = nil
|
|
232
272
|
process_lock_lost(self)
|
|
233
|
-
error(
|
|
273
|
+
error(errors.lockless_locked(self, state.lock :: types.Lock), 0)
|
|
234
274
|
end
|
|
235
275
|
|
|
236
276
|
if state.tx_lock_lost then
|
|
237
277
|
-- whatever is on the record now is not ours; do not touch it again
|
|
238
278
|
self._tx_lock = nil
|
|
239
279
|
process_lock_lost(self)
|
|
240
|
-
error(
|
|
280
|
+
error(errors.tx_lock_lost(self, old_tx_lock :: types.Lock, state.lock), 0)
|
|
241
281
|
end
|
|
242
282
|
|
|
243
283
|
if state.tx_locked then
|
|
@@ -248,7 +288,8 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
|
248
288
|
|
|
249
289
|
if state.outdated then
|
|
250
290
|
error(
|
|
251
|
-
|
|
291
|
+
errors.outdated(self.key, record.migrations or {}, common.all_migration_names(config.migrations), self),
|
|
292
|
+
0
|
|
252
293
|
)
|
|
253
294
|
end
|
|
254
295
|
|
|
@@ -307,16 +348,21 @@ end
|
|
|
307
348
|
|
|
308
349
|
--- Applies every queued update to the stored data in one update_async and
|
|
309
350
|
--- refreshes the local data with the result. Marks the profile fetched.
|
|
310
|
-
---
|
|
311
|
-
---
|
|
312
|
-
---
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
351
|
+
--- Fails (keeping the queue) with `lockless_locked` if another server holds a
|
|
352
|
+
--- live session lock (the profile is closed), `outdated` if the record was
|
|
353
|
+
--- already written by a newer server, `predicate_failed` if the store's
|
|
354
|
+
--- predicate rejected the stored data, `migration_mismatch`, `roblox` or
|
|
355
|
+
--- `profile_closed`. An error thrown by a queued dispatcher propagates as is.
|
|
356
|
+
function lockless_profile.save(self: LocklessProfile): types.Result<nil>
|
|
357
|
+
return result.capture(function()
|
|
358
|
+
common.assert_open(self)
|
|
359
|
+
-- before the mutex so hooks can still queue updates that land in this save
|
|
360
|
+
fire_hooks(self, "save", self._hooks.save)
|
|
361
|
+
common.assert_open(self)
|
|
362
|
+
self._mutex:with(function()
|
|
363
|
+
write_queued(self, {})
|
|
364
|
+
end)
|
|
365
|
+
return nil
|
|
320
366
|
end)
|
|
321
367
|
end
|
|
322
368
|
|
|
@@ -326,11 +372,15 @@ function lockless_profile.unload(self: LocklessProfile)
|
|
|
326
372
|
end
|
|
327
373
|
-- last chance to queue updates while the profile is still open
|
|
328
374
|
fire_hooks(self, "closing", self._hooks.closing)
|
|
375
|
+
-- a closing hook may have unloaded the profile itself
|
|
376
|
+
if not self.open then
|
|
377
|
+
return
|
|
378
|
+
end
|
|
329
379
|
|
|
330
380
|
self.open = false
|
|
331
381
|
self._mutex:lock()
|
|
332
382
|
-- everything still waiting on the mutex (and anything after) is rejected
|
|
333
|
-
self._mutex:close(
|
|
383
|
+
self._mutex:close(errors.profile_closed(self, "unloaded"))
|
|
334
384
|
local ok, err = utils.try(function()
|
|
335
385
|
if #self.queued_updates > 0 or self.dirty_pending then
|
|
336
386
|
write_queued(self, {})
|
|
@@ -338,7 +388,9 @@ function lockless_profile.unload(self: LocklessProfile)
|
|
|
338
388
|
end)
|
|
339
389
|
self._mutex:unlock()
|
|
340
390
|
if not ok then
|
|
341
|
-
self.config._hook.warn(
|
|
391
|
+
self.config._hook.warn(
|
|
392
|
+
`datastore: failed to flush "{self.key}" on unload ({#self.queued_updates} changes lost): {tostring(err)}`
|
|
393
|
+
)
|
|
342
394
|
end
|
|
343
395
|
fire_hooks(self, "closed", self._hooks.closed)
|
|
344
396
|
end
|
|
@@ -375,24 +427,30 @@ end
|
|
|
375
427
|
--- Reads the stored record (get_async) into the local data: migrations are
|
|
376
428
|
--- applied, then the queued updates replayed on top, so `get_data()` reflects the
|
|
377
429
|
--- local changes still waiting for a save. Marks the profile fetched. Throws
|
|
378
|
-
--- (keeping the queue) if any session lock is on the record
|
|
430
|
+
--- (keeping the queue) if any session lock is on the record, or if the store's
|
|
431
|
+
--- predicate rejects the stored data. Caller holds the mutex.
|
|
379
432
|
local function read_record(self: LocklessProfile)
|
|
380
433
|
local config = self.config
|
|
381
434
|
local datastore = self._datastore
|
|
382
435
|
|
|
383
|
-
local record = utils.
|
|
384
|
-
return datastore.get_async(self.key)
|
|
385
|
-
end) :: DataRecord?
|
|
436
|
+
local record = utils.get_async(config, datastore, self.key) :: DataRecord?
|
|
386
437
|
|
|
387
438
|
local lock = if record then record.lock else nil
|
|
388
439
|
if lock and not lock.tx and lock.expires > config._scheduler.now() then
|
|
389
440
|
process_lock_lost(self)
|
|
390
|
-
error(
|
|
441
|
+
error(errors.lockless_locked(self, lock), 0)
|
|
391
442
|
end
|
|
392
443
|
-- an expired session lock belongs to a dead server: ignored, the next write clears it
|
|
393
444
|
-- a transaction lock does not matter for a read: the data under it is the
|
|
394
445
|
-- data until that transaction's `pending` lands (handled below)
|
|
395
446
|
|
|
447
|
+
local stored = if record then record.data else nil
|
|
448
|
+
local passed, predicate_failure = common.run_predicate(config, self.key, stored)
|
|
449
|
+
if not passed then
|
|
450
|
+
-- nothing is adopted: the profile keeps whatever it had and stays open
|
|
451
|
+
error(predicate_failure or errors.predicate_failed(self.key, "fetch", stored, self), 0)
|
|
452
|
+
end
|
|
453
|
+
|
|
396
454
|
-- a `pending` on the record: find out whether it committed (the record is
|
|
397
455
|
-- left as is; the next write folds it in)
|
|
398
456
|
local base: any = nil
|
|
@@ -414,22 +472,33 @@ local function read_record(self: LocklessProfile)
|
|
|
414
472
|
return self._data
|
|
415
473
|
end
|
|
416
474
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
475
|
+
--- Fails with `lockless_locked` (a session lock is on the record; the profile
|
|
476
|
+
--- is closed), `outdated` (a newer server wrote the record), `predicate_failed`
|
|
477
|
+
--- (the store's predicate rejected the stored data), `migration_mismatch`,
|
|
478
|
+
--- `roblox`, `tx_marker_invalid` or `profile_closed`. An error thrown by a
|
|
479
|
+
--- queued dispatcher propagates as is.
|
|
480
|
+
function lockless_profile.fetch(self: LocklessProfile): types.Result<any>
|
|
481
|
+
return result.capture(function()
|
|
482
|
+
common.assert_open(self)
|
|
483
|
+
return self._mutex:with(function()
|
|
484
|
+
return read_record(self)
|
|
485
|
+
end)
|
|
421
486
|
end)
|
|
422
487
|
end
|
|
423
488
|
|
|
424
489
|
--- Queues `dispatcher` to run against the stored data on the next save. When
|
|
425
490
|
--- the profile is fetched, the local data is advanced right away so `get_data()`
|
|
426
491
|
--- reflects the queued change. A dispatcher returning `false` (or nil) is a
|
|
427
|
-
--- no-op and is not queued. Waits for any in-flight save/fetch on
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
return
|
|
432
|
-
|
|
492
|
+
--- no-op and is not queued (Ok(false)). Waits for any in-flight save/fetch on
|
|
493
|
+
--- the mutex. Fails with `profile_closed`; an error thrown by the dispatcher
|
|
494
|
+
--- propagates as is.
|
|
495
|
+
function lockless_profile.update(self: LocklessProfile, dispatcher: (any) -> any): types.Result<boolean>
|
|
496
|
+
return result.capture(function()
|
|
497
|
+
common.assert_open(self)
|
|
498
|
+
|
|
499
|
+
return self._mutex:with(function()
|
|
500
|
+
return queue_update(self, dispatcher)
|
|
501
|
+
end)
|
|
433
502
|
end)
|
|
434
503
|
end
|
|
435
504
|
|
|
@@ -449,9 +518,11 @@ local function run_flush_loop(self: LocklessProfile)
|
|
|
449
518
|
continue
|
|
450
519
|
end
|
|
451
520
|
|
|
452
|
-
local ok,
|
|
521
|
+
local ok, res = utils.try(self.save, self)
|
|
453
522
|
if not ok then
|
|
454
|
-
config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(
|
|
523
|
+
config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(res)}`)
|
|
524
|
+
elseif not res.success then
|
|
525
|
+
config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(res.error)}`)
|
|
455
526
|
end
|
|
456
527
|
end
|
|
457
528
|
end)
|
package/src/profile/peek.luau
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
local common = require "./common"
|
|
2
|
+
local errors = require "../errors"
|
|
3
|
+
local result = require "../result"
|
|
2
4
|
local types = require "../types"
|
|
3
5
|
local utils = require "../utils"
|
|
4
6
|
|
|
@@ -16,14 +18,20 @@ peek_profile.__index = peek_profile
|
|
|
16
18
|
|
|
17
19
|
--- Reads the record with `GetAsync` and adopts it: `lock` and `pending` are
|
|
18
20
|
--- taken as stored, the data gets the declared migrations applied in memory
|
|
19
|
-
--- (nothing is written). A missing key resolves to the template.
|
|
21
|
+
--- (nothing is written). A missing key resolves to the template. Throws
|
|
22
|
+
--- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
|
|
20
23
|
local function read(self: PeekProfile): any
|
|
21
24
|
local config = self._config
|
|
22
25
|
local datastore = self._datastore
|
|
23
26
|
|
|
24
|
-
local record = utils.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
local record = utils.get_async(config, datastore, self.key) :: DataRecord?
|
|
28
|
+
|
|
29
|
+
-- before anything is adopted: a rejected key leaves the snapshot untouched
|
|
30
|
+
local stored = if record then record.data else nil
|
|
31
|
+
local passed, predicate_failure = common.run_predicate(config, self.key, stored)
|
|
32
|
+
if not passed then
|
|
33
|
+
error(predicate_failure or errors.predicate_failed(self.key, "peek", stored), 0)
|
|
34
|
+
end
|
|
27
35
|
|
|
28
36
|
self.lock = if record then record.lock else nil
|
|
29
37
|
self.pending = if record then record.pending else nil
|
|
@@ -34,12 +42,12 @@ local function read(self: PeekProfile): any
|
|
|
34
42
|
data = utils.deep_copy(config.template)
|
|
35
43
|
migrations = common.all_migration_names(config.migrations)
|
|
36
44
|
else
|
|
37
|
-
local
|
|
38
|
-
if not
|
|
39
|
-
error(
|
|
45
|
+
local migrated = common.apply_migrations(self.key, record.data, record.migrations or {}, config.migrations)
|
|
46
|
+
if not migrated.success then
|
|
47
|
+
error(migrated.err, 0)
|
|
40
48
|
end
|
|
41
|
-
data =
|
|
42
|
-
migrations =
|
|
49
|
+
data = migrated.result
|
|
50
|
+
migrations = migrated.new_migrations
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
self.migrations = migrations
|
|
@@ -51,14 +59,17 @@ function peek_profile.get_data(self: PeekProfile)
|
|
|
51
59
|
return self._data
|
|
52
60
|
end
|
|
53
61
|
|
|
54
|
-
--- Calls `get_async` again and replaces the snapshot. Yields.
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
--- Calls `get_async` again and replaces the snapshot. Yields. Fails with
|
|
63
|
+
--- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`, leaving
|
|
64
|
+
--- the previous snapshot in place.
|
|
65
|
+
function peek_profile.refresh(self: PeekProfile): types.Result<any>
|
|
66
|
+
return result.capture(read, self)
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
--- Takes the first snapshot right away (yields). The profile has no mutex, no
|
|
60
|
-
--- loops and no hooks: it is a plain value that can be refreshed.
|
|
61
|
-
|
|
70
|
+
--- loops and no hooks: it is a plain value that can be refreshed. Fails with
|
|
71
|
+
--- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
|
|
72
|
+
function peek_profile.create(options: PeekOptions): types.Result<PeekProfile>
|
|
62
73
|
local self = {} :: PeekProfile
|
|
63
74
|
self.kind = "peek"
|
|
64
75
|
self.key = options.key
|
|
@@ -70,8 +81,11 @@ function peek_profile.create(options: PeekOptions): PeekProfile
|
|
|
70
81
|
self._data = nil :: any
|
|
71
82
|
|
|
72
83
|
local profile = setmetatable(self, peek_profile) :: any
|
|
73
|
-
read
|
|
74
|
-
|
|
84
|
+
local first = result.capture(read, profile)
|
|
85
|
+
if not first.success then
|
|
86
|
+
return first
|
|
87
|
+
end
|
|
88
|
+
return result.ok(profile)
|
|
75
89
|
end
|
|
76
90
|
|
|
77
91
|
return {
|