@nimbus-cqrs/eventsourcingdb 2.4.0 → 3.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -46
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/{esm → dist}/lib/client.d.ts +28 -17
- package/{esm → dist}/lib/client.d.ts.map +1 -1
- package/{esm → dist}/lib/client.js +25 -19
- package/dist/lib/client.js.map +1 -0
- package/dist/lib/consumerRunner.d.ts +62 -0
- package/dist/lib/consumerRunner.d.ts.map +1 -0
- package/dist/lib/consumerRunner.js +386 -0
- package/dist/lib/consumerRunner.js.map +1 -0
- package/dist/lib/eventConsumer.d.ts +94 -0
- package/dist/lib/eventConsumer.d.ts.map +1 -0
- package/dist/lib/eventConsumer.js +2 -0
- package/dist/lib/eventConsumer.js.map +1 -0
- package/{esm → dist}/lib/eventMapping.d.ts +1 -1
- package/dist/lib/eventMapping.d.ts.map +1 -0
- package/{esm → dist}/lib/eventMapping.js +2 -1
- package/dist/lib/eventMapping.js.map +1 -0
- package/dist/lib/eventObserver.d.ts +158 -0
- package/dist/lib/eventObserver.d.ts.map +1 -0
- package/dist/lib/eventObserver.js +339 -0
- package/dist/lib/eventObserver.js.map +1 -0
- package/dist/lib/observerRegistry.d.ts +39 -0
- package/dist/lib/observerRegistry.d.ts.map +1 -0
- package/dist/lib/observerRegistry.js +56 -0
- package/dist/lib/observerRegistry.js.map +1 -0
- package/{esm → dist}/lib/readEvents.d.ts.map +1 -1
- package/{esm → dist}/lib/readEvents.js +3 -2
- package/dist/lib/readEvents.js.map +1 -0
- package/{esm → dist}/lib/tracing.d.ts +3 -0
- package/dist/lib/tracing.d.ts.map +1 -0
- package/{esm → dist}/lib/tracing.js +5 -1
- package/dist/lib/tracing.js.map +1 -0
- package/dist/lib/writeEvents.d.ts +29 -0
- package/dist/lib/writeEvents.d.ts.map +1 -0
- package/dist/lib/writeEvents.js +47 -0
- package/dist/lib/writeEvents.js.map +1 -0
- package/package.json +32 -13
- package/src/index.ts +7 -0
- package/src/lib/client.ts +166 -0
- package/src/lib/consumerRunner.ts +592 -0
- package/src/lib/eventConsumer.ts +95 -0
- package/src/lib/eventMapping.ts +115 -0
- package/src/lib/eventObserver.ts +620 -0
- package/src/lib/observerRegistry.ts +69 -0
- package/src/lib/readEvents.ts +28 -0
- package/src/lib/tracing.ts +295 -0
- package/src/lib/writeEvents.ts +72 -0
- package/esm/_dnt.polyfills.d.ts +0 -7
- package/esm/_dnt.polyfills.d.ts.map +0 -1
- package/esm/_dnt.polyfills.js +0 -1
- package/esm/_dnt.shims.d.ts +0 -2
- package/esm/_dnt.shims.d.ts.map +0 -1
- package/esm/_dnt.shims.js +0 -57
- package/esm/deps/jsr.io/@std/fmt/1.0.10/colors.d.ts +0 -700
- package/esm/deps/jsr.io/@std/fmt/1.0.10/colors.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/fmt/1.0.10/colors.js +0 -903
- package/esm/deps/jsr.io/@std/ulid/1.0.0/_util.d.ts +0 -14
- package/esm/deps/jsr.io/@std/ulid/1.0.0/_util.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/ulid/1.0.0/_util.js +0 -65
- package/esm/deps/jsr.io/@std/ulid/1.0.0/decode_time.d.ts +0 -20
- package/esm/deps/jsr.io/@std/ulid/1.0.0/decode_time.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/ulid/1.0.0/decode_time.js +0 -43
- package/esm/deps/jsr.io/@std/ulid/1.0.0/mod.d.ts +0 -44
- package/esm/deps/jsr.io/@std/ulid/1.0.0/mod.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/ulid/1.0.0/mod.js +0 -47
- package/esm/deps/jsr.io/@std/ulid/1.0.0/monotonic_ulid.d.ts +0 -44
- package/esm/deps/jsr.io/@std/ulid/1.0.0/monotonic_ulid.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/ulid/1.0.0/monotonic_ulid.js +0 -51
- package/esm/deps/jsr.io/@std/ulid/1.0.0/ulid.d.ts +0 -31
- package/esm/deps/jsr.io/@std/ulid/1.0.0/ulid.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/ulid/1.0.0/ulid.js +0 -37
- package/esm/index.d.ts +0 -7
- package/esm/index.d.ts.map +0 -1
- package/esm/index.js +0 -6
- package/esm/lib/eventMapping.d.ts.map +0 -1
- package/esm/lib/eventObserver.d.ts +0 -133
- package/esm/lib/eventObserver.d.ts.map +0 -1
- package/esm/lib/eventObserver.js +0 -291
- package/esm/lib/tracing.d.ts.map +0 -1
- package/esm/lib/writeEvents.d.ts +0 -11
- package/esm/lib/writeEvents.d.ts.map +0 -1
- package/esm/lib/writeEvents.js +0 -31
- package/esm/package.json +0 -3
- /package/{esm → dist}/lib/readEvents.d.ts +0 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CheckpointFailure,
|
|
3
|
+
type CheckpointStore,
|
|
4
|
+
type CheckpointUpdate,
|
|
5
|
+
type Event,
|
|
6
|
+
GenericException,
|
|
7
|
+
getLogger,
|
|
8
|
+
} from '@nimbus-cqrs/core';
|
|
9
|
+
import type { Event as EventSourcingDBEvent } from 'eventsourcingdb';
|
|
10
|
+
import type { ConsumerStatus, EventConsumer } from './eventConsumer.ts';
|
|
11
|
+
import { eventSourcingDBEventToNimbusEvent } from './eventMapping.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where an observer starts reading the stream.
|
|
15
|
+
*/
|
|
16
|
+
export type Bound = {
|
|
17
|
+
id: string;
|
|
18
|
+
type: 'inclusive' | 'exclusive';
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs the consumers of one observer and keeps their checkpoints.
|
|
23
|
+
*/
|
|
24
|
+
export type ConsumerRunner = {
|
|
25
|
+
/**
|
|
26
|
+
* The lowest position of all consumers, from where the observer has
|
|
27
|
+
* to resume so that every consumer sees what it still misses.
|
|
28
|
+
*/
|
|
29
|
+
readonly lowerBound: Bound;
|
|
30
|
+
/**
|
|
31
|
+
* Offers one event to every consumer in declaration order. Rejects
|
|
32
|
+
* when at least one consumer failed so the observer retries in place.
|
|
33
|
+
*/
|
|
34
|
+
readonly handle: (raw: EventSourcingDBEvent) => Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Halts the consumers that failed on the most recent attempt and
|
|
37
|
+
* persists their failure. Called once handler retries are exhausted.
|
|
38
|
+
*/
|
|
39
|
+
readonly onExhausted: (
|
|
40
|
+
error: Error,
|
|
41
|
+
raw: EventSourcingDBEvent,
|
|
42
|
+
) => Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The current state of every consumer.
|
|
45
|
+
*/
|
|
46
|
+
readonly status: () => ConsumerStatus[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type CreateConsumerRunnerOptions = {
|
|
50
|
+
observer: string;
|
|
51
|
+
consumers: readonly EventConsumer[];
|
|
52
|
+
checkpointStore: CheckpointStore;
|
|
53
|
+
head: string | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type ConsumerState = {
|
|
57
|
+
consumer: EventConsumer;
|
|
58
|
+
/** Last finished event id; null until the consumer finishes its first. */
|
|
59
|
+
position: bigint | null;
|
|
60
|
+
/** Position or failure changed since the last successful flush. */
|
|
61
|
+
pending: boolean;
|
|
62
|
+
halted: boolean;
|
|
63
|
+
failure?: CheckpointFailure;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** How long the stream has to go quiet before a burst is considered over. */
|
|
67
|
+
const BURST_IDLE_MS = 2000;
|
|
68
|
+
|
|
69
|
+
const toError = (error: unknown): Error => {
|
|
70
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const describeError = (error: Error): Record<string, unknown> => {
|
|
74
|
+
return {
|
|
75
|
+
name: error.name,
|
|
76
|
+
message: error.message,
|
|
77
|
+
...(error.stack !== undefined ? { stack: error.stack } : {}),
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const describeFailures = (
|
|
82
|
+
failures: ReadonlyMap<string, Error>,
|
|
83
|
+
): Record<string, unknown> => {
|
|
84
|
+
return Object.fromEntries(
|
|
85
|
+
[...failures.entries()].map(([name, error]) => {
|
|
86
|
+
return [name, describeError(error)];
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const failureSummary = (failures: ReadonlyMap<string, Error>): string => {
|
|
92
|
+
return [...failures.entries()]
|
|
93
|
+
.map(([name, error]) => {
|
|
94
|
+
return `${name}: ${error.name}: ${error.message}`;
|
|
95
|
+
})
|
|
96
|
+
.join('; ');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type Burst = {
|
|
100
|
+
startedAt: number;
|
|
101
|
+
finishedAt: number;
|
|
102
|
+
events: number;
|
|
103
|
+
eventsHandled: number;
|
|
104
|
+
firstEventId: string;
|
|
105
|
+
lastEventId: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Times contiguous runs of events and logs one summary per run, so a replay
|
|
110
|
+
* reports as a single line instead of one per event. A run ends once the
|
|
111
|
+
* stream has been quiet for {@link BURST_IDLE_MS}; the reported duration
|
|
112
|
+
* covers only the events themselves, never the idle wait.
|
|
113
|
+
*/
|
|
114
|
+
const createBurstTimer = (observer: string) => {
|
|
115
|
+
let burst: Burst | null = null;
|
|
116
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
+
let inFlight = false;
|
|
118
|
+
|
|
119
|
+
const log = () => {
|
|
120
|
+
if (burst === null) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const durationMs = burst.finishedAt - burst.startedAt;
|
|
125
|
+
const durationSeconds = Math.floor((durationMs / 1000) * 1000) / 1000;
|
|
126
|
+
|
|
127
|
+
const { events, eventsHandled, firstEventId, lastEventId } = burst;
|
|
128
|
+
burst = null;
|
|
129
|
+
|
|
130
|
+
getLogger().debug({
|
|
131
|
+
category: 'Nimbus',
|
|
132
|
+
message: `Observer "${observer}" processed ${events} event(s) in ${durationSeconds}s`,
|
|
133
|
+
data: {
|
|
134
|
+
observer,
|
|
135
|
+
events,
|
|
136
|
+
eventsHandled,
|
|
137
|
+
durationMs,
|
|
138
|
+
eventsPerSecond:
|
|
139
|
+
durationMs > 0
|
|
140
|
+
? Math.round((events / durationMs) * 1000)
|
|
141
|
+
: events,
|
|
142
|
+
firstEventId,
|
|
143
|
+
lastEventId,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const schedule = () => {
|
|
149
|
+
if (timer !== undefined) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const scheduled = setTimeout(() => {
|
|
154
|
+
timer = undefined;
|
|
155
|
+
|
|
156
|
+
// A single event slower than the idle window must not split its
|
|
157
|
+
// own burst in two.
|
|
158
|
+
if (inFlight) {
|
|
159
|
+
schedule();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
log();
|
|
164
|
+
}, BURST_IDLE_MS);
|
|
165
|
+
|
|
166
|
+
timer = scheduled;
|
|
167
|
+
|
|
168
|
+
// Never hold the process open just to emit a timing line.
|
|
169
|
+
scheduled.unref();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
start: (eventId: string) => {
|
|
174
|
+
inFlight = true;
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
|
|
177
|
+
burst ??= {
|
|
178
|
+
startedAt: now,
|
|
179
|
+
finishedAt: now,
|
|
180
|
+
events: 0,
|
|
181
|
+
eventsHandled: 0,
|
|
182
|
+
firstEventId: eventId,
|
|
183
|
+
lastEventId: eventId,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
burst.events += 1;
|
|
187
|
+
burst.lastEventId = eventId;
|
|
188
|
+
},
|
|
189
|
+
finish: (handled: boolean) => {
|
|
190
|
+
inFlight = false;
|
|
191
|
+
|
|
192
|
+
if (burst !== null) {
|
|
193
|
+
burst.finishedAt = Date.now();
|
|
194
|
+
|
|
195
|
+
if (handled) {
|
|
196
|
+
burst.eventsHandled += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
schedule();
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The lowest position of all consumers. Consumers ahead of it skip the
|
|
207
|
+
* replayed events in memory.
|
|
208
|
+
*/
|
|
209
|
+
const lowerBoundFor = (states: readonly ConsumerState[]): Bound => {
|
|
210
|
+
let lowest: bigint | null = null;
|
|
211
|
+
|
|
212
|
+
for (const state of states) {
|
|
213
|
+
if (state.position === null) {
|
|
214
|
+
return { id: '0', type: 'inclusive' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (lowest === null || state.position < lowest) {
|
|
218
|
+
lowest = state.position;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return lowest === null
|
|
223
|
+
? { id: '0', type: 'inclusive' }
|
|
224
|
+
: { id: lowest.toString(), type: 'exclusive' };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const startsFromHead = (consumer: EventConsumer): boolean => {
|
|
228
|
+
return consumer.startFrom === 'head';
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Every consumer name has to be unique and every dependency has to name a
|
|
233
|
+
* consumer declared earlier on the same observer. A consumer that resumes
|
|
234
|
+
* from its checkpoint must not depend on one that starts from the head,
|
|
235
|
+
* because that dependency never built state for older events.
|
|
236
|
+
*/
|
|
237
|
+
const validateConsumers = (
|
|
238
|
+
observer: string,
|
|
239
|
+
consumers: readonly EventConsumer[],
|
|
240
|
+
): void => {
|
|
241
|
+
const declared = new Map<string, EventConsumer>();
|
|
242
|
+
|
|
243
|
+
for (const consumer of consumers) {
|
|
244
|
+
if (declared.has(consumer.name)) {
|
|
245
|
+
throw new GenericException(
|
|
246
|
+
`Observer "${observer}" declares consumer "${consumer.name}" more than once`,
|
|
247
|
+
{ observer, consumer: consumer.name },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const dependency of consumer.dependsOn ?? []) {
|
|
252
|
+
const declaredDependency = declared.get(dependency);
|
|
253
|
+
|
|
254
|
+
if (declaredDependency === undefined) {
|
|
255
|
+
throw new GenericException(
|
|
256
|
+
`Consumer "${consumer.name}" of observer "${observer}" depends on "${dependency}", which is not declared before it`,
|
|
257
|
+
{ observer, consumer: consumer.name, dependency },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (
|
|
262
|
+
!startsFromHead(consumer) &&
|
|
263
|
+
startsFromHead(declaredDependency)
|
|
264
|
+
) {
|
|
265
|
+
throw new GenericException(
|
|
266
|
+
`Consumer "${consumer.name}" of observer "${observer}" resumes from its checkpoint but depends on "${dependency}", which starts from the head and skips older events`,
|
|
267
|
+
{ observer, consumer: consumer.name, dependency },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
declared.set(consumer.name, consumer);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Wires the consumers of one observer onto a shared stream.
|
|
278
|
+
*
|
|
279
|
+
* Consumers run sequentially in declaration order. Every non-halted
|
|
280
|
+
* consumer advances its position on every event — handled or not — which
|
|
281
|
+
* keeps the position contiguous and lets a restart resume exactly where it
|
|
282
|
+
* stopped.
|
|
283
|
+
*
|
|
284
|
+
* A consumer that throws holds its position while its siblings advance, as
|
|
285
|
+
* does every consumer that depends on it. The handler then rejects so the
|
|
286
|
+
* observer retries in place. On the next retry the advanced consumers skip
|
|
287
|
+
* themselves, so only the failing ones run again. Once retries are
|
|
288
|
+
* exhausted those consumers are halted. They are skipped for the rest of
|
|
289
|
+
* the process lifetime and their checkpoint stays at the last event they
|
|
290
|
+
* finished, so a restart resumes at the event they could not handle.
|
|
291
|
+
*
|
|
292
|
+
* Consumers with `startFrom: 'head'` ignore their stored position and
|
|
293
|
+
* start at the head of the subject on every start. Their checkpoint is
|
|
294
|
+
* still written like any other, so operators see where they are.
|
|
295
|
+
*/
|
|
296
|
+
export const createConsumerRunner = async ({
|
|
297
|
+
observer,
|
|
298
|
+
consumers,
|
|
299
|
+
checkpointStore,
|
|
300
|
+
head,
|
|
301
|
+
}: CreateConsumerRunnerOptions): Promise<ConsumerRunner> => {
|
|
302
|
+
validateConsumers(observer, consumers);
|
|
303
|
+
|
|
304
|
+
const names = consumers.map((consumer) => {
|
|
305
|
+
return consumer.name;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const checkpoints = await checkpointStore.load(names);
|
|
309
|
+
|
|
310
|
+
const positionFor = (consumer: EventConsumer): bigint | null => {
|
|
311
|
+
if (startsFromHead(consumer)) {
|
|
312
|
+
return head === null ? null : BigInt(head);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const position = checkpoints.get(consumer.name)?.position;
|
|
316
|
+
|
|
317
|
+
return position === undefined ? null : BigInt(position);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const states: ConsumerState[] = consumers.map((consumer) => {
|
|
321
|
+
const checkpoint = checkpoints.get(consumer.name);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
consumer,
|
|
325
|
+
position: positionFor(consumer),
|
|
326
|
+
pending: false,
|
|
327
|
+
halted: false,
|
|
328
|
+
...(checkpoint?.failure !== undefined
|
|
329
|
+
? { failure: checkpoint.failure }
|
|
330
|
+
: {}),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const statesByName = new Map(
|
|
335
|
+
states.map((state) => {
|
|
336
|
+
return [state.consumer.name, state];
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
// Failures from the most recent handler attempt, read by onExhausted
|
|
341
|
+
// to know which consumers to halt.
|
|
342
|
+
let lastFailures = new Map<string, Error>();
|
|
343
|
+
|
|
344
|
+
const burstTimer = createBurstTimer(observer);
|
|
345
|
+
|
|
346
|
+
const flush = async (failed?: {
|
|
347
|
+
position: string;
|
|
348
|
+
errors: ReadonlyMap<string, Error>;
|
|
349
|
+
}) => {
|
|
350
|
+
const updates: CheckpointUpdate[] = [];
|
|
351
|
+
const now = new Date().toISOString();
|
|
352
|
+
|
|
353
|
+
for (const state of states) {
|
|
354
|
+
const error = failed?.errors.get(state.consumer.name);
|
|
355
|
+
|
|
356
|
+
if (failed !== undefined && error !== undefined) {
|
|
357
|
+
state.failure = {
|
|
358
|
+
position: failed.position,
|
|
359
|
+
error: `${error.name}: ${error.message}`,
|
|
360
|
+
at: now,
|
|
361
|
+
};
|
|
362
|
+
state.pending = true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!state.pending) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A halted consumer carries its marker on every write until
|
|
370
|
+
// it is reset, so a marker whose first write failed is still
|
|
371
|
+
// persisted by the next successful flush. A running consumer
|
|
372
|
+
// only has a marker loaded from the store, which the save
|
|
373
|
+
// after its next success clears.
|
|
374
|
+
const failure =
|
|
375
|
+
state.halted && state.failure !== undefined
|
|
376
|
+
? {
|
|
377
|
+
position: state.failure.position,
|
|
378
|
+
error: state.failure.error,
|
|
379
|
+
}
|
|
380
|
+
: undefined;
|
|
381
|
+
|
|
382
|
+
updates.push({
|
|
383
|
+
consumer: state.consumer.name,
|
|
384
|
+
observer,
|
|
385
|
+
...(state.position !== null
|
|
386
|
+
? { position: state.position.toString() }
|
|
387
|
+
: {}),
|
|
388
|
+
...(failure !== undefined ? { failure } : {}),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (updates.length === 0) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await checkpointStore.save(updates);
|
|
397
|
+
|
|
398
|
+
for (const state of states) {
|
|
399
|
+
state.pending = false;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The error that blocks the consumer on this event because one of its
|
|
405
|
+
* dependencies failed on it or is halted, or `undefined`.
|
|
406
|
+
*/
|
|
407
|
+
const dependencyFailure = (
|
|
408
|
+
state: ConsumerState,
|
|
409
|
+
failures: ReadonlyMap<string, Error>,
|
|
410
|
+
eventId: string,
|
|
411
|
+
): Error | undefined => {
|
|
412
|
+
const failedDependency = (state.consumer.dependsOn ?? []).find(
|
|
413
|
+
(dependency) => {
|
|
414
|
+
return (
|
|
415
|
+
failures.has(dependency) ||
|
|
416
|
+
statesByName.get(dependency)?.halted === true
|
|
417
|
+
);
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
return failedDependency === undefined
|
|
422
|
+
? undefined
|
|
423
|
+
: new GenericException(
|
|
424
|
+
`Dependency "${failedDependency}" did not finish event ${eventId}`,
|
|
425
|
+
{ dependency: failedDependency },
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Offers the event to one consumer. Both the predicate and the handler
|
|
431
|
+
* run inside the error boundary, so a throwing predicate is a failure
|
|
432
|
+
* of this consumer alone and its siblings still receive the event.
|
|
433
|
+
*/
|
|
434
|
+
const runConsumer = async (
|
|
435
|
+
state: ConsumerState,
|
|
436
|
+
event: Event,
|
|
437
|
+
raw: EventSourcingDBEvent,
|
|
438
|
+
): Promise<{ handled: boolean; error?: Error }> => {
|
|
439
|
+
try {
|
|
440
|
+
if (!state.consumer.handles(event)) {
|
|
441
|
+
return { handled: false };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
await state.consumer.handle(event, raw);
|
|
445
|
+
|
|
446
|
+
return { handled: true };
|
|
447
|
+
} catch (error) {
|
|
448
|
+
const failure = toError(error);
|
|
449
|
+
|
|
450
|
+
getLogger().error({
|
|
451
|
+
category: 'Nimbus',
|
|
452
|
+
message: `Consumer "${state.consumer.name}" failed to handle event ${raw.id} (${raw.type})`,
|
|
453
|
+
error: failure,
|
|
454
|
+
data: {
|
|
455
|
+
observer,
|
|
456
|
+
consumer: state.consumer.name,
|
|
457
|
+
eventId: raw.id,
|
|
458
|
+
eventType: raw.type,
|
|
459
|
+
},
|
|
460
|
+
correlationId: event.correlationid,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
return { handled: true, error: failure };
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const handle = async (raw: EventSourcingDBEvent) => {
|
|
468
|
+
const eventId = BigInt(raw.id);
|
|
469
|
+
const failures = new Map<string, Error>();
|
|
470
|
+
let handled = false;
|
|
471
|
+
|
|
472
|
+
// Before mapping: an event that cannot be mapped leaves an empty
|
|
473
|
+
// map behind, which tells onExhausted to halt every consumer.
|
|
474
|
+
lastFailures = failures;
|
|
475
|
+
|
|
476
|
+
const event = eventSourcingDBEventToNimbusEvent(raw);
|
|
477
|
+
burstTimer.start(raw.id);
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
for (const state of states) {
|
|
481
|
+
if (
|
|
482
|
+
state.halted ||
|
|
483
|
+
(state.position !== null && eventId <= state.position)
|
|
484
|
+
) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const blocked = dependencyFailure(state, failures, raw.id);
|
|
489
|
+
|
|
490
|
+
if (blocked !== undefined) {
|
|
491
|
+
failures.set(state.consumer.name, blocked);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Declaration order: a later consumer reads the earlier projection.
|
|
496
|
+
const result = await runConsumer(state, event, raw); // NOSONAR
|
|
497
|
+
handled ||= result.handled;
|
|
498
|
+
|
|
499
|
+
if (result.error !== undefined) {
|
|
500
|
+
failures.set(state.consumer.name, result.error);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
state.position = eventId;
|
|
505
|
+
state.pending = true;
|
|
506
|
+
state.failure = undefined;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
await flush();
|
|
510
|
+
} finally {
|
|
511
|
+
burstTimer.finish(handled);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (failures.size > 0) {
|
|
515
|
+
const exception = new GenericException(
|
|
516
|
+
`Observer "${observer}" could not handle event ${raw.id} for ${failureSummary(
|
|
517
|
+
failures,
|
|
518
|
+
)}`,
|
|
519
|
+
{
|
|
520
|
+
eventId: raw.id,
|
|
521
|
+
eventType: raw.type,
|
|
522
|
+
consumers: describeFailures(failures),
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
const cause = [...failures.values()][0];
|
|
526
|
+
|
|
527
|
+
if (cause !== undefined) {
|
|
528
|
+
exception.cause = cause;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
throw exception;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const onExhausted = async (error: Error, raw: EventSourcingDBEvent) => {
|
|
536
|
+
// An empty failure map means the handler broke outside a consumer
|
|
537
|
+
// (event mapping or the checkpoint flush), leaving the whole
|
|
538
|
+
// observer in an unknown state — halt all of it.
|
|
539
|
+
const errors =
|
|
540
|
+
lastFailures.size > 0
|
|
541
|
+
? lastFailures
|
|
542
|
+
: new Map(
|
|
543
|
+
states
|
|
544
|
+
.filter((state) => {
|
|
545
|
+
return !state.halted;
|
|
546
|
+
})
|
|
547
|
+
.map((state) => {
|
|
548
|
+
return [state.consumer.name, error];
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
for (const state of states) {
|
|
553
|
+
if (errors.has(state.consumer.name)) {
|
|
554
|
+
state.halted = true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const cause = errors.size === 1 ? [...errors.values()][0] : error;
|
|
559
|
+
|
|
560
|
+
getLogger().critical({
|
|
561
|
+
category: 'Nimbus',
|
|
562
|
+
message: `Halted ${errors.size} consumer(s) of observer "${observer}" at event ${raw.id}. They stay at their checkpoint until the process restarts. ${failureSummary(
|
|
563
|
+
errors,
|
|
564
|
+
)}`,
|
|
565
|
+
error: cause ?? error,
|
|
566
|
+
data: {
|
|
567
|
+
observer,
|
|
568
|
+
eventId: raw.id,
|
|
569
|
+
eventType: raw.type,
|
|
570
|
+
consumers: describeFailures(errors),
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
await flush({ position: raw.id, errors });
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const status = (): ConsumerStatus[] => {
|
|
578
|
+
return states.map((state) => {
|
|
579
|
+
return {
|
|
580
|
+
name: state.consumer.name,
|
|
581
|
+
position:
|
|
582
|
+
state.position === null ? null : state.position.toString(),
|
|
583
|
+
halted: state.halted,
|
|
584
|
+
...(state.failure !== undefined
|
|
585
|
+
? { failure: { ...state.failure } }
|
|
586
|
+
: {}),
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
return { lowerBound: lowerBoundFor(states), handle, onExhausted, status };
|
|
592
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CheckpointFailure, Event } from '@nimbus-cqrs/core';
|
|
2
|
+
import type { Event as EventSourcingDBEvent } from 'eventsourcingdb';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One stateful consumer of an observed event stream.
|
|
6
|
+
*
|
|
7
|
+
* A read-model projection, a policy, an event handler and a process manager
|
|
8
|
+
* all share this shape. Each consumer owns a checkpoint keyed by its
|
|
9
|
+
* {@link name}, so one of them can be rebuilt or halted without moving the
|
|
10
|
+
* others on the same observer.
|
|
11
|
+
*
|
|
12
|
+
* Consumers run sequentially in the order they are declared on the
|
|
13
|
+
* observer. A consumer that reads what another one writes should declare
|
|
14
|
+
* it in {@link dependsOn} instead of relying on the order alone.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import type { EventConsumer } from '@nimbus-cqrs/eventsourcingdb';
|
|
19
|
+
*
|
|
20
|
+
* export const ticketsProjection: EventConsumer = {
|
|
21
|
+
* name: 'ticketing.tickets',
|
|
22
|
+
* handles: (event) => event.type.startsWith('at.example.ticketing.ticket.'),
|
|
23
|
+
* handle: async (event) => {
|
|
24
|
+
* const ticket = ticketEventSchema.parse(event);
|
|
25
|
+
* await ticketsRepository.apply(ticket);
|
|
26
|
+
* },
|
|
27
|
+
* };
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export type EventConsumer = {
|
|
31
|
+
/**
|
|
32
|
+
* The checkpoint key. It has to be unique across the whole application,
|
|
33
|
+
* not just within one observer, because checkpoint stores key on the
|
|
34
|
+
* consumer name alone. It also has to stay stable: renaming a consumer
|
|
35
|
+
* makes it replay the subject from the beginning.
|
|
36
|
+
*/
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the consumer handles the given event. Events it does not
|
|
40
|
+
* handle still advance its checkpoint, so the position stays
|
|
41
|
+
* contiguous.
|
|
42
|
+
*
|
|
43
|
+
* @param event - The observed event, mapped to a Nimbus event.
|
|
44
|
+
*/
|
|
45
|
+
readonly handles: (event: Event) => boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Handles one event. A thrown error holds the consumer's checkpoint
|
|
48
|
+
* while its siblings advance; the observer retries in place and halts
|
|
49
|
+
* the consumer once the handler retries are exhausted.
|
|
50
|
+
*
|
|
51
|
+
* @param event - The observed event, mapped to a Nimbus event.
|
|
52
|
+
* @param raw - The original EventSourcingDB event.
|
|
53
|
+
*/
|
|
54
|
+
readonly handle: (event: Event, raw: EventSourcingDBEvent) => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Names of consumers on the same observer this one reads from. They
|
|
57
|
+
* have to be declared before this consumer. When a dependency fails on
|
|
58
|
+
* an event, this consumer holds its position as well, so it never
|
|
59
|
+
* checkpoints past data its dependency has not written yet. When a
|
|
60
|
+
* dependency is halted, this consumer is halted too.
|
|
61
|
+
*
|
|
62
|
+
* A consumer that starts from its checkpoint must not depend on one
|
|
63
|
+
* that starts from the head, since the latter never built state for
|
|
64
|
+
* older events.
|
|
65
|
+
*/
|
|
66
|
+
readonly dependsOn?: readonly string[];
|
|
67
|
+
/**
|
|
68
|
+
* Where the consumer starts on every start of the observer.
|
|
69
|
+
*
|
|
70
|
+
* - `checkpoint` (default): resumes after its stored checkpoint, or
|
|
71
|
+
* from the beginning of the subject when it has none.
|
|
72
|
+
* - `head`: starts at the newest event of the subject, ignoring its
|
|
73
|
+
* stored checkpoint. Events written while the process was down are
|
|
74
|
+
* never delivered. Use this for live-only consumers such as pushing
|
|
75
|
+
* to connected clients or invalidating in-memory caches. The
|
|
76
|
+
* checkpoint is still persisted for visibility, but it has no effect
|
|
77
|
+
* on where the consumer resumes, so resetting it does nothing.
|
|
78
|
+
*/
|
|
79
|
+
readonly startFrom?: 'checkpoint' | 'head';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The runtime state of one {@link EventConsumer}.
|
|
84
|
+
*
|
|
85
|
+
* @property {string} name - The consumer name.
|
|
86
|
+
* @property {string | null} position - The id of the last event the consumer finished, `null` before its first.
|
|
87
|
+
* @property {boolean} halted - Whether the consumer gave up for the rest of the process lifetime.
|
|
88
|
+
* @property {CheckpointFailure} [failure] - The most recent failure, from this run or loaded from the checkpoint store.
|
|
89
|
+
*/
|
|
90
|
+
export type ConsumerStatus = {
|
|
91
|
+
name: string;
|
|
92
|
+
position: string | null;
|
|
93
|
+
halted: boolean;
|
|
94
|
+
failure?: CheckpointFailure;
|
|
95
|
+
};
|