@loomwork/projection-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 +64 -2
- package/dist/InMemoryProjectionRuntime.d.ts +70 -0
- package/dist/InMemoryProjectionRuntime.d.ts.map +1 -0
- package/dist/InMemoryProjectionRuntime.js +305 -0
- package/dist/InMemoryProjectionRuntime.js.map +1 -0
- package/dist/esm/InMemoryProjectionRuntime.js +301 -0
- package/dist/esm/InMemoryProjectionRuntime.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -3
- package/src/InMemoryProjectionRuntime.ts +384 -0
- package/src/index.ts +5 -0
- package/src/projectionStorm.bench.ts +146 -0
- package/src/types.ts +73 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
import type { Projection, ProjectionFoldMap, ProjectionRuntime } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The runtime's normalized view of a registered projection. An eager projection has its
|
|
6
|
+
* `handlers` resolved up front; a lazy one gates loading on its `eventTypes` and buffers
|
|
7
|
+
* matching events until its fold code is `import()`-ed (event-type-gated on-demand loading).
|
|
8
|
+
*/
|
|
9
|
+
interface ActiveProjection {
|
|
10
|
+
id: string;
|
|
11
|
+
/** ADR 0095 — the declared semantics of the fold, as given at registration. */
|
|
12
|
+
version?: string;
|
|
13
|
+
initialState: unknown;
|
|
14
|
+
handlers?: ProjectionFoldMap<unknown>;
|
|
15
|
+
// Lazy state — cleared to undefined once resolved into `handlers`.
|
|
16
|
+
eventTypes?: Set<string> | undefined;
|
|
17
|
+
load?: () => Promise<ProjectionFoldMap<unknown>>;
|
|
18
|
+
loading: boolean;
|
|
19
|
+
buffer: EventEnvelope[];
|
|
20
|
+
// Set once a load rejects permanently (a missing/broken chunk is not transient): the
|
|
21
|
+
// projection is abandoned so it can't leak an unbounded buffer or retry-storm the import.
|
|
22
|
+
loadFailed?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cap on how many matching events a lazy projection buffers while its fold code loads. A local
|
|
27
|
+
* `import()` resolves in well under this window; exceeding it means the load is stuck or broken,
|
|
28
|
+
* so we stop buffering (the projection is a read-model, rebuildable from the log) rather than
|
|
29
|
+
* grow memory without bound. Generous so normal bursts never hit it.
|
|
30
|
+
*/
|
|
31
|
+
const LAZY_BUFFER_LIMIT = 10_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How many least-recently-touched executions eviction will step over looking for an
|
|
35
|
+
* unsubscribed one. Bounds the per-event cost of `touch`; see its comment for why giving up
|
|
36
|
+
* (and sitting one entry over the cap) is the right failure direction.
|
|
37
|
+
*/
|
|
38
|
+
const EVICTION_SCAN_LIMIT = 64;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A registered selector-subscription (see {@link InMemoryProjectionRuntime.subscribeSelector}).
|
|
42
|
+
* `last` holds the previously-seen slice so we can fire only on change; it is seeded from
|
|
43
|
+
* current state at subscribe time (change-only semantics — no initial call).
|
|
44
|
+
*/
|
|
45
|
+
interface SelectorEntry {
|
|
46
|
+
selector: (state: unknown) => unknown;
|
|
47
|
+
isEqual: (a: unknown, b: unknown) => boolean;
|
|
48
|
+
handler: (slice: unknown) => void;
|
|
49
|
+
last: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface InMemoryProjectionRuntimeConfig {
|
|
53
|
+
/**
|
|
54
|
+
* Coalesce subscriber notifications. When true, instead of notifying
|
|
55
|
+
* synchronously on every event, dirty keys are collected and flushed once per
|
|
56
|
+
* microtask with each projection's LATEST state. A streaming burst of N events
|
|
57
|
+
* (e.g. one LLM token per event) collapses from N subscriber calls to 1 per
|
|
58
|
+
* affected key, cutting downstream re-renders without changing final state.
|
|
59
|
+
*
|
|
60
|
+
* Off by default: notifications stay synchronous, preserving exact current
|
|
61
|
+
* semantics and adding zero overhead unless explicitly enabled.
|
|
62
|
+
*/
|
|
63
|
+
coalesceNotifications?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Bound how many distinct executions' projection states are retained (LRU by last touch).
|
|
66
|
+
* Prevents unbounded growth in a long-lived server: an execution evicted here reverts to
|
|
67
|
+
* `initialState` on the next `getState` (graceful). Default 10_000; set higher/lower to taste.
|
|
68
|
+
*/
|
|
69
|
+
maxExecutions?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class InMemoryProjectionRuntime implements ProjectionRuntime {
|
|
73
|
+
private readonly projections = new Map<string, ActiveProjection>();
|
|
74
|
+
private readonly states = new Map<string, unknown>();
|
|
75
|
+
private readonly subscribers = new Map<string, Set<(state: unknown) => void>>();
|
|
76
|
+
// Selector-subscribers, keyed like `subscribers`. Kept separate so whole-state
|
|
77
|
+
// subscription stays a zero-overhead fast path when no selector-subscribers exist.
|
|
78
|
+
private readonly selectorSubscribers = new Map<string, Set<SelectorEntry>>();
|
|
79
|
+
|
|
80
|
+
private readonly coalesce: boolean;
|
|
81
|
+
private readonly dirtyKeys = new Set<string>();
|
|
82
|
+
private flushScheduled = false;
|
|
83
|
+
|
|
84
|
+
// Insertion order = LRU order over distinct executions, for the retention bound.
|
|
85
|
+
private readonly maxExecutions: number;
|
|
86
|
+
private readonly execOrder = new Map<string, true>();
|
|
87
|
+
|
|
88
|
+
constructor(config?: InMemoryProjectionRuntimeConfig) {
|
|
89
|
+
this.coalesce = config?.coalesceNotifications ?? false;
|
|
90
|
+
this.maxExecutions = config?.maxExecutions ?? 10_000;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Drop all retained state + subscribers for an execution (e.g. after it completes). */
|
|
94
|
+
forget(executionId: string): void {
|
|
95
|
+
for (const projectionId of this.projections.keys()) {
|
|
96
|
+
const key = this.stateKey(projectionId, executionId);
|
|
97
|
+
this.states.delete(key);
|
|
98
|
+
this.subscribers.delete(key);
|
|
99
|
+
this.selectorSubscribers.delete(key);
|
|
100
|
+
}
|
|
101
|
+
this.execOrder.delete(executionId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True if anything is currently subscribed to any projection of this execution. */
|
|
105
|
+
private hasSubscribers(executionId: string): boolean {
|
|
106
|
+
for (const projectionId of this.projections.keys()) {
|
|
107
|
+
const key = this.stateKey(projectionId, executionId);
|
|
108
|
+
if (this.subscribers.has(key) || this.selectorSubscribers.has(key)) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Drop an execution's retained STATE only — never its subscriber registries.
|
|
115
|
+
*
|
|
116
|
+
* The distinction is the whole point. `forget` is an explicit "this execution is done"
|
|
117
|
+
* from a caller who knows it; eviction is a cache decision the caller never made, and
|
|
118
|
+
* dropping a live subscriber's registry there does not degrade — it disconnects. The
|
|
119
|
+
* handler set is captured by closure in `subscribe`, so once the map entry is gone
|
|
120
|
+
* `notify` looks up a key that no longer exists and that subscriber never fires again.
|
|
121
|
+
*/
|
|
122
|
+
private evictState(executionId: string): void {
|
|
123
|
+
for (const projectionId of this.projections.keys()) {
|
|
124
|
+
this.states.delete(this.stateKey(projectionId, executionId));
|
|
125
|
+
}
|
|
126
|
+
this.execOrder.delete(executionId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private touch(executionId: string): void {
|
|
130
|
+
this.execOrder.delete(executionId);
|
|
131
|
+
this.execOrder.set(executionId, true);
|
|
132
|
+
if (this.execOrder.size <= this.maxExecutions) return;
|
|
133
|
+
// Evict least-recently-touched first, but SKIP anything with a live subscriber:
|
|
134
|
+
// evicting a state someone is folding into resets it to `initialState`, which is
|
|
135
|
+
// corruption rather than a cache miss (the same rule `AgentSurfaceRuntime` follows).
|
|
136
|
+
//
|
|
137
|
+
// The scan is bounded rather than exhaustive: if the oldest entries are all subscribed,
|
|
138
|
+
// giving up costs one over-cap entry, while scanning on would make `touch` — which runs
|
|
139
|
+
// once per event — O(retained). Over the cap is the safe direction; `forget` is the
|
|
140
|
+
// explicit remedy.
|
|
141
|
+
let examined = 0;
|
|
142
|
+
for (const candidate of this.execOrder.keys()) {
|
|
143
|
+
if (examined++ >= EVICTION_SCAN_LIMIT) return;
|
|
144
|
+
if (this.hasSubscribers(candidate)) continue;
|
|
145
|
+
this.evictState(candidate);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private stateKey(projectionId: string, executionId: string): string {
|
|
151
|
+
return `${projectionId}::${executionId}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
register<TState>(projection: Projection<TState>): void {
|
|
155
|
+
const active: ActiveProjection = {
|
|
156
|
+
id: projection.id,
|
|
157
|
+
initialState: projection.initialState,
|
|
158
|
+
...(projection.version !== undefined && { version: projection.version }),
|
|
159
|
+
loading: false,
|
|
160
|
+
buffer: [],
|
|
161
|
+
...(projection.handlers !== undefined && {
|
|
162
|
+
handlers: projection.handlers as ProjectionFoldMap<unknown>,
|
|
163
|
+
}),
|
|
164
|
+
...(projection.lazy !== undefined && {
|
|
165
|
+
eventTypes: new Set(projection.lazy.eventTypes),
|
|
166
|
+
load: projection.lazy.load as () => Promise<ProjectionFoldMap<unknown>>,
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
this.projections.set(projection.id, active);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
getState<TState>(projectionId: string, executionId: string): TState {
|
|
173
|
+
const key = this.stateKey(projectionId, executionId);
|
|
174
|
+
if (this.states.has(key)) {
|
|
175
|
+
return this.states.get(key) as TState;
|
|
176
|
+
}
|
|
177
|
+
const projection = this.projections.get(projectionId);
|
|
178
|
+
if (!projection) {
|
|
179
|
+
throw new Error(`Projection "${projectionId}" is not registered`);
|
|
180
|
+
}
|
|
181
|
+
return projection.initialState as TState;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
subscribe<TState>(
|
|
185
|
+
projectionId: string,
|
|
186
|
+
executionId: string,
|
|
187
|
+
handler: (state: TState) => void
|
|
188
|
+
): () => void {
|
|
189
|
+
const key = this.stateKey(projectionId, executionId);
|
|
190
|
+
if (!this.subscribers.has(key)) {
|
|
191
|
+
this.subscribers.set(key, new Set());
|
|
192
|
+
}
|
|
193
|
+
const handlers = this.subscribers.get(key)!;
|
|
194
|
+
const wrapper = (s: unknown) => handler(s as TState);
|
|
195
|
+
handlers.add(wrapper);
|
|
196
|
+
return () => {
|
|
197
|
+
handlers.delete(wrapper);
|
|
198
|
+
// Only retire the map entry if it is still OUR set. After an explicit `forget`, a later
|
|
199
|
+
// subscriber installs a fresh set under the same key; without this identity check a
|
|
200
|
+
// stale unsubscribe would empty its own orphaned set and then delete the live one,
|
|
201
|
+
// silently disconnecting a subscriber that did nothing wrong.
|
|
202
|
+
if (handlers.size === 0 && this.subscribers.get(key) === handlers) {
|
|
203
|
+
this.subscribers.delete(key);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
subscribeSelector<TState, TSlice>(
|
|
209
|
+
projectionId: string,
|
|
210
|
+
executionId: string,
|
|
211
|
+
selector: (state: TState) => TSlice,
|
|
212
|
+
handler: (slice: TSlice) => void,
|
|
213
|
+
isEqual: (a: TSlice, b: TSlice) => boolean = Object.is
|
|
214
|
+
): () => void {
|
|
215
|
+
const key = this.stateKey(projectionId, executionId);
|
|
216
|
+
let set = this.selectorSubscribers.get(key);
|
|
217
|
+
if (!set) {
|
|
218
|
+
set = new Set();
|
|
219
|
+
this.selectorSubscribers.set(key, set);
|
|
220
|
+
}
|
|
221
|
+
const sel = selector as (state: unknown) => unknown;
|
|
222
|
+
const entry: SelectorEntry = {
|
|
223
|
+
selector: sel,
|
|
224
|
+
isEqual: isEqual as (a: unknown, b: unknown) => boolean,
|
|
225
|
+
handler: handler as (slice: unknown) => void,
|
|
226
|
+
// Seed from current state so change-only holds from the first event on.
|
|
227
|
+
last: sel(this.peekState(projectionId, executionId)),
|
|
228
|
+
};
|
|
229
|
+
set.add(entry);
|
|
230
|
+
return () => {
|
|
231
|
+
set!.delete(entry);
|
|
232
|
+
// Same identity check as `subscribe` — never retire a set that is no longer ours.
|
|
233
|
+
if (set!.size === 0 && this.selectorSubscribers.get(key) === set) {
|
|
234
|
+
this.selectorSubscribers.delete(key);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Current state without throwing for an unregistered/unseen projection (unlike
|
|
240
|
+
// getState) — used to seed a selector-subscription's `last`. resolve-by-path
|
|
241
|
+
// selectors are null-safe, so an absent state seeds cleanly to `undefined`.
|
|
242
|
+
private peekState(projectionId: string, executionId: string): unknown {
|
|
243
|
+
const key = this.stateKey(projectionId, executionId);
|
|
244
|
+
if (this.states.has(key)) return this.states.get(key);
|
|
245
|
+
return this.projections.get(projectionId)?.initialState;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
handleEvent(event: EventEnvelope): void {
|
|
249
|
+
for (const [, projection] of this.projections) {
|
|
250
|
+
if (projection.handlers !== undefined) {
|
|
251
|
+
// Eager (or an already-loaded lazy) projection — fold synchronously as before.
|
|
252
|
+
this.apply(projection, projection.handlers, event);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// Lazy projection: gate on the compiler-recorded event types WITHOUT loading the
|
|
256
|
+
// fold code. A non-matching event never touches this projection (0 code loaded).
|
|
257
|
+
// A projection whose load already failed permanently is skipped (no buffer, no retry).
|
|
258
|
+
if (projection.loadFailed === true) continue;
|
|
259
|
+
if (projection.eventTypes?.has(event.eventType) !== true) continue;
|
|
260
|
+
|
|
261
|
+
// First matching event: buffer it (and any that arrive during load), kick the
|
|
262
|
+
// one-time import, and drain the buffer in arrival order once the code is ready.
|
|
263
|
+
// Bounded so a stuck/broken load can't grow the buffer without limit.
|
|
264
|
+
if (projection.buffer.length < LAZY_BUFFER_LIMIT) projection.buffer.push(event);
|
|
265
|
+
if (!projection.loading) {
|
|
266
|
+
projection.loading = true;
|
|
267
|
+
projection.load!()
|
|
268
|
+
.then((handlers) => {
|
|
269
|
+
projection.handlers = handlers;
|
|
270
|
+
projection.eventTypes = undefined; // resolved — the eager path takes over
|
|
271
|
+
projection.loading = false;
|
|
272
|
+
const buffered = projection.buffer;
|
|
273
|
+
projection.buffer = [];
|
|
274
|
+
for (const buf of buffered) this.apply(projection, handlers, buf);
|
|
275
|
+
})
|
|
276
|
+
.catch((err) => {
|
|
277
|
+
// A chunk load failure is not transient (missing/broken module). Abandon the
|
|
278
|
+
// projection: free the buffer and stop retrying, rather than leak memory and
|
|
279
|
+
// re-import on every matching event. The read-model can be rebuilt from the log.
|
|
280
|
+
projection.loading = false;
|
|
281
|
+
projection.loadFailed = true;
|
|
282
|
+
projection.buffer = [];
|
|
283
|
+
console.warn(
|
|
284
|
+
`[loom] projection "${projection.id}" failed to load its fold code — it will not update: ${String(err)}`
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
this.touch(event.executionId);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Fold one event into a projection's state via the matching handler, then notify. */
|
|
293
|
+
private apply(
|
|
294
|
+
projection: ActiveProjection,
|
|
295
|
+
handlers: ProjectionFoldMap<unknown>,
|
|
296
|
+
event: EventEnvelope
|
|
297
|
+
): void {
|
|
298
|
+
// Own-key lookup: the fold map is user-authored and the event type is caller-controlled,
|
|
299
|
+
// so `handlers["__proto__"]` would resolve to `Object.prototype` — truthy, defeating the
|
|
300
|
+
// `!handler` guard, and then called as a function. Mirrors `getHandler` in @loomwork/replay-vm.
|
|
301
|
+
if (!Object.hasOwn(handlers, event.eventType)) return;
|
|
302
|
+
const handler = handlers[event.eventType];
|
|
303
|
+
if (typeof handler !== "function") return;
|
|
304
|
+
const key = this.stateKey(projection.id, event.executionId);
|
|
305
|
+
// One `get` on the hot path (this runs per event per projection); `has` is consulted only
|
|
306
|
+
// in the rare case where the retained state is itself `undefined`.
|
|
307
|
+
const retained = this.states.get(key);
|
|
308
|
+
const currentState =
|
|
309
|
+
retained !== undefined || this.states.has(key) ? retained : projection.initialState;
|
|
310
|
+
const nextState = handler(currentState, event);
|
|
311
|
+
this.states.set(key, nextState);
|
|
312
|
+
this.notify(key, nextState);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
seedState(projectionId: string, executionId: string, state: unknown): void {
|
|
316
|
+
const key = this.stateKey(projectionId, executionId);
|
|
317
|
+
this.states.set(key, state);
|
|
318
|
+
this.touch(executionId);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* ADR 0095 — the declared `version` of every registered projection that has one.
|
|
323
|
+
*
|
|
324
|
+
* Projections that declare nothing are ABSENT rather than present-and-empty: the caller
|
|
325
|
+
* compares this against what an artifact recorded, and a key mapped to `""` would compare
|
|
326
|
+
* equal to another empty declaration and read as a verified match.
|
|
327
|
+
*/
|
|
328
|
+
pins(): Readonly<Record<string, string>> {
|
|
329
|
+
// No prototype: a projection registered as `__proto__` would otherwise set one instead
|
|
330
|
+
// of adding a key, and vanish from every comparison without a finding.
|
|
331
|
+
const pins: Record<string, string> = Object.create(null);
|
|
332
|
+
for (const [id, projection] of this.projections) {
|
|
333
|
+
if (projection.version !== undefined) pins[id] = projection.version;
|
|
334
|
+
}
|
|
335
|
+
return pins;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private notify(key: string, state: unknown): void {
|
|
339
|
+
if (!this.coalesce) {
|
|
340
|
+
this.emit(key, state);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Defer: mark dirty and flush once per microtask with the latest state.
|
|
344
|
+
this.dirtyKeys.add(key);
|
|
345
|
+
if (!this.flushScheduled) {
|
|
346
|
+
this.flushScheduled = true;
|
|
347
|
+
queueMicrotask(() => this.flush());
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private flush(): void {
|
|
352
|
+
this.flushScheduled = false;
|
|
353
|
+
const keys = Array.from(this.dirtyKeys);
|
|
354
|
+
this.dirtyKeys.clear();
|
|
355
|
+
for (const key of keys) {
|
|
356
|
+
// Read the current state so subscribers always see the latest value even
|
|
357
|
+
// when many events for the same key arrived within the batch.
|
|
358
|
+
this.emit(key, this.states.get(key));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private emit(key: string, state: unknown): void {
|
|
363
|
+
const handlers = this.subscribers.get(key);
|
|
364
|
+
if (handlers) {
|
|
365
|
+
for (const handler of handlers) {
|
|
366
|
+
handler(state);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Selector-subscribers: recompute each slice against the latest state and fire
|
|
370
|
+
// only those whose slice actually changed — so an unrelated slice's update never
|
|
371
|
+
// wakes this subscriber. Snapshot the set so an unsubscribe during iteration
|
|
372
|
+
// (a handler tearing itself down) can't mutate the live set mid-loop.
|
|
373
|
+
const selectors = this.selectorSubscribers.get(key);
|
|
374
|
+
if (selectors) {
|
|
375
|
+
for (const entry of [...selectors]) {
|
|
376
|
+
const next = entry.selector(state);
|
|
377
|
+
if (!entry.isEqual(entry.last, next)) {
|
|
378
|
+
entry.last = next;
|
|
379
|
+
entry.handler(next);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
import { bench, describe } from "vitest";
|
|
3
|
+
import { InMemoryProjectionRuntime } from "./InMemoryProjectionRuntime";
|
|
4
|
+
|
|
5
|
+
// ── Projection storm baseline ─────────────────────────────────────────────────
|
|
6
|
+
// Streaming (e.g. LLM token-by-token) produces one event per token. Today each
|
|
7
|
+
// event synchronously notifies subscribers, so a subscriber fires once per event.
|
|
8
|
+
// This bench captures the pre-coalescing baseline: wall time AND subscriber-call
|
|
9
|
+
// count for a burst of N events. Phase 4 coalescing should keep this wall time
|
|
10
|
+
// flat (or better) while collapsing subscriber calls from O(events) to
|
|
11
|
+
// O(microtask flushes).
|
|
12
|
+
|
|
13
|
+
const EXECUTION_ID = "exec-1";
|
|
14
|
+
const PROJECTION_ID = "counter";
|
|
15
|
+
|
|
16
|
+
function makeRuntime(coalesce = false) {
|
|
17
|
+
const runtime = new InMemoryProjectionRuntime({ coalesceNotifications: coalesce });
|
|
18
|
+
runtime.register<{ count: number }>({
|
|
19
|
+
id: PROJECTION_ID,
|
|
20
|
+
initialState: { count: 0 },
|
|
21
|
+
handlers: {
|
|
22
|
+
TICK: (state) => ({ count: state.count + 1 }),
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
return runtime;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeEvent(sequence: number): EventEnvelope {
|
|
29
|
+
return {
|
|
30
|
+
id: `e${sequence}`,
|
|
31
|
+
workflowId: "wf",
|
|
32
|
+
executionId: EXECUTION_ID,
|
|
33
|
+
eventType: "TICK",
|
|
34
|
+
sequence,
|
|
35
|
+
timestamp: 0,
|
|
36
|
+
payload: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const burst of [100, 1000]) {
|
|
41
|
+
describe(`projection storm / ${burst} events`, () => {
|
|
42
|
+
const events = Array.from({ length: burst }, (_unused, i) => makeEvent(i));
|
|
43
|
+
|
|
44
|
+
bench("handleEvent burst — synchronous notify (baseline)", () => {
|
|
45
|
+
const runtime = makeRuntime(false);
|
|
46
|
+
let notifications = 0;
|
|
47
|
+
runtime.subscribe(PROJECTION_ID, EXECUTION_ID, () => {
|
|
48
|
+
notifications++;
|
|
49
|
+
});
|
|
50
|
+
for (const event of events) runtime.handleEvent(event);
|
|
51
|
+
// notifications === burst (per-event notify).
|
|
52
|
+
void notifications;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
bench("handleEvent burst — coalesced notify", async () => {
|
|
56
|
+
const runtime = makeRuntime(true);
|
|
57
|
+
let notifications = 0;
|
|
58
|
+
runtime.subscribe(PROJECTION_ID, EXECUTION_ID, () => {
|
|
59
|
+
notifications++;
|
|
60
|
+
});
|
|
61
|
+
for (const event of events) runtime.handleEvent(event);
|
|
62
|
+
// Flush the coalesced microtask; notifications collapses to 1.
|
|
63
|
+
await Promise.resolve();
|
|
64
|
+
void notifications;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Selector fan-out ──────────────────────────────────────────────────────────
|
|
70
|
+
// A dashboard with W widgets bound to distinct slices of one projection. When a
|
|
71
|
+
// single slice changes, whole-state `subscribe` invokes ALL W handlers (each then
|
|
72
|
+
// diffs its own slice DOWNSTREAM — e.g. a React getSnapshot + scheduler ping);
|
|
73
|
+
// slice-scoped `subscribeSelector` invokes only the ONE whose slice changed.
|
|
74
|
+
//
|
|
75
|
+
// Honest reading of this bench: it measures RUNTIME dispatch cost only (setup is
|
|
76
|
+
// hoisted out of the timed fn). At the runtime level the selector path is not
|
|
77
|
+
// cheaper — it evaluates W selectors + W equality checks to decide whom to wake,
|
|
78
|
+
// versus whole-state calling W (here no-op) handlers — so expect roughly comparable
|
|
79
|
+
// (often modestly slower) wall time. The actual payoff is the DOWNSTREAM handler-
|
|
80
|
+
// invocation count, W → (changed slices), which this micro-bench cannot see and the
|
|
81
|
+
// unit tests assert directly. This bench exists to prove the filtering overhead
|
|
82
|
+
// stays bounded, not to claim a runtime speedup.
|
|
83
|
+
|
|
84
|
+
const WIDE_PROJECTION = "wide";
|
|
85
|
+
|
|
86
|
+
function makeWideRuntime(widgetCount: number) {
|
|
87
|
+
const runtime = new InMemoryProjectionRuntime();
|
|
88
|
+
const initialState: Record<string, number> = {};
|
|
89
|
+
for (let i = 0; i < widgetCount; i++) initialState[`w${i}`] = 0;
|
|
90
|
+
runtime.register<Record<string, number>>({
|
|
91
|
+
id: WIDE_PROJECTION,
|
|
92
|
+
initialState,
|
|
93
|
+
// BUMP increments exactly one field, named in the event payload.
|
|
94
|
+
handlers: {
|
|
95
|
+
BUMP: (state, event) => {
|
|
96
|
+
const field = (event.payload as { field: string }).field;
|
|
97
|
+
return { ...state, [field]: (state[field] ?? 0) + 1 };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return runtime;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function bumpEvent(sequence: number, field: string): EventEnvelope {
|
|
105
|
+
return {
|
|
106
|
+
id: `b${sequence}`,
|
|
107
|
+
workflowId: "wf",
|
|
108
|
+
executionId: EXECUTION_ID,
|
|
109
|
+
eventType: "BUMP",
|
|
110
|
+
sequence,
|
|
111
|
+
timestamp: 0,
|
|
112
|
+
payload: { field },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const widgets of [100, 1000]) {
|
|
117
|
+
describe(`selector fan-out / ${widgets} widgets — dispatch cost (setup hoisted)`, () => {
|
|
118
|
+
// Setup once at collection time — NOT inside the timed fn — so the bench reflects
|
|
119
|
+
// per-dispatch cost, not the O(W) cost of wiring up subscriptions.
|
|
120
|
+
const wholeStateRuntime = makeWideRuntime(widgets);
|
|
121
|
+
for (let i = 0; i < widgets; i++) {
|
|
122
|
+
wholeStateRuntime.subscribe(WIDE_PROJECTION, EXECUTION_ID, () => {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const selectorRuntime = makeWideRuntime(widgets);
|
|
126
|
+
for (let i = 0; i < widgets; i++) {
|
|
127
|
+
const field = `w${i}`;
|
|
128
|
+
selectorRuntime.subscribeSelector<Record<string, number>, number>(
|
|
129
|
+
WIDE_PROJECTION,
|
|
130
|
+
EXECUTION_ID,
|
|
131
|
+
(state) => state[field] ?? 0,
|
|
132
|
+
() => {}
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let seq = 0;
|
|
137
|
+
|
|
138
|
+
bench("whole-state subscribe — invokes all W handlers per dispatch", () => {
|
|
139
|
+
wholeStateRuntime.handleEvent(bumpEvent(seq++, "w0"));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
bench("subscribeSelector — invokes only the changed handler per dispatch", () => {
|
|
143
|
+
selectorRuntime.handleEvent(bumpEvent(seq++, "w0"));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
export type ProjectionFoldMap<TState> = {
|
|
4
|
+
[eventType: string]: (state: TState, event: EventEnvelope) => TState;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export interface Projection<TState> {
|
|
8
|
+
id: string;
|
|
9
|
+
/**
|
|
10
|
+
* ADR 0095 — the declared semantics of this fold. An artifact that binds to `id` (a
|
|
11
|
+
* blueprint's `DataBinding`, a saved report) records this string, and `checkVersionPins`
|
|
12
|
+
* compares what was recorded against what is registered: a changed query, filter,
|
|
13
|
+
* aggregation or window then reads as a finding instead of a silently different number.
|
|
14
|
+
*
|
|
15
|
+
* Bump it when the fold's MEANING changes — a refactor producing the same state is not a
|
|
16
|
+
* new version. Declared rather than derived, for the reason `flowContentHash` excludes
|
|
17
|
+
* handler code: a fold closure has no stable fingerprint.
|
|
18
|
+
*/
|
|
19
|
+
version?: string;
|
|
20
|
+
initialState: TState;
|
|
21
|
+
/** The eager fold map — present when handlers are inline / already resolved. */
|
|
22
|
+
handlers?: ProjectionFoldMap<TState>;
|
|
23
|
+
/**
|
|
24
|
+
* Event-type-gated lazy form: the fold code stays unloaded until an event whose type
|
|
25
|
+
* is in `eventTypes` first arrives, at which point `load()` is `import()`-ed once (its
|
|
26
|
+
* events buffered until ready). A projection whose event types never fire never loads
|
|
27
|
+
* its code. Exactly one of `handlers` / `lazy` should be set.
|
|
28
|
+
*/
|
|
29
|
+
lazy?: {
|
|
30
|
+
eventTypes: string[];
|
|
31
|
+
load: () => Promise<ProjectionFoldMap<TState>>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProjectionRuntime {
|
|
36
|
+
register<TState>(projection: Projection<TState>): void;
|
|
37
|
+
getState<TState>(projectionId: string, executionId: string): TState;
|
|
38
|
+
subscribe<TState>(
|
|
39
|
+
projectionId: string,
|
|
40
|
+
executionId: string,
|
|
41
|
+
handler: (state: TState) => void
|
|
42
|
+
): () => void;
|
|
43
|
+
/**
|
|
44
|
+
* Fine-grained ("selector") subscription: invoke `handler` only when
|
|
45
|
+
* `selector(state)` changes — by `isEqual`, default `Object.is` — rather than on
|
|
46
|
+
* every projection event. The framework-agnostic building block for live rendering
|
|
47
|
+
* surfaces (terminal, editor webview, TUI, …): each derives a slice-granular,
|
|
48
|
+
* memoized subscription from the shared read-model without reimplementing the diff.
|
|
49
|
+
*
|
|
50
|
+
* Change-only, exactly like {@link subscribe}: it does NOT fire on subscribe. The
|
|
51
|
+
* last slice is seeded from current state at subscribe time, so the first `handler`
|
|
52
|
+
* call is the first genuine change. Read the initial value via `getState` + selector.
|
|
53
|
+
* Participates in `coalesceNotifications` — a burst collapses to one call per changed
|
|
54
|
+
* slice per microtask.
|
|
55
|
+
*/
|
|
56
|
+
subscribeSelector<TState, TSlice>(
|
|
57
|
+
projectionId: string,
|
|
58
|
+
executionId: string,
|
|
59
|
+
selector: (state: TState) => TSlice,
|
|
60
|
+
handler: (slice: TSlice) => void,
|
|
61
|
+
isEqual?: (a: TSlice, b: TSlice) => boolean
|
|
62
|
+
): () => void;
|
|
63
|
+
handleEvent(event: EventEnvelope): void;
|
|
64
|
+
seedState(projectionId: string, executionId: string, state: unknown): void;
|
|
65
|
+
/**
|
|
66
|
+
* ADR 0095 — the declared `version` of every registered projection that has one, by id.
|
|
67
|
+
*
|
|
68
|
+
* OPTIONAL on the interface so an existing implementation stays valid; a runtime that
|
|
69
|
+
* omits it simply cannot answer the question, and `checkVersionPins` reports that as
|
|
70
|
+
* `unpinned` rather than as a pass.
|
|
71
|
+
*/
|
|
72
|
+
pins?(): Readonly<Record<string, string>>;
|
|
73
|
+
}
|