@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,498 @@
|
|
|
1
|
+
local mutex = require "../mutex"
|
|
2
|
+
local types = require "../types"
|
|
3
|
+
local utils = require "../utils"
|
|
4
|
+
local common = require "./common"
|
|
5
|
+
local tx_handle = require "../tx/handle"
|
|
6
|
+
|
|
7
|
+
type Array<T> = { T }
|
|
8
|
+
type DataRecord = types.DataRecord
|
|
9
|
+
type HooksList<T... = ()> = types.HooksList<T...>
|
|
10
|
+
export type Profile<T = any> = types.Profile<T>
|
|
11
|
+
export type LoadOptions = types.LoadOptions
|
|
12
|
+
|
|
13
|
+
local profile = {}
|
|
14
|
+
profile.__index = profile
|
|
15
|
+
|
|
16
|
+
local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
17
|
+
local config = load_options.config
|
|
18
|
+
local user_ids = load_options.user_ids
|
|
19
|
+
local datastore = load_options._datastore
|
|
20
|
+
local job_id = config._hook.job_id()
|
|
21
|
+
local key = load_options.key
|
|
22
|
+
|
|
23
|
+
local scheduler = config._scheduler
|
|
24
|
+
|
|
25
|
+
local deadline = scheduler.now() + config.load_timeout
|
|
26
|
+
|
|
27
|
+
while true do
|
|
28
|
+
local now = scheduler.now()
|
|
29
|
+
local lock_applied = utils.retry(config, function()
|
|
30
|
+
return datastore.update_async(key, function(old: types.DataRecord?): types.DataRecord?
|
|
31
|
+
if common.is_locked_for(old :: types.DataRecord, job_id, now) then
|
|
32
|
+
return nil
|
|
33
|
+
end
|
|
34
|
+
local record: types.DataRecord = old or {} :: types.DataRecord
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
data = record.data,
|
|
38
|
+
pending = record.pending,
|
|
39
|
+
migrations = record.migrations or {},
|
|
40
|
+
lock = {
|
|
41
|
+
id = job_id,
|
|
42
|
+
expires = now + config.lock_ttl,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
end, user_ids)
|
|
46
|
+
end) :: types.DataRecord
|
|
47
|
+
|
|
48
|
+
if common.is_lock_owned(lock_applied, job_id) then
|
|
49
|
+
return lock_applied
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
while true do
|
|
53
|
+
if scheduler.now() >= deadline then
|
|
54
|
+
error(`datastore: timed out loading "{key}" (session locked by another server)`)
|
|
55
|
+
end
|
|
56
|
+
scheduler.wait(config.load_poll)
|
|
57
|
+
|
|
58
|
+
local current = utils.retry(config, function()
|
|
59
|
+
return datastore.get_async(key)
|
|
60
|
+
end) :: types.DataRecord?
|
|
61
|
+
|
|
62
|
+
if not common.is_locked_for(current, job_id, scheduler.now()) then
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
local apply_migrations = common.apply_migrations
|
|
70
|
+
|
|
71
|
+
local function run_autosave_loop(self: Profile)
|
|
72
|
+
local config = self.config
|
|
73
|
+
local scheduler = config._scheduler
|
|
74
|
+
|
|
75
|
+
scheduler.spawn(function()
|
|
76
|
+
while true do
|
|
77
|
+
scheduler.wait(config.autosave_interval)
|
|
78
|
+
if not self.open then
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
if not self.is_locked then
|
|
82
|
+
-- released: nothing to refresh, nothing may be written
|
|
83
|
+
continue
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
-- a failed write must not kill the loop: the next tick retries, and
|
|
87
|
+
-- `write_data_record` gives the lock up after a full ttl of failures
|
|
88
|
+
local ok, err = utils.try(self.save, self)
|
|
89
|
+
if not ok then
|
|
90
|
+
config._hook.warn(`datastore: autosave failed for "{self.key}": {tostring(err)}`)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
local function fire_hooks<T...>(self: Profile, hooks: HooksList<T...>, ...: T...)
|
|
97
|
+
for _, hook in hooks.hooks do
|
|
98
|
+
local ok, err = utils.try(hook, ...)
|
|
99
|
+
if not ok then
|
|
100
|
+
self.config._hook.warn(`datastore: hook {hooks.type} failed for {self.key}: {tostring(err)}`)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
local function set_data(self: Profile, data: any)
|
|
106
|
+
local old = self._data
|
|
107
|
+
local new = utils.deep_freeze(data)
|
|
108
|
+
self._data = new
|
|
109
|
+
fire_hooks(self, self._hooks.change, new, old)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
--- Another server took the key: the profile closes without a release write.
|
|
113
|
+
--- Runs inside a write (mutex held by the caller), so it only closes the mutex:
|
|
114
|
+
--- the holder finishes, everything waiting on it is rejected.
|
|
115
|
+
local function process_lock_lost(self: Profile)
|
|
116
|
+
if not self.open then
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
self.open = false
|
|
120
|
+
self._mutex:close(`datastore: profile "{self.key}" lost its session lock`)
|
|
121
|
+
fire_hooks(self, self._hooks.lock_lost)
|
|
122
|
+
fire_hooks(self, self._hooks.closed)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
type WriteOptions = {
|
|
126
|
+
release: boolean,
|
|
127
|
+
pending: types.Pending?,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
local function write_data_record(self: Profile, options: WriteOptions): boolean
|
|
131
|
+
local config = self.config
|
|
132
|
+
local scheduler = config._scheduler
|
|
133
|
+
local datastore = self._datastore
|
|
134
|
+
local now = scheduler.now()
|
|
135
|
+
local job_id = config._hook.job_id()
|
|
136
|
+
|
|
137
|
+
-- set by the transform: whether the write went through (the transform may
|
|
138
|
+
-- run more than once when the storage retries it)
|
|
139
|
+
local applied = false
|
|
140
|
+
|
|
141
|
+
local function transform(old: any?): DataRecord?
|
|
142
|
+
applied = false
|
|
143
|
+
if not common.is_lock_owned(old, job_id) then
|
|
144
|
+
return nil
|
|
145
|
+
end
|
|
146
|
+
applied = true
|
|
147
|
+
|
|
148
|
+
local new_lock: types.Lock? = nil
|
|
149
|
+
if not options.release then
|
|
150
|
+
new_lock = {
|
|
151
|
+
id = job_id,
|
|
152
|
+
expires = now + config.lock_ttl,
|
|
153
|
+
tx = nil,
|
|
154
|
+
} :: types.Lock
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
data = self._data,
|
|
159
|
+
migrations = self.migrations,
|
|
160
|
+
pending = options.pending,
|
|
161
|
+
lock = new_lock,
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
local success, err = pcall(function()
|
|
166
|
+
utils.retry(config, function()
|
|
167
|
+
return datastore.update_async(self.key, transform, self.user_ids)
|
|
168
|
+
end)
|
|
169
|
+
end)
|
|
170
|
+
|
|
171
|
+
if not success then
|
|
172
|
+
if scheduler.now() - self.last_write > config.lock_ttl then
|
|
173
|
+
-- We have not managed to refresh the lock for a full TTL; assume its gone.
|
|
174
|
+
process_lock_lost(self)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
error(err, 0)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if not applied then
|
|
181
|
+
-- the transform bailed out: the lock is not ours any more
|
|
182
|
+
process_lock_lost(self)
|
|
183
|
+
return false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
self.last_write = now
|
|
187
|
+
self._dirty = false
|
|
188
|
+
|
|
189
|
+
if options.pending == nil then
|
|
190
|
+
-- the record is clean: every transaction waiting on that can drop its marker
|
|
191
|
+
local cleanups = self._cleanup_txs
|
|
192
|
+
self._cleanup_txs = {}
|
|
193
|
+
for _, operation in cleanups do
|
|
194
|
+
operation:cleaned()
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
return true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
--- Gives the lock taken by `adquire_lock` back, leaving the record otherwise
|
|
201
|
+
--- untouched. Used when the load fails after locking; a failure here is only
|
|
202
|
+
--- warned about (the lock expires on its own).
|
|
203
|
+
local function release_lock(load_options: LoadOptions)
|
|
204
|
+
local config = load_options.config
|
|
205
|
+
local job_id = config._hook.job_id()
|
|
206
|
+
|
|
207
|
+
local ok, err = utils.try(function()
|
|
208
|
+
utils.retry(config, function()
|
|
209
|
+
return load_options._datastore.update_async(load_options.key, function(old: DataRecord?): DataRecord?
|
|
210
|
+
if not common.is_lock_owned(old, job_id) then
|
|
211
|
+
return nil
|
|
212
|
+
end
|
|
213
|
+
local record = old :: DataRecord
|
|
214
|
+
return {
|
|
215
|
+
data = record.data,
|
|
216
|
+
pending = record.pending,
|
|
217
|
+
migrations = record.migrations,
|
|
218
|
+
lock = nil,
|
|
219
|
+
}
|
|
220
|
+
end, load_options.user_ids)
|
|
221
|
+
end)
|
|
222
|
+
end)
|
|
223
|
+
if not ok then
|
|
224
|
+
config._hook.warn(`datastore: could not release the lock of "{load_options.key}" after a failed load (it will expire): {tostring(err)}`)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
--- Turns the locked record into the data the profile starts with: resolves a
|
|
229
|
+
--- `pending`, falls back to the template, applies migrations. May throw.
|
|
230
|
+
local function resolve_loaded(load_options: LoadOptions, record: DataRecord): (any, { string }, boolean)
|
|
231
|
+
local config = load_options.config
|
|
232
|
+
local data = record.data
|
|
233
|
+
local dirty = false
|
|
234
|
+
|
|
235
|
+
if record.pending ~= nil then
|
|
236
|
+
local outcome = tx_handle.resolve_pending(config._hook, config, record.pending, true)
|
|
237
|
+
if outcome == "committed" then
|
|
238
|
+
data = record.pending.data
|
|
239
|
+
end
|
|
240
|
+
dirty = true
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
local migrations = config.migrations
|
|
244
|
+
local new_migrations: { string }
|
|
245
|
+
|
|
246
|
+
if data == nil then
|
|
247
|
+
data = utils.deep_copy(config.template)
|
|
248
|
+
|
|
249
|
+
-- fresh template already matches the latest schema, mark every migration as applied
|
|
250
|
+
new_migrations = table.create(#migrations) :: { string }
|
|
251
|
+
for index, migration in migrations do
|
|
252
|
+
new_migrations[index] = migration.name
|
|
253
|
+
end
|
|
254
|
+
dirty = true
|
|
255
|
+
else
|
|
256
|
+
local result = apply_migrations(data, record.migrations or {}, migrations)
|
|
257
|
+
if not result.success then
|
|
258
|
+
error(`datastore: failed to apply migrations for "{load_options.key}": {result.err}`)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
data = result.result
|
|
262
|
+
new_migrations = result.new_migrations
|
|
263
|
+
|
|
264
|
+
if result.applied then
|
|
265
|
+
dirty = true
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
return data, new_migrations, dirty
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
--- Takes the session lock and resolves the locked record into data. If the
|
|
273
|
+
--- resolution fails the lock is given back before rethrowing.
|
|
274
|
+
local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean)
|
|
275
|
+
local record = adquire_lock(load_options)
|
|
276
|
+
local config = load_options.config
|
|
277
|
+
|
|
278
|
+
-- from here on the key is locked: anything failing must give it back
|
|
279
|
+
local ok, data, new_migrations, dirty = pcall(resolve_loaded, load_options, record)
|
|
280
|
+
if not ok then
|
|
281
|
+
local err = data
|
|
282
|
+
config._hook.warn(`datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`)
|
|
283
|
+
release_lock(load_options)
|
|
284
|
+
error(err, 0)
|
|
285
|
+
end
|
|
286
|
+
return data, new_migrations, dirty
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
local function load_options_of(self: Profile): LoadOptions
|
|
290
|
+
return {
|
|
291
|
+
key = self.key,
|
|
292
|
+
_datastore = self._datastore,
|
|
293
|
+
config = self.config,
|
|
294
|
+
user_ids = self.user_ids,
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
local function assert_locked(self: Profile)
|
|
299
|
+
if not self.is_locked then
|
|
300
|
+
error(`datastore: profile "{self.key}" does not hold its session lock`, 3)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
function profile.load(load_options: LoadOptions): Profile
|
|
305
|
+
local data, new_migrations, dirty = adquire_and_resolve(load_options)
|
|
306
|
+
local config = load_options.config
|
|
307
|
+
|
|
308
|
+
local self: Profile = {} :: any
|
|
309
|
+
self.kind = "locked"
|
|
310
|
+
self.key = load_options.key
|
|
311
|
+
self.user_ids = load_options.user_ids
|
|
312
|
+
self._datastore = load_options._datastore
|
|
313
|
+
self.open = true
|
|
314
|
+
self.is_locked = true
|
|
315
|
+
|
|
316
|
+
self.config = load_options.config
|
|
317
|
+
self.migrations = new_migrations
|
|
318
|
+
|
|
319
|
+
self._hooks = {
|
|
320
|
+
change = { type = "change", hooks = {} },
|
|
321
|
+
lock_lost = { type = "lock_lost", hooks = {} },
|
|
322
|
+
save = { type = "save", hooks = {} },
|
|
323
|
+
closing = { type = "closing", hooks = {} },
|
|
324
|
+
closed = { type = "closed", hooks = {} },
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
self._data = nil
|
|
328
|
+
set_data(self, data)
|
|
329
|
+
-- the lock was just taken by `adquire_lock`; counts as a successful write
|
|
330
|
+
self.last_write = config._scheduler.now()
|
|
331
|
+
|
|
332
|
+
self._dirty = dirty
|
|
333
|
+
self._mutex = mutex.create(config._scheduler)
|
|
334
|
+
self._cleanup_txs = {}
|
|
335
|
+
|
|
336
|
+
run_autosave_loop(self)
|
|
337
|
+
return setmetatable(self, profile) :: any
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
function profile.get_data(self: Profile)
|
|
341
|
+
common.assert_open(self)
|
|
342
|
+
return self._data
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
--- Applies `dispatcher` to the current data. Returning nil or false leaves the
|
|
346
|
+
--- data untouched (returns false); anything else becomes the new data and is
|
|
347
|
+
--- persisted on the next save. Waits for any in-flight save on the mutex.
|
|
348
|
+
function profile.update(self: Profile, dispatcher: (any) -> any): boolean
|
|
349
|
+
common.assert_open(self)
|
|
350
|
+
assert_locked(self)
|
|
351
|
+
|
|
352
|
+
return self._mutex:with(function()
|
|
353
|
+
-- a release may have run while this call waited on the mutex
|
|
354
|
+
assert_locked(self)
|
|
355
|
+
local result = dispatcher(self._data)
|
|
356
|
+
if result == nil or result == false then
|
|
357
|
+
return false
|
|
358
|
+
end
|
|
359
|
+
set_data(self, result)
|
|
360
|
+
self._dirty = true
|
|
361
|
+
return true
|
|
362
|
+
end)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
function profile.on_change(self: Profile, callback: (new: any, old: any?) -> ())
|
|
366
|
+
table.insert(self._hooks.change.hooks, callback)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
function profile.on_save(self: Profile, callback: () -> ())
|
|
370
|
+
table.insert(self._hooks.save.hooks, callback)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
function profile.on_closing(self: Profile, callback: () -> ())
|
|
374
|
+
table.insert(self._hooks.closing.hooks, callback)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
function profile.on_closed(self: Profile, callback: () -> ())
|
|
378
|
+
table.insert(self._hooks.closed.hooks, callback)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
function profile.on_lock_lost(self: Profile, callback: () -> ())
|
|
382
|
+
table.insert(self._hooks.lock_lost.hooks, callback)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
function profile.save(self: Profile)
|
|
386
|
+
common.assert_open(self)
|
|
387
|
+
assert_locked(self)
|
|
388
|
+
-- before the mutex so hooks can still run updates that land in this save
|
|
389
|
+
fire_hooks(self, self._hooks.save)
|
|
390
|
+
common.assert_open(self)
|
|
391
|
+
assert_locked(self)
|
|
392
|
+
self._mutex:with(function()
|
|
393
|
+
-- without the lock the write would be cancelled and wrongly read as lost
|
|
394
|
+
assert_locked(self)
|
|
395
|
+
write_data_record(self, { release = false })
|
|
396
|
+
end)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
--- Writes the data and gives the session lock back. The profile stays open
|
|
400
|
+
--- (another server may load the key meanwhile); `update` and `save` throw
|
|
401
|
+
--- until `readquire`. No-op when already released.
|
|
402
|
+
function profile.release(self: Profile)
|
|
403
|
+
common.assert_open(self)
|
|
404
|
+
if not self.is_locked then
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
self._mutex:with(function()
|
|
408
|
+
if not self.open or not self.is_locked then
|
|
409
|
+
return
|
|
410
|
+
end
|
|
411
|
+
-- false: the lock was not ours any more, `process_lock_lost` closed the profile.
|
|
412
|
+
-- A storage error propagates; the lock is still on the record, so `is_locked` stays.
|
|
413
|
+
if write_data_record(self, { release = true }) then
|
|
414
|
+
self.is_locked = false
|
|
415
|
+
end
|
|
416
|
+
end)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
--- Takes the session lock again, waiting for a holder like `load` does, and
|
|
420
|
+
--- adopts the stored record (resolved pending, migrations) as the current
|
|
421
|
+
--- data. Throws on timeout, leaving the profile open and released. No-op when
|
|
422
|
+
--- already locked.
|
|
423
|
+
function profile.readquire(self: Profile)
|
|
424
|
+
common.assert_open(self)
|
|
425
|
+
if self.is_locked then
|
|
426
|
+
return
|
|
427
|
+
end
|
|
428
|
+
self._mutex:with(function()
|
|
429
|
+
common.assert_open(self)
|
|
430
|
+
if self.is_locked then
|
|
431
|
+
return
|
|
432
|
+
end
|
|
433
|
+
local data, new_migrations, dirty = adquire_and_resolve(load_options_of(self))
|
|
434
|
+
|
|
435
|
+
self.migrations = new_migrations
|
|
436
|
+
set_data(self, data)
|
|
437
|
+
self._dirty = dirty
|
|
438
|
+
self.last_write = self.config._scheduler.now()
|
|
439
|
+
self.is_locked = true
|
|
440
|
+
end)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
function profile.unload(self: Profile)
|
|
444
|
+
if not self.open then
|
|
445
|
+
return
|
|
446
|
+
end
|
|
447
|
+
-- last chance to update while the profile is still open
|
|
448
|
+
fire_hooks(self, self._hooks.closing)
|
|
449
|
+
if not self.open then
|
|
450
|
+
return
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
self.open = false
|
|
454
|
+
self._mutex:lock()
|
|
455
|
+
self._mutex:close(`datastore: profile "{self.key}" is closed`)
|
|
456
|
+
local ok, err = utils.try(function()
|
|
457
|
+
-- an already released profile has nothing on the record to give back
|
|
458
|
+
if self.is_locked then
|
|
459
|
+
write_data_record(self, { release = true })
|
|
460
|
+
end
|
|
461
|
+
end)
|
|
462
|
+
self._mutex:unlock()
|
|
463
|
+
if not ok then
|
|
464
|
+
self.config._hook.warn(`datastore: failed to release "{self.key}" (lock will expire): {tostring(err)}`)
|
|
465
|
+
end
|
|
466
|
+
fire_hooks(self, self._hooks.closed)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
--- Yields until no operation holds or waits on the profile mutex. Once this
|
|
470
|
+
--- returns, the next update call is guaranteed not to yield (nothing is queued
|
|
471
|
+
--- ahead of it).
|
|
472
|
+
function profile.wait_settled(self: Profile)
|
|
473
|
+
return self._mutex:wait_settled()
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
function profile.wait_closed(self: Profile)
|
|
477
|
+
if self.open then
|
|
478
|
+
error(`profile: {self.key} has not been closed`)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
return self._mutex:wait_settled()
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
function is_profile(unknown: any)
|
|
485
|
+
if type(unknown) ~= "table" then
|
|
486
|
+
return false
|
|
487
|
+
end
|
|
488
|
+
return getmetatable(unknown) == profile
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
-- internal methods, used by transactions
|
|
492
|
+
profile._set_data = set_data
|
|
493
|
+
profile._write_record = write_data_record
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
load = profile.load,
|
|
497
|
+
is = is_profile,
|
|
498
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
local types = require "../types"
|
|
2
|
+
|
|
3
|
+
export type RobloxScheduler = types.Scheduler
|
|
4
|
+
|
|
5
|
+
local roblox_scheduler = {}
|
|
6
|
+
|
|
7
|
+
function roblox_scheduler.create()
|
|
8
|
+
return {
|
|
9
|
+
spawn = task.spawn,
|
|
10
|
+
defer = task.defer,
|
|
11
|
+
wait = task.wait,
|
|
12
|
+
now = function()
|
|
13
|
+
return DateTime.now().UnixTimestampMillis / 1000
|
|
14
|
+
end,
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return roblox_scheduler
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
local types = require "../types"
|
|
2
|
+
|
|
3
|
+
--[[
|
|
4
|
+
Virtual-time task scheduler.
|
|
5
|
+
|
|
6
|
+
Never yields the real thread. `wait` suspends the calling coroutine and
|
|
7
|
+
registers it to resume at `now + seconds`; `step(dt)` advances the virtual
|
|
8
|
+
clock and resumes every due thread in (resume_time, registration order).
|
|
9
|
+
Threads that wait again while stepping are re-queued and processed in the
|
|
10
|
+
same step if they are still due.
|
|
11
|
+
]]
|
|
12
|
+
|
|
13
|
+
export type VirtualScheduler = types.Scheduler & {
|
|
14
|
+
step: (dt: number) -> (),
|
|
15
|
+
flush: () -> (),
|
|
16
|
+
pending: () -> number,
|
|
17
|
+
errors: { string },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Entry = {
|
|
21
|
+
at: number,
|
|
22
|
+
seq: number,
|
|
23
|
+
thread: thread,
|
|
24
|
+
-- wait entries resume with the time they were resumed at,
|
|
25
|
+
-- deferred entries resume with their stored args
|
|
26
|
+
args: { any }?,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
local virtual_scheduler = {}
|
|
30
|
+
|
|
31
|
+
function virtual_scheduler.create(): VirtualScheduler
|
|
32
|
+
local time = 0
|
|
33
|
+
local seq = 0
|
|
34
|
+
local queue: { Entry } = {}
|
|
35
|
+
local errors: { string } = {}
|
|
36
|
+
|
|
37
|
+
local function insert(entry: Entry)
|
|
38
|
+
-- keep sorted by (at, seq); scan from the back since most inserts are late
|
|
39
|
+
local index = #queue
|
|
40
|
+
while index >= 1 do
|
|
41
|
+
local other = queue[index]
|
|
42
|
+
if other.at < entry.at or (other.at == entry.at and other.seq < entry.seq) then
|
|
43
|
+
break
|
|
44
|
+
end
|
|
45
|
+
index -= 1
|
|
46
|
+
end
|
|
47
|
+
table.insert(queue, index + 1, entry)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local function resume(thread: thread, ...: any)
|
|
51
|
+
local ok, err = coroutine.resume(thread, ...)
|
|
52
|
+
if not ok then
|
|
53
|
+
-- recorded, not printed: tests assert on `errors`
|
|
54
|
+
table.insert(errors, `scheduler: thread error: {tostring(err)}`)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local function to_thread(fn: ((...any) -> ...any) | thread): thread
|
|
59
|
+
if type(fn) == "thread" then
|
|
60
|
+
return fn
|
|
61
|
+
end
|
|
62
|
+
return coroutine.create(fn)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
local self = {} :: VirtualScheduler
|
|
66
|
+
self.errors = errors
|
|
67
|
+
|
|
68
|
+
function self.now(): number
|
|
69
|
+
return time
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
function self.spawn(fn, ...)
|
|
73
|
+
local thread = to_thread(fn)
|
|
74
|
+
resume(thread, ...)
|
|
75
|
+
return thread
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
function self.defer(fn, ...)
|
|
79
|
+
local thread = to_thread(fn)
|
|
80
|
+
seq += 1
|
|
81
|
+
insert { at = time, seq = seq, thread = thread, args = { ... } }
|
|
82
|
+
return thread
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
function self.wait(seconds)
|
|
86
|
+
local thread = coroutine.running()
|
|
87
|
+
if not coroutine.isyieldable() then
|
|
88
|
+
error "scheduler.wait called from a non-yieldable thread; run your code inside scheduler.spawn"
|
|
89
|
+
end
|
|
90
|
+
local delay = math.max(seconds or 0, 0)
|
|
91
|
+
seq += 1
|
|
92
|
+
local started = time
|
|
93
|
+
insert { at = time + delay, seq = seq, thread = thread }
|
|
94
|
+
local resumed_at: number = coroutine.yield()
|
|
95
|
+
return resumed_at - started
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
function self.step(dt)
|
|
99
|
+
local target = time + math.max(dt, 0)
|
|
100
|
+
while #queue > 0 and queue[1].at <= target do
|
|
101
|
+
local entry = table.remove(queue, 1) :: Entry
|
|
102
|
+
if entry.at > time then
|
|
103
|
+
time = entry.at
|
|
104
|
+
end
|
|
105
|
+
if coroutine.status(entry.thread) == "dead" then
|
|
106
|
+
continue
|
|
107
|
+
end
|
|
108
|
+
if entry.args then
|
|
109
|
+
resume(entry.thread, table.unpack(entry.args))
|
|
110
|
+
else
|
|
111
|
+
resume(entry.thread, time)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
time = target
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
function self.flush()
|
|
118
|
+
self.step(0)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
function self.pending(): number
|
|
122
|
+
return #queue
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
return self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return virtual_scheduler
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
local types = require "../types"
|
|
2
|
+
|
|
3
|
+
export type RobloxScheduler = types.Scheduler
|
|
4
|
+
|
|
5
|
+
local zune_scheduler = {}
|
|
6
|
+
|
|
7
|
+
function zune_scheduler.create()
|
|
8
|
+
return {
|
|
9
|
+
spawn = zune.task.spawn,
|
|
10
|
+
defer = zune.task.defer,
|
|
11
|
+
wait = zune.task.wait,
|
|
12
|
+
now = function()
|
|
13
|
+
return zune.time.timestampMillis() / 1000
|
|
14
|
+
end,
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return zune_scheduler
|