@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.
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,31 +46,42 @@ 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), `predicate_failed`
52
+ --- (`config.predicate` rejected the stored data; no lock is taken),
53
+ --- `migration_mismatch` or `tx_marker_invalid`.
54
+ load: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
55
+ --- Returns the profile for `key`, waiting for it to load or loading it if
56
+ --- nobody has yet. Waiters get the same Result as the load.
57
+ wait_loaded: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
48
58
  get_loaded: (self: Store<T>, key: string) -> Profile<T>?,
49
59
 
50
60
  --- Returns the lockless profile for `key`, creating it if needed. Never
51
- --- yields: a lockless profile does no storage work until `fetch`.
61
+ --- yields: a lockless profile does no storage work until `fetch`. Throws a
62
+ --- `store_closed` error on a closed store.
52
63
  get_lockless: (self: Store<T>, key: string, user_ids: { number }) -> LocklessProfile<T>,
53
64
 
54
65
  --- Read-only snapshot of `key` via `GetAsync`: the current data and lock.
55
66
  --- 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>,
67
+ --- join a transaction. Fails with `store_closed`, `roblox`, `outdated`,
68
+ --- `predicate_failed` or `migration_mismatch`.
69
+ peek: (self: Store<T>, key: string) -> Result<PeekProfile<T>>,
58
70
 
59
71
  --- Atomically updates profiles of this store (locked or lockless). `transform`
60
72
  --- 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.
73
+ --- false to cancel. Ok(false) when cancelled, Ok(true) when committed; fails
74
+ --- like `dataforge.transaction`. `config` fills in what the store does not
75
+ --- know (ttls, retry); hook and scheduler default to the store's.
64
76
  transaction: (
65
77
  self: Store<T>,
66
78
  profiles: Array<ProfileBase<T>>,
67
79
  transform: (data: Array<T>) -> Array<T> | false,
68
80
  config: types.TransactionConfig?
69
- ) -> boolean,
81
+ ) -> Result<boolean>,
70
82
 
83
+ --- Fires for every data change of every profile of this store, including
84
+ --- the data a key loads with (`old == nil` marks that initial value).
71
85
  on_change: (self: Store<T>, callback: (profile: Profile<T>, new: T, old: T?) -> ()) -> (),
72
86
  on_save: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
73
87
  on_closing: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
@@ -77,7 +91,9 @@ export type Store<T = any> = {
77
91
  close: (self: Store<T>) -> (),
78
92
  }
79
93
 
80
- local function resume_load_locks(self: Store, key: string, ok: boolean, result: any)
94
+ --- Resumes every `wait_loaded` caller with the load's Result, or with a raw
95
+ --- error (`res == nil`) they rethrow.
96
+ local function resume_load_locks(self: Store, key: string, res: Result<Profile>?, raw: any?)
81
97
  local loading_locks = self._loading_locks[key]
82
98
  if not loading_locks then
83
99
  return
@@ -85,7 +101,7 @@ local function resume_load_locks(self: Store, key: string, ok: boolean, result:
85
101
  self._loading_locks[key] = nil
86
102
 
87
103
  for _, thread in loading_locks do
88
- self.config._scheduler.spawn(thread, ok, result)
104
+ self.config._scheduler.spawn(thread, res, raw)
89
105
  end
90
106
  end
91
107
 
@@ -100,6 +116,9 @@ local function insert_loaded_lock(self: Store, key: string)
100
116
  table.insert(waiters, thread)
101
117
  end
102
118
 
119
+ --- Drops `profile` from the registry, unless the key has already been taken by
120
+ --- a newer one: with instant detach a new profile may be registered while the
121
+ --- old one is still writing, and the old one's late unregister must not evict it.
103
122
  local function unregister_profile(self: Store, profile: Profile)
104
123
  local key = profile.key
105
124
  if self.profiles[key] == profile then
@@ -145,6 +164,10 @@ function store.create<T>(config: Config<T>): Store<T>
145
164
  assert(type(config) == "table", "datastore: config must be a table")
146
165
  assert(type(config.name) == "string" and #config.name > 0, "datastore: config.name is required")
147
166
  assert(config.template ~= nil, "datastore: config.template is required")
167
+ assert(
168
+ config.predicate == nil or type(config.predicate) == "function",
169
+ "datastore: config.predicate must be a function"
170
+ )
148
171
 
149
172
  local lock_ttl = config.lock_ttl or 60
150
173
  local hook = config._hook
@@ -168,6 +191,7 @@ function store.create<T>(config: Config<T>): Store<T>
168
191
  _scheduler = scheduler,
169
192
 
170
193
  migrations = config.migrations or {},
194
+ predicate = config.predicate,
171
195
 
172
196
  autosave_interval = config.autosave_interval or 30,
173
197
  flush_interval = config.flush_interval or config.autosave_interval or 30,
@@ -202,16 +226,19 @@ function store.create<T>(config: Config<T>): Store<T>
202
226
  return setmetatable(self, store) :: any
203
227
  end
204
228
 
205
- function store.load(self: Store, key: string, user_ids: { number }): Profile
229
+ function store.load(self: Store, key: string, user_ids: { number }): Result<Profile>
206
230
  if self.closed then
207
- error "datastore: store is closed"
231
+ return result.err(errors.store_closed(self))
208
232
  end
209
- if self.profiles[key] ~= nil or self._loading[key] then
210
- error(`datastore: "{key}" is already loaded in this store`)
233
+ -- a closing profile detaches before its final write, so it is normally gone
234
+ -- from the registry already; the `open` check is belt and braces
235
+ local existing = self.profiles[key]
236
+ if (existing ~= nil and existing.open) or self._loading[key] then
237
+ return result.err(errors.already_loaded(self, key))
211
238
  end
212
239
  self._loading[key] = true
213
240
 
214
- local ok, profile = pcall(profile.load, {
241
+ local ok, loaded = utils.try(profile.load, {
215
242
  key = key,
216
243
  config = self.config,
217
244
  user_ids = user_ids,
@@ -220,29 +247,58 @@ function store.load(self: Store, key: string, user_ids: { number }): Profile
220
247
  self._loading[key] = nil
221
248
 
222
249
  if not ok then
223
- resume_load_locks(self, key, false, profile)
224
- error(profile, 0)
250
+ if errors.is(loaded) then
251
+ local failure = result.err(loaded)
252
+ resume_load_locks(self, key, failure)
253
+ return failure
254
+ end
255
+ -- a migration threw: everyone waiting gets the raw error too
256
+ resume_load_locks(self, key, nil, loaded)
257
+ error(loaded, 0)
225
258
  end
226
259
 
227
- profile:on_closed(function()
228
- unregister_profile(self, profile)
229
- end)
230
- forward_hooks(self, profile)
260
+ if self.closed then
261
+ -- the store closed while this load was in flight: give the lock back
262
+ -- right away instead of registering a profile nobody will unload
263
+ loaded:unload()
264
+ local failure = result.err(errors.store_closed(self))
265
+ resume_load_locks(self, key, failure)
266
+ return failure
267
+ end
231
268
 
232
- self.profiles[key] = profile
233
- resume_load_locks(self, key, ok, profile)
234
- return profile
269
+ -- dropped from the registry the moment it stops being usable, so the key can
270
+ -- be loaded again while the old session's final write is still in flight
271
+ loaded._on_detach = function()
272
+ unregister_profile(self, loaded)
273
+ end
274
+ -- safety net: a profile that closes some other way still unregisters
275
+ loaded:on_closed(function()
276
+ unregister_profile(self, loaded)
277
+ end)
278
+ forward_hooks(self, loaded)
279
+
280
+ self.profiles[key] = loaded
281
+ -- a load's initial data never reaches the profile's own hooks (they are
282
+ -- attached after `profile.load` set it), so report it here: `old == nil`
283
+ -- marks a key that just appeared. Lockless profiles need no equivalent:
284
+ -- `get_lockless` forwards their hooks before any `fetch` sets data.
285
+ fire_hooks(self, self._hooks.change, loaded, loaded:get_data(), nil)
286
+
287
+ local success = result.ok(loaded)
288
+ resume_load_locks(self, key, success)
289
+ return success
235
290
  end
236
291
 
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
292
+ --- Returns the profile for `key`, waiting for it to load or loading it if
293
+ --- nobody has yet. Waiters get the same Result (or raw error) as the load.
294
+ function store.wait_loaded(self: Store, key: string, user_ids: { number }): Result<Profile>
239
295
  if self.closed then
240
- error "datastore: store is closed"
296
+ return result.err(errors.store_closed(self))
241
297
  end
242
298
 
243
299
  local existing = self.profiles[key]
244
300
  if existing ~= nil then
245
- return existing
301
+ return result.ok(existing)
246
302
  end
247
303
 
248
304
  if not self._loading[key] then
@@ -250,12 +306,11 @@ function store.wait_loaded(self: Store, key: string, user_ids: { number }): Prof
250
306
  end
251
307
  insert_loaded_lock(self, key)
252
308
 
253
- local ok, result = coroutine.yield()
254
- if not ok then
255
- error(result, 0)
309
+ local res, raw = coroutine.yield()
310
+ if res == nil then
311
+ error(raw, 0)
256
312
  end
257
-
258
- return result
313
+ return res
259
314
  end
260
315
 
261
316
  function store.get_loaded(self: Store, key: string): Profile?
@@ -264,7 +319,7 @@ end
264
319
 
265
320
  function store.get_lockless(self: Store, key: string, user_ids: { number }): LocklessProfile
266
321
  if self.closed then
267
- error "datastore: store is closed"
322
+ error(errors.store_closed(self), 0)
268
323
  end
269
324
 
270
325
  local existing = self.lockless_profiles[key]
@@ -292,9 +347,9 @@ end
292
347
 
293
348
  --- One `get_async` on `key`, no write. Never registered in the store: `close`
294
349
  --- does not know about it and hooks do not fire for it.
295
- function store.peek(self: Store, key: string): PeekProfile
350
+ function store.peek(self: Store, key: string): Result<PeekProfile>
296
351
  if self.closed then
297
- error "datastore: store is closed"
352
+ return result.err(errors.store_closed(self))
298
353
  end
299
354
 
300
355
  return peek_profile.create {
@@ -317,9 +372,9 @@ function store.transaction(
317
372
  profiles: Array<ProfileBase>,
318
373
  transform: (data: Array<any>) -> Array<any> | false,
319
374
  config: types.TransactionConfig?
320
- ): boolean
375
+ ): Result<boolean>
321
376
  if self.closed then
322
- error "datastore: store is closed"
377
+ return result.err(errors.store_closed(self))
323
378
  end
324
379
 
325
380
  local resolved = tx.resolve_config(config, {
@@ -335,11 +390,11 @@ function store.transaction(
335
390
  datas[index] = ctx:get(participant)
336
391
  end
337
392
 
338
- local result = transform(datas)
339
- if result == false then
393
+ local transformed = transform(datas)
394
+ if transformed == false then
340
395
  return false
341
396
  end
342
- local new_datas = result :: Array<any>
397
+ local new_datas = transformed :: Array<any>
343
398
 
344
399
  for index, participant in profiles do
345
400
  local new = new_datas[index]
@@ -353,7 +408,9 @@ function store.transaction(
353
408
  end
354
409
 
355
410
  --- Unloads every profile (locked and lockless) concurrently and waits for all
356
- --- of them. Loads and transactions are rejected from the first line on.
411
+ --- of them, and for any load still in flight (which fails with `store_closed`
412
+ --- and releases its lock). Loads and transactions are rejected from the first
413
+ --- line on.
357
414
  function store.close(self: Store)
358
415
  if self.closed then
359
416
  return
@@ -379,12 +436,40 @@ function store.close(self: Store)
379
436
  end)
380
437
  end
381
438
 
439
+ --- A load still in flight: `load` sees `closed` when it finishes and gives
440
+ --- the lock back itself; wait for that like for an unload.
441
+ local function wait_load(key: string)
442
+ remaining += 1
443
+ scheduler.spawn(function()
444
+ insert_loaded_lock(self, key)
445
+ coroutine.yield()
446
+ remaining -= 1
447
+ if remaining == 0 and waiting then
448
+ scheduler.spawn(thread)
449
+ end
450
+ end)
451
+ end
452
+
453
+ -- snapshots: `unload` spawns a thread that runs up to its first yield right
454
+ -- away, and a detaching profile removes itself from these tables
455
+ local locked: Array<Profile> = {}
382
456
  for _, profile in self.profiles do
383
- unload(profile)
457
+ table.insert(locked, profile)
384
458
  end
459
+ local lockless: Array<LocklessProfile> = {}
385
460
  for _, profile in self.lockless_profiles do
461
+ table.insert(lockless, profile)
462
+ end
463
+
464
+ for _, profile in locked do
386
465
  unload(profile)
387
466
  end
467
+ for _, profile in lockless do
468
+ unload(profile)
469
+ end
470
+ for key in self._loading do
471
+ wait_load(key)
472
+ end
388
473
 
389
474
  if remaining > 0 then
390
475
  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