@loomwork/event-runtime 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +95 -2
- package/dist/BranchRegistry.d.ts +140 -0
- package/dist/BranchRegistry.d.ts.map +1 -0
- package/dist/BranchRegistry.js +66 -0
- package/dist/BranchRegistry.js.map +1 -0
- package/dist/CoalescingEventStore.d.ts +97 -0
- package/dist/CoalescingEventStore.d.ts.map +1 -0
- package/dist/CoalescingEventStore.js +180 -0
- package/dist/CoalescingEventStore.js.map +1 -0
- package/dist/InMemoryArchiveStore.d.ts +19 -0
- package/dist/InMemoryArchiveStore.d.ts.map +1 -0
- package/dist/InMemoryArchiveStore.js +49 -0
- package/dist/InMemoryArchiveStore.js.map +1 -0
- package/dist/InMemoryAsyncEventStore.d.ts +111 -0
- package/dist/InMemoryAsyncEventStore.d.ts.map +1 -0
- package/dist/InMemoryAsyncEventStore.js +374 -0
- package/dist/InMemoryAsyncEventStore.js.map +1 -0
- package/dist/InMemoryBranchRegistry.d.ts +31 -0
- package/dist/InMemoryBranchRegistry.d.ts.map +1 -0
- package/dist/InMemoryBranchRegistry.js +167 -0
- package/dist/InMemoryBranchRegistry.js.map +1 -0
- package/dist/ProfilingEventStore.d.ts +75 -0
- package/dist/ProfilingEventStore.d.ts.map +1 -0
- package/dist/ProfilingEventStore.js +133 -0
- package/dist/ProfilingEventStore.js.map +1 -0
- package/dist/ShardPlan.d.ts +87 -0
- package/dist/ShardPlan.d.ts.map +1 -0
- package/dist/ShardPlan.js +193 -0
- package/dist/ShardPlan.js.map +1 -0
- package/dist/ShardedEventStore.d.ts +149 -0
- package/dist/ShardedEventStore.d.ts.map +1 -0
- package/dist/ShardedEventStore.js +468 -0
- package/dist/ShardedEventStore.js.map +1 -0
- package/dist/SubscriberRegistry.d.ts +59 -0
- package/dist/SubscriberRegistry.d.ts.map +1 -0
- package/dist/SubscriberRegistry.js +113 -0
- package/dist/SubscriberRegistry.js.map +1 -0
- package/dist/TenantScopedArchiveStore.d.ts +36 -0
- package/dist/TenantScopedArchiveStore.d.ts.map +1 -0
- package/dist/TenantScopedArchiveStore.js +96 -0
- package/dist/TenantScopedArchiveStore.js.map +1 -0
- package/dist/TenantScopedEventStore.d.ts +59 -0
- package/dist/TenantScopedEventStore.d.ts.map +1 -0
- package/dist/TenantScopedEventStore.js +227 -0
- package/dist/TenantScopedEventStore.js.map +1 -0
- package/dist/archive.d.ts +68 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +207 -0
- package/dist/archive.js.map +1 -0
- package/dist/archiveSpi.d.ts +165 -0
- package/dist/archiveSpi.d.ts.map +1 -0
- package/dist/archiveSpi.js +184 -0
- package/dist/archiveSpi.js.map +1 -0
- package/dist/creationMarker.d.ts +13 -0
- package/dist/creationMarker.d.ts.map +1 -0
- package/dist/creationMarker.js +17 -0
- package/dist/creationMarker.js.map +1 -0
- package/dist/decoratorPlan.typecheck.d.ts +2 -0
- package/dist/decoratorPlan.typecheck.d.ts.map +1 -0
- package/dist/decoratorPlan.typecheck.js +41 -0
- package/dist/decoratorPlan.typecheck.js.map +1 -0
- package/dist/eventKindClass.d.ts +40 -0
- package/dist/eventKindClass.d.ts.map +1 -0
- package/dist/eventKindClass.js +99 -0
- package/dist/eventKindClass.js.map +1 -0
- package/dist/executionIdOrder.d.ts +21 -0
- package/dist/executionIdOrder.d.ts.map +1 -0
- package/dist/executionIdOrder.js +43 -0
- package/dist/executionIdOrder.js.map +1 -0
- package/dist/fencing.d.ts +21 -0
- package/dist/fencing.d.ts.map +1 -0
- package/dist/fencing.js +36 -0
- package/dist/fencing.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/installOptional.d.ts +52 -0
- package/dist/installOptional.d.ts.map +1 -0
- package/dist/installOptional.js +112 -0
- package/dist/installOptional.js.map +1 -0
- package/dist/keyUnavailable.d.ts +67 -0
- package/dist/keyUnavailable.d.ts.map +1 -0
- package/dist/keyUnavailable.js +87 -0
- package/dist/keyUnavailable.js.map +1 -0
- package/dist/leases.d.ts +177 -0
- package/dist/leases.d.ts.map +1 -0
- package/dist/leases.js +142 -0
- package/dist/leases.js.map +1 -0
- package/dist/payloadShredded.d.ts +30 -0
- package/dist/payloadShredded.d.ts.map +1 -0
- package/dist/payloadShredded.js +67 -0
- package/dist/payloadShredded.js.map +1 -0
- package/dist/scaleDecorators.bench.d.ts +2 -0
- package/dist/scaleDecorators.bench.d.ts.map +1 -0
- package/dist/scaleDecorators.bench.js +93 -0
- package/dist/scaleDecorators.bench.js.map +1 -0
- package/dist/sequenceRange.bench.d.ts +2 -0
- package/dist/sequenceRange.bench.d.ts.map +1 -0
- package/dist/sequenceRange.bench.js +46 -0
- package/dist/sequenceRange.bench.js.map +1 -0
- package/dist/sequenceRange.d.ts +10 -0
- package/dist/sequenceRange.d.ts.map +1 -0
- package/dist/sequenceRange.js +35 -0
- package/dist/sequenceRange.js.map +1 -0
- package/dist/timers.d.ts +87 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +68 -0
- package/dist/timers.js.map +1 -0
- package/dist/types.d.ts +634 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +429 -0
- package/dist/types.js.map +1 -0
- package/dist/writeConflicts.d.ts +46 -0
- package/dist/writeConflicts.d.ts.map +1 -0
- package/dist/writeConflicts.js +63 -0
- package/dist/writeConflicts.js.map +1 -0
- package/memory.d.ts +4 -0
- package/memory.js +13 -0
- package/package.json +67 -3
- package/sequence.d.ts +1 -0
- package/sequence.js +5 -0
- package/src/BranchRegistry.ts +161 -0
- package/src/CoalescingEventStore.ts +214 -0
- package/src/InMemoryArchiveStore.ts +69 -0
- package/src/InMemoryAsyncEventStore.ts +425 -0
- package/src/InMemoryBranchRegistry.ts +178 -0
- package/src/ProfilingEventStore.ts +153 -0
- package/src/ShardPlan.ts +235 -0
- package/src/ShardedEventStore.ts +557 -0
- package/src/SubscriberRegistry.ts +115 -0
- package/src/TenantScopedArchiveStore.ts +114 -0
- package/src/TenantScopedEventStore.ts +266 -0
- package/src/archive.ts +248 -0
- package/src/archiveSpi.ts +308 -0
- package/src/creationMarker.ts +15 -0
- package/src/decoratorPlan.typecheck.ts +52 -0
- package/src/eventKindClass.ts +127 -0
- package/src/executionIdOrder.ts +38 -0
- package/src/fencing.ts +31 -0
- package/src/index.ts +151 -0
- package/src/installOptional.ts +136 -0
- package/src/keyUnavailable.ts +98 -0
- package/src/leases.ts +319 -0
- package/src/payloadShredded.ts +63 -0
- package/src/scaleDecorators.bench.ts +107 -0
- package/src/sequenceRange.bench.ts +51 -0
- package/src/sequenceRange.ts +35 -0
- package/src/timers.ts +150 -0
- package/src/types.ts +857 -0
- package/src/writeConflicts.ts +56 -0
package/src/leases.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// ── Durable ownership of a running execution (#380) ───────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A `running` execution owned by a healthy node is byte-identical, in the log, to one
|
|
4
|
+
// orphaned by a dead one: the difference is a fact about the cluster, not about the
|
|
5
|
+
// execution, so no fold can see it. That is why `recoverAbandoned` demands a predicate — and
|
|
6
|
+
// why a double dispatch today is merely SAFE rather than avoided. Fencing (K9.4) rejects the
|
|
7
|
+
// loser's write; both runs still executed, and both still paid for their effects.
|
|
8
|
+
//
|
|
9
|
+
// A lease is the missing fact: ownership with an expiry, written where both nodes can see it.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately shaped like `TimerStore`: a small store with a polling service above it, no
|
|
12
|
+
// coordination protocol, and nothing required of a deployment that does not want it. Unwired,
|
|
13
|
+
// every path behaves byte-identically (G2).
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SPI version for the {@link LeaseStore} contract. Pinned by `runLeaseStoreConformance`.
|
|
17
|
+
*
|
|
18
|
+
* 2.0.0 (L28): `expired`'s `now` became optional and MUST default to the store's stamping clock.
|
|
19
|
+
* MAJOR, not additive: a 1.x store written `expired(now: number)` still type-checks against the
|
|
20
|
+
* new signature but, handed `undefined`, returns `[]` — recovery silently recovers nothing.
|
|
21
|
+
*
|
|
22
|
+
* 2.1.0 (L29): optional `storeNow()` — the store's own clock. Additive: a store without it behaves
|
|
23
|
+
* exactly as before, and a caller with no `storeNow()` falls back to `Date.now()`.
|
|
24
|
+
*
|
|
25
|
+
* 2.2.0 (ADR 0130 D11, heartbeat leases): optional `scope` (who can see this store's leases:
|
|
26
|
+
* "process" | "host" | "cluster"; absent = unknown, treated as NOT cluster-visible), optional `acquireMany` / `renewMany` (returns the HELD
|
|
27
|
+
* set) / `releaseMany`, and the `steal` option on `acquire`. Additive: a store without them is
|
|
28
|
+
* driven through the single-id methods by the caller (correct, just slower). A store that claims
|
|
29
|
+
* 2.2.0 implements all three batch methods and the steal gate; one that claims 2.1.0 is not asked.
|
|
30
|
+
*/
|
|
31
|
+
export const LEASE_STORE_SPI_VERSION = "2.2.0";
|
|
32
|
+
|
|
33
|
+
export interface Lease {
|
|
34
|
+
executionId: string;
|
|
35
|
+
/** Who holds it. A node id, a pod name, a process uuid — the deployment's vocabulary. */
|
|
36
|
+
owner: string;
|
|
37
|
+
/**
|
|
38
|
+
* Absolute expiry (epoch ms), on the store's clock (the one `expired()` defaults to), not
|
|
39
|
+
* necessarily the caller's. Past this, the lease is collectable by anyone.
|
|
40
|
+
*/
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The proof a caller must hold to `steal` a lease: the fence it won with a CONDITIONAL fence
|
|
46
|
+
* acquisition (`acquireFenceIf`, i.e. proof the execution is parked). The store cannot verify it
|
|
47
|
+
* against the event log (the lease store is deliberately a separate, dumb SPI); it can only refuse
|
|
48
|
+
* a steal that does not even carry one, so a flag cannot be passed by accident.
|
|
49
|
+
*
|
|
50
|
+
* The proof is SHAPE-ONLY. Callers MUST pass a fence actually won via `acquireFenceIf` /
|
|
51
|
+
* `acquireFence` for THIS execution. A bogus steal is a liveness attack, never log corruption:
|
|
52
|
+
* the live owner's next `renew` fails (`LeaseLostError`), but the fence is the write guard
|
|
53
|
+
* (ADR 0130 D11 section 4), so the stale owner's appends are still rejected. An explicit `now`
|
|
54
|
+
* bypasses the expiry gate on every backend (pre-existing): production callers must never pass
|
|
55
|
+
* `now`, and on Postgres should omit it so the DATABASE clock rules.
|
|
56
|
+
*/
|
|
57
|
+
export interface StealProof {
|
|
58
|
+
/** The fence value the caller won: a positive safe integer (0 is the inert park token, ADR 0118). */
|
|
59
|
+
fence: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Options for {@link LeaseStore.acquire} / {@link LeaseStore.acquireMany} (SPI 2.2.0). */
|
|
63
|
+
export interface AcquireOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Take the lease even though ANOTHER owner holds it unexpired (a slow release left a stale row
|
|
66
|
+
* behind a driver that has already proved the execution is parked). Valid ONLY with a
|
|
67
|
+
* {@link StealProof}; anything else (`true`, `{}`, a negative or fractional fence) THROWS
|
|
68
|
+
* `TypeError` and changes nothing. After a release (no row), a steal is just an acquire. The
|
|
69
|
+
* displaced owner's next `renew`/`renewMany` reports it lost (`false` / absent).
|
|
70
|
+
*
|
|
71
|
+
* Single-id `acquire` ONLY: a fence belongs to one execution, so `acquireMany` REJECTS `steal`
|
|
72
|
+
* with `TypeError` (nothing changes).
|
|
73
|
+
*/
|
|
74
|
+
steal?: StealProof;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface LeaseStore {
|
|
78
|
+
/**
|
|
79
|
+
* OPTIONAL (SPI 2.2.0): who can observe this store's leases. `"process"`: one process (in-memory);
|
|
80
|
+
* `"host"`: processes on one machine (a local file); `"cluster"`: every node (a shared server).
|
|
81
|
+
* Absent means unknown and is treated as NOT cluster-visible. Heartbeat evidence ("a lease
|
|
82
|
+
* expired, so the driver is gone") holds only if every node reads the same table.
|
|
83
|
+
*/
|
|
84
|
+
readonly scope?: "process" | "host" | "cluster";
|
|
85
|
+
/**
|
|
86
|
+
* Take ownership, or report that someone else has it.
|
|
87
|
+
*
|
|
88
|
+
* Returns the lease on success and `null` when another owner holds an unexpired one — that
|
|
89
|
+
* single `null` is the mutual exclusion "only one node should run this execution" needs.
|
|
90
|
+
* Re-acquiring a lease you already hold is a renewal, not a conflict: a node that restarts
|
|
91
|
+
* its own work must not be locked out by its own previous claim.
|
|
92
|
+
*
|
|
93
|
+
* An EXPIRED lease is acquirable by anyone, which is the whole point — the previous owner
|
|
94
|
+
* stopped saying it was alive.
|
|
95
|
+
*/
|
|
96
|
+
acquire(
|
|
97
|
+
executionId: string,
|
|
98
|
+
owner: string,
|
|
99
|
+
ttlMs: number,
|
|
100
|
+
now?: number,
|
|
101
|
+
options?: AcquireOptions
|
|
102
|
+
): Promise<Lease | null>;
|
|
103
|
+
/**
|
|
104
|
+
* OPTIONAL (SPI 2.2.0): `acquire` for many executions in ONE round trip. Returns the leases
|
|
105
|
+
* granted; an id another owner holds unexpired is simply absent (the batch form of `null`).
|
|
106
|
+
* Duplicate ids count once. Per id the semantics are exactly {@link acquire}'s, and `now`
|
|
107
|
+
* applies to the whole batch. `options.steal` THROWS `TypeError` (steals go through `acquire`). Empty input returns `[]` without touching the store.
|
|
108
|
+
*/
|
|
109
|
+
acquireMany?(
|
|
110
|
+
executionIds: readonly string[],
|
|
111
|
+
owner: string,
|
|
112
|
+
ttlMs: number,
|
|
113
|
+
now?: number,
|
|
114
|
+
options?: AcquireOptions
|
|
115
|
+
): Promise<Lease[]>;
|
|
116
|
+
/**
|
|
117
|
+
* OPTIONAL (SPI 2.2.0): `renew` for many executions in one round trip. Returns the HELD set:
|
|
118
|
+
* the ids whose lease this `owner` still holds after the call (their expiry extended). Every
|
|
119
|
+
* other id, released, taken over, or never held, is absent, and nothing about it changes: a
|
|
120
|
+
* renew NEVER resurrects. The returned set is what a keeper diffs against its own to find the
|
|
121
|
+
* lost ids, so a store must not echo its input.
|
|
122
|
+
*/
|
|
123
|
+
renewMany?(
|
|
124
|
+
executionIds: readonly string[],
|
|
125
|
+
owner: string,
|
|
126
|
+
ttlMs: number,
|
|
127
|
+
now?: number
|
|
128
|
+
): Promise<string[]>;
|
|
129
|
+
/** OPTIONAL (SPI 2.2.0): `release` for many executions in one round trip; other owners' are untouched. */
|
|
130
|
+
releaseMany?(executionIds: readonly string[], owner: string): Promise<void>;
|
|
131
|
+
/**
|
|
132
|
+
* Extend a lease you hold. `false` means you no longer hold it — someone else took over
|
|
133
|
+
* after it expired — and a caller that keeps working on `false` is the split-brain this
|
|
134
|
+
* exists to surface. Never resurrects: renewing an expired lease that another owner has
|
|
135
|
+
* since taken fails rather than stealing it back.
|
|
136
|
+
*/
|
|
137
|
+
renew(executionId: string, owner: string, ttlMs: number, now?: number): Promise<boolean>;
|
|
138
|
+
/**
|
|
139
|
+
* Leases past their expiry, soonest-expired first. The evidence an execution was orphaned.
|
|
140
|
+
*
|
|
141
|
+
* `now` is OPTIONAL (SPI 2.0.0) and an implementation MUST default it to the store's OWN
|
|
142
|
+
* clock — the same one `acquire`/`renew` stamp `expiresAt` with. Expiry is only meaningful
|
|
143
|
+
* judged on the clock that stamped it: a caller passing wall-clock `Date.now()` to a store
|
|
144
|
+
* running on an injected/simulated clock sees every live lease as expired (a
|
|
145
|
+
* double-execution hazard). Callers with no reason to override the clock
|
|
146
|
+
* (`recoverAbandoned`) pass `undefined`. An implementation that still declares
|
|
147
|
+
* `expired(now: number)` compiles, but given `undefined` its comparison is false (`<=
|
|
148
|
+
* undefined`, SQL `<= NULL`) and it returns `[]`: recovery silently recovers nothing.
|
|
149
|
+
*
|
|
150
|
+
* The SQL stores' "own clock" is one clock for every node: SQLite is single-host, so the
|
|
151
|
+
* process clock is its one clock; Postgres stamps and judges on DATABASE time (L29), so node
|
|
152
|
+
* skew cannot make a live lease look expired or let a fast node steal one.
|
|
153
|
+
*/
|
|
154
|
+
expired(now?: number, limit?: number): Promise<Lease[]>;
|
|
155
|
+
/**
|
|
156
|
+
* OPTIONAL read: the lease on an execution, or `null` if nothing holds one.
|
|
157
|
+
*
|
|
158
|
+
* `expired()` answers "who stopped saying they were alive", which cannot distinguish an
|
|
159
|
+
* execution someone is actively renewing from one that never had a lease at all. That
|
|
160
|
+
* distinction is the whole question for `orphanCensus` (#408): a stuck execution with NO
|
|
161
|
+
* lease is a path nothing is watching, and counting those is how "should the runtime lease
|
|
162
|
+
* every run" stops being a guess.
|
|
163
|
+
*/
|
|
164
|
+
get?(executionId: string): Promise<Lease | null>;
|
|
165
|
+
/**
|
|
166
|
+
* OPTIONAL (SPI 2.1.0): the store's OWN clock, in epoch ms, the one `expiresAt` is stamped and
|
|
167
|
+
* `expired()` judged on. A reader that must compare `expiresAt` to "now" outside the store
|
|
168
|
+
* (`orphanCensus`) asks this rather than assuming its host's `Date.now()`, which for a shared
|
|
169
|
+
* database (Postgres stamps on DB time) is a different clock. Absent means the store uses the
|
|
170
|
+
* caller-visible process clock.
|
|
171
|
+
*/
|
|
172
|
+
storeNow?(): Promise<number>;
|
|
173
|
+
/** Give it up. Releasing a lease held by someone else is a no-op, never a steal. */
|
|
174
|
+
release(executionId: string, owner: string): Promise<void>;
|
|
175
|
+
close(): Promise<void>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface InMemoryLeaseStoreOptions {
|
|
179
|
+
/** Injectable clock, as everywhere else in the runtime. Default: `Date.now`. */
|
|
180
|
+
now?: () => number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Map-backed {@link LeaseStore} — the browser-safe / test reference implementation beside the
|
|
185
|
+
* durable backends. Behaviour is pinned by `runLeaseStoreConformance`.
|
|
186
|
+
*/
|
|
187
|
+
export class InMemoryLeaseStore implements LeaseStore {
|
|
188
|
+
readonly scope = "process" as const;
|
|
189
|
+
private readonly leases = new Map<string, Lease>();
|
|
190
|
+
private readonly now: () => number;
|
|
191
|
+
|
|
192
|
+
constructor(options: InMemoryLeaseStoreOptions = {}) {
|
|
193
|
+
this.now = options.now ?? (() => Date.now());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async acquire(
|
|
197
|
+
executionId: string,
|
|
198
|
+
owner: string,
|
|
199
|
+
ttlMs: number,
|
|
200
|
+
now = this.now(),
|
|
201
|
+
options?: AcquireOptions
|
|
202
|
+
): Promise<Lease | null> {
|
|
203
|
+
assertTtl(ttlMs);
|
|
204
|
+
const steal = assertSteal(options);
|
|
205
|
+
const held = this.leases.get(executionId);
|
|
206
|
+
// Someone else's, and still alive (unless the caller proved it may take it).
|
|
207
|
+
if (held !== undefined && held.owner !== owner && held.expiresAt > now && !steal) return null;
|
|
208
|
+
const lease: Lease = { executionId, owner, expiresAt: now + ttlMs };
|
|
209
|
+
this.leases.set(executionId, lease);
|
|
210
|
+
return { ...lease };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async acquireMany(
|
|
214
|
+
executionIds: readonly string[],
|
|
215
|
+
owner: string,
|
|
216
|
+
ttlMs: number,
|
|
217
|
+
now = this.now(),
|
|
218
|
+
options?: AcquireOptions
|
|
219
|
+
): Promise<Lease[]> {
|
|
220
|
+
assertTtl(ttlMs);
|
|
221
|
+
assertNoBatchSteal(options);
|
|
222
|
+
const out: Lease[] = [];
|
|
223
|
+
for (const id of new Set(executionIds)) {
|
|
224
|
+
const lease = await this.acquire(id, owner, ttlMs, now);
|
|
225
|
+
if (lease !== null) out.push(lease);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async renewMany(
|
|
231
|
+
executionIds: readonly string[],
|
|
232
|
+
owner: string,
|
|
233
|
+
ttlMs: number,
|
|
234
|
+
now = this.now()
|
|
235
|
+
): Promise<string[]> {
|
|
236
|
+
assertTtl(ttlMs);
|
|
237
|
+
const held: string[] = [];
|
|
238
|
+
for (const id of new Set(executionIds))
|
|
239
|
+
if (await this.renew(id, owner, ttlMs, now)) held.push(id);
|
|
240
|
+
return held;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async releaseMany(executionIds: readonly string[], owner: string): Promise<void> {
|
|
244
|
+
for (const id of new Set(executionIds)) await this.release(id, owner);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async renew(
|
|
248
|
+
executionId: string,
|
|
249
|
+
owner: string,
|
|
250
|
+
ttlMs: number,
|
|
251
|
+
now = this.now()
|
|
252
|
+
): Promise<boolean> {
|
|
253
|
+
assertTtl(ttlMs);
|
|
254
|
+
const held = this.leases.get(executionId);
|
|
255
|
+
if (held === undefined || held.owner !== owner) return false;
|
|
256
|
+
held.expiresAt = now + ttlMs;
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async expired(now = this.now(), limit = 100): Promise<Lease[]> {
|
|
261
|
+
return [...this.leases.values()]
|
|
262
|
+
.filter((l) => l.expiresAt <= now)
|
|
263
|
+
.sort((a, b) => a.expiresAt - b.expiresAt)
|
|
264
|
+
.slice(0, limit)
|
|
265
|
+
.map((l) => ({ ...l }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async get(executionId: string): Promise<Lease | null> {
|
|
269
|
+
const held = this.leases.get(executionId);
|
|
270
|
+
return held !== undefined ? { ...held } : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The store's own (injectable) clock: the one `expiresAt` is stamped and judged on (SPI 2.1.0). */
|
|
274
|
+
async storeNow(): Promise<number> {
|
|
275
|
+
return this.now();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async release(executionId: string, owner: string): Promise<void> {
|
|
279
|
+
const held = this.leases.get(executionId);
|
|
280
|
+
if (held?.owner === owner) this.leases.delete(executionId);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async close(): Promise<void> {
|
|
284
|
+
this.leases.clear();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A zero or negative TTL would make a lease expired on arrival — refuse it at every backend. */
|
|
289
|
+
export function assertTtl(ttlMs: number): void {
|
|
290
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
291
|
+
throw new RangeError(`lease ttlMs must be a positive number of milliseconds, got ${ttlMs}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The `steal` gate every backend applies BEFORE touching storage: `true` only for a real
|
|
297
|
+
* {@link StealProof}; `undefined` options or no `steal` is `false`; anything else THROWS
|
|
298
|
+
* `TypeError` (a flag is never a proof).
|
|
299
|
+
*/
|
|
300
|
+
export function assertSteal(options: AcquireOptions | undefined): boolean {
|
|
301
|
+
const steal = options?.steal as unknown;
|
|
302
|
+
if (steal === undefined) return false;
|
|
303
|
+
const fence = (steal as { fence?: unknown } | null)?.fence;
|
|
304
|
+
if (typeof fence !== "number" || !Number.isSafeInteger(fence) || fence < 1) {
|
|
305
|
+
throw new TypeError(
|
|
306
|
+
"lease steal requires a conditional-fence proof: { steal: { fence: <positive integer> } }"
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** `acquireMany` takes no `steal`: a fence belongs to one execution. THROWS `TypeError`, changing nothing. */
|
|
313
|
+
export function assertNoBatchSteal(options: AcquireOptions | undefined): void {
|
|
314
|
+
if ((options?.steal as unknown) !== undefined) {
|
|
315
|
+
throw new TypeError(
|
|
316
|
+
"acquireMany does not accept `steal`: a fence belongs to one execution; steal through acquire()"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The realm-safe brand of the erasure outcome (ADR 0039, amendment 2026-10-17 (c)). A crypto-shredded
|
|
3
|
+
* payload is recognised by THIS symbol, never by the error's `name` alone: any store decorator, third
|
|
4
|
+
* party store or app code can throw an `Error` that merely NAMES itself `PayloadShreddedError`, and on
|
|
5
|
+
* the authorization path "proven erasure" means "unknown owner", which `unknownOwner: "allow"` admits.
|
|
6
|
+
*
|
|
7
|
+
* `Symbol.for` (the global registry) makes the brand survive duplicated package copies (dual CJS/ESM
|
|
8
|
+
* builds, two installs), where `instanceof` does not. A store that cannot extend the crypto plugin's
|
|
9
|
+
* class sets the brand on its own error with {@link brandPayloadShredded}.
|
|
10
|
+
*/
|
|
11
|
+
export const PAYLOAD_SHREDDED_BRAND: unique symbol = Symbol.for("loom.payloadShredded") as never;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mark `err` as the erasure outcome (non-enumerable, so serialization and logging are unchanged) and
|
|
15
|
+
* return it. Idempotent. The error's `name` and message are untouched.
|
|
16
|
+
*
|
|
17
|
+
* Brand BEFORE freezing: a frozen or otherwise non-extensible object cannot take the brand, and
|
|
18
|
+
* throwing here would replace the erasure with a TypeError. For such an object this returns a branded
|
|
19
|
+
* WRAPPER instead (a new `Error` with the same name, message, own fields and stack, `cause` = the
|
|
20
|
+
* original), so the erasure is still recognised.
|
|
21
|
+
*/
|
|
22
|
+
export function brandPayloadShredded<T extends object>(err: T): T {
|
|
23
|
+
if (!Object.isExtensible(err)) {
|
|
24
|
+
const src = err as unknown as Record<string, unknown>;
|
|
25
|
+
const wrapper = new Error(typeof src.message === "string" ? src.message : "", { cause: err });
|
|
26
|
+
for (const key of Object.getOwnPropertyNames(src)) {
|
|
27
|
+
if (key === "cause") continue;
|
|
28
|
+
const d = Object.getOwnPropertyDescriptor(src, key);
|
|
29
|
+
if (d !== undefined) Object.defineProperty(wrapper, key, { ...d, configurable: true });
|
|
30
|
+
}
|
|
31
|
+
if (!Object.prototype.hasOwnProperty.call(src, "name") && typeof src.name === "string") {
|
|
32
|
+
Object.defineProperty(wrapper, "name", {
|
|
33
|
+
value: src.name,
|
|
34
|
+
configurable: true,
|
|
35
|
+
writable: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return brandPayloadShredded(wrapper) as unknown as T;
|
|
39
|
+
}
|
|
40
|
+
Object.defineProperty(err, PAYLOAD_SHREDDED_BRAND, {
|
|
41
|
+
value: true,
|
|
42
|
+
enumerable: false,
|
|
43
|
+
configurable: true,
|
|
44
|
+
writable: false,
|
|
45
|
+
});
|
|
46
|
+
return err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when `err` carries the {@link PAYLOAD_SHREDDED_BRAND} as an OWN property (an inherited brand,
|
|
51
|
+
* e.g. from a shared prototype, does not count). The ONE definition every consumer uses.
|
|
52
|
+
* A plain object or `Error` that only has `name === "PayloadShreddedError"` is NOT shredded, and a
|
|
53
|
+
* value that crossed a process boundary (JSON) has lost the brand: such a boundary must re-establish
|
|
54
|
+
* trust explicitly (e.g. by checking the log's own `PAYLOAD_SHREDDED` tombstone).
|
|
55
|
+
*/
|
|
56
|
+
export function isPayloadShredded(err: unknown): boolean {
|
|
57
|
+
return (
|
|
58
|
+
typeof err === "object" &&
|
|
59
|
+
err !== null &&
|
|
60
|
+
Object.prototype.hasOwnProperty.call(err, PAYLOAD_SHREDDED_BRAND) &&
|
|
61
|
+
(err as Record<symbol, unknown>)[PAYLOAD_SHREDDED_BRAND] === true
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
import { bench, describe } from "vitest";
|
|
3
|
+
import { CoalescingEventStore } from "./CoalescingEventStore";
|
|
4
|
+
import { InMemoryAsyncEventStore } from "./InMemoryAsyncEventStore";
|
|
5
|
+
import { ShardedEventStore } from "./ShardedEventStore";
|
|
6
|
+
import type { EventStore } from "./types";
|
|
7
|
+
|
|
8
|
+
// ── What the scale-out decorators cost and buy (ADR 0074) ────────────────────
|
|
9
|
+
//
|
|
10
|
+
// `scaleDecorators.test.ts` already proves round-trips are SAVED — it counts them. What a
|
|
11
|
+
// count cannot say is what that is worth, because the answer depends entirely on what one
|
|
12
|
+
// round-trip costs: over a free in-memory store, coalescing is pure overhead, and over a
|
|
13
|
+
// network backend it is the difference between 18k and 78k events/sec (`docs/benchmarks.md`,
|
|
14
|
+
// measured against real Postgres).
|
|
15
|
+
//
|
|
16
|
+
// So this bench prices a round-trip EXPLICITLY, spinning rather than sleeping — a timer
|
|
17
|
+
// would put scheduler granularity into a measurement whose whole point is the number of
|
|
18
|
+
// calls. `ROUND_TRIP_US` is the one assumption; every result below is a function of it, and
|
|
19
|
+
// a reader who thinks their backend is cheaper or dearer can move it and re-run.
|
|
20
|
+
//
|
|
21
|
+
// The second half is the honest counterweight: what the decorators cost when there is
|
|
22
|
+
// nothing to save. A decorator that is free on a free backend is a decorator that can be
|
|
23
|
+
// left in place.
|
|
24
|
+
|
|
25
|
+
const ROUND_TRIP_US = 20;
|
|
26
|
+
|
|
27
|
+
const evt = (executionId: string, sequence: number): EventEnvelope => ({
|
|
28
|
+
id: `${executionId}-${sequence}`,
|
|
29
|
+
workflowId: "wf",
|
|
30
|
+
executionId,
|
|
31
|
+
eventType: "STEP_COMPLETED",
|
|
32
|
+
sequence,
|
|
33
|
+
timestamp: 1000 + sequence,
|
|
34
|
+
payload: { sequence },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Burn `us` microseconds of CPU. Deterministic; no timer, no scheduler. */
|
|
38
|
+
function spin(us: number): void {
|
|
39
|
+
const until = performance.now() + us / 1000;
|
|
40
|
+
while (performance.now() < until) {
|
|
41
|
+
/* deliberately empty */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A backend that charges `ROUND_TRIP_US` per CALL, not per envelope — which is what a
|
|
47
|
+
* network backend does, and the entire reason batching exists. It keeps nothing: the
|
|
48
|
+
* measurement is the round-trips, not the storage.
|
|
49
|
+
*/
|
|
50
|
+
function pricedStore(): EventStore {
|
|
51
|
+
return {
|
|
52
|
+
append: async () => {
|
|
53
|
+
spin(ROUND_TRIP_US);
|
|
54
|
+
},
|
|
55
|
+
appendBatch: async () => {
|
|
56
|
+
spin(ROUND_TRIP_US);
|
|
57
|
+
},
|
|
58
|
+
read: async () => [],
|
|
59
|
+
readFrom: async () => [],
|
|
60
|
+
subscribe: async () => () => {},
|
|
61
|
+
close: async () => {},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One envelope from each of N distinct executions, appended concurrently — the real shape:
|
|
66
|
+
* N journals, each holding a single pending envelope, all flushing into one turn. */
|
|
67
|
+
const N = 512;
|
|
68
|
+
const wave = Array.from({ length: N }, (_, i) => evt(`x${i}`, 0));
|
|
69
|
+
|
|
70
|
+
async function appendAll(store: EventStore): Promise<void> {
|
|
71
|
+
await Promise.all(wave.map((e) => store.append(e)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe(`${N} cross-execution appends, one round-trip priced at ${ROUND_TRIP_US}µs`, () => {
|
|
75
|
+
bench("per-event append (no decorator)", async () => {
|
|
76
|
+
await appendAll(pricedStore());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
bench("CoalescingEventStore (default maxBatch: 16)", async () => {
|
|
80
|
+
await appendAll(new CoalescingEventStore(pricedStore(), { maxBatch: 16 }));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
bench("CoalescingEventStore (maxBatch: 64)", async () => {
|
|
84
|
+
await appendAll(new CoalescingEventStore(pricedStore(), { maxBatch: 64 }));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe(`${N} cross-execution appends against a FREE backend (what the decorators cost)`, () => {
|
|
89
|
+
// The counterweight. Over an in-memory store there are no round-trips worth saving, so
|
|
90
|
+
// these numbers are the price of leaving a decorator wired where it buys nothing.
|
|
91
|
+
bench("InMemoryAsyncEventStore (no decorator)", async () => {
|
|
92
|
+
await appendAll(new InMemoryAsyncEventStore());
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
bench("CoalescingEventStore over in-memory", async () => {
|
|
96
|
+
await appendAll(new CoalescingEventStore(new InMemoryAsyncEventStore(), { maxBatch: 16 }));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
bench("ShardedEventStore over 4 in-memory shards", async () => {
|
|
100
|
+
// Routing only — hash the id, pick a shard, delegate. What sharding actually buys is
|
|
101
|
+
// parallelism across backends, which a single-process bench cannot show and this
|
|
102
|
+
// deliberately does not claim.
|
|
103
|
+
await appendAll(
|
|
104
|
+
new ShardedEventStore(Array.from({ length: 4 }, () => new InMemoryAsyncEventStore()))
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
import { bench, describe } from "vitest";
|
|
3
|
+
import { InMemoryAsyncEventStore } from "./InMemoryAsyncEventStore";
|
|
4
|
+
import { sliceBySequence } from "./sequenceRange";
|
|
5
|
+
|
|
6
|
+
// The fork/materialize hot path: extract a bounded [from..to] window from a long,
|
|
7
|
+
// sequence-sorted log. The baselines are the previous O(n) shapes (a `.filter`, and
|
|
8
|
+
// read()+sort()+filter) so the binary-search win is visible on a large history.
|
|
9
|
+
|
|
10
|
+
function ev(sequence: number): EventEnvelope {
|
|
11
|
+
return {
|
|
12
|
+
id: `e-${sequence}`,
|
|
13
|
+
workflowId: "wf",
|
|
14
|
+
executionId: "x",
|
|
15
|
+
eventType: "STEP_STARTED",
|
|
16
|
+
sequence,
|
|
17
|
+
timestamp: sequence,
|
|
18
|
+
payload: {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const N = 100_000;
|
|
23
|
+
const events = Array.from({ length: N }, (_, i) => ev(i));
|
|
24
|
+
// Fork early in the history — the pathological case for a full scan.
|
|
25
|
+
const FROM = 100;
|
|
26
|
+
const TO = 200;
|
|
27
|
+
|
|
28
|
+
describe("bounded range over a 100k-event log", () => {
|
|
29
|
+
bench("filter baseline (previous shape)", () => {
|
|
30
|
+
events.filter((e) => e.sequence >= FROM && e.sequence <= TO);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
bench("sliceBySequence (binary search)", () => {
|
|
34
|
+
sliceBySequence(events, FROM, TO);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("InMemoryAsyncEventStore range read (100k events)", () => {
|
|
39
|
+
const store = new InMemoryAsyncEventStore();
|
|
40
|
+
// Prime the store once outside the measured loop.
|
|
41
|
+
for (let i = 0; i < N; i++) void store.append(ev(i));
|
|
42
|
+
|
|
43
|
+
bench("read()+sort()+filter baseline (previous shape)", async () => {
|
|
44
|
+
const all = [...(await store.read("x"))].sort((a, b) => a.sequence - b.sequence);
|
|
45
|
+
all.filter((e) => e.sequence >= FROM && e.sequence <= TO);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
bench("readRange (binary search, no sort)", async () => {
|
|
49
|
+
await store.readRange("x", FROM, TO);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
// Binary-search helpers over an event array that is sorted ascending by
|
|
4
|
+
// `sequence` — the invariant every EventStore preserves (the per-execution
|
|
5
|
+
// sequencer assigns strictly increasing sequences, and `copyPrefix` copies in
|
|
6
|
+
// order). They turn range reads on an in-memory log from O(n) filters into
|
|
7
|
+
// O(log n + k) slices, and back the workflow-runtime fork/materialize path.
|
|
8
|
+
|
|
9
|
+
/** First index whose event.sequence >= `seq` (i.e. events.length if none). */
|
|
10
|
+
export function lowerBoundBySequence(events: readonly EventEnvelope[], seq: number): number {
|
|
11
|
+
let lo = 0;
|
|
12
|
+
let hi = events.length;
|
|
13
|
+
while (lo < hi) {
|
|
14
|
+
const mid = (lo + hi) >> 1;
|
|
15
|
+
if ((events[mid] as EventEnvelope).sequence < seq) lo = mid + 1;
|
|
16
|
+
else hi = mid;
|
|
17
|
+
}
|
|
18
|
+
return lo;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Events with `fromSequence <= sequence <= toSequence`, assuming `events` is
|
|
23
|
+
* sorted ascending by sequence. O(log n) to locate the bounds + O(k) to copy the
|
|
24
|
+
* k in-range events — no full scan.
|
|
25
|
+
*/
|
|
26
|
+
export function sliceBySequence(
|
|
27
|
+
events: readonly EventEnvelope[],
|
|
28
|
+
fromSequence: number,
|
|
29
|
+
toSequence: number
|
|
30
|
+
): EventEnvelope[] {
|
|
31
|
+
if (toSequence < fromSequence) return [];
|
|
32
|
+
const start = lowerBoundBySequence(events, fromSequence);
|
|
33
|
+
const end = lowerBoundBySequence(events, toSequence + 1);
|
|
34
|
+
return events.slice(start, end);
|
|
35
|
+
}
|