@evolu/common 8.0.0-next.4 → 8.0.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/dist/src/Array.d.ts +279 -173
- package/dist/src/Array.d.ts.map +1 -1
- package/dist/src/Array.js +105 -101
- package/dist/src/Assert.d.ts +44 -44
- package/dist/src/Assert.d.ts.map +1 -1
- package/dist/src/Assert.js +44 -32
- package/dist/src/BigInt.d.ts +6 -3
- package/dist/src/BigInt.d.ts.map +1 -1
- package/dist/src/BigInt.js +5 -2
- package/dist/src/Brand.d.ts +27 -40
- package/dist/src/Brand.d.ts.map +1 -1
- package/dist/src/Buffer.d.ts +27 -11
- package/dist/src/Buffer.d.ts.map +1 -1
- package/dist/src/Cache.d.ts +9 -4
- package/dist/src/Cache.d.ts.map +1 -1
- package/dist/src/Cache.js +8 -3
- package/dist/src/Callbacks.d.ts +30 -19
- package/dist/src/Callbacks.d.ts.map +1 -1
- package/dist/src/Console.d.ts +145 -66
- package/dist/src/Console.d.ts.map +1 -1
- package/dist/src/Console.js +91 -43
- package/dist/src/Crypto.d.ts +85 -42
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +44 -20
- package/dist/src/Eq.d.ts +87 -21
- package/dist/src/Eq.d.ts.map +1 -1
- package/dist/src/Eq.js +67 -19
- package/dist/src/Error.d.ts +5 -3
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +16 -0
- package/dist/src/Function.d.ts +119 -58
- package/dist/src/Function.d.ts.map +1 -1
- package/dist/src/Function.js +86 -43
- package/dist/src/Http.d.ts +394 -0
- package/dist/src/Http.d.ts.map +1 -0
- package/dist/src/Http.js +102 -0
- package/dist/src/Identicon.d.ts +16 -7
- package/dist/src/Identicon.d.ts.map +1 -1
- package/dist/src/Identicon.js +14 -5
- package/dist/src/LeakDetector.d.ts +88 -0
- package/dist/src/LeakDetector.d.ts.map +1 -0
- package/dist/src/LeakDetector.js +86 -0
- package/dist/src/LockManager.d.ts +18 -9
- package/dist/src/LockManager.d.ts.map +1 -1
- package/dist/src/LockManager.js +7 -8
- package/dist/src/Lookup.d.ts +43 -13
- package/dist/src/Lookup.d.ts.map +1 -1
- package/dist/src/Lookup.js +28 -8
- package/dist/src/Microtask.d.ts +1 -1
- package/dist/src/Microtask.d.ts.map +1 -1
- package/dist/src/Microtask.js +3 -7
- package/dist/src/Number.d.ts +49 -13
- package/dist/src/Number.d.ts.map +1 -1
- package/dist/src/Number.js +34 -5
- package/dist/src/Object.d.ts +160 -40
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +92 -46
- package/dist/src/Option.d.ts +10 -7
- package/dist/src/Option.d.ts.map +1 -1
- package/dist/src/Option.js +9 -6
- package/dist/src/Order.d.ts +21 -21
- package/dist/src/Order.d.ts.map +1 -1
- package/dist/src/Order.js +18 -21
- package/dist/src/Platform.d.ts +31 -0
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +98 -0
- package/dist/src/Random.d.ts +32 -17
- package/dist/src/Random.d.ts.map +1 -1
- package/dist/src/Random.js +12 -8
- package/dist/src/Redacted.d.ts +43 -29
- package/dist/src/Redacted.d.ts.map +1 -1
- package/dist/src/Redacted.js +14 -3
- package/dist/src/Ref.d.ts +11 -3
- package/dist/src/Ref.d.ts.map +1 -1
- package/dist/src/Ref.js +2 -2
- package/dist/src/RefCount.d.ts +2 -6
- package/dist/src/RefCount.d.ts.map +1 -1
- package/dist/src/RefCount.js +33 -90
- package/dist/src/Relation.d.ts +70 -5
- package/dist/src/Relation.d.ts.map +1 -1
- package/dist/src/Relation.js +77 -2
- package/dist/src/Resource.d.ts +590 -155
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +592 -343
- package/dist/src/Result.d.ts +838 -191
- package/dist/src/Result.d.ts.map +1 -1
- package/dist/src/Result.js +163 -102
- package/dist/src/Schedule.d.ts +684 -231
- package/dist/src/Schedule.d.ts.map +1 -1
- package/dist/src/Schedule.js +766 -295
- package/dist/src/Set.d.ts +89 -40
- package/dist/src/Set.d.ts.map +1 -1
- package/dist/src/Set.js +50 -29
- package/dist/src/Sqlite.d.ts +29 -72
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +41 -46
- package/dist/src/StackTrace.d.ts +40 -0
- package/dist/src/StackTrace.d.ts.map +1 -0
- package/dist/src/StackTrace.js +140 -0
- package/dist/src/Store.d.ts +5 -4
- package/dist/src/Store.d.ts.map +1 -1
- package/dist/src/Store.js +5 -4
- package/dist/src/String.d.ts +7 -1
- package/dist/src/String.d.ts.map +1 -1
- package/dist/src/String.js +20 -7
- package/dist/src/Task.d.ts +3572 -1770
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +1879 -1274
- package/dist/src/Test.d.ts +17 -125
- package/dist/src/Test.d.ts.map +1 -1
- package/dist/src/Test.js +17 -68
- package/dist/src/Time.d.ts +108 -49
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +172 -73
- package/dist/src/Type.d.ts +3542 -1926
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +3070 -1784
- package/dist/src/Types.d.ts +200 -108
- package/dist/src/Types.d.ts.map +1 -1
- package/dist/src/Types.js +39 -8
- package/dist/src/WebSocket.d.ts +48 -23
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +15 -6
- package/dist/src/Worker.d.ts +94 -36
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +58 -24
- package/dist/src/index.d.ts +55 -52
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/intl/cs.d.ts +94 -0
- package/dist/src/intl/cs.d.ts.map +1 -0
- package/dist/src/intl/cs.js +220 -0
- package/dist/src/intl/index.d.ts +7 -0
- package/dist/src/intl/index.d.ts.map +1 -0
- package/dist/src/intl/index.js +6 -0
- package/dist/src/local-first/Db.d.ts +12 -8
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +109 -132
- package/dist/src/local-first/Error.d.ts +4 -4
- package/dist/src/local-first/Evolu.d.ts +338 -92
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +47 -55
- package/dist/src/local-first/LocalAuth.d.ts +4 -4
- package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
- package/dist/src/local-first/Owner.d.ts +61 -24
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +45 -8
- package/dist/src/local-first/Protocol.d.ts +44 -31
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +36 -13
- package/dist/src/local-first/Query.d.ts +147 -62
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Query.js +112 -58
- package/dist/src/local-first/Relay.d.ts +44 -14
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Schema.d.ts +92 -41
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +25 -8
- package/dist/src/local-first/Shared.d.ts +23 -21
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +100 -57
- package/dist/src/local-first/Storage.d.ts +53 -32
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +194 -234
- package/dist/src/local-first/Timestamp.d.ts +21 -21
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +2 -2
- package/dist/src/local-first/index.d.ts +11 -11
- package/package.json +14 -13
- package/src/Array.ts +306 -205
- package/src/Assert.ts +54 -69
- package/src/BigInt.ts +6 -3
- package/src/Brand.ts +27 -40
- package/src/Buffer.ts +28 -12
- package/src/Cache.ts +9 -4
- package/src/Callbacks.ts +30 -19
- package/src/Console.ts +156 -84
- package/src/Crypto.ts +83 -41
- package/src/Eq.ts +95 -22
- package/src/Error.ts +8 -4
- package/src/Function.ts +167 -59
- package/src/Http.ts +549 -0
- package/src/Identicon.ts +16 -7
- package/src/LeakDetector.ts +182 -0
- package/src/LockManager.ts +43 -39
- package/src/Lookup.ts +62 -28
- package/src/Microtask.ts +19 -23
- package/src/Number.ts +64 -12
- package/src/Object.ts +173 -49
- package/src/Option.ts +10 -7
- package/src/Order.ts +23 -21
- package/src/Platform.ts +136 -0
- package/src/Polyfills.ts +6 -12
- package/src/Random.ts +32 -19
- package/src/Redacted.ts +44 -30
- package/src/Ref.ts +14 -6
- package/src/RefCount.ts +38 -80
- package/src/Relation.ts +202 -10
- package/src/Resource.ts +1199 -591
- package/src/Result.ts +930 -271
- package/src/Schedule.ts +859 -343
- package/src/Set.ts +89 -40
- package/src/Sqlite.ts +144 -138
- package/src/StackTrace.ts +230 -0
- package/src/Store.ts +8 -7
- package/src/String.ts +22 -5
- package/src/Task.ts +5101 -2832
- package/src/Test.ts +20 -168
- package/src/Time.ts +293 -90
- package/src/Type.ts +10034 -4117
- package/src/Types.ts +227 -110
- package/src/WebSocket.ts +64 -36
- package/src/Worker.ts +180 -72
- package/src/index.ts +55 -52
- package/src/intl/cs.ts +388 -0
- package/src/intl/index.ts +7 -0
- package/src/local-first/Db.ts +152 -179
- package/src/local-first/Error.ts +4 -4
- package/src/local-first/Evolu.ts +495 -250
- package/src/local-first/LocalAuth.ts +6 -12
- package/src/local-first/Owner.ts +58 -21
- package/src/local-first/Protocol.ts +49 -31
- package/src/local-first/Query.ts +146 -61
- package/src/local-first/Relay.ts +51 -21
- package/src/local-first/Schema.ts +109 -51
- package/src/local-first/Shared.ts +266 -226
- package/src/local-first/Storage.ts +266 -264
- package/src/local-first/Timestamp.ts +25 -18
- package/src/local-first/index.ts +11 -11
- package/dist/src/Skiplist.d.ts +0 -28
- package/dist/src/Skiplist.d.ts.map +0 -1
- package/dist/src/Skiplist.js +0 -64
- package/dist/src/Tracer.d.ts +0 -48
- package/dist/src/Tracer.d.ts.map +0 -1
- package/dist/src/Tracer.js +0 -6
- package/src/Skiplist.ts +0 -108
- package/src/Tracer.ts +0 -54
package/src/Resource.ts
CHANGED
|
@@ -4,42 +4,29 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import type { NonEmptyArray, NonEmptyReadonlyArray } from "./Array.ts";
|
|
8
|
+
import { assert, assertNonNullable } from "./Assert.ts";
|
|
9
|
+
import { identity, type disposable } from "./Function.ts";
|
|
9
10
|
import {
|
|
10
11
|
createLookupMap,
|
|
11
12
|
createLookupSet,
|
|
12
13
|
type Lookup,
|
|
13
14
|
type LookupOption,
|
|
14
|
-
} from "./Lookup.
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
createRefCountByKey,
|
|
18
|
-
type RefCountByKey,
|
|
19
|
-
} from "./RefCount.js";
|
|
20
|
-
import { createRelation } from "./Relation.js";
|
|
21
|
-
import { ok } from "./Result.js";
|
|
15
|
+
} from "./Lookup.ts";
|
|
16
|
+
import { createRefCountedRelation } from "./Relation.ts";
|
|
17
|
+
import { ok } from "./Result.ts";
|
|
22
18
|
import {
|
|
23
19
|
createMutex,
|
|
24
20
|
createMutexByKey,
|
|
25
21
|
sleep,
|
|
26
|
-
|
|
27
|
-
type
|
|
28
|
-
type Fiber,
|
|
29
|
-
type MutexRef,
|
|
22
|
+
type AbortableFiber,
|
|
23
|
+
type DisposableRun,
|
|
30
24
|
type SemaphoreSnapshot,
|
|
31
25
|
type Task,
|
|
32
|
-
} from "./Task.
|
|
33
|
-
import { type
|
|
34
|
-
import { NonNegativeInt
|
|
35
|
-
|
|
36
|
-
export {
|
|
37
|
-
createRefCount,
|
|
38
|
-
createRefCountByKey,
|
|
39
|
-
type CreateRefCountByKeyOptions,
|
|
40
|
-
type RefCount,
|
|
41
|
-
type RefCountByKey,
|
|
42
|
-
} from "./RefCount.js";
|
|
26
|
+
} from "./Task.ts";
|
|
27
|
+
import { type PositiveDuration } from "./Time.ts";
|
|
28
|
+
import { NonNegativeInt } from "./Type.ts";
|
|
29
|
+
import { type DistributiveOmit } from "./Types.ts";
|
|
43
30
|
|
|
44
31
|
/**
|
|
45
32
|
* Disposable resource.
|
|
@@ -47,159 +34,215 @@ export {
|
|
|
47
34
|
* A resource is any object that implements {@link Disposable} or
|
|
48
35
|
* {@link AsyncDisposable}.
|
|
49
36
|
*
|
|
37
|
+
* Successfully returning an owned Resource transfers ownership of a live
|
|
38
|
+
* resource to the caller. Return a typed Result error for a recoverable
|
|
39
|
+
* creation failure; use `undefined` only when absence is a valid success. If
|
|
40
|
+
* creation fails, aborts, or defects, use {@link AsyncDisposableStack} to
|
|
41
|
+
* dispose partially created resources and return no Resource. Never represent
|
|
42
|
+
* failed creation with an already-disposed Resource.
|
|
43
|
+
*
|
|
50
44
|
* Disposal must succeed. A disposer that throws indicates an unrecoverable
|
|
51
45
|
* invariant violation, not a recoverable domain error, so resource lifecycle
|
|
52
|
-
* APIs let that error propagate.
|
|
53
|
-
*
|
|
54
|
-
* resource helpers is to guarantee cleanup and prevent leaks.
|
|
46
|
+
* APIs let that error propagate as a defect. The purpose of resource helpers is
|
|
47
|
+
* to guarantee cleanup and prevent leaks.
|
|
55
48
|
*
|
|
56
|
-
* @see {@link ResourceRef}
|
|
57
|
-
* @see {@link createResourceRef}
|
|
58
49
|
* @see {@link SharedResource}
|
|
59
50
|
* @see {@link createSharedResource}
|
|
60
|
-
* @see {@link SharedResourceByKey}
|
|
61
|
-
* @see {@link createSharedResourceByKey}
|
|
62
51
|
*/
|
|
63
52
|
export type Resource = Disposable | AsyncDisposable;
|
|
64
53
|
|
|
65
54
|
/**
|
|
66
55
|
* Borrowed {@link Resource}.
|
|
67
56
|
*
|
|
68
|
-
* A borrowed resource
|
|
57
|
+
* A borrowed resource removes disposal methods from a {@link Resource}'s type.
|
|
58
|
+
* The runtime object is unchanged.
|
|
69
59
|
*
|
|
70
60
|
* Another abstraction owns the resource and controls its lifecycle. Exposing
|
|
71
|
-
* disposal would break that ownership and allow callers to dispose
|
|
72
|
-
* they do not own.
|
|
61
|
+
* disposal in the type would break that ownership and allow callers to dispose
|
|
62
|
+
* a resource they do not own.
|
|
73
63
|
*/
|
|
74
|
-
export type BorrowedResource<T extends Resource> =
|
|
64
|
+
export type BorrowedResource<T extends Resource> = DistributiveOmit<
|
|
75
65
|
T,
|
|
76
66
|
typeof Symbol.dispose | typeof Symbol.asyncDispose
|
|
77
67
|
>;
|
|
78
68
|
|
|
79
69
|
/**
|
|
80
|
-
* {@link Resource}
|
|
70
|
+
* An owned lease on a shared {@link Resource}.
|
|
81
71
|
*
|
|
82
|
-
* A
|
|
83
|
-
*
|
|
72
|
+
* A lease keeps the underlying resource alive. Release it with
|
|
73
|
+
* {@link Lease.release} or `using`; releasing the last lease starts the resource
|
|
74
|
+
* disposal path. Release is idempotent, so double release is a safe no-op.
|
|
84
75
|
*
|
|
85
|
-
*
|
|
86
|
-
* the
|
|
76
|
+
* Release is synchronous accounting and never waits for resource disposal.
|
|
77
|
+
* Disposal completion belongs to the owning {@link SharedResource}: it may be
|
|
78
|
+
* delayed by {@link SharedResourceOptions.idleDisposeAfter | idleDisposeAfter}
|
|
79
|
+
* and is awaited by the owner's async disposal.
|
|
87
80
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
81
|
+
* Release does not revoke {@link Lease.resource | resource}; a released lease
|
|
82
|
+
* keeps a plain reference, and using it after the resource is disposed is a
|
|
83
|
+
* programmer error that nothing catches statically. Create resources with the
|
|
84
|
+
* {@link disposable} helper so use-after-dispose throws eagerly instead of
|
|
85
|
+
* operating on disposed state.
|
|
92
86
|
*/
|
|
93
|
-
export interface
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
readonly
|
|
87
|
+
export interface Lease<T extends Resource> extends Disposable {
|
|
88
|
+
/** The leased resource. */
|
|
89
|
+
readonly resource: BorrowedResource<T>;
|
|
90
|
+
|
|
91
|
+
/** Whether this acquisition created the leased resource generation. */
|
|
92
|
+
readonly created: boolean;
|
|
99
93
|
|
|
100
94
|
/**
|
|
101
|
-
*
|
|
95
|
+
* Releases this lease.
|
|
102
96
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
97
|
+
* Returns whether this call released a still-held lease. Returns `false` if
|
|
98
|
+
* the lease was already released, drained by disposal of its owning
|
|
99
|
+
* {@link SharedResource}, or drained by disposal of the Run tree that owns
|
|
100
|
+
* it.
|
|
105
101
|
*/
|
|
106
|
-
readonly
|
|
102
|
+
readonly release: () => boolean;
|
|
107
103
|
}
|
|
108
104
|
|
|
109
|
-
/** Creates {@link ResourceRef}. */
|
|
110
|
-
export const createResourceRef = <T extends Resource, D>(
|
|
111
|
-
create: Task<T, never, D>,
|
|
112
|
-
): Task<ResourceRef<T, D>, never, D> =>
|
|
113
|
-
unabortable<ResourceRef<T, D>, never, D>(async (run) => {
|
|
114
|
-
const resourceRefRun = run.create();
|
|
115
|
-
|
|
116
|
-
await using disposer = new AsyncDisposableStack();
|
|
117
|
-
disposer.use(resourceRefRun);
|
|
118
|
-
|
|
119
|
-
const initial = await resourceRefRun(create);
|
|
120
|
-
if (!initial.ok) return initial;
|
|
121
|
-
|
|
122
|
-
let current = createOwnedResource(initial.value);
|
|
123
|
-
|
|
124
|
-
const mutex = disposer.use(createMutex());
|
|
125
|
-
disposer.defer(() => current.stack.disposeAsync());
|
|
126
|
-
// Register as the last so disposal aborts further calls first.
|
|
127
|
-
// Repeated registration is safe because disposal is idempotent.
|
|
128
|
-
disposer.use(resourceRefRun);
|
|
129
|
-
|
|
130
|
-
const disposables = disposer.move();
|
|
131
|
-
|
|
132
|
-
return ok({
|
|
133
|
-
get: () => resourceRefRun(mutex.withLock(() => ok(current.resource))),
|
|
134
|
-
|
|
135
|
-
set: (create: Task<T, never, D>): Task<void, never, D> =>
|
|
136
|
-
unabortable(() =>
|
|
137
|
-
resourceRefRun(
|
|
138
|
-
mutex.withLock(async (run) => {
|
|
139
|
-
await current.stack.disposeAsync();
|
|
140
|
-
const next = await run(create);
|
|
141
|
-
if (!next.ok) return next;
|
|
142
|
-
current = createOwnedResource(next.value);
|
|
143
|
-
return ok();
|
|
144
|
-
}),
|
|
145
|
-
),
|
|
146
|
-
),
|
|
147
|
-
|
|
148
|
-
[Symbol.asyncDispose]: () => disposables.disposeAsync(),
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
105
|
/**
|
|
153
106
|
* Shared {@link Resource}.
|
|
154
107
|
*
|
|
155
|
-
* Lazily
|
|
156
|
-
* {@link SharedResource.acquire | acquire} call, shares it across callers
|
|
157
|
-
* disposes it when the last
|
|
158
|
-
* it.
|
|
108
|
+
* Lazily creates the underlying resource on the first
|
|
109
|
+
* {@link SharedResource.acquire | acquire} call, shares it across callers via
|
|
110
|
+
* {@link Lease}s, and disposes it when the last lease is released.
|
|
159
111
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
112
|
+
* ### Example
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* import {
|
|
116
|
+
* all,
|
|
117
|
+
* createRun,
|
|
118
|
+
* createSharedResource,
|
|
119
|
+
* ok,
|
|
120
|
+
* type Task,
|
|
121
|
+
* } from "@evolu/common";
|
|
122
|
+
*
|
|
123
|
+
* interface Connection extends Disposable {
|
|
124
|
+
* readonly send: (message: string) => void;
|
|
125
|
+
* }
|
|
126
|
+
*
|
|
127
|
+
* let createdCount = 0;
|
|
128
|
+
* const createConnection: Task<Connection> = () => {
|
|
129
|
+
* createdCount += 1;
|
|
130
|
+
* return ok({
|
|
131
|
+
* send: (_message) => {},
|
|
132
|
+
* [Symbol.dispose]: () => {},
|
|
133
|
+
* });
|
|
134
|
+
* };
|
|
135
|
+
*
|
|
136
|
+
* await using run = createRun();
|
|
137
|
+
* await using sharedConnection = await run.ok(
|
|
138
|
+
* createSharedResource(createConnection, { idleDisposeAfter: "5s" }),
|
|
139
|
+
* );
|
|
140
|
+
*
|
|
141
|
+
* // Creating the owner is lazy; no connection is open yet.
|
|
142
|
+
* expect(createdCount).toBe(0);
|
|
143
|
+
*
|
|
144
|
+
* // `use` owns and releases a lease around each operation.
|
|
145
|
+
* const send = (message: string): Task<void> =>
|
|
146
|
+
* sharedConnection.use((connection) => () => {
|
|
147
|
+
* connection.send(message);
|
|
148
|
+
* return ok();
|
|
149
|
+
* });
|
|
150
|
+
*
|
|
151
|
+
* {
|
|
152
|
+
* // An explicit lease can span several operations. Concurrent `use` calls
|
|
153
|
+
* // share the connection kept alive by this lease.
|
|
154
|
+
* using batchLease = await run.ok(sharedConnection.acquire);
|
|
155
|
+
* batchLease.resource.send("first");
|
|
156
|
+
* batchLease.resource.send("second");
|
|
157
|
+
* await run.ok(all([send("hello"), send("world")], { concurrency: 2 }));
|
|
158
|
+
* }
|
|
159
|
+
*
|
|
160
|
+
* // Reacquiring during the idle delay reuses the same generation.
|
|
161
|
+
* using reusedLease = await run.ok(sharedConnection.acquire);
|
|
162
|
+
* expect(reusedLease.created).toBe(false);
|
|
163
|
+
* expect(createdCount).toBe(1);
|
|
164
|
+
* ```
|
|
165
|
+
*
|
|
166
|
+
* ## FAQ
|
|
167
|
+
*
|
|
168
|
+
* ### Why release a lease with `using`?
|
|
169
|
+
*
|
|
170
|
+
* `using` guarantees a {@link Lease} is released on every exit path: normal
|
|
171
|
+
* completion, a thrown error, and abort — abort surfaces as an exception in
|
|
172
|
+
* Task code, so stack unwinding runs disposers. Binding a lease to an ordinary
|
|
173
|
+
* `const` instead is a deliberate ownership transfer; the new owner must
|
|
174
|
+
* guarantee release.
|
|
175
|
+
*
|
|
176
|
+
* ### What happens when a lease leaks?
|
|
177
|
+
*
|
|
178
|
+
* Nothing in JavaScript enforces `using` or release; a lease that is never
|
|
179
|
+
* released compiles silently (see [MDN resource management
|
|
180
|
+
* pitfalls](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management#pitfalls);
|
|
181
|
+
* a lint rule may eventually close this gap:
|
|
182
|
+
* https://github.com/typescript-eslint/typescript-eslint/issues/8255). Encoding
|
|
183
|
+
* resource lifetime in the type system would not be bulletproof either: types
|
|
184
|
+
* can force a lifetime to exist, but a lifetime scoped too widely leaks just as
|
|
185
|
+
* silently. Evolu therefore stays JS-native — `using` in the language — and
|
|
186
|
+
* bounds the damage structurally. A leaked lease keeps the resource alive but
|
|
187
|
+
* never past its owner: disposing a SharedResource drains all outstanding
|
|
188
|
+
* leases and disposes the resource. A leaked SharedResource is bounded by its
|
|
189
|
+
* Run, whose disposal also drains its leases and disposes its current resource.
|
|
190
|
+
* Leaks are also observable — {@link SharedResource.snapshot | snapshot} exposes
|
|
191
|
+
* a lease count that never returns to zero — and detected: in development
|
|
192
|
+
* builds, a lease that is garbage-collected without release logs a warning with
|
|
193
|
+
* its acquire stack via the LeakDetector dependency.
|
|
163
194
|
*/
|
|
164
|
-
export interface SharedResource<
|
|
165
|
-
T extends Resource,
|
|
166
|
-
D = unknown,
|
|
167
|
-
> extends AsyncDisposable {
|
|
195
|
+
export interface SharedResource<T extends Resource> extends AsyncDisposable {
|
|
168
196
|
/**
|
|
169
|
-
* Acquires a shared
|
|
197
|
+
* Acquires a {@link Lease} on the shared resource.
|
|
170
198
|
*
|
|
171
199
|
* The first call lazily creates the resource. Later calls reuse the same
|
|
172
|
-
* resource until the
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
200
|
+
* resource until the last lease is released.
|
|
201
|
+
*
|
|
202
|
+
* Once started, acquire runs to completion even when the caller aborts its
|
|
203
|
+
* Fiber, and the returned lease still counts as held. If resource creation or
|
|
204
|
+
* disposal is in progress, acquire waits for it to complete. Always await the
|
|
205
|
+
* result and release the lease; a caller that stops waiting (for example with
|
|
206
|
+
* the `daemon` helper) abandons a lease that is never released, retaining the
|
|
207
|
+
* resource until the owning {@link SharedResource} is disposed.
|
|
176
208
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
209
|
+
* Owner disposal is different from caller abort: if disposal starts before a
|
|
210
|
+
* lease is transferred, acquire aborts with `runDisposedAbortReason` from the
|
|
211
|
+
* SharedResource's internal Run. A resource returned by `create` after
|
|
212
|
+
* shutdown starts remains owned by this SharedResource and is disposed
|
|
213
|
+
* without a lease escaping. Transfer occurs when the internal acquisition
|
|
214
|
+
* completes, before the caller necessarily resumes. If owner disposal starts
|
|
215
|
+
* in that gap, the caller can receive a lease already drained by disposal.
|
|
216
|
+
* Its `release` returns `false`, and its resource must not be used.
|
|
181
217
|
*/
|
|
182
|
-
readonly acquire: Task<
|
|
218
|
+
readonly acquire: Task<Lease<T>>;
|
|
183
219
|
|
|
184
220
|
/**
|
|
185
|
-
*
|
|
221
|
+
* Acquires a {@link Lease} on the current resource without creating one.
|
|
186
222
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* disposal
|
|
191
|
-
* the current resource.
|
|
192
|
-
*
|
|
193
|
-
* Once started, release runs to completion even if the caller aborts its
|
|
194
|
-
* Fiber. Always await the result instead of assuming no cleanup happened.
|
|
223
|
+
* Waits for preceding creation or disposal to finish. Returns `undefined` if
|
|
224
|
+
* no current resource remains. Owner disposal while waiting aborts the
|
|
225
|
+
* acquisition before a lease is transferred. As with `acquire`, owner
|
|
226
|
+
* disposal after transfer can drain the lease before the caller resumes.
|
|
195
227
|
*/
|
|
196
|
-
readonly
|
|
197
|
-
|
|
198
|
-
/** Returns the current acquire count. */
|
|
199
|
-
readonly getCount: Task<NonNegativeInt, never, D>;
|
|
228
|
+
readonly acquireCurrent: Task<Lease<T> | undefined>;
|
|
200
229
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Acquires a {@link Lease}, runs a Task with the shared resource, and releases
|
|
232
|
+
* the lease after the Task settles.
|
|
233
|
+
*
|
|
234
|
+
* Creates the resource when absent. The callback receives whether this use
|
|
235
|
+
* created the resource generation. While the owner remains running, the lease
|
|
236
|
+
* prevents ordinary idle disposal. Disposing this SharedResource is forceful:
|
|
237
|
+
* it drains the lease and may dispose the resource before or while the
|
|
238
|
+
* callback Task runs.
|
|
239
|
+
*/
|
|
240
|
+
readonly use: <R, E, D>(
|
|
241
|
+
callback: (
|
|
242
|
+
resource: BorrowedResource<T>,
|
|
243
|
+
created: boolean,
|
|
244
|
+
) => Task<R, E, D>,
|
|
245
|
+
) => Task<R, E, D>;
|
|
203
246
|
|
|
204
247
|
/** Returns the current shared-resource state for monitoring/debugging. */
|
|
205
248
|
readonly snapshot: () => SharedResourceSnapshot;
|
|
@@ -208,61 +251,185 @@ export interface SharedResource<
|
|
|
208
251
|
/** Snapshot returned by {@link SharedResource.snapshot}. */
|
|
209
252
|
export interface SharedResourceSnapshot {
|
|
210
253
|
/**
|
|
211
|
-
* Whether the resource has no current value, no
|
|
212
|
-
*
|
|
254
|
+
* Whether the resource has no current value, no leases, no pending idle
|
|
255
|
+
* disposal, and no acquisition in progress.
|
|
213
256
|
*/
|
|
214
257
|
readonly isIdle: boolean;
|
|
215
258
|
|
|
216
|
-
/** Current
|
|
259
|
+
/** Current active lease count. */
|
|
260
|
+
readonly leaseCount: NonNegativeInt;
|
|
261
|
+
|
|
262
|
+
/** Whether a current resource exists. */
|
|
263
|
+
readonly hasResource: boolean;
|
|
264
|
+
|
|
265
|
+
/** Whether delayed idle disposal is scheduled and pending. */
|
|
266
|
+
readonly idleDisposePending: boolean;
|
|
267
|
+
|
|
268
|
+
/** Current internal mutex state for monitoring/debugging. */
|
|
217
269
|
readonly mutex: SemaphoreSnapshot;
|
|
218
270
|
}
|
|
219
271
|
|
|
220
272
|
/** Options for {@link createSharedResource}. */
|
|
221
273
|
export interface SharedResourceOptions {
|
|
222
274
|
/**
|
|
223
|
-
* Keeps the resource alive briefly after the last
|
|
275
|
+
* Keeps the resource alive briefly after the last lease is released.
|
|
224
276
|
*
|
|
225
277
|
* This avoids immediate disposal when the resource is expensive to create and
|
|
226
278
|
* likely to be acquired again soon. A new acquire during this delay cancels
|
|
227
279
|
* the pending disposal and reuses the current resource.
|
|
228
280
|
*/
|
|
229
|
-
readonly idleDisposeAfter?:
|
|
281
|
+
readonly idleDisposeAfter?: PositiveDuration | undefined;
|
|
230
282
|
|
|
231
|
-
/**
|
|
232
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Called after each current resource disposal is attempted and the current
|
|
285
|
+
* reference is cleared, including during {@link SharedResource} disposal.
|
|
286
|
+
* Native disposal still calls it if the resource disposer defects. Not called
|
|
287
|
+
* if no resource was created.
|
|
288
|
+
*/
|
|
289
|
+
readonly onDisposed?: (() => void) | undefined;
|
|
233
290
|
}
|
|
234
291
|
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Creates {@link SharedResource}.
|
|
294
|
+
*
|
|
295
|
+
* The `create` Task must not fail. Creation establishes shared resource state
|
|
296
|
+
* and may serve multiple concurrent acquirers, so a recoverable failure does
|
|
297
|
+
* not belong to one lease. Handle recoverable failures inside `create`: retry
|
|
298
|
+
* until creation succeeds (for example with a Schedule), or return a resource
|
|
299
|
+
* whose state models connection and reconnection failures.
|
|
300
|
+
*
|
|
301
|
+
* A successfully returned resource must be live and independently owned. The
|
|
302
|
+
* `create` Task must not bind its disposal to that Task's {@link DisposableRun}
|
|
303
|
+
* with `defer`.
|
|
304
|
+
*
|
|
305
|
+
* Create resources with {@link disposable} when their shape supports it. Its
|
|
306
|
+
* disposal guard makes calls through a lease fail eagerly after resource
|
|
307
|
+
* disposal instead of operating on disposed state.
|
|
308
|
+
*
|
|
309
|
+
* The `create` Task runs while acquisition is locked and must not directly or
|
|
310
|
+
* transitively acquire from the same SharedResource. The lock is non-reentrant,
|
|
311
|
+
* so doing so deadlocks.
|
|
312
|
+
*
|
|
313
|
+
* Internal work runs on a Run created from the Run that executes this Task, so
|
|
314
|
+
* dependencies are captured at creation time. Deps provided to later acquire
|
|
315
|
+
* calls do not change what `create` observes.
|
|
316
|
+
*
|
|
317
|
+
* Lifecycle callbacks must not throw. A throwing
|
|
318
|
+
* {@link SharedResourceOptions.onDisposed | onDisposed} is a defect that panics
|
|
319
|
+
* the Run tree.
|
|
320
|
+
*/
|
|
321
|
+
export const createSharedResource =
|
|
322
|
+
<T extends Resource, D>(
|
|
323
|
+
create: Task<T, never, D>,
|
|
324
|
+
{ idleDisposeAfter, onDisposed }: SharedResourceOptions = {},
|
|
325
|
+
): Task<SharedResource<T>, never, D> =>
|
|
326
|
+
(run) => {
|
|
241
327
|
const sharedResourceRun = run.create();
|
|
242
|
-
|
|
243
|
-
let idleDisposeFiber: Fiber<void, AbortError, D> | undefined;
|
|
328
|
+
const { leakDetector } = run.deps;
|
|
244
329
|
|
|
245
|
-
|
|
246
|
-
|
|
330
|
+
let current: T | undefined;
|
|
331
|
+
let idleDisposeFiber: AbortableFiber<void, never, D> | undefined;
|
|
332
|
+
const heldLeases = new Set<object>();
|
|
247
333
|
|
|
248
|
-
const mutex =
|
|
334
|
+
const mutex = createMutex();
|
|
335
|
+
const sharedResourceHandle = {};
|
|
249
336
|
|
|
250
|
-
|
|
337
|
+
// Idle disposal calls this under the mutex. Owner finalization calls it only
|
|
338
|
+
// after child Tasks, including idle disposal, settle, so calls cannot
|
|
339
|
+
// overlap.
|
|
340
|
+
const disposeCurrent = async (): Promise<void> => {
|
|
251
341
|
if (!current) return;
|
|
252
|
-
await using
|
|
253
|
-
if (onDisposed)
|
|
254
|
-
|
|
342
|
+
await using resourceDisposer = new AsyncDisposableStack();
|
|
343
|
+
if (onDisposed) resourceDisposer.defer(onDisposed);
|
|
344
|
+
resourceDisposer.use(current);
|
|
255
345
|
current = undefined;
|
|
256
346
|
};
|
|
257
|
-
disposer.defer(disposeCurrent);
|
|
258
347
|
|
|
259
|
-
|
|
260
|
-
|
|
348
|
+
/** Disposes the current resource unless a new lease arrived meanwhile. */
|
|
349
|
+
const disposeCurrentWhenUnused: Task<void, never, D> = mutex.withLock(
|
|
350
|
+
async () => {
|
|
351
|
+
// For delayed disposal, this is safe to clear unconditionally: a newer
|
|
352
|
+
// idle-dispose fiber would require a release after a completed acquire,
|
|
353
|
+
// but FIFO orders that acquire behind this task. Therefore this is the
|
|
354
|
+
// current fiber or already undefined, never newer. An acquire that got
|
|
355
|
+
// the mutex before this task queued aborted this fiber while it was
|
|
356
|
+
// still sleeping. If this fiber was already queued on the mutex, abort
|
|
357
|
+
// removes its semaphore waiter before grant, so this body cannot run
|
|
358
|
+
// stale.
|
|
359
|
+
idleDisposeFiber = undefined;
|
|
360
|
+
if (heldLeases.size === 0) await disposeCurrent();
|
|
361
|
+
return ok();
|
|
362
|
+
},
|
|
363
|
+
);
|
|
261
364
|
|
|
262
|
-
|
|
365
|
+
// Execution order is LIFO: clear idle state, drain leases, dispose current,
|
|
366
|
+
// then untrack the owner. Separate entries preserve later cleanup when an
|
|
367
|
+
// earlier finalizer defects.
|
|
368
|
+
sharedResourceRun.defer(() => {
|
|
369
|
+
leakDetector.untrack(sharedResourceHandle);
|
|
370
|
+
});
|
|
371
|
+
sharedResourceRun.defer(disposeCurrent);
|
|
372
|
+
sharedResourceRun.defer(() => {
|
|
373
|
+
for (const handle of heldLeases) leakDetector.untrack(handle);
|
|
374
|
+
heldLeases.clear();
|
|
375
|
+
});
|
|
376
|
+
sharedResourceRun.defer(() => {
|
|
377
|
+
idleDisposeFiber = undefined;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const createLease = (created: boolean): Lease<T> => {
|
|
381
|
+
const handle = {};
|
|
382
|
+
heldLeases.add(handle);
|
|
383
|
+
|
|
384
|
+
const release = (): boolean => {
|
|
385
|
+
if (!heldLeases.delete(handle)) return false;
|
|
386
|
+
|
|
387
|
+
leakDetector.untrack(handle);
|
|
388
|
+
|
|
389
|
+
if (heldLeases.size > 0) return true;
|
|
390
|
+
|
|
391
|
+
// During shutdown, disposal owns the resource; release is accounting
|
|
392
|
+
// only.
|
|
393
|
+
if (sharedResourceRun.getState().type !== "Running") return true;
|
|
394
|
+
|
|
395
|
+
if (idleDisposeAfter) {
|
|
396
|
+
idleDisposeFiber = sharedResourceRun.abortable<void, never>(
|
|
397
|
+
async (run) => {
|
|
398
|
+
await run.ok(sleep(idleDisposeAfter));
|
|
399
|
+
return run(disposeCurrentWhenUnused);
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
// Immediate disposal is not retained for acquire to cancel. Starting
|
|
404
|
+
// it synchronously queues or acquires the mutex before release
|
|
405
|
+
// returns. The mutex orders it against acquires, and disposal
|
|
406
|
+
// rechecks the lease count under the lock.
|
|
407
|
+
void sharedResourceRun.abortable(disposeCurrentWhenUnused);
|
|
408
|
+
}
|
|
263
409
|
|
|
264
|
-
|
|
265
|
-
|
|
410
|
+
return true;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const lease: Lease<T> = {
|
|
414
|
+
resource: current as unknown as BorrowedResource<T>,
|
|
415
|
+
created,
|
|
416
|
+
release,
|
|
417
|
+
[Symbol.dispose]: release,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
leakDetector.track(
|
|
421
|
+
lease,
|
|
422
|
+
{ name: "Lease", isLeaked: () => heldLeases.has(handle) },
|
|
423
|
+
handle,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
return lease;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const sharedResource: SharedResource<T> = {
|
|
430
|
+
// Run on the resource-owned tree so caller abort cannot interrupt acquire
|
|
431
|
+
// and later acquire deps cannot replace the owner's captured deps.
|
|
432
|
+
acquire: () =>
|
|
266
433
|
sharedResourceRun(
|
|
267
434
|
mutex.withLock(async (run) => {
|
|
268
435
|
if (idleDisposeFiber) {
|
|
@@ -270,147 +437,266 @@ export const createSharedResource = <T extends Resource, D>(
|
|
|
270
437
|
idleDisposeFiber = undefined;
|
|
271
438
|
}
|
|
272
439
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
refCount.increment();
|
|
280
|
-
return ok(current.resource);
|
|
440
|
+
const created = current === undefined;
|
|
441
|
+
current ??= await run.ok(create);
|
|
442
|
+
// Publish ownership before the checkpoint so owner finalization
|
|
443
|
+
// disposes a resource returned after shutdown started.
|
|
444
|
+
run.signal.throwIfAborted();
|
|
445
|
+
return ok(createLease(created));
|
|
281
446
|
}),
|
|
282
447
|
),
|
|
283
|
-
),
|
|
284
448
|
|
|
285
|
-
|
|
449
|
+
acquireCurrent: () =>
|
|
286
450
|
sharedResourceRun(
|
|
287
|
-
|
|
288
|
-
|
|
451
|
+
// This body must stay synchronous so owner disposal cannot start
|
|
452
|
+
// between checking current and transferring its lease.
|
|
453
|
+
mutex.withLock(() => {
|
|
454
|
+
if (!current) return ok(undefined);
|
|
289
455
|
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
456
|
+
if (idleDisposeFiber) {
|
|
457
|
+
idleDisposeFiber.abort();
|
|
458
|
+
idleDisposeFiber = undefined;
|
|
293
459
|
}
|
|
294
460
|
|
|
295
|
-
|
|
296
|
-
const slept = await run(sleep(idleDisposeAfter));
|
|
297
|
-
if (!slept.ok) return slept;
|
|
298
|
-
|
|
299
|
-
return run(
|
|
300
|
-
mutex.withLock(async () => {
|
|
301
|
-
idleDisposeFiber = undefined;
|
|
302
|
-
await disposeCurrent();
|
|
303
|
-
return ok();
|
|
304
|
-
}),
|
|
305
|
-
);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
return ok();
|
|
461
|
+
return ok(createLease(false));
|
|
309
462
|
}),
|
|
310
463
|
),
|
|
311
|
-
),
|
|
312
464
|
|
|
313
|
-
|
|
314
|
-
|
|
465
|
+
use: (callback) => async (run) => {
|
|
466
|
+
using lease = await run.ok(sharedResource.acquire);
|
|
467
|
+
return await run(callback(lease.resource, lease.created));
|
|
468
|
+
},
|
|
315
469
|
|
|
316
|
-
|
|
470
|
+
snapshot: () => {
|
|
471
|
+
const mutexSnapshot = mutex.snapshot();
|
|
472
|
+
return {
|
|
473
|
+
isIdle:
|
|
474
|
+
heldLeases.size === 0 &&
|
|
475
|
+
!current &&
|
|
476
|
+
!idleDisposeFiber &&
|
|
477
|
+
mutexSnapshot.isIdle,
|
|
478
|
+
leaseCount: NonNegativeInt.orThrow(heldLeases.size),
|
|
479
|
+
hasResource: current !== undefined,
|
|
480
|
+
idleDisposePending: idleDisposeFiber !== undefined,
|
|
481
|
+
mutex: mutexSnapshot,
|
|
482
|
+
};
|
|
483
|
+
},
|
|
317
484
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
mutex: mutex.snapshot(),
|
|
321
|
-
}),
|
|
485
|
+
[Symbol.asyncDispose]: () => sharedResourceRun[Symbol.asyncDispose](),
|
|
486
|
+
};
|
|
322
487
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
488
|
+
leakDetector.track(
|
|
489
|
+
sharedResource,
|
|
490
|
+
{
|
|
491
|
+
name: "SharedResource",
|
|
492
|
+
isLeaked: () => sharedResourceRun.getState().type === "Running",
|
|
493
|
+
},
|
|
494
|
+
sharedResourceHandle,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
return ok(sharedResource);
|
|
498
|
+
};
|
|
326
499
|
|
|
327
500
|
/**
|
|
328
501
|
* Shared {@link Resource}s keyed by logical identity.
|
|
329
502
|
*
|
|
330
503
|
* A map-like registry of {@link SharedResource}s. Each key owns at most one
|
|
331
|
-
* current resource instance.
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* resource until the final {@link SharedResourceByKey.release | release} starts
|
|
336
|
-
* the final disposal path for that key. Disposal and registry removal happen
|
|
337
|
-
* immediately by default, or after
|
|
338
|
-
* {@link SharedResourceByKeyOptions.idleDisposeAfter | idleDisposeAfter} elapses
|
|
339
|
-
* when configured.
|
|
504
|
+
* current resource instance. The first
|
|
505
|
+
* {@link SharedResourceByKey.acquire | acquire} for a key lazily creates that
|
|
506
|
+
* key's resource; releasing the key's last {@link Lease} disposes it and removes
|
|
507
|
+
* the key from the registry.
|
|
340
508
|
*
|
|
341
509
|
* Different keys are independent and may progress concurrently. Operations for
|
|
342
|
-
* the same key are serialized.
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* {
|
|
352
|
-
*
|
|
510
|
+
* the same key are serialized.
|
|
511
|
+
*
|
|
512
|
+
* By default, keys use reference identity, matching native `Map`. Callers may
|
|
513
|
+
* instead provide a {@link Lookup lookup} so logical equality is based on a
|
|
514
|
+
* derived stable key.
|
|
515
|
+
*
|
|
516
|
+
* ### Example
|
|
517
|
+
*
|
|
518
|
+
* ```ts
|
|
519
|
+
* import {
|
|
520
|
+
* all,
|
|
521
|
+
* createRun,
|
|
522
|
+
* createSharedResourceByKey,
|
|
523
|
+
* ok,
|
|
524
|
+
* type Task,
|
|
525
|
+
* } from "@evolu/common";
|
|
526
|
+
*
|
|
527
|
+
* interface Connection extends Disposable {
|
|
528
|
+
* readonly ownerId: string;
|
|
529
|
+
* readonly send: (message: string) => void;
|
|
530
|
+
* readonly flush: () => ReadonlyArray<string>;
|
|
531
|
+
* }
|
|
532
|
+
*
|
|
533
|
+
* const createConnection =
|
|
534
|
+
* (ownerId: string): Task<Connection> =>
|
|
535
|
+
* () => {
|
|
536
|
+
* const pendingMessages: Array<string> = [];
|
|
537
|
+
* return ok({
|
|
538
|
+
* ownerId,
|
|
539
|
+
* send: (message) => {
|
|
540
|
+
* pendingMessages.push(message);
|
|
541
|
+
* },
|
|
542
|
+
* flush: () => pendingMessages.splice(0),
|
|
543
|
+
* [Symbol.dispose]: () => {},
|
|
544
|
+
* });
|
|
545
|
+
* };
|
|
546
|
+
*
|
|
547
|
+
* await using run = createRun();
|
|
548
|
+
* await using connections = await run.ok(
|
|
549
|
+
* createSharedResourceByKey(createConnection, {
|
|
550
|
+
* idleDisposeAfter: "30s",
|
|
551
|
+
* }),
|
|
552
|
+
* );
|
|
553
|
+
* const send = (ownerId: string, message: string): Task<void> =>
|
|
554
|
+
* connections.use(ownerId, (connection) => () => {
|
|
555
|
+
* connection.send(message);
|
|
556
|
+
* return ok();
|
|
557
|
+
* });
|
|
558
|
+
*
|
|
559
|
+
* // Same-key calls share one connection. Work for different keys remains
|
|
560
|
+
* // independent, so all three operations can run concurrently.
|
|
561
|
+
* await run.ok(
|
|
562
|
+
* all(
|
|
563
|
+
* [
|
|
564
|
+
* send("owner-1", "first"),
|
|
565
|
+
* send("owner-1", "second"),
|
|
566
|
+
* send("owner-2", "hello"),
|
|
567
|
+
* ],
|
|
568
|
+
* { concurrency: 3 },
|
|
569
|
+
* ),
|
|
570
|
+
* );
|
|
571
|
+
*
|
|
572
|
+
* // `acquireCurrent` does not create absent keys. The idle delay keeps the two
|
|
573
|
+
* // existing connections available to enumerate and flush.
|
|
574
|
+
* using missingLease = await run.ok(connections.acquireCurrent("owner-3"));
|
|
575
|
+
* const messagesByOwnerId = new Map<string, ReadonlyArray<string>>();
|
|
576
|
+
* await run.ok(
|
|
577
|
+
* connections.forEachCurrent((connection) => {
|
|
578
|
+
* messagesByOwnerId.set(connection.ownerId, connection.flush());
|
|
579
|
+
* }),
|
|
580
|
+
* );
|
|
581
|
+
*
|
|
582
|
+
* expect(missingLease).toBeUndefined();
|
|
583
|
+
* expect(messagesByOwnerId).toEqual(
|
|
584
|
+
* new Map([
|
|
585
|
+
* ["owner-1", ["first", "second"]],
|
|
586
|
+
* ["owner-2", ["hello"]],
|
|
587
|
+
* ]),
|
|
588
|
+
* );
|
|
589
|
+
* ```
|
|
353
590
|
*/
|
|
354
591
|
export interface SharedResourceByKey<
|
|
355
592
|
K,
|
|
356
593
|
T extends Resource,
|
|
357
|
-
D = unknown,
|
|
358
594
|
> extends AsyncDisposable {
|
|
359
595
|
/**
|
|
360
|
-
* Acquires the shared resource for `key`, creating
|
|
596
|
+
* Acquires a {@link Lease} on the shared resource for `key`, creating the
|
|
597
|
+
* resource on first use.
|
|
361
598
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
599
|
+
* The same contract as {@link SharedResource.acquire}: once started, acquire
|
|
600
|
+
* runs to completion even when the caller aborts its Fiber, and the returned
|
|
601
|
+
* lease still counts as held. Registry disposal before lease transfer aborts
|
|
602
|
+
* the acquisition. Always await the result and release the lease.
|
|
366
603
|
*/
|
|
367
|
-
readonly acquire: (key: K) => Task<
|
|
604
|
+
readonly acquire: (key: K) => Task<Lease<T>>;
|
|
368
605
|
|
|
369
606
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* When the last acquired reference for `key` is released, that key's current
|
|
373
|
-
* resource is disposed and removed from the registry immediately by default.
|
|
374
|
-
* If {@link SharedResourceByKeyOptions.idleDisposeAfter | idleDisposeAfter} is
|
|
375
|
-
* set, disposal and registry removal are scheduled instead and a new acquire
|
|
376
|
-
* for the same key during that delay reuses the current resource.
|
|
607
|
+
* Acquires a {@link Lease} on the current resource for `key` without creating
|
|
608
|
+
* one.
|
|
377
609
|
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
610
|
+
* Waits for preceding creation or disposal for the same key to finish.
|
|
611
|
+
* Returns `undefined` if no current resource remains. Registry disposal while
|
|
612
|
+
* waiting aborts the acquisition before a lease is transferred. Registry
|
|
613
|
+
* disposal after transfer can drain the lease before the caller resumes,
|
|
614
|
+
* matching {@link SharedResource.acquireCurrent}.
|
|
380
615
|
*/
|
|
381
|
-
readonly
|
|
616
|
+
readonly acquireCurrent: (key: K) => Task<Lease<T> | undefined>;
|
|
382
617
|
|
|
383
|
-
/**
|
|
384
|
-
|
|
618
|
+
/**
|
|
619
|
+
* Acquires a {@link Lease}, runs a Task with the shared resource for `key`,
|
|
620
|
+
* and releases the lease after the Task settles.
|
|
621
|
+
*
|
|
622
|
+
* Creates the resource when absent. The callback receives whether this use
|
|
623
|
+
* created the resource generation. While the registry remains running, the
|
|
624
|
+
* lease prevents ordinary idle disposal. Disposing this registry is forceful:
|
|
625
|
+
* it drains the lease and may dispose the resource before or while the
|
|
626
|
+
* callback Task runs.
|
|
627
|
+
*/
|
|
628
|
+
readonly use: <R, E, D>(
|
|
629
|
+
key: K,
|
|
630
|
+
callback: (
|
|
631
|
+
resource: BorrowedResource<T>,
|
|
632
|
+
created: boolean,
|
|
633
|
+
) => Task<R, E, D>,
|
|
634
|
+
) => Task<R, E, D>;
|
|
385
635
|
|
|
386
|
-
/**
|
|
387
|
-
|
|
636
|
+
/**
|
|
637
|
+
* Calls `callback` for each current resource it can lease from keys
|
|
638
|
+
* registered when this Task starts.
|
|
639
|
+
*
|
|
640
|
+
* Never creates resources. A resource whose creation is in progress may be
|
|
641
|
+
* included after creation finishes; one disposed before its temporary lease
|
|
642
|
+
* is acquired is skipped. Each acquired lease stays held while later keys are
|
|
643
|
+
* awaited, and every callback runs while all acquired resources remain
|
|
644
|
+
* leased. Caller abort is observed between keys; already-collected leases are
|
|
645
|
+
* released and no callbacks run.
|
|
646
|
+
*/
|
|
647
|
+
readonly forEachCurrent: (
|
|
648
|
+
callback: (resource: BorrowedResource<T>, key: K) => void,
|
|
649
|
+
) => Task<void>;
|
|
388
650
|
|
|
389
|
-
/** Returns current
|
|
390
|
-
readonly snapshot: () => SharedResourceByKeySnapshot<K
|
|
651
|
+
/** Returns current per-key shared-resource states for monitoring/debugging. */
|
|
652
|
+
readonly snapshot: () => SharedResourceByKeySnapshot<K>;
|
|
391
653
|
}
|
|
392
654
|
|
|
393
655
|
/** Snapshot returned by {@link SharedResourceByKey.snapshot}. */
|
|
394
|
-
export interface SharedResourceByKeySnapshot<K
|
|
395
|
-
/**
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
656
|
+
export interface SharedResourceByKeySnapshot<K> {
|
|
657
|
+
/**
|
|
658
|
+
* Current registered {@link SharedResourceSnapshot} for each key.
|
|
659
|
+
*
|
|
660
|
+
* A key is absent during the brief first-acquire interval before its
|
|
661
|
+
* SharedResource is registered, even though that acquire may already hold the
|
|
662
|
+
* key mutex.
|
|
663
|
+
*/
|
|
664
|
+
readonly resourcesByKey: ReadonlyMap<K, SharedResourceSnapshot>;
|
|
400
665
|
}
|
|
401
666
|
|
|
402
667
|
/** Options for {@link createSharedResourceByKey}. */
|
|
403
668
|
export interface SharedResourceByKeyOptions<K, L = K>
|
|
404
669
|
extends Pick<SharedResourceOptions, "idleDisposeAfter">, LookupOption<K, L> {
|
|
405
|
-
/**
|
|
406
|
-
|
|
670
|
+
/**
|
|
671
|
+
* Called with `key` after each current resource for that key is disposed,
|
|
672
|
+
* including during {@link SharedResourceByKey} disposal. Not called for keys
|
|
673
|
+
* whose resources were never created.
|
|
674
|
+
*
|
|
675
|
+
* After ordinary idle disposal, the key is removed before this callback
|
|
676
|
+
* unless a same-key operation is already in progress. A regular acquire can
|
|
677
|
+
* then create the next current resource. An acquireCurrent that finds no
|
|
678
|
+
* resource removes the retained key before returning.
|
|
679
|
+
*/
|
|
680
|
+
readonly onDisposed?: ((key: K) => void) | undefined;
|
|
407
681
|
}
|
|
408
682
|
|
|
409
683
|
/**
|
|
410
684
|
* Creates {@link SharedResourceByKey}.
|
|
411
685
|
*
|
|
412
|
-
* The `create` Task
|
|
413
|
-
* {@link createSharedResource}.
|
|
686
|
+
* The `create` callback returns a Task scoped to one key. The returned Task
|
|
687
|
+
* must not fail, matching {@link createSharedResource}.
|
|
688
|
+
*
|
|
689
|
+
* Create each resource with {@link disposable} when its shape supports it. The
|
|
690
|
+
* same disposal-guard recommendation as {@link createSharedResource} applies.
|
|
691
|
+
*
|
|
692
|
+
* The returned Task must not directly or transitively acquire the same logical
|
|
693
|
+
* key or call `forEachCurrent` on this registry. Same-key acquisition is
|
|
694
|
+
* non-reentrant, and `forEachCurrent` attempts to acquire every registered key.
|
|
695
|
+
* Different keys remain independent.
|
|
696
|
+
*
|
|
697
|
+
* Like {@link createSharedResource}, dependencies are captured when the registry
|
|
698
|
+
* is created; dependencies provided to later acquire calls do not change what
|
|
699
|
+
* the returned Task observes.
|
|
414
700
|
*/
|
|
415
701
|
export function createSharedResourceByKey<
|
|
416
702
|
K = unknown,
|
|
@@ -418,11 +704,11 @@ export function createSharedResourceByKey<
|
|
|
418
704
|
D = unknown,
|
|
419
705
|
>(
|
|
420
706
|
create: (key: K) => Task<T, never, D>,
|
|
421
|
-
): Task<SharedResourceByKey<K, T
|
|
707
|
+
): Task<SharedResourceByKey<K, T>, never, D>;
|
|
422
708
|
export function createSharedResourceByKey<K, T extends Resource, D, L = K>(
|
|
423
709
|
create: (key: K) => Task<T, never, D>,
|
|
424
710
|
options: SharedResourceByKeyOptions<K, L>,
|
|
425
|
-
): Task<SharedResourceByKey<K, T
|
|
711
|
+
): Task<SharedResourceByKey<K, T>, never, D>;
|
|
426
712
|
export function createSharedResourceByKey<
|
|
427
713
|
K = unknown,
|
|
428
714
|
T extends Resource = Resource,
|
|
@@ -435,156 +721,359 @@ export function createSharedResourceByKey<
|
|
|
435
721
|
lookup = identity as Lookup<K, L>,
|
|
436
722
|
onDisposed,
|
|
437
723
|
}: SharedResourceByKeyOptions<K, L> = {},
|
|
438
|
-
): Task<SharedResourceByKey<K, T
|
|
439
|
-
return
|
|
724
|
+
): Task<SharedResourceByKey<K, T>, never, D> {
|
|
725
|
+
return (run) => {
|
|
440
726
|
const sharedResourceByKeyRun = run.create();
|
|
441
|
-
const
|
|
727
|
+
const { leakDetector } = run.deps;
|
|
728
|
+
|
|
729
|
+
const sharedResourcesByKey = createLookupMap<K, SharedResource<T>, L>({
|
|
442
730
|
lookup,
|
|
443
731
|
});
|
|
444
732
|
|
|
445
|
-
|
|
733
|
+
const mutexByKey = createMutexByKey<K, L>({ lookup });
|
|
734
|
+
const disposer = new AsyncDisposableStack();
|
|
735
|
+
const sharedResourceByKeyHandle = {};
|
|
736
|
+
|
|
737
|
+
const removeKey = (key: K, sharedResource: SharedResource<T>): void => {
|
|
738
|
+
// Callers must ensure no same-key operation can still use sharedResource.
|
|
739
|
+
// onDisposed checks that the key mutex is idle; acquireCurrent holds the
|
|
740
|
+
// key mutex after observing that the inner resource is empty.
|
|
741
|
+
// Explicit registry disposal owns every registered SharedResource. Keep
|
|
742
|
+
// the key visible until keyDisposer captures it.
|
|
743
|
+
if (disposer.disposed) return;
|
|
744
|
+
|
|
745
|
+
sharedResourcesByKey.delete(key);
|
|
746
|
+
|
|
747
|
+
// During root shutdown, the inner Run is already being disposed.
|
|
748
|
+
if (sharedResourceByKeyRun.getState().type !== "Running") return;
|
|
446
749
|
|
|
447
|
-
|
|
750
|
+
void sharedResourceByKeyRun.abortable(async () => {
|
|
751
|
+
await sharedResource[Symbol.asyncDispose]();
|
|
752
|
+
return ok();
|
|
753
|
+
});
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
// Disposed in reverse order: the Run first, so pending acquires and removed
|
|
757
|
+
// SharedResource disposals are aborted and awaited before the registered
|
|
758
|
+
// SharedResources are disposed.
|
|
448
759
|
disposer.defer(async () => {
|
|
449
|
-
await using
|
|
450
|
-
for (const
|
|
451
|
-
|
|
452
|
-
await disposer.disposeAsync();
|
|
760
|
+
await using keyDisposer = new AsyncDisposableStack();
|
|
761
|
+
for (const sharedResource of sharedResourcesByKey.values())
|
|
762
|
+
keyDisposer.use(sharedResource);
|
|
453
763
|
sharedResourcesByKey.clear();
|
|
454
764
|
});
|
|
455
|
-
// Register as the last so disposal aborts further calls first.
|
|
456
765
|
disposer.use(sharedResourceByKeyRun);
|
|
457
766
|
|
|
458
|
-
|
|
767
|
+
sharedResourceByKeyRun.defer(() => {
|
|
768
|
+
leakDetector.untrack(sharedResourceByKeyHandle);
|
|
769
|
+
});
|
|
459
770
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const sharedResourceResult = await run(
|
|
469
|
-
createSharedResource(create(key), {
|
|
470
|
-
idleDisposeAfter,
|
|
471
|
-
onDisposed: () => {
|
|
472
|
-
onDisposed?.(key);
|
|
473
|
-
|
|
474
|
-
void sharedResourceByKeyRun(
|
|
475
|
-
mutexByKey.withLock(key, async () => {
|
|
476
|
-
if (
|
|
477
|
-
sharedResource &&
|
|
478
|
-
sharedResourcesByKey.get(key) === sharedResource &&
|
|
479
|
-
sharedResource.snapshot().isIdle
|
|
480
|
-
) {
|
|
481
|
-
sharedResourcesByKey.delete(key);
|
|
482
|
-
await sharedResource[Symbol.asyncDispose]();
|
|
483
|
-
}
|
|
484
|
-
return ok();
|
|
485
|
-
}),
|
|
486
|
-
);
|
|
487
|
-
},
|
|
488
|
-
}),
|
|
489
|
-
);
|
|
490
|
-
assertNotAborted(sharedResourceResult);
|
|
491
|
-
sharedResource = sharedResourceResult.value;
|
|
492
|
-
sharedResourcesByKey.set(key, sharedResource);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return run(sharedResource.acquire);
|
|
496
|
-
}),
|
|
497
|
-
),
|
|
498
|
-
),
|
|
771
|
+
const releaseLeaseIfAborted = (
|
|
772
|
+
signal: AbortSignal,
|
|
773
|
+
lease: Lease<T> | undefined,
|
|
774
|
+
): void => {
|
|
775
|
+
if (!signal.aborted) return;
|
|
776
|
+
lease?.release();
|
|
777
|
+
signal.throwIfAborted();
|
|
778
|
+
};
|
|
499
779
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
780
|
+
const sharedResourceByKey: SharedResourceByKey<K, T> = {
|
|
781
|
+
acquire: (key) => () =>
|
|
782
|
+
sharedResourceByKeyRun(
|
|
783
|
+
mutexByKey.withLock(key, async (run) => {
|
|
784
|
+
// The outer key mutex spans registration and the inner acquire so
|
|
785
|
+
// key removal cannot race resource creation or lease transfer.
|
|
786
|
+
let sharedResource = sharedResourcesByKey.get(key);
|
|
787
|
+
|
|
788
|
+
if (!sharedResource) {
|
|
789
|
+
sharedResource = await run.ok(
|
|
790
|
+
createSharedResource(create(key), {
|
|
791
|
+
idleDisposeAfter,
|
|
792
|
+
onDisposed: () => {
|
|
793
|
+
assertNonNullable(
|
|
794
|
+
sharedResource,
|
|
795
|
+
"SharedResource must be assigned before its resource is disposed.",
|
|
796
|
+
);
|
|
797
|
+
if (mutexByKey.isIdle(key)) removeKey(key, sharedResource);
|
|
798
|
+
onDisposed?.(key);
|
|
799
|
+
},
|
|
800
|
+
}),
|
|
508
801
|
);
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
|
|
802
|
+
sharedResourcesByKey.set(key, sharedResource);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const lease = await run.ok(sharedResource.acquire);
|
|
806
|
+
releaseLeaseIfAborted(run.signal, lease);
|
|
807
|
+
return ok(lease);
|
|
808
|
+
}),
|
|
512
809
|
),
|
|
513
810
|
|
|
514
|
-
|
|
811
|
+
acquireCurrent: (key) => () =>
|
|
515
812
|
sharedResourceByKeyRun(
|
|
516
813
|
mutexByKey.withLock(key, async (run) => {
|
|
517
814
|
const sharedResource = sharedResourcesByKey.get(key);
|
|
518
|
-
if (!sharedResource) return ok(
|
|
519
|
-
|
|
815
|
+
if (!sharedResource) return ok(undefined);
|
|
816
|
+
|
|
817
|
+
const lease = await run.ok(sharedResource.acquireCurrent);
|
|
818
|
+
releaseLeaseIfAborted(run.signal, lease);
|
|
819
|
+
if (!lease) removeKey(key, sharedResource);
|
|
820
|
+
return ok(lease);
|
|
520
821
|
}),
|
|
521
822
|
),
|
|
522
823
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const mutexSnapshotsByKey = new Map<K, SemaphoreSnapshot | null>();
|
|
824
|
+
use: (key, callback) => async (run) => {
|
|
825
|
+
using lease = await run.ok(sharedResourceByKey.acquire(key));
|
|
826
|
+
return await run(callback(lease.resource, lease.created));
|
|
827
|
+
},
|
|
528
828
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
829
|
+
forEachCurrent: (callback) => async (run) => {
|
|
830
|
+
using leaseDisposer = new DisposableStack();
|
|
831
|
+
const leasesByKey: Array<readonly [Lease<T>, K]> = [];
|
|
532
832
|
|
|
533
|
-
|
|
534
|
-
|
|
833
|
+
for (const [key] of [...sharedResourcesByKey]) {
|
|
834
|
+
const lease = await run.ok(sharedResourceByKey.acquireCurrent(key));
|
|
835
|
+
if (lease) leasesByKey.push([leaseDisposer.use(lease), key]);
|
|
836
|
+
run.signal.throwIfAborted();
|
|
535
837
|
}
|
|
536
838
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
};
|
|
839
|
+
for (const [lease, key] of leasesByKey) callback(lease.resource, key);
|
|
840
|
+
|
|
841
|
+
return ok();
|
|
541
842
|
},
|
|
542
843
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
844
|
+
snapshot: () => {
|
|
845
|
+
const resourcesByKey = new Map<K, SharedResourceSnapshot>();
|
|
846
|
+
for (const [key, sharedResource] of sharedResourcesByKey)
|
|
847
|
+
resourcesByKey.set(key, sharedResource.snapshot());
|
|
848
|
+
return { resourcesByKey };
|
|
849
|
+
},
|
|
850
|
+
|
|
851
|
+
[Symbol.asyncDispose]: () => disposer.disposeAsync(),
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
leakDetector.track(
|
|
855
|
+
sharedResourceByKey,
|
|
856
|
+
{
|
|
857
|
+
name: "SharedResourceByKey",
|
|
858
|
+
isLeaked: () => sharedResourceByKeyRun.getState().type === "Running",
|
|
859
|
+
},
|
|
860
|
+
sharedResourceByKeyHandle,
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
return ok(sharedResourceByKey);
|
|
864
|
+
};
|
|
546
865
|
}
|
|
547
866
|
|
|
548
867
|
/**
|
|
549
|
-
* Shared {@link Resource}s
|
|
868
|
+
* Shared keyed {@link Resource}s retained through claims.
|
|
869
|
+
*
|
|
870
|
+
* A claim identifies an application-level owner such as an account, tenant, or
|
|
871
|
+
* open document. Resource keys identify the shared resources that owner needs,
|
|
872
|
+
* such as relay URLs, database names, or worker IDs.
|
|
873
|
+
*
|
|
874
|
+
* Use this abstraction when each owner needs a set of keyed resources and those
|
|
875
|
+
* sets can overlap. Calling {@link SharedResourceByKeyWithClaims.claim |
|
|
876
|
+
* claim}
|
|
877
|
+
* retains every resource in one owner's set. Overlapping owners share one
|
|
878
|
+
* resource instance per key, and the resource remains alive until every
|
|
879
|
+
* {@link ClaimLease} retaining that key is released.
|
|
880
|
+
*
|
|
881
|
+
* Relation queries reflect active claims, not physical resource liveness. With
|
|
882
|
+
* `idleDisposeAfter`, a resource can outlive its last claim.
|
|
883
|
+
*
|
|
884
|
+
* Use {@link SharedResourceByKey} instead when callers only need independent
|
|
885
|
+
* leases by key and the application does not need to associate those leases
|
|
886
|
+
* with logical owners.
|
|
887
|
+
*
|
|
888
|
+
* Two accounts can share a relay connection while one also uses a local-network
|
|
889
|
+
* transport:
|
|
890
|
+
*
|
|
891
|
+
* ```ts
|
|
892
|
+
* import {
|
|
893
|
+
* createRun,
|
|
894
|
+
* createSharedResourceByKeyWithClaims,
|
|
895
|
+
* ok,
|
|
896
|
+
* type Brand,
|
|
897
|
+
* type Task,
|
|
898
|
+
* } from "@evolu/common";
|
|
899
|
+
*
|
|
900
|
+
* type AccountId = string & Brand<"AccountId">;
|
|
901
|
+
* type TransportUrl = string & Brand<"TransportUrl">;
|
|
902
|
+
*
|
|
903
|
+
* interface Connection extends Disposable {
|
|
904
|
+
* readonly url: TransportUrl;
|
|
905
|
+
* }
|
|
550
906
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
907
|
+
* const createConnection =
|
|
908
|
+
* (url: TransportUrl): Task<Connection> =>
|
|
909
|
+
* () =>
|
|
910
|
+
* ok({
|
|
911
|
+
* url,
|
|
912
|
+
* [Symbol.dispose]: () => {},
|
|
913
|
+
* });
|
|
914
|
+
* // These literals stand in for values validated at application boundaries.
|
|
915
|
+
* const accountA = "account-a" as AccountId;
|
|
916
|
+
* const accountB = "account-b" as AccountId;
|
|
917
|
+
* const relay = "wss://relay.example.com" as TransportUrl;
|
|
918
|
+
* const localNetwork = "ws://local-network" as TransportUrl;
|
|
553
919
|
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
920
|
+
* await using run = createRun();
|
|
921
|
+
* await using transports = await run.ok(
|
|
922
|
+
* createSharedResourceByKeyWithClaims<
|
|
923
|
+
* TransportUrl,
|
|
924
|
+
* AccountId,
|
|
925
|
+
* Connection
|
|
926
|
+
* >(createConnection),
|
|
927
|
+
* );
|
|
928
|
+
*
|
|
929
|
+
* {
|
|
930
|
+
* // Account A retains both transports.
|
|
931
|
+
* using accountATransports = await run.ok(
|
|
932
|
+
* transports.claim(accountA, [relay, localNetwork]),
|
|
933
|
+
* );
|
|
934
|
+
*
|
|
935
|
+
* {
|
|
936
|
+
* // Account B reuses the relay already retained by account A.
|
|
937
|
+
* using accountBRelay = await run.ok(
|
|
938
|
+
* transports.claim(accountB, [relay]),
|
|
939
|
+
* );
|
|
940
|
+
* expect(transports.getClaimsForResource(relay)).toEqual(
|
|
941
|
+
* new Set([accountA, accountB]),
|
|
942
|
+
* );
|
|
943
|
+
* expect(transports.getResourceKeysForClaim(accountA)).toEqual(
|
|
944
|
+
* new Set([relay, localNetwork]),
|
|
945
|
+
* );
|
|
946
|
+
* }
|
|
947
|
+
*
|
|
948
|
+
* // Releasing account B leaves account A's relay retain intact.
|
|
949
|
+
* expect(transports.getClaimsForResource(relay)).toEqual(
|
|
950
|
+
* new Set([accountA]),
|
|
951
|
+
* );
|
|
952
|
+
* }
|
|
953
|
+
*
|
|
954
|
+
* // Releasing the final claim removes the relation for both transports.
|
|
955
|
+
* expect(transports.getClaimsForResource(relay)).toEqual(new Set());
|
|
956
|
+
* expect(transports.getClaimsForResource(localNetwork)).toEqual(new Set());
|
|
957
|
+
* ```
|
|
558
958
|
*/
|
|
559
959
|
export interface SharedResourceByKeyWithClaims<
|
|
560
960
|
K,
|
|
561
961
|
C,
|
|
562
962
|
T extends Resource,
|
|
563
|
-
D = unknown,
|
|
564
963
|
> extends AsyncDisposable {
|
|
565
|
-
/**
|
|
566
|
-
|
|
964
|
+
/**
|
|
965
|
+
* Retains every resource key for `claim`, creating absent resources lazily.
|
|
966
|
+
*
|
|
967
|
+
* Keys are snapshotted when this Task starts and acquired sequentially in
|
|
968
|
+
* input order.
|
|
969
|
+
*
|
|
970
|
+
* `resourceKeys` must not contain logical duplicates according to
|
|
971
|
+
* {@link SharedResourceByKeyWithClaimsOptions.resourceLookup}. A duplicate is
|
|
972
|
+
* a programmer defect that panics the owning Run.
|
|
973
|
+
*
|
|
974
|
+
* The returned {@link ClaimLease} releases all retains added by this call.
|
|
975
|
+
* Resource creation must succeed, so this Task has no recoverable error.
|
|
976
|
+
*/
|
|
977
|
+
readonly claim: (
|
|
567
978
|
claim: C,
|
|
568
|
-
resourceKeys:
|
|
569
|
-
) => Task<
|
|
979
|
+
resourceKeys: NonEmptyReadonlyArray<K>,
|
|
980
|
+
) => Task<ClaimLease>;
|
|
570
981
|
|
|
571
|
-
/**
|
|
572
|
-
|
|
982
|
+
/**
|
|
983
|
+
* Retains every resource key for `claim`, runs a Task with those resources,
|
|
984
|
+
* and releases the resulting {@link ClaimLease} after the Task settles.
|
|
985
|
+
*
|
|
986
|
+
* The callback receives only the resources retained by this call, in input
|
|
987
|
+
* order. Other active ClaimLeases for the same logical claim are excluded.
|
|
988
|
+
* While this registry remains running, every borrowed resource remains valid
|
|
989
|
+
* until the callback Task settles. Disposing this registry is forceful: it
|
|
990
|
+
* drains the ClaimLease and may dispose resources before or while the
|
|
991
|
+
* callback Task runs.
|
|
992
|
+
*/
|
|
993
|
+
readonly use: <R, E, D>(
|
|
573
994
|
claim: C,
|
|
574
|
-
resourceKeys:
|
|
575
|
-
|
|
995
|
+
resourceKeys: NonEmptyReadonlyArray<K>,
|
|
996
|
+
callback: (
|
|
997
|
+
resources: NonEmptyReadonlyArray<readonly [BorrowedResource<T>, K]>,
|
|
998
|
+
) => Task<R, E, D>,
|
|
999
|
+
) => Task<R, E, D>;
|
|
576
1000
|
|
|
577
|
-
/**
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
1001
|
+
/**
|
|
1002
|
+
* Returns the current unique claims retaining `resourceKey`.
|
|
1003
|
+
*
|
|
1004
|
+
* Returns an empty snapshot when no claim retains the key, including after
|
|
1005
|
+
* this registry is disposed.
|
|
1006
|
+
*/
|
|
1007
|
+
readonly getClaimsForResource: (resourceKey: K) => ReadonlySet<C>;
|
|
582
1008
|
|
|
583
|
-
/**
|
|
1009
|
+
/**
|
|
1010
|
+
* Returns the current unique resource keys retained by `claim`.
|
|
1011
|
+
*
|
|
1012
|
+
* Returns an empty snapshot when the claim retains no keys, including after
|
|
1013
|
+
* this registry is disposed.
|
|
1014
|
+
*/
|
|
584
1015
|
readonly getResourceKeysForClaim: (claim: C) => ReadonlySet<K>;
|
|
585
1016
|
|
|
586
|
-
/**
|
|
587
|
-
|
|
1017
|
+
/**
|
|
1018
|
+
* Calls `callback` for each current resource retained by `claim`.
|
|
1019
|
+
*
|
|
1020
|
+
* The keys and resources are snapshotted before the first callback runs.
|
|
1021
|
+
* Resources remain valid until this synchronous iteration returns, even if a
|
|
1022
|
+
* callback releases the final retaining ClaimLease. Callbacks must not retain
|
|
1023
|
+
* or asynchronously use borrowed resources afterward. Does nothing when the
|
|
1024
|
+
* claim retains no resources, including after this registry is disposed.
|
|
1025
|
+
*/
|
|
1026
|
+
readonly forEachResourceForClaim: (
|
|
1027
|
+
claim: C,
|
|
1028
|
+
callback: (resource: BorrowedResource<T>, resourceKey: K) => void,
|
|
1029
|
+
) => void;
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Returns current claim-retain and keyed-resource states.
|
|
1033
|
+
*
|
|
1034
|
+
* While a claim is acquiring keys, `resourcesByKey` can include inner leases
|
|
1035
|
+
* not yet reflected by `claimLeaseCount` or
|
|
1036
|
+
* `retainCountsByResourceKeyByClaim`.
|
|
1037
|
+
*/
|
|
1038
|
+
readonly snapshot: () => SharedResourceByKeyWithClaimsSnapshot<K, C>;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** Snapshot returned by {@link SharedResourceByKeyWithClaims.snapshot}. */
|
|
1042
|
+
export interface SharedResourceByKeyWithClaimsSnapshot<K, C> {
|
|
1043
|
+
/** Number of currently held ClaimLeases. */
|
|
1044
|
+
readonly claimLeaseCount: NonNegativeInt;
|
|
1045
|
+
|
|
1046
|
+
/** Current retain count for every active logical claim-resource pair. */
|
|
1047
|
+
readonly retainCountsByResourceKeyByClaim: ReadonlyMap<
|
|
1048
|
+
C,
|
|
1049
|
+
ReadonlyMap<K, number>
|
|
1050
|
+
>;
|
|
1051
|
+
|
|
1052
|
+
/** Current registered {@link SharedResourceSnapshot} for each key. */
|
|
1053
|
+
readonly resourcesByKey: ReadonlyMap<K, SharedResourceSnapshot>;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* An owned lease on the resource-retains added by one claim operation.
|
|
1058
|
+
*
|
|
1059
|
+
* A ClaimLease is a grouped ownership token. For example, a claim for one
|
|
1060
|
+
* account can retain its relay, local-network, and Bluetooth transports. The
|
|
1061
|
+
* ClaimLease releases that whole group together; it does not expose or transfer
|
|
1062
|
+
* ownership of the resources themselves.
|
|
1063
|
+
*
|
|
1064
|
+
* Releasing the lease removes every retain added by that claim call. Release is
|
|
1065
|
+
* idempotent and synchronous; resource disposal remains owned by the
|
|
1066
|
+
* {@link SharedResourceByKeyWithClaims} that created the lease.
|
|
1067
|
+
*/
|
|
1068
|
+
export interface ClaimLease extends Disposable {
|
|
1069
|
+
/**
|
|
1070
|
+
* Releases every resource retain owned by this claim lease.
|
|
1071
|
+
*
|
|
1072
|
+
* Returns whether this call released a still-held lease. Returns `false` if
|
|
1073
|
+
* the lease was already released or drained by disposal of its owning
|
|
1074
|
+
* {@link SharedResourceByKeyWithClaims} or the Run tree that owns it.
|
|
1075
|
+
*/
|
|
1076
|
+
readonly release: () => boolean;
|
|
588
1077
|
}
|
|
589
1078
|
|
|
590
1079
|
/** Options for {@link createSharedResourceByKeyWithClaims}. */
|
|
@@ -595,20 +1084,36 @@ export interface SharedResourceByKeyWithClaimsOptions<
|
|
|
595
1084
|
LK = K,
|
|
596
1085
|
LC = C,
|
|
597
1086
|
> extends Pick<SharedResourceOptions, "idleDisposeAfter"> {
|
|
598
|
-
/** Derives
|
|
1087
|
+
/** Derives the identity used to compare resource keys. */
|
|
599
1088
|
readonly resourceLookup?: Lookup<K, LK>;
|
|
600
1089
|
|
|
601
|
-
/** Derives
|
|
1090
|
+
/** Derives the identity used to compare claims. */
|
|
602
1091
|
readonly claimLookup?: Lookup<C, LC>;
|
|
603
1092
|
|
|
604
|
-
/**
|
|
1093
|
+
/**
|
|
1094
|
+
* Called when a claim-resource pair transitions from zero retains to one.
|
|
1095
|
+
*
|
|
1096
|
+
* This is a pair-retain transition, not a resource-generation transition: a
|
|
1097
|
+
* resource can already be retained by other claims or be idling after an
|
|
1098
|
+
* earlier release. Arguments use the first stored representatives for their
|
|
1099
|
+
* logical claim and key.
|
|
1100
|
+
*/
|
|
605
1101
|
readonly onFirstClaimAdded?: (
|
|
606
1102
|
claim: C,
|
|
607
1103
|
resource: BorrowedResource<T>,
|
|
608
1104
|
resourceKey: K,
|
|
609
1105
|
) => void;
|
|
610
1106
|
|
|
611
|
-
/**
|
|
1107
|
+
/**
|
|
1108
|
+
* Called when a claim-resource pair transitions from one retain to zero.
|
|
1109
|
+
*
|
|
1110
|
+
* Runs before the ClaimLease's inner resource lease is released. Other claims
|
|
1111
|
+
* can still retain the same resource. Arguments use the first stored
|
|
1112
|
+
* representatives for their logical claim and key. During normal release, the
|
|
1113
|
+
* current pair and earlier keys from the same ClaimLease are already absent
|
|
1114
|
+
* from relation reads; later keys remain. During compensation for a failed
|
|
1115
|
+
* first-claim callback, the failed claim remains fully visible.
|
|
1116
|
+
*/
|
|
612
1117
|
readonly onLastClaimRemoved?: (
|
|
613
1118
|
claim: C,
|
|
614
1119
|
resource: BorrowedResource<T>,
|
|
@@ -619,253 +1124,356 @@ export interface SharedResourceByKeyWithClaimsOptions<
|
|
|
619
1124
|
/**
|
|
620
1125
|
* Creates {@link SharedResourceByKeyWithClaims}.
|
|
621
1126
|
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
1127
|
+
* The `create` callback is called with a resource key when that resource must
|
|
1128
|
+
* be created. The returned Task must not fail. Its dependencies are captured
|
|
1129
|
+
* when the registry is created; later claim calls do not require or replace
|
|
1130
|
+
* them.
|
|
1131
|
+
*
|
|
1132
|
+
* Transition callbacks represent semantic claim-resource pair changes and are
|
|
1133
|
+
* not called when this registry or its Run tree drains claims during disposal.
|
|
1134
|
+
* They must not throw. If a first-claim callback defects after earlier
|
|
1135
|
+
* callbacks in the same claim completed, matching last-claim callbacks
|
|
1136
|
+
* compensate those completed transitions in reverse order before cleanup.
|
|
1137
|
+
* Relation reads during compensation still include the failed claim. Callback
|
|
1138
|
+
* defects panic the owner Run after claim metadata and resource ownership are
|
|
1139
|
+
* cleaned up.
|
|
1140
|
+
*
|
|
1141
|
+
* Transition callbacks may synchronously read relation state. They must not
|
|
1142
|
+
* call `claim`, release a {@link ClaimLease} owned by this registry, or dispose
|
|
1143
|
+
* this registry.
|
|
1144
|
+
*
|
|
1145
|
+
* Transition callbacks do not report actual resource disposal. Disposal can
|
|
1146
|
+
* happen later when `idleDisposeAfter` is configured.
|
|
625
1147
|
*/
|
|
626
|
-
export
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
)
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1148
|
+
export const createSharedResourceByKeyWithClaims =
|
|
1149
|
+
<
|
|
1150
|
+
K = unknown,
|
|
1151
|
+
C = unknown,
|
|
1152
|
+
T extends Resource = Resource,
|
|
1153
|
+
D = unknown,
|
|
1154
|
+
LK = K,
|
|
1155
|
+
LC = C,
|
|
1156
|
+
>(
|
|
1157
|
+
create: (key: K) => Task<T, never, D>,
|
|
1158
|
+
{
|
|
1159
|
+
idleDisposeAfter,
|
|
1160
|
+
resourceLookup = identity as Lookup<K, LK>,
|
|
1161
|
+
claimLookup = identity as Lookup<C, LC>,
|
|
1162
|
+
onFirstClaimAdded,
|
|
1163
|
+
onLastClaimRemoved,
|
|
1164
|
+
}: SharedResourceByKeyWithClaimsOptions<K, C, T, LK, LC> = {},
|
|
1165
|
+
): Task<SharedResourceByKeyWithClaims<K, C, T>, never, D> =>
|
|
1166
|
+
async (run) => {
|
|
1167
|
+
type ClaimedResource = readonly [BorrowedResource<T>, K];
|
|
1168
|
+
|
|
1169
|
+
interface HeldClaimLease {
|
|
1170
|
+
readonly claim: C;
|
|
1171
|
+
readonly resourceKeys: NonEmptyReadonlyArray<K>;
|
|
1172
|
+
readonly resourceLeases: DisposableStack;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const sharedResourceByKeyWithClaimsRun = run.create();
|
|
1176
|
+
const { leakDetector } = run.deps;
|
|
1177
|
+
|
|
1178
|
+
const claimResourceRelation = createRefCountedRelation<C, K, LC, LK>({
|
|
1179
|
+
lookupA: claimLookup,
|
|
1180
|
+
lookupB: resourceLookup,
|
|
1181
|
+
});
|
|
1182
|
+
// This map and the relation's resource-key index use the same lookup and
|
|
1183
|
+
// are inserted and removed together, so they keep the same canonical keys.
|
|
1184
|
+
const resourcesByKey = createLookupMap<K, BorrowedResource<T>, LK>({
|
|
1185
|
+
lookup: resourceLookup,
|
|
1186
|
+
});
|
|
1187
|
+
const heldClaimLeases = new Set<HeldClaimLease>();
|
|
1188
|
+
const sharedResourceByKeyWithClaimsHandle = {};
|
|
1189
|
+
|
|
1190
|
+
const notifyLastClaimRemoved = (
|
|
1191
|
+
claim: C,
|
|
1192
|
+
resource: BorrowedResource<T>,
|
|
1193
|
+
resourceKey: K,
|
|
1194
|
+
): void => {
|
|
1195
|
+
try {
|
|
1196
|
+
onLastClaimRemoved?.(claim, resource, resourceKey);
|
|
1197
|
+
} catch (defect) {
|
|
1198
|
+
sharedResourceByKeyWithClaimsRun.panic(defect);
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
678
1201
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1202
|
+
await using disposer = new AsyncDisposableStack();
|
|
1203
|
+
// Explicit disposal stops claim operations and runs their finalizers before
|
|
1204
|
+
// disposing the keyed registry. The nested stack is empty if keyed-registry
|
|
1205
|
+
// creation fails. Both internal Runs attach to the root, whose disposal
|
|
1206
|
+
// awaits them independently of this explicit-disposal stack.
|
|
1207
|
+
const sharedResourceByKeyDisposer = disposer.use(
|
|
1208
|
+
new AsyncDisposableStack(),
|
|
1209
|
+
);
|
|
1210
|
+
disposer.use(sharedResourceByKeyWithClaimsRun);
|
|
1211
|
+
|
|
1212
|
+
const releaseClaimLease = (
|
|
1213
|
+
heldClaimLease: HeldClaimLease,
|
|
1214
|
+
notifyTransition = true,
|
|
1215
|
+
): boolean => {
|
|
1216
|
+
if (!heldClaimLeases.delete(heldClaimLease)) return false;
|
|
1217
|
+
leakDetector.untrack(heldClaimLease);
|
|
1218
|
+
|
|
1219
|
+
using _resourceLeases = heldClaimLease.resourceLeases;
|
|
1220
|
+
|
|
1221
|
+
for (const resourceKey of heldClaimLease.resourceKeys) {
|
|
1222
|
+
const decremented = claimResourceRelation.decrement(
|
|
1223
|
+
heldClaimLease.claim,
|
|
1224
|
+
resourceKey,
|
|
1225
|
+
);
|
|
1226
|
+
if (decremented.count > 0) continue;
|
|
684
1227
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1228
|
+
const resource = resourcesByKey.get(decremented.b);
|
|
1229
|
+
assertNonNullable(
|
|
1230
|
+
resource,
|
|
1231
|
+
"Resource must exist while its claim-resource pair is retained.",
|
|
1232
|
+
);
|
|
1233
|
+
if (notifyTransition) {
|
|
1234
|
+
notifyLastClaimRemoved(decremented.a, resource, decremented.b);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (!claimResourceRelation.hasB(decremented.b)) {
|
|
1238
|
+
// Delete before _resourceLeases disposes at function exit. While this
|
|
1239
|
+
// entry exists, its leases pin one resource generation.
|
|
1240
|
+
resourcesByKey.delete(decremented.b);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
688
1243
|
|
|
1244
|
+
return true;
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
const drainClaimLeases = (): void => {
|
|
1248
|
+
using disposer = new DisposableStack();
|
|
689
1249
|
disposer.defer(() => {
|
|
690
|
-
|
|
1250
|
+
claimResourceRelation.clear();
|
|
1251
|
+
resourcesByKey.clear();
|
|
691
1252
|
});
|
|
1253
|
+
for (const heldClaimLease of heldClaimLeases) {
|
|
1254
|
+
disposer.defer(() => {
|
|
1255
|
+
releaseClaimLease(heldClaimLease, false);
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
sharedResourceByKeyWithClaimsRun.defer(() => {
|
|
1261
|
+
leakDetector.untrack(sharedResourceByKeyWithClaimsHandle);
|
|
1262
|
+
});
|
|
1263
|
+
sharedResourceByKeyWithClaimsRun.defer(drainClaimLeases);
|
|
692
1264
|
|
|
693
|
-
|
|
1265
|
+
const sharedResourceByKey = sharedResourceByKeyDisposer.use(
|
|
1266
|
+
await sharedResourceByKeyWithClaimsRun.ok(
|
|
694
1267
|
createSharedResourceByKey(create, {
|
|
695
1268
|
idleDisposeAfter,
|
|
696
1269
|
lookup: resourceLookup,
|
|
697
1270
|
}),
|
|
698
|
-
)
|
|
699
|
-
|
|
700
|
-
const sharedResourcesByKey = disposer.use(
|
|
701
|
-
sharedResourcesByKeyResult.value,
|
|
702
|
-
);
|
|
703
|
-
|
|
704
|
-
// Register as the last so disposal aborts further calls first.
|
|
705
|
-
disposer.use(sharedResourceClaimsRun);
|
|
706
|
-
|
|
707
|
-
/** Asserts that one call does not contain the same logical key twice. */
|
|
708
|
-
const assertNoDuplicateResourceKeys = (
|
|
709
|
-
resourceKeys: ReadonlyArray<K>,
|
|
710
|
-
) => {
|
|
711
|
-
assert(
|
|
712
|
-
createLookupSet<K, LK>({
|
|
713
|
-
lookup: resourceLookup,
|
|
714
|
-
values: resourceKeys,
|
|
715
|
-
}).size === resourceKeys.length,
|
|
716
|
-
"resourceKeys must not contain lookup duplicates.",
|
|
717
|
-
);
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
const disposables = disposer.move();
|
|
721
|
-
|
|
722
|
-
return ok({
|
|
723
|
-
addClaim: (claim, resourceKeys) =>
|
|
724
|
-
unabortable<void, never, D>(() =>
|
|
725
|
-
sharedResourceClaimsRun(async (run) => {
|
|
726
|
-
assertNoDuplicateResourceKeys(resourceKeys);
|
|
727
|
-
|
|
728
|
-
for (const resourceKey of resourceKeys) {
|
|
729
|
-
const added = await run(
|
|
730
|
-
mutexByKey.withLock(resourceKey, async (run) => {
|
|
731
|
-
let pairRefCountByKey = pairRefCountsByClaim.get(claim);
|
|
732
|
-
if (!pairRefCountByKey) {
|
|
733
|
-
pairRefCountByKey = createRefCountByKey<K, LK>({
|
|
734
|
-
lookup: resourceLookup,
|
|
735
|
-
});
|
|
736
|
-
pairRefCountsByClaim.set(claim, pairRefCountByKey);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
if (pairRefCountByKey.has(resourceKey)) {
|
|
740
|
-
pairRefCountByKey.increment(resourceKey);
|
|
741
|
-
return ok();
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const hasClaimsForKey = keyByClaim.hasB(resourceKey);
|
|
745
|
-
let firstResource: BorrowedResource<T> | undefined;
|
|
746
|
-
if (!hasClaimsForKey) {
|
|
747
|
-
const resourceResult = await run(
|
|
748
|
-
sharedResourcesByKey.acquire(resourceKey),
|
|
749
|
-
);
|
|
750
|
-
assertNotAborted(resourceResult);
|
|
751
|
-
firstResource = resourceResult.value;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
assert(
|
|
755
|
-
keyByClaim.add(claim, resourceKey),
|
|
756
|
-
"Claim-resource relation must be absent before first retain.",
|
|
757
|
-
);
|
|
758
|
-
|
|
759
|
-
pairRefCountByKey.increment(resourceKey);
|
|
760
|
-
|
|
761
|
-
if (firstResource) {
|
|
762
|
-
onFirstClaimAdded?.(claim, firstResource, resourceKey);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
return ok();
|
|
766
|
-
}),
|
|
767
|
-
);
|
|
768
|
-
assertNotAborted(added);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
return ok();
|
|
772
|
-
}),
|
|
773
|
-
),
|
|
774
|
-
|
|
775
|
-
removeClaim: (claim, resourceKeys) =>
|
|
776
|
-
unabortable<void, never, D>(() =>
|
|
777
|
-
sharedResourceClaimsRun(async (run) => {
|
|
778
|
-
assertNoDuplicateResourceKeys(resourceKeys);
|
|
779
|
-
|
|
780
|
-
for (const resourceKey of resourceKeys) {
|
|
781
|
-
const removed = await run(
|
|
782
|
-
mutexByKey.withLock(resourceKey, async (run) => {
|
|
783
|
-
const pairRefCountByKey = pairRefCountsByClaim.get(claim);
|
|
784
|
-
|
|
785
|
-
assert(
|
|
786
|
-
pairRefCountByKey,
|
|
787
|
-
"Claim-resource pair must not be removed more times than added.",
|
|
788
|
-
);
|
|
789
|
-
|
|
790
|
-
if (pairRefCountByKey.decrement(resourceKey) > 0) {
|
|
791
|
-
return ok();
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
if (pairRefCountByKey.keys().size === 0) {
|
|
795
|
-
pairRefCountsByClaim.delete(claim);
|
|
796
|
-
pairRefCountByKey[Symbol.dispose]();
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
assert(
|
|
800
|
-
keyByClaim.remove(claim, resourceKey),
|
|
801
|
-
"Claim-resource relation must exist while its ref count is positive.",
|
|
802
|
-
);
|
|
803
|
-
|
|
804
|
-
if (keyByClaim.hasB(resourceKey)) {
|
|
805
|
-
return ok();
|
|
806
|
-
}
|
|
1271
|
+
),
|
|
1272
|
+
);
|
|
807
1273
|
|
|
808
|
-
|
|
809
|
-
assert(
|
|
810
|
-
resource,
|
|
811
|
-
"Resource must exist when the last claim is removed.",
|
|
812
|
-
);
|
|
1274
|
+
run.signal.throwIfAborted();
|
|
813
1275
|
|
|
814
|
-
|
|
1276
|
+
const disposables = disposer.move();
|
|
1277
|
+
const acquireClaim =
|
|
1278
|
+
<R>(
|
|
1279
|
+
claim: C,
|
|
1280
|
+
resourceKeys: NonEmptyReadonlyArray<K>,
|
|
1281
|
+
project: (
|
|
1282
|
+
claimLease: ClaimLease,
|
|
1283
|
+
resources: NonEmptyReadonlyArray<ClaimedResource>,
|
|
1284
|
+
) => R,
|
|
1285
|
+
): Task<R> =>
|
|
1286
|
+
() => {
|
|
1287
|
+
const resourceKeysSnapshot: NonEmptyReadonlyArray<K> = [
|
|
1288
|
+
resourceKeys[0],
|
|
1289
|
+
...resourceKeys.slice(1),
|
|
1290
|
+
];
|
|
1291
|
+
return sharedResourceByKeyWithClaimsRun(async (run) => {
|
|
1292
|
+
assert(
|
|
1293
|
+
createLookupSet<K, LK>({
|
|
1294
|
+
lookup: resourceLookup,
|
|
1295
|
+
values: resourceKeysSnapshot,
|
|
1296
|
+
}).size === resourceKeysSnapshot.length,
|
|
1297
|
+
"resourceKeys must not contain lookup duplicates.",
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
using resourceLeases = new DisposableStack();
|
|
1301
|
+
const [firstResourceKey, ...remainingResourceKeys] =
|
|
1302
|
+
resourceKeysSnapshot;
|
|
1303
|
+
// acquire releases a transferred lease before surfacing owner abort,
|
|
1304
|
+
// so a throwing await cannot leave ownership uncollected.
|
|
1305
|
+
const firstResourceLease = resourceLeases.use(
|
|
1306
|
+
await run.ok(sharedResourceByKey.acquire(firstResourceKey)),
|
|
1307
|
+
);
|
|
1308
|
+
const resources: NonEmptyArray<ClaimedResource> = [
|
|
1309
|
+
[firstResourceLease.resource, firstResourceKey],
|
|
1310
|
+
];
|
|
1311
|
+
const firstClaimTransitions: Array<
|
|
1312
|
+
readonly [C, BorrowedResource<T>, K]
|
|
1313
|
+
> = [];
|
|
1314
|
+
for (const resourceKey of remainingResourceKeys) {
|
|
1315
|
+
const resourceLease = resourceLeases.use(
|
|
1316
|
+
await run.ok(sharedResourceByKey.acquire(resourceKey)),
|
|
1317
|
+
);
|
|
1318
|
+
resources.push([resourceLease.resource, resourceKey]);
|
|
1319
|
+
}
|
|
1320
|
+
run.signal.throwIfAborted();
|
|
815
1321
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1322
|
+
for (const [resource, resourceKey] of resources) {
|
|
1323
|
+
const incremented = claimResourceRelation.increment(
|
|
1324
|
+
claim,
|
|
1325
|
+
resourceKey,
|
|
1326
|
+
);
|
|
1327
|
+
resourcesByKey.getOrInsert(incremented.b, resource);
|
|
1328
|
+
if (incremented.count === 1) {
|
|
1329
|
+
firstClaimTransitions.push([
|
|
1330
|
+
incremented.a,
|
|
1331
|
+
resource,
|
|
1332
|
+
incremented.b,
|
|
1333
|
+
]);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
820
1336
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1337
|
+
const heldClaimLease: HeldClaimLease = {
|
|
1338
|
+
claim,
|
|
1339
|
+
resourceKeys: resourceKeysSnapshot,
|
|
1340
|
+
resourceLeases: resourceLeases.move(),
|
|
1341
|
+
};
|
|
1342
|
+
heldClaimLeases.add(heldClaimLease);
|
|
1343
|
+
|
|
1344
|
+
const release = (): boolean => releaseClaimLease(heldClaimLease);
|
|
1345
|
+
const claimLease: ClaimLease = {
|
|
1346
|
+
release,
|
|
1347
|
+
[Symbol.dispose]: release,
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
leakDetector.track(
|
|
1351
|
+
claimLease,
|
|
1352
|
+
{
|
|
1353
|
+
name: "ClaimLease",
|
|
1354
|
+
isLeaked: () => heldClaimLeases.has(heldClaimLease),
|
|
1355
|
+
},
|
|
1356
|
+
heldClaimLease,
|
|
1357
|
+
);
|
|
1358
|
+
|
|
1359
|
+
try {
|
|
1360
|
+
let succeeded = false;
|
|
1361
|
+
using compensations = new DisposableStack();
|
|
1362
|
+
for (const [
|
|
1363
|
+
claim,
|
|
1364
|
+
resource,
|
|
1365
|
+
resourceKey,
|
|
1366
|
+
] of firstClaimTransitions) {
|
|
1367
|
+
onFirstClaimAdded?.(claim, resource, resourceKey);
|
|
1368
|
+
compensations.defer(() => {
|
|
1369
|
+
if (!succeeded) {
|
|
1370
|
+
notifyLastClaimRemoved(claim, resource, resourceKey);
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
succeeded = true;
|
|
1375
|
+
} catch (defect) {
|
|
1376
|
+
releaseClaimLease(heldClaimLease, false);
|
|
1377
|
+
throw defect;
|
|
1378
|
+
}
|
|
826
1379
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1380
|
+
return ok(project(claimLease, resources));
|
|
1381
|
+
});
|
|
1382
|
+
};
|
|
830
1383
|
|
|
831
|
-
|
|
1384
|
+
const sharedResourceByKeyWithClaims: SharedResourceByKeyWithClaims<
|
|
1385
|
+
K,
|
|
1386
|
+
C,
|
|
1387
|
+
T
|
|
1388
|
+
> = {
|
|
1389
|
+
claim: (claim, resourceKeys) =>
|
|
1390
|
+
acquireClaim(claim, resourceKeys, (claimLease) => claimLease),
|
|
1391
|
+
|
|
1392
|
+
use: (claim, resourceKeys, callback) => async (run) => {
|
|
1393
|
+
const acquired = await run.ok(
|
|
1394
|
+
acquireClaim(claim, resourceKeys, (claimLease, resources) => ({
|
|
1395
|
+
claimLease,
|
|
1396
|
+
resources,
|
|
1397
|
+
})),
|
|
1398
|
+
);
|
|
1399
|
+
using _claimLease = acquired.claimLease;
|
|
1400
|
+
return await run(callback(acquired.resources));
|
|
1401
|
+
},
|
|
832
1402
|
|
|
833
|
-
|
|
1403
|
+
getClaimsForResource: (resourceKey) =>
|
|
1404
|
+
new Set(claimResourceRelation.getAs(resourceKey)),
|
|
834
1405
|
|
|
835
|
-
|
|
1406
|
+
getResourceKeysForClaim: (claim) =>
|
|
1407
|
+
new Set(claimResourceRelation.getBs(claim)),
|
|
836
1408
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1409
|
+
forEachResourceForClaim: (claim, callback) => {
|
|
1410
|
+
const resources = claimResourceRelation
|
|
1411
|
+
.getBs(claim)
|
|
1412
|
+
.map((resourceKey) => {
|
|
1413
|
+
const resource = resourcesByKey.get(resourceKey);
|
|
1414
|
+
assertNonNullable(
|
|
842
1415
|
resource,
|
|
843
|
-
"Resource must exist for every
|
|
1416
|
+
"Resource must exist for every active claim-resource relation.",
|
|
844
1417
|
);
|
|
845
|
-
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
|
|
1418
|
+
return [resource, resourceKey] as const;
|
|
1419
|
+
});
|
|
1420
|
+
// Releasing the final ClaimLease from a callback only schedules inner
|
|
1421
|
+
// disposal: the mutex Task resumes after this synchronous frame, so all
|
|
1422
|
+
// snapshotted resources remain live through the loop.
|
|
1423
|
+
for (const [resource, resourceKey] of resources) {
|
|
1424
|
+
callback(resource, resourceKey);
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
849
1427
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1428
|
+
snapshot: () => {
|
|
1429
|
+
const retainCountsByResourceKeyByClaimSnapshot = new Map<
|
|
1430
|
+
C,
|
|
1431
|
+
Map<K, number>
|
|
1432
|
+
>();
|
|
1433
|
+
for (const [
|
|
1434
|
+
claim,
|
|
1435
|
+
resourceKey,
|
|
1436
|
+
count,
|
|
1437
|
+
] of claimResourceRelation.getEntries()) {
|
|
1438
|
+
retainCountsByResourceKeyByClaimSnapshot
|
|
1439
|
+
.getOrInsertComputed(claim, () => new Map())
|
|
1440
|
+
.set(resourceKey, count);
|
|
1441
|
+
}
|
|
855
1442
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1443
|
+
return {
|
|
1444
|
+
claimLeaseCount: NonNegativeInt.orThrow(heldClaimLeases.size),
|
|
1445
|
+
retainCountsByResourceKeyByClaim:
|
|
1446
|
+
retainCountsByResourceKeyByClaimSnapshot,
|
|
1447
|
+
resourcesByKey: sharedResourceByKey.snapshot().resourcesByKey,
|
|
1448
|
+
};
|
|
1449
|
+
},
|
|
860
1450
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1451
|
+
[Symbol.asyncDispose]: () => disposables.disposeAsync(),
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
leakDetector.track(
|
|
1455
|
+
sharedResourceByKeyWithClaims,
|
|
1456
|
+
{
|
|
1457
|
+
name: "SharedResourceByKeyWithClaims",
|
|
1458
|
+
isLeaked: () =>
|
|
1459
|
+
sharedResourceByKeyWithClaimsRun.getState().type === "Running",
|
|
1460
|
+
},
|
|
1461
|
+
sharedResourceByKeyWithClaimsHandle,
|
|
1462
|
+
);
|
|
1463
|
+
|
|
1464
|
+
return ok(sharedResourceByKeyWithClaims);
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
// TODO: Add a lease-based reloadable resource when a concrete use case needs
|
|
1468
|
+
// resource swapping. A ResourceRef-style get/set API is unsafe because set can
|
|
1469
|
+
// dispose a resource still held by a caller of get. Model resource generations
|
|
1470
|
+
// explicitly and distinguish two replacement policies, probably as separate
|
|
1471
|
+
// APIs rather than a boolean option:
|
|
1472
|
+
// - Overlapping: create and validate the replacement, publish it to new leases,
|
|
1473
|
+
// then dispose the old generation after its existing leases drain. This
|
|
1474
|
+
// supports zero-downtime reload and keeps the old generation when creation
|
|
1475
|
+
// fails, but both generations temporarily exist.
|
|
1476
|
+
// - Exclusive: stop or queue new leases, drain and dispose the old generation,
|
|
1477
|
+
// then create and publish the replacement. This guarantees at most one live
|
|
1478
|
+
// resource, but introduces downtime and leaves no valid resource when creation
|
|
1479
|
+
// fails unless the owner retries or recreates the old configuration.
|