@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
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# DataForge
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/// <reference types="@rbxts/types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Roblox DataStore library with cross-key transactions, session-locked and
|
|
5
|
+
* lockless profiles, migrations and an immutable data model.
|
|
6
|
+
*/
|
|
7
|
+
declare namespace dataforge {
|
|
8
|
+
/*
|
|
9
|
+
Storage backends
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A storage handle for one named store. Deliberately not shaped like a
|
|
14
|
+
* Roblox DataStore. All methods throw on failure.
|
|
15
|
+
*/
|
|
16
|
+
interface DatastoreHandle {
|
|
17
|
+
name: string;
|
|
18
|
+
|
|
19
|
+
get_async: (key: string) => unknown;
|
|
20
|
+
set_async: (key: string, value: unknown, user_ids?: number[]) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Atomic per key. Cancels when `transform` returns `undefined` and
|
|
23
|
+
* returns the value stored after the call.
|
|
24
|
+
*/
|
|
25
|
+
update_async: (key: string, transform: (old: unknown) => unknown, user_ids?: number[]) => unknown;
|
|
26
|
+
/** Optional; used to delete transaction markers. */
|
|
27
|
+
remove_async?: (key: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** How a store reaches storage. */
|
|
31
|
+
interface Hook {
|
|
32
|
+
/** Identifies this server. Session locks and transaction ids carry it. */
|
|
33
|
+
job_id: () => string;
|
|
34
|
+
/** Returns the handle for a named store. Called once per store name. */
|
|
35
|
+
get_datastore: (name: string) => DatastoreHandle;
|
|
36
|
+
/**
|
|
37
|
+
* Non-fatal problems (a hook that threw, a write that could not be
|
|
38
|
+
* released) are reported here instead of a global `warn`.
|
|
39
|
+
*/
|
|
40
|
+
warn: (message: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How the library spawns threads, waits and reads the clock. */
|
|
44
|
+
interface Scheduler {
|
|
45
|
+
spawn: (fn: ((...args: unknown[]) => unknown) | thread, ...args: unknown[]) => thread;
|
|
46
|
+
defer: (fn: ((...args: unknown[]) => unknown) | thread, ...args: unknown[]) => thread;
|
|
47
|
+
wait: (seconds?: number) => number;
|
|
48
|
+
now: () => number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type MemoryHookOp = "get_async" | "set_async" | "update_async" | "remove_async" | "*";
|
|
52
|
+
type MemoryHookFailMode = "before" | "after";
|
|
53
|
+
|
|
54
|
+
interface MemoryHookOpLogEntry {
|
|
55
|
+
store: string;
|
|
56
|
+
key: string;
|
|
57
|
+
op: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* In-memory hook meant for tests. Every handle created from one hook shares
|
|
62
|
+
* one backend table, so several stores over one hook behave like several
|
|
63
|
+
* servers. Values are deep-copied in and out.
|
|
64
|
+
*/
|
|
65
|
+
interface MemoryHook extends Hook {
|
|
66
|
+
scheduler: Scheduler;
|
|
67
|
+
|
|
68
|
+
/** Raw backend: `data[store][key]`. */
|
|
69
|
+
data: Map<string, Map<string, unknown>>;
|
|
70
|
+
/** Seconds waited through the scheduler before every operation (default `0`). */
|
|
71
|
+
latency: number;
|
|
72
|
+
/** Log of every operation performed. */
|
|
73
|
+
ops: MemoryHookOpLogEntry[];
|
|
74
|
+
/** Every message the library reported through `warn` (nothing is printed). */
|
|
75
|
+
warnings: string[];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The next matching operation throws. `"before"` throws without touching
|
|
79
|
+
* the backend; `"after"` performs the write and then throws (unknown
|
|
80
|
+
* outcome). `op = "*"` matches any operation.
|
|
81
|
+
*/
|
|
82
|
+
fail_next(
|
|
83
|
+
store: string,
|
|
84
|
+
key: string,
|
|
85
|
+
op: MemoryHookOp,
|
|
86
|
+
mode: MemoryHookFailMode,
|
|
87
|
+
message?: string,
|
|
88
|
+
): void;
|
|
89
|
+
/** Drops pending injected failures. */
|
|
90
|
+
clear_failures(): void;
|
|
91
|
+
/** Deep copy of the stored record. */
|
|
92
|
+
dump(store: string, key: string): unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Virtual-time scheduler for tests. Never yields the real thread: `wait`
|
|
97
|
+
* suspends the calling coroutine and registers it to resume at
|
|
98
|
+
* `now + seconds`.
|
|
99
|
+
*/
|
|
100
|
+
interface VirtualScheduler extends Scheduler {
|
|
101
|
+
/**
|
|
102
|
+
* Advances the clock by `dt` and resumes every due thread in
|
|
103
|
+
* (resume time, registration order).
|
|
104
|
+
*/
|
|
105
|
+
step: (dt: number) => void;
|
|
106
|
+
/** Runs everything due at the current time. */
|
|
107
|
+
flush: () => void;
|
|
108
|
+
/** Number of queued threads. */
|
|
109
|
+
pending: () => number;
|
|
110
|
+
/** Errors thrown by spawned threads. */
|
|
111
|
+
errors: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
Configuration
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
interface Migration {
|
|
119
|
+
name: string;
|
|
120
|
+
apply: (data: unknown) => unknown;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface Config<T = unknown> {
|
|
124
|
+
name: string;
|
|
125
|
+
template: T;
|
|
126
|
+
|
|
127
|
+
migrations?: Migration[];
|
|
128
|
+
|
|
129
|
+
/** seconds, default 30 (locked profiles) */
|
|
130
|
+
autosave_interval?: number;
|
|
131
|
+
/** seconds, default autosave_interval (lockless profiles) */
|
|
132
|
+
flush_interval?: number;
|
|
133
|
+
/** seconds, default 60 */
|
|
134
|
+
lock_ttl?: number;
|
|
135
|
+
/** seconds, default lock_ttl + 10 */
|
|
136
|
+
load_timeout?: number;
|
|
137
|
+
/** seconds, default 1 */
|
|
138
|
+
load_poll?: number;
|
|
139
|
+
/** default 5 */
|
|
140
|
+
retry_attempts?: number;
|
|
141
|
+
/** seconds, default 1 */
|
|
142
|
+
retry_base?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `Config<T>` with every optional field filled in. */
|
|
146
|
+
type ResolvedConfig<T = unknown> = Required<Config<T>>;
|
|
147
|
+
|
|
148
|
+
interface TransactionConfig {
|
|
149
|
+
/**
|
|
150
|
+
* seconds, default 60. Grace period others give this transaction's
|
|
151
|
+
* `pending` before force-resolving it.
|
|
152
|
+
*/
|
|
153
|
+
tx_ttl?: number;
|
|
154
|
+
/**
|
|
155
|
+
* seconds, default 10. How long a transaction holds a lockless key
|
|
156
|
+
* between taking its snapshot and writing `pending`.
|
|
157
|
+
*/
|
|
158
|
+
lock_ttl?: number;
|
|
159
|
+
/** default 5 */
|
|
160
|
+
retry_attempts?: number;
|
|
161
|
+
/** seconds, default 1 */
|
|
162
|
+
retry_base?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
Stored shapes
|
|
167
|
+
*/
|
|
168
|
+
|
|
169
|
+
interface Lock {
|
|
170
|
+
id: string;
|
|
171
|
+
expires: number;
|
|
172
|
+
/**
|
|
173
|
+
* Short-lived lock a transaction takes on a lockless key. Other
|
|
174
|
+
* lockless writers wait it out instead of failing.
|
|
175
|
+
*/
|
|
176
|
+
tx?: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface Pending<T = unknown> {
|
|
180
|
+
tx_id: string;
|
|
181
|
+
store: string;
|
|
182
|
+
/**
|
|
183
|
+
* Until when a non-owner leaves this pending alone; after it, the marker
|
|
184
|
+
* may be force-resolved (fallback "aborted").
|
|
185
|
+
*/
|
|
186
|
+
expires: number;
|
|
187
|
+
data: T;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What is stored under each data key. */
|
|
191
|
+
interface DataRecord<T = unknown> {
|
|
192
|
+
data?: T;
|
|
193
|
+
lock?: Lock;
|
|
194
|
+
pending?: Pending<T>;
|
|
195
|
+
migrations: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type TxOutcome = "committed" | "aborted";
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
Profiles
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The transform passed to `update`. Returns the new data, or
|
|
206
|
+
* `undefined` / `false` to leave it untouched.
|
|
207
|
+
*/
|
|
208
|
+
type DataDispatcher<T> = (data: T) => T | false | undefined;
|
|
209
|
+
|
|
210
|
+
/** Shared shape of both profile kinds. */
|
|
211
|
+
interface ProfileBase<T = unknown> {
|
|
212
|
+
readonly kind: "locked" | "lockless";
|
|
213
|
+
readonly key: string;
|
|
214
|
+
readonly config: ResolvedConfig<T>;
|
|
215
|
+
readonly user_ids: number[];
|
|
216
|
+
readonly open: boolean;
|
|
217
|
+
readonly migrations: string[];
|
|
218
|
+
|
|
219
|
+
/** Current data. Throws if the profile is closed or (lockless) not fetched. */
|
|
220
|
+
get_data(): T;
|
|
221
|
+
save(): void;
|
|
222
|
+
unload(): void;
|
|
223
|
+
/**
|
|
224
|
+
* Applies `dispatcher` to the current data. Returning `undefined` or
|
|
225
|
+
* `false` leaves the data untouched (returns `false`); anything else
|
|
226
|
+
* becomes the new data and is persisted on the next save.
|
|
227
|
+
*/
|
|
228
|
+
update(dispatcher: DataDispatcher<T>): boolean;
|
|
229
|
+
|
|
230
|
+
on_change(callback: (newData: T, oldData: T | undefined) => void): void;
|
|
231
|
+
on_save(callback: () => void): void;
|
|
232
|
+
/**
|
|
233
|
+
* Fires at the start of `unload`, while the profile is still open: the
|
|
234
|
+
* last chance to update before the final write.
|
|
235
|
+
*/
|
|
236
|
+
on_closing(callback: () => void): void;
|
|
237
|
+
/**
|
|
238
|
+
* Fires once the profile is closed and its final write (if any) is done.
|
|
239
|
+
* Also fires after `lock_lost`.
|
|
240
|
+
*/
|
|
241
|
+
on_closed(callback: () => void): void;
|
|
242
|
+
on_lock_lost(callback: () => void): void;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Yields until no operation holds or waits on the profile mutex. Once
|
|
246
|
+
* this returns, the next update call is guaranteed not to yield.
|
|
247
|
+
*/
|
|
248
|
+
wait_settled(): void;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Session-locked profile. */
|
|
252
|
+
interface Profile<T = unknown> extends ProfileBase<T> {
|
|
253
|
+
readonly kind: "locked";
|
|
254
|
+
readonly last_write: number;
|
|
255
|
+
/**
|
|
256
|
+
* Whether the profile currently holds its session lock. True after
|
|
257
|
+
* `load`; `release` clears it, `readquire` takes it again. While
|
|
258
|
+
* released, `update` / `save` throw and the profile cannot join a
|
|
259
|
+
* transaction.
|
|
260
|
+
*/
|
|
261
|
+
readonly is_locked: boolean;
|
|
262
|
+
|
|
263
|
+
/** Yields until the closed profile finished its final write. Throws if still open. */
|
|
264
|
+
wait_closed(): void;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Writes the data and gives the session lock back, keeping the profile
|
|
268
|
+
* open so another server may take the key. No-op when already released.
|
|
269
|
+
*/
|
|
270
|
+
release(): void;
|
|
271
|
+
/**
|
|
272
|
+
* Takes the session lock again (waits like `load` does) and adopts the
|
|
273
|
+
* stored record as the current data. No-op when already locked.
|
|
274
|
+
*/
|
|
275
|
+
readquire(): void;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Profile without a session lock; updates are queued and folded into storage on save. */
|
|
279
|
+
interface LocklessProfile<T = unknown> extends ProfileBase<T> {
|
|
280
|
+
readonly kind: "lockless";
|
|
281
|
+
readonly fetched: boolean;
|
|
282
|
+
|
|
283
|
+
readonly queued_updates: DataDispatcher<T>[];
|
|
284
|
+
readonly dirty_pending: boolean;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Reads the stored record into the local data: migrations are applied,
|
|
288
|
+
* then the queued updates replayed on top. Marks the profile fetched.
|
|
289
|
+
* Yields. Throws (keeping the queue) if a session lock is on the record.
|
|
290
|
+
*/
|
|
291
|
+
fetch(): T;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Either profile kind; narrow on `kind`. */
|
|
295
|
+
type AnyProfile<T = unknown> = Profile<T> | LocklessProfile<T>;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Read-only snapshot of a key. Not a `ProfileBase`: it cannot write, hold a
|
|
299
|
+
* lock or join a transaction. Returned by `store.peek`.
|
|
300
|
+
*/
|
|
301
|
+
interface PeekProfile<T = unknown> {
|
|
302
|
+
readonly kind: "peek";
|
|
303
|
+
readonly key: string;
|
|
304
|
+
|
|
305
|
+
/** Lock currently on the record (session or transaction), `undefined` when free. */
|
|
306
|
+
readonly lock: Lock | undefined;
|
|
307
|
+
/** Transaction marker on the record, if any. Left as stored, never resolved. */
|
|
308
|
+
readonly pending: Pending<T> | undefined;
|
|
309
|
+
/** Migration names after the in-memory migrations of the last read. */
|
|
310
|
+
readonly migrations: string[];
|
|
311
|
+
|
|
312
|
+
get_data(): T;
|
|
313
|
+
/** Calls `GetAsync` again and replaces `lock`, `pending`, `migrations` and the data. Yields. */
|
|
314
|
+
refresh(): T;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/*
|
|
318
|
+
Transactions
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
interface TxContext {
|
|
322
|
+
get<T>(profile: ProfileBase<T>): T;
|
|
323
|
+
set<T>(profile: ProfileBase<T>, newData: T): void;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/*
|
|
327
|
+
Store
|
|
328
|
+
*/
|
|
329
|
+
|
|
330
|
+
interface Store<T = unknown> {
|
|
331
|
+
readonly closed: boolean;
|
|
332
|
+
readonly config: ResolvedConfig<T>;
|
|
333
|
+
|
|
334
|
+
readonly profiles: ReadonlyMap<string, Profile<T>>;
|
|
335
|
+
readonly lockless_profiles: ReadonlyMap<string, LocklessProfile<T>>;
|
|
336
|
+
|
|
337
|
+
/** Takes the session lock for `key` and loads it. Yields. Throws when already loaded. */
|
|
338
|
+
load(key: string, user_ids: number[]): Profile<T>;
|
|
339
|
+
/** Returns the profile for `key`, waiting for it to load or loading it if nobody has yet. */
|
|
340
|
+
wait_loaded(key: string, user_ids: number[]): Profile<T>;
|
|
341
|
+
get_loaded(key: string): Profile<T> | undefined;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Returns the lockless profile for `key`, creating it if needed. Never
|
|
345
|
+
* yields: a lockless profile does no storage work until `fetch`.
|
|
346
|
+
*/
|
|
347
|
+
get_lockless(key: string, user_ids: number[]): LocklessProfile<T>;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Read-only snapshot of `key` via `GetAsync`: the current data and lock.
|
|
351
|
+
* Yields. The result is not tracked by the store, takes no lock and
|
|
352
|
+
* cannot join a transaction.
|
|
353
|
+
*/
|
|
354
|
+
peek(key: string): PeekProfile<T>;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Atomically updates profiles of this store (locked or lockless).
|
|
358
|
+
* `transform` gets their data in order and returns the new data in the
|
|
359
|
+
* same order, or `false` to cancel. Returns `false` when cancelled,
|
|
360
|
+
* `true` when committed; throws when aborted.
|
|
361
|
+
*/
|
|
362
|
+
transaction(
|
|
363
|
+
profiles: ProfileBase<T>[],
|
|
364
|
+
transform: (data: T[]) => T[] | false,
|
|
365
|
+
config?: TransactionConfig,
|
|
366
|
+
): boolean;
|
|
367
|
+
|
|
368
|
+
on_change(callback: (profile: AnyProfile<T>, newData: T, oldData: T | undefined) => void): void;
|
|
369
|
+
on_save(callback: (profile: AnyProfile<T>) => void): void;
|
|
370
|
+
on_closing(callback: (profile: AnyProfile<T>) => void): void;
|
|
371
|
+
on_closed(callback: (profile: AnyProfile<T>) => void): void;
|
|
372
|
+
on_lock_lost(callback: (profile: AnyProfile<T>) => void): void;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Unloads every profile (locked and lockless) concurrently and waits for
|
|
376
|
+
* all of them. Loads and transactions are rejected from the first line on.
|
|
377
|
+
*/
|
|
378
|
+
close(): void;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/*
|
|
382
|
+
Module
|
|
383
|
+
*/
|
|
384
|
+
|
|
385
|
+
/** Creates a store over the datastore named `config.name`. Throws if `name` or `template` is missing. */
|
|
386
|
+
function create_store<T>(config: Config<T>): Store<T>;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Atomically updates any set of profiles, across stores and mixing locked
|
|
390
|
+
* and lockless ones. `process` reads with `ctx.get(profile)` and stages
|
|
391
|
+
* writes with `ctx.set(profile, newData)`; return `false` to cancel.
|
|
392
|
+
*
|
|
393
|
+
* Returns `false` when cancelled, `true` when committed. Throws when aborted.
|
|
394
|
+
* `config` defaults its retry settings to the first profile's.
|
|
395
|
+
*/
|
|
396
|
+
function transaction(
|
|
397
|
+
profiles: ProfileBase<any>[],
|
|
398
|
+
process: (ctx: TxContext) => boolean | undefined,
|
|
399
|
+
config?: TransactionConfig,
|
|
400
|
+
): boolean;
|
|
401
|
+
|
|
402
|
+
namespace hooks {
|
|
403
|
+
namespace memory {
|
|
404
|
+
function create(scheduler: Scheduler): MemoryHook;
|
|
405
|
+
}
|
|
406
|
+
namespace roblox {
|
|
407
|
+
/** Adapts `DataStoreService`. Default inside Roblox. */
|
|
408
|
+
function create(): Hook;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
namespace schedulers {
|
|
413
|
+
namespace virtual {
|
|
414
|
+
function create(): VirtualScheduler;
|
|
415
|
+
}
|
|
416
|
+
namespace roblox {
|
|
417
|
+
/** Wraps the `task` library. Default inside Roblox. */
|
|
418
|
+
function create(): Scheduler;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export = dataforge;
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rbxts/dataforge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Roblox DataStore library with cross-key transactions, session-locked and lockless profiles, migrations and an immutable data model.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "PepeElToro41",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/PepeElToro41/dataforge.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://pepeeltoro41.github.io/dataforge/",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"roblox",
|
|
14
|
+
"roblox-ts",
|
|
15
|
+
"datastore",
|
|
16
|
+
"luau"
|
|
17
|
+
],
|
|
18
|
+
"main": "src/init.luau",
|
|
19
|
+
"types": "index.d.ts",
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"index.d.ts",
|
|
23
|
+
"default.project.json",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@rbxts/types": "^1.0.947"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
|
|
3
|
+
local types = require "../types"
|
|
4
|
+
local utils = require "../utils"
|
|
5
|
+
|
|
6
|
+
--[[
|
|
7
|
+
In-memory hook. Meant for tests (the library's own and yours).
|
|
8
|
+
|
|
9
|
+
- Every store handle created from the same hook shares one backend table, so
|
|
10
|
+
several `create_store` instances over one hook behave like several servers.
|
|
11
|
+
- Values are deep-copied on the way in and out (no aliasing with the backend).
|
|
12
|
+
- `latency` (seconds) is waited through the runtime before every operation, so
|
|
13
|
+
op interleavings are controllable under the virtual scheduler.
|
|
14
|
+
- `fail_next(store, key, op, mode)`: the next matching operation throws.
|
|
15
|
+
"before" throws without touching the backend; "after" performs the write and
|
|
16
|
+
then throws (simulates an unknown outcome / dying mid-call).
|
|
17
|
+
]]
|
|
18
|
+
|
|
19
|
+
export type Op = "get_async" | "set_async" | "update_async" | "remove_async" | "*"
|
|
20
|
+
export type FailMode = "before" | "after"
|
|
21
|
+
|
|
22
|
+
export type OpLogEntry = { store: string, key: string, op: string }
|
|
23
|
+
|
|
24
|
+
export type MemoryHook = types.Hook & {
|
|
25
|
+
scheduler: types.Scheduler,
|
|
26
|
+
|
|
27
|
+
data: { [string]: { [string]: any } },
|
|
28
|
+
latency: number,
|
|
29
|
+
ops: { OpLogEntry },
|
|
30
|
+
--- every message the library reported through `warn` (nothing is printed)
|
|
31
|
+
warnings: { string },
|
|
32
|
+
fail_next: (self: MemoryHook, store: string, key: string, op: Op, mode: FailMode, message: string?) -> (),
|
|
33
|
+
dump: (self: MemoryHook, store: string, key: string) -> any?,
|
|
34
|
+
clear_failures: (self: MemoryHook) -> (),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Failure = {
|
|
38
|
+
store: string,
|
|
39
|
+
key: string,
|
|
40
|
+
op: Op,
|
|
41
|
+
mode: FailMode,
|
|
42
|
+
message: string,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
local HEX = "0123456789abcdef"
|
|
46
|
+
|
|
47
|
+
function random_jobid(): string
|
|
48
|
+
local chars = table.create(16)
|
|
49
|
+
for index = 1, 16 do
|
|
50
|
+
local nibble = math.random(1, 16)
|
|
51
|
+
chars[index] = string.sub(HEX, nibble, nibble)
|
|
52
|
+
end
|
|
53
|
+
return table.concat(chars)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
local memory = {}
|
|
57
|
+
|
|
58
|
+
function memory.create(scheduler: types.Scheduler): MemoryHook
|
|
59
|
+
local handles: { [string]: types.DatastoreHandle } = {}
|
|
60
|
+
local failures: { Failure } = {}
|
|
61
|
+
|
|
62
|
+
local self = {} :: MemoryHook
|
|
63
|
+
|
|
64
|
+
self.data = {}
|
|
65
|
+
self.latency = 0
|
|
66
|
+
self.ops = {}
|
|
67
|
+
self.warnings = {}
|
|
68
|
+
self.scheduler = scheduler
|
|
69
|
+
|
|
70
|
+
function self.warn(message: string)
|
|
71
|
+
table.insert(self.warnings, message)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
local function bucket(store: string): { [string]: any }
|
|
75
|
+
local existing = self.data[store]
|
|
76
|
+
if existing == nil then
|
|
77
|
+
existing = {}
|
|
78
|
+
self.data[store] = existing
|
|
79
|
+
end
|
|
80
|
+
return existing
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
local function take_failure(store: string, key: string, op: string, mode: FailMode): Failure?
|
|
84
|
+
for index, failure in failures do
|
|
85
|
+
local matches = failure.store == store
|
|
86
|
+
and (failure.key == "*" or failure.key == key)
|
|
87
|
+
and (failure.op == "*" or failure.op == op)
|
|
88
|
+
and failure.mode == mode
|
|
89
|
+
if matches then
|
|
90
|
+
table.remove(failures, index)
|
|
91
|
+
return failure
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
local function begin(store: string, key: string, op: string)
|
|
98
|
+
if self.latency > 0 then
|
|
99
|
+
scheduler.wait(self.latency)
|
|
100
|
+
end
|
|
101
|
+
table.insert(self.ops, { store = store, key = key, op = op })
|
|
102
|
+
local failure = take_failure(store, key, op, "before")
|
|
103
|
+
if failure then
|
|
104
|
+
error(failure.message, 0)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
local function finish(store: string, key: string, op: string)
|
|
109
|
+
local failure = take_failure(store, key, op, "after")
|
|
110
|
+
if failure then
|
|
111
|
+
error(failure.message, 0)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
-- one id per hook instance: locks are owned by job id, so it must be stable
|
|
116
|
+
local job_id = random_jobid()
|
|
117
|
+
function self.job_id()
|
|
118
|
+
return job_id
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
function self.get_datastore(name: string): types.DatastoreHandle
|
|
122
|
+
local existing = handles[name]
|
|
123
|
+
if existing then
|
|
124
|
+
return existing
|
|
125
|
+
end
|
|
126
|
+
local values = bucket(name)
|
|
127
|
+
local handle = {} :: types.DatastoreHandle
|
|
128
|
+
handle.name = name
|
|
129
|
+
|
|
130
|
+
function handle.get_async(key: string): any?
|
|
131
|
+
begin(name, key, "get_async")
|
|
132
|
+
local value = utils.deep_copy(values[key])
|
|
133
|
+
finish(name, key, "get_async")
|
|
134
|
+
return value
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
function handle.set_async(key: string, value: any, _user_ids: { number }?)
|
|
138
|
+
begin(name, key, "set_async")
|
|
139
|
+
values[key] = utils.deep_copy(value)
|
|
140
|
+
finish(name, key, "set_async")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
function handle.update_async(key: string, transform: (old: any?) -> any?, _user_ids: { number }?): any?
|
|
144
|
+
begin(name, key, "update_async")
|
|
145
|
+
local old = utils.deep_copy(values[key])
|
|
146
|
+
local new = transform(old)
|
|
147
|
+
local result = old
|
|
148
|
+
if new ~= nil then
|
|
149
|
+
values[key] = utils.deep_copy(new)
|
|
150
|
+
result = new
|
|
151
|
+
end
|
|
152
|
+
finish(name, key, "update_async")
|
|
153
|
+
return result
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
function handle.remove_async(key: string)
|
|
157
|
+
begin(name, key, "remove_async")
|
|
158
|
+
values[key] = nil
|
|
159
|
+
finish(name, key, "remove_async")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
handles[name] = handle
|
|
163
|
+
return handle
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
function self.fail_next(_, store, key, op, mode, message)
|
|
167
|
+
table.insert(failures, {
|
|
168
|
+
store = store,
|
|
169
|
+
key = key,
|
|
170
|
+
op = op,
|
|
171
|
+
mode = mode,
|
|
172
|
+
message = message or `memory hook: injected failure ({mode} {op} {store}/{key})`,
|
|
173
|
+
})
|
|
174
|
+
end
|
|
175
|
+
function self.clear_failures()
|
|
176
|
+
table.clear(failures)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
function self.dump(_, store, key)
|
|
180
|
+
return utils.deep_copy(bucket(store)[key])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return self
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
return memory
|