@kronos-ts/messaging 0.3.1 → 0.5.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.
Files changed (63) hide show
  1. package/dist/command-handling-module.d.ts.map +1 -1
  2. package/dist/command-handling-module.js +5 -1
  3. package/dist/command-handling-module.js.map +1 -1
  4. package/dist/dead-letter-listener.d.ts +36 -0
  5. package/dist/dead-letter-listener.d.ts.map +1 -0
  6. package/dist/dead-letter-listener.js +69 -0
  7. package/dist/dead-letter-listener.js.map +1 -0
  8. package/dist/dead-letter-queue.d.ts +5 -7
  9. package/dist/dead-letter-queue.d.ts.map +1 -1
  10. package/dist/dead-letter-queue.js +3 -13
  11. package/dist/dead-letter-queue.js.map +1 -1
  12. package/dist/dead-letter-reprocessor.d.ts +44 -0
  13. package/dist/dead-letter-reprocessor.d.ts.map +1 -0
  14. package/dist/dead-letter-reprocessor.js +48 -0
  15. package/dist/dead-letter-reprocessor.js.map +1 -0
  16. package/dist/dead-lettering-handler.d.ts +7 -4
  17. package/dist/dead-lettering-handler.d.ts.map +1 -1
  18. package/dist/dead-lettering-handler.js +36 -15
  19. package/dist/dead-lettering-handler.js.map +1 -1
  20. package/dist/enqueue-policy.d.ts +79 -0
  21. package/dist/enqueue-policy.d.ts.map +1 -0
  22. package/dist/enqueue-policy.js +95 -0
  23. package/dist/enqueue-policy.js.map +1 -0
  24. package/dist/event-processor-builder.d.ts +42 -3
  25. package/dist/event-processor-builder.d.ts.map +1 -1
  26. package/dist/event-processor-builder.js +49 -2
  27. package/dist/event-processor-builder.js.map +1 -1
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +9 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/processor-configuration.d.ts +1 -1
  33. package/dist/processor-configuration.d.ts.map +1 -1
  34. package/dist/sequencing-policy.d.ts +41 -0
  35. package/dist/sequencing-policy.d.ts.map +1 -0
  36. package/dist/sequencing-policy.js +39 -0
  37. package/dist/sequencing-policy.js.map +1 -0
  38. package/dist/streaming-event-processor.d.ts +27 -0
  39. package/dist/streaming-event-processor.d.ts.map +1 -1
  40. package/dist/streaming-event-processor.js +81 -2
  41. package/dist/streaming-event-processor.js.map +1 -1
  42. package/dist/subscribing-event-processor.d.ts +9 -0
  43. package/dist/subscribing-event-processor.d.ts.map +1 -1
  44. package/dist/subscribing-event-processor.js +4 -2
  45. package/dist/subscribing-event-processor.js.map +1 -1
  46. package/dist/tracking-event-processor.d.ts +30 -0
  47. package/dist/tracking-event-processor.d.ts.map +1 -1
  48. package/dist/tracking-event-processor.js +84 -2
  49. package/dist/tracking-event-processor.js.map +1 -1
  50. package/package.json +3 -3
  51. package/src/command-handling-module.ts +6 -0
  52. package/src/dead-letter-listener.ts +97 -0
  53. package/src/dead-letter-queue.ts +8 -17
  54. package/src/dead-letter-reprocessor.ts +95 -0
  55. package/src/dead-lettering-handler.ts +50 -26
  56. package/src/enqueue-policy.ts +118 -0
  57. package/src/event-processor-builder.ts +67 -3
  58. package/src/index.ts +33 -1
  59. package/src/processor-configuration.ts +1 -1
  60. package/src/sequencing-policy.ts +55 -0
  61. package/src/streaming-event-processor.ts +119 -2
  62. package/src/subscribing-event-processor.ts +12 -1
  63. package/src/tracking-event-processor.ts +125 -2
@@ -0,0 +1,55 @@
1
+ import { qualifiedNameToString } from "@kronos-ts/common"
2
+ import type { EventMessage } from "./message.js"
3
+
4
+ /**
5
+ * Decides which ordered sequence an event belongs to.
6
+ *
7
+ * Events sharing a sequence identifier are processed in order; if one is
8
+ * dead-lettered, subsequent events in the same sequence are parked behind it
9
+ * (see {@link createDeadLetteringDelivery}). This is the minimal analog of
10
+ * Axon's `SequencingPolicy` — a plain function rather than a class hierarchy,
11
+ * because Kronos does not (yet) do segmented parallel processing where the
12
+ * policy would also drive segment assignment. When/if it does, this same type
13
+ * is the seam to extend.
14
+ */
15
+ export type SequencingPolicy = (event: EventMessage) => string
16
+
17
+ /**
18
+ * Sequences events by the value of a named tag, falling back to the event name
19
+ * when the tag is absent. The DCB/tag-world analog of Axon's
20
+ * `SequentialPerAggregatePolicy` (aggregate id → sequence).
21
+ *
22
+ * Prefer this — naming the tag explicitly — over relying on tag *order*.
23
+ *
24
+ * ```typescript
25
+ * trackingProcessor("balances")
26
+ * .deadLetterQueue(dlq)
27
+ * .sequencingPolicy(sequentialPerTag("accountId"))
28
+ * ```
29
+ */
30
+ export function sequentialPerTag(tagKey: string): SequencingPolicy {
31
+ return (event) => {
32
+ const match = event.tags.find((t) => t.key === tagKey)
33
+ return match ? match.value : qualifiedNameToString(event.name)
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Default policy: sequence by the first tag value, else the event name.
39
+ *
40
+ * Order-dependent on the tag list, so it is a reasonable default but a poor
41
+ * deliberate choice — prefer {@link sequentialPerTag} with an explicit key.
42
+ */
43
+ export const defaultSequencingPolicy: SequencingPolicy = (event) => {
44
+ if (event.tags.length > 0) {
45
+ return event.tags[0]!.value
46
+ }
47
+ return qualifiedNameToString(event.name)
48
+ }
49
+
50
+ /**
51
+ * Full concurrency: every event is its own singleton sequence (keyed by the
52
+ * unique message identifier), so no two events ever block each other. Use when
53
+ * handlers carry no cross-event ordering requirement.
54
+ */
55
+ export const fullConcurrencyPolicy: SequencingPolicy = (event) => event.identifier
@@ -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"
@@ -20,7 +28,8 @@ import { REPLAY_STATE_KEY } from "./replay-token.js"
20
28
  import { setResource, onPrepareCommit } from "./processing-state.js"
21
29
  import type { CommandBus } from "./command-bus.js"
22
30
  import type { QueryBus } from "./query-bus.js"
23
- import { STATE_MANAGER_KEY } from "@kronos-ts/eventsourcing"
31
+ import { STATE_MANAGER_KEY, EVENT_SCHEDULER_KEY } from "@kronos-ts/eventsourcing"
32
+ import type { EventScheduler } from "./event-scheduler.js"
24
33
  import { COMMAND_BUS_KEY } from "./send.js"
25
34
  import { QUERY_BUS_KEY } from "./emit-update.js"
26
35
 
@@ -55,6 +64,9 @@ export interface StreamingEventProcessor {
55
64
  start(): Promise<void>
56
65
  stop(): void
57
66
  resetTokens(startPosition?: bigint, resetContext?: unknown): Promise<void>
67
+ /** Replay parked dead letters back through the handlers (oldest matching
68
+ * sequence). No-op returning false when no DLQ is configured. */
69
+ reprocessDeadLetters(filter?: (sequenceId: string) => boolean): Promise<boolean>
58
70
  splitSegment(segmentId: number): Promise<boolean>
59
71
  mergeSegment(segmentId: number): Promise<boolean>
60
72
  releaseSegment(segmentId: number): Promise<void>
@@ -70,10 +82,30 @@ export interface StreamingEventProcessorOptions {
70
82
  commandBus?: CommandBus
71
83
  /** Query bus injected into ALS at handler-invocation entry (D-44). */
72
84
  queryBus?: QueryBus
85
+ /** Event scheduler injected into ALS at handler-invocation entry (read by schedule()). */
86
+ eventScheduler?: EventScheduler
73
87
  /** Optional per-event callback fired inside the UoW before handler invocation (e.g. monitoring). */
74
88
  onEventDelivery?: () => void
75
89
  unitOfWorkRunner?: UoWRunner
76
90
  tokenStore?: TokenStore
91
+ /**
92
+ * Dead letter queue for poison-pill handling. When set, a handler failure
93
+ * parks the event in the DLQ (per {@link enqueuePolicy}) and the batch
94
+ * commits so the token advances past it — instead of redelivering the batch
95
+ * forever. The enqueue runs inside the batch UnitOfWork, so it commits in the
96
+ * same transaction as the token update.
97
+ */
98
+ deadLetterQueue?: SequencedDeadLetterQueue
99
+ /** Decides whether a failed event is enqueued. Default: always enqueue. */
100
+ enqueuePolicy?: EnqueuePolicy
101
+ /** Decides each event's ordered sequence for the DLQ. Default: first tag value. */
102
+ sequencingPolicy?: SequencingPolicy
103
+ /** Observability hook for dead-letter lifecycle events. Default: no-op. */
104
+ deadLetterListener?: DeadLetterListener
105
+ /** When true, resetTokens() also clears this processor's DLQ (Axon allowReset). Default: false. */
106
+ resetClearsDeadLetters?: boolean
107
+ /** When set, automatically drains the DLQ on this interval (ms). Off by default. */
108
+ dlqRetryIntervalMs?: number
77
109
  batchSize?: number
78
110
  /** Delay before retrying after a batch failure, in ms. Backs off to avoid hot-looping a deterministic failure. */
79
111
  errorBackoffMs?: number
@@ -94,10 +126,17 @@ export function createStreamingEventProcessor(
94
126
  stateManager,
95
127
  commandBus,
96
128
  queryBus,
129
+ eventScheduler,
97
130
  onEventDelivery,
98
131
  unitOfWorkRunner = runInNewUoW,
99
132
  tokenStore,
100
- batchSize = 100,
133
+ deadLetterQueue,
134
+ enqueuePolicy,
135
+ sequencingPolicy,
136
+ deadLetterListener = noOpDeadLetterListener(),
137
+ resetClearsDeadLetters = false,
138
+ dlqRetryIntervalMs,
139
+ batchSize = 1,
101
140
  errorBackoffMs = 1000,
102
141
  errorHandler = loggingErrorHandler(name),
103
142
  handlerEnhancer,
@@ -106,6 +145,31 @@ export function createStreamingEventProcessor(
106
145
 
107
146
  const segment = 0
108
147
 
148
+ // Option A: when a DLQ is configured, handler failures are caught and parked
149
+ // (not propagated), so the batch commits and the token advances past the
150
+ // poison pill. Built once; invoked inside the batch UnitOfWork by deliverEvent.
151
+ const deadLetterDelivery = deadLetterQueue
152
+ ? createDeadLetteringDelivery({
153
+ queue: deadLetterQueue,
154
+ policy: enqueuePolicy,
155
+ sequencingPolicy,
156
+ listener: deadLetterListener,
157
+ })
158
+ : undefined
159
+
160
+ // Reprocessor: replays a parked letter through the same handlers, with the
161
+ // same ALS resources as live delivery, so dependencies resolve identically.
162
+ const reprocessor: DeadLetterReprocessor | undefined = deadLetterQueue
163
+ ? createDeadLetterReprocessor({
164
+ queue: deadLetterQueue,
165
+ policy: enqueuePolicy,
166
+ unitOfWorkRunner,
167
+ listener: deadLetterListener,
168
+ replay: replayDeadLetter,
169
+ })
170
+ : undefined
171
+ let dlqRetryTimer: ReturnType<typeof setInterval> | null = null
172
+
109
173
  const handlerMap = new Map<string, Array<EventHandlerRegistration<any>>>()
110
174
  for (const reg of eventHandlers) {
111
175
  const eventName = qualifiedNameToString(reg.descriptor.name)
@@ -265,9 +329,18 @@ export function createStreamingEventProcessor(
265
329
  if (stateManager !== undefined) setResource(STATE_MANAGER_KEY, stateManager as any)
266
330
  if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
267
331
  if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
332
+ if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
268
333
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
269
334
  if (onEventDelivery) onEventDelivery()
270
335
 
336
+ // DLQ path: park failures, keep the batch committable (Option A). The DLQ
337
+ // delivery enforces per-sequence ordering and never propagates, so the
338
+ // errorHandler / batch-redelivery path is bypassed while a DLQ is active.
339
+ if (deadLetterDelivery) {
340
+ await deadLetterDelivery.deliver(sequencedEvent, handlers)
341
+ return
342
+ }
343
+
271
344
  for (const reg of handlers) {
272
345
  try {
273
346
  await reg.handler({ ...event, sequence: sequencedEvent.sequence })
@@ -277,6 +350,28 @@ export function createStreamingEventProcessor(
277
350
  }
278
351
  }
279
352
 
353
+ // Replay a parked dead letter through the handlers, re-establishing the same
354
+ // ALS resources as live delivery. Throws on the first handler failure so the
355
+ // reprocessor can requeue the letter (delivery is at-least-once → handlers
356
+ // must be idempotent).
357
+ async function replayDeadLetter(letter: DeadLetter): Promise<void> {
358
+ const event = letter.message
359
+ const eventName = qualifiedNameToString(event.name)
360
+ const handlers = handlerMap.get(eventName)
361
+ if (!handlers || handlers.length === 0) return
362
+
363
+ if (stateManager !== undefined) setResource(STATE_MANAGER_KEY, stateManager as any)
364
+ if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
365
+ if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
366
+ if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
367
+
368
+ const position =
369
+ typeof letter.diagnostics.position === "number" ? BigInt(letter.diagnostics.position) : 0n
370
+ for (const reg of handlers) {
371
+ await reg.handler({ ...event, sequence: position })
372
+ }
373
+ }
374
+
280
375
  function scheduleImmediate() {
281
376
  if (processTimer !== null) {
282
377
  clearTimeout(processTimer)
@@ -308,6 +403,13 @@ export function createStreamingEventProcessor(
308
403
  isRunning = true
309
404
  openStream()
310
405
  scheduleImmediate()
406
+ if (reprocessor && dlqRetryIntervalMs && dlqRetryIntervalMs > 0) {
407
+ dlqRetryTimer = setInterval(() => {
408
+ void reprocessor.reprocessAll().catch((err) => {
409
+ console.error(`Event processor "${name}": scheduled DLQ drain failed:`, err)
410
+ })
411
+ }, dlqRetryIntervalMs)
412
+ }
311
413
  },
312
414
 
313
415
  stop() {
@@ -316,12 +418,21 @@ export function createStreamingEventProcessor(
316
418
  clearTimeout(processTimer)
317
419
  processTimer = null
318
420
  }
421
+ if (dlqRetryTimer !== null) {
422
+ clearInterval(dlqRetryTimer)
423
+ dlqRetryTimer = null
424
+ }
319
425
  if (stream) {
320
426
  stream.close()
321
427
  stream = null
322
428
  }
323
429
  },
324
430
 
431
+ async reprocessDeadLetters(filter?: (sequenceId: string) => boolean) {
432
+ if (!reprocessor) return false
433
+ return reprocessor.reprocess(filter)
434
+ },
435
+
325
436
  async resetTokens(startPosition: bigint = 0n, resetContext?: unknown) {
326
437
  if (isRunning) {
327
438
  throw new Error(`Processor "${name}" must be stopped before resetting tokens`)
@@ -343,6 +454,12 @@ export function createStreamingEventProcessor(
343
454
  await tokenStore.store(name, segment, token)
344
455
  }
345
456
 
457
+ // Axon allowReset: only clear parked letters when opted in (a replay
458
+ // re-derives view state, making prior dead letters meaningless).
459
+ if (resetClearsDeadLetters && deadLetterQueue) {
460
+ await deadLetterQueue.clear()
461
+ }
462
+
346
463
  if (onReset) {
347
464
  await onReset()
348
465
  }
@@ -11,7 +11,8 @@ import type { CommandBus } from "./command-bus.js"
11
11
  import type { QueryBus } from "./query-bus.js"
12
12
  import type { HandlerEnhancerDefinition } from "./handler-enhancer.js"
13
13
  import { setResource } from "./processing-state.js"
14
- import { STATE_MANAGER_KEY } from "@kronos-ts/eventsourcing"
14
+ import { STATE_MANAGER_KEY, EVENT_SCHEDULER_KEY } from "@kronos-ts/eventsourcing"
15
+ import type { EventScheduler } from "./event-scheduler.js"
15
16
  import { COMMAND_BUS_KEY } from "./send.js"
16
17
  import { QUERY_BUS_KEY } from "./emit-update.js"
17
18
 
@@ -27,6 +28,12 @@ export type { SubscribableEventSource } from "./event-bus.js"
27
28
  * - Processes events synchronously with the publisher
28
29
  * - Is suitable for in-memory projections that don't need persistence
29
30
  *
31
+ * It also does **not** support a dead-letter queue — deliberately. A DLQ exists
32
+ * to let a processor advance its token past a poison pill and reprocess later;
33
+ * a subscribing processor has no token and runs in the publisher's call stack,
34
+ * so a failure must surface to the publisher (via the error handler), not be
35
+ * silently parked. Use a tracking/streaming processor with `.deadLetterQueue()`
36
+ * when you need dead-lettering.
30
37
  */
31
38
  export interface SubscribingEventProcessor {
32
39
  readonly name: string
@@ -47,6 +54,8 @@ export interface SubscribingEventProcessorOptions {
47
54
  commandBus?: CommandBus
48
55
  /** Query bus injected into ALS at handler-invocation entry (D-44). */
49
56
  queryBus?: QueryBus
57
+ /** Event scheduler injected into ALS at handler-invocation entry (read by schedule()). */
58
+ eventScheduler?: EventScheduler
50
59
  /** Optional per-event callback fired inside the UoW before handler invocation (e.g. monitoring). */
51
60
  onEventDelivery?: () => void
52
61
  unitOfWorkRunner?: UoWRunner
@@ -77,6 +86,7 @@ export function createSubscribingEventProcessor(
77
86
  stateManager,
78
87
  commandBus,
79
88
  queryBus,
89
+ eventScheduler,
80
90
  onEventDelivery,
81
91
  unitOfWorkRunner = runInNewUoW,
82
92
  errorHandler = loggingErrorHandler(name),
@@ -130,6 +140,7 @@ export function createSubscribingEventProcessor(
130
140
  if (stateManager !== undefined) setResource(STATE_MANAGER_KEY, stateManager as any)
131
141
  if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
132
142
  if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
143
+ if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
133
144
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
134
145
  if (onEventDelivery) onEventDelivery()
135
146
 
@@ -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,
@@ -18,7 +26,8 @@ import { setResource, onPrepareCommit } from "./processing-state.js"
18
26
  import type { HandlerEnhancerDefinition } from "./handler-enhancer.js"
19
27
  import type { CommandBus } from "./command-bus.js"
20
28
  import type { QueryBus } from "./query-bus.js"
21
- import { STATE_MANAGER_KEY } from "@kronos-ts/eventsourcing"
29
+ import { STATE_MANAGER_KEY, EVENT_SCHEDULER_KEY } from "@kronos-ts/eventsourcing"
30
+ import type { EventScheduler } from "./event-scheduler.js"
22
31
  import { COMMAND_BUS_KEY } from "./send.js"
23
32
  import { QUERY_BUS_KEY } from "./emit-update.js"
24
33
 
@@ -47,6 +56,12 @@ export interface TrackingEventProcessor {
47
56
  * The processor must be stopped before calling this.
48
57
  */
49
58
  resetTokens(startPosition?: bigint, resetContext?: unknown): Promise<void>
59
+ /**
60
+ * Replay parked dead letters back through the handlers (the oldest matching
61
+ * sequence). No-op returning false when no DLQ is configured. Safe to call
62
+ * whether or not the processor is running.
63
+ */
64
+ reprocessDeadLetters(filter?: (sequenceId: string) => boolean): Promise<boolean>
50
65
  }
51
66
 
52
67
  export interface TrackingEventProcessorOptions {
@@ -59,10 +74,30 @@ export interface TrackingEventProcessorOptions {
59
74
  commandBus?: CommandBus
60
75
  /** Query bus injected into ALS at handler-invocation entry (D-44). */
61
76
  queryBus?: QueryBus
77
+ /** Event scheduler injected into ALS at handler-invocation entry (read by schedule()). */
78
+ eventScheduler?: EventScheduler
62
79
  /** Optional per-event callback fired inside the UoW before handler invocation (e.g. monitoring). */
63
80
  onEventDelivery?: () => void
64
81
  unitOfWorkRunner?: UoWRunner
65
82
  tokenStore?: TokenStore
83
+ /**
84
+ * Dead letter queue for poison-pill handling. When set, a handler failure
85
+ * parks the event in the DLQ (per {@link enqueuePolicy}) and the batch
86
+ * commits so the token advances past it — instead of redelivering the batch
87
+ * forever. The enqueue runs inside the batch UnitOfWork, so it commits in the
88
+ * same transaction as the token update.
89
+ */
90
+ deadLetterQueue?: SequencedDeadLetterQueue
91
+ /** Decides whether a failed event is enqueued. Default: always enqueue. */
92
+ enqueuePolicy?: EnqueuePolicy
93
+ /** Decides each event's ordered sequence for the DLQ. Default: first tag value. */
94
+ sequencingPolicy?: SequencingPolicy
95
+ /** Observability hook for dead-letter lifecycle events. Default: no-op. */
96
+ deadLetterListener?: DeadLetterListener
97
+ /** When true, resetTokens() also clears this processor's DLQ (Axon allowReset). Default: false. */
98
+ resetClearsDeadLetters?: boolean
99
+ /** When set, automatically drains the DLQ on this interval (ms). Off by default. */
100
+ dlqRetryIntervalMs?: number
66
101
  /** Polling interval when no events are available (ms). Default: 500. */
67
102
  pollingIntervalMs?: number
68
103
  batchSize?: number
@@ -115,11 +150,18 @@ export function createTrackingEventProcessor(
115
150
  stateManager,
116
151
  commandBus,
117
152
  queryBus,
153
+ eventScheduler,
118
154
  onEventDelivery,
119
155
  unitOfWorkRunner = runInNewUoW,
120
156
  tokenStore,
157
+ deadLetterQueue,
158
+ enqueuePolicy,
159
+ sequencingPolicy,
160
+ deadLetterListener = noOpDeadLetterListener(),
161
+ resetClearsDeadLetters = false,
162
+ dlqRetryIntervalMs,
121
163
  pollingIntervalMs = 500,
122
- batchSize = 100,
164
+ batchSize = 1,
123
165
  errorHandler = loggingErrorHandler(name),
124
166
  handlerEnhancer,
125
167
  onReset,
@@ -127,6 +169,31 @@ export function createTrackingEventProcessor(
127
169
 
128
170
  const segment = 0
129
171
 
172
+ // Option A: when a DLQ is configured, handler failures are caught and parked
173
+ // (not propagated), so the batch commits and the token advances past the
174
+ // poison pill. Built once; invoked inside the batch UnitOfWork by deliverEvent.
175
+ const deadLetterDelivery = deadLetterQueue
176
+ ? createDeadLetteringDelivery({
177
+ queue: deadLetterQueue,
178
+ policy: enqueuePolicy,
179
+ sequencingPolicy,
180
+ listener: deadLetterListener,
181
+ })
182
+ : undefined
183
+
184
+ // Reprocessor: replays a parked letter through the same handlers, with the
185
+ // same ALS resources as live delivery, so dependencies resolve identically.
186
+ const reprocessor: DeadLetterReprocessor | undefined = deadLetterQueue
187
+ ? createDeadLetterReprocessor({
188
+ queue: deadLetterQueue,
189
+ policy: enqueuePolicy,
190
+ unitOfWorkRunner,
191
+ listener: deadLetterListener,
192
+ replay: replayDeadLetter,
193
+ })
194
+ : undefined
195
+ let dlqRetryTimer: ReturnType<typeof setInterval> | null = null
196
+
130
197
  const handlerMap = new Map<string, Array<EventHandlerRegistration<any>>>()
131
198
  for (const reg of eventHandlers) {
132
199
  const eventName = qualifiedNameToString(reg.descriptor.name)
@@ -273,9 +340,18 @@ export function createTrackingEventProcessor(
273
340
  if (stateManager !== undefined) setResource(STATE_MANAGER_KEY, stateManager as any)
274
341
  if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
275
342
  if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
343
+ if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
276
344
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
277
345
  if (onEventDelivery) onEventDelivery()
278
346
 
347
+ // DLQ path: park failures, keep the batch committable (Option A). The DLQ
348
+ // delivery enforces per-sequence ordering and never propagates, so the
349
+ // errorHandler / batch-redelivery path is bypassed while a DLQ is active.
350
+ if (deadLetterDelivery) {
351
+ await deadLetterDelivery.deliver(sequencedEvent, handlers)
352
+ return
353
+ }
354
+
279
355
  for (const reg of handlers) {
280
356
  try {
281
357
  await reg.handler({ ...event, sequence: sequencedEvent.sequence })
@@ -285,6 +361,28 @@ export function createTrackingEventProcessor(
285
361
  }
286
362
  }
287
363
 
364
+ // Replay a parked dead letter through the handlers, re-establishing the same
365
+ // ALS resources as live delivery. Throws on the first handler failure so the
366
+ // reprocessor can requeue the letter (delivery is at-least-once → handlers
367
+ // must be idempotent).
368
+ async function replayDeadLetter(letter: DeadLetter): Promise<void> {
369
+ const event = letter.message
370
+ const eventName = qualifiedNameToString(event.name)
371
+ const handlers = handlerMap.get(eventName)
372
+ if (!handlers || handlers.length === 0) return
373
+
374
+ if (stateManager !== undefined) setResource(STATE_MANAGER_KEY, stateManager as any)
375
+ if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
376
+ if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
377
+ if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
378
+
379
+ const position =
380
+ typeof letter.diagnostics.position === "number" ? BigInt(letter.diagnostics.position) : 0n
381
+ for (const reg of handlers) {
382
+ await reg.handler({ ...event, sequence: position })
383
+ }
384
+ }
385
+
288
386
  function scheduleImmediate() {
289
387
  if (pollTimer !== null) {
290
388
  clearTimeout(pollTimer)
@@ -303,6 +401,15 @@ export function createTrackingEventProcessor(
303
401
  await initialize()
304
402
  isRunning = true
305
403
  poll()
404
+ // Optional scheduled DLQ drain — operators can also trigger reprocessing
405
+ // manually via reprocessDeadLetters().
406
+ if (reprocessor && dlqRetryIntervalMs && dlqRetryIntervalMs > 0) {
407
+ dlqRetryTimer = setInterval(() => {
408
+ void reprocessor.reprocessAll().catch((err) => {
409
+ console.error(`Event processor "${name}": scheduled DLQ drain failed:`, err)
410
+ })
411
+ }, dlqRetryIntervalMs)
412
+ }
306
413
  },
307
414
 
308
415
  stop() {
@@ -311,12 +418,21 @@ export function createTrackingEventProcessor(
311
418
  clearTimeout(pollTimer)
312
419
  pollTimer = null
313
420
  }
421
+ if (dlqRetryTimer !== null) {
422
+ clearInterval(dlqRetryTimer)
423
+ dlqRetryTimer = null
424
+ }
314
425
  if (stream) {
315
426
  stream.close()
316
427
  stream = null
317
428
  }
318
429
  },
319
430
 
431
+ async reprocessDeadLetters(filter?: (sequenceId: string) => boolean) {
432
+ if (!reprocessor) return false
433
+ return reprocessor.reprocess(filter)
434
+ },
435
+
320
436
  async resetTokens(startPosition: bigint = 0n, resetContext?: unknown) {
321
437
  if (isRunning) {
322
438
  throw new Error(`Processor "${name}" must be stopped before resetting tokens`)
@@ -338,6 +454,13 @@ export function createTrackingEventProcessor(
338
454
  await tokenStore.store(name, segment, token)
339
455
  }
340
456
 
457
+ // Axon allowReset: a replay re-derives view state from scratch, so stale
458
+ // dead letters from the prior run are meaningless. Only clear when opted
459
+ // in — otherwise parked letters survive a reset for manual handling.
460
+ if (resetClearsDeadLetters && deadLetterQueue) {
461
+ await deadLetterQueue.clear()
462
+ }
463
+
341
464
  if (onReset) {
342
465
  await onReset()
343
466
  }