@rbxts/dataforge 0.1.0 → 0.4.1

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,5 +1,7 @@
1
1
  local common = require "./common"
2
+ local errors = require "../errors"
2
3
  local mutex = require "../mutex"
4
+ local result = require "../result"
3
5
  local tx_handle = require "../tx/handle"
4
6
  local types = require "../types"
5
7
  local utils = require "../utils"
@@ -15,7 +17,7 @@ lockless_profile.__index = lockless_profile
15
17
  function lockless_profile.get_data(self: LocklessProfile)
16
18
  common.assert_open(self)
17
19
  if not self.fetched then
18
- error(`datastore: lockless profile "{self.key}" has not been fetched`)
20
+ error(errors.not_fetched(self), 0)
19
21
  end
20
22
  return self._data
21
23
  end
@@ -67,11 +69,11 @@ local function resolve_record(self: LocklessProfile, record: DataRecord?, data:
67
69
  return utils.deep_copy(config.template), common.all_migration_names(config.migrations)
68
70
  end
69
71
 
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}`)
72
+ local migrated = common.apply_migrations(self.key, data, applied, config.migrations)
73
+ if not migrated.success then
74
+ common.raise_migrations(self, migrated)
73
75
  end
74
- return result.result, result.new_migrations
76
+ return migrated.result, migrated.new_migrations
75
77
  end
76
78
 
77
79
  local function replay(queue: Array<types.DataDispatcher<any>>, data: any): any
@@ -108,13 +110,15 @@ local function process_lock_lost(self: LocklessProfile)
108
110
  return
109
111
  end
110
112
  self.open = false
111
- self._mutex:close(`datastore: profile "{self.key}" is session locked`)
113
+ self._mutex:close(errors.profile_closed(self, "lock_lost"))
112
114
  fire_hooks(self, "lock_lost", self._hooks.lock_lost)
113
115
  fire_hooks(self, "closed", self._hooks.closed)
114
116
  end
115
117
 
116
118
  type SaveState = {
117
119
  applied: boolean,
120
+ --- the lock found on the record (session or transaction), nil when none
121
+ lock: types.Lock?,
118
122
  --- a live transaction lock is on the record; `tx_lock_expires` says until when
119
123
  tx_locked: boolean,
120
124
  tx_lock_expires: number,
@@ -123,6 +127,9 @@ type SaveState = {
123
127
  tx_lock_lost: boolean,
124
128
  outdated: boolean,
125
129
  unknown_pending: types.Pending?,
130
+ --- the transform threw (a migration or a queued dispatcher): rethrown after
131
+ --- the storage call instead of being retried as a storage failure
132
+ failure: any,
126
133
  }
127
134
 
128
135
  type WriteOptions = types.LocklessWriteOptions
@@ -135,7 +142,13 @@ type WriteOptions = types.LocklessWriteOptions
135
142
  --- `self._tx_lock`). `options.old_tx_lock` is the lock this write expects to
136
143
  --- find on the record: the write only goes through (and clears that lock) when
137
144
  --- 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".
145
+ --- else, nothing is written and the call fails with `tx_lock_lost`.
146
+ ---
147
+ --- Throws `lockless_locked` (a live session lock; the profile is closed),
148
+ --- `tx_lock_lost` (closed too), `outdated` (a newer server wrote the record;
149
+ --- nothing written), `predicate_failed` (the store's predicate rejected the
150
+ --- stored data; nothing written), `migration_mismatch`, `roblox`, or whatever
151
+ --- a queued dispatcher / migration threw.
139
152
  local function write_queued(self: LocklessProfile, options: WriteOptions)
140
153
  local config = self.config
141
154
  local scheduler = config._scheduler
@@ -146,23 +159,19 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
146
159
 
147
160
  local state: SaveState = {
148
161
  applied = false,
162
+ lock = nil,
149
163
  tx_locked = false,
150
164
  tx_lock_expires = 0,
151
165
  tx_lock_lost = false,
152
166
  session_locked = false,
153
167
  outdated = false,
154
168
  unknown_pending = nil,
169
+ failure = nil,
155
170
  }
156
171
 
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
-
172
+ local function transform_body(old: DataRecord?): DataRecord?
165
173
  local lock = if old then old.lock else nil
174
+ state.lock = lock
166
175
 
167
176
  -- a live session lock means a locked profile owns this key now: the
168
177
  -- profile lost its lockless standing, whatever else it expected on the
@@ -192,11 +201,19 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
192
201
 
193
202
  -- more migrations applied than this server declares: a newer server
194
203
  -- already touched the record, so this server must not write to it
195
- if old and old.migrations and #old.migrations > #config.migrations then
204
+ if common.is_outdated(old, config) then
196
205
  state.outdated = true
197
206
  return nil
198
207
  end
199
208
 
209
+ -- the store's predicate gates the key: a rejected record is not written
210
+ -- to, and no `pending` of it is resolved
211
+ local stored = if old then old.data else nil
212
+ local passed, predicate_failure = common.run_predicate(config, self.key, stored)
213
+ if not passed then
214
+ error(predicate_failure or errors.predicate_failed(self.key, "save", stored, self), 0)
215
+ end
216
+
200
217
  -- a `pending` left by some transaction (ours after commit, or another
201
218
  -- server's) decides which data this write builds on
202
219
  local base: any = nil
@@ -222,22 +239,45 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
222
239
  }
223
240
  end
224
241
 
242
+ -- the body runs migrations and queued dispatchers (user code) and may raise
243
+ -- a `migration_mismatch`: none of those is a storage failure, so the
244
+ -- storage retry must not see them. They cancel the write and are rethrown.
245
+ local function transform(old: DataRecord?): DataRecord?
246
+ state.applied = false
247
+ state.lock = nil
248
+ state.tx_locked = false
249
+ state.session_locked = false
250
+ state.tx_lock_lost = false
251
+ state.outdated = false
252
+ state.unknown_pending = nil
253
+ state.failure = nil
254
+
255
+ local ok, new = pcall(transform_body, old)
256
+ if not ok then
257
+ state.failure = new
258
+ return nil
259
+ end
260
+ return new
261
+ end
262
+
225
263
  while true do
226
- local record = utils.retry(config, function()
227
- return datastore.update_async(self.key, transform, self.user_ids)
228
- end) :: DataRecord
264
+ local record = utils.update_async(config, datastore, self.key, transform, self.user_ids) :: DataRecord
265
+
266
+ if state.failure ~= nil then
267
+ error(state.failure, 0)
268
+ end
229
269
 
230
270
  if state.session_locked then
231
271
  self._tx_lock = nil
232
272
  process_lock_lost(self)
233
- error(`datastore: "{self.key}" is session locked`)
273
+ error(errors.lockless_locked(self, state.lock :: types.Lock), 0)
234
274
  end
235
275
 
236
276
  if state.tx_lock_lost then
237
277
  -- whatever is on the record now is not ours; do not touch it again
238
278
  self._tx_lock = nil
239
279
  process_lock_lost(self)
240
- error(`datastore: lock_lost for "{self.key}"`)
280
+ error(errors.tx_lock_lost(self, old_tx_lock :: types.Lock, state.lock), 0)
241
281
  end
242
282
 
243
283
  if state.tx_locked then
@@ -248,7 +288,8 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
248
288
 
249
289
  if state.outdated then
250
290
  error(
251
- `datastore: "{self.key}" was written by a newer server (record has {#record.migrations} applied migrations, this server declares {#config.migrations})`
291
+ errors.outdated(self.key, record.migrations or {}, common.all_migration_names(config.migrations), self),
292
+ 0
252
293
  )
253
294
  end
254
295
 
@@ -307,16 +348,21 @@ end
307
348
 
308
349
  --- Applies every queued update to the stored data in one update_async and
309
350
  --- 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, {})
351
+ --- Fails (keeping the queue) with `lockless_locked` if another server holds a
352
+ --- live session lock (the profile is closed), `outdated` if the record was
353
+ --- already written by a newer server, `predicate_failed` if the store's
354
+ --- predicate rejected the stored data, `migration_mismatch`, `roblox` or
355
+ --- `profile_closed`. An error thrown by a queued dispatcher propagates as is.
356
+ function lockless_profile.save(self: LocklessProfile): types.Result<nil>
357
+ return result.capture(function()
358
+ common.assert_open(self)
359
+ -- before the mutex so hooks can still queue updates that land in this save
360
+ fire_hooks(self, "save", self._hooks.save)
361
+ common.assert_open(self)
362
+ self._mutex:with(function()
363
+ write_queued(self, {})
364
+ end)
365
+ return nil
320
366
  end)
321
367
  end
322
368
 
@@ -326,11 +372,15 @@ function lockless_profile.unload(self: LocklessProfile)
326
372
  end
327
373
  -- last chance to queue updates while the profile is still open
328
374
  fire_hooks(self, "closing", self._hooks.closing)
375
+ -- a closing hook may have unloaded the profile itself
376
+ if not self.open then
377
+ return
378
+ end
329
379
 
330
380
  self.open = false
331
381
  self._mutex:lock()
332
382
  -- everything still waiting on the mutex (and anything after) is rejected
333
- self._mutex:close(`datastore: profile "{self.key}" is closed`)
383
+ self._mutex:close(errors.profile_closed(self, "unloaded"))
334
384
  local ok, err = utils.try(function()
335
385
  if #self.queued_updates > 0 or self.dirty_pending then
336
386
  write_queued(self, {})
@@ -338,7 +388,9 @@ function lockless_profile.unload(self: LocklessProfile)
338
388
  end)
339
389
  self._mutex:unlock()
340
390
  if not ok then
341
- self.config._hook.warn(`datastore: failed to flush "{self.key}" on unload ({#self.queued_updates} changes lost): {tostring(err)}`)
391
+ self.config._hook.warn(
392
+ `datastore: failed to flush "{self.key}" on unload ({#self.queued_updates} changes lost): {tostring(err)}`
393
+ )
342
394
  end
343
395
  fire_hooks(self, "closed", self._hooks.closed)
344
396
  end
@@ -375,24 +427,30 @@ end
375
427
  --- Reads the stored record (get_async) into the local data: migrations are
376
428
  --- applied, then the queued updates replayed on top, so `get_data()` reflects the
377
429
  --- 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.
430
+ --- (keeping the queue) if any session lock is on the record, or if the store's
431
+ --- predicate rejects the stored data. Caller holds the mutex.
379
432
  local function read_record(self: LocklessProfile)
380
433
  local config = self.config
381
434
  local datastore = self._datastore
382
435
 
383
- local record = utils.retry(config, function()
384
- return datastore.get_async(self.key)
385
- end) :: DataRecord?
436
+ local record = utils.get_async(config, datastore, self.key) :: DataRecord?
386
437
 
387
438
  local lock = if record then record.lock else nil
388
439
  if lock and not lock.tx and lock.expires > config._scheduler.now() then
389
440
  process_lock_lost(self)
390
- error(`datastore: "{self.key}" is session locked`)
441
+ error(errors.lockless_locked(self, lock), 0)
391
442
  end
392
443
  -- an expired session lock belongs to a dead server: ignored, the next write clears it
393
444
  -- a transaction lock does not matter for a read: the data under it is the
394
445
  -- data until that transaction's `pending` lands (handled below)
395
446
 
447
+ local stored = if record then record.data else nil
448
+ local passed, predicate_failure = common.run_predicate(config, self.key, stored)
449
+ if not passed then
450
+ -- nothing is adopted: the profile keeps whatever it had and stays open
451
+ error(predicate_failure or errors.predicate_failed(self.key, "fetch", stored, self), 0)
452
+ end
453
+
396
454
  -- a `pending` on the record: find out whether it committed (the record is
397
455
  -- left as is; the next write folds it in)
398
456
  local base: any = nil
@@ -414,22 +472,33 @@ local function read_record(self: LocklessProfile)
414
472
  return self._data
415
473
  end
416
474
 
417
- function lockless_profile.fetch(self: LocklessProfile)
418
- common.assert_open(self)
419
- return self._mutex:with(function()
420
- return read_record(self)
475
+ --- Fails with `lockless_locked` (a session lock is on the record; the profile
476
+ --- is closed), `outdated` (a newer server wrote the record), `predicate_failed`
477
+ --- (the store's predicate rejected the stored data), `migration_mismatch`,
478
+ --- `roblox`, `tx_marker_invalid` or `profile_closed`. An error thrown by a
479
+ --- queued dispatcher propagates as is.
480
+ function lockless_profile.fetch(self: LocklessProfile): types.Result<any>
481
+ return result.capture(function()
482
+ common.assert_open(self)
483
+ return self._mutex:with(function()
484
+ return read_record(self)
485
+ end)
421
486
  end)
422
487
  end
423
488
 
424
489
  --- Queues `dispatcher` to run against the stored data on the next save. When
425
490
  --- the profile is fetched, the local data is advanced right away so `get_data()`
426
491
  --- 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)
492
+ --- no-op and is not queued (Ok(false)). Waits for any in-flight save/fetch on
493
+ --- the mutex. Fails with `profile_closed`; an error thrown by the dispatcher
494
+ --- propagates as is.
495
+ function lockless_profile.update(self: LocklessProfile, dispatcher: (any) -> any): types.Result<boolean>
496
+ return result.capture(function()
497
+ common.assert_open(self)
498
+
499
+ return self._mutex:with(function()
500
+ return queue_update(self, dispatcher)
501
+ end)
433
502
  end)
434
503
  end
435
504
 
@@ -449,9 +518,11 @@ local function run_flush_loop(self: LocklessProfile)
449
518
  continue
450
519
  end
451
520
 
452
- local ok, err = utils.try(self.save, self)
521
+ local ok, res = utils.try(self.save, self)
453
522
  if not ok then
454
- config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(err)}`)
523
+ config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(res)}`)
524
+ elseif not res.success then
525
+ config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(res.error)}`)
455
526
  end
456
527
  end
457
528
  end)
@@ -1,4 +1,6 @@
1
1
  local common = require "./common"
2
+ local errors = require "../errors"
3
+ local result = require "../result"
2
4
  local types = require "../types"
3
5
  local utils = require "../utils"
4
6
 
@@ -16,14 +18,20 @@ peek_profile.__index = peek_profile
16
18
 
17
19
  --- Reads the record with `GetAsync` and adopts it: `lock` and `pending` are
18
20
  --- taken as stored, the data gets the declared migrations applied in memory
19
- --- (nothing is written). A missing key resolves to the template.
21
+ --- (nothing is written). A missing key resolves to the template. Throws
22
+ --- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
20
23
  local function read(self: PeekProfile): any
21
24
  local config = self._config
22
25
  local datastore = self._datastore
23
26
 
24
- local record = utils.retry(config, function()
25
- return datastore.get_async(self.key)
26
- end) :: DataRecord?
27
+ local record = utils.get_async(config, datastore, self.key) :: DataRecord?
28
+
29
+ -- before anything is adopted: a rejected key leaves the snapshot untouched
30
+ local stored = if record then record.data else nil
31
+ local passed, predicate_failure = common.run_predicate(config, self.key, stored)
32
+ if not passed then
33
+ error(predicate_failure or errors.predicate_failed(self.key, "peek", stored), 0)
34
+ end
27
35
 
28
36
  self.lock = if record then record.lock else nil
29
37
  self.pending = if record then record.pending else nil
@@ -34,12 +42,12 @@ local function read(self: PeekProfile): any
34
42
  data = utils.deep_copy(config.template)
35
43
  migrations = common.all_migration_names(config.migrations)
36
44
  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}`)
45
+ local migrated = common.apply_migrations(self.key, record.data, record.migrations or {}, config.migrations)
46
+ if not migrated.success then
47
+ error(migrated.err, 0)
40
48
  end
41
- data = result.result
42
- migrations = result.new_migrations
49
+ data = migrated.result
50
+ migrations = migrated.new_migrations
43
51
  end
44
52
 
45
53
  self.migrations = migrations
@@ -51,14 +59,17 @@ function peek_profile.get_data(self: PeekProfile)
51
59
  return self._data
52
60
  end
53
61
 
54
- --- Calls `get_async` again and replaces the snapshot. Yields.
55
- function peek_profile.refresh(self: PeekProfile)
56
- return read(self)
62
+ --- Calls `get_async` again and replaces the snapshot. Yields. Fails with
63
+ --- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`, leaving
64
+ --- the previous snapshot in place.
65
+ function peek_profile.refresh(self: PeekProfile): types.Result<any>
66
+ return result.capture(read, self)
57
67
  end
58
68
 
59
69
  --- 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
70
+ --- loops and no hooks: it is a plain value that can be refreshed. Fails with
71
+ --- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
72
+ function peek_profile.create(options: PeekOptions): types.Result<PeekProfile>
62
73
  local self = {} :: PeekProfile
63
74
  self.kind = "peek"
64
75
  self.key = options.key
@@ -70,8 +81,11 @@ function peek_profile.create(options: PeekOptions): PeekProfile
70
81
  self._data = nil :: any
71
82
 
72
83
  local profile = setmetatable(self, peek_profile) :: any
73
- read(profile)
74
- return profile
84
+ local first = result.capture(read, profile)
85
+ if not first.success then
86
+ return first
87
+ end
88
+ return result.ok(profile)
75
89
  end
76
90
 
77
91
  return {