@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
package/src/store.luau
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
--! strict
|
|
2
2
|
|
|
3
|
+
local errors = require "./errors"
|
|
4
|
+
local result = require "./result"
|
|
3
5
|
local types = require "./types"
|
|
4
6
|
local utils = require "./utils"
|
|
5
7
|
local roblox_scheduler = require "./schedulers/roblox"
|
|
@@ -20,6 +22,7 @@ type Profile<T = any> = types.Profile<T>
|
|
|
20
22
|
type LocklessProfile<T = any> = types.LocklessProfile<T>
|
|
21
23
|
type PeekProfile<T = any> = types.PeekProfile<T>
|
|
22
24
|
type HooksList<T... = ()> = types.HooksList<T...>
|
|
25
|
+
type Result<T> = types.Result<T>
|
|
23
26
|
|
|
24
27
|
export type Store<T = any> = {
|
|
25
28
|
_loading: { [string]: boolean },
|
|
@@ -43,31 +46,42 @@ export type Store<T = any> = {
|
|
|
43
46
|
},
|
|
44
47
|
|
|
45
48
|
config: ResolvedConfig<T>,
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
--- Takes the session lock for `key` and loads it. Yields. Fails with
|
|
50
|
+
--- `store_closed`, `already_loaded`, `timeout`, `roblox`, `outdated` (a
|
|
51
|
+
--- newer server wrote the record; no lock is taken), `predicate_failed`
|
|
52
|
+
--- (`config.predicate` rejected the stored data; no lock is taken),
|
|
53
|
+
--- `migration_mismatch` or `tx_marker_invalid`.
|
|
54
|
+
load: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
|
|
55
|
+
--- Returns the profile for `key`, waiting for it to load or loading it if
|
|
56
|
+
--- nobody has yet. Waiters get the same Result as the load.
|
|
57
|
+
wait_loaded: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
|
|
48
58
|
get_loaded: (self: Store<T>, key: string) -> Profile<T>?,
|
|
49
59
|
|
|
50
60
|
--- Returns the lockless profile for `key`, creating it if needed. Never
|
|
51
|
-
--- yields: a lockless profile does no storage work until `fetch`.
|
|
61
|
+
--- yields: a lockless profile does no storage work until `fetch`. Throws a
|
|
62
|
+
--- `store_closed` error on a closed store.
|
|
52
63
|
get_lockless: (self: Store<T>, key: string, user_ids: { number }) -> LocklessProfile<T>,
|
|
53
64
|
|
|
54
65
|
--- Read-only snapshot of `key` via `GetAsync`: the current data and lock.
|
|
55
66
|
--- Yields. The result is not tracked by the store, takes no lock and cannot
|
|
56
|
-
--- join a transaction.
|
|
57
|
-
|
|
67
|
+
--- join a transaction. Fails with `store_closed`, `roblox`, `outdated`,
|
|
68
|
+
--- `predicate_failed` or `migration_mismatch`.
|
|
69
|
+
peek: (self: Store<T>, key: string) -> Result<PeekProfile<T>>,
|
|
58
70
|
|
|
59
71
|
--- Atomically updates profiles of this store (locked or lockless). `transform`
|
|
60
72
|
--- gets their data in order and returns the new data in the same order, or
|
|
61
|
-
--- false to cancel.
|
|
62
|
-
---
|
|
63
|
-
--- hook and scheduler default to the store's.
|
|
73
|
+
--- false to cancel. Ok(false) when cancelled, Ok(true) when committed; fails
|
|
74
|
+
--- like `dataforge.transaction`. `config` fills in what the store does not
|
|
75
|
+
--- know (ttls, retry); hook and scheduler default to the store's.
|
|
64
76
|
transaction: (
|
|
65
77
|
self: Store<T>,
|
|
66
78
|
profiles: Array<ProfileBase<T>>,
|
|
67
79
|
transform: (data: Array<T>) -> Array<T> | false,
|
|
68
80
|
config: types.TransactionConfig?
|
|
69
|
-
) -> boolean
|
|
81
|
+
) -> Result<boolean>,
|
|
70
82
|
|
|
83
|
+
--- Fires for every data change of every profile of this store, including
|
|
84
|
+
--- the data a key loads with (`old == nil` marks that initial value).
|
|
71
85
|
on_change: (self: Store<T>, callback: (profile: Profile<T>, new: T, old: T?) -> ()) -> (),
|
|
72
86
|
on_save: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
73
87
|
on_closing: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
@@ -77,7 +91,9 @@ export type Store<T = any> = {
|
|
|
77
91
|
close: (self: Store<T>) -> (),
|
|
78
92
|
}
|
|
79
93
|
|
|
80
|
-
|
|
94
|
+
--- Resumes every `wait_loaded` caller with the load's Result, or with a raw
|
|
95
|
+
--- error (`res == nil`) they rethrow.
|
|
96
|
+
local function resume_load_locks(self: Store, key: string, res: Result<Profile>?, raw: any?)
|
|
81
97
|
local loading_locks = self._loading_locks[key]
|
|
82
98
|
if not loading_locks then
|
|
83
99
|
return
|
|
@@ -85,7 +101,7 @@ local function resume_load_locks(self: Store, key: string, ok: boolean, result:
|
|
|
85
101
|
self._loading_locks[key] = nil
|
|
86
102
|
|
|
87
103
|
for _, thread in loading_locks do
|
|
88
|
-
self.config._scheduler.spawn(thread,
|
|
104
|
+
self.config._scheduler.spawn(thread, res, raw)
|
|
89
105
|
end
|
|
90
106
|
end
|
|
91
107
|
|
|
@@ -100,6 +116,9 @@ local function insert_loaded_lock(self: Store, key: string)
|
|
|
100
116
|
table.insert(waiters, thread)
|
|
101
117
|
end
|
|
102
118
|
|
|
119
|
+
--- Drops `profile` from the registry, unless the key has already been taken by
|
|
120
|
+
--- a newer one: with instant detach a new profile may be registered while the
|
|
121
|
+
--- old one is still writing, and the old one's late unregister must not evict it.
|
|
103
122
|
local function unregister_profile(self: Store, profile: Profile)
|
|
104
123
|
local key = profile.key
|
|
105
124
|
if self.profiles[key] == profile then
|
|
@@ -145,6 +164,10 @@ function store.create<T>(config: Config<T>): Store<T>
|
|
|
145
164
|
assert(type(config) == "table", "datastore: config must be a table")
|
|
146
165
|
assert(type(config.name) == "string" and #config.name > 0, "datastore: config.name is required")
|
|
147
166
|
assert(config.template ~= nil, "datastore: config.template is required")
|
|
167
|
+
assert(
|
|
168
|
+
config.predicate == nil or type(config.predicate) == "function",
|
|
169
|
+
"datastore: config.predicate must be a function"
|
|
170
|
+
)
|
|
148
171
|
|
|
149
172
|
local lock_ttl = config.lock_ttl or 60
|
|
150
173
|
local hook = config._hook
|
|
@@ -168,6 +191,7 @@ function store.create<T>(config: Config<T>): Store<T>
|
|
|
168
191
|
_scheduler = scheduler,
|
|
169
192
|
|
|
170
193
|
migrations = config.migrations or {},
|
|
194
|
+
predicate = config.predicate,
|
|
171
195
|
|
|
172
196
|
autosave_interval = config.autosave_interval or 30,
|
|
173
197
|
flush_interval = config.flush_interval or config.autosave_interval or 30,
|
|
@@ -202,16 +226,19 @@ function store.create<T>(config: Config<T>): Store<T>
|
|
|
202
226
|
return setmetatable(self, store) :: any
|
|
203
227
|
end
|
|
204
228
|
|
|
205
|
-
function store.load(self: Store, key: string, user_ids: { number }): Profile
|
|
229
|
+
function store.load(self: Store, key: string, user_ids: { number }): Result<Profile>
|
|
206
230
|
if self.closed then
|
|
207
|
-
|
|
231
|
+
return result.err(errors.store_closed(self))
|
|
208
232
|
end
|
|
209
|
-
|
|
210
|
-
|
|
233
|
+
-- a closing profile detaches before its final write, so it is normally gone
|
|
234
|
+
-- from the registry already; the `open` check is belt and braces
|
|
235
|
+
local existing = self.profiles[key]
|
|
236
|
+
if (existing ~= nil and existing.open) or self._loading[key] then
|
|
237
|
+
return result.err(errors.already_loaded(self, key))
|
|
211
238
|
end
|
|
212
239
|
self._loading[key] = true
|
|
213
240
|
|
|
214
|
-
local ok,
|
|
241
|
+
local ok, loaded = utils.try(profile.load, {
|
|
215
242
|
key = key,
|
|
216
243
|
config = self.config,
|
|
217
244
|
user_ids = user_ids,
|
|
@@ -220,29 +247,58 @@ function store.load(self: Store, key: string, user_ids: { number }): Profile
|
|
|
220
247
|
self._loading[key] = nil
|
|
221
248
|
|
|
222
249
|
if not ok then
|
|
223
|
-
|
|
224
|
-
|
|
250
|
+
if errors.is(loaded) then
|
|
251
|
+
local failure = result.err(loaded)
|
|
252
|
+
resume_load_locks(self, key, failure)
|
|
253
|
+
return failure
|
|
254
|
+
end
|
|
255
|
+
-- a migration threw: everyone waiting gets the raw error too
|
|
256
|
+
resume_load_locks(self, key, nil, loaded)
|
|
257
|
+
error(loaded, 0)
|
|
225
258
|
end
|
|
226
259
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
260
|
+
if self.closed then
|
|
261
|
+
-- the store closed while this load was in flight: give the lock back
|
|
262
|
+
-- right away instead of registering a profile nobody will unload
|
|
263
|
+
loaded:unload()
|
|
264
|
+
local failure = result.err(errors.store_closed(self))
|
|
265
|
+
resume_load_locks(self, key, failure)
|
|
266
|
+
return failure
|
|
267
|
+
end
|
|
231
268
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
269
|
+
-- dropped from the registry the moment it stops being usable, so the key can
|
|
270
|
+
-- be loaded again while the old session's final write is still in flight
|
|
271
|
+
loaded._on_detach = function()
|
|
272
|
+
unregister_profile(self, loaded)
|
|
273
|
+
end
|
|
274
|
+
-- safety net: a profile that closes some other way still unregisters
|
|
275
|
+
loaded:on_closed(function()
|
|
276
|
+
unregister_profile(self, loaded)
|
|
277
|
+
end)
|
|
278
|
+
forward_hooks(self, loaded)
|
|
279
|
+
|
|
280
|
+
self.profiles[key] = loaded
|
|
281
|
+
-- a load's initial data never reaches the profile's own hooks (they are
|
|
282
|
+
-- attached after `profile.load` set it), so report it here: `old == nil`
|
|
283
|
+
-- marks a key that just appeared. Lockless profiles need no equivalent:
|
|
284
|
+
-- `get_lockless` forwards their hooks before any `fetch` sets data.
|
|
285
|
+
fire_hooks(self, self._hooks.change, loaded, loaded:get_data(), nil)
|
|
286
|
+
|
|
287
|
+
local success = result.ok(loaded)
|
|
288
|
+
resume_load_locks(self, key, success)
|
|
289
|
+
return success
|
|
235
290
|
end
|
|
236
291
|
|
|
237
|
-
--- Returns the profile for `key`, waiting for it to load or loading it if
|
|
238
|
-
|
|
292
|
+
--- Returns the profile for `key`, waiting for it to load or loading it if
|
|
293
|
+
--- nobody has yet. Waiters get the same Result (or raw error) as the load.
|
|
294
|
+
function store.wait_loaded(self: Store, key: string, user_ids: { number }): Result<Profile>
|
|
239
295
|
if self.closed then
|
|
240
|
-
|
|
296
|
+
return result.err(errors.store_closed(self))
|
|
241
297
|
end
|
|
242
298
|
|
|
243
299
|
local existing = self.profiles[key]
|
|
244
300
|
if existing ~= nil then
|
|
245
|
-
return existing
|
|
301
|
+
return result.ok(existing)
|
|
246
302
|
end
|
|
247
303
|
|
|
248
304
|
if not self._loading[key] then
|
|
@@ -250,12 +306,11 @@ function store.wait_loaded(self: Store, key: string, user_ids: { number }): Prof
|
|
|
250
306
|
end
|
|
251
307
|
insert_loaded_lock(self, key)
|
|
252
308
|
|
|
253
|
-
local
|
|
254
|
-
if
|
|
255
|
-
error(
|
|
309
|
+
local res, raw = coroutine.yield()
|
|
310
|
+
if res == nil then
|
|
311
|
+
error(raw, 0)
|
|
256
312
|
end
|
|
257
|
-
|
|
258
|
-
return result
|
|
313
|
+
return res
|
|
259
314
|
end
|
|
260
315
|
|
|
261
316
|
function store.get_loaded(self: Store, key: string): Profile?
|
|
@@ -264,7 +319,7 @@ end
|
|
|
264
319
|
|
|
265
320
|
function store.get_lockless(self: Store, key: string, user_ids: { number }): LocklessProfile
|
|
266
321
|
if self.closed then
|
|
267
|
-
error
|
|
322
|
+
error(errors.store_closed(self), 0)
|
|
268
323
|
end
|
|
269
324
|
|
|
270
325
|
local existing = self.lockless_profiles[key]
|
|
@@ -292,9 +347,9 @@ end
|
|
|
292
347
|
|
|
293
348
|
--- One `get_async` on `key`, no write. Never registered in the store: `close`
|
|
294
349
|
--- does not know about it and hooks do not fire for it.
|
|
295
|
-
function store.peek(self: Store, key: string): PeekProfile
|
|
350
|
+
function store.peek(self: Store, key: string): Result<PeekProfile>
|
|
296
351
|
if self.closed then
|
|
297
|
-
|
|
352
|
+
return result.err(errors.store_closed(self))
|
|
298
353
|
end
|
|
299
354
|
|
|
300
355
|
return peek_profile.create {
|
|
@@ -317,9 +372,9 @@ function store.transaction(
|
|
|
317
372
|
profiles: Array<ProfileBase>,
|
|
318
373
|
transform: (data: Array<any>) -> Array<any> | false,
|
|
319
374
|
config: types.TransactionConfig?
|
|
320
|
-
): boolean
|
|
375
|
+
): Result<boolean>
|
|
321
376
|
if self.closed then
|
|
322
|
-
|
|
377
|
+
return result.err(errors.store_closed(self))
|
|
323
378
|
end
|
|
324
379
|
|
|
325
380
|
local resolved = tx.resolve_config(config, {
|
|
@@ -335,11 +390,11 @@ function store.transaction(
|
|
|
335
390
|
datas[index] = ctx:get(participant)
|
|
336
391
|
end
|
|
337
392
|
|
|
338
|
-
local
|
|
339
|
-
if
|
|
393
|
+
local transformed = transform(datas)
|
|
394
|
+
if transformed == false then
|
|
340
395
|
return false
|
|
341
396
|
end
|
|
342
|
-
local new_datas =
|
|
397
|
+
local new_datas = transformed :: Array<any>
|
|
343
398
|
|
|
344
399
|
for index, participant in profiles do
|
|
345
400
|
local new = new_datas[index]
|
|
@@ -353,7 +408,9 @@ function store.transaction(
|
|
|
353
408
|
end
|
|
354
409
|
|
|
355
410
|
--- Unloads every profile (locked and lockless) concurrently and waits for all
|
|
356
|
-
--- of them
|
|
411
|
+
--- of them, and for any load still in flight (which fails with `store_closed`
|
|
412
|
+
--- and releases its lock). Loads and transactions are rejected from the first
|
|
413
|
+
--- line on.
|
|
357
414
|
function store.close(self: Store)
|
|
358
415
|
if self.closed then
|
|
359
416
|
return
|
|
@@ -379,12 +436,40 @@ function store.close(self: Store)
|
|
|
379
436
|
end)
|
|
380
437
|
end
|
|
381
438
|
|
|
439
|
+
--- A load still in flight: `load` sees `closed` when it finishes and gives
|
|
440
|
+
--- the lock back itself; wait for that like for an unload.
|
|
441
|
+
local function wait_load(key: string)
|
|
442
|
+
remaining += 1
|
|
443
|
+
scheduler.spawn(function()
|
|
444
|
+
insert_loaded_lock(self, key)
|
|
445
|
+
coroutine.yield()
|
|
446
|
+
remaining -= 1
|
|
447
|
+
if remaining == 0 and waiting then
|
|
448
|
+
scheduler.spawn(thread)
|
|
449
|
+
end
|
|
450
|
+
end)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
-- snapshots: `unload` spawns a thread that runs up to its first yield right
|
|
454
|
+
-- away, and a detaching profile removes itself from these tables
|
|
455
|
+
local locked: Array<Profile> = {}
|
|
382
456
|
for _, profile in self.profiles do
|
|
383
|
-
|
|
457
|
+
table.insert(locked, profile)
|
|
384
458
|
end
|
|
459
|
+
local lockless: Array<LocklessProfile> = {}
|
|
385
460
|
for _, profile in self.lockless_profiles do
|
|
461
|
+
table.insert(lockless, profile)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
for _, profile in locked do
|
|
386
465
|
unload(profile)
|
|
387
466
|
end
|
|
467
|
+
for _, profile in lockless do
|
|
468
|
+
unload(profile)
|
|
469
|
+
end
|
|
470
|
+
for key in self._loading do
|
|
471
|
+
wait_load(key)
|
|
472
|
+
end
|
|
388
473
|
|
|
389
474
|
if remaining > 0 then
|
|
390
475
|
waiting = true
|
package/src/tx/handle.luau
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
foreign `pending` entries.
|
|
8
8
|
]]
|
|
9
9
|
|
|
10
|
+
local errors = require "../errors"
|
|
11
|
+
local result = require "../result"
|
|
10
12
|
local types = require "../types"
|
|
11
13
|
local utils = require "../utils"
|
|
12
14
|
local roblox_scheduler = require "../schedulers/roblox"
|
|
@@ -25,6 +27,7 @@ type ResolvedTransactionConfig = types.ResolvedTransactionConfig
|
|
|
25
27
|
|
|
26
28
|
type TxOutcome = types.TxOutcome
|
|
27
29
|
type TxPatch = types.TxPatch
|
|
30
|
+
type Error = types.Error
|
|
28
31
|
export type TxContext = types.TxContext
|
|
29
32
|
|
|
30
33
|
local tx_handle = {}
|
|
@@ -50,19 +53,17 @@ local function resolve_marker_in(
|
|
|
50
53
|
id: string,
|
|
51
54
|
fallback: TxOutcome
|
|
52
55
|
): TxOutcome
|
|
53
|
-
local
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return fallback
|
|
59
|
-
end)
|
|
56
|
+
local outcome = utils.update_async(config, datastore, id, function(current: any?)
|
|
57
|
+
if current == "committed" or current == "aborted" then
|
|
58
|
+
return current
|
|
59
|
+
end
|
|
60
|
+
return fallback
|
|
60
61
|
end)
|
|
61
62
|
|
|
62
|
-
if
|
|
63
|
-
error(
|
|
63
|
+
if outcome ~= "committed" and outcome ~= "aborted" then
|
|
64
|
+
error(errors.tx_marker_invalid(id, outcome), 0)
|
|
64
65
|
end
|
|
65
|
-
return
|
|
66
|
+
return outcome :: TxOutcome
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
--- Reads the marker for `id`, writing `fallback` if it has not been decided yet.
|
|
@@ -154,42 +155,47 @@ local function prepare_sort(a: TxPatch, b: TxPatch): boolean
|
|
|
154
155
|
return sort_key(a.profile) < sort_key(b.profile)
|
|
155
156
|
end
|
|
156
157
|
|
|
157
|
-
--- Locks every profile mutex in order
|
|
158
|
-
---
|
|
159
|
-
---
|
|
160
|
-
local function
|
|
158
|
+
--- Locks every profile mutex in order, appending each one taken to `locked`
|
|
159
|
+
--- so a rejection midway (a mutex closed while waiting) leaves an exact list
|
|
160
|
+
--- for `unlock_all`.
|
|
161
|
+
local function lock_all(profiles: Array<ProfileBase>, locked: Array<ProfileBase>)
|
|
161
162
|
for _, participant in profiles do
|
|
162
163
|
participant._mutex:lock()
|
|
164
|
+
table.insert(locked, participant)
|
|
163
165
|
end
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
--- Releases the mutexes in reverse and, for a lockless profile still holding
|
|
169
|
+
--- its storage transaction lock (the transaction ended before writing
|
|
170
|
+
--- `pending`), gives the key back.
|
|
171
|
+
local function unlock_all(locked: Array<ProfileBase>)
|
|
172
|
+
for index = #locked, 1, -1 do
|
|
173
|
+
local participant = locked[index]
|
|
174
|
+
local lockless = participant :: LocklessProfile
|
|
175
|
+
if participant.kind == "lockless" and lockless._tx_lock then
|
|
176
|
+
local ok, err = utils.try(function()
|
|
177
|
+
lockless:_write_queued {
|
|
178
|
+
old_tx_lock = lockless._tx_lock,
|
|
179
|
+
}
|
|
180
|
+
end)
|
|
181
|
+
if not ok then
|
|
182
|
+
participant.config._hook.warn(
|
|
183
|
+
`datastore: failed to release tx lock on "{participant.key}" (it will expire): {tostring(err)}`
|
|
184
|
+
)
|
|
185
|
+
lockless._tx_lock = nil
|
|
180
186
|
end
|
|
181
|
-
participant._mutex:unlock()
|
|
182
187
|
end
|
|
188
|
+
participant._mutex:unlock()
|
|
183
189
|
end
|
|
184
190
|
end
|
|
185
191
|
|
|
186
192
|
local function assert_open_profiles(profiles: Array<ProfileBase>)
|
|
187
193
|
for _, participant in profiles do
|
|
188
194
|
if not participant.open then
|
|
189
|
-
error(
|
|
195
|
+
error(errors.closed(participant), 0)
|
|
190
196
|
end
|
|
191
197
|
if participant.kind == "locked" and not (participant :: Profile).is_locked then
|
|
192
|
-
error(
|
|
198
|
+
error(errors.released(participant), 0)
|
|
193
199
|
end
|
|
194
200
|
end
|
|
195
201
|
end
|
|
@@ -230,7 +236,10 @@ local function locked_patch(participant: Profile, new_data: any): TxPatch
|
|
|
230
236
|
patch.prepared = false
|
|
231
237
|
|
|
232
238
|
function patch.prepare(pending)
|
|
233
|
-
|
|
239
|
+
local written, lock = participant:_write_record { release = false, pending = pending }
|
|
240
|
+
if not written then
|
|
241
|
+
error(errors.lock_lost(participant, lock), 0)
|
|
242
|
+
end
|
|
234
243
|
end
|
|
235
244
|
function patch.commit(operation)
|
|
236
245
|
table.insert(participant._cleanup_txs, operation)
|
|
@@ -256,13 +265,13 @@ local function lockless_patch(participant: LocklessProfile, new_data: any): TxPa
|
|
|
256
265
|
patch.prepared = false
|
|
257
266
|
|
|
258
267
|
function patch.prepare(pending)
|
|
259
|
-
-- releases the transaction lock taken in `adquire_lockless_lock`;
|
|
260
|
-
-- (nothing written) if that lock is gone or someone
|
|
268
|
+
-- releases the transaction lock taken in `adquire_lockless_lock`; throws
|
|
269
|
+
-- `tx_lock_lost` (nothing written) if that lock is gone or someone
|
|
270
|
+
-- else's by now
|
|
261
271
|
participant:_write_queued {
|
|
262
272
|
pending = pending,
|
|
263
273
|
old_tx_lock = participant._tx_lock,
|
|
264
274
|
}
|
|
265
|
-
return true
|
|
266
275
|
end
|
|
267
276
|
function patch.commit(operation)
|
|
268
277
|
participant._known_outcomes[operation.id] = "committed"
|
|
@@ -287,13 +296,16 @@ local function make_patch(participant: ProfileBase, new_data: any): TxPatch
|
|
|
287
296
|
end
|
|
288
297
|
|
|
289
298
|
--- Runs `process(ctx)` over the given profiles (any store, locked or lockless)
|
|
290
|
-
--- and commits every `ctx:set` atomically.
|
|
291
|
-
---
|
|
299
|
+
--- and commits every `ctx:set` atomically. Ok(false) when `process` returns
|
|
300
|
+
--- false, Ok(true) when committed. Fails with `tx_aborted` (its `cause` says
|
|
301
|
+
--- why phase 1 gave up), `profile_closed`, `released`, or what flushing a
|
|
302
|
+
--- lockless participant raised (`lockless_locked`, `outdated`, `roblox`, ...).
|
|
303
|
+
--- Argument misuse throws; an error thrown by `process` propagates as is.
|
|
292
304
|
function tx_handle.run(
|
|
293
305
|
config: ResolvedTransactionConfig,
|
|
294
306
|
profiles: Array<ProfileBase>,
|
|
295
307
|
process: (ctx: TxContext) -> boolean?
|
|
296
|
-
): boolean
|
|
308
|
+
): types.Result<boolean>
|
|
297
309
|
local seen: { [ProfileBase]: boolean } = {}
|
|
298
310
|
local profile_list: Array<ProfileBase> = {}
|
|
299
311
|
|
|
@@ -301,18 +313,18 @@ function tx_handle.run(
|
|
|
301
313
|
if not is_profile(profile) then
|
|
302
314
|
error "datastore: transaction expects profiles"
|
|
303
315
|
end
|
|
304
|
-
if not profile.open then
|
|
305
|
-
error "datastore: all profiles in a transaction must be open"
|
|
306
|
-
end
|
|
307
|
-
if profile.kind == "locked" and not (profile :: Profile).is_locked then
|
|
308
|
-
error(`datastore: transaction failed, profile "{profile.key}" does not hold its session lock`)
|
|
309
|
-
end
|
|
310
316
|
if seen[profile] then
|
|
311
317
|
error "datastore: a profile was provided twice in a transaction"
|
|
312
318
|
end
|
|
313
319
|
if profile.config._hook ~= config._hook then
|
|
314
320
|
error "datastore: all profiles in a transaction must share the transaction's hook"
|
|
315
321
|
end
|
|
322
|
+
if not profile.open then
|
|
323
|
+
return result.err(errors.closed(profile))
|
|
324
|
+
end
|
|
325
|
+
if profile.kind == "locked" and not (profile :: Profile).is_locked then
|
|
326
|
+
return result.err(errors.released(profile))
|
|
327
|
+
end
|
|
316
328
|
|
|
317
329
|
seen[profile] = true
|
|
318
330
|
table.insert(profile_list, profile)
|
|
@@ -323,9 +335,12 @@ function tx_handle.run(
|
|
|
323
335
|
end
|
|
324
336
|
profile_list = utils.sorted(profile_list, profile_sort)
|
|
325
337
|
|
|
326
|
-
local
|
|
338
|
+
local locked: Array<ProfileBase> = {}
|
|
327
339
|
|
|
328
|
-
local ok,
|
|
340
|
+
local ok, value = utils.try(function()
|
|
341
|
+
-- inside the protected region: a mutex closed while waiting rejects the
|
|
342
|
+
-- lock, and the ones already taken must still be given back
|
|
343
|
+
lock_all(profile_list, locked)
|
|
329
344
|
assert_open_profiles(profile_list)
|
|
330
345
|
-- kind of an oxymoron
|
|
331
346
|
adquire_lockless_lock(config, profile_list)
|
|
@@ -385,7 +400,10 @@ function tx_handle.run(
|
|
|
385
400
|
-- Phase 1: every changed participant persists its post-tx data as `pending`.
|
|
386
401
|
local order = utils.sorted(patches, prepare_sort)
|
|
387
402
|
local desired: TxOutcome = "committed"
|
|
388
|
-
|
|
403
|
+
-- why phase 1 gave up: a library error (reported as the abort's cause)
|
|
404
|
+
-- or a raw one (an internal invariant; rethrown as is after settling)
|
|
405
|
+
local cause: Error? = nil
|
|
406
|
+
local raw: any = nil
|
|
389
407
|
local expires = config._scheduler.now() + config.tx_ttl
|
|
390
408
|
local store_name = config._datastore.name
|
|
391
409
|
|
|
@@ -396,13 +414,16 @@ function tx_handle.run(
|
|
|
396
414
|
expires = expires,
|
|
397
415
|
data = patch.new_data,
|
|
398
416
|
}
|
|
399
|
-
local write_ok,
|
|
400
|
-
if write_ok
|
|
417
|
+
local write_ok, err = utils.try(patch.prepare, pending)
|
|
418
|
+
if write_ok then
|
|
401
419
|
patch.prepared = true
|
|
402
420
|
else
|
|
403
421
|
desired = "aborted"
|
|
404
|
-
|
|
405
|
-
|
|
422
|
+
if errors.is(err) then
|
|
423
|
+
cause = err
|
|
424
|
+
else
|
|
425
|
+
raw = err
|
|
426
|
+
end
|
|
406
427
|
break
|
|
407
428
|
end
|
|
408
429
|
end
|
|
@@ -414,14 +435,16 @@ function tx_handle.run(
|
|
|
414
435
|
if outcome == "committed" then
|
|
415
436
|
return true
|
|
416
437
|
end
|
|
417
|
-
|
|
438
|
+
if raw ~= nil then
|
|
439
|
+
error(raw, 0)
|
|
440
|
+
end
|
|
441
|
+
-- no cause: every prepare went through but the marker was already
|
|
442
|
+
-- resolved as aborted by another server (the pending's grace expired)
|
|
443
|
+
error(errors.tx_aborted(id, cause), 0)
|
|
418
444
|
end)
|
|
419
445
|
|
|
420
|
-
unlock_all()
|
|
421
|
-
|
|
422
|
-
error(result)
|
|
423
|
-
end
|
|
424
|
-
return result
|
|
446
|
+
unlock_all(locked)
|
|
447
|
+
return result.of(ok, value)
|
|
425
448
|
end
|
|
426
449
|
|
|
427
450
|
return tx_handle
|
package/src/tx/operation.luau
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
local errors = require "../errors"
|
|
1
2
|
local types = require "../types"
|
|
2
3
|
|
|
3
4
|
export type TxOperation = types.TxOperation
|
|
@@ -23,14 +24,16 @@ local function resolve_marker(self: TxOperation): TxOutcome
|
|
|
23
24
|
end)
|
|
24
25
|
|
|
25
26
|
if result ~= "committed" and result ~= "aborted" then
|
|
26
|
-
error(
|
|
27
|
+
error(errors.tx_marker_invalid(id, result), 0)
|
|
27
28
|
end
|
|
28
29
|
return result :: TxOutcome
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
--- Resolves the marker (blocking, retrying with backoff until the storage
|
|
32
|
-
--- answers), then applies the outcome to every patch
|
|
33
|
-
---
|
|
33
|
+
--- answers with a valid outcome), then applies the outcome to every patch
|
|
34
|
+
--- exactly once. Never fails: every participant mutex is held meanwhile, so
|
|
35
|
+
--- giving up would leave them without an outcome. Safe to call from several
|
|
36
|
+
--- coroutines: later callers wait for the first resolution.
|
|
34
37
|
function tx_operation.settle(self: TxOperation): TxOutcome
|
|
35
38
|
local scheduler = self.config._scheduler
|
|
36
39
|
|