@kronos-ts/messaging 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dead-letter-listener.d.ts +36 -0
- package/dist/dead-letter-listener.d.ts.map +1 -0
- package/dist/dead-letter-listener.js +69 -0
- package/dist/dead-letter-listener.js.map +1 -0
- package/dist/dead-letter-queue.d.ts +5 -7
- package/dist/dead-letter-queue.d.ts.map +1 -1
- package/dist/dead-letter-queue.js +3 -13
- package/dist/dead-letter-queue.js.map +1 -1
- package/dist/dead-letter-reprocessor.d.ts +44 -0
- package/dist/dead-letter-reprocessor.d.ts.map +1 -0
- package/dist/dead-letter-reprocessor.js +48 -0
- package/dist/dead-letter-reprocessor.js.map +1 -0
- package/dist/dead-lettering-handler.d.ts +7 -4
- package/dist/dead-lettering-handler.d.ts.map +1 -1
- package/dist/dead-lettering-handler.js +36 -15
- package/dist/dead-lettering-handler.js.map +1 -1
- package/dist/enqueue-policy.d.ts +79 -0
- package/dist/enqueue-policy.d.ts.map +1 -0
- package/dist/enqueue-policy.js +95 -0
- package/dist/enqueue-policy.js.map +1 -0
- package/dist/event-processor-builder.d.ts +42 -3
- package/dist/event-processor-builder.d.ts.map +1 -1
- package/dist/event-processor-builder.js +49 -2
- package/dist/event-processor-builder.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/processor-configuration.d.ts +1 -1
- package/dist/processor-configuration.d.ts.map +1 -1
- package/dist/sequencing-policy.d.ts +41 -0
- package/dist/sequencing-policy.d.ts.map +1 -0
- package/dist/sequencing-policy.js +39 -0
- package/dist/sequencing-policy.js.map +1 -0
- package/dist/streaming-event-processor.d.ts +26 -0
- package/dist/streaming-event-processor.d.ts.map +1 -1
- package/dist/streaming-event-processor.js +96 -2
- package/dist/streaming-event-processor.js.map +1 -1
- package/dist/subscribing-event-processor.d.ts +6 -0
- package/dist/subscribing-event-processor.d.ts.map +1 -1
- package/dist/subscribing-event-processor.js.map +1 -1
- package/dist/tracking-event-processor.d.ts +27 -0
- package/dist/tracking-event-processor.d.ts.map +1 -1
- package/dist/tracking-event-processor.js +89 -1
- package/dist/tracking-event-processor.js.map +1 -1
- package/package.json +3 -3
- package/src/dead-letter-listener.ts +97 -0
- package/src/dead-letter-queue.ts +8 -17
- package/src/dead-letter-reprocessor.ts +95 -0
- package/src/dead-lettering-handler.ts +50 -26
- package/src/enqueue-policy.ts +118 -0
- package/src/event-processor-builder.ts +67 -3
- package/src/index.ts +33 -1
- package/src/processor-configuration.ts +1 -1
- package/src/sequencing-policy.ts +55 -0
- package/src/streaming-event-processor.ts +140 -8
- package/src/subscribing-event-processor.ts +6 -0
- package/src/tracking-event-processor.ts +128 -1
package/src/dead-letter-queue.ts
CHANGED
|
@@ -43,17 +43,6 @@ export interface EnqueuePolicy {
|
|
|
43
43
|
decide(letter: DeadLetter, cause: Error): EnqueueDecision
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
* Default policy: always enqueue with the original cause.
|
|
48
|
-
*/
|
|
49
|
-
export function alwaysEnqueuePolicy(): EnqueuePolicy {
|
|
50
|
-
return {
|
|
51
|
-
decide() {
|
|
52
|
-
return { shouldEnqueue: true }
|
|
53
|
-
},
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
46
|
/**
|
|
58
47
|
* A sequenced dead letter queue that maintains ordering within sequences.
|
|
59
48
|
*
|
|
@@ -125,10 +114,10 @@ export interface SequencedDeadLetterQueue {
|
|
|
125
114
|
): Promise<boolean>
|
|
126
115
|
|
|
127
116
|
/** Total number of dead letters across all sequences. */
|
|
128
|
-
size(): number
|
|
117
|
+
size(): Promise<number>
|
|
129
118
|
|
|
130
119
|
/** Number of sequences with dead letters. */
|
|
131
|
-
amountOfSequences(): number
|
|
120
|
+
amountOfSequences(): Promise<number>
|
|
132
121
|
|
|
133
122
|
/** Clear all dead letters. */
|
|
134
123
|
clear(): Promise<void>
|
|
@@ -136,8 +125,10 @@ export interface SequencedDeadLetterQueue {
|
|
|
136
125
|
/**
|
|
137
126
|
* Check if the queue is full for the given sequence.
|
|
138
127
|
* Returns true if max sequences or max sequence size is reached.
|
|
128
|
+
*
|
|
129
|
+
* Async so persistent backends can answer with a count query.
|
|
139
130
|
*/
|
|
140
|
-
isFull(sequenceIdentifier: string): boolean
|
|
131
|
+
isFull(sequenceIdentifier: string): Promise<boolean>
|
|
141
132
|
}
|
|
142
133
|
|
|
143
134
|
/**
|
|
@@ -272,7 +263,7 @@ export function createInMemoryDeadLetterQueue(options?: {
|
|
|
272
263
|
}
|
|
273
264
|
},
|
|
274
265
|
|
|
275
|
-
size() {
|
|
266
|
+
async size() {
|
|
276
267
|
let total = 0
|
|
277
268
|
for (const letters of sequences.values()) {
|
|
278
269
|
total += letters.length
|
|
@@ -280,7 +271,7 @@ export function createInMemoryDeadLetterQueue(options?: {
|
|
|
280
271
|
return total
|
|
281
272
|
},
|
|
282
273
|
|
|
283
|
-
amountOfSequences() {
|
|
274
|
+
async amountOfSequences() {
|
|
284
275
|
return sequences.size
|
|
285
276
|
},
|
|
286
277
|
|
|
@@ -289,7 +280,7 @@ export function createInMemoryDeadLetterQueue(options?: {
|
|
|
289
280
|
processing.clear()
|
|
290
281
|
},
|
|
291
282
|
|
|
292
|
-
isFull(sequenceIdentifier) {
|
|
283
|
+
async isFull(sequenceIdentifier) {
|
|
293
284
|
const seq = sequences.get(sequenceIdentifier)
|
|
294
285
|
if (seq) {
|
|
295
286
|
return seq.length >= maxSequenceSize
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { emptyMetadata } from "@kronos-ts/common"
|
|
2
|
+
import type { DeadLetter, SequencedDeadLetterQueue, EnqueuePolicy } from "./dead-letter-queue.js"
|
|
3
|
+
import { Decisions, alwaysEnqueuePolicy } from "./enqueue-policy.js"
|
|
4
|
+
import { type DeadLetterListener, noOpDeadLetterListener } from "./dead-letter-listener.js"
|
|
5
|
+
import type { UoWRunner } from "./unit-of-work.js"
|
|
6
|
+
import { runInNewUoW } from "./unit-of-work.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Replays a single dead letter through its handlers. Resolves on success and
|
|
10
|
+
* rejects with the handler's error on failure. Supplied by the owning
|
|
11
|
+
* processor, which re-establishes the same ALS resources (state manager,
|
|
12
|
+
* command/query bus) it uses for live delivery.
|
|
13
|
+
*/
|
|
14
|
+
export type DeadLetterReplay = (letter: DeadLetter) => Promise<void>
|
|
15
|
+
|
|
16
|
+
export interface DeadLetterReprocessorOptions {
|
|
17
|
+
queue: SequencedDeadLetterQueue
|
|
18
|
+
replay: DeadLetterReplay
|
|
19
|
+
/** Decides requeue-vs-evict after a failed retry. Default: always requeue. */
|
|
20
|
+
policy?: EnqueuePolicy
|
|
21
|
+
/** Runs each reprocess inside a UnitOfWork so the claim/evict/requeue and any
|
|
22
|
+
* handler side effects commit in one transaction. Default: a fresh UoW. */
|
|
23
|
+
unitOfWorkRunner?: UoWRunner
|
|
24
|
+
listener?: DeadLetterListener
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DeadLetterReprocessor {
|
|
28
|
+
/**
|
|
29
|
+
* Reprocess the oldest dead-letter sequence matching the filter, once.
|
|
30
|
+
* Walks the sequence head-to-tail: each letter that replays successfully is
|
|
31
|
+
* evicted; the first that fails is requeued (per policy) and stops the walk,
|
|
32
|
+
* keeping the sequence ordered. Returns true if a sequence was processed.
|
|
33
|
+
*/
|
|
34
|
+
reprocess(filter?: (sequenceId: string) => boolean): Promise<boolean>
|
|
35
|
+
/**
|
|
36
|
+
* One drain pass over every currently-parked sequence matching the filter,
|
|
37
|
+
* each attempted at most once (a still-failing sequence is left requeued, not
|
|
38
|
+
* retried in a hot loop). Returns the number of sequences attempted.
|
|
39
|
+
*/
|
|
40
|
+
reprocessAll(filter?: (sequenceId: string) => boolean): Promise<number>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Binds a {@link SequencedDeadLetterQueue} to a replay function, producing the
|
|
45
|
+
* Axon `SequencedDeadLetterProcessor` capability: drive parked events back
|
|
46
|
+
* through their handlers, evicting on success and requeuing on continued
|
|
47
|
+
* failure. Trigger it manually or on a schedule (see the processor's
|
|
48
|
+
* `dlqRetryIntervalMs`).
|
|
49
|
+
*/
|
|
50
|
+
export function createDeadLetterReprocessor(
|
|
51
|
+
options: DeadLetterReprocessorOptions,
|
|
52
|
+
): DeadLetterReprocessor {
|
|
53
|
+
const {
|
|
54
|
+
queue,
|
|
55
|
+
replay,
|
|
56
|
+
policy = alwaysEnqueuePolicy(),
|
|
57
|
+
unitOfWorkRunner = runInNewUoW,
|
|
58
|
+
listener = noOpDeadLetterListener(),
|
|
59
|
+
} = options
|
|
60
|
+
|
|
61
|
+
async function reprocess(filter: (sequenceId: string) => boolean = () => true): Promise<boolean> {
|
|
62
|
+
return unitOfWorkRunner(emptyMetadata(), async () =>
|
|
63
|
+
queue.process(filter, async (letter) => {
|
|
64
|
+
try {
|
|
65
|
+
await replay(letter)
|
|
66
|
+
listener.onReprocessSuccess(letter)
|
|
67
|
+
listener.onEvicted(letter)
|
|
68
|
+
return Decisions.evict()
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
71
|
+
listener.onReprocessFailure(letter, error)
|
|
72
|
+
const decision = policy.decide(letter, error)
|
|
73
|
+
if (decision.shouldEnqueue) {
|
|
74
|
+
listener.onRequeued(letter)
|
|
75
|
+
} else {
|
|
76
|
+
listener.onEvicted(letter)
|
|
77
|
+
}
|
|
78
|
+
return decision
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function reprocessAll(filter: (sequenceId: string) => boolean = () => true): Promise<number> {
|
|
85
|
+
const ids = (await queue.sequenceIdentifiers()).filter(filter)
|
|
86
|
+
let attempted = 0
|
|
87
|
+
for (const id of ids) {
|
|
88
|
+
const processed = await reprocess((s) => s === id)
|
|
89
|
+
if (processed) attempted++
|
|
90
|
+
}
|
|
91
|
+
return attempted
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { reprocess, reprocessAll }
|
|
95
|
+
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { qualifiedNameToString } from "@kronos-ts/common"
|
|
2
|
-
import type { EventMessage } from "./message.js"
|
|
3
2
|
import type { EventHandlerRegistration } from "./handler.js"
|
|
4
3
|
import type { SequencedEvent } from "./event-source.js"
|
|
4
|
+
import { type SequencingPolicy, defaultSequencingPolicy } from "./sequencing-policy.js"
|
|
5
5
|
import {
|
|
6
6
|
type SequencedDeadLetterQueue,
|
|
7
7
|
type EnqueuePolicy,
|
|
8
|
-
alwaysEnqueuePolicy,
|
|
9
8
|
createDeadLetter,
|
|
9
|
+
DeadLetterQueueOverflowError,
|
|
10
10
|
} from "./dead-letter-queue.js"
|
|
11
|
+
import { alwaysEnqueuePolicy } from "./enqueue-policy.js"
|
|
12
|
+
import {
|
|
13
|
+
type DeadLetterListener,
|
|
14
|
+
noOpDeadLetterListener,
|
|
15
|
+
} from "./dead-letter-listener.js"
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* Options for dead-lettering event handler wrapper.
|
|
@@ -18,11 +23,13 @@ export interface DeadLetteringOptions {
|
|
|
18
23
|
/** Policy deciding whether to dead-letter a failed event. Default: always. */
|
|
19
24
|
policy?: EnqueuePolicy
|
|
20
25
|
/**
|
|
21
|
-
*
|
|
26
|
+
* Decides which ordered sequence an event belongs to. Events in the same
|
|
22
27
|
* sequence are ordered — if one fails, subsequent ones are blocked.
|
|
23
|
-
* Default:
|
|
28
|
+
* Default: {@link defaultSequencingPolicy} (first tag value, else event name).
|
|
24
29
|
*/
|
|
25
|
-
|
|
30
|
+
sequencingPolicy?: SequencingPolicy
|
|
31
|
+
/** Observability hook for dead-letter lifecycle events. Default: no-op. */
|
|
32
|
+
listener?: DeadLetterListener
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -41,7 +48,8 @@ export function createDeadLetteringDelivery(options: DeadLetteringOptions) {
|
|
|
41
48
|
const {
|
|
42
49
|
queue,
|
|
43
50
|
policy = alwaysEnqueuePolicy(),
|
|
44
|
-
|
|
51
|
+
sequencingPolicy = defaultSequencingPolicy,
|
|
52
|
+
listener = noOpDeadLetterListener(),
|
|
45
53
|
} = options
|
|
46
54
|
|
|
47
55
|
return {
|
|
@@ -57,19 +65,27 @@ export function createDeadLetteringDelivery(options: DeadLetteringOptions) {
|
|
|
57
65
|
handlers: Array<EventHandlerRegistration<any>>,
|
|
58
66
|
): Promise<void> {
|
|
59
67
|
const event = sequencedEvent.event
|
|
60
|
-
const seqId =
|
|
68
|
+
const seqId = sequencingPolicy(event)
|
|
61
69
|
|
|
62
|
-
// If this sequence already has dead letters, block this event too
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
// If this sequence already has dead letters, block this event too —
|
|
71
|
+
// preserving per-sequence ordering. A full-queue rejection here
|
|
72
|
+
// propagates as backpressure (see enqueue path below).
|
|
73
|
+
let blockedLetter: ReturnType<typeof createDeadLetter> | undefined
|
|
74
|
+
const blocked = await withOverflowReported(seqId, () =>
|
|
75
|
+
queue.enqueueIfPresent(seqId, () => {
|
|
76
|
+
blockedLetter = createDeadLetter(
|
|
77
|
+
event,
|
|
78
|
+
new Error("Blocked: previous event in sequence failed"),
|
|
79
|
+
seqId,
|
|
80
|
+
{ blocked: true, position: Number(sequencedEvent.sequence) },
|
|
81
|
+
)
|
|
82
|
+
return blockedLetter
|
|
83
|
+
}),
|
|
71
84
|
)
|
|
72
|
-
if (blocked)
|
|
85
|
+
if (blocked) {
|
|
86
|
+
if (blockedLetter) listener.onEnqueued(blockedLetter, { blocked: true })
|
|
87
|
+
return
|
|
88
|
+
}
|
|
73
89
|
|
|
74
90
|
// Try to deliver to all handlers
|
|
75
91
|
for (const reg of handlers) {
|
|
@@ -84,26 +100,34 @@ export function createDeadLetteringDelivery(options: DeadLetteringOptions) {
|
|
|
84
100
|
|
|
85
101
|
const decision = policy.decide(letter, error)
|
|
86
102
|
if (decision.shouldEnqueue) {
|
|
87
|
-
|
|
103
|
+
const enqueued = {
|
|
88
104
|
...letter,
|
|
89
105
|
cause: decision.cause ?? letter.cause,
|
|
90
106
|
diagnostics: decision.diagnostics
|
|
91
107
|
? { ...letter.diagnostics, ...decision.diagnostics }
|
|
92
108
|
: letter.diagnostics,
|
|
93
|
-
}
|
|
109
|
+
}
|
|
110
|
+
// A full queue throws DeadLetterQueueOverflowError, which propagates
|
|
111
|
+
// to stall and redeliver the batch (Axon backpressure) — surfaced
|
|
112
|
+
// via the listener rather than silently looping.
|
|
113
|
+
await withOverflowReported(seqId, () => queue.enqueue(enqueued))
|
|
114
|
+
listener.onEnqueued(enqueued, { blocked: false })
|
|
94
115
|
}
|
|
95
|
-
// Error is consumed by DLQ — don't propagate
|
|
116
|
+
// Error is consumed by DLQ (parked or dropped) — don't propagate.
|
|
96
117
|
return
|
|
97
118
|
}
|
|
98
119
|
}
|
|
99
120
|
},
|
|
100
121
|
}
|
|
101
|
-
}
|
|
102
122
|
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
async function withOverflowReported<T>(seqId: string, op: () => Promise<T>): Promise<T> {
|
|
124
|
+
try {
|
|
125
|
+
return await op()
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof DeadLetterQueueOverflowError) {
|
|
128
|
+
listener.onOverflow(seqId, err)
|
|
129
|
+
}
|
|
130
|
+
throw err
|
|
131
|
+
}
|
|
107
132
|
}
|
|
108
|
-
return qualifiedNameToString(event.name)
|
|
109
133
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { DeadLetter, EnqueueDecision, EnqueuePolicy } from "./dead-letter-queue.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Diagnostics key under which retry policies track how many times a letter has
|
|
5
|
+
* been processed (initial failure counts as attempt 1).
|
|
6
|
+
*/
|
|
7
|
+
export const ATTEMPTS_DIAGNOSTIC = "attempts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Factory for the standard {@link EnqueueDecision}s, mirroring Axon's
|
|
11
|
+
* `Decisions`. The two lifecycle moments share one decision type:
|
|
12
|
+
*
|
|
13
|
+
* - On an **initial** handler failure, `shouldEnqueue === true` means "park the
|
|
14
|
+
* event" and `false` means "drop it" (the error is swallowed, the token
|
|
15
|
+
* advances anyway).
|
|
16
|
+
* - During **reprocessing**, `shouldEnqueue === true` means "requeue (keep it,
|
|
17
|
+
* still failing)" and `false` means "evict (done — succeeded or gave up)".
|
|
18
|
+
*
|
|
19
|
+
* Hence the deliberate aliases: `evict` ≡ `doNotEnqueue`, `requeue` ≡ `enqueue`.
|
|
20
|
+
* They exist purely to read correctly at each call site.
|
|
21
|
+
*/
|
|
22
|
+
export const Decisions = {
|
|
23
|
+
/** Park / keep the letter, recording the given cause and diagnostics. */
|
|
24
|
+
enqueue(cause?: Error, diagnostics?: Record<string, unknown>): EnqueueDecision {
|
|
25
|
+
return { shouldEnqueue: true, cause, diagnostics }
|
|
26
|
+
},
|
|
27
|
+
/** Alias of {@link enqueue}, read at reprocessing time ("still failing, keep it"). */
|
|
28
|
+
requeue(cause?: Error, diagnostics?: Record<string, unknown>): EnqueueDecision {
|
|
29
|
+
return { shouldEnqueue: true, cause, diagnostics }
|
|
30
|
+
},
|
|
31
|
+
/** Drop the letter without parking it (initial failure path). */
|
|
32
|
+
doNotEnqueue(): EnqueueDecision {
|
|
33
|
+
return { shouldEnqueue: false }
|
|
34
|
+
},
|
|
35
|
+
/** Alias of {@link doNotEnqueue}, read at reprocessing time ("done — remove it"). */
|
|
36
|
+
evict(): EnqueueDecision {
|
|
37
|
+
return { shouldEnqueue: false }
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* Keep the letter in place without recording a new cause — used when a
|
|
41
|
+
* reprocess attempt should neither evict nor overwrite the existing failure.
|
|
42
|
+
*/
|
|
43
|
+
ignore(): EnqueueDecision {
|
|
44
|
+
return { shouldEnqueue: true }
|
|
45
|
+
},
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default policy: always park a failed event, preserving the original cause.
|
|
50
|
+
* Matches Axon's default `(letter, cause) -> Decisions.enqueue(cause)`.
|
|
51
|
+
*
|
|
52
|
+
* With no retry cap, letters stay until reprocessed successfully or evicted by
|
|
53
|
+
* an operator — appropriate when every failure deserves manual inspection.
|
|
54
|
+
*/
|
|
55
|
+
export function alwaysEnqueuePolicy(): EnqueuePolicy {
|
|
56
|
+
return {
|
|
57
|
+
decide(_letter, cause) {
|
|
58
|
+
return Decisions.enqueue(cause)
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function attemptsOf(letter: DeadLetter): number {
|
|
64
|
+
const raw = letter.diagnostics[ATTEMPTS_DIAGNOSTIC]
|
|
65
|
+
return typeof raw === "number" ? raw : 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Options for {@link retryThenEvictPolicy}.
|
|
70
|
+
*/
|
|
71
|
+
export interface RetryThenEvictOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Total processing attempts (including the first failure) before the letter
|
|
74
|
+
* is evicted/dropped. `maxAttempts: 1` means "never retry".
|
|
75
|
+
*/
|
|
76
|
+
maxAttempts: number
|
|
77
|
+
/**
|
|
78
|
+
* Classifies a cause as non-transient. Non-transient failures are parked once
|
|
79
|
+
* for manual inspection (marked `retryable: false` in diagnostics) and never
|
|
80
|
+
* counted against the retry budget — retrying a deterministic failure is
|
|
81
|
+
* pointless. Optional; when omitted, every failure is treated as transient.
|
|
82
|
+
*/
|
|
83
|
+
nonTransient?: (cause: Error) => boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Caps automatic retries by counting attempts in the letter's diagnostics,
|
|
88
|
+
* then evicting once the budget is exhausted. This is the Axon idiom: the
|
|
89
|
+
* framework holds no retry counter — the policy authors it in diagnostics.
|
|
90
|
+
*
|
|
91
|
+
* - Initial failure → park with `attempts: 1` (unless `maxAttempts <= 1`, which
|
|
92
|
+
* evicts/drops immediately).
|
|
93
|
+
* - Each reprocess failure → increment `attempts`; once `attempts >= maxAttempts`
|
|
94
|
+
* the letter is evicted (given up on).
|
|
95
|
+
* - A `nonTransient` cause is parked once, never retried, and flagged for an
|
|
96
|
+
* operator.
|
|
97
|
+
*
|
|
98
|
+
* ```typescript
|
|
99
|
+
* trackingProcessor("balances")
|
|
100
|
+
* .deadLetterQueue(dlq)
|
|
101
|
+
* .enqueuePolicy(retryThenEvictPolicy({ maxAttempts: 5 }))
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function retryThenEvictPolicy(options: RetryThenEvictOptions): EnqueuePolicy {
|
|
105
|
+
const { maxAttempts, nonTransient } = options
|
|
106
|
+
return {
|
|
107
|
+
decide(letter, cause) {
|
|
108
|
+
if (nonTransient?.(cause)) {
|
|
109
|
+
return Decisions.enqueue(cause, { retryable: false })
|
|
110
|
+
}
|
|
111
|
+
const attempts = attemptsOf(letter) + 1
|
|
112
|
+
if (attempts >= maxAttempts) {
|
|
113
|
+
return Decisions.evict()
|
|
114
|
+
}
|
|
115
|
+
return Decisions.enqueue(cause, { [ATTEMPTS_DIAGNOSTIC]: attempts })
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -2,7 +2,9 @@ import type { EventHandlerDefinition } from "./event-handler.js"
|
|
|
2
2
|
import type { TokenStore } from "./token-store.js"
|
|
3
3
|
import type { UoWRunner } from "./unit-of-work.js"
|
|
4
4
|
import type { EventProcessingErrorHandler } from "./tracking-event-processor.js"
|
|
5
|
-
import type { SequencedDeadLetterQueue } from "./dead-letter-queue.js"
|
|
5
|
+
import type { SequencedDeadLetterQueue, EnqueuePolicy } from "./dead-letter-queue.js"
|
|
6
|
+
import type { SequencingPolicy } from "./sequencing-policy.js"
|
|
7
|
+
import type { DeadLetterListener } from "./dead-letter-listener.js"
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Base configuration shared by all event processor types.
|
|
@@ -27,6 +29,16 @@ export interface TrackingProcessorModule extends EventProcessorBase {
|
|
|
27
29
|
readonly unitOfWorkRunner?: UoWRunner
|
|
28
30
|
readonly errorHandler?: EventProcessingErrorHandler
|
|
29
31
|
readonly deadLetterQueue?: SequencedDeadLetterQueue
|
|
32
|
+
/** Decides whether a failed event is enqueued in the DLQ. Default: always enqueue. */
|
|
33
|
+
readonly enqueuePolicy?: EnqueuePolicy
|
|
34
|
+
/** Decides each event's ordered sequence for the DLQ. Default: first tag value. */
|
|
35
|
+
readonly sequencingPolicy?: SequencingPolicy
|
|
36
|
+
/** Observability hook for dead-letter lifecycle events. Default: no-op. */
|
|
37
|
+
readonly deadLetterListener?: DeadLetterListener
|
|
38
|
+
/** When true, resetTokens() also clears this processor's DLQ. Default: false. */
|
|
39
|
+
readonly resetClearsDeadLetters?: boolean
|
|
40
|
+
/** When set, automatically drains the DLQ on this interval (ms). Off by default. */
|
|
41
|
+
readonly dlqRetryIntervalMs?: number
|
|
30
42
|
/** Number of segments created on first startup. Default 16 (Axon Framework parity). Always set by builder.build(). */
|
|
31
43
|
readonly initialSegmentCount: number
|
|
32
44
|
readonly claimExtensionThresholdMs?: number
|
|
@@ -89,6 +101,11 @@ export class TrackingProcessorBuilder {
|
|
|
89
101
|
private _unitOfWorkRunner?: UoWRunner
|
|
90
102
|
private _errorHandler?: EventProcessingErrorHandler
|
|
91
103
|
private _deadLetterQueue?: SequencedDeadLetterQueue
|
|
104
|
+
private _enqueuePolicy?: EnqueuePolicy
|
|
105
|
+
private _sequencingPolicy?: SequencingPolicy
|
|
106
|
+
private _deadLetterListener?: DeadLetterListener
|
|
107
|
+
private _resetClearsDeadLetters?: boolean
|
|
108
|
+
private _dlqRetryIntervalMs?: number
|
|
92
109
|
private _initialSegmentCount?: number
|
|
93
110
|
private _claimExtensionThresholdMs?: number
|
|
94
111
|
private _tokenClaimIntervalMs?: number
|
|
@@ -109,7 +126,15 @@ export class TrackingProcessorBuilder {
|
|
|
109
126
|
return this
|
|
110
127
|
}
|
|
111
128
|
|
|
112
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Events per batch/transaction (one UnitOfWork). Default: 1 (Axon parity).
|
|
131
|
+
*
|
|
132
|
+
* A batch shares one UnitOfWork, so all events in it share the per-UoW
|
|
133
|
+
* `load()` cache, DCB read-set, and a single atomic commit. Keep the default
|
|
134
|
+
* of 1 for processors that make per-entity decisions via `load()`
|
|
135
|
+
* (automations) so each decision stays isolated. Raise it for read-model
|
|
136
|
+
* projections that only apply idempotent view updates and want throughput.
|
|
137
|
+
*/
|
|
113
138
|
batchSize(size: number): this {
|
|
114
139
|
this._batchSize = size
|
|
115
140
|
return this
|
|
@@ -143,12 +168,46 @@ export class TrackingProcessorBuilder {
|
|
|
143
168
|
return this
|
|
144
169
|
}
|
|
145
170
|
|
|
146
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Set a dead letter queue for this processor. When set, handler failures are
|
|
173
|
+
* parked in the queue and the processor advances past them (Option A) rather
|
|
174
|
+
* than redelivering the failed batch indefinitely.
|
|
175
|
+
*/
|
|
147
176
|
deadLetterQueue(queue: SequencedDeadLetterQueue): this {
|
|
148
177
|
this._deadLetterQueue = queue
|
|
149
178
|
return this
|
|
150
179
|
}
|
|
151
180
|
|
|
181
|
+
/** Policy deciding whether a failed event is enqueued in the DLQ. Default: always. */
|
|
182
|
+
enqueuePolicy(policy: EnqueuePolicy): this {
|
|
183
|
+
this._enqueuePolicy = policy
|
|
184
|
+
return this
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Policy deciding each event's ordered sequence for the DLQ. Default: first tag value. */
|
|
188
|
+
sequencingPolicy(policy: SequencingPolicy): this {
|
|
189
|
+
this._sequencingPolicy = policy
|
|
190
|
+
return this
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Observability hook for dead-letter lifecycle events. */
|
|
194
|
+
deadLetterListener(listener: DeadLetterListener): this {
|
|
195
|
+
this._deadLetterListener = listener
|
|
196
|
+
return this
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** When true, resetTokens() also clears this processor's DLQ (Axon allowReset). */
|
|
200
|
+
resetClearsDeadLetters(enabled = true): this {
|
|
201
|
+
this._resetClearsDeadLetters = enabled
|
|
202
|
+
return this
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Automatically drain the DLQ on this interval (ms). Omit to disable scheduled retries. */
|
|
206
|
+
dlqRetryInterval(ms: number): this {
|
|
207
|
+
this._dlqRetryIntervalMs = ms
|
|
208
|
+
return this
|
|
209
|
+
}
|
|
210
|
+
|
|
152
211
|
/** Number of segments to create on first startup. Default: 16 (Axon Framework parity). */
|
|
153
212
|
initialSegmentCount(count: number): this {
|
|
154
213
|
this._initialSegmentCount = count
|
|
@@ -167,6 +226,11 @@ export class TrackingProcessorBuilder {
|
|
|
167
226
|
unitOfWorkRunner: this._unitOfWorkRunner,
|
|
168
227
|
errorHandler: this._errorHandler,
|
|
169
228
|
deadLetterQueue: this._deadLetterQueue,
|
|
229
|
+
enqueuePolicy: this._enqueuePolicy,
|
|
230
|
+
sequencingPolicy: this._sequencingPolicy,
|
|
231
|
+
deadLetterListener: this._deadLetterListener,
|
|
232
|
+
resetClearsDeadLetters: this._resetClearsDeadLetters,
|
|
233
|
+
dlqRetryIntervalMs: this._dlqRetryIntervalMs,
|
|
170
234
|
initialSegmentCount: this._initialSegmentCount ?? 16,
|
|
171
235
|
claimExtensionThresholdMs: this._claimExtensionThresholdMs,
|
|
172
236
|
tokenClaimIntervalMs: this._tokenClaimIntervalMs,
|
package/src/index.ts
CHANGED
|
@@ -304,18 +304,50 @@ export {
|
|
|
304
304
|
type EnqueueDecision,
|
|
305
305
|
type EnqueuePolicy,
|
|
306
306
|
type SequencedDeadLetterQueue,
|
|
307
|
-
alwaysEnqueuePolicy,
|
|
308
307
|
createDeadLetter,
|
|
309
308
|
createInMemoryDeadLetterQueue,
|
|
310
309
|
DeadLetterQueueOverflowError,
|
|
311
310
|
} from "./dead-letter-queue.js"
|
|
312
311
|
|
|
312
|
+
// Enqueue policies + decisions
|
|
313
|
+
export {
|
|
314
|
+
Decisions,
|
|
315
|
+
alwaysEnqueuePolicy,
|
|
316
|
+
retryThenEvictPolicy,
|
|
317
|
+
type RetryThenEvictOptions,
|
|
318
|
+
ATTEMPTS_DIAGNOSTIC,
|
|
319
|
+
} from "./enqueue-policy.js"
|
|
320
|
+
|
|
313
321
|
// Dead-lettering event delivery
|
|
314
322
|
export {
|
|
315
323
|
type DeadLetteringOptions,
|
|
316
324
|
createDeadLetteringDelivery,
|
|
317
325
|
} from "./dead-lettering-handler.js"
|
|
318
326
|
|
|
327
|
+
// Dead-letter reprocessing
|
|
328
|
+
export {
|
|
329
|
+
type DeadLetterReprocessor,
|
|
330
|
+
type DeadLetterReprocessorOptions,
|
|
331
|
+
type DeadLetterReplay,
|
|
332
|
+
createDeadLetterReprocessor,
|
|
333
|
+
} from "./dead-letter-reprocessor.js"
|
|
334
|
+
|
|
335
|
+
// Dead-letter observability
|
|
336
|
+
export {
|
|
337
|
+
type DeadLetterListener,
|
|
338
|
+
noOpDeadLetterListener,
|
|
339
|
+
loggingDeadLetterListener,
|
|
340
|
+
multiDeadLetterListener,
|
|
341
|
+
} from "./dead-letter-listener.js"
|
|
342
|
+
|
|
343
|
+
// Sequencing policy
|
|
344
|
+
export {
|
|
345
|
+
type SequencingPolicy,
|
|
346
|
+
sequentialPerTag,
|
|
347
|
+
defaultSequencingPolicy,
|
|
348
|
+
fullConcurrencyPolicy,
|
|
349
|
+
} from "./sequencing-policy.js"
|
|
350
|
+
|
|
319
351
|
// Upcasting
|
|
320
352
|
export {
|
|
321
353
|
type IntermediateEventRepresentation,
|
|
@@ -30,7 +30,7 @@ import type { SequencedDeadLetterQueue } from "./dead-letter-queue.js"
|
|
|
30
30
|
* with `registerEventProcessor()` instead.
|
|
31
31
|
*/
|
|
32
32
|
export interface ProcessorConfiguration {
|
|
33
|
-
/** Events per transaction. Default:
|
|
33
|
+
/** Events per transaction (one UnitOfWork). Default: 1 (Axon parity). */
|
|
34
34
|
batchSize?: number
|
|
35
35
|
|
|
36
36
|
/** Number of segments to create on first startup. Default: 1 */
|
|
@@ -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
|