@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
@@ -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
- * Extract a sequence identifier from an event. Events in the same
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: uses the first tag value or event name.
28
+ * Default: {@link defaultSequencingPolicy} (first tag value, else event name).
24
29
  */
25
- sequenceIdentifier?: (event: EventMessage) => string
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
- sequenceIdentifier = defaultSequenceIdentifier,
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 = sequenceIdentifier(event)
68
+ const seqId = sequencingPolicy(event)
61
69
 
62
- // If this sequence already has dead letters, block this event too
63
- const blocked = await queue.enqueueIfPresent(
64
- seqId,
65
- () => createDeadLetter(
66
- event,
67
- new Error("Blocked: previous event in sequence failed"),
68
- seqId,
69
- { blocked: true, position: Number(sequencedEvent.sequence) },
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) return
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
- await queue.enqueue({
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 defaultSequenceIdentifier(event: EventMessage): string {
104
- // Use first tag value if available, otherwise event name
105
- if (event.tags && event.tags.length > 0) {
106
- return event.tags[0]!.value
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
- /** Events per batch/transaction. Default: 100. */
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
- /** Set a dead letter queue for this processor. */
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: 100 */
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