@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.
- package/README.md +1 -0
- package/default.project.json +6 -0
- package/index.d.ts +423 -0
- package/package.json +32 -0
- package/src/hooks/memory.luau +186 -0
- package/src/hooks/roblox.luau +68 -0
- package/src/init.luau +58 -0
- package/src/mutex.luau +107 -0
- package/src/profile/common.luau +102 -0
- package/src/profile/lockless.luau +502 -0
- package/src/profile/peek.luau +79 -0
- package/src/profile/profile.luau +498 -0
- package/src/schedulers/roblox.luau +18 -0
- package/src/schedulers/virtual.luau +128 -0
- package/src/schedulers/zune.luau +18 -0
- package/src/store.luau +409 -0
- package/src/tx/handle.luau +427 -0
- package/src/tx/operation.luau +117 -0
- package/src/types.luau +375 -0
- package/src/utils.luau +104 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
local common = require "./common"
|
|
2
|
+
local mutex = require "../mutex"
|
|
3
|
+
local tx_handle = require "../tx/handle"
|
|
4
|
+
local types = require "../types"
|
|
5
|
+
local utils = require "../utils"
|
|
6
|
+
|
|
7
|
+
type Array<T> = { T }
|
|
8
|
+
type LoadOptions = types.LoadOptions
|
|
9
|
+
type DataRecord = types.DataRecord
|
|
10
|
+
export type LocklessProfile<T = any> = types.LocklessProfile<T>
|
|
11
|
+
|
|
12
|
+
local lockless_profile = {}
|
|
13
|
+
lockless_profile.__index = lockless_profile
|
|
14
|
+
|
|
15
|
+
function lockless_profile.get_data(self: LocklessProfile)
|
|
16
|
+
common.assert_open(self)
|
|
17
|
+
if not self.fetched then
|
|
18
|
+
error(`datastore: lockless profile "{self.key}" has not been fetched`)
|
|
19
|
+
end
|
|
20
|
+
return self._data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
--- The data a record stands for once its `pending` (if any) is accounted for:
|
|
24
|
+
--- `pending.data` when that transaction is known to have committed, the plain
|
|
25
|
+
--- data otherwise. Returns nil when the outcome is not known yet; pure, so it
|
|
26
|
+
--- can run inside a transform (the caller resolves the marker outside).
|
|
27
|
+
local function pending_data(self: LocklessProfile, record: DataRecord): (boolean, any)
|
|
28
|
+
local pending = record.pending
|
|
29
|
+
if not pending then
|
|
30
|
+
return true, record.data
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
local outcome = self._known_outcomes[pending.tx_id]
|
|
34
|
+
if outcome == nil then
|
|
35
|
+
return false, nil
|
|
36
|
+
end
|
|
37
|
+
if outcome == "committed" then
|
|
38
|
+
return true, pending.data
|
|
39
|
+
end
|
|
40
|
+
return true, record.data
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
--- Learns the outcome of a foreign `pending`. Until the pending's `expires` it
|
|
44
|
+
--- is given time (its coordinator may still be running); after that the marker
|
|
45
|
+
--- is force-resolved with fallback "aborted". Yields, so never inside a transform.
|
|
46
|
+
local function resolve_unknown_pending(self: LocklessProfile, pending: types.Pending)
|
|
47
|
+
local config = self.config
|
|
48
|
+
while true do
|
|
49
|
+
local outcome: types.TxOutcome? = tx_handle.resolve_pending(config._hook, config, pending, false)
|
|
50
|
+
if outcome ~= nil then
|
|
51
|
+
self._known_outcomes[pending.tx_id] = outcome :: types.TxOutcome
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
config._scheduler.wait(config.load_poll)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local function resolve_record(self: LocklessProfile, record: DataRecord?, data: any): (any, { string })
|
|
59
|
+
local config = self.config
|
|
60
|
+
local applied: { string } = {}
|
|
61
|
+
|
|
62
|
+
if record then
|
|
63
|
+
applied = record.migrations or {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if data == nil then
|
|
67
|
+
return utils.deep_copy(config.template), common.all_migration_names(config.migrations)
|
|
68
|
+
end
|
|
69
|
+
|
|
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}`)
|
|
73
|
+
end
|
|
74
|
+
return result.result, result.new_migrations
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
local function replay(queue: Array<types.DataDispatcher<any>>, data: any): any
|
|
78
|
+
for _, dispatcher in queue do
|
|
79
|
+
local result = dispatcher(data)
|
|
80
|
+
if result ~= nil and result ~= false then
|
|
81
|
+
data = result
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
return data
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local function fire_hooks<T...>(self: LocklessProfile, name: string, hooks: Array<(T...) -> ()>, ...: T...)
|
|
88
|
+
for _, hook in hooks do
|
|
89
|
+
local ok, err = utils.try(hook, ...)
|
|
90
|
+
if not ok then
|
|
91
|
+
self.config._hook.warn(`datastore: hook {name} failed for {self.key}: {tostring(err)}`)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
local function set_data(self: LocklessProfile, data: any)
|
|
97
|
+
local old = self._data
|
|
98
|
+
local new = utils.deep_freeze(data)
|
|
99
|
+
self._data = new
|
|
100
|
+
fire_hooks(self, "change", self._hooks.change, new, old)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
--- A session lock showed up on the record: a locked profile owns the key now
|
|
104
|
+
--- and this profile lost its lockless standing. Closes it (no write; the
|
|
105
|
+
--- queue stays for the error message). Caller holds the mutex.
|
|
106
|
+
local function process_lock_lost(self: LocklessProfile)
|
|
107
|
+
if not self.open then
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
self.open = false
|
|
111
|
+
self._mutex:close(`datastore: profile "{self.key}" is session locked`)
|
|
112
|
+
fire_hooks(self, "lock_lost", self._hooks.lock_lost)
|
|
113
|
+
fire_hooks(self, "closed", self._hooks.closed)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
type SaveState = {
|
|
117
|
+
applied: boolean,
|
|
118
|
+
--- a live transaction lock is on the record; `tx_lock_expires` says until when
|
|
119
|
+
tx_locked: boolean,
|
|
120
|
+
tx_lock_expires: number,
|
|
121
|
+
--- a session lock is on the record
|
|
122
|
+
session_locked: boolean,
|
|
123
|
+
tx_lock_lost: boolean,
|
|
124
|
+
outdated: boolean,
|
|
125
|
+
unknown_pending: types.Pending?,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type WriteOptions = types.LocklessWriteOptions
|
|
129
|
+
|
|
130
|
+
--- Folds the queued updates into storage with one update_async: the stored
|
|
131
|
+
--- data (whatever any server left there) goes through migrations, then every
|
|
132
|
+
--- queued update, and what lands becomes the local data. Caller holds the mutex.
|
|
133
|
+
---
|
|
134
|
+
--- `options.tx_lock` leaves a short transaction lock on the record (kept in
|
|
135
|
+
--- `self._tx_lock`). `options.old_tx_lock` is the lock this write expects to
|
|
136
|
+
--- find on the record: the write only goes through (and clears that lock) when
|
|
137
|
+
--- 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".
|
|
139
|
+
local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
140
|
+
local config = self.config
|
|
141
|
+
local scheduler = config._scheduler
|
|
142
|
+
local datastore = self._datastore
|
|
143
|
+
local queue = self.queued_updates
|
|
144
|
+
local old_tx_lock = options.old_tx_lock
|
|
145
|
+
local new_lock = options.tx_lock
|
|
146
|
+
|
|
147
|
+
local state: SaveState = {
|
|
148
|
+
applied = false,
|
|
149
|
+
tx_locked = false,
|
|
150
|
+
tx_lock_expires = 0,
|
|
151
|
+
tx_lock_lost = false,
|
|
152
|
+
session_locked = false,
|
|
153
|
+
outdated = false,
|
|
154
|
+
unknown_pending = nil,
|
|
155
|
+
}
|
|
156
|
+
|
|
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
|
+
|
|
165
|
+
local lock = if old then old.lock else nil
|
|
166
|
+
|
|
167
|
+
-- a live session lock means a locked profile owns this key now: the
|
|
168
|
+
-- profile lost its lockless standing, whatever else it expected on the
|
|
169
|
+
-- record. An expired one belongs to a dead server and is written over.
|
|
170
|
+
if lock and not lock.tx then
|
|
171
|
+
if lock.expires > scheduler.now() then
|
|
172
|
+
state.session_locked = true
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
lock = nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if old_tx_lock then
|
|
179
|
+
-- only our own transaction lock may be replaced; anything else
|
|
180
|
+
-- (no lock, or a different one) means we no longer hold the key
|
|
181
|
+
if not lock or lock.id ~= old_tx_lock.id then
|
|
182
|
+
state.tx_lock_lost = true
|
|
183
|
+
return nil
|
|
184
|
+
end
|
|
185
|
+
elseif lock and lock.expires > scheduler.now() then
|
|
186
|
+
-- a live transaction lock (another server's, or a stale one of ours)
|
|
187
|
+
-- blocks the write until it is released or expires; the caller waits
|
|
188
|
+
state.tx_locked = true
|
|
189
|
+
state.tx_lock_expires = lock.expires
|
|
190
|
+
return nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
-- more migrations applied than this server declares: a newer server
|
|
194
|
+
-- already touched the record, so this server must not write to it
|
|
195
|
+
if old and old.migrations and #old.migrations > #config.migrations then
|
|
196
|
+
state.outdated = true
|
|
197
|
+
return nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
-- a `pending` left by some transaction (ours after commit, or another
|
|
201
|
+
-- server's) decides which data this write builds on
|
|
202
|
+
local base: any = nil
|
|
203
|
+
if old then
|
|
204
|
+
local known, folded = pending_data(self, old)
|
|
205
|
+
if not known then
|
|
206
|
+
state.unknown_pending = old.pending
|
|
207
|
+
return nil
|
|
208
|
+
end
|
|
209
|
+
base = folded
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
local data, migrations = resolve_record(self, old, base)
|
|
213
|
+
data = replay(queue, data)
|
|
214
|
+
|
|
215
|
+
state.applied = true
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
data = data,
|
|
219
|
+
migrations = migrations,
|
|
220
|
+
pending = options.pending,
|
|
221
|
+
lock = new_lock,
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
while true do
|
|
226
|
+
local record = utils.retry(config, function()
|
|
227
|
+
return datastore.update_async(self.key, transform, self.user_ids)
|
|
228
|
+
end) :: DataRecord
|
|
229
|
+
|
|
230
|
+
if state.session_locked then
|
|
231
|
+
self._tx_lock = nil
|
|
232
|
+
process_lock_lost(self)
|
|
233
|
+
error(`datastore: "{self.key}" is session locked`)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if state.tx_lock_lost then
|
|
237
|
+
-- whatever is on the record now is not ours; do not touch it again
|
|
238
|
+
self._tx_lock = nil
|
|
239
|
+
process_lock_lost(self)
|
|
240
|
+
error(`datastore: lock_lost for "{self.key}"`)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
if state.tx_locked then
|
|
244
|
+
-- wait for the transaction to release the key (or its lock to expire)
|
|
245
|
+
scheduler.wait(math.min(config.load_poll, math.max(state.tx_lock_expires - scheduler.now(), 0)))
|
|
246
|
+
continue
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if state.outdated then
|
|
250
|
+
error(
|
|
251
|
+
`datastore: "{self.key}" was written by a newer server (record has {#record.migrations} applied migrations, this server declares {#config.migrations})`
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
local unknown = state.unknown_pending
|
|
256
|
+
if unknown then
|
|
257
|
+
-- learn the outcome (may wait for the coordinator), then write again
|
|
258
|
+
resolve_unknown_pending(self, unknown)
|
|
259
|
+
continue
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if not state.applied then
|
|
263
|
+
error(`datastore: unexpected cancelled write for "{self.key}"`)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
-- the queue is now in storage; the local view restarts from what landed.
|
|
267
|
+
-- Whatever `pending` the record carried was folded in, so its outcome is
|
|
268
|
+
-- no longer needed (a pending written now gets its outcome at settle).
|
|
269
|
+
self._known_outcomes = {}
|
|
270
|
+
self._tx_lock = new_lock
|
|
271
|
+
self.queued_updates = {}
|
|
272
|
+
self.dirty_pending = options.pending ~= nil
|
|
273
|
+
self.migrations = record.migrations
|
|
274
|
+
set_data(self, record.data)
|
|
275
|
+
self.fetched = true
|
|
276
|
+
|
|
277
|
+
if options.pending == nil then
|
|
278
|
+
-- the record is clean: every transaction waiting on that can drop its marker
|
|
279
|
+
local cleanups = self._cleanup_txs
|
|
280
|
+
self._cleanup_txs = {}
|
|
281
|
+
for _, operation in cleanups do
|
|
282
|
+
operation:cleaned()
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
--- Inserts `dispatcher` into the queue (no storage call; it lands on the next
|
|
290
|
+
--- save / flush / unload). When fetched, the local data is advanced right away.
|
|
291
|
+
--- Caller holds the mutex.
|
|
292
|
+
local function queue_update(self: LocklessProfile, dispatcher: types.DataDispatcher<any>): boolean
|
|
293
|
+
if not self.fetched then
|
|
294
|
+
table.insert(self.queued_updates, dispatcher)
|
|
295
|
+
return true
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
local result = dispatcher(self._data)
|
|
299
|
+
if result == nil or result == false then
|
|
300
|
+
return false
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
table.insert(self.queued_updates, dispatcher)
|
|
304
|
+
set_data(self, result)
|
|
305
|
+
return true
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
--- Applies every queued update to the stored data in one update_async and
|
|
309
|
+
--- 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, {})
|
|
320
|
+
end)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
function lockless_profile.unload(self: LocklessProfile)
|
|
324
|
+
if not self.open then
|
|
325
|
+
return
|
|
326
|
+
end
|
|
327
|
+
-- last chance to queue updates while the profile is still open
|
|
328
|
+
fire_hooks(self, "closing", self._hooks.closing)
|
|
329
|
+
|
|
330
|
+
self.open = false
|
|
331
|
+
self._mutex:lock()
|
|
332
|
+
-- everything still waiting on the mutex (and anything after) is rejected
|
|
333
|
+
self._mutex:close(`datastore: profile "{self.key}" is closed`)
|
|
334
|
+
local ok, err = utils.try(function()
|
|
335
|
+
if #self.queued_updates > 0 or self.dirty_pending then
|
|
336
|
+
write_queued(self, {})
|
|
337
|
+
end
|
|
338
|
+
end)
|
|
339
|
+
self._mutex:unlock()
|
|
340
|
+
if not ok then
|
|
341
|
+
self.config._hook.warn(`datastore: failed to flush "{self.key}" on unload ({#self.queued_updates} changes lost): {tostring(err)}`)
|
|
342
|
+
end
|
|
343
|
+
fire_hooks(self, "closed", self._hooks.closed)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
function lockless_profile.on_change(self: LocklessProfile, callback: (new: any, old: any?) -> ())
|
|
347
|
+
table.insert(self._hooks.change, callback)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
function lockless_profile.on_save(self: LocklessProfile, callback: () -> ())
|
|
351
|
+
table.insert(self._hooks.save, callback)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
function lockless_profile.on_closing(self: LocklessProfile, callback: () -> ())
|
|
355
|
+
table.insert(self._hooks.closing, callback)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
function lockless_profile.on_closed(self: LocklessProfile, callback: () -> ())
|
|
359
|
+
table.insert(self._hooks.closed, callback)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
--- Fires when a session lock shows up on the record: a locked profile owns
|
|
363
|
+
--- the key now, this profile is closed.
|
|
364
|
+
function lockless_profile.on_lock_lost(self: LocklessProfile, callback: () -> ())
|
|
365
|
+
table.insert(self._hooks.lock_lost, callback)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
--- Yields until no operation holds or waits on the profile mutex. Once this
|
|
369
|
+
--- returns, the next update call is guaranteed not to yield (nothing is queued
|
|
370
|
+
--- ahead of it).
|
|
371
|
+
function lockless_profile.wait_settled(self: LocklessProfile)
|
|
372
|
+
return self._mutex:wait_settled()
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
--- Reads the stored record (get_async) into the local data: migrations are
|
|
376
|
+
--- applied, then the queued updates replayed on top, so `get_data()` reflects the
|
|
377
|
+
--- 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.
|
|
379
|
+
local function read_record(self: LocklessProfile)
|
|
380
|
+
local config = self.config
|
|
381
|
+
local datastore = self._datastore
|
|
382
|
+
|
|
383
|
+
local record = utils.retry(config, function()
|
|
384
|
+
return datastore.get_async(self.key)
|
|
385
|
+
end) :: DataRecord?
|
|
386
|
+
|
|
387
|
+
local lock = if record then record.lock else nil
|
|
388
|
+
if lock and not lock.tx and lock.expires > config._scheduler.now() then
|
|
389
|
+
process_lock_lost(self)
|
|
390
|
+
error(`datastore: "{self.key}" is session locked`)
|
|
391
|
+
end
|
|
392
|
+
-- an expired session lock belongs to a dead server: ignored, the next write clears it
|
|
393
|
+
-- a transaction lock does not matter for a read: the data under it is the
|
|
394
|
+
-- data until that transaction's `pending` lands (handled below)
|
|
395
|
+
|
|
396
|
+
-- a `pending` on the record: find out whether it committed (the record is
|
|
397
|
+
-- left as is; the next write folds it in)
|
|
398
|
+
local base: any = nil
|
|
399
|
+
if record then
|
|
400
|
+
local known, folded = pending_data(self, record)
|
|
401
|
+
if not known then
|
|
402
|
+
resolve_unknown_pending(self, record.pending :: types.Pending)
|
|
403
|
+
known, folded = pending_data(self, record)
|
|
404
|
+
end
|
|
405
|
+
base = folded
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
local data, migrations = resolve_record(self, record, base)
|
|
409
|
+
data = replay(self.queued_updates, data)
|
|
410
|
+
|
|
411
|
+
self.migrations = migrations
|
|
412
|
+
set_data(self, data)
|
|
413
|
+
self.fetched = true
|
|
414
|
+
return self._data
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
function lockless_profile.fetch(self: LocklessProfile)
|
|
418
|
+
common.assert_open(self)
|
|
419
|
+
return self._mutex:with(function()
|
|
420
|
+
return read_record(self)
|
|
421
|
+
end)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
--- Queues `dispatcher` to run against the stored data on the next save. When
|
|
425
|
+
--- the profile is fetched, the local data is advanced right away so `get_data()`
|
|
426
|
+
--- 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)
|
|
433
|
+
end)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
--- Periodically flushes the queue (only when there is something to write; an
|
|
437
|
+
--- idle lockless profile costs no storage calls). Stops once the profile closes.
|
|
438
|
+
local function run_flush_loop(self: LocklessProfile)
|
|
439
|
+
local config = self.config
|
|
440
|
+
local scheduler = config._scheduler
|
|
441
|
+
|
|
442
|
+
scheduler.spawn(function()
|
|
443
|
+
while true do
|
|
444
|
+
scheduler.wait(config.flush_interval)
|
|
445
|
+
if not self.open then
|
|
446
|
+
return
|
|
447
|
+
end
|
|
448
|
+
if #self.queued_updates == 0 and not self.dirty_pending then
|
|
449
|
+
continue
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
local ok, err = utils.try(self.save, self)
|
|
453
|
+
if not ok then
|
|
454
|
+
config._hook.warn(`datastore: failed to flush "{self.key}": {tostring(err)}`)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
-- its called create not load because it doesnt do anything in the Datastore
|
|
461
|
+
function lockless_profile.create(options: LoadOptions): LocklessProfile
|
|
462
|
+
local self = {} :: LocklessProfile
|
|
463
|
+
self.kind = "lockless"
|
|
464
|
+
self.key = options.key
|
|
465
|
+
self.user_ids = options.user_ids
|
|
466
|
+
self._datastore = options._datastore
|
|
467
|
+
self.open = true
|
|
468
|
+
|
|
469
|
+
self.config = options.config
|
|
470
|
+
self.migrations = nil :: any
|
|
471
|
+
|
|
472
|
+
self._data = nil
|
|
473
|
+
self._mutex = mutex.create(options.config._scheduler)
|
|
474
|
+
self.fetched = false
|
|
475
|
+
self.queued_updates = {}
|
|
476
|
+
self.dirty_pending = false
|
|
477
|
+
self._tx_lock = nil
|
|
478
|
+
self._known_outcomes = {}
|
|
479
|
+
self._cleanup_txs = {}
|
|
480
|
+
self._hooks = { change = {}, save = {}, closing = {}, closed = {}, lock_lost = {} }
|
|
481
|
+
|
|
482
|
+
setmetatable(self, lockless_profile)
|
|
483
|
+
run_flush_loop(self)
|
|
484
|
+
return self :: any
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
function is_lockless_profile(unknown: any)
|
|
488
|
+
if type(unknown) ~= "table" then
|
|
489
|
+
return false
|
|
490
|
+
end
|
|
491
|
+
return getmetatable(unknown) == lockless_profile
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
-- internal methods, used by transactions
|
|
495
|
+
lockless_profile._write_queued = write_queued
|
|
496
|
+
lockless_profile._queue_update = queue_update
|
|
497
|
+
lockless_profile._set_data = set_data
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
create = lockless_profile.create,
|
|
501
|
+
is = is_lockless_profile,
|
|
502
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
local common = require "./common"
|
|
2
|
+
local types = require "../types"
|
|
3
|
+
local utils = require "../utils"
|
|
4
|
+
|
|
5
|
+
type DataRecord = types.DataRecord
|
|
6
|
+
export type PeekProfile<T = any> = types.PeekProfile<T>
|
|
7
|
+
|
|
8
|
+
export type PeekOptions = {
|
|
9
|
+
key: string,
|
|
10
|
+
_datastore: types.DatastoreHandle,
|
|
11
|
+
_config: types.ResolvedConfig,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
local peek_profile = {}
|
|
15
|
+
peek_profile.__index = peek_profile
|
|
16
|
+
|
|
17
|
+
--- Reads the record with `GetAsync` and adopts it: `lock` and `pending` are
|
|
18
|
+
--- taken as stored, the data gets the declared migrations applied in memory
|
|
19
|
+
--- (nothing is written). A missing key resolves to the template.
|
|
20
|
+
local function read(self: PeekProfile): any
|
|
21
|
+
local config = self._config
|
|
22
|
+
local datastore = self._datastore
|
|
23
|
+
|
|
24
|
+
local record = utils.retry(config, function()
|
|
25
|
+
return datastore.get_async(self.key)
|
|
26
|
+
end) :: DataRecord?
|
|
27
|
+
|
|
28
|
+
self.lock = if record then record.lock else nil
|
|
29
|
+
self.pending = if record then record.pending else nil
|
|
30
|
+
|
|
31
|
+
local data: any
|
|
32
|
+
local migrations: { string }
|
|
33
|
+
if record == nil or record.data == nil then
|
|
34
|
+
data = utils.deep_copy(config.template)
|
|
35
|
+
migrations = common.all_migration_names(config.migrations)
|
|
36
|
+
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}`)
|
|
40
|
+
end
|
|
41
|
+
data = result.result
|
|
42
|
+
migrations = result.new_migrations
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
self.migrations = migrations
|
|
46
|
+
self._data = utils.deep_freeze(data)
|
|
47
|
+
return self._data
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
function peek_profile.get_data(self: PeekProfile)
|
|
51
|
+
return self._data
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
--- Calls `get_async` again and replaces the snapshot. Yields.
|
|
55
|
+
function peek_profile.refresh(self: PeekProfile)
|
|
56
|
+
return read(self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
--- 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
|
|
62
|
+
local self = {} :: PeekProfile
|
|
63
|
+
self.kind = "peek"
|
|
64
|
+
self.key = options.key
|
|
65
|
+
self._datastore = options._datastore
|
|
66
|
+
self._config = options._config
|
|
67
|
+
self.lock = nil
|
|
68
|
+
self.pending = nil
|
|
69
|
+
self.migrations = {}
|
|
70
|
+
self._data = nil :: any
|
|
71
|
+
|
|
72
|
+
local profile = setmetatable(self, peek_profile) :: any
|
|
73
|
+
read(profile)
|
|
74
|
+
return profile
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
create = peek_profile.create,
|
|
79
|
+
}
|