@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,498 @@
1
+ local mutex = require "../mutex"
2
+ local types = require "../types"
3
+ local utils = require "../utils"
4
+ local common = require "./common"
5
+ local tx_handle = require "../tx/handle"
6
+
7
+ type Array<T> = { T }
8
+ type DataRecord = types.DataRecord
9
+ type HooksList<T... = ()> = types.HooksList<T...>
10
+ export type Profile<T = any> = types.Profile<T>
11
+ export type LoadOptions = types.LoadOptions
12
+
13
+ local profile = {}
14
+ profile.__index = profile
15
+
16
+ local function adquire_lock(load_options: LoadOptions): types.DataRecord
17
+ local config = load_options.config
18
+ local user_ids = load_options.user_ids
19
+ local datastore = load_options._datastore
20
+ local job_id = config._hook.job_id()
21
+ local key = load_options.key
22
+
23
+ local scheduler = config._scheduler
24
+
25
+ local deadline = scheduler.now() + config.load_timeout
26
+
27
+ while true do
28
+ local now = scheduler.now()
29
+ local lock_applied = utils.retry(config, function()
30
+ return datastore.update_async(key, function(old: types.DataRecord?): types.DataRecord?
31
+ if common.is_locked_for(old :: types.DataRecord, job_id, now) then
32
+ return nil
33
+ end
34
+ local record: types.DataRecord = old or {} :: types.DataRecord
35
+
36
+ return {
37
+ data = record.data,
38
+ pending = record.pending,
39
+ migrations = record.migrations or {},
40
+ lock = {
41
+ id = job_id,
42
+ expires = now + config.lock_ttl,
43
+ },
44
+ }
45
+ end, user_ids)
46
+ end) :: types.DataRecord
47
+
48
+ if common.is_lock_owned(lock_applied, job_id) then
49
+ return lock_applied
50
+ end
51
+
52
+ while true do
53
+ if scheduler.now() >= deadline then
54
+ error(`datastore: timed out loading "{key}" (session locked by another server)`)
55
+ end
56
+ scheduler.wait(config.load_poll)
57
+
58
+ local current = utils.retry(config, function()
59
+ return datastore.get_async(key)
60
+ end) :: types.DataRecord?
61
+
62
+ if not common.is_locked_for(current, job_id, scheduler.now()) then
63
+ break
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ local apply_migrations = common.apply_migrations
70
+
71
+ local function run_autosave_loop(self: Profile)
72
+ local config = self.config
73
+ local scheduler = config._scheduler
74
+
75
+ scheduler.spawn(function()
76
+ while true do
77
+ scheduler.wait(config.autosave_interval)
78
+ if not self.open then
79
+ return
80
+ end
81
+ if not self.is_locked then
82
+ -- released: nothing to refresh, nothing may be written
83
+ continue
84
+ end
85
+
86
+ -- a failed write must not kill the loop: the next tick retries, and
87
+ -- `write_data_record` gives the lock up after a full ttl of failures
88
+ local ok, err = utils.try(self.save, self)
89
+ if not ok then
90
+ config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(err)}`)
91
+ end
92
+ end
93
+ end)
94
+ end
95
+
96
+ local function fire_hooks<T...>(self: Profile, hooks: HooksList<T...>, ...: T...)
97
+ for _, hook in hooks.hooks do
98
+ local ok, err = utils.try(hook, ...)
99
+ if not ok then
100
+ self.config._hook.warn(`datastore: hook {hooks.type} failed for {self.key}: {tostring(err)}`)
101
+ end
102
+ end
103
+ end
104
+
105
+ local function set_data(self: Profile, data: any)
106
+ local old = self._data
107
+ local new = utils.deep_freeze(data)
108
+ self._data = new
109
+ fire_hooks(self, self._hooks.change, new, old)
110
+ end
111
+
112
+ --- Another server took the key: the profile closes without a release write.
113
+ --- Runs inside a write (mutex held by the caller), so it only closes the mutex:
114
+ --- the holder finishes, everything waiting on it is rejected.
115
+ local function process_lock_lost(self: Profile)
116
+ if not self.open then
117
+ return
118
+ end
119
+ self.open = false
120
+ self._mutex:close(`datastore: profile "{self.key}" lost its session lock`)
121
+ fire_hooks(self, self._hooks.lock_lost)
122
+ fire_hooks(self, self._hooks.closed)
123
+ end
124
+
125
+ type WriteOptions = {
126
+ release: boolean,
127
+ pending: types.Pending?,
128
+ }
129
+
130
+ local function write_data_record(self: Profile, options: WriteOptions): boolean
131
+ local config = self.config
132
+ local scheduler = config._scheduler
133
+ local datastore = self._datastore
134
+ local now = scheduler.now()
135
+ local job_id = config._hook.job_id()
136
+
137
+ -- set by the transform: whether the write went through (the transform may
138
+ -- run more than once when the storage retries it)
139
+ local applied = false
140
+
141
+ local function transform(old: any?): DataRecord?
142
+ applied = false
143
+ if not common.is_lock_owned(old, job_id) then
144
+ return nil
145
+ end
146
+ applied = true
147
+
148
+ local new_lock: types.Lock? = nil
149
+ if not options.release then
150
+ new_lock = {
151
+ id = job_id,
152
+ expires = now + config.lock_ttl,
153
+ tx = nil,
154
+ } :: types.Lock
155
+ end
156
+
157
+ return {
158
+ data = self._data,
159
+ migrations = self.migrations,
160
+ pending = options.pending,
161
+ lock = new_lock,
162
+ }
163
+ end
164
+
165
+ local success, err = pcall(function()
166
+ utils.retry(config, function()
167
+ return datastore.update_async(self.key, transform, self.user_ids)
168
+ end)
169
+ end)
170
+
171
+ if not success then
172
+ if scheduler.now() - self.last_write > config.lock_ttl then
173
+ -- We have not managed to refresh the lock for a full TTL; assume its gone.
174
+ process_lock_lost(self)
175
+ end
176
+
177
+ error(err, 0)
178
+ end
179
+
180
+ if not applied then
181
+ -- the transform bailed out: the lock is not ours any more
182
+ process_lock_lost(self)
183
+ return false
184
+ end
185
+
186
+ self.last_write = now
187
+ self._dirty = false
188
+
189
+ if options.pending == nil then
190
+ -- the record is clean: every transaction waiting on that can drop its marker
191
+ local cleanups = self._cleanup_txs
192
+ self._cleanup_txs = {}
193
+ for _, operation in cleanups do
194
+ operation:cleaned()
195
+ end
196
+ end
197
+ return true
198
+ end
199
+
200
+ --- Gives the lock taken by `adquire_lock` back, leaving the record otherwise
201
+ --- untouched. Used when the load fails after locking; a failure here is only
202
+ --- warned about (the lock expires on its own).
203
+ local function release_lock(load_options: LoadOptions)
204
+ local config = load_options.config
205
+ local job_id = config._hook.job_id()
206
+
207
+ local ok, err = utils.try(function()
208
+ utils.retry(config, function()
209
+ return load_options._datastore.update_async(load_options.key, function(old: DataRecord?): DataRecord?
210
+ if not common.is_lock_owned(old, job_id) then
211
+ return nil
212
+ end
213
+ local record = old :: DataRecord
214
+ return {
215
+ data = record.data,
216
+ pending = record.pending,
217
+ migrations = record.migrations,
218
+ lock = nil,
219
+ }
220
+ end, load_options.user_ids)
221
+ end)
222
+ end)
223
+ if not ok then
224
+ config._hook.warn(`datastore: could not release the lock of "{load_options.key}" after a failed load (it will expire): {tostring(err)}`)
225
+ end
226
+ end
227
+
228
+ --- Turns the locked record into the data the profile starts with: resolves a
229
+ --- `pending`, falls back to the template, applies migrations. May throw.
230
+ local function resolve_loaded(load_options: LoadOptions, record: DataRecord): (any, { string }, boolean)
231
+ local config = load_options.config
232
+ local data = record.data
233
+ local dirty = false
234
+
235
+ if record.pending ~= nil then
236
+ local outcome = tx_handle.resolve_pending(config._hook, config, record.pending, true)
237
+ if outcome == "committed" then
238
+ data = record.pending.data
239
+ end
240
+ dirty = true
241
+ end
242
+
243
+ local migrations = config.migrations
244
+ local new_migrations: { string }
245
+
246
+ if data == nil then
247
+ data = utils.deep_copy(config.template)
248
+
249
+ -- fresh template already matches the latest schema, mark every migration as applied
250
+ new_migrations = table.create(#migrations) :: { string }
251
+ for index, migration in migrations do
252
+ new_migrations[index] = migration.name
253
+ end
254
+ dirty = true
255
+ else
256
+ local result = apply_migrations(data, record.migrations or {}, migrations)
257
+ if not result.success then
258
+ error(`datastore: failed to apply migrations for "{load_options.key}": {result.err}`)
259
+ end
260
+
261
+ data = result.result
262
+ new_migrations = result.new_migrations
263
+
264
+ if result.applied then
265
+ dirty = true
266
+ end
267
+ end
268
+
269
+ return data, new_migrations, dirty
270
+ end
271
+
272
+ --- Takes the session lock and resolves the locked record into data. If the
273
+ --- resolution fails the lock is given back before rethrowing.
274
+ local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean)
275
+ local record = adquire_lock(load_options)
276
+ local config = load_options.config
277
+
278
+ -- from here on the key is locked: anything failing must give it back
279
+ local ok, data, new_migrations, dirty = pcall(resolve_loaded, load_options, record)
280
+ if not ok then
281
+ local err = data
282
+ config._hook.warn(`datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`)
283
+ release_lock(load_options)
284
+ error(err, 0)
285
+ end
286
+ return data, new_migrations, dirty
287
+ end
288
+
289
+ local function load_options_of(self: Profile): LoadOptions
290
+ return {
291
+ key = self.key,
292
+ _datastore = self._datastore,
293
+ config = self.config,
294
+ user_ids = self.user_ids,
295
+ }
296
+ end
297
+
298
+ local function assert_locked(self: Profile)
299
+ if not self.is_locked then
300
+ error(`datastore: profile "{self.key}" does not hold its session lock`, 3)
301
+ end
302
+ end
303
+
304
+ function profile.load(load_options: LoadOptions): Profile
305
+ local data, new_migrations, dirty = adquire_and_resolve(load_options)
306
+ local config = load_options.config
307
+
308
+ local self: Profile = {} :: any
309
+ self.kind = "locked"
310
+ self.key = load_options.key
311
+ self.user_ids = load_options.user_ids
312
+ self._datastore = load_options._datastore
313
+ self.open = true
314
+ self.is_locked = true
315
+
316
+ self.config = load_options.config
317
+ self.migrations = new_migrations
318
+
319
+ self._hooks = {
320
+ change = { type = "change", hooks = {} },
321
+ lock_lost = { type = "lock_lost", hooks = {} },
322
+ save = { type = "save", hooks = {} },
323
+ closing = { type = "closing", hooks = {} },
324
+ closed = { type = "closed", hooks = {} },
325
+ }
326
+
327
+ self._data = nil
328
+ set_data(self, data)
329
+ -- the lock was just taken by `adquire_lock`; counts as a successful write
330
+ self.last_write = config._scheduler.now()
331
+
332
+ self._dirty = dirty
333
+ self._mutex = mutex.create(config._scheduler)
334
+ self._cleanup_txs = {}
335
+
336
+ run_autosave_loop(self)
337
+ return setmetatable(self, profile) :: any
338
+ end
339
+
340
+ function profile.get_data(self: Profile)
341
+ common.assert_open(self)
342
+ return self._data
343
+ end
344
+
345
+ --- Applies `dispatcher` to the current data. Returning nil or false leaves the
346
+ --- data untouched (returns false); anything else becomes the new data and is
347
+ --- persisted on the next save. Waits for any in-flight save on the mutex.
348
+ function profile.update(self: Profile, dispatcher: (any) -> any): boolean
349
+ common.assert_open(self)
350
+ assert_locked(self)
351
+
352
+ return self._mutex:with(function()
353
+ -- a release may have run while this call waited on the mutex
354
+ assert_locked(self)
355
+ local result = dispatcher(self._data)
356
+ if result == nil or result == false then
357
+ return false
358
+ end
359
+ set_data(self, result)
360
+ self._dirty = true
361
+ return true
362
+ end)
363
+ end
364
+
365
+ function profile.on_change(self: Profile, callback: (new: any, old: any?) -> ())
366
+ table.insert(self._hooks.change.hooks, callback)
367
+ end
368
+
369
+ function profile.on_save(self: Profile, callback: () -> ())
370
+ table.insert(self._hooks.save.hooks, callback)
371
+ end
372
+
373
+ function profile.on_closing(self: Profile, callback: () -> ())
374
+ table.insert(self._hooks.closing.hooks, callback)
375
+ end
376
+
377
+ function profile.on_closed(self: Profile, callback: () -> ())
378
+ table.insert(self._hooks.closed.hooks, callback)
379
+ end
380
+
381
+ function profile.on_lock_lost(self: Profile, callback: () -> ())
382
+ table.insert(self._hooks.lock_lost.hooks, callback)
383
+ end
384
+
385
+ function profile.save(self: Profile)
386
+ common.assert_open(self)
387
+ assert_locked(self)
388
+ -- before the mutex so hooks can still run updates that land in this save
389
+ fire_hooks(self, self._hooks.save)
390
+ common.assert_open(self)
391
+ assert_locked(self)
392
+ self._mutex:with(function()
393
+ -- without the lock the write would be cancelled and wrongly read as lost
394
+ assert_locked(self)
395
+ write_data_record(self, { release = false })
396
+ end)
397
+ end
398
+
399
+ --- Writes the data and gives the session lock back. The profile stays open
400
+ --- (another server may load the key meanwhile); `update` and `save` throw
401
+ --- until `readquire`. No-op when already released.
402
+ function profile.release(self: Profile)
403
+ common.assert_open(self)
404
+ if not self.is_locked then
405
+ return
406
+ end
407
+ self._mutex:with(function()
408
+ if not self.open or not self.is_locked then
409
+ return
410
+ end
411
+ -- false: the lock was not ours any more, `process_lock_lost` closed the profile.
412
+ -- A storage error propagates; the lock is still on the record, so `is_locked` stays.
413
+ if write_data_record(self, { release = true }) then
414
+ self.is_locked = false
415
+ end
416
+ end)
417
+ end
418
+
419
+ --- Takes the session lock again, waiting for a holder like `load` does, and
420
+ --- adopts the stored record (resolved pending, migrations) as the current
421
+ --- data. Throws on timeout, leaving the profile open and released. No-op when
422
+ --- already locked.
423
+ function profile.readquire(self: Profile)
424
+ common.assert_open(self)
425
+ if self.is_locked then
426
+ return
427
+ end
428
+ self._mutex:with(function()
429
+ common.assert_open(self)
430
+ if self.is_locked then
431
+ return
432
+ end
433
+ local data, new_migrations, dirty = adquire_and_resolve(load_options_of(self))
434
+
435
+ self.migrations = new_migrations
436
+ set_data(self, data)
437
+ self._dirty = dirty
438
+ self.last_write = self.config._scheduler.now()
439
+ self.is_locked = true
440
+ end)
441
+ end
442
+
443
+ function profile.unload(self: Profile)
444
+ if not self.open then
445
+ return
446
+ end
447
+ -- last chance to update while the profile is still open
448
+ fire_hooks(self, self._hooks.closing)
449
+ if not self.open then
450
+ return
451
+ end
452
+
453
+ self.open = false
454
+ self._mutex:lock()
455
+ self._mutex:close(`datastore: profile "{self.key}" is closed`)
456
+ local ok, err = utils.try(function()
457
+ -- an already released profile has nothing on the record to give back
458
+ if self.is_locked then
459
+ write_data_record(self, { release = true })
460
+ end
461
+ end)
462
+ self._mutex:unlock()
463
+ if not ok then
464
+ self.config._hook.warn(`datastore: failed to release "{self.key}" (lock will expire): {tostring(err)}`)
465
+ end
466
+ fire_hooks(self, self._hooks.closed)
467
+ end
468
+
469
+ --- Yields until no operation holds or waits on the profile mutex. Once this
470
+ --- returns, the next update call is guaranteed not to yield (nothing is queued
471
+ --- ahead of it).
472
+ function profile.wait_settled(self: Profile)
473
+ return self._mutex:wait_settled()
474
+ end
475
+
476
+ function profile.wait_closed(self: Profile)
477
+ if self.open then
478
+ error(`profile: {self.key} has not been closed`)
479
+ end
480
+
481
+ return self._mutex:wait_settled()
482
+ end
483
+
484
+ function is_profile(unknown: any)
485
+ if type(unknown) ~= "table" then
486
+ return false
487
+ end
488
+ return getmetatable(unknown) == profile
489
+ end
490
+
491
+ -- internal methods, used by transactions
492
+ profile._set_data = set_data
493
+ profile._write_record = write_data_record
494
+
495
+ return {
496
+ load = profile.load,
497
+ is = is_profile,
498
+ }
@@ -0,0 +1,18 @@
1
+ local types = require "../types"
2
+
3
+ export type RobloxScheduler = types.Scheduler
4
+
5
+ local roblox_scheduler = {}
6
+
7
+ function roblox_scheduler.create()
8
+ return {
9
+ spawn = task.spawn,
10
+ defer = task.defer,
11
+ wait = task.wait,
12
+ now = function()
13
+ return DateTime.now().UnixTimestampMillis / 1000
14
+ end,
15
+ }
16
+ end
17
+
18
+ return roblox_scheduler
@@ -0,0 +1,128 @@
1
+ local types = require "../types"
2
+
3
+ --[[
4
+ Virtual-time task scheduler.
5
+
6
+ Never yields the real thread. `wait` suspends the calling coroutine and
7
+ registers it to resume at `now + seconds`; `step(dt)` advances the virtual
8
+ clock and resumes every due thread in (resume_time, registration order).
9
+ Threads that wait again while stepping are re-queued and processed in the
10
+ same step if they are still due.
11
+ ]]
12
+
13
+ export type VirtualScheduler = types.Scheduler & {
14
+ step: (dt: number) -> (),
15
+ flush: () -> (),
16
+ pending: () -> number,
17
+ errors: { string },
18
+ }
19
+
20
+ type Entry = {
21
+ at: number,
22
+ seq: number,
23
+ thread: thread,
24
+ -- wait entries resume with the time they were resumed at,
25
+ -- deferred entries resume with their stored args
26
+ args: { any }?,
27
+ }
28
+
29
+ local virtual_scheduler = {}
30
+
31
+ function virtual_scheduler.create(): VirtualScheduler
32
+ local time = 0
33
+ local seq = 0
34
+ local queue: { Entry } = {}
35
+ local errors: { string } = {}
36
+
37
+ local function insert(entry: Entry)
38
+ -- keep sorted by (at, seq); scan from the back since most inserts are late
39
+ local index = #queue
40
+ while index >= 1 do
41
+ local other = queue[index]
42
+ if other.at < entry.at or (other.at == entry.at and other.seq < entry.seq) then
43
+ break
44
+ end
45
+ index -= 1
46
+ end
47
+ table.insert(queue, index + 1, entry)
48
+ end
49
+
50
+ local function resume(thread: thread, ...: any)
51
+ local ok, err = coroutine.resume(thread, ...)
52
+ if not ok then
53
+ -- recorded, not printed: tests assert on `errors`
54
+ table.insert(errors, `scheduler: thread error: {tostring(err)}`)
55
+ end
56
+ end
57
+
58
+ local function to_thread(fn: ((...any) -> ...any) | thread): thread
59
+ if type(fn) == "thread" then
60
+ return fn
61
+ end
62
+ return coroutine.create(fn)
63
+ end
64
+
65
+ local self = {} :: VirtualScheduler
66
+ self.errors = errors
67
+
68
+ function self.now(): number
69
+ return time
70
+ end
71
+
72
+ function self.spawn(fn, ...)
73
+ local thread = to_thread(fn)
74
+ resume(thread, ...)
75
+ return thread
76
+ end
77
+
78
+ function self.defer(fn, ...)
79
+ local thread = to_thread(fn)
80
+ seq += 1
81
+ insert { at = time, seq = seq, thread = thread, args = { ... } }
82
+ return thread
83
+ end
84
+
85
+ function self.wait(seconds)
86
+ local thread = coroutine.running()
87
+ if not coroutine.isyieldable() then
88
+ error "scheduler.wait called from a non-yieldable thread; run your code inside scheduler.spawn"
89
+ end
90
+ local delay = math.max(seconds or 0, 0)
91
+ seq += 1
92
+ local started = time
93
+ insert { at = time + delay, seq = seq, thread = thread }
94
+ local resumed_at: number = coroutine.yield()
95
+ return resumed_at - started
96
+ end
97
+
98
+ function self.step(dt)
99
+ local target = time + math.max(dt, 0)
100
+ while #queue > 0 and queue[1].at <= target do
101
+ local entry = table.remove(queue, 1) :: Entry
102
+ if entry.at > time then
103
+ time = entry.at
104
+ end
105
+ if coroutine.status(entry.thread) == "dead" then
106
+ continue
107
+ end
108
+ if entry.args then
109
+ resume(entry.thread, table.unpack(entry.args))
110
+ else
111
+ resume(entry.thread, time)
112
+ end
113
+ end
114
+ time = target
115
+ end
116
+
117
+ function self.flush()
118
+ self.step(0)
119
+ end
120
+
121
+ function self.pending(): number
122
+ return #queue
123
+ end
124
+
125
+ return self
126
+ end
127
+
128
+ return virtual_scheduler
@@ -0,0 +1,18 @@
1
+ local types = require "../types"
2
+
3
+ export type RobloxScheduler = types.Scheduler
4
+
5
+ local zune_scheduler = {}
6
+
7
+ function zune_scheduler.create()
8
+ return {
9
+ spawn = zune.task.spawn,
10
+ defer = zune.task.defer,
11
+ wait = zune.task.wait,
12
+ now = function()
13
+ return zune.time.timestampMillis() / 1000
14
+ end,
15
+ }
16
+ end
17
+
18
+ return zune_scheduler