@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.
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 +26 -0
  36. package/dist/streaming-event-processor.d.ts.map +1 -1
  37. package/dist/streaming-event-processor.js +96 -2
  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 +89 -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 +140 -8
  57. package/src/subscribing-event-processor.ts +6 -0
  58. 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
- batchSize = 100,
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
- if (!stream) return
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.error()
257
+ const streamError = stream!.error()
177
258
  if (streamError) {
178
259
  console.error(`Event processor "${name}": stream error, reopening:`, streamError)
179
- stream.close()
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.next()
267
+ let event = stream!.next()
187
268
  while (event && batch.length < batchSize) {
188
269
  batch.push(event)
189
- if (batch.length < batchSize && stream.hasNextAvailable()) {
190
- event = stream.next()
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.hasNextAvailable()) {
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 = 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)
@@ -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
  }