@kronos-ts/messaging 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dead-letter-listener.d.ts +36 -0
- package/dist/dead-letter-listener.d.ts.map +1 -0
- package/dist/dead-letter-listener.js +69 -0
- package/dist/dead-letter-listener.js.map +1 -0
- package/dist/dead-letter-queue.d.ts +5 -7
- package/dist/dead-letter-queue.d.ts.map +1 -1
- package/dist/dead-letter-queue.js +3 -13
- package/dist/dead-letter-queue.js.map +1 -1
- package/dist/dead-letter-reprocessor.d.ts +44 -0
- package/dist/dead-letter-reprocessor.d.ts.map +1 -0
- package/dist/dead-letter-reprocessor.js +48 -0
- package/dist/dead-letter-reprocessor.js.map +1 -0
- package/dist/dead-lettering-handler.d.ts +7 -4
- package/dist/dead-lettering-handler.d.ts.map +1 -1
- package/dist/dead-lettering-handler.js +36 -15
- package/dist/dead-lettering-handler.js.map +1 -1
- package/dist/enqueue-policy.d.ts +79 -0
- package/dist/enqueue-policy.d.ts.map +1 -0
- package/dist/enqueue-policy.js +95 -0
- package/dist/enqueue-policy.js.map +1 -0
- package/dist/event-processor-builder.d.ts +42 -3
- package/dist/event-processor-builder.d.ts.map +1 -1
- package/dist/event-processor-builder.js +49 -2
- package/dist/event-processor-builder.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/processor-configuration.d.ts +1 -1
- package/dist/processor-configuration.d.ts.map +1 -1
- package/dist/sequencing-policy.d.ts +41 -0
- package/dist/sequencing-policy.d.ts.map +1 -0
- package/dist/sequencing-policy.js +39 -0
- package/dist/sequencing-policy.js.map +1 -0
- package/dist/streaming-event-processor.d.ts +26 -0
- package/dist/streaming-event-processor.d.ts.map +1 -1
- package/dist/streaming-event-processor.js +96 -2
- package/dist/streaming-event-processor.js.map +1 -1
- package/dist/subscribing-event-processor.d.ts +6 -0
- package/dist/subscribing-event-processor.d.ts.map +1 -1
- package/dist/subscribing-event-processor.js.map +1 -1
- package/dist/tracking-event-processor.d.ts +27 -0
- package/dist/tracking-event-processor.d.ts.map +1 -1
- package/dist/tracking-event-processor.js +89 -1
- package/dist/tracking-event-processor.js.map +1 -1
- package/package.json +3 -3
- package/src/dead-letter-listener.ts +97 -0
- package/src/dead-letter-queue.ts +8 -17
- package/src/dead-letter-reprocessor.ts +95 -0
- package/src/dead-lettering-handler.ts +50 -26
- package/src/enqueue-policy.ts +118 -0
- package/src/event-processor-builder.ts +67 -3
- package/src/index.ts +33 -1
- package/src/processor-configuration.ts +1 -1
- package/src/sequencing-policy.ts +55 -0
- package/src/streaming-event-processor.ts +140 -8
- package/src/subscribing-event-processor.ts +6 -0
- package/src/tracking-event-processor.ts +128 -1
|
@@ -5,6 +5,14 @@ import type { StreamableEventSource, MessageStream, SequencedEvent } from "./eve
|
|
|
5
5
|
import type { UoWRunner } from "./unit-of-work.js"
|
|
6
6
|
import { runInNewUoW } from "./unit-of-work.js"
|
|
7
7
|
import type { TokenStore } from "./token-store.js"
|
|
8
|
+
import type { SequencedDeadLetterQueue, EnqueuePolicy, DeadLetter } from "./dead-letter-queue.js"
|
|
9
|
+
import type { SequencingPolicy } from "./sequencing-policy.js"
|
|
10
|
+
import { createDeadLetteringDelivery } from "./dead-lettering-handler.js"
|
|
11
|
+
import { type DeadLetterListener, noOpDeadLetterListener } from "./dead-letter-listener.js"
|
|
12
|
+
import {
|
|
13
|
+
type DeadLetterReprocessor,
|
|
14
|
+
createDeadLetterReprocessor,
|
|
15
|
+
} from "./dead-letter-reprocessor.js"
|
|
8
16
|
import type { EventProcessingErrorHandler } from "./tracking-event-processor.js"
|
|
9
17
|
import { loggingErrorHandler } from "./tracking-event-processor.js"
|
|
10
18
|
import type { HandlerEnhancerDefinition } from "./handler-enhancer.js"
|
|
@@ -55,6 +63,9 @@ export interface StreamingEventProcessor {
|
|
|
55
63
|
start(): Promise<void>
|
|
56
64
|
stop(): void
|
|
57
65
|
resetTokens(startPosition?: bigint, resetContext?: unknown): Promise<void>
|
|
66
|
+
/** Replay parked dead letters back through the handlers (oldest matching
|
|
67
|
+
* sequence). No-op returning false when no DLQ is configured. */
|
|
68
|
+
reprocessDeadLetters(filter?: (sequenceId: string) => boolean): Promise<boolean>
|
|
58
69
|
splitSegment(segmentId: number): Promise<boolean>
|
|
59
70
|
mergeSegment(segmentId: number): Promise<boolean>
|
|
60
71
|
releaseSegment(segmentId: number): Promise<void>
|
|
@@ -74,7 +85,27 @@ export interface StreamingEventProcessorOptions {
|
|
|
74
85
|
onEventDelivery?: () => void
|
|
75
86
|
unitOfWorkRunner?: UoWRunner
|
|
76
87
|
tokenStore?: TokenStore
|
|
88
|
+
/**
|
|
89
|
+
* Dead letter queue for poison-pill handling. When set, a handler failure
|
|
90
|
+
* parks the event in the DLQ (per {@link enqueuePolicy}) and the batch
|
|
91
|
+
* commits so the token advances past it — instead of redelivering the batch
|
|
92
|
+
* forever. The enqueue runs inside the batch UnitOfWork, so it commits in the
|
|
93
|
+
* same transaction as the token update.
|
|
94
|
+
*/
|
|
95
|
+
deadLetterQueue?: SequencedDeadLetterQueue
|
|
96
|
+
/** Decides whether a failed event is enqueued. Default: always enqueue. */
|
|
97
|
+
enqueuePolicy?: EnqueuePolicy
|
|
98
|
+
/** Decides each event's ordered sequence for the DLQ. Default: first tag value. */
|
|
99
|
+
sequencingPolicy?: SequencingPolicy
|
|
100
|
+
/** Observability hook for dead-letter lifecycle events. Default: no-op. */
|
|
101
|
+
deadLetterListener?: DeadLetterListener
|
|
102
|
+
/** When true, resetTokens() also clears this processor's DLQ (Axon allowReset). Default: false. */
|
|
103
|
+
resetClearsDeadLetters?: boolean
|
|
104
|
+
/** When set, automatically drains the DLQ on this interval (ms). Off by default. */
|
|
105
|
+
dlqRetryIntervalMs?: number
|
|
77
106
|
batchSize?: number
|
|
107
|
+
/** Delay before retrying after a batch failure, in ms. Backs off to avoid hot-looping a deterministic failure. */
|
|
108
|
+
errorBackoffMs?: number
|
|
78
109
|
errorHandler?: EventProcessingErrorHandler
|
|
79
110
|
/** Optional handler enhancer applied to all event handlers at setup time. */
|
|
80
111
|
handlerEnhancer?: HandlerEnhancerDefinition
|
|
@@ -95,7 +126,14 @@ export function createStreamingEventProcessor(
|
|
|
95
126
|
onEventDelivery,
|
|
96
127
|
unitOfWorkRunner = runInNewUoW,
|
|
97
128
|
tokenStore,
|
|
98
|
-
|
|
129
|
+
deadLetterQueue,
|
|
130
|
+
enqueuePolicy,
|
|
131
|
+
sequencingPolicy,
|
|
132
|
+
deadLetterListener = noOpDeadLetterListener(),
|
|
133
|
+
resetClearsDeadLetters = false,
|
|
134
|
+
dlqRetryIntervalMs,
|
|
135
|
+
batchSize = 1,
|
|
136
|
+
errorBackoffMs = 1000,
|
|
99
137
|
errorHandler = loggingErrorHandler(name),
|
|
100
138
|
handlerEnhancer,
|
|
101
139
|
onReset,
|
|
@@ -103,6 +141,31 @@ export function createStreamingEventProcessor(
|
|
|
103
141
|
|
|
104
142
|
const segment = 0
|
|
105
143
|
|
|
144
|
+
// Option A: when a DLQ is configured, handler failures are caught and parked
|
|
145
|
+
// (not propagated), so the batch commits and the token advances past the
|
|
146
|
+
// poison pill. Built once; invoked inside the batch UnitOfWork by deliverEvent.
|
|
147
|
+
const deadLetterDelivery = deadLetterQueue
|
|
148
|
+
? createDeadLetteringDelivery({
|
|
149
|
+
queue: deadLetterQueue,
|
|
150
|
+
policy: enqueuePolicy,
|
|
151
|
+
sequencingPolicy,
|
|
152
|
+
listener: deadLetterListener,
|
|
153
|
+
})
|
|
154
|
+
: undefined
|
|
155
|
+
|
|
156
|
+
// Reprocessor: replays a parked letter through the same handlers, with the
|
|
157
|
+
// same ALS resources as live delivery, so dependencies resolve identically.
|
|
158
|
+
const reprocessor: DeadLetterReprocessor | undefined = deadLetterQueue
|
|
159
|
+
? createDeadLetterReprocessor({
|
|
160
|
+
queue: deadLetterQueue,
|
|
161
|
+
policy: enqueuePolicy,
|
|
162
|
+
unitOfWorkRunner,
|
|
163
|
+
listener: deadLetterListener,
|
|
164
|
+
replay: replayDeadLetter,
|
|
165
|
+
})
|
|
166
|
+
: undefined
|
|
167
|
+
let dlqRetryTimer: ReturnType<typeof setInterval> | null = null
|
|
168
|
+
|
|
106
169
|
const handlerMap = new Map<string, Array<EventHandlerRegistration<any>>>()
|
|
107
170
|
for (const reg of eventHandlers) {
|
|
108
171
|
const eventName = qualifiedNameToString(reg.descriptor.name)
|
|
@@ -158,6 +221,22 @@ export function createStreamingEventProcessor(
|
|
|
158
221
|
} catch (err) {
|
|
159
222
|
lastError = err instanceof Error ? err : new Error(String(err))
|
|
160
223
|
console.error(`Event processor "${name}" error:`, err)
|
|
224
|
+
// Realign the live stream to the committed checkpoint. During batch
|
|
225
|
+
// accumulation the stream cursor (and any read-ahead buffer) advanced
|
|
226
|
+
// past this batch, but `token` was NOT advanced — the failing UnitOfWork
|
|
227
|
+
// never reached PREPARE_COMMIT. Closing discards the stream's buffer so
|
|
228
|
+
// the next cycle reopens at token.position() and re-reads — and thus
|
|
229
|
+
// redelivers — the failed batch. Without this the stream cursor outruns
|
|
230
|
+
// the checkpoint and the failed events are skipped until a restart.
|
|
231
|
+
// Mirrors Axon's close-and-reopen-from-token recovery. Back off before
|
|
232
|
+
// retrying so a deterministically failing handler can't hot-loop.
|
|
233
|
+
stream?.close()
|
|
234
|
+
stream = null
|
|
235
|
+
if (isRunning) {
|
|
236
|
+
if (processTimer !== null) clearTimeout(processTimer)
|
|
237
|
+
processTimer = setTimeout(processAvailable, errorBackoffMs)
|
|
238
|
+
}
|
|
239
|
+
return
|
|
161
240
|
} finally {
|
|
162
241
|
processing = false
|
|
163
242
|
}
|
|
@@ -170,24 +249,26 @@ export function createStreamingEventProcessor(
|
|
|
170
249
|
}
|
|
171
250
|
|
|
172
251
|
async function processFromStream() {
|
|
173
|
-
|
|
252
|
+
// Lazily (re)open the stream at the committed token. The error path nulls
|
|
253
|
+
// `stream` so processing resumes from the checkpoint, not a stale cursor.
|
|
254
|
+
if (!stream) openStream()
|
|
174
255
|
|
|
175
256
|
// Check for stream errors — reopen if needed
|
|
176
|
-
const streamError = stream
|
|
257
|
+
const streamError = stream!.error()
|
|
177
258
|
if (streamError) {
|
|
178
259
|
console.error(`Event processor "${name}": stream error, reopening:`, streamError)
|
|
179
|
-
stream
|
|
260
|
+
stream!.close()
|
|
180
261
|
stream = null
|
|
181
262
|
openStream()
|
|
182
263
|
return
|
|
183
264
|
}
|
|
184
265
|
|
|
185
266
|
const batch: SequencedEvent[] = []
|
|
186
|
-
let event = stream
|
|
267
|
+
let event = stream!.next()
|
|
187
268
|
while (event && batch.length < batchSize) {
|
|
188
269
|
batch.push(event)
|
|
189
|
-
if (batch.length < batchSize && stream
|
|
190
|
-
event = stream
|
|
270
|
+
if (batch.length < batchSize && stream!.hasNextAvailable()) {
|
|
271
|
+
event = stream!.next()
|
|
191
272
|
} else {
|
|
192
273
|
break
|
|
193
274
|
}
|
|
@@ -196,7 +277,7 @@ export function createStreamingEventProcessor(
|
|
|
196
277
|
if (batch.length > 0) {
|
|
197
278
|
caughtUp = false
|
|
198
279
|
await processBatch(batch)
|
|
199
|
-
if (stream
|
|
280
|
+
if (stream!.hasNextAvailable()) {
|
|
200
281
|
scheduleImmediate()
|
|
201
282
|
}
|
|
202
283
|
} else {
|
|
@@ -247,6 +328,14 @@ export function createStreamingEventProcessor(
|
|
|
247
328
|
// Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
|
|
248
329
|
if (onEventDelivery) onEventDelivery()
|
|
249
330
|
|
|
331
|
+
// DLQ path: park failures, keep the batch committable (Option A). The DLQ
|
|
332
|
+
// delivery enforces per-sequence ordering and never propagates, so the
|
|
333
|
+
// errorHandler / batch-redelivery path is bypassed while a DLQ is active.
|
|
334
|
+
if (deadLetterDelivery) {
|
|
335
|
+
await deadLetterDelivery.deliver(sequencedEvent, handlers)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
250
339
|
for (const reg of handlers) {
|
|
251
340
|
try {
|
|
252
341
|
await reg.handler({ ...event, sequence: sequencedEvent.sequence })
|
|
@@ -256,6 +345,27 @@ export function createStreamingEventProcessor(
|
|
|
256
345
|
}
|
|
257
346
|
}
|
|
258
347
|
|
|
348
|
+
// Replay a parked dead letter through the handlers, re-establishing the same
|
|
349
|
+
// ALS resources as live delivery. Throws on the first handler failure so the
|
|
350
|
+
// reprocessor can requeue the letter (delivery is at-least-once → handlers
|
|
351
|
+
// must be idempotent).
|
|
352
|
+
async function replayDeadLetter(letter: DeadLetter): Promise<void> {
|
|
353
|
+
const event = letter.message
|
|
354
|
+
const eventName = qualifiedNameToString(event.name)
|
|
355
|
+
const handlers = handlerMap.get(eventName)
|
|
356
|
+
if (!handlers || handlers.length === 0) return
|
|
357
|
+
|
|
358
|
+
if (stateManager !== undefined) setResource(STATE_MANAGER_KEY, stateManager as any)
|
|
359
|
+
if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
|
|
360
|
+
if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
|
|
361
|
+
|
|
362
|
+
const position =
|
|
363
|
+
typeof letter.diagnostics.position === "number" ? BigInt(letter.diagnostics.position) : 0n
|
|
364
|
+
for (const reg of handlers) {
|
|
365
|
+
await reg.handler({ ...event, sequence: position })
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
259
369
|
function scheduleImmediate() {
|
|
260
370
|
if (processTimer !== null) {
|
|
261
371
|
clearTimeout(processTimer)
|
|
@@ -287,6 +397,13 @@ export function createStreamingEventProcessor(
|
|
|
287
397
|
isRunning = true
|
|
288
398
|
openStream()
|
|
289
399
|
scheduleImmediate()
|
|
400
|
+
if (reprocessor && dlqRetryIntervalMs && dlqRetryIntervalMs > 0) {
|
|
401
|
+
dlqRetryTimer = setInterval(() => {
|
|
402
|
+
void reprocessor.reprocessAll().catch((err) => {
|
|
403
|
+
console.error(`Event processor "${name}": scheduled DLQ drain failed:`, err)
|
|
404
|
+
})
|
|
405
|
+
}, dlqRetryIntervalMs)
|
|
406
|
+
}
|
|
290
407
|
},
|
|
291
408
|
|
|
292
409
|
stop() {
|
|
@@ -295,12 +412,21 @@ export function createStreamingEventProcessor(
|
|
|
295
412
|
clearTimeout(processTimer)
|
|
296
413
|
processTimer = null
|
|
297
414
|
}
|
|
415
|
+
if (dlqRetryTimer !== null) {
|
|
416
|
+
clearInterval(dlqRetryTimer)
|
|
417
|
+
dlqRetryTimer = null
|
|
418
|
+
}
|
|
298
419
|
if (stream) {
|
|
299
420
|
stream.close()
|
|
300
421
|
stream = null
|
|
301
422
|
}
|
|
302
423
|
},
|
|
303
424
|
|
|
425
|
+
async reprocessDeadLetters(filter?: (sequenceId: string) => boolean) {
|
|
426
|
+
if (!reprocessor) return false
|
|
427
|
+
return reprocessor.reprocess(filter)
|
|
428
|
+
},
|
|
429
|
+
|
|
304
430
|
async resetTokens(startPosition: bigint = 0n, resetContext?: unknown) {
|
|
305
431
|
if (isRunning) {
|
|
306
432
|
throw new Error(`Processor "${name}" must be stopped before resetting tokens`)
|
|
@@ -322,6 +448,12 @@ export function createStreamingEventProcessor(
|
|
|
322
448
|
await tokenStore.store(name, segment, token)
|
|
323
449
|
}
|
|
324
450
|
|
|
451
|
+
// Axon allowReset: only clear parked letters when opted in (a replay
|
|
452
|
+
// re-derives view state, making prior dead letters meaningless).
|
|
453
|
+
if (resetClearsDeadLetters && deadLetterQueue) {
|
|
454
|
+
await deadLetterQueue.clear()
|
|
455
|
+
}
|
|
456
|
+
|
|
325
457
|
if (onReset) {
|
|
326
458
|
await onReset()
|
|
327
459
|
}
|
|
@@ -27,6 +27,12 @@ export type { SubscribableEventSource } from "./event-bus.js"
|
|
|
27
27
|
* - Processes events synchronously with the publisher
|
|
28
28
|
* - Is suitable for in-memory projections that don't need persistence
|
|
29
29
|
*
|
|
30
|
+
* It also does **not** support a dead-letter queue — deliberately. A DLQ exists
|
|
31
|
+
* to let a processor advance its token past a poison pill and reprocess later;
|
|
32
|
+
* a subscribing processor has no token and runs in the publisher's call stack,
|
|
33
|
+
* so a failure must surface to the publisher (via the error handler), not be
|
|
34
|
+
* silently parked. Use a tracking/streaming processor with `.deadLetterQueue()`
|
|
35
|
+
* when you need dead-lettering.
|
|
30
36
|
*/
|
|
31
37
|
export interface SubscribingEventProcessor {
|
|
32
38
|
readonly name: string
|
|
@@ -5,6 +5,14 @@ import type { StreamableEventSource, MessageStream, SequencedEvent } from "./eve
|
|
|
5
5
|
import type { UoWRunner } from "./unit-of-work.js"
|
|
6
6
|
import { runInNewUoW } from "./unit-of-work.js"
|
|
7
7
|
import type { TokenStore } from "./token-store.js"
|
|
8
|
+
import type { SequencedDeadLetterQueue, EnqueuePolicy, DeadLetter } from "./dead-letter-queue.js"
|
|
9
|
+
import type { SequencingPolicy } from "./sequencing-policy.js"
|
|
10
|
+
import { createDeadLetteringDelivery } from "./dead-lettering-handler.js"
|
|
11
|
+
import { type DeadLetterListener, noOpDeadLetterListener } from "./dead-letter-listener.js"
|
|
12
|
+
import {
|
|
13
|
+
type DeadLetterReprocessor,
|
|
14
|
+
createDeadLetterReprocessor,
|
|
15
|
+
} from "./dead-letter-reprocessor.js"
|
|
8
16
|
import type { TrackingToken } from "./tracking-token.js"
|
|
9
17
|
import {
|
|
10
18
|
globalSequenceToken,
|
|
@@ -47,6 +55,12 @@ export interface TrackingEventProcessor {
|
|
|
47
55
|
* The processor must be stopped before calling this.
|
|
48
56
|
*/
|
|
49
57
|
resetTokens(startPosition?: bigint, resetContext?: unknown): Promise<void>
|
|
58
|
+
/**
|
|
59
|
+
* Replay parked dead letters back through the handlers (the oldest matching
|
|
60
|
+
* sequence). No-op returning false when no DLQ is configured. Safe to call
|
|
61
|
+
* whether or not the processor is running.
|
|
62
|
+
*/
|
|
63
|
+
reprocessDeadLetters(filter?: (sequenceId: string) => boolean): Promise<boolean>
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
export interface TrackingEventProcessorOptions {
|
|
@@ -63,6 +77,24 @@ export interface TrackingEventProcessorOptions {
|
|
|
63
77
|
onEventDelivery?: () => void
|
|
64
78
|
unitOfWorkRunner?: UoWRunner
|
|
65
79
|
tokenStore?: TokenStore
|
|
80
|
+
/**
|
|
81
|
+
* Dead letter queue for poison-pill handling. When set, a handler failure
|
|
82
|
+
* parks the event in the DLQ (per {@link enqueuePolicy}) and the batch
|
|
83
|
+
* commits so the token advances past it — instead of redelivering the batch
|
|
84
|
+
* forever. The enqueue runs inside the batch UnitOfWork, so it commits in the
|
|
85
|
+
* same transaction as the token update.
|
|
86
|
+
*/
|
|
87
|
+
deadLetterQueue?: SequencedDeadLetterQueue
|
|
88
|
+
/** Decides whether a failed event is enqueued. Default: always enqueue. */
|
|
89
|
+
enqueuePolicy?: EnqueuePolicy
|
|
90
|
+
/** Decides each event's ordered sequence for the DLQ. Default: first tag value. */
|
|
91
|
+
sequencingPolicy?: SequencingPolicy
|
|
92
|
+
/** Observability hook for dead-letter lifecycle events. Default: no-op. */
|
|
93
|
+
deadLetterListener?: DeadLetterListener
|
|
94
|
+
/** When true, resetTokens() also clears this processor's DLQ (Axon allowReset). Default: false. */
|
|
95
|
+
resetClearsDeadLetters?: boolean
|
|
96
|
+
/** When set, automatically drains the DLQ on this interval (ms). Off by default. */
|
|
97
|
+
dlqRetryIntervalMs?: number
|
|
66
98
|
/** Polling interval when no events are available (ms). Default: 500. */
|
|
67
99
|
pollingIntervalMs?: number
|
|
68
100
|
batchSize?: number
|
|
@@ -118,8 +150,14 @@ export function createTrackingEventProcessor(
|
|
|
118
150
|
onEventDelivery,
|
|
119
151
|
unitOfWorkRunner = runInNewUoW,
|
|
120
152
|
tokenStore,
|
|
153
|
+
deadLetterQueue,
|
|
154
|
+
enqueuePolicy,
|
|
155
|
+
sequencingPolicy,
|
|
156
|
+
deadLetterListener = noOpDeadLetterListener(),
|
|
157
|
+
resetClearsDeadLetters = false,
|
|
158
|
+
dlqRetryIntervalMs,
|
|
121
159
|
pollingIntervalMs = 500,
|
|
122
|
-
batchSize =
|
|
160
|
+
batchSize = 1,
|
|
123
161
|
errorHandler = loggingErrorHandler(name),
|
|
124
162
|
handlerEnhancer,
|
|
125
163
|
onReset,
|
|
@@ -127,6 +165,31 @@ export function createTrackingEventProcessor(
|
|
|
127
165
|
|
|
128
166
|
const segment = 0
|
|
129
167
|
|
|
168
|
+
// Option A: when a DLQ is configured, handler failures are caught and parked
|
|
169
|
+
// (not propagated), so the batch commits and the token advances past the
|
|
170
|
+
// poison pill. Built once; invoked inside the batch UnitOfWork by deliverEvent.
|
|
171
|
+
const deadLetterDelivery = deadLetterQueue
|
|
172
|
+
? createDeadLetteringDelivery({
|
|
173
|
+
queue: deadLetterQueue,
|
|
174
|
+
policy: enqueuePolicy,
|
|
175
|
+
sequencingPolicy,
|
|
176
|
+
listener: deadLetterListener,
|
|
177
|
+
})
|
|
178
|
+
: undefined
|
|
179
|
+
|
|
180
|
+
// Reprocessor: replays a parked letter through the same handlers, with the
|
|
181
|
+
// same ALS resources as live delivery, so dependencies resolve identically.
|
|
182
|
+
const reprocessor: DeadLetterReprocessor | undefined = deadLetterQueue
|
|
183
|
+
? createDeadLetterReprocessor({
|
|
184
|
+
queue: deadLetterQueue,
|
|
185
|
+
policy: enqueuePolicy,
|
|
186
|
+
unitOfWorkRunner,
|
|
187
|
+
listener: deadLetterListener,
|
|
188
|
+
replay: replayDeadLetter,
|
|
189
|
+
})
|
|
190
|
+
: undefined
|
|
191
|
+
let dlqRetryTimer: ReturnType<typeof setInterval> | null = null
|
|
192
|
+
|
|
130
193
|
const handlerMap = new Map<string, Array<EventHandlerRegistration<any>>>()
|
|
131
194
|
for (const reg of eventHandlers) {
|
|
132
195
|
const eventName = qualifiedNameToString(reg.descriptor.name)
|
|
@@ -224,6 +287,16 @@ export function createTrackingEventProcessor(
|
|
|
224
287
|
}
|
|
225
288
|
} catch (err) {
|
|
226
289
|
console.error(`Event processor "${name}" error during poll:`, err)
|
|
290
|
+
// Realign the live stream to the committed checkpoint. During batch
|
|
291
|
+
// accumulation the stream cursor (and any read-ahead buffer) advanced
|
|
292
|
+
// past this batch, but `token` was NOT advanced — the failing UnitOfWork
|
|
293
|
+
// never reached PREPARE_COMMIT. Closing discards the stream's buffer so
|
|
294
|
+
// the next poll reopens at token.position() and re-reads — and thus
|
|
295
|
+
// redelivers — the failed batch. Without this the stream cursor outruns
|
|
296
|
+
// the checkpoint and the failed events are skipped until a restart.
|
|
297
|
+
// Mirrors Axon's close-and-reopen-from-token recovery.
|
|
298
|
+
stream?.close()
|
|
299
|
+
stream = null
|
|
227
300
|
if (isRunning) pollTimer = setTimeout(poll, pollingIntervalMs * 2)
|
|
228
301
|
} finally {
|
|
229
302
|
processing = false
|
|
@@ -266,6 +339,14 @@ export function createTrackingEventProcessor(
|
|
|
266
339
|
// Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
|
|
267
340
|
if (onEventDelivery) onEventDelivery()
|
|
268
341
|
|
|
342
|
+
// DLQ path: park failures, keep the batch committable (Option A). The DLQ
|
|
343
|
+
// delivery enforces per-sequence ordering and never propagates, so the
|
|
344
|
+
// errorHandler / batch-redelivery path is bypassed while a DLQ is active.
|
|
345
|
+
if (deadLetterDelivery) {
|
|
346
|
+
await deadLetterDelivery.deliver(sequencedEvent, handlers)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
269
350
|
for (const reg of handlers) {
|
|
270
351
|
try {
|
|
271
352
|
await reg.handler({ ...event, sequence: sequencedEvent.sequence })
|
|
@@ -275,6 +356,27 @@ export function createTrackingEventProcessor(
|
|
|
275
356
|
}
|
|
276
357
|
}
|
|
277
358
|
|
|
359
|
+
// Replay a parked dead letter through the handlers, re-establishing the same
|
|
360
|
+
// ALS resources as live delivery. Throws on the first handler failure so the
|
|
361
|
+
// reprocessor can requeue the letter (delivery is at-least-once → handlers
|
|
362
|
+
// must be idempotent).
|
|
363
|
+
async function replayDeadLetter(letter: DeadLetter): Promise<void> {
|
|
364
|
+
const event = letter.message
|
|
365
|
+
const eventName = qualifiedNameToString(event.name)
|
|
366
|
+
const handlers = handlerMap.get(eventName)
|
|
367
|
+
if (!handlers || handlers.length === 0) return
|
|
368
|
+
|
|
369
|
+
if (stateManager !== undefined) setResource(STATE_MANAGER_KEY, stateManager as any)
|
|
370
|
+
if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
|
|
371
|
+
if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
|
|
372
|
+
|
|
373
|
+
const position =
|
|
374
|
+
typeof letter.diagnostics.position === "number" ? BigInt(letter.diagnostics.position) : 0n
|
|
375
|
+
for (const reg of handlers) {
|
|
376
|
+
await reg.handler({ ...event, sequence: position })
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
278
380
|
function scheduleImmediate() {
|
|
279
381
|
if (pollTimer !== null) {
|
|
280
382
|
clearTimeout(pollTimer)
|
|
@@ -293,6 +395,15 @@ export function createTrackingEventProcessor(
|
|
|
293
395
|
await initialize()
|
|
294
396
|
isRunning = true
|
|
295
397
|
poll()
|
|
398
|
+
// Optional scheduled DLQ drain — operators can also trigger reprocessing
|
|
399
|
+
// manually via reprocessDeadLetters().
|
|
400
|
+
if (reprocessor && dlqRetryIntervalMs && dlqRetryIntervalMs > 0) {
|
|
401
|
+
dlqRetryTimer = setInterval(() => {
|
|
402
|
+
void reprocessor.reprocessAll().catch((err) => {
|
|
403
|
+
console.error(`Event processor "${name}": scheduled DLQ drain failed:`, err)
|
|
404
|
+
})
|
|
405
|
+
}, dlqRetryIntervalMs)
|
|
406
|
+
}
|
|
296
407
|
},
|
|
297
408
|
|
|
298
409
|
stop() {
|
|
@@ -301,12 +412,21 @@ export function createTrackingEventProcessor(
|
|
|
301
412
|
clearTimeout(pollTimer)
|
|
302
413
|
pollTimer = null
|
|
303
414
|
}
|
|
415
|
+
if (dlqRetryTimer !== null) {
|
|
416
|
+
clearInterval(dlqRetryTimer)
|
|
417
|
+
dlqRetryTimer = null
|
|
418
|
+
}
|
|
304
419
|
if (stream) {
|
|
305
420
|
stream.close()
|
|
306
421
|
stream = null
|
|
307
422
|
}
|
|
308
423
|
},
|
|
309
424
|
|
|
425
|
+
async reprocessDeadLetters(filter?: (sequenceId: string) => boolean) {
|
|
426
|
+
if (!reprocessor) return false
|
|
427
|
+
return reprocessor.reprocess(filter)
|
|
428
|
+
},
|
|
429
|
+
|
|
310
430
|
async resetTokens(startPosition: bigint = 0n, resetContext?: unknown) {
|
|
311
431
|
if (isRunning) {
|
|
312
432
|
throw new Error(`Processor "${name}" must be stopped before resetting tokens`)
|
|
@@ -328,6 +448,13 @@ export function createTrackingEventProcessor(
|
|
|
328
448
|
await tokenStore.store(name, segment, token)
|
|
329
449
|
}
|
|
330
450
|
|
|
451
|
+
// Axon allowReset: a replay re-derives view state from scratch, so stale
|
|
452
|
+
// dead letters from the prior run are meaningless. Only clear when opted
|
|
453
|
+
// in — otherwise parked letters survive a reset for manual handling.
|
|
454
|
+
if (resetClearsDeadLetters && deadLetterQueue) {
|
|
455
|
+
await deadLetterQueue.clear()
|
|
456
|
+
}
|
|
457
|
+
|
|
331
458
|
if (onReset) {
|
|
332
459
|
await onReset()
|
|
333
460
|
}
|