@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,502 @@
1
+ local common = require "./common"
2
+ local mutex = require "../mutex"
3
+ local tx_handle = require "../tx/handle"
4
+ local types = require "../types"
5
+ local utils = require "../utils"
6
+
7
+ type Array<T> = { T }
8
+ type LoadOptions = types.LoadOptions
9
+ type DataRecord = types.DataRecord
10
+ export type LocklessProfile<T = any> = types.LocklessProfile<T>
11
+
12
+ local lockless_profile = {}
13
+ lockless_profile.__index = lockless_profile
14
+
15
+ function lockless_profile.get_data(self: LocklessProfile)
16
+ common.assert_open(self)
17
+ if not self.fetched then
18
+ error(`datastore: lockless profile "{self.key}" has not been fetched`)
19
+ end
20
+ return self._data
21
+ end
22
+
23
+ --- The data a record stands for once its `pending` (if any) is accounted for:
24
+ --- `pending.data` when that transaction is known to have committed, the plain
25
+ --- data otherwise. Returns nil when the outcome is not known yet; pure, so it
26
+ --- can run inside a transform (the caller resolves the marker outside).
27
+ local function pending_data(self: LocklessProfile, record: DataRecord): (boolean, any)
28
+ local pending = record.pending
29
+ if not pending then
30
+ return true, record.data
31
+ end
32
+
33
+ local outcome = self._known_outcomes[pending.tx_id]
34
+ if outcome == nil then
35
+ return false, nil
36
+ end
37
+ if outcome == "committed" then
38
+ return true, pending.data
39
+ end
40
+ return true, record.data
41
+ end
42
+
43
+ --- Learns the outcome of a foreign `pending`. Until the pending's `expires` it
44
+ --- is given time (its coordinator may still be running); after that the marker
45
+ --- is force-resolved with fallback "aborted". Yields, so never inside a transform.
46
+ local function resolve_unknown_pending(self: LocklessProfile, pending: types.Pending)
47
+ local config = self.config
48
+ while true do
49
+ local outcome: types.TxOutcome? = tx_handle.resolve_pending(config._hook, config, pending, false)
50
+ if outcome ~= nil then
51
+ self._known_outcomes[pending.tx_id] = outcome :: types.TxOutcome
52
+ return
53
+ end
54
+ config._scheduler.wait(config.load_poll)
55
+ end
56
+ end
57
+
58
+ local function resolve_record(self: LocklessProfile, record: DataRecord?, data: any): (any, { string })
59
+ local config = self.config
60
+ local applied: { string } = {}
61
+
62
+ if record then
63
+ applied = record.migrations or {}
64
+ end
65
+
66
+ if data == nil then
67
+ return utils.deep_copy(config.template), common.all_migration_names(config.migrations)
68
+ end
69
+
70
+ local result = common.apply_migrations(data, applied, config.migrations)
71
+ if not result.success then
72
+ error(`datastore: failed to apply migrations for "{self.key}": {result.err}`)
73
+ end
74
+ return result.result, result.new_migrations
75
+ end
76
+
77
+ local function replay(queue: Array<types.DataDispatcher<any>>, data: any): any
78
+ for _, dispatcher in queue do
79
+ local result = dispatcher(data)
80
+ if result ~= nil and result ~= false then
81
+ data = result
82
+ end
83
+ end
84
+ return data
85
+ end
86
+
87
+ local function fire_hooks<T...>(self: LocklessProfile, name: string, hooks: Array<(T...) -> ()>, ...: T...)
88
+ for _, hook in hooks do
89
+ local ok, err = utils.try(hook, ...)
90
+ if not ok then
91
+ self.config._hook.warn(`datastore: hook {name} failed for {self.key}: {tostring(err)}`)
92
+ end
93
+ end
94
+ end
95
+
96
+ local function set_data(self: LocklessProfile, data: any)
97
+ local old = self._data
98
+ local new = utils.deep_freeze(data)
99
+ self._data = new
100
+ fire_hooks(self, "change", self._hooks.change, new, old)
101
+ end
102
+
103
+ --- A session lock showed up on the record: a locked profile owns the key now
104
+ --- and this profile lost its lockless standing. Closes it (no write; the
105
+ --- queue stays for the error message). Caller holds the mutex.
106
+ local function process_lock_lost(self: LocklessProfile)
107
+ if not self.open then
108
+ return
109
+ end
110
+ self.open = false
111
+ self._mutex:close(`datastore: profile "{self.key}" is session locked`)
112
+ fire_hooks(self, "lock_lost", self._hooks.lock_lost)
113
+ fire_hooks(self, "closed", self._hooks.closed)
114
+ end
115
+
116
+ type SaveState = {
117
+ applied: boolean,
118
+ --- a live transaction lock is on the record; `tx_lock_expires` says until when
119
+ tx_locked: boolean,
120
+ tx_lock_expires: number,
121
+ --- a session lock is on the record
122
+ session_locked: boolean,
123
+ tx_lock_lost: boolean,
124
+ outdated: boolean,
125
+ unknown_pending: types.Pending?,
126
+ }
127
+
128
+ type WriteOptions = types.LocklessWriteOptions
129
+
130
+ --- Folds the queued updates into storage with one update_async: the stored
131
+ --- data (whatever any server left there) goes through migrations, then every
132
+ --- queued update, and what lands becomes the local data. Caller holds the mutex.
133
+ ---
134
+ --- `options.tx_lock` leaves a short transaction lock on the record (kept in
135
+ --- `self._tx_lock`). `options.old_tx_lock` is the lock this write expects to
136
+ --- find on the record: the write only goes through (and clears that lock) when
137
+ --- 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 "lock_lost".
139
+ local function write_queued(self: LocklessProfile, options: WriteOptions)
140
+ local config = self.config
141
+ local scheduler = config._scheduler
142
+ local datastore = self._datastore
143
+ local queue = self.queued_updates
144
+ local old_tx_lock = options.old_tx_lock
145
+ local new_lock = options.tx_lock
146
+
147
+ local state: SaveState = {
148
+ applied = false,
149
+ tx_locked = false,
150
+ tx_lock_expires = 0,
151
+ tx_lock_lost = false,
152
+ session_locked = false,
153
+ outdated = false,
154
+ unknown_pending = nil,
155
+ }
156
+
157
+ local function transform(old: DataRecord?): DataRecord?
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
+
165
+ local lock = if old then old.lock else nil
166
+
167
+ -- a live session lock means a locked profile owns this key now: the
168
+ -- profile lost its lockless standing, whatever else it expected on the
169
+ -- record. An expired one belongs to a dead server and is written over.
170
+ if lock and not lock.tx then
171
+ if lock.expires > scheduler.now() then
172
+ state.session_locked = true
173
+ return nil
174
+ end
175
+ lock = nil
176
+ end
177
+
178
+ if old_tx_lock then
179
+ -- only our own transaction lock may be replaced; anything else
180
+ -- (no lock, or a different one) means we no longer hold the key
181
+ if not lock or lock.id ~= old_tx_lock.id then
182
+ state.tx_lock_lost = true
183
+ return nil
184
+ end
185
+ elseif lock and lock.expires > scheduler.now() then
186
+ -- a live transaction lock (another server's, or a stale one of ours)
187
+ -- blocks the write until it is released or expires; the caller waits
188
+ state.tx_locked = true
189
+ state.tx_lock_expires = lock.expires
190
+ return nil
191
+ end
192
+
193
+ -- more migrations applied than this server declares: a newer server
194
+ -- already touched the record, so this server must not write to it
195
+ if old and old.migrations and #old.migrations > #config.migrations then
196
+ state.outdated = true
197
+ return nil
198
+ end
199
+
200
+ -- a `pending` left by some transaction (ours after commit, or another
201
+ -- server's) decides which data this write builds on
202
+ local base: any = nil
203
+ if old then
204
+ local known, folded = pending_data(self, old)
205
+ if not known then
206
+ state.unknown_pending = old.pending
207
+ return nil
208
+ end
209
+ base = folded
210
+ end
211
+
212
+ local data, migrations = resolve_record(self, old, base)
213
+ data = replay(queue, data)
214
+
215
+ state.applied = true
216
+
217
+ return {
218
+ data = data,
219
+ migrations = migrations,
220
+ pending = options.pending,
221
+ lock = new_lock,
222
+ }
223
+ end
224
+
225
+ while true do
226
+ local record = utils.retry(config, function()
227
+ return datastore.update_async(self.key, transform, self.user_ids)
228
+ end) :: DataRecord
229
+
230
+ if state.session_locked then
231
+ self._tx_lock = nil
232
+ process_lock_lost(self)
233
+ error(`datastore: "{self.key}" is session locked`)
234
+ end
235
+
236
+ if state.tx_lock_lost then
237
+ -- whatever is on the record now is not ours; do not touch it again
238
+ self._tx_lock = nil
239
+ process_lock_lost(self)
240
+ error(`datastore: lock_lost for "{self.key}"`)
241
+ end
242
+
243
+ if state.tx_locked then
244
+ -- wait for the transaction to release the key (or its lock to expire)
245
+ scheduler.wait(math.min(config.load_poll, math.max(state.tx_lock_expires - scheduler.now(), 0)))
246
+ continue
247
+ end
248
+
249
+ if state.outdated then
250
+ error(
251
+ `datastore: "{self.key}" was written by a newer server (record has {#record.migrations} applied migrations, this server declares {#config.migrations})`
252
+ )
253
+ end
254
+
255
+ local unknown = state.unknown_pending
256
+ if unknown then
257
+ -- learn the outcome (may wait for the coordinator), then write again
258
+ resolve_unknown_pending(self, unknown)
259
+ continue
260
+ end
261
+
262
+ if not state.applied then
263
+ error(`datastore: unexpected cancelled write for "{self.key}"`)
264
+ end
265
+
266
+ -- the queue is now in storage; the local view restarts from what landed.
267
+ -- Whatever `pending` the record carried was folded in, so its outcome is
268
+ -- no longer needed (a pending written now gets its outcome at settle).
269
+ self._known_outcomes = {}
270
+ self._tx_lock = new_lock
271
+ self.queued_updates = {}
272
+ self.dirty_pending = options.pending ~= nil
273
+ self.migrations = record.migrations
274
+ set_data(self, record.data)
275
+ self.fetched = true
276
+
277
+ if options.pending == nil then
278
+ -- the record is clean: every transaction waiting on that can drop its marker
279
+ local cleanups = self._cleanup_txs
280
+ self._cleanup_txs = {}
281
+ for _, operation in cleanups do
282
+ operation:cleaned()
283
+ end
284
+ end
285
+ return
286
+ end
287
+ end
288
+
289
+ --- Inserts `dispatcher` into the queue (no storage call; it lands on the next
290
+ --- save / flush / unload). When fetched, the local data is advanced right away.
291
+ --- Caller holds the mutex.
292
+ local function queue_update(self: LocklessProfile, dispatcher: types.DataDispatcher<any>): boolean
293
+ if not self.fetched then
294
+ table.insert(self.queued_updates, dispatcher)
295
+ return true
296
+ end
297
+
298
+ local result = dispatcher(self._data)
299
+ if result == nil or result == false then
300
+ return false
301
+ end
302
+
303
+ table.insert(self.queued_updates, dispatcher)
304
+ set_data(self, result)
305
+ return true
306
+ end
307
+
308
+ --- Applies every queued update to the stored data in one update_async and
309
+ --- refreshes the local data with the result. Marks the profile fetched.
310
+ --- Throws (keeping the queue) if another server holds a live session lock, or
311
+ --- if the record was already written by a newer server (more migrations applied
312
+ --- than this server declares). Neither case is retried.
313
+ function lockless_profile.save(self: LocklessProfile)
314
+ common.assert_open(self)
315
+ -- before the mutex so hooks can still queue updates that land in this save
316
+ fire_hooks(self, "save", self._hooks.save)
317
+ common.assert_open(self)
318
+ self._mutex:with(function()
319
+ write_queued(self, {})
320
+ end)
321
+ end
322
+
323
+ function lockless_profile.unload(self: LocklessProfile)
324
+ if not self.open then
325
+ return
326
+ end
327
+ -- last chance to queue updates while the profile is still open
328
+ fire_hooks(self, "closing", self._hooks.closing)
329
+
330
+ self.open = false
331
+ self._mutex:lock()
332
+ -- everything still waiting on the mutex (and anything after) is rejected
333
+ self._mutex:close(`datastore: profile "{self.key}" is closed`)
334
+ local ok, err = utils.try(function()
335
+ if #self.queued_updates > 0 or self.dirty_pending then
336
+ write_queued(self, {})
337
+ end
338
+ end)
339
+ self._mutex:unlock()
340
+ if not ok then
341
+ self.config._hook.warn(`datastore: failed to flush "{self.key}" on unload ({#self.queued_updates} changes lost): {tostring(err)}`)
342
+ end
343
+ fire_hooks(self, "closed", self._hooks.closed)
344
+ end
345
+
346
+ function lockless_profile.on_change(self: LocklessProfile, callback: (new: any, old: any?) -> ())
347
+ table.insert(self._hooks.change, callback)
348
+ end
349
+
350
+ function lockless_profile.on_save(self: LocklessProfile, callback: () -> ())
351
+ table.insert(self._hooks.save, callback)
352
+ end
353
+
354
+ function lockless_profile.on_closing(self: LocklessProfile, callback: () -> ())
355
+ table.insert(self._hooks.closing, callback)
356
+ end
357
+
358
+ function lockless_profile.on_closed(self: LocklessProfile, callback: () -> ())
359
+ table.insert(self._hooks.closed, callback)
360
+ end
361
+
362
+ --- Fires when a session lock shows up on the record: a locked profile owns
363
+ --- the key now, this profile is closed.
364
+ function lockless_profile.on_lock_lost(self: LocklessProfile, callback: () -> ())
365
+ table.insert(self._hooks.lock_lost, callback)
366
+ end
367
+
368
+ --- Yields until no operation holds or waits on the profile mutex. Once this
369
+ --- returns, the next update call is guaranteed not to yield (nothing is queued
370
+ --- ahead of it).
371
+ function lockless_profile.wait_settled(self: LocklessProfile)
372
+ return self._mutex:wait_settled()
373
+ end
374
+
375
+ --- Reads the stored record (get_async) into the local data: migrations are
376
+ --- applied, then the queued updates replayed on top, so `get_data()` reflects the
377
+ --- local changes still waiting for a save. Marks the profile fetched. Throws
378
+ --- (keeping the queue) if any session lock is on the record. Caller holds the mutex.
379
+ local function read_record(self: LocklessProfile)
380
+ local config = self.config
381
+ local datastore = self._datastore
382
+
383
+ local record = utils.retry(config, function()
384
+ return datastore.get_async(self.key)
385
+ end) :: DataRecord?
386
+
387
+ local lock = if record then record.lock else nil
388
+ if lock and not lock.tx and lock.expires > config._scheduler.now() then
389
+ process_lock_lost(self)
390
+ error(`datastore: "{self.key}" is session locked`)
391
+ end
392
+ -- an expired session lock belongs to a dead server: ignored, the next write clears it
393
+ -- a transaction lock does not matter for a read: the data under it is the
394
+ -- data until that transaction's `pending` lands (handled below)
395
+
396
+ -- a `pending` on the record: find out whether it committed (the record is
397
+ -- left as is; the next write folds it in)
398
+ local base: any = nil
399
+ if record then
400
+ local known, folded = pending_data(self, record)
401
+ if not known then
402
+ resolve_unknown_pending(self, record.pending :: types.Pending)
403
+ known, folded = pending_data(self, record)
404
+ end
405
+ base = folded
406
+ end
407
+
408
+ local data, migrations = resolve_record(self, record, base)
409
+ data = replay(self.queued_updates, data)
410
+
411
+ self.migrations = migrations
412
+ set_data(self, data)
413
+ self.fetched = true
414
+ return self._data
415
+ end
416
+
417
+ function lockless_profile.fetch(self: LocklessProfile)
418
+ common.assert_open(self)
419
+ return self._mutex:with(function()
420
+ return read_record(self)
421
+ end)
422
+ end
423
+
424
+ --- Queues `dispatcher` to run against the stored data on the next save. When
425
+ --- the profile is fetched, the local data is advanced right away so `get_data()`
426
+ --- 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 the mutex.
428
+ function lockless_profile.update(self: LocklessProfile, dispatcher: (any) -> any): boolean
429
+ common.assert_open(self)
430
+
431
+ return self._mutex:with(function()
432
+ return queue_update(self, dispatcher)
433
+ end)
434
+ end
435
+
436
+ --- Periodically flushes the queue (only when there is something to write; an
437
+ --- idle lockless profile costs no storage calls). Stops once the profile closes.
438
+ local function run_flush_loop(self: LocklessProfile)
439
+ local config = self.config
440
+ local scheduler = config._scheduler
441
+
442
+ scheduler.spawn(function()
443
+ while true do
444
+ scheduler.wait(config.flush_interval)
445
+ if not self.open then
446
+ return
447
+ end
448
+ if #self.queued_updates == 0 and not self.dirty_pending then
449
+ continue
450
+ end
451
+
452
+ local ok, err = utils.try(self.save, self)
453
+ if not ok then
454
+ config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(err)}`)
455
+ end
456
+ end
457
+ end)
458
+ end
459
+
460
+ -- its called create not load because it doesnt do anything in the Datastore
461
+ function lockless_profile.create(options: LoadOptions): LocklessProfile
462
+ local self = {} :: LocklessProfile
463
+ self.kind = "lockless"
464
+ self.key = options.key
465
+ self.user_ids = options.user_ids
466
+ self._datastore = options._datastore
467
+ self.open = true
468
+
469
+ self.config = options.config
470
+ self.migrations = nil :: any
471
+
472
+ self._data = nil
473
+ self._mutex = mutex.create(options.config._scheduler)
474
+ self.fetched = false
475
+ self.queued_updates = {}
476
+ self.dirty_pending = false
477
+ self._tx_lock = nil
478
+ self._known_outcomes = {}
479
+ self._cleanup_txs = {}
480
+ self._hooks = { change = {}, save = {}, closing = {}, closed = {}, lock_lost = {} }
481
+
482
+ setmetatable(self, lockless_profile)
483
+ run_flush_loop(self)
484
+ return self :: any
485
+ end
486
+
487
+ function is_lockless_profile(unknown: any)
488
+ if type(unknown) ~= "table" then
489
+ return false
490
+ end
491
+ return getmetatable(unknown) == lockless_profile
492
+ end
493
+
494
+ -- internal methods, used by transactions
495
+ lockless_profile._write_queued = write_queued
496
+ lockless_profile._queue_update = queue_update
497
+ lockless_profile._set_data = set_data
498
+
499
+ return {
500
+ create = lockless_profile.create,
501
+ is = is_lockless_profile,
502
+ }
@@ -0,0 +1,79 @@
1
+ local common = require "./common"
2
+ local types = require "../types"
3
+ local utils = require "../utils"
4
+
5
+ type DataRecord = types.DataRecord
6
+ export type PeekProfile<T = any> = types.PeekProfile<T>
7
+
8
+ export type PeekOptions = {
9
+ key: string,
10
+ _datastore: types.DatastoreHandle,
11
+ _config: types.ResolvedConfig,
12
+ }
13
+
14
+ local peek_profile = {}
15
+ peek_profile.__index = peek_profile
16
+
17
+ --- Reads the record with `GetAsync` and adopts it: `lock` and `pending` are
18
+ --- taken as stored, the data gets the declared migrations applied in memory
19
+ --- (nothing is written). A missing key resolves to the template.
20
+ local function read(self: PeekProfile): any
21
+ local config = self._config
22
+ local datastore = self._datastore
23
+
24
+ local record = utils.retry(config, function()
25
+ return datastore.get_async(self.key)
26
+ end) :: DataRecord?
27
+
28
+ self.lock = if record then record.lock else nil
29
+ self.pending = if record then record.pending else nil
30
+
31
+ local data: any
32
+ local migrations: { string }
33
+ if record == nil or record.data == nil then
34
+ data = utils.deep_copy(config.template)
35
+ migrations = common.all_migration_names(config.migrations)
36
+ else
37
+ local result = common.apply_migrations(record.data, record.migrations or {}, config.migrations)
38
+ if not result.success then
39
+ error(`datastore: failed to apply migrations for "{self.key}": {result.err}`)
40
+ end
41
+ data = result.result
42
+ migrations = result.new_migrations
43
+ end
44
+
45
+ self.migrations = migrations
46
+ self._data = utils.deep_freeze(data)
47
+ return self._data
48
+ end
49
+
50
+ function peek_profile.get_data(self: PeekProfile)
51
+ return self._data
52
+ end
53
+
54
+ --- Calls `get_async` again and replaces the snapshot. Yields.
55
+ function peek_profile.refresh(self: PeekProfile)
56
+ return read(self)
57
+ end
58
+
59
+ --- 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
+ function peek_profile.create(options: PeekOptions): PeekProfile
62
+ local self = {} :: PeekProfile
63
+ self.kind = "peek"
64
+ self.key = options.key
65
+ self._datastore = options._datastore
66
+ self._config = options._config
67
+ self.lock = nil
68
+ self.pending = nil
69
+ self.migrations = {}
70
+ self._data = nil :: any
71
+
72
+ local profile = setmetatable(self, peek_profile) :: any
73
+ read(profile)
74
+ return profile
75
+ end
76
+
77
+ return {
78
+ create = peek_profile.create,
79
+ }