@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.
@@ -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
+ }