@rbxts/dataforge 0.1.0 → 0.2.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.
@@ -1,4 +1,6 @@
1
+ local errors = require "../errors"
1
2
  local mutex = require "../mutex"
3
+ local result = require "../result"
2
4
  local types = require "../types"
3
5
  local utils = require "../utils"
4
6
  local common = require "./common"
@@ -23,11 +25,24 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
23
25
  local scheduler = config._scheduler
24
26
 
25
27
  local deadline = scheduler.now() + config.load_timeout
28
+ -- the last lock seen on the record, reported when the load times out
29
+ local last_lock: types.Lock? = nil
30
+ -- set by the transform: the record lists more migrations than this server
31
+ -- declares, so the lock must not be taken (the write is cancelled)
32
+ local outdated_migrations: { string }? = nil
26
33
 
27
34
  while true do
28
35
  local now = scheduler.now()
29
- local lock_applied = utils.retry(config, function()
30
- return datastore.update_async(key, function(old: types.DataRecord?): types.DataRecord?
36
+ local lock_applied = utils.update_async(
37
+ config,
38
+ datastore,
39
+ key,
40
+ function(old: types.DataRecord?): types.DataRecord?
41
+ outdated_migrations = nil
42
+ if common.is_outdated(old, config) then
43
+ outdated_migrations = (old :: types.DataRecord).migrations
44
+ return nil
45
+ end
31
46
  if common.is_locked_for(old :: types.DataRecord, job_id, now) then
32
47
  return nil
33
48
  end
@@ -42,22 +57,26 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
42
57
  expires = now + config.lock_ttl,
43
58
  },
44
59
  }
45
- end, user_ids)
46
- end) :: types.DataRecord
60
+ end,
61
+ user_ids
62
+ ) :: types.DataRecord
47
63
 
64
+ if outdated_migrations ~= nil then
65
+ error(errors.outdated(key, outdated_migrations, common.all_migration_names(config.migrations)), 0)
66
+ end
48
67
  if common.is_lock_owned(lock_applied, job_id) then
49
68
  return lock_applied
50
69
  end
70
+ last_lock = if lock_applied then lock_applied.lock else nil
51
71
 
52
72
  while true do
53
73
  if scheduler.now() >= deadline then
54
- error(`datastore: timed out loading "{key}" (session locked by another server)`)
74
+ error(errors.timeout(key, last_lock), 0)
55
75
  end
56
76
  scheduler.wait(config.load_poll)
57
77
 
58
- local current = utils.retry(config, function()
59
- return datastore.get_async(key)
60
- end) :: types.DataRecord?
78
+ local current = utils.get_async(config, datastore, key) :: types.DataRecord?
79
+ last_lock = if current then current.lock else nil
61
80
 
62
81
  if not common.is_locked_for(current, job_id, scheduler.now()) then
63
82
  break
@@ -85,9 +104,11 @@ local function run_autosave_loop(self: Profile)
85
104
 
86
105
  -- a failed write must not kill the loop: the next tick retries, and
87
106
  -- `write_data_record` gives the lock up after a full ttl of failures
88
- local ok, err = utils.try(self.save, self)
107
+ local ok, res = utils.try(self.save, self)
89
108
  if not ok then
90
- config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(err)}`)
109
+ config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(res)}`)
110
+ elseif not res.success then
111
+ config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(res.error)}`)
91
112
  end
92
113
  end
93
114
  end)
@@ -117,7 +138,7 @@ local function process_lock_lost(self: Profile)
117
138
  return
118
139
  end
119
140
  self.open = false
120
- self._mutex:close(`datastore: profile "{self.key}" lost its session lock`)
141
+ self._mutex:close(errors.profile_closed(self, "lock_lost"))
121
142
  fire_hooks(self, self._hooks.lock_lost)
122
143
  fire_hooks(self, self._hooks.closed)
123
144
  end
@@ -127,7 +148,11 @@ type WriteOptions = {
127
148
  pending: types.Pending?,
128
149
  }
129
150
 
130
- local function write_data_record(self: Profile, options: WriteOptions): boolean
151
+ --- Writes the data (and `pending`, if any) under our session lock, refreshing
152
+ --- or releasing it. Returns true when written; false and the lock now on the
153
+ --- record (nil when none) when the lock is not ours any more, in which case
154
+ --- the profile is closed. Throws a `roblox` error when storage fails.
155
+ local function write_data_record(self: Profile, options: WriteOptions): (boolean, types.Lock?)
131
156
  local config = self.config
132
157
  local scheduler = config._scheduler
133
158
  local datastore = self._datastore
@@ -135,11 +160,13 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
135
160
  local job_id = config._hook.job_id()
136
161
 
137
162
  -- set by the transform: whether the write went through (the transform may
138
- -- run more than once when the storage retries it)
163
+ -- run more than once when the storage retries it), and the lock it found
139
164
  local applied = false
165
+ local seen_lock: types.Lock? = nil
140
166
 
141
167
  local function transform(old: any?): DataRecord?
142
168
  applied = false
169
+ seen_lock = if old then old.lock else nil
143
170
  if not common.is_lock_owned(old, job_id) then
144
171
  return nil
145
172
  end
@@ -163,9 +190,7 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
163
190
  end
164
191
 
165
192
  local success, err = pcall(function()
166
- utils.retry(config, function()
167
- return datastore.update_async(self.key, transform, self.user_ids)
168
- end)
193
+ utils.update_async(config, datastore, self.key, transform, self.user_ids)
169
194
  end)
170
195
 
171
196
  if not success then
@@ -180,7 +205,7 @@ local function write_data_record(self: Profile, options: WriteOptions): boolean
180
205
  if not applied then
181
206
  -- the transform bailed out: the lock is not ours any more
182
207
  process_lock_lost(self)
183
- return false
208
+ return false, seen_lock
184
209
  end
185
210
 
186
211
  self.last_write = now
@@ -205,23 +230,25 @@ local function release_lock(load_options: LoadOptions)
205
230
  local job_id = config._hook.job_id()
206
231
 
207
232
  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)
233
+ utils.update_async(config, load_options._datastore, load_options.key, function(old: DataRecord?): DataRecord?
234
+ if not common.is_lock_owned(old, job_id) then
235
+ return nil
236
+ end
237
+ local record = old :: DataRecord
238
+ return {
239
+ data = record.data,
240
+ pending = record.pending,
241
+ migrations = record.migrations,
242
+ lock = nil,
243
+ }
244
+ end, load_options.user_ids)
222
245
  end)
223
246
  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)}`)
247
+ config._hook.warn(
248
+ `datastore: could not release the lock of "{load_options.key}" after a failed load (it will expire): {tostring(
249
+ err
250
+ )}`
251
+ )
225
252
  end
226
253
  end
227
254
 
@@ -253,15 +280,15 @@ local function resolve_loaded(load_options: LoadOptions, record: DataRecord): (a
253
280
  end
254
281
  dirty = true
255
282
  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}`)
283
+ local migrated = apply_migrations(load_options.key, data, record.migrations or {}, migrations)
284
+ if not migrated.success then
285
+ error(migrated.err, 0)
259
286
  end
260
287
 
261
- data = result.result
262
- new_migrations = result.new_migrations
288
+ data = migrated.result
289
+ new_migrations = migrated.new_migrations
263
290
 
264
- if result.applied then
291
+ if migrated.applied then
265
292
  dirty = true
266
293
  end
267
294
  end
@@ -279,7 +306,9 @@ local function adquire_and_resolve(load_options: LoadOptions): (any, { string },
279
306
  local ok, data, new_migrations, dirty = pcall(resolve_loaded, load_options, record)
280
307
  if not ok then
281
308
  local err = data
282
- config._hook.warn(`datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`)
309
+ config._hook.warn(
310
+ `datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`
311
+ )
283
312
  release_lock(load_options)
284
313
  error(err, 0)
285
314
  end
@@ -297,7 +326,7 @@ end
297
326
 
298
327
  local function assert_locked(self: Profile)
299
328
  if not self.is_locked then
300
- error(`datastore: profile "{self.key}" does not hold its session lock`, 3)
329
+ error(errors.released(self), 0)
301
330
  end
302
331
  end
303
332
 
@@ -343,22 +372,26 @@ function profile.get_data(self: Profile)
343
372
  end
344
373
 
345
374
  --- 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
375
+ --- data untouched (Ok(false)); anything else becomes the new data and is
376
+ --- persisted on the next save (Ok(true)). Waits for any in-flight save on the
377
+ --- mutex. Fails with `profile_closed` / `released`; an error thrown by the
378
+ --- dispatcher propagates as is.
379
+ function profile.update(self: Profile, dispatcher: (any) -> any): types.Result<boolean>
380
+ return result.capture(function()
381
+ common.assert_open(self)
354
382
  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
383
+
384
+ return self._mutex:with(function()
385
+ -- a release may have run while this call waited on the mutex
386
+ assert_locked(self)
387
+ local new = dispatcher(self._data)
388
+ if new == nil or new == false then
389
+ return false
390
+ end
391
+ set_data(self, new)
392
+ self._dirty = true
393
+ return true
394
+ end)
362
395
  end)
363
396
  end
364
397
 
@@ -382,61 +415,88 @@ function profile.on_lock_lost(self: Profile, callback: () -> ())
382
415
  table.insert(self._hooks.lock_lost.hooks, callback)
383
416
  end
384
417
 
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
418
+ --- Writes the data, refreshing the session lock. Fails with `profile_closed`,
419
+ --- `released`, `roblox`, or `lock_lost` (another server took the key; the
420
+ --- profile is closed).
421
+ function profile.save(self: Profile): types.Result<nil>
422
+ return result.capture(function()
423
+ common.assert_open(self)
424
+ assert_locked(self)
425
+ -- before the mutex so hooks can still run updates that land in this save
426
+ fire_hooks(self, self._hooks.save)
427
+ common.assert_open(self)
394
428
  assert_locked(self)
395
- write_data_record(self, { release = false })
429
+ self._mutex:with(function()
430
+ -- without the lock the write would be cancelled and wrongly read as lost
431
+ assert_locked(self)
432
+ local written, lock = write_data_record(self, { release = false })
433
+ if not written then
434
+ error(errors.lock_lost(self, lock), 0)
435
+ end
436
+ end)
437
+ return nil
396
438
  end)
397
439
  end
398
440
 
399
441
  --- 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
442
+ --- (another server may load the key meanwhile); `update` and `save` fail with
443
+ --- `released` until `readquire`. No-op when already released. Fails with
444
+ --- `profile_closed`, `roblox` (the lock stays on the record, `is_locked`
445
+ --- stays true) or `lock_lost` (the profile is closed).
446
+ function profile.release(self: Profile): types.Result<nil>
447
+ return result.capture(function()
448
+ common.assert_open(self)
449
+ if not self.is_locked then
450
+ return nil
410
451
  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
452
+ self._mutex:with(function()
453
+ -- an unload queued behind this call has flipped `open`: its own write
454
+ -- gives the lock back
455
+ if not self.open or not self.is_locked then
456
+ return
457
+ end
458
+ local written, lock = write_data_record(self, { release = true })
459
+ if not written then
460
+ error(errors.lock_lost(self, lock), 0)
461
+ end
414
462
  self.is_locked = false
415
- end
463
+ end)
464
+ return nil
416
465
  end)
417
466
  end
418
467
 
419
468
  --- Takes the session lock again, waiting for a holder like `load` does, and
420
469
  --- adopts the stored record (resolved pending, migrations) as the current
421
- --- data. Throws on timeout, leaving the profile open and released. No-op when
470
+ --- data. Fails with `timeout` (the profile stays open and released),
471
+ --- `profile_closed`, `roblox`, `outdated`, `migration_mismatch` or `tx_marker_invalid`. No-op when
422
472
  --- 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()
473
+ function profile.readquire(self: Profile): types.Result<nil>
474
+ return result.capture(function()
429
475
  common.assert_open(self)
430
476
  if self.is_locked then
431
- return
477
+ return nil
432
478
  end
433
- local data, new_migrations, dirty = adquire_and_resolve(load_options_of(self))
479
+ self._mutex:with(function()
480
+ common.assert_open(self)
481
+ if self.is_locked then
482
+ return
483
+ end
484
+ local ok, data, new_migrations, dirty = pcall(adquire_and_resolve, load_options_of(self))
485
+ if not ok then
486
+ local err: any = data
487
+ if errors.is(err) and err.type == "outdated" then
488
+ err.profile = self
489
+ end
490
+ error(err, 0)
491
+ end
434
492
 
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
493
+ self.migrations = new_migrations
494
+ set_data(self, data)
495
+ self._dirty = dirty
496
+ self.last_write = self.config._scheduler.now()
497
+ self.is_locked = true
498
+ end)
499
+ return nil
440
500
  end)
441
501
  end
442
502
 
@@ -452,9 +512,10 @@ function profile.unload(self: Profile)
452
512
 
453
513
  self.open = false
454
514
  self._mutex:lock()
455
- self._mutex:close(`datastore: profile "{self.key}" is closed`)
515
+ self._mutex:close(errors.profile_closed(self, "unloaded"))
456
516
  local ok, err = utils.try(function()
457
- -- an already released profile has nothing on the record to give back
517
+ -- an already released profile has nothing on the record to give back;
518
+ -- a lost lock (false) has nothing to give back either
458
519
  if self.is_locked then
459
520
  write_data_record(self, { release = true })
460
521
  end
@@ -0,0 +1,61 @@
1
+ --!strict
2
+
3
+ local errors = require "./errors"
4
+ local types = require "./types"
5
+ local utils = require "./utils"
6
+
7
+ --[[
8
+ `Result<T>`: what every fallible library call returns instead of throwing.
9
+
10
+ Internally the library keeps throwing, but it throws `Error` tables (see
11
+ `errors.luau`). `result.capture` at each public entry point turns an
12
+ `Error` into `Err`; anything else (a string thrown by a user callback, an
13
+ internal invariant) is rethrown untouched.
14
+ ]]
15
+
16
+ export type Result<T> = types.Result<T>
17
+ export type Error = types.Error
18
+
19
+ local result_mt = {}
20
+ result_mt.__index = result_mt
21
+
22
+ function result_mt.unwrap(self: any): any
23
+ if self.success then
24
+ return self.value
25
+ end
26
+ error(self.error, 0)
27
+ end
28
+
29
+ local result = {}
30
+
31
+ function result.ok<T>(value: T): Result<T>
32
+ return setmetatable({ success = true, value = value }, result_mt) :: any
33
+ end
34
+
35
+ function result.err(err: Error): Result<any>
36
+ assert(errors.is(err), "result.err expects an Error table")
37
+ return setmetatable({ success = false, error = err }, result_mt) :: any
38
+ end
39
+
40
+ function result.is(value: any): boolean
41
+ return type(value) == "table" and getmetatable(value) == result_mt
42
+ end
43
+
44
+ --- Turns a `pcall` outcome into a Result: `Ok(value)` on success, `Err` when
45
+ --- the thrown value is an `Error` table, rethrown raw otherwise.
46
+ function result.of<T>(ok: boolean, value: any): Result<T>
47
+ if ok then
48
+ return result.ok(value)
49
+ end
50
+ if errors.is(value) then
51
+ return result.err(value)
52
+ end
53
+ error(value, 0)
54
+ end
55
+
56
+ --- `pcall(fn, ...)` folded through `result.of`.
57
+ function result.capture<T>(fn: (...any) -> T, ...: any): Result<T>
58
+ return result.of(utils.try(fn, ...))
59
+ end
60
+
61
+ return result