@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.
package/src/store.luau CHANGED
@@ -1,5 +1,7 @@
1
1
  --! strict
2
2
 
3
+ local errors = require "./errors"
4
+ local result = require "./result"
3
5
  local types = require "./types"
4
6
  local utils = require "./utils"
5
7
  local roblox_scheduler = require "./schedulers/roblox"
@@ -20,6 +22,7 @@ type Profile<T = any> = types.Profile<T>
20
22
  type LocklessProfile<T = any> = types.LocklessProfile<T>
21
23
  type PeekProfile<T = any> = types.PeekProfile<T>
22
24
  type HooksList<T... = ()> = types.HooksList<T...>
25
+ type Result<T> = types.Result<T>
23
26
 
24
27
  export type Store<T = any> = {
25
28
  _loading: { [string]: boolean },
@@ -43,30 +46,38 @@ export type Store<T = any> = {
43
46
  },
44
47
 
45
48
  config: ResolvedConfig<T>,
46
- load: (self: Store<T>, key: string, user_ids: { number }) -> Profile<T>,
47
- wait_loaded: (self: Store<T>, key: string, user_ids: { number }) -> Profile<T>,
49
+ --- Takes the session lock for `key` and loads it. Yields. Fails with
50
+ --- `store_closed`, `already_loaded`, `timeout`, `roblox`, `outdated` (a
51
+ --- newer server wrote the record; no lock is taken), `migration_mismatch`
52
+ --- or `tx_marker_invalid`.
53
+ load: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
54
+ --- Returns the profile for `key`, waiting for it to load or loading it if
55
+ --- nobody has yet. Waiters get the same Result as the load.
56
+ wait_loaded: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
48
57
  get_loaded: (self: Store<T>, key: string) -> Profile<T>?,
49
58
 
50
59
  --- Returns the lockless profile for `key`, creating it if needed. Never
51
- --- yields: a lockless profile does no storage work until `fetch`.
60
+ --- yields: a lockless profile does no storage work until `fetch`. Throws a
61
+ --- `store_closed` error on a closed store.
52
62
  get_lockless: (self: Store<T>, key: string, user_ids: { number }) -> LocklessProfile<T>,
53
63
 
54
64
  --- Read-only snapshot of `key` via `GetAsync`: the current data and lock.
55
65
  --- Yields. The result is not tracked by the store, takes no lock and cannot
56
- --- join a transaction.
57
- peek: (self: Store<T>, key: string) -> PeekProfile<T>,
66
+ --- join a transaction. Fails with `store_closed`, `roblox`, `outdated` or
67
+ --- `migration_mismatch`.
68
+ peek: (self: Store<T>, key: string) -> Result<PeekProfile<T>>,
58
69
 
59
70
  --- Atomically updates profiles of this store (locked or lockless). `transform`
60
71
  --- gets their data in order and returns the new data in the same order, or
61
- --- false to cancel. Returns false when cancelled, true when committed; throws
62
- --- when aborted. `config` fills in what the store does not know (ttls, retry);
63
- --- hook and scheduler default to the store's.
72
+ --- false to cancel. Ok(false) when cancelled, Ok(true) when committed; fails
73
+ --- like `dataforge.transaction`. `config` fills in what the store does not
74
+ --- know (ttls, retry); hook and scheduler default to the store's.
64
75
  transaction: (
65
76
  self: Store<T>,
66
77
  profiles: Array<ProfileBase<T>>,
67
78
  transform: (data: Array<T>) -> Array<T> | false,
68
79
  config: types.TransactionConfig?
69
- ) -> boolean,
80
+ ) -> Result<boolean>,
70
81
 
71
82
  on_change: (self: Store<T>, callback: (profile: Profile<T>, new: T, old: T?) -> ()) -> (),
72
83
  on_save: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
@@ -77,7 +88,9 @@ export type Store<T = any> = {
77
88
  close: (self: Store<T>) -> (),
78
89
  }
79
90
 
80
- local function resume_load_locks(self: Store, key: string, ok: boolean, result: any)
91
+ --- Resumes every `wait_loaded` caller with the load's Result, or with a raw
92
+ --- error (`res == nil`) they rethrow.
93
+ local function resume_load_locks(self: Store, key: string, res: Result<Profile>?, raw: any?)
81
94
  local loading_locks = self._loading_locks[key]
82
95
  if not loading_locks then
83
96
  return
@@ -85,7 +98,7 @@ local function resume_load_locks(self: Store, key: string, ok: boolean, result:
85
98
  self._loading_locks[key] = nil
86
99
 
87
100
  for _, thread in loading_locks do
88
- self.config._scheduler.spawn(thread, ok, result)
101
+ self.config._scheduler.spawn(thread, res, raw)
89
102
  end
90
103
  end
91
104
 
@@ -202,16 +215,16 @@ function store.create<T>(config: Config<T>): Store<T>
202
215
  return setmetatable(self, store) :: any
203
216
  end
204
217
 
205
- function store.load(self: Store, key: string, user_ids: { number }): Profile
218
+ function store.load(self: Store, key: string, user_ids: { number }): Result<Profile>
206
219
  if self.closed then
207
- error "datastore: store is closed"
220
+ return result.err(errors.store_closed(self))
208
221
  end
209
222
  if self.profiles[key] ~= nil or self._loading[key] then
210
- error(`datastore: "{key}" is already loaded in this store`)
223
+ return result.err(errors.already_loaded(self, key))
211
224
  end
212
225
  self._loading[key] = true
213
226
 
214
- local ok, profile = pcall(profile.load, {
227
+ local ok, loaded = utils.try(profile.load, {
215
228
  key = key,
216
229
  config = self.config,
217
230
  user_ids = user_ids,
@@ -220,29 +233,46 @@ function store.load(self: Store, key: string, user_ids: { number }): Profile
220
233
  self._loading[key] = nil
221
234
 
222
235
  if not ok then
223
- resume_load_locks(self, key, false, profile)
224
- error(profile, 0)
236
+ if errors.is(loaded) then
237
+ local failure = result.err(loaded)
238
+ resume_load_locks(self, key, failure)
239
+ return failure
240
+ end
241
+ -- a migration threw: everyone waiting gets the raw error too
242
+ resume_load_locks(self, key, nil, loaded)
243
+ error(loaded, 0)
225
244
  end
226
245
 
227
- profile:on_closed(function()
228
- unregister_profile(self, profile)
246
+ if self.closed then
247
+ -- the store closed while this load was in flight: give the lock back
248
+ -- right away instead of registering a profile nobody will unload
249
+ loaded:unload()
250
+ local failure = result.err(errors.store_closed(self))
251
+ resume_load_locks(self, key, failure)
252
+ return failure
253
+ end
254
+
255
+ loaded:on_closed(function()
256
+ unregister_profile(self, loaded)
229
257
  end)
230
- forward_hooks(self, profile)
258
+ forward_hooks(self, loaded)
231
259
 
232
- self.profiles[key] = profile
233
- resume_load_locks(self, key, ok, profile)
234
- return profile
260
+ self.profiles[key] = loaded
261
+ local success = result.ok(loaded)
262
+ resume_load_locks(self, key, success)
263
+ return success
235
264
  end
236
265
 
237
- --- Returns the profile for `key`, waiting for it to load or loading it if nobody has yet.
238
- function store.wait_loaded(self: Store, key: string, user_ids: { number }): Profile
266
+ --- Returns the profile for `key`, waiting for it to load or loading it if
267
+ --- nobody has yet. Waiters get the same Result (or raw error) as the load.
268
+ function store.wait_loaded(self: Store, key: string, user_ids: { number }): Result<Profile>
239
269
  if self.closed then
240
- error "datastore: store is closed"
270
+ return result.err(errors.store_closed(self))
241
271
  end
242
272
 
243
273
  local existing = self.profiles[key]
244
274
  if existing ~= nil then
245
- return existing
275
+ return result.ok(existing)
246
276
  end
247
277
 
248
278
  if not self._loading[key] then
@@ -250,12 +280,11 @@ function store.wait_loaded(self: Store, key: string, user_ids: { number }): Prof
250
280
  end
251
281
  insert_loaded_lock(self, key)
252
282
 
253
- local ok, result = coroutine.yield()
254
- if not ok then
255
- error(result, 0)
283
+ local res, raw = coroutine.yield()
284
+ if res == nil then
285
+ error(raw, 0)
256
286
  end
257
-
258
- return result
287
+ return res
259
288
  end
260
289
 
261
290
  function store.get_loaded(self: Store, key: string): Profile?
@@ -264,7 +293,7 @@ end
264
293
 
265
294
  function store.get_lockless(self: Store, key: string, user_ids: { number }): LocklessProfile
266
295
  if self.closed then
267
- error "datastore: store is closed"
296
+ error(errors.store_closed(self), 0)
268
297
  end
269
298
 
270
299
  local existing = self.lockless_profiles[key]
@@ -292,9 +321,9 @@ end
292
321
 
293
322
  --- One `get_async` on `key`, no write. Never registered in the store: `close`
294
323
  --- does not know about it and hooks do not fire for it.
295
- function store.peek(self: Store, key: string): PeekProfile
324
+ function store.peek(self: Store, key: string): Result<PeekProfile>
296
325
  if self.closed then
297
- error "datastore: store is closed"
326
+ return result.err(errors.store_closed(self))
298
327
  end
299
328
 
300
329
  return peek_profile.create {
@@ -317,9 +346,9 @@ function store.transaction(
317
346
  profiles: Array<ProfileBase>,
318
347
  transform: (data: Array<any>) -> Array<any> | false,
319
348
  config: types.TransactionConfig?
320
- ): boolean
349
+ ): Result<boolean>
321
350
  if self.closed then
322
- error "datastore: store is closed"
351
+ return result.err(errors.store_closed(self))
323
352
  end
324
353
 
325
354
  local resolved = tx.resolve_config(config, {
@@ -335,11 +364,11 @@ function store.transaction(
335
364
  datas[index] = ctx:get(participant)
336
365
  end
337
366
 
338
- local result = transform(datas)
339
- if result == false then
367
+ local transformed = transform(datas)
368
+ if transformed == false then
340
369
  return false
341
370
  end
342
- local new_datas = result :: Array<any>
371
+ local new_datas = transformed :: Array<any>
343
372
 
344
373
  for index, participant in profiles do
345
374
  local new = new_datas[index]
@@ -353,7 +382,9 @@ function store.transaction(
353
382
  end
354
383
 
355
384
  --- Unloads every profile (locked and lockless) concurrently and waits for all
356
- --- of them. Loads and transactions are rejected from the first line on.
385
+ --- of them, and for any load still in flight (which fails with `store_closed`
386
+ --- and releases its lock). Loads and transactions are rejected from the first
387
+ --- line on.
357
388
  function store.close(self: Store)
358
389
  if self.closed then
359
390
  return
@@ -379,12 +410,29 @@ function store.close(self: Store)
379
410
  end)
380
411
  end
381
412
 
413
+ --- A load still in flight: `load` sees `closed` when it finishes and gives
414
+ --- the lock back itself; wait for that like for an unload.
415
+ local function wait_load(key: string)
416
+ remaining += 1
417
+ scheduler.spawn(function()
418
+ insert_loaded_lock(self, key)
419
+ coroutine.yield()
420
+ remaining -= 1
421
+ if remaining == 0 and waiting then
422
+ scheduler.spawn(thread)
423
+ end
424
+ end)
425
+ end
426
+
382
427
  for _, profile in self.profiles do
383
428
  unload(profile)
384
429
  end
385
430
  for _, profile in self.lockless_profiles do
386
431
  unload(profile)
387
432
  end
433
+ for key in self._loading do
434
+ wait_load(key)
435
+ end
388
436
 
389
437
  if remaining > 0 then
390
438
  waiting = true
@@ -7,6 +7,8 @@
7
7
  foreign `pending` entries.
8
8
  ]]
9
9
 
10
+ local errors = require "../errors"
11
+ local result = require "../result"
10
12
  local types = require "../types"
11
13
  local utils = require "../utils"
12
14
  local roblox_scheduler = require "../schedulers/roblox"
@@ -25,6 +27,7 @@ type ResolvedTransactionConfig = types.ResolvedTransactionConfig
25
27
 
26
28
  type TxOutcome = types.TxOutcome
27
29
  type TxPatch = types.TxPatch
30
+ type Error = types.Error
28
31
  export type TxContext = types.TxContext
29
32
 
30
33
  local tx_handle = {}
@@ -50,19 +53,17 @@ local function resolve_marker_in(
50
53
  id: string,
51
54
  fallback: TxOutcome
52
55
  ): 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)
56
+ local outcome = utils.update_async(config, datastore, id, function(current: any?)
57
+ if current == "committed" or current == "aborted" then
58
+ return current
59
+ end
60
+ return fallback
60
61
  end)
61
62
 
62
- if result ~= "committed" and result ~= "aborted" then
63
- error(`datastore: unexpected tx marker value for {id}: {tostring(result)}`)
63
+ if outcome ~= "committed" and outcome ~= "aborted" then
64
+ error(errors.tx_marker_invalid(id, outcome), 0)
64
65
  end
65
- return result :: TxOutcome
66
+ return outcome :: TxOutcome
66
67
  end
67
68
 
68
69
  --- Reads the marker for `id`, writing `fallback` if it has not been decided yet.
@@ -154,42 +155,47 @@ local function prepare_sort(a: TxPatch, b: TxPatch): boolean
154
155
  return sort_key(a.profile) < sort_key(b.profile)
155
156
  end
156
157
 
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>)
158
+ --- Locks every profile mutex in order, appending each one taken to `locked`
159
+ --- so a rejection midway (a mutex closed while waiting) leaves an exact list
160
+ --- for `unlock_all`.
161
+ local function lock_all(profiles: Array<ProfileBase>, locked: Array<ProfileBase>)
161
162
  for _, participant in profiles do
162
163
  participant._mutex:lock()
164
+ table.insert(locked, participant)
163
165
  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
166
+ end
167
+
168
+ --- Releases the mutexes in reverse and, for a lockless profile still holding
169
+ --- its storage transaction lock (the transaction ended before writing
170
+ --- `pending`), gives the key back.
171
+ local function unlock_all(locked: Array<ProfileBase>)
172
+ for index = #locked, 1, -1 do
173
+ local participant = locked[index]
174
+ local lockless = participant :: LocklessProfile
175
+ if participant.kind == "lockless" and lockless._tx_lock then
176
+ local ok, err = utils.try(function()
177
+ lockless:_write_queued {
178
+ old_tx_lock = lockless._tx_lock,
179
+ }
180
+ end)
181
+ if not ok then
182
+ participant.config._hook.warn(
183
+ `datastore: failed to release tx lock on "{participant.key}" (it will expire): {tostring(err)}`
184
+ )
185
+ lockless._tx_lock = nil
180
186
  end
181
- participant._mutex:unlock()
182
187
  end
188
+ participant._mutex:unlock()
183
189
  end
184
190
  end
185
191
 
186
192
  local function assert_open_profiles(profiles: Array<ProfileBase>)
187
193
  for _, participant in profiles do
188
194
  if not participant.open then
189
- error(`datastore: transaction failed, profile "{participant.key}" is closed`)
195
+ error(errors.closed(participant), 0)
190
196
  end
191
197
  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`)
198
+ error(errors.released(participant), 0)
193
199
  end
194
200
  end
195
201
  end
@@ -230,7 +236,10 @@ local function locked_patch(participant: Profile, new_data: any): TxPatch
230
236
  patch.prepared = false
231
237
 
232
238
  function patch.prepare(pending)
233
- return participant:_write_record { release = false, pending = pending }
239
+ local written, lock = participant:_write_record { release = false, pending = pending }
240
+ if not written then
241
+ error(errors.lock_lost(participant, lock), 0)
242
+ end
234
243
  end
235
244
  function patch.commit(operation)
236
245
  table.insert(participant._cleanup_txs, operation)
@@ -256,13 +265,13 @@ local function lockless_patch(participant: LocklessProfile, new_data: any): TxPa
256
265
  patch.prepared = false
257
266
 
258
267
  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
268
+ -- releases the transaction lock taken in `adquire_lockless_lock`; throws
269
+ -- `tx_lock_lost` (nothing written) if that lock is gone or someone
270
+ -- else's by now
261
271
  participant:_write_queued {
262
272
  pending = pending,
263
273
  old_tx_lock = participant._tx_lock,
264
274
  }
265
- return true
266
275
  end
267
276
  function patch.commit(operation)
268
277
  participant._known_outcomes[operation.id] = "committed"
@@ -287,13 +296,16 @@ local function make_patch(participant: ProfileBase, new_data: any): TxPatch
287
296
  end
288
297
 
289
298
  --- 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.
299
+ --- and commits every `ctx:set` atomically. Ok(false) when `process` returns
300
+ --- false, Ok(true) when committed. Fails with `tx_aborted` (its `cause` says
301
+ --- why phase 1 gave up), `profile_closed`, `released`, or what flushing a
302
+ --- lockless participant raised (`lockless_locked`, `outdated`, `roblox`, ...).
303
+ --- Argument misuse throws; an error thrown by `process` propagates as is.
292
304
  function tx_handle.run(
293
305
  config: ResolvedTransactionConfig,
294
306
  profiles: Array<ProfileBase>,
295
307
  process: (ctx: TxContext) -> boolean?
296
- ): boolean
308
+ ): types.Result<boolean>
297
309
  local seen: { [ProfileBase]: boolean } = {}
298
310
  local profile_list: Array<ProfileBase> = {}
299
311
 
@@ -301,18 +313,18 @@ function tx_handle.run(
301
313
  if not is_profile(profile) then
302
314
  error "datastore: transaction expects profiles"
303
315
  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
316
  if seen[profile] then
311
317
  error "datastore: a profile was provided twice in a transaction"
312
318
  end
313
319
  if profile.config._hook ~= config._hook then
314
320
  error "datastore: all profiles in a transaction must share the transaction's hook"
315
321
  end
322
+ if not profile.open then
323
+ return result.err(errors.closed(profile))
324
+ end
325
+ if profile.kind == "locked" and not (profile :: Profile).is_locked then
326
+ return result.err(errors.released(profile))
327
+ end
316
328
 
317
329
  seen[profile] = true
318
330
  table.insert(profile_list, profile)
@@ -323,9 +335,12 @@ function tx_handle.run(
323
335
  end
324
336
  profile_list = utils.sorted(profile_list, profile_sort)
325
337
 
326
- local unlock_all = lock_profiles(profile_list)
338
+ local locked: Array<ProfileBase> = {}
327
339
 
328
- local ok, result: boolean = utils.try(function()
340
+ local ok, value = utils.try(function()
341
+ -- inside the protected region: a mutex closed while waiting rejects the
342
+ -- lock, and the ones already taken must still be given back
343
+ lock_all(profile_list, locked)
329
344
  assert_open_profiles(profile_list)
330
345
  -- kind of an oxymoron
331
346
  adquire_lockless_lock(config, profile_list)
@@ -385,7 +400,10 @@ function tx_handle.run(
385
400
  -- Phase 1: every changed participant persists its post-tx data as `pending`.
386
401
  local order = utils.sorted(patches, prepare_sort)
387
402
  local desired: TxOutcome = "committed"
388
- local reason = ""
403
+ -- why phase 1 gave up: a library error (reported as the abort's cause)
404
+ -- or a raw one (an internal invariant; rethrown as is after settling)
405
+ local cause: Error? = nil
406
+ local raw: any = nil
389
407
  local expires = config._scheduler.now() + config.tx_ttl
390
408
  local store_name = config._datastore.name
391
409
 
@@ -396,13 +414,16 @@ function tx_handle.run(
396
414
  expires = expires,
397
415
  data = patch.new_data,
398
416
  }
399
- local write_ok, prepared = utils.try(patch.prepare, pending)
400
- if write_ok and prepared == true then
417
+ local write_ok, err = utils.try(patch.prepare, pending)
418
+ if write_ok then
401
419
  patch.prepared = true
402
420
  else
403
421
  desired = "aborted"
404
- local key = patch.profile.key
405
- reason = if not write_ok then tostring(prepared) else `lock lost for "{key}"`
422
+ if errors.is(err) then
423
+ cause = err
424
+ else
425
+ raw = err
426
+ end
406
427
  break
407
428
  end
408
429
  end
@@ -414,14 +435,16 @@ function tx_handle.run(
414
435
  if outcome == "committed" then
415
436
  return true
416
437
  end
417
- error(`datastore: transaction aborted: {reason}`)
438
+ if raw ~= nil then
439
+ error(raw, 0)
440
+ end
441
+ -- no cause: every prepare went through but the marker was already
442
+ -- resolved as aborted by another server (the pending's grace expired)
443
+ error(errors.tx_aborted(id, cause), 0)
418
444
  end)
419
445
 
420
- unlock_all()
421
- if not ok then
422
- error(result)
423
- end
424
- return result
446
+ unlock_all(locked)
447
+ return result.of(ok, value)
425
448
  end
426
449
 
427
450
  return tx_handle
@@ -1,3 +1,4 @@
1
+ local errors = require "../errors"
1
2
  local types = require "../types"
2
3
 
3
4
  export type TxOperation = types.TxOperation
@@ -23,14 +24,16 @@ local function resolve_marker(self: TxOperation): TxOutcome
23
24
  end)
24
25
 
25
26
  if result ~= "committed" and result ~= "aborted" then
26
- error(`datastore: unexpected tx marker value for {id}: {tostring(result)}`)
27
+ error(errors.tx_marker_invalid(id, result), 0)
27
28
  end
28
29
  return result :: TxOutcome
29
30
  end
30
31
 
31
32
  --- 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.
33
+ --- answers with a valid outcome), then applies the outcome to every patch
34
+ --- exactly once. Never fails: every participant mutex is held meanwhile, so
35
+ --- giving up would leave them without an outcome. Safe to call from several
36
+ --- coroutines: later callers wait for the first resolution.
34
37
  function tx_operation.settle(self: TxOperation): TxOutcome
35
38
  local scheduler = self.config._scheduler
36
39