@rbxts/dataforge 0.1.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/README.md +1 -0
- package/default.project.json +6 -0
- package/index.d.ts +423 -0
- package/package.json +32 -0
- package/src/hooks/memory.luau +186 -0
- package/src/hooks/roblox.luau +68 -0
- package/src/init.luau +58 -0
- package/src/mutex.luau +107 -0
- package/src/profile/common.luau +102 -0
- package/src/profile/lockless.luau +502 -0
- package/src/profile/peek.luau +79 -0
- package/src/profile/profile.luau +498 -0
- package/src/schedulers/roblox.luau +18 -0
- package/src/schedulers/virtual.luau +128 -0
- package/src/schedulers/zune.luau +18 -0
- package/src/store.luau +409 -0
- package/src/tx/handle.luau +427 -0
- package/src/tx/operation.luau +117 -0
- package/src/types.luau +375 -0
- package/src/utils.luau +104 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
Transaction functions. No object: everything takes a
|
|
3
|
+
`ResolvedTransactionConfig` (hook, scheduler, marker datastore, ttls) so
|
|
4
|
+
profiles and the top-level module share one config without a handle class.
|
|
5
|
+
|
|
6
|
+
This module must not require the profile modules: they require it to resolve
|
|
7
|
+
foreign `pending` entries.
|
|
8
|
+
]]
|
|
9
|
+
|
|
10
|
+
local types = require "../types"
|
|
11
|
+
local utils = require "../utils"
|
|
12
|
+
local roblox_scheduler = require "../schedulers/roblox"
|
|
13
|
+
local roblox_hook = require "../hooks/roblox"
|
|
14
|
+
local tx_operation = require "./operation"
|
|
15
|
+
|
|
16
|
+
type Array<T> = { T }
|
|
17
|
+
|
|
18
|
+
type ProfileBase<T = any> = types.ProfileBase<T>
|
|
19
|
+
type Profile<T = any> = types.Profile<T>
|
|
20
|
+
type LocklessProfile<T = any> = types.LocklessProfile<T>
|
|
21
|
+
|
|
22
|
+
type DatastoreHandle = types.DatastoreHandle
|
|
23
|
+
type TransactionConfig = types.TransactionConfig
|
|
24
|
+
type ResolvedTransactionConfig = types.ResolvedTransactionConfig
|
|
25
|
+
|
|
26
|
+
type TxOutcome = types.TxOutcome
|
|
27
|
+
type TxPatch = types.TxPatch
|
|
28
|
+
export type TxContext = types.TxContext
|
|
29
|
+
|
|
30
|
+
local tx_handle = {}
|
|
31
|
+
|
|
32
|
+
--- Name of the single, global marker datastore shared by every transaction.
|
|
33
|
+
local TX_STORE_NAME = "tx"
|
|
34
|
+
|
|
35
|
+
--- Returns the global marker datastore. Every transaction, whatever stores it
|
|
36
|
+
--- touches, writes its outcome marker here under its tx id.
|
|
37
|
+
function tx_handle.get_tx_store(hook: types.Hook): DatastoreHandle
|
|
38
|
+
return hook.get_datastore(TX_STORE_NAME)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
function tx_handle.store_name(): string
|
|
42
|
+
return TX_STORE_NAME
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
--- Compare-and-set the marker for `id` in `datastore`: keeps an existing
|
|
46
|
+
--- outcome, otherwise stores `fallback`. Returns the outcome now on record.
|
|
47
|
+
local function resolve_marker_in(
|
|
48
|
+
config: types.RetryConfig,
|
|
49
|
+
datastore: DatastoreHandle,
|
|
50
|
+
id: string,
|
|
51
|
+
fallback: TxOutcome
|
|
52
|
+
): TxOutcome
|
|
53
|
+
local result = utils.retry(config, function()
|
|
54
|
+
return datastore.update_async(id, function(current: any?)
|
|
55
|
+
if current == "committed" or current == "aborted" then
|
|
56
|
+
return current
|
|
57
|
+
end
|
|
58
|
+
return fallback
|
|
59
|
+
end)
|
|
60
|
+
end)
|
|
61
|
+
|
|
62
|
+
if result ~= "committed" and result ~= "aborted" then
|
|
63
|
+
error(`datastore: unexpected tx marker value for {id}: {tostring(result)}`)
|
|
64
|
+
end
|
|
65
|
+
return result :: TxOutcome
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
--- Reads the marker for `id`, writing `fallback` if it has not been decided yet.
|
|
69
|
+
function tx_handle.resolve_marker(config: ResolvedTransactionConfig, id: string, fallback: TxOutcome): TxOutcome
|
|
70
|
+
return resolve_marker_in(config, config._datastore, id, fallback)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
--- Resolves a `pending` found on a record. With `force = false` a pending
|
|
74
|
+
--- that has not reached its `expires` is left alone (returns nil) so a live
|
|
75
|
+
--- coordinator gets the chance to commit; otherwise the marker is CAS-resolved
|
|
76
|
+
--- with fallback "aborted". Works with a store config or a transaction config:
|
|
77
|
+
--- only their hook, scheduler and retry policy are used.
|
|
78
|
+
function tx_handle.resolve_pending(
|
|
79
|
+
hook: types.Hook,
|
|
80
|
+
config: types.RetryConfig,
|
|
81
|
+
pending: types.Pending,
|
|
82
|
+
force: boolean
|
|
83
|
+
): TxOutcome?
|
|
84
|
+
if not force and config._scheduler.now() < (pending.expires or 0) then
|
|
85
|
+
return nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
-- `pending.store` names the marker datastore the coordinator used; honour it
|
|
89
|
+
-- rather than assuming it matches ours.
|
|
90
|
+
local datastore = hook.get_datastore(pending.store)
|
|
91
|
+
return resolve_marker_in(config, datastore, pending.tx_id, "aborted")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
--- One counter per process: transaction ids are `{job_id}-{n}` and must not
|
|
95
|
+
--- repeat across transactions, whatever config each one resolved.
|
|
96
|
+
local tx_counter = utils.counter()
|
|
97
|
+
|
|
98
|
+
--- Fills the transaction config with defaults. `defaults` (a store's hook and
|
|
99
|
+
--- scheduler, typically) wins over the Roblox fallbacks, `config` over both.
|
|
100
|
+
function tx_handle.resolve_config(config: TransactionConfig?, defaults: TransactionConfig?): ResolvedTransactionConfig
|
|
101
|
+
local given: TransactionConfig = config or {}
|
|
102
|
+
assert(type(given) == "table", "datastore: transaction config must be a table")
|
|
103
|
+
local fallback: TransactionConfig = defaults or {}
|
|
104
|
+
|
|
105
|
+
local hook = given._hook or fallback._hook
|
|
106
|
+
local scheduler = given._scheduler or fallback._scheduler
|
|
107
|
+
|
|
108
|
+
if not hook and game then
|
|
109
|
+
hook = roblox_hook.create()
|
|
110
|
+
end
|
|
111
|
+
if not scheduler and game and task then
|
|
112
|
+
scheduler = roblox_scheduler.create()
|
|
113
|
+
end
|
|
114
|
+
assert(hook, "datastore: transaction config needs a hook outside Roblox")
|
|
115
|
+
assert(scheduler, "datastore: transaction config needs a scheduler outside Roblox")
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
_hook = hook,
|
|
119
|
+
_scheduler = scheduler,
|
|
120
|
+
_datastore = given._datastore or fallback._datastore or tx_handle.get_tx_store(hook),
|
|
121
|
+
_counter = tx_counter,
|
|
122
|
+
|
|
123
|
+
tx_ttl = given.tx_ttl or fallback.tx_ttl or 60,
|
|
124
|
+
lock_ttl = given.lock_ttl or fallback.lock_ttl or 10,
|
|
125
|
+
retry_attempts = given.retry_attempts or fallback.retry_attempts or 5,
|
|
126
|
+
retry_base = given.retry_base or fallback.retry_base or 1,
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
--- Structural check (no require of the profile modules, see header).
|
|
131
|
+
local function is_profile(unknown: any): boolean
|
|
132
|
+
if type(unknown) ~= "table" then
|
|
133
|
+
return false
|
|
134
|
+
end
|
|
135
|
+
return (unknown.kind == "locked" or unknown.kind == "lockless") and unknown._mutex ~= nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
local function sort_key(participant: ProfileBase): string
|
|
139
|
+
return participant.config.name .. "\0" .. participant.key
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
local function profile_sort(a: ProfileBase, b: ProfileBase): boolean
|
|
143
|
+
return sort_key(a) < sort_key(b)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
--- Phase 1 order: locked participants first. A lockless key is held under a
|
|
147
|
+
--- short transaction lock until its pending is written, so keep that window short.
|
|
148
|
+
local function prepare_sort(a: TxPatch, b: TxPatch): boolean
|
|
149
|
+
local a_lockless = a.profile.kind == "lockless"
|
|
150
|
+
local b_lockless = b.profile.kind == "lockless"
|
|
151
|
+
if a_lockless ~= b_lockless then
|
|
152
|
+
return not a_lockless
|
|
153
|
+
end
|
|
154
|
+
return sort_key(a.profile) < sort_key(b.profile)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
--- Locks every profile mutex in order. The returned function releases them in
|
|
158
|
+
--- reverse and, for a lockless profile still holding its storage transaction
|
|
159
|
+
--- lock (the transaction ended before writing `pending`), gives the key back.
|
|
160
|
+
local function lock_profiles(profiles: Array<ProfileBase>)
|
|
161
|
+
for _, participant in profiles do
|
|
162
|
+
participant._mutex:lock()
|
|
163
|
+
end
|
|
164
|
+
return function()
|
|
165
|
+
for index = #profiles, 1, -1 do
|
|
166
|
+
local participant = profiles[index]
|
|
167
|
+
local lockless = participant :: LocklessProfile
|
|
168
|
+
if participant.kind == "lockless" and lockless._tx_lock then
|
|
169
|
+
local ok, err = utils.try(function()
|
|
170
|
+
lockless:_write_queued {
|
|
171
|
+
old_tx_lock = lockless._tx_lock,
|
|
172
|
+
}
|
|
173
|
+
end)
|
|
174
|
+
if not ok then
|
|
175
|
+
participant.config._hook.warn(
|
|
176
|
+
`datastore: failed to release tx lock on "{participant.key}" (it will expire): {tostring(err)}`
|
|
177
|
+
)
|
|
178
|
+
lockless._tx_lock = nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
participant._mutex:unlock()
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
local function assert_open_profiles(profiles: Array<ProfileBase>)
|
|
187
|
+
for _, participant in profiles do
|
|
188
|
+
if not participant.open then
|
|
189
|
+
error(`datastore: transaction failed, profile "{participant.key}" is closed`)
|
|
190
|
+
end
|
|
191
|
+
if participant.kind == "locked" and not (participant :: Profile).is_locked then
|
|
192
|
+
error(`datastore: transaction failed, profile "{participant.key}" does not hold its session lock`)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
--- Flushes every lockless participant and leaves a short transaction lock on
|
|
198
|
+
--- its record (see `lock_ttl`), so the snapshot taken next is exact.
|
|
199
|
+
local function adquire_lockless_lock(config: ResolvedTransactionConfig, profiles: Array<ProfileBase>)
|
|
200
|
+
for _, participant in profiles do
|
|
201
|
+
if participant.kind == "lockless" then
|
|
202
|
+
(participant :: LocklessProfile):_write_queued {
|
|
203
|
+
tx_lock = {
|
|
204
|
+
id = `{config._hook.job_id()}-{config._counter()}`,
|
|
205
|
+
expires = config._scheduler.now() + config.lock_ttl,
|
|
206
|
+
tx = true,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
local function get_snapshots(profiles: Array<ProfileBase>)
|
|
214
|
+
local snapshots: { [ProfileBase]: any } = {}
|
|
215
|
+
|
|
216
|
+
for _, participant in profiles do
|
|
217
|
+
-- the mutex is held and lockless participants were just flushed, so `_data`
|
|
218
|
+
-- is current; not `get_data()`: an unload queued behind this transaction
|
|
219
|
+
-- has already flipped `open` and must not abort it
|
|
220
|
+
snapshots[participant] = participant._data
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
return snapshots
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
local function locked_patch(participant: Profile, new_data: any): TxPatch
|
|
227
|
+
local patch = {} :: TxPatch
|
|
228
|
+
patch.profile = participant
|
|
229
|
+
patch.new_data = new_data
|
|
230
|
+
patch.prepared = false
|
|
231
|
+
|
|
232
|
+
function patch.prepare(pending)
|
|
233
|
+
return participant:_write_record { release = false, pending = pending }
|
|
234
|
+
end
|
|
235
|
+
function patch.commit(operation)
|
|
236
|
+
table.insert(participant._cleanup_txs, operation)
|
|
237
|
+
-- always, even when an unload queued behind this transaction has already
|
|
238
|
+
-- flipped `open`: its release write must carry the committed data
|
|
239
|
+
participant:_set_data(new_data)
|
|
240
|
+
participant._dirty = true
|
|
241
|
+
end
|
|
242
|
+
function patch.abort(_)
|
|
243
|
+
if patch.prepared then
|
|
244
|
+
-- record still carries `pending`; next save writes it clean
|
|
245
|
+
participant._dirty = true
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
return patch
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
local function lockless_patch(participant: LocklessProfile, new_data: any): TxPatch
|
|
253
|
+
local patch = {} :: TxPatch
|
|
254
|
+
patch.profile = participant
|
|
255
|
+
patch.new_data = new_data
|
|
256
|
+
patch.prepared = false
|
|
257
|
+
|
|
258
|
+
function patch.prepare(pending)
|
|
259
|
+
-- releases the transaction lock taken in `adquire_lockless_lock`; fails
|
|
260
|
+
-- (nothing written) if that lock is gone or someone else's by now
|
|
261
|
+
participant:_write_queued {
|
|
262
|
+
pending = pending,
|
|
263
|
+
old_tx_lock = participant._tx_lock,
|
|
264
|
+
}
|
|
265
|
+
return true
|
|
266
|
+
end
|
|
267
|
+
function patch.commit(operation)
|
|
268
|
+
participant._known_outcomes[operation.id] = "committed"
|
|
269
|
+
table.insert(participant._cleanup_txs, operation)
|
|
270
|
+
-- storage still holds old data + pending; the local data moves on now
|
|
271
|
+
participant:_set_data(new_data)
|
|
272
|
+
participant.dirty_pending = true
|
|
273
|
+
end
|
|
274
|
+
function patch.abort(operation)
|
|
275
|
+
participant._known_outcomes[operation.id] = "aborted"
|
|
276
|
+
participant.dirty_pending = patch.prepared
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
return patch
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
local function make_patch(participant: ProfileBase, new_data: any): TxPatch
|
|
283
|
+
if participant.kind == "locked" then
|
|
284
|
+
return locked_patch(participant :: Profile, new_data)
|
|
285
|
+
end
|
|
286
|
+
return lockless_patch(participant :: LocklessProfile, new_data)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
--- Runs `process(ctx)` over the given profiles (any store, locked or lockless)
|
|
290
|
+
--- and commits every `ctx:set` atomically. Returns false when `process`
|
|
291
|
+
--- returns false. Throws when the transaction aborts.
|
|
292
|
+
function tx_handle.run(
|
|
293
|
+
config: ResolvedTransactionConfig,
|
|
294
|
+
profiles: Array<ProfileBase>,
|
|
295
|
+
process: (ctx: TxContext) -> boolean?
|
|
296
|
+
): boolean
|
|
297
|
+
local seen: { [ProfileBase]: boolean } = {}
|
|
298
|
+
local profile_list: Array<ProfileBase> = {}
|
|
299
|
+
|
|
300
|
+
for _, profile in profiles do
|
|
301
|
+
if not is_profile(profile) then
|
|
302
|
+
error "datastore: transaction expects profiles"
|
|
303
|
+
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
|
+
if seen[profile] then
|
|
311
|
+
error "datastore: a profile was provided twice in a transaction"
|
|
312
|
+
end
|
|
313
|
+
if profile.config._hook ~= config._hook then
|
|
314
|
+
error "datastore: all profiles in a transaction must share the transaction's hook"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
seen[profile] = true
|
|
318
|
+
table.insert(profile_list, profile)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if #profile_list == 0 then
|
|
322
|
+
error "datastore: transactions need at least one profile"
|
|
323
|
+
end
|
|
324
|
+
profile_list = utils.sorted(profile_list, profile_sort)
|
|
325
|
+
|
|
326
|
+
local unlock_all = lock_profiles(profile_list)
|
|
327
|
+
|
|
328
|
+
local ok, result: boolean = utils.try(function()
|
|
329
|
+
assert_open_profiles(profile_list)
|
|
330
|
+
-- kind of an oxymoron
|
|
331
|
+
adquire_lockless_lock(config, profile_list)
|
|
332
|
+
|
|
333
|
+
local snapshots = get_snapshots(profile_list)
|
|
334
|
+
local staged: { [ProfileBase]: any } = {}
|
|
335
|
+
|
|
336
|
+
local ctx = {} :: TxContext
|
|
337
|
+
|
|
338
|
+
function ctx.get(_, participant)
|
|
339
|
+
return snapshots[participant]
|
|
340
|
+
end
|
|
341
|
+
function ctx.set(_, participant, new_value)
|
|
342
|
+
if new_value == nil then
|
|
343
|
+
error "datastore: ctx:set requires a value"
|
|
344
|
+
end
|
|
345
|
+
staged[participant] = new_value
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
if process(ctx) == false then
|
|
349
|
+
return false
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
local patches: { TxPatch } = {}
|
|
353
|
+
|
|
354
|
+
for _, participant in profile_list do
|
|
355
|
+
local new_value = staged[participant]
|
|
356
|
+
if new_value ~= nil and new_value ~= snapshots[participant] then
|
|
357
|
+
table.insert(patches, make_patch(participant, utils.deep_freeze(new_value)))
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
if #patches == 0 then
|
|
362
|
+
return true
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
if #patches == 1 then
|
|
366
|
+
-- single key can just write to data and it will be already atomic
|
|
367
|
+
local new_data = patches[1].new_data
|
|
368
|
+
local participant = patches[1].profile
|
|
369
|
+
if participant.kind == "locked" then
|
|
370
|
+
(participant :: Profile):_set_data(new_data);
|
|
371
|
+
(participant :: Profile)._dirty = true
|
|
372
|
+
elseif participant.kind == "lockless" then
|
|
373
|
+
(participant :: LocklessProfile):_queue_update(function()
|
|
374
|
+
return new_data
|
|
375
|
+
end)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
return true
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
-- multi key: two-phase commit through the marker
|
|
382
|
+
local id = `{config._hook.job_id()}-{config._counter()}`
|
|
383
|
+
local operation = tx_operation.create(id, config, patches)
|
|
384
|
+
|
|
385
|
+
-- Phase 1: every changed participant persists its post-tx data as `pending`.
|
|
386
|
+
local order = utils.sorted(patches, prepare_sort)
|
|
387
|
+
local desired: TxOutcome = "committed"
|
|
388
|
+
local reason = ""
|
|
389
|
+
local expires = config._scheduler.now() + config.tx_ttl
|
|
390
|
+
local store_name = config._datastore.name
|
|
391
|
+
|
|
392
|
+
for _, patch in order do
|
|
393
|
+
local pending: types.Pending = {
|
|
394
|
+
tx_id = id,
|
|
395
|
+
store = store_name,
|
|
396
|
+
expires = expires,
|
|
397
|
+
data = patch.new_data,
|
|
398
|
+
}
|
|
399
|
+
local write_ok, prepared = utils.try(patch.prepare, pending)
|
|
400
|
+
if write_ok and prepared == true then
|
|
401
|
+
patch.prepared = true
|
|
402
|
+
else
|
|
403
|
+
desired = "aborted"
|
|
404
|
+
local key = patch.profile.key
|
|
405
|
+
reason = if not write_ok then tostring(prepared) else `lock lost for "{key}"`
|
|
406
|
+
break
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
operation.desired = desired
|
|
410
|
+
|
|
411
|
+
-- Phase 2: compare-and-set the marker. Whatever it says is the truth;
|
|
412
|
+
-- settle applies it to every participant (in memory) exactly once.
|
|
413
|
+
local outcome = operation:settle()
|
|
414
|
+
if outcome == "committed" then
|
|
415
|
+
return true
|
|
416
|
+
end
|
|
417
|
+
error(`datastore: transaction aborted: {reason}`)
|
|
418
|
+
end)
|
|
419
|
+
|
|
420
|
+
unlock_all()
|
|
421
|
+
if not ok then
|
|
422
|
+
error(result)
|
|
423
|
+
end
|
|
424
|
+
return result
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
return tx_handle
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
local types = require "../types"
|
|
2
|
+
|
|
3
|
+
export type TxOperation = types.TxOperation
|
|
4
|
+
type ResolvedTransactionConfig = types.ResolvedTransactionConfig
|
|
5
|
+
type TxOutcome = types.TxOutcome
|
|
6
|
+
|
|
7
|
+
local tx_operation = {}
|
|
8
|
+
tx_operation.__index = tx_operation
|
|
9
|
+
|
|
10
|
+
--- Compare-and-set the marker for this operation: keeps an existing outcome,
|
|
11
|
+
--- otherwise stores `desired`. Retries through the config's retry policy.
|
|
12
|
+
local function resolve_marker(self: TxOperation): TxOutcome
|
|
13
|
+
local config = self.config
|
|
14
|
+
local datastore = config._datastore
|
|
15
|
+
local id = self.id
|
|
16
|
+
local desired = self.desired
|
|
17
|
+
|
|
18
|
+
local result = datastore.update_async(id, function(current: any?)
|
|
19
|
+
if current == "committed" or current == "aborted" then
|
|
20
|
+
return current
|
|
21
|
+
end
|
|
22
|
+
return desired
|
|
23
|
+
end)
|
|
24
|
+
|
|
25
|
+
if result ~= "committed" and result ~= "aborted" then
|
|
26
|
+
error(`datastore: unexpected tx marker value for {id}: {tostring(result)}`)
|
|
27
|
+
end
|
|
28
|
+
return result :: TxOutcome
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
--- Resolves the marker (blocking, retrying with backoff until the storage
|
|
32
|
+
--- answers), then applies the outcome to every patch exactly once. Safe to call
|
|
33
|
+
--- from several coroutines: later callers wait for the first resolution.
|
|
34
|
+
function tx_operation.settle(self: TxOperation): TxOutcome
|
|
35
|
+
local scheduler = self.config._scheduler
|
|
36
|
+
|
|
37
|
+
if self.outcome == nil then
|
|
38
|
+
if self._resolving then
|
|
39
|
+
table.insert(self._waiters, coroutine.running())
|
|
40
|
+
coroutine.yield()
|
|
41
|
+
else
|
|
42
|
+
self._resolving = true
|
|
43
|
+
local backoff = 1
|
|
44
|
+
while self.outcome == nil do
|
|
45
|
+
local ok, result = pcall(resolve_marker, self)
|
|
46
|
+
if ok then
|
|
47
|
+
self.outcome = result :: TxOutcome
|
|
48
|
+
else
|
|
49
|
+
scheduler.wait(backoff)
|
|
50
|
+
backoff = math.min(backoff * 2, 30)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
self._resolving = false
|
|
54
|
+
|
|
55
|
+
local waiters = self._waiters
|
|
56
|
+
self._waiters = {}
|
|
57
|
+
for _, waiter in waiters do
|
|
58
|
+
scheduler.spawn(waiter)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
local outcome: TxOutcome = self.outcome :: any
|
|
64
|
+
if not self._applied then
|
|
65
|
+
self._applied = true
|
|
66
|
+
local committed = outcome == "committed"
|
|
67
|
+
for _, patch in self.patches do
|
|
68
|
+
if committed then
|
|
69
|
+
patch.commit(self)
|
|
70
|
+
else
|
|
71
|
+
patch.abort(self)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
return outcome
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
--- Called by a participant after it wrote a clean record (no `pending`). Once
|
|
79
|
+
--- every participant is clean nothing references the marker and it can go.
|
|
80
|
+
function tx_operation.cleaned(self: TxOperation)
|
|
81
|
+
self._unclean -= 1
|
|
82
|
+
if self._unclean > 0 then
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
local remove = self.config._datastore.remove_async
|
|
87
|
+
if remove == nil then
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
local id = self.id
|
|
92
|
+
self.config._scheduler.spawn(function()
|
|
93
|
+
pcall(remove, id)
|
|
94
|
+
end)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
--- The outcome marker lives in `config._datastore` under `id`.
|
|
98
|
+
function tx_operation.create(id: string, config: ResolvedTransactionConfig, patches: { types.TxPatch }): TxOperation
|
|
99
|
+
local self = {} :: TxOperation
|
|
100
|
+
|
|
101
|
+
self.id = id
|
|
102
|
+
self.config = config
|
|
103
|
+
self.desired = "aborted"
|
|
104
|
+
self.outcome = nil
|
|
105
|
+
self.patches = patches
|
|
106
|
+
|
|
107
|
+
self._resolving = false
|
|
108
|
+
self._applied = false
|
|
109
|
+
self._waiters = {}
|
|
110
|
+
self._unclean = #patches
|
|
111
|
+
|
|
112
|
+
return setmetatable(self, tx_operation) :: any
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
create = tx_operation.create,
|
|
117
|
+
}
|