@kronos-ts/messaging 0.3.1 → 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.
Files changed (58) hide show
  1. package/dist/dead-letter-listener.d.ts +36 -0
  2. package/dist/dead-letter-listener.d.ts.map +1 -0
  3. package/dist/dead-letter-listener.js +69 -0
  4. package/dist/dead-letter-listener.js.map +1 -0
  5. package/dist/dead-letter-queue.d.ts +5 -7
  6. package/dist/dead-letter-queue.d.ts.map +1 -1
  7. package/dist/dead-letter-queue.js +3 -13
  8. package/dist/dead-letter-queue.js.map +1 -1
  9. package/dist/dead-letter-reprocessor.d.ts +44 -0
  10. package/dist/dead-letter-reprocessor.d.ts.map +1 -0
  11. package/dist/dead-letter-reprocessor.js +48 -0
  12. package/dist/dead-letter-reprocessor.js.map +1 -0
  13. package/dist/dead-lettering-handler.d.ts +7 -4
  14. package/dist/dead-lettering-handler.d.ts.map +1 -1
  15. package/dist/dead-lettering-handler.js +36 -15
  16. package/dist/dead-lettering-handler.js.map +1 -1
  17. package/dist/enqueue-policy.d.ts +79 -0
  18. package/dist/enqueue-policy.d.ts.map +1 -0
  19. package/dist/enqueue-policy.js +95 -0
  20. package/dist/enqueue-policy.js.map +1 -0
  21. package/dist/event-processor-builder.d.ts +42 -3
  22. package/dist/event-processor-builder.d.ts.map +1 -1
  23. package/dist/event-processor-builder.js +49 -2
  24. package/dist/event-processor-builder.js.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/processor-configuration.d.ts +1 -1
  30. package/dist/processor-configuration.d.ts.map +1 -1
  31. package/dist/sequencing-policy.d.ts +41 -0
  32. package/dist/sequencing-policy.d.ts.map +1 -0
  33. package/dist/sequencing-policy.js +39 -0
  34. package/dist/sequencing-policy.js.map +1 -0
  35. package/dist/streaming-event-processor.d.ts +24 -0
  36. package/dist/streaming-event-processor.d.ts.map +1 -1
  37. package/dist/streaming-event-processor.js +76 -1
  38. package/dist/streaming-event-processor.js.map +1 -1
  39. package/dist/subscribing-event-processor.d.ts +6 -0
  40. package/dist/subscribing-event-processor.d.ts.map +1 -1
  41. package/dist/subscribing-event-processor.js.map +1 -1
  42. package/dist/tracking-event-processor.d.ts +27 -0
  43. package/dist/tracking-event-processor.d.ts.map +1 -1
  44. package/dist/tracking-event-processor.js +79 -1
  45. package/dist/tracking-event-processor.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/dead-letter-listener.ts +97 -0
  48. package/src/dead-letter-queue.ts +8 -17
  49. package/src/dead-letter-reprocessor.ts +95 -0
  50. package/src/dead-lettering-handler.ts +50 -26
  51. package/src/enqueue-policy.ts +118 -0
  52. package/src/event-processor-builder.ts +67 -3
  53. package/src/index.ts +33 -1
  54. package/src/processor-configuration.ts +1 -1
  55. package/src/sequencing-policy.ts +55 -0
  56. package/src/streaming-event-processor.ts +112 -1
  57. package/src/subscribing-event-processor.ts +6 -0
  58. package/src/tracking-event-processor.ts +118 -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,6 +85,24 @@ 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
78
107
  /** Delay before retrying after a batch failure, in ms. Backs off to avoid hot-looping a deterministic failure. */
79
108
  errorBackoffMs?: number
@@ -97,7 +126,13 @@ export function createStreamingEventProcessor(
97
126
  onEventDelivery,
98
127
  unitOfWorkRunner = runInNewUoW,
99
128
  tokenStore,
100
- batchSize = 100,
129
+ deadLetterQueue,
130
+ enqueuePolicy,
131
+ sequencingPolicy,
132
+ deadLetterListener = noOpDeadLetterListener(),
133
+ resetClearsDeadLetters = false,
134
+ dlqRetryIntervalMs,
135
+ batchSize = 1,
101
136
  errorBackoffMs = 1000,
102
137
  errorHandler = loggingErrorHandler(name),
103
138
  handlerEnhancer,
@@ -106,6 +141,31 @@ export function createStreamingEventProcessor(
106
141
 
107
142
  const segment = 0
108
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
+
109
169
  const handlerMap = new Map<string, Array<EventHandlerRegistration<any>>>()
110
170
  for (const reg of eventHandlers) {
111
171
  const eventName = qualifiedNameToString(reg.descriptor.name)
@@ -268,6 +328,14 @@ export function createStreamingEventProcessor(
268
328
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
269
329
  if (onEventDelivery) onEventDelivery()
270
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
+
271
339
  for (const reg of handlers) {
272
340
  try {
273
341
  await reg.handler({ ...event, sequence: sequencedEvent.sequence })
@@ -277,6 +345,27 @@ export function createStreamingEventProcessor(
277
345
  }
278
346
  }
279
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
+
280
369
  function scheduleImmediate() {
281
370
  if (processTimer !== null) {
282
371
  clearTimeout(processTimer)
@@ -308,6 +397,13 @@ export function createStreamingEventProcessor(
308
397
  isRunning = true
309
398
  openStream()
310
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
+ }
311
407
  },
312
408
 
313
409
  stop() {
@@ -316,12 +412,21 @@ export function createStreamingEventProcessor(
316
412
  clearTimeout(processTimer)
317
413
  processTimer = null
318
414
  }
415
+ if (dlqRetryTimer !== null) {
416
+ clearInterval(dlqRetryTimer)
417
+ dlqRetryTimer = null
418
+ }
319
419
  if (stream) {
320
420
  stream.close()
321
421
  stream = null
322
422
  }
323
423
  },
324
424
 
425
+ async reprocessDeadLetters(filter?: (sequenceId: string) => boolean) {
426
+ if (!reprocessor) return false
427
+ return reprocessor.reprocess(filter)
428
+ },
429
+
325
430
  async resetTokens(startPosition: bigint = 0n, resetContext?: unknown) {
326
431
  if (isRunning) {
327
432
  throw new Error(`Processor "${name}" must be stopped before resetting tokens`)
@@ -343,6 +448,12 @@ export function createStreamingEventProcessor(
343
448
  await tokenStore.store(name, segment, token)
344
449
  }
345
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
+
346
457
  if (onReset) {
347
458
  await onReset()
348
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 = 100,
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)
@@ -276,6 +339,14 @@ export function createTrackingEventProcessor(
276
339
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
277
340
  if (onEventDelivery) onEventDelivery()
278
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
+
279
350
  for (const reg of handlers) {
280
351
  try {
281
352
  await reg.handler({ ...event, sequence: sequencedEvent.sequence })
@@ -285,6 +356,27 @@ export function createTrackingEventProcessor(
285
356
  }
286
357
  }
287
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
+
288
380
  function scheduleImmediate() {
289
381
  if (pollTimer !== null) {
290
382
  clearTimeout(pollTimer)
@@ -303,6 +395,15 @@ export function createTrackingEventProcessor(
303
395
  await initialize()
304
396
  isRunning = true
305
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
+ }
306
407
  },
307
408
 
308
409
  stop() {
@@ -311,12 +412,21 @@ export function createTrackingEventProcessor(
311
412
  clearTimeout(pollTimer)
312
413
  pollTimer = null
313
414
  }
415
+ if (dlqRetryTimer !== null) {
416
+ clearInterval(dlqRetryTimer)
417
+ dlqRetryTimer = null
418
+ }
314
419
  if (stream) {
315
420
  stream.close()
316
421
  stream = null
317
422
  }
318
423
  },
319
424
 
425
+ async reprocessDeadLetters(filter?: (sequenceId: string) => boolean) {
426
+ if (!reprocessor) return false
427
+ return reprocessor.reprocess(filter)
428
+ },
429
+
320
430
  async resetTokens(startPosition: bigint = 0n, resetContext?: unknown) {
321
431
  if (isRunning) {
322
432
  throw new Error(`Processor "${name}" must be stopped before resetting tokens`)
@@ -338,6 +448,13 @@ export function createTrackingEventProcessor(
338
448
  await tokenStore.store(name, segment, token)
339
449
  }
340
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
+
341
458
  if (onReset) {
342
459
  await onReset()
343
460
  }