@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.
- package/dist/command-handling-module.d.ts.map +1 -1
- package/dist/command-handling-module.js +5 -1
- package/dist/command-handling-module.js.map +1 -1
- 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 +27 -0
- package/dist/streaming-event-processor.d.ts.map +1 -1
- package/dist/streaming-event-processor.js +81 -2
- package/dist/streaming-event-processor.js.map +1 -1
- package/dist/subscribing-event-processor.d.ts +9 -0
- package/dist/subscribing-event-processor.d.ts.map +1 -1
- package/dist/subscribing-event-processor.js +4 -2
- package/dist/subscribing-event-processor.js.map +1 -1
- package/dist/tracking-event-processor.d.ts +30 -0
- package/dist/tracking-event-processor.d.ts.map +1 -1
- package/dist/tracking-event-processor.js +84 -2
- package/dist/tracking-event-processor.js.map +1 -1
- package/package.json +3 -3
- package/src/command-handling-module.ts +6 -0
- 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 +119 -2
- package/src/subscribing-event-processor.ts +12 -1
- 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
|
-
|
|
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 =
|
|
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
|
}
|