@rbxts/dataforge 0.2.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/LICENSE +21 -0
- package/index.d.ts +111 -17
- package/package.json +7 -2
- package/src/errors.luau +16 -0
- package/src/init.luau +14 -0
- package/src/profile/common.luau +20 -4
- package/src/profile/lockless.luau +24 -5
- package/src/profile/peek.luau +12 -4
- package/src/profile/profile.luau +98 -29
- package/src/store.luau +43 -6
- package/src/types.luau +50 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PepeElToro41
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ declare namespace dataforge {
|
|
|
29
29
|
|
|
30
30
|
/** How a store reaches storage. */
|
|
31
31
|
interface Hook {
|
|
32
|
-
/** Identifies this server. Session
|
|
32
|
+
/** Identifies this server. Session lock and transaction ids are built from it. */
|
|
33
33
|
job_id: () => string;
|
|
34
34
|
/** Returns the handle for a named store. Called once per store name. */
|
|
35
35
|
get_datastore: (name: string) => DatastoreHandle;
|
|
@@ -115,16 +115,40 @@ declare namespace dataforge {
|
|
|
115
115
|
Configuration
|
|
116
116
|
*/
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
/** A named migration whose output can be checked against a target schema. */
|
|
119
|
+
interface Migration<T = unknown> {
|
|
119
120
|
name: string;
|
|
120
|
-
|
|
121
|
+
/** Stored data may have an older shape; narrow it before reading fields. */
|
|
122
|
+
apply: (data: unknown) => T;
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
/** Earlier steps may return intermediate shapes; the last must return T. */
|
|
126
|
+
type Migrations<T = unknown> = [] | [...Migration[], Migration<T>];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Decides whether this server may work with a key at all. `data` is the
|
|
130
|
+
* raw stored data as it is in storage: `undefined` when the key does not
|
|
131
|
+
* exist yet, and with the declared migrations *not* applied. Returning
|
|
132
|
+
* `false` rejects the operation with a `predicate_failed` error: no lock
|
|
133
|
+
* is taken and nothing is written.
|
|
134
|
+
*
|
|
135
|
+
* It runs inside the `update_async` transform, so it must be pure: no
|
|
136
|
+
* yielding, no storage calls, and no mutation of `data`.
|
|
137
|
+
*/
|
|
138
|
+
type Predicate<T = unknown> = (data: T | undefined, key: string) => boolean;
|
|
139
|
+
|
|
123
140
|
interface Config<T = unknown> {
|
|
124
141
|
name: string;
|
|
125
142
|
template: T;
|
|
126
143
|
|
|
127
|
-
|
|
144
|
+
/** The final output must match the template, rather than influence its inference. */
|
|
145
|
+
migrations?: Migrations<NoInfer<T>>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Gate on a key's stored data: every load, save, fetch and peek is
|
|
149
|
+
* rejected with `predicate_failed` when it returns `false`.
|
|
150
|
+
*/
|
|
151
|
+
predicate?: Predicate<T>;
|
|
128
152
|
|
|
129
153
|
/** seconds, default 30 (locked profiles) */
|
|
130
154
|
autosave_interval?: number;
|
|
@@ -142,8 +166,13 @@ declare namespace dataforge {
|
|
|
142
166
|
retry_base?: number;
|
|
143
167
|
}
|
|
144
168
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
169
|
+
/**
|
|
170
|
+
* `Config<T>` with every optional field filled in. `predicate` stays
|
|
171
|
+
* optional: a store without one gates nothing.
|
|
172
|
+
*/
|
|
173
|
+
type ResolvedConfig<T = unknown> = Required<Omit<Config<T>, "predicate">> & {
|
|
174
|
+
predicate?: Predicate<T>;
|
|
175
|
+
};
|
|
147
176
|
|
|
148
177
|
interface TransactionConfig {
|
|
149
178
|
/**
|
|
@@ -167,6 +196,12 @@ declare namespace dataforge {
|
|
|
167
196
|
*/
|
|
168
197
|
|
|
169
198
|
interface Lock {
|
|
199
|
+
/**
|
|
200
|
+
* Identifies the *acquisition* that holds the lock, not the server:
|
|
201
|
+
* `{job_id}#{n}` for a profile's session lock, `{job_id}-{n}` for a
|
|
202
|
+
* transaction's. Two sessions of one key on the same server hold
|
|
203
|
+
* different ids, so they contend like sessions on different servers do.
|
|
204
|
+
*/
|
|
170
205
|
id: string;
|
|
171
206
|
expires: number;
|
|
172
207
|
/**
|
|
@@ -269,6 +304,21 @@ declare namespace dataforge {
|
|
|
269
304
|
readonly declared_migrations: string[];
|
|
270
305
|
}
|
|
271
306
|
|
|
307
|
+
/**
|
|
308
|
+
* `config.predicate` returned `false` for a key's stored data: this server
|
|
309
|
+
* must not work with that key. Nothing is written and no lock is taken.
|
|
310
|
+
*/
|
|
311
|
+
interface PredicateFailedError extends ErrorBase {
|
|
312
|
+
readonly type: "predicate_failed";
|
|
313
|
+
readonly key: string;
|
|
314
|
+
/** The operation the predicate rejected. */
|
|
315
|
+
readonly op: "load" | "save" | "fetch" | "peek";
|
|
316
|
+
/** The raw stored data the predicate rejected (`undefined` when the key is empty). */
|
|
317
|
+
readonly data: unknown;
|
|
318
|
+
/** The profile the call ran on; `undefined` for `store.load` and `store.peek`. */
|
|
319
|
+
readonly profile: AnyProfile<any> | undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
272
322
|
/** Lockless `get_data` before any fetch / flush. Thrown, never returned. */
|
|
273
323
|
interface NotFetchedError extends ErrorBase {
|
|
274
324
|
readonly type: "not_fetched";
|
|
@@ -335,6 +385,7 @@ declare namespace dataforge {
|
|
|
335
385
|
| LocklessLockedError
|
|
336
386
|
| TxLockLostError
|
|
337
387
|
| OutdatedError
|
|
388
|
+
| PredicateFailedError
|
|
338
389
|
| NotFetchedError
|
|
339
390
|
| ProfileClosedError
|
|
340
391
|
| MigrationMismatchError
|
|
@@ -387,7 +438,10 @@ declare namespace dataforge {
|
|
|
387
438
|
* (`profile_closed`, or `not_fetched` for a lockless profile).
|
|
388
439
|
*/
|
|
389
440
|
get_data(): T;
|
|
390
|
-
/**
|
|
441
|
+
/**
|
|
442
|
+
* Fails with `profile_closed`, `released`, `roblox`, `predicate_failed`,
|
|
443
|
+
* `lock_lost` (locked) or `lockless_locked`, `outdated`, ... (lockless).
|
|
444
|
+
*/
|
|
391
445
|
save(): Result<void>;
|
|
392
446
|
unload(): void;
|
|
393
447
|
/**
|
|
@@ -424,6 +478,11 @@ declare namespace dataforge {
|
|
|
424
478
|
interface Profile<T = unknown> extends ProfileBase<T> {
|
|
425
479
|
readonly kind: "locked";
|
|
426
480
|
readonly last_write: number;
|
|
481
|
+
/**
|
|
482
|
+
* The id of this acquisition's lock; re-stamped unchanged on every
|
|
483
|
+
* save, replaced by `readquire`. See `Lock.id`.
|
|
484
|
+
*/
|
|
485
|
+
readonly lock_id: string;
|
|
427
486
|
/**
|
|
428
487
|
* Whether the profile currently holds its session lock. True after
|
|
429
488
|
* `load`; `release` clears it, `readquire` takes it again. While
|
|
@@ -438,13 +497,14 @@ declare namespace dataforge {
|
|
|
438
497
|
/**
|
|
439
498
|
* Writes the data and gives the session lock back, keeping the profile
|
|
440
499
|
* open so another server may take the key. No-op when already released.
|
|
441
|
-
* Fails with `profile_closed`, `roblox` or `lock_lost`.
|
|
500
|
+
* Fails with `profile_closed`, `roblox`, `predicate_failed` or `lock_lost`.
|
|
442
501
|
*/
|
|
443
502
|
release(): Result<void>;
|
|
444
503
|
/**
|
|
445
504
|
* Takes the session lock again (waits like `load` does) and adopts the
|
|
446
505
|
* stored record as the current data. No-op when already locked. Fails
|
|
447
|
-
* with `timeout`, `profile_closed`, `roblox`, `outdated`,
|
|
506
|
+
* with `timeout`, `profile_closed`, `roblox`, `outdated`,
|
|
507
|
+
* `predicate_failed`, `migration_mismatch`.
|
|
448
508
|
*/
|
|
449
509
|
readquire(): Result<void>;
|
|
450
510
|
}
|
|
@@ -461,7 +521,8 @@ declare namespace dataforge {
|
|
|
461
521
|
* Reads the stored record into the local data: migrations are applied,
|
|
462
522
|
* then the queued updates replayed on top. Marks the profile fetched.
|
|
463
523
|
* Yields. Fails (keeping the queue) with `lockless_locked` if a session
|
|
464
|
-
* lock is on the record, `outdated`, `
|
|
524
|
+
* lock is on the record, `outdated`, `predicate_failed`, `roblox`,
|
|
525
|
+
* `migration_mismatch`, `profile_closed`.
|
|
465
526
|
*/
|
|
466
527
|
fetch(): Result<T>;
|
|
467
528
|
}
|
|
@@ -487,8 +548,8 @@ declare namespace dataforge {
|
|
|
487
548
|
get_data(): T;
|
|
488
549
|
/**
|
|
489
550
|
* Calls `GetAsync` again and replaces `lock`, `pending`, `migrations`
|
|
490
|
-
* and the data. Yields. Fails with `roblox`, `
|
|
491
|
-
* keeping the previous snapshot.
|
|
551
|
+
* and the data. Yields. Fails with `roblox`, `predicate_failed`,
|
|
552
|
+
* `outdated` or `migration_mismatch`, keeping the previous snapshot.
|
|
492
553
|
*/
|
|
493
554
|
refresh(): Result<T>;
|
|
494
555
|
}
|
|
@@ -514,9 +575,14 @@ declare namespace dataforge {
|
|
|
514
575
|
readonly lockless_profiles: ReadonlyMap<string, LocklessProfile<T>>;
|
|
515
576
|
|
|
516
577
|
/**
|
|
517
|
-
* Takes the session lock for `key` and loads it. Yields.
|
|
518
|
-
*
|
|
519
|
-
*
|
|
578
|
+
* Takes the session lock for `key` and loads it. Yields. A profile that
|
|
579
|
+
* is closing no longer counts as loaded: the load waits out its final
|
|
580
|
+
* write through the record lock instead of failing `already_loaded`.
|
|
581
|
+
*
|
|
582
|
+
* Fails with `store_closed`, `already_loaded` (a profile for `key` is
|
|
583
|
+
* still open on this store), `timeout`, `roblox`, `outdated`,
|
|
584
|
+
* `predicate_failed` (`config.predicate` rejected the stored data; no
|
|
585
|
+
* lock is taken), `migration_mismatch` or `tx_marker_invalid`.
|
|
520
586
|
*/
|
|
521
587
|
load(key: string, user_ids: number[]): Result<Profile<T>>;
|
|
522
588
|
/**
|
|
@@ -536,8 +602,8 @@ declare namespace dataforge {
|
|
|
536
602
|
/**
|
|
537
603
|
* Read-only snapshot of `key` via `GetAsync`: the current data and lock.
|
|
538
604
|
* Yields. The result is not tracked by the store, takes no lock and
|
|
539
|
-
* cannot join a transaction. Fails with `store_closed`, `roblox`,
|
|
540
|
-
* or `migration_mismatch`.
|
|
605
|
+
* cannot join a transaction. Fails with `store_closed`, `roblox`,
|
|
606
|
+
* `outdated`, `predicate_failed` or `migration_mismatch`.
|
|
541
607
|
*/
|
|
542
608
|
peek(key: string): Result<PeekProfile<T>>;
|
|
543
609
|
|
|
@@ -554,6 +620,11 @@ declare namespace dataforge {
|
|
|
554
620
|
config?: TransactionConfig,
|
|
555
621
|
): Result<boolean>;
|
|
556
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Fires for every data change of every profile of this store, including
|
|
625
|
+
* the data a key loads with (`oldData === undefined` marks that initial
|
|
626
|
+
* value).
|
|
627
|
+
*/
|
|
557
628
|
on_change(callback: (profile: AnyProfile<T>, newData: T, oldData: T | undefined) => void): void;
|
|
558
629
|
on_save(callback: (profile: AnyProfile<T>) => void): void;
|
|
559
630
|
on_closing(callback: (profile: AnyProfile<T>) => void): void;
|
|
@@ -591,6 +662,29 @@ declare namespace dataforge {
|
|
|
591
662
|
config?: TransactionConfig,
|
|
592
663
|
): Result<boolean>;
|
|
593
664
|
|
|
665
|
+
/**
|
|
666
|
+
* Whether `value` is a dataforge error (what a failed `Result` carries and
|
|
667
|
+
* what the library throws internally). Lets a wrapper around library calls
|
|
668
|
+
* tell a library error from any other thrown value.
|
|
669
|
+
*/
|
|
670
|
+
function is_error(value: unknown): boolean;
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The `Result` helpers, so you can build library-shaped results in your own
|
|
674
|
+
* wrappers. `err` only accepts a dataforge error, so a malformed result
|
|
675
|
+
* cannot be built with it.
|
|
676
|
+
*/
|
|
677
|
+
namespace result {
|
|
678
|
+
function ok<T>(value: T): Ok<T>;
|
|
679
|
+
function err(error: DataforgeError): Err;
|
|
680
|
+
/** Whether `value` is a `Result`. */
|
|
681
|
+
function is(value: unknown): boolean;
|
|
682
|
+
/** `Ok(value)` when `ok`, `Err` when `value` is a dataforge error, rethrown otherwise. */
|
|
683
|
+
function of<T>(ok: boolean, value: unknown): Result<T>;
|
|
684
|
+
/** `pcall(fn)` folded through `of`. */
|
|
685
|
+
function capture<T>(fn: () => T): Result<T>;
|
|
686
|
+
}
|
|
687
|
+
|
|
594
688
|
namespace hooks {
|
|
595
689
|
namespace memory {
|
|
596
690
|
function create(scheduler: Scheduler): MemoryHook;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rbxts/dataforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Roblox DataStore library with cross-key transactions, session-locked and lockless profiles, migrations and an immutable data model.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "PepeElToro41",
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
],
|
|
18
18
|
"main": "src/init.luau",
|
|
19
19
|
"types": "index.d.ts",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test:types": "tsc --project tests/types/tsconfig.json"
|
|
22
|
+
},
|
|
20
23
|
"files": [
|
|
21
24
|
"src",
|
|
22
25
|
"index.d.ts",
|
|
@@ -27,6 +30,8 @@
|
|
|
27
30
|
"access": "public"
|
|
28
31
|
},
|
|
29
32
|
"devDependencies": {
|
|
30
|
-
"@rbxts/types": "
|
|
33
|
+
"@rbxts/compiler-types": "3.0.0-types.0",
|
|
34
|
+
"@rbxts/types": "^1.0.947",
|
|
35
|
+
"typescript": "5.5.3"
|
|
31
36
|
}
|
|
32
37
|
}
|
package/src/errors.luau
CHANGED
|
@@ -102,6 +102,22 @@ function errors.outdated(
|
|
|
102
102
|
}
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
function errors.predicate_failed(
|
|
106
|
+
key: string,
|
|
107
|
+
op: "load" | "save" | "fetch" | "peek",
|
|
108
|
+
data: any,
|
|
109
|
+
profile: any?
|
|
110
|
+
): types.PredicateFailedError
|
|
111
|
+
return make {
|
|
112
|
+
type = "predicate_failed" :: "predicate_failed",
|
|
113
|
+
message = `datastore: the store predicate rejected the stored data of "{key}" (on {op})`,
|
|
114
|
+
key = key,
|
|
115
|
+
op = op,
|
|
116
|
+
data = data,
|
|
117
|
+
profile = profile,
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
105
121
|
function errors.not_fetched(profile: any): types.NotFetchedError
|
|
106
122
|
return make {
|
|
107
123
|
type = "not_fetched" :: "not_fetched",
|
package/src/init.luau
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
local errors = require "@self/errors"
|
|
2
|
+
local result = require "@self/result"
|
|
1
3
|
local store = require "@self/store"
|
|
2
4
|
local tx = require "@self/tx/handle"
|
|
3
5
|
local types = require "@self/types"
|
|
@@ -20,6 +22,8 @@ export type ProfileBase<T = any> = types.ProfileBase<T>
|
|
|
20
22
|
export type PeekProfile<T = any> = types.PeekProfile<T>
|
|
21
23
|
export type Result<T> = types.Result<T>
|
|
22
24
|
export type Error = types.Error
|
|
25
|
+
export type Migration = types.Migration
|
|
26
|
+
export type DataDispatcher<T = any> = types.DataDispatcher<T>
|
|
23
27
|
|
|
24
28
|
local dataforge = {}
|
|
25
29
|
|
|
@@ -49,6 +53,16 @@ function dataforge.transaction(
|
|
|
49
53
|
return tx.run(tx.resolve_config(config, defaults), profiles, process)
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
--- Whether `value` is a dataforge `Error` table (what a failed Result carries
|
|
57
|
+
--- and what the library throws internally). Lets a wrapper around library calls
|
|
58
|
+
--- tell a library error from any other thrown value.
|
|
59
|
+
dataforge.is_error = errors.is
|
|
60
|
+
|
|
61
|
+
--- The `Result` helpers (`ok`, `err`, `is`, `of`, `capture`), so a game can
|
|
62
|
+
--- build library-shaped results in its own wrappers. `result.err` only accepts
|
|
63
|
+
--- an `Error`, so a malformed result cannot be fabricated with it.
|
|
64
|
+
dataforge.result = result
|
|
65
|
+
|
|
52
66
|
dataforge.hooks = {
|
|
53
67
|
memory = memory_hook,
|
|
54
68
|
roblox = roblox_hook,
|
package/src/profile/common.luau
CHANGED
|
@@ -90,6 +90,18 @@ function common.is_outdated(record: types.DataRecord?, config: types.ResolvedCon
|
|
|
90
90
|
return #record.migrations > #config.migrations
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
function common.run_predicate(config: types.ResolvedConfig, key: string, data: any): (boolean, any?)
|
|
94
|
+
local predicate = config.predicate
|
|
95
|
+
if predicate == nil then
|
|
96
|
+
return true, nil
|
|
97
|
+
end
|
|
98
|
+
local ok, passed = pcall(predicate, data, key)
|
|
99
|
+
if not ok then
|
|
100
|
+
return false, passed
|
|
101
|
+
end
|
|
102
|
+
return not not passed, nil
|
|
103
|
+
end
|
|
104
|
+
|
|
93
105
|
--- Rethrows a failed `apply_migrations` with the profile attached to an
|
|
94
106
|
--- `outdated` error (a `migration_mismatch` has no profile field).
|
|
95
107
|
function common.raise_migrations(profile: ProfileBase, migrated: MigrationsResult)
|
|
@@ -100,7 +112,9 @@ function common.raise_migrations(profile: ProfileBase, migrated: MigrationsResul
|
|
|
100
112
|
error(err, 0)
|
|
101
113
|
end
|
|
102
114
|
|
|
103
|
-
|
|
115
|
+
--- Whether `lock_id` (the id of one acquisition) is the lock currently on
|
|
116
|
+
--- the record, so this acquisition may write it.
|
|
117
|
+
function common.is_lock_owned(record: types.DataRecord?, lock_id: string)
|
|
104
118
|
if not record then
|
|
105
119
|
return false
|
|
106
120
|
end
|
|
@@ -110,10 +124,12 @@ function common.is_lock_owned(record: types.DataRecord?, job_id: string)
|
|
|
110
124
|
return false
|
|
111
125
|
end
|
|
112
126
|
|
|
113
|
-
return lock.id ==
|
|
127
|
+
return lock.id == lock_id
|
|
114
128
|
end
|
|
115
129
|
|
|
116
|
-
|
|
130
|
+
--- Whether a live lock of another acquisition holds the record, in which case
|
|
131
|
+
--- `lock_id` must wait it out or steal it once it expires.
|
|
132
|
+
function common.is_locked_for(record: types.DataRecord?, lock_id: string, now: number)
|
|
117
133
|
if not record then
|
|
118
134
|
return false
|
|
119
135
|
end
|
|
@@ -122,7 +138,7 @@ function common.is_locked_for(record: types.DataRecord?, job_id: string, now: nu
|
|
|
122
138
|
return false
|
|
123
139
|
end
|
|
124
140
|
|
|
125
|
-
return lock.id ~=
|
|
141
|
+
return lock.id ~= lock_id and lock.expires > now
|
|
126
142
|
end
|
|
127
143
|
|
|
128
144
|
function common.has_lock(record: types.DataRecord?)
|
|
@@ -146,8 +146,9 @@ type WriteOptions = types.LocklessWriteOptions
|
|
|
146
146
|
---
|
|
147
147
|
--- Throws `lockless_locked` (a live session lock; the profile is closed),
|
|
148
148
|
--- `tx_lock_lost` (closed too), `outdated` (a newer server wrote the record;
|
|
149
|
-
--- nothing written), `
|
|
150
|
-
---
|
|
149
|
+
--- nothing written), `predicate_failed` (the store's predicate rejected the
|
|
150
|
+
--- stored data; nothing written), `migration_mismatch`, `roblox`, or whatever
|
|
151
|
+
--- a queued dispatcher / migration threw.
|
|
151
152
|
local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
152
153
|
local config = self.config
|
|
153
154
|
local scheduler = config._scheduler
|
|
@@ -205,6 +206,14 @@ local function write_queued(self: LocklessProfile, options: WriteOptions)
|
|
|
205
206
|
return nil
|
|
206
207
|
end
|
|
207
208
|
|
|
209
|
+
-- the store's predicate gates the key: a rejected record is not written
|
|
210
|
+
-- to, and no `pending` of it is resolved
|
|
211
|
+
local stored = if old then old.data else nil
|
|
212
|
+
local passed, predicate_failure = common.run_predicate(config, self.key, stored)
|
|
213
|
+
if not passed then
|
|
214
|
+
error(predicate_failure or errors.predicate_failed(self.key, "save", stored, self), 0)
|
|
215
|
+
end
|
|
216
|
+
|
|
208
217
|
-- a `pending` left by some transaction (ours after commit, or another
|
|
209
218
|
-- server's) decides which data this write builds on
|
|
210
219
|
local base: any = nil
|
|
@@ -341,7 +350,8 @@ end
|
|
|
341
350
|
--- refreshes the local data with the result. Marks the profile fetched.
|
|
342
351
|
--- Fails (keeping the queue) with `lockless_locked` if another server holds a
|
|
343
352
|
--- live session lock (the profile is closed), `outdated` if the record was
|
|
344
|
-
--- already written by a newer server, `
|
|
353
|
+
--- already written by a newer server, `predicate_failed` if the store's
|
|
354
|
+
--- predicate rejected the stored data, `migration_mismatch`, `roblox` or
|
|
345
355
|
--- `profile_closed`. An error thrown by a queued dispatcher propagates as is.
|
|
346
356
|
function lockless_profile.save(self: LocklessProfile): types.Result<nil>
|
|
347
357
|
return result.capture(function()
|
|
@@ -417,7 +427,8 @@ end
|
|
|
417
427
|
--- Reads the stored record (get_async) into the local data: migrations are
|
|
418
428
|
--- applied, then the queued updates replayed on top, so `get_data()` reflects the
|
|
419
429
|
--- local changes still waiting for a save. Marks the profile fetched. Throws
|
|
420
|
-
--- (keeping the queue) if any session lock is on the record
|
|
430
|
+
--- (keeping the queue) if any session lock is on the record, or if the store's
|
|
431
|
+
--- predicate rejects the stored data. Caller holds the mutex.
|
|
421
432
|
local function read_record(self: LocklessProfile)
|
|
422
433
|
local config = self.config
|
|
423
434
|
local datastore = self._datastore
|
|
@@ -433,6 +444,13 @@ local function read_record(self: LocklessProfile)
|
|
|
433
444
|
-- a transaction lock does not matter for a read: the data under it is the
|
|
434
445
|
-- data until that transaction's `pending` lands (handled below)
|
|
435
446
|
|
|
447
|
+
local stored = if record then record.data else nil
|
|
448
|
+
local passed, predicate_failure = common.run_predicate(config, self.key, stored)
|
|
449
|
+
if not passed then
|
|
450
|
+
-- nothing is adopted: the profile keeps whatever it had and stays open
|
|
451
|
+
error(predicate_failure or errors.predicate_failed(self.key, "fetch", stored, self), 0)
|
|
452
|
+
end
|
|
453
|
+
|
|
436
454
|
-- a `pending` on the record: find out whether it committed (the record is
|
|
437
455
|
-- left as is; the next write folds it in)
|
|
438
456
|
local base: any = nil
|
|
@@ -455,7 +473,8 @@ local function read_record(self: LocklessProfile)
|
|
|
455
473
|
end
|
|
456
474
|
|
|
457
475
|
--- Fails with `lockless_locked` (a session lock is on the record; the profile
|
|
458
|
-
--- is closed), `outdated` (a newer server wrote the record), `
|
|
476
|
+
--- is closed), `outdated` (a newer server wrote the record), `predicate_failed`
|
|
477
|
+
--- (the store's predicate rejected the stored data), `migration_mismatch`,
|
|
459
478
|
--- `roblox`, `tx_marker_invalid` or `profile_closed`. An error thrown by a
|
|
460
479
|
--- queued dispatcher propagates as is.
|
|
461
480
|
function lockless_profile.fetch(self: LocklessProfile): types.Result<any>
|
package/src/profile/peek.luau
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
local common = require "./common"
|
|
2
|
+
local errors = require "../errors"
|
|
2
3
|
local result = require "../result"
|
|
3
4
|
local types = require "../types"
|
|
4
5
|
local utils = require "../utils"
|
|
@@ -18,13 +19,20 @@ peek_profile.__index = peek_profile
|
|
|
18
19
|
--- Reads the record with `GetAsync` and adopts it: `lock` and `pending` are
|
|
19
20
|
--- taken as stored, the data gets the declared migrations applied in memory
|
|
20
21
|
--- (nothing is written). A missing key resolves to the template. Throws
|
|
21
|
-
--- `roblox`, `outdated` or `migration_mismatch`.
|
|
22
|
+
--- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
|
|
22
23
|
local function read(self: PeekProfile): any
|
|
23
24
|
local config = self._config
|
|
24
25
|
local datastore = self._datastore
|
|
25
26
|
|
|
26
27
|
local record = utils.get_async(config, datastore, self.key) :: DataRecord?
|
|
27
28
|
|
|
29
|
+
-- before anything is adopted: a rejected key leaves the snapshot untouched
|
|
30
|
+
local stored = if record then record.data else nil
|
|
31
|
+
local passed, predicate_failure = common.run_predicate(config, self.key, stored)
|
|
32
|
+
if not passed then
|
|
33
|
+
error(predicate_failure or errors.predicate_failed(self.key, "peek", stored), 0)
|
|
34
|
+
end
|
|
35
|
+
|
|
28
36
|
self.lock = if record then record.lock else nil
|
|
29
37
|
self.pending = if record then record.pending else nil
|
|
30
38
|
|
|
@@ -52,15 +60,15 @@ function peek_profile.get_data(self: PeekProfile)
|
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
--- Calls `get_async` again and replaces the snapshot. Yields. Fails with
|
|
55
|
-
--- `roblox`, `outdated` or `migration_mismatch`, leaving
|
|
56
|
-
--- in place.
|
|
63
|
+
--- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`, leaving
|
|
64
|
+
--- the previous snapshot in place.
|
|
57
65
|
function peek_profile.refresh(self: PeekProfile): types.Result<any>
|
|
58
66
|
return result.capture(read, self)
|
|
59
67
|
end
|
|
60
68
|
|
|
61
69
|
--- Takes the first snapshot right away (yields). The profile has no mutex, no
|
|
62
70
|
--- loops and no hooks: it is a plain value that can be refreshed. Fails with
|
|
63
|
-
--- `roblox`, `outdated` or `migration_mismatch`.
|
|
71
|
+
--- `roblox`, `predicate_failed`, `outdated` or `migration_mismatch`.
|
|
64
72
|
function peek_profile.create(options: PeekOptions): types.Result<PeekProfile>
|
|
65
73
|
local self = {} :: PeekProfile
|
|
66
74
|
self.kind = "peek"
|
package/src/profile/profile.luau
CHANGED
|
@@ -15,12 +15,22 @@ export type LoadOptions = types.LoadOptions
|
|
|
15
15
|
local profile = {}
|
|
16
16
|
profile.__index = profile
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
--- One counter per process: a lock id must be unique per acquisition, not per
|
|
19
|
+
--- server, so two sessions of the same key on this server can tell their locks
|
|
20
|
+
--- apart. `#` keeps them out of the transaction id namespace (`{job_id}-{n}`),
|
|
21
|
+
--- which lockless writes compare by id.
|
|
22
|
+
local lock_counter = utils.counter()
|
|
23
|
+
|
|
24
|
+
--- Takes the session lock on `key`, waiting out (or stealing by expiry) any
|
|
25
|
+
--- holder. Returns the locked record and the id this acquisition stamped on it.
|
|
26
|
+
local function adquire_lock(load_options: LoadOptions): (types.DataRecord, string)
|
|
19
27
|
local config = load_options.config
|
|
20
28
|
local user_ids = load_options.user_ids
|
|
21
29
|
local datastore = load_options._datastore
|
|
22
|
-
local job_id = config._hook.job_id()
|
|
23
30
|
local key = load_options.key
|
|
31
|
+
-- minted once per acquisition, before the loop: waiting may write the lock
|
|
32
|
+
-- several times and every attempt must stamp the same id
|
|
33
|
+
local lock_id = `{config._hook.job_id()}#{lock_counter()}`
|
|
24
34
|
|
|
25
35
|
local scheduler = config._scheduler
|
|
26
36
|
|
|
@@ -30,6 +40,9 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
|
30
40
|
-- set by the transform: the record lists more migrations than this server
|
|
31
41
|
-- declares, so the lock must not be taken (the write is cancelled)
|
|
32
42
|
local outdated_migrations: { string }? = nil
|
|
43
|
+
-- set by the transform: the error the store's predicate rejected the record
|
|
44
|
+
-- with (or the one the predicate itself threw); the lock is not taken
|
|
45
|
+
local predicate_error: any = nil
|
|
33
46
|
|
|
34
47
|
while true do
|
|
35
48
|
local now = scheduler.now()
|
|
@@ -39,11 +52,20 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
|
39
52
|
key,
|
|
40
53
|
function(old: types.DataRecord?): types.DataRecord?
|
|
41
54
|
outdated_migrations = nil
|
|
55
|
+
predicate_error = nil
|
|
42
56
|
if common.is_outdated(old, config) then
|
|
43
57
|
outdated_migrations = (old :: types.DataRecord).migrations
|
|
44
58
|
return nil
|
|
45
59
|
end
|
|
46
|
-
|
|
60
|
+
-- before the lock check on purpose: a key this server must not
|
|
61
|
+
-- own is rejected right away instead of waiting out its holder
|
|
62
|
+
local stored = if old then old.data else nil
|
|
63
|
+
local passed, failure = common.run_predicate(config, key, stored)
|
|
64
|
+
if not passed then
|
|
65
|
+
predicate_error = failure or errors.predicate_failed(key, "load", stored)
|
|
66
|
+
return nil
|
|
67
|
+
end
|
|
68
|
+
if common.is_locked_for(old :: types.DataRecord, lock_id, now) then
|
|
47
69
|
return nil
|
|
48
70
|
end
|
|
49
71
|
local record: types.DataRecord = old or {} :: types.DataRecord
|
|
@@ -53,7 +75,7 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
|
53
75
|
pending = record.pending,
|
|
54
76
|
migrations = record.migrations or {},
|
|
55
77
|
lock = {
|
|
56
|
-
id =
|
|
78
|
+
id = lock_id,
|
|
57
79
|
expires = now + config.lock_ttl,
|
|
58
80
|
},
|
|
59
81
|
}
|
|
@@ -64,8 +86,11 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
|
64
86
|
if outdated_migrations ~= nil then
|
|
65
87
|
error(errors.outdated(key, outdated_migrations, common.all_migration_names(config.migrations)), 0)
|
|
66
88
|
end
|
|
67
|
-
if
|
|
68
|
-
|
|
89
|
+
if predicate_error ~= nil then
|
|
90
|
+
error(predicate_error, 0)
|
|
91
|
+
end
|
|
92
|
+
if common.is_lock_owned(lock_applied, lock_id) then
|
|
93
|
+
return lock_applied, lock_id
|
|
69
94
|
end
|
|
70
95
|
last_lock = if lock_applied then lock_applied.lock else nil
|
|
71
96
|
|
|
@@ -78,7 +103,7 @@ local function adquire_lock(load_options: LoadOptions): types.DataRecord
|
|
|
78
103
|
local current = utils.get_async(config, datastore, key) :: types.DataRecord?
|
|
79
104
|
last_lock = if current then current.lock else nil
|
|
80
105
|
|
|
81
|
-
if not common.is_locked_for(current,
|
|
106
|
+
if not common.is_locked_for(current, lock_id, scheduler.now()) then
|
|
82
107
|
break
|
|
83
108
|
end
|
|
84
109
|
end
|
|
@@ -130,6 +155,18 @@ local function set_data(self: Profile, data: any)
|
|
|
130
155
|
fire_hooks(self, self._hooks.change, new, old)
|
|
131
156
|
end
|
|
132
157
|
|
|
158
|
+
--- Lets the store drop the key as soon as the profile stops being usable,
|
|
159
|
+
--- without waiting for the final write to land. Idempotent: a lost lock
|
|
160
|
+
--- followed by an unload must only detach once.
|
|
161
|
+
local function detach(self: Profile)
|
|
162
|
+
local on_detach = self._on_detach
|
|
163
|
+
if on_detach == nil or self._detached then
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
self._detached = true
|
|
167
|
+
on_detach()
|
|
168
|
+
end
|
|
169
|
+
|
|
133
170
|
--- Another server took the key: the profile closes without a release write.
|
|
134
171
|
--- Runs inside a write (mutex held by the caller), so it only closes the mutex:
|
|
135
172
|
--- the holder finishes, everything waiting on it is rejected.
|
|
@@ -138,6 +175,7 @@ local function process_lock_lost(self: Profile)
|
|
|
138
175
|
return
|
|
139
176
|
end
|
|
140
177
|
self.open = false
|
|
178
|
+
detach(self)
|
|
141
179
|
self._mutex:close(errors.profile_closed(self, "lock_lost"))
|
|
142
180
|
fire_hooks(self, self._hooks.lock_lost)
|
|
143
181
|
fire_hooks(self, self._hooks.closed)
|
|
@@ -151,23 +189,37 @@ type WriteOptions = {
|
|
|
151
189
|
--- Writes the data (and `pending`, if any) under our session lock, refreshing
|
|
152
190
|
--- or releasing it. Returns true when written; false and the lock now on the
|
|
153
191
|
--- record (nil when none) when the lock is not ours any more, in which case
|
|
154
|
-
--- the profile is closed. Throws a `roblox` error when storage fails
|
|
192
|
+
--- the profile is closed. Throws a `roblox` error when storage fails, or
|
|
193
|
+
--- `predicate_failed` when the store's predicate rejected the stored data
|
|
194
|
+
--- (nothing is written and the lock is kept).
|
|
155
195
|
local function write_data_record(self: Profile, options: WriteOptions): (boolean, types.Lock?)
|
|
156
196
|
local config = self.config
|
|
157
197
|
local scheduler = config._scheduler
|
|
158
198
|
local datastore = self._datastore
|
|
159
199
|
local now = scheduler.now()
|
|
160
|
-
|
|
200
|
+
-- this acquisition's id, not the job id: another session of this key on this
|
|
201
|
+
-- server holds a different lock and must not be written over
|
|
202
|
+
local lock_id = self.lock_id
|
|
161
203
|
|
|
162
204
|
-- set by the transform: whether the write went through (the transform may
|
|
163
205
|
-- run more than once when the storage retries it), and the lock it found
|
|
164
206
|
local applied = false
|
|
165
207
|
local seen_lock: types.Lock? = nil
|
|
208
|
+
-- set by the transform: the store's predicate rejected the stored data (or
|
|
209
|
+
-- threw); the write is cancelled without the lock being lost
|
|
210
|
+
local predicate_error: any = nil
|
|
166
211
|
|
|
167
212
|
local function transform(old: any?): DataRecord?
|
|
168
213
|
applied = false
|
|
214
|
+
predicate_error = nil
|
|
169
215
|
seen_lock = if old then old.lock else nil
|
|
170
|
-
if not common.is_lock_owned(old,
|
|
216
|
+
if not common.is_lock_owned(old, lock_id) then
|
|
217
|
+
return nil
|
|
218
|
+
end
|
|
219
|
+
local stored = if old then old.data else nil
|
|
220
|
+
local passed, failure = common.run_predicate(config, self.key, stored)
|
|
221
|
+
if not passed then
|
|
222
|
+
predicate_error = failure or errors.predicate_failed(self.key, "save", stored, self)
|
|
171
223
|
return nil
|
|
172
224
|
end
|
|
173
225
|
applied = true
|
|
@@ -175,7 +227,9 @@ local function write_data_record(self: Profile, options: WriteOptions): (boolean
|
|
|
175
227
|
local new_lock: types.Lock? = nil
|
|
176
228
|
if not options.release then
|
|
177
229
|
new_lock = {
|
|
178
|
-
id
|
|
230
|
+
-- the same id every time: a fresh one would look like a new lock
|
|
231
|
+
-- to everyone else
|
|
232
|
+
id = lock_id,
|
|
179
233
|
expires = now + config.lock_ttl,
|
|
180
234
|
tx = nil,
|
|
181
235
|
} :: types.Lock
|
|
@@ -202,6 +256,12 @@ local function write_data_record(self: Profile, options: WriteOptions): (boolean
|
|
|
202
256
|
error(err, 0)
|
|
203
257
|
end
|
|
204
258
|
|
|
259
|
+
if predicate_error ~= nil then
|
|
260
|
+
-- the predicate cancelled the write, not a lost lock: the profile keeps
|
|
261
|
+
-- its lock and stays open, so the next save may go through again
|
|
262
|
+
error(predicate_error, 0)
|
|
263
|
+
end
|
|
264
|
+
|
|
205
265
|
if not applied then
|
|
206
266
|
-- the transform bailed out: the lock is not ours any more
|
|
207
267
|
process_lock_lost(self)
|
|
@@ -222,16 +282,16 @@ local function write_data_record(self: Profile, options: WriteOptions): (boolean
|
|
|
222
282
|
return true
|
|
223
283
|
end
|
|
224
284
|
|
|
225
|
-
--- Gives the lock taken by `adquire_lock` back, leaving the record
|
|
226
|
-
--- untouched. Used when the load fails after locking; a failure here
|
|
227
|
-
--- warned about (the lock expires on its own).
|
|
228
|
-
local
|
|
285
|
+
--- Gives the lock `lock_id` (taken by `adquire_lock`) back, leaving the record
|
|
286
|
+
--- otherwise untouched. Used when the load fails after locking; a failure here
|
|
287
|
+
--- is only warned about (the lock expires on its own). Takes the acquisition id
|
|
288
|
+
--- so a failed load cannot clear a lock another local acquisition just took.
|
|
289
|
+
local function release_lock(load_options: LoadOptions, lock_id: string)
|
|
229
290
|
local config = load_options.config
|
|
230
|
-
local job_id = config._hook.job_id()
|
|
231
291
|
|
|
232
292
|
local ok, err = utils.try(function()
|
|
233
293
|
utils.update_async(config, load_options._datastore, load_options.key, function(old: DataRecord?): DataRecord?
|
|
234
|
-
if not common.is_lock_owned(old,
|
|
294
|
+
if not common.is_lock_owned(old, lock_id) then
|
|
235
295
|
return nil
|
|
236
296
|
end
|
|
237
297
|
local record = old :: DataRecord
|
|
@@ -298,8 +358,8 @@ end
|
|
|
298
358
|
|
|
299
359
|
--- Takes the session lock and resolves the locked record into data. If the
|
|
300
360
|
--- resolution fails the lock is given back before rethrowing.
|
|
301
|
-
local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean)
|
|
302
|
-
local record = adquire_lock(load_options)
|
|
361
|
+
local function adquire_and_resolve(load_options: LoadOptions): (any, { string }, boolean, string)
|
|
362
|
+
local record, lock_id = adquire_lock(load_options)
|
|
303
363
|
local config = load_options.config
|
|
304
364
|
|
|
305
365
|
-- from here on the key is locked: anything failing must give it back
|
|
@@ -309,10 +369,10 @@ local function adquire_and_resolve(load_options: LoadOptions): (any, { string },
|
|
|
309
369
|
config._hook.warn(
|
|
310
370
|
`datastore: load of "{load_options.key}" failed after taking its lock, releasing it: {tostring(err)}`
|
|
311
371
|
)
|
|
312
|
-
release_lock(load_options)
|
|
372
|
+
release_lock(load_options, lock_id)
|
|
313
373
|
error(err, 0)
|
|
314
374
|
end
|
|
315
|
-
return data, new_migrations, dirty
|
|
375
|
+
return data, new_migrations, dirty, lock_id
|
|
316
376
|
end
|
|
317
377
|
|
|
318
378
|
local function load_options_of(self: Profile): LoadOptions
|
|
@@ -331,7 +391,7 @@ local function assert_locked(self: Profile)
|
|
|
331
391
|
end
|
|
332
392
|
|
|
333
393
|
function profile.load(load_options: LoadOptions): Profile
|
|
334
|
-
local data, new_migrations, dirty = adquire_and_resolve(load_options)
|
|
394
|
+
local data, new_migrations, dirty, lock_id = adquire_and_resolve(load_options)
|
|
335
395
|
local config = load_options.config
|
|
336
396
|
|
|
337
397
|
local self: Profile = {} :: any
|
|
@@ -341,6 +401,8 @@ function profile.load(load_options: LoadOptions): Profile
|
|
|
341
401
|
self._datastore = load_options._datastore
|
|
342
402
|
self.open = true
|
|
343
403
|
self.is_locked = true
|
|
404
|
+
self.lock_id = lock_id
|
|
405
|
+
self._detached = false
|
|
344
406
|
|
|
345
407
|
self.config = load_options.config
|
|
346
408
|
self.migrations = new_migrations
|
|
@@ -416,8 +478,9 @@ function profile.on_lock_lost(self: Profile, callback: () -> ())
|
|
|
416
478
|
end
|
|
417
479
|
|
|
418
480
|
--- Writes the data, refreshing the session lock. Fails with `profile_closed`,
|
|
419
|
-
--- `released`, `roblox`,
|
|
420
|
-
---
|
|
481
|
+
--- `released`, `roblox`, `predicate_failed` (the store's predicate rejected
|
|
482
|
+
--- the stored data; nothing is written and the lock is kept), or `lock_lost`
|
|
483
|
+
--- (another server took the key; the profile is closed).
|
|
421
484
|
function profile.save(self: Profile): types.Result<nil>
|
|
422
485
|
return result.capture(function()
|
|
423
486
|
common.assert_open(self)
|
|
@@ -441,8 +504,8 @@ end
|
|
|
441
504
|
--- Writes the data and gives the session lock back. The profile stays open
|
|
442
505
|
--- (another server may load the key meanwhile); `update` and `save` fail with
|
|
443
506
|
--- `released` until `readquire`. No-op when already released. Fails with
|
|
444
|
-
--- `profile_closed`, `roblox` (the lock stays on the
|
|
445
|
-
--- stays true) or `lock_lost` (the profile is closed).
|
|
507
|
+
--- `profile_closed`, `roblox` or `predicate_failed` (the lock stays on the
|
|
508
|
+
--- record, `is_locked` stays true), or `lock_lost` (the profile is closed).
|
|
446
509
|
function profile.release(self: Profile): types.Result<nil>
|
|
447
510
|
return result.capture(function()
|
|
448
511
|
common.assert_open(self)
|
|
@@ -468,8 +531,8 @@ end
|
|
|
468
531
|
--- Takes the session lock again, waiting for a holder like `load` does, and
|
|
469
532
|
--- adopts the stored record (resolved pending, migrations) as the current
|
|
470
533
|
--- data. Fails with `timeout` (the profile stays open and released),
|
|
471
|
-
--- `profile_closed`, `roblox`, `outdated`, `
|
|
472
|
-
--- already locked.
|
|
534
|
+
--- `profile_closed`, `roblox`, `outdated`, `predicate_failed`,
|
|
535
|
+
--- `migration_mismatch` or `tx_marker_invalid`. No-op when already locked.
|
|
473
536
|
function profile.readquire(self: Profile): types.Result<nil>
|
|
474
537
|
return result.capture(function()
|
|
475
538
|
common.assert_open(self)
|
|
@@ -481,7 +544,7 @@ function profile.readquire(self: Profile): types.Result<nil>
|
|
|
481
544
|
if self.is_locked then
|
|
482
545
|
return
|
|
483
546
|
end
|
|
484
|
-
local ok, data, new_migrations, dirty = pcall(adquire_and_resolve, load_options_of(self))
|
|
547
|
+
local ok, data, new_migrations, dirty, lock_id = pcall(adquire_and_resolve, load_options_of(self))
|
|
485
548
|
if not ok then
|
|
486
549
|
local err: any = data
|
|
487
550
|
if errors.is(err) and err.type == "outdated" then
|
|
@@ -491,6 +554,9 @@ function profile.readquire(self: Profile): types.Result<nil>
|
|
|
491
554
|
end
|
|
492
555
|
|
|
493
556
|
self.migrations = new_migrations
|
|
557
|
+
-- a fresh acquisition, so a fresh id (the released profile kept the
|
|
558
|
+
-- old one; nothing could be written with it while released)
|
|
559
|
+
self.lock_id = lock_id
|
|
494
560
|
set_data(self, data)
|
|
495
561
|
self._dirty = dirty
|
|
496
562
|
self.last_write = self.config._scheduler.now()
|
|
@@ -511,6 +577,9 @@ function profile.unload(self: Profile)
|
|
|
511
577
|
end
|
|
512
578
|
|
|
513
579
|
self.open = false
|
|
580
|
+
-- before the release write (which may take seconds under throttling): the
|
|
581
|
+
-- key is free to be loaded again, the new load waits on the record lock
|
|
582
|
+
detach(self)
|
|
514
583
|
self._mutex:lock()
|
|
515
584
|
self._mutex:close(errors.profile_closed(self, "unloaded"))
|
|
516
585
|
local ok, err = utils.try(function()
|
package/src/store.luau
CHANGED
|
@@ -48,8 +48,9 @@ export type Store<T = any> = {
|
|
|
48
48
|
config: ResolvedConfig<T>,
|
|
49
49
|
--- Takes the session lock for `key` and loads it. Yields. Fails with
|
|
50
50
|
--- `store_closed`, `already_loaded`, `timeout`, `roblox`, `outdated` (a
|
|
51
|
-
--- newer server wrote the record; no lock is taken), `
|
|
52
|
-
---
|
|
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`.
|
|
53
54
|
load: (self: Store<T>, key: string, user_ids: { number }) -> Result<Profile<T>>,
|
|
54
55
|
--- Returns the profile for `key`, waiting for it to load or loading it if
|
|
55
56
|
--- nobody has yet. Waiters get the same Result as the load.
|
|
@@ -63,8 +64,8 @@ export type Store<T = any> = {
|
|
|
63
64
|
|
|
64
65
|
--- Read-only snapshot of `key` via `GetAsync`: the current data and lock.
|
|
65
66
|
--- Yields. The result is not tracked by the store, takes no lock and cannot
|
|
66
|
-
--- join a transaction. Fails with `store_closed`, `roblox`, `outdated
|
|
67
|
-
--- `migration_mismatch`.
|
|
67
|
+
--- join a transaction. Fails with `store_closed`, `roblox`, `outdated`,
|
|
68
|
+
--- `predicate_failed` or `migration_mismatch`.
|
|
68
69
|
peek: (self: Store<T>, key: string) -> Result<PeekProfile<T>>,
|
|
69
70
|
|
|
70
71
|
--- Atomically updates profiles of this store (locked or lockless). `transform`
|
|
@@ -79,6 +80,8 @@ export type Store<T = any> = {
|
|
|
79
80
|
config: types.TransactionConfig?
|
|
80
81
|
) -> Result<boolean>,
|
|
81
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).
|
|
82
85
|
on_change: (self: Store<T>, callback: (profile: Profile<T>, new: T, old: T?) -> ()) -> (),
|
|
83
86
|
on_save: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
84
87
|
on_closing: (self: Store<T>, callback: (profile: Profile<T>) -> ()) -> (),
|
|
@@ -113,6 +116,9 @@ local function insert_loaded_lock(self: Store, key: string)
|
|
|
113
116
|
table.insert(waiters, thread)
|
|
114
117
|
end
|
|
115
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.
|
|
116
122
|
local function unregister_profile(self: Store, profile: Profile)
|
|
117
123
|
local key = profile.key
|
|
118
124
|
if self.profiles[key] == profile then
|
|
@@ -158,6 +164,10 @@ function store.create<T>(config: Config<T>): Store<T>
|
|
|
158
164
|
assert(type(config) == "table", "datastore: config must be a table")
|
|
159
165
|
assert(type(config.name) == "string" and #config.name > 0, "datastore: config.name is required")
|
|
160
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
|
+
)
|
|
161
171
|
|
|
162
172
|
local lock_ttl = config.lock_ttl or 60
|
|
163
173
|
local hook = config._hook
|
|
@@ -181,6 +191,7 @@ function store.create<T>(config: Config<T>): Store<T>
|
|
|
181
191
|
_scheduler = scheduler,
|
|
182
192
|
|
|
183
193
|
migrations = config.migrations or {},
|
|
194
|
+
predicate = config.predicate,
|
|
184
195
|
|
|
185
196
|
autosave_interval = config.autosave_interval or 30,
|
|
186
197
|
flush_interval = config.flush_interval or config.autosave_interval or 30,
|
|
@@ -219,7 +230,10 @@ function store.load(self: Store, key: string, user_ids: { number }): Result<Prof
|
|
|
219
230
|
if self.closed then
|
|
220
231
|
return result.err(errors.store_closed(self))
|
|
221
232
|
end
|
|
222
|
-
|
|
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
|
|
223
237
|
return result.err(errors.already_loaded(self, key))
|
|
224
238
|
end
|
|
225
239
|
self._loading[key] = true
|
|
@@ -252,12 +266,24 @@ function store.load(self: Store, key: string, user_ids: { number }): Result<Prof
|
|
|
252
266
|
return failure
|
|
253
267
|
end
|
|
254
268
|
|
|
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
|
|
255
275
|
loaded:on_closed(function()
|
|
256
276
|
unregister_profile(self, loaded)
|
|
257
277
|
end)
|
|
258
278
|
forward_hooks(self, loaded)
|
|
259
279
|
|
|
260
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
|
+
|
|
261
287
|
local success = result.ok(loaded)
|
|
262
288
|
resume_load_locks(self, key, success)
|
|
263
289
|
return success
|
|
@@ -424,10 +450,21 @@ function store.close(self: Store)
|
|
|
424
450
|
end)
|
|
425
451
|
end
|
|
426
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> = {}
|
|
427
456
|
for _, profile in self.profiles do
|
|
428
|
-
|
|
457
|
+
table.insert(locked, profile)
|
|
429
458
|
end
|
|
459
|
+
local lockless: Array<LocklessProfile> = {}
|
|
430
460
|
for _, profile in self.lockless_profiles do
|
|
461
|
+
table.insert(lockless, profile)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
for _, profile in locked do
|
|
465
|
+
unload(profile)
|
|
466
|
+
end
|
|
467
|
+
for _, profile in lockless do
|
|
431
468
|
unload(profile)
|
|
432
469
|
end
|
|
433
470
|
for key in self._loading do
|
package/src/types.luau
CHANGED
|
@@ -5,6 +5,17 @@ export type Counter = () -> number
|
|
|
5
5
|
--- Returns the new data, or nil / false to leave it untouched.
|
|
6
6
|
export type DataDispatcher<T> = (T) -> (T | false)?
|
|
7
7
|
|
|
8
|
+
--- Decides whether this server may work with a key at all. `data` is the raw
|
|
9
|
+
--- stored data as it is in storage: nil when the key does not exist yet, and
|
|
10
|
+
--- with the declared migrations *not* applied. Returning false rejects the
|
|
11
|
+
--- operation with a `predicate_failed` error: no lock is taken and nothing is
|
|
12
|
+
--- written.
|
|
13
|
+
---
|
|
14
|
+
--- It runs inside the `update_async` transform, so it must be pure: no
|
|
15
|
+
--- yielding, no storage calls, and no mutation of `data` (it may be retried,
|
|
16
|
+
--- and the data it gets is the record this server is about to write back).
|
|
17
|
+
export type Predicate<T = any> = (data: T?, key: string) -> boolean
|
|
18
|
+
|
|
8
19
|
--- A storage handle for one named store. Deliberately not shaped like a
|
|
9
20
|
--- Roblox DataStore. All methods throw on failure.
|
|
10
21
|
export type DatastoreHandle = {
|
|
@@ -45,6 +56,10 @@ export type Config<T = any> = {
|
|
|
45
56
|
|
|
46
57
|
migrations: Array<Migration>?,
|
|
47
58
|
|
|
59
|
+
--- Gate on a key's stored data: every load, save, fetch and peek is
|
|
60
|
+
--- rejected with `predicate_failed` when it returns false. See `Predicate`.
|
|
61
|
+
predicate: Predicate<T>?,
|
|
62
|
+
|
|
48
63
|
--- seconds, default 30 (locked profiles)
|
|
49
64
|
autosave_interval: number?,
|
|
50
65
|
-- seconds, default autosave_interval (lockless profiles)
|
|
@@ -77,6 +92,8 @@ export type ResolvedConfig<T = any> = RetryConfig & {
|
|
|
77
92
|
_scheduler: Scheduler,
|
|
78
93
|
|
|
79
94
|
migrations: Array<Migration>,
|
|
95
|
+
--- nil when the store declared none (nothing is gated).
|
|
96
|
+
predicate: Predicate<T>?,
|
|
80
97
|
|
|
81
98
|
autosave_interval: number,
|
|
82
99
|
flush_interval: number,
|
|
@@ -115,6 +132,10 @@ export type ResolvedTransactionConfig = RetryConfig & {
|
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
export type Lock = {
|
|
135
|
+
--- Identifies the *acquisition* that holds the lock, not the server:
|
|
136
|
+
--- `{job_id}#{n}` for a profile's session lock, `{job_id}-{n}` for a
|
|
137
|
+
--- transaction's. Two sessions of one key on the same server hold
|
|
138
|
+
--- different ids, so they contend like sessions on different servers do.
|
|
118
139
|
id: string,
|
|
119
140
|
expires: number,
|
|
120
141
|
--- Short-lived lock a transaction takes on a lockless key (see `lock_ttl`
|
|
@@ -162,7 +183,8 @@ export type TimeoutError = {
|
|
|
162
183
|
type: "timeout",
|
|
163
184
|
message: string,
|
|
164
185
|
key: string,
|
|
165
|
-
--- the last lock seen on the record while waiting
|
|
186
|
+
--- the last lock seen on the record while waiting; its `id` names the
|
|
187
|
+
--- acquisition holding it, which may be another session on this server
|
|
166
188
|
lock: Lock?,
|
|
167
189
|
}
|
|
168
190
|
|
|
@@ -210,6 +232,20 @@ export type OutdatedError = {
|
|
|
210
232
|
declared_migrations: Array<string>,
|
|
211
233
|
}
|
|
212
234
|
|
|
235
|
+
--- The store's `predicate` returned false for a key's stored data: this server
|
|
236
|
+
--- must not work with that key. Nothing is written and no lock is taken.
|
|
237
|
+
export type PredicateFailedError = {
|
|
238
|
+
type: "predicate_failed",
|
|
239
|
+
message: string,
|
|
240
|
+
key: string,
|
|
241
|
+
--- the operation the predicate rejected
|
|
242
|
+
op: "load" | "save" | "fetch" | "peek",
|
|
243
|
+
--- the raw stored data the predicate rejected (nil when the key is empty)
|
|
244
|
+
data: any,
|
|
245
|
+
--- the profile the call ran on; nil for `store:load` and `store:peek`
|
|
246
|
+
profile: any?,
|
|
247
|
+
}
|
|
248
|
+
|
|
213
249
|
export type NotFetchedError = {
|
|
214
250
|
type: "not_fetched",
|
|
215
251
|
message: string,
|
|
@@ -275,6 +311,7 @@ export type Error =
|
|
|
275
311
|
| LocklessLockedError
|
|
276
312
|
| TxLockLostError
|
|
277
313
|
| OutdatedError
|
|
314
|
+
| PredicateFailedError
|
|
278
315
|
| NotFetchedError
|
|
279
316
|
| ProfileClosedError
|
|
280
317
|
| MigrationMismatchError
|
|
@@ -399,7 +436,7 @@ export type ProfileBase<T = any> = {
|
|
|
399
436
|
unload: (self: ProfileBase<T>) -> (),
|
|
400
437
|
--- Ok(true) when the data changed, Ok(false) when the dispatcher returned
|
|
401
438
|
--- nil / false. An error thrown by the dispatcher propagates as is.
|
|
402
|
-
update: (self: ProfileBase<T>,
|
|
439
|
+
update: (self: ProfileBase<T>, dispatcher: DataDispatcher<T>) -> Result<boolean>,
|
|
403
440
|
|
|
404
441
|
on_change: (self: ProfileBase<T>, callback: (new: T, old: T?) -> ()) -> (),
|
|
405
442
|
on_save: (self: ProfileBase<T>, callback: () -> ()) -> (),
|
|
@@ -424,6 +461,9 @@ export type ProfileBase<T = any> = {
|
|
|
424
461
|
export type Profile<T = any> = ProfileBase<T> & {
|
|
425
462
|
kind: "locked",
|
|
426
463
|
last_write: number,
|
|
464
|
+
--- The id of this acquisition's lock; re-stamped unchanged on every save,
|
|
465
|
+
--- replaced by `readquire`. See `Lock.id`.
|
|
466
|
+
lock_id: string,
|
|
427
467
|
--- Whether the profile currently holds its session lock. True after `load`;
|
|
428
468
|
--- `release` clears it, `readquire` takes it again. While released,
|
|
429
469
|
--- `update` / `save` throw and the profile cannot join a transaction.
|
|
@@ -450,6 +490,13 @@ export type Profile<T = any> = ProfileBase<T> & {
|
|
|
450
490
|
-- internal, used by transactions. Returns false (and the lock now on the
|
|
451
491
|
-- record) when the lock is gone.
|
|
452
492
|
_write_record: (self: Profile<T>, options: ProfileWriteOptions) -> (boolean, Lock?),
|
|
493
|
+
|
|
494
|
+
--- internal, set by the store: called once, as soon as the profile stops
|
|
495
|
+
--- being usable (an unload before its final write, or a lost lock), so the
|
|
496
|
+
--- key can be loaded again without waiting for that write.
|
|
497
|
+
_on_detach: (() -> ())?,
|
|
498
|
+
--- internal: whether `_on_detach` has run.
|
|
499
|
+
_detached: boolean,
|
|
453
500
|
}
|
|
454
501
|
|
|
455
502
|
export type ProfileWriteOptions = {
|
|
@@ -497,6 +544,7 @@ export type PeekProfile<T = any> = {
|
|
|
497
544
|
_datastore: DatastoreHandle,
|
|
498
545
|
|
|
499
546
|
--- Lock currently on the record (session or transaction), nil when free.
|
|
547
|
+
--- Its `id` names an acquisition, not a server (see `Lock.id`).
|
|
500
548
|
lock: Lock?,
|
|
501
549
|
--- Transaction marker on the record, if any. Left as stored, never resolved.
|
|
502
550
|
pending: Pending<T>?,
|