@vytches/ddd-messaging 0.27.0 → 0.29.1

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.
@@ -1,6 +1,15 @@
1
1
  import { IEventBus } from '../../../contracts/src/index.ts';
2
2
  import { IOutboxMessage, IOutboxMessageHandler, OutboxMiddleware, MessagePriority } from './outbox-interfaces';
3
3
  import { IOutboxRepository } from './outbox-repository.interface';
4
+ import { OutboxProcessorHooks } from './outbox-hooks';
5
+ export interface RetryBackoffConfig {
6
+ /** Initial delay in milliseconds before first retry (default: 1000) */
7
+ initial: number;
8
+ /** Multiplier applied per subsequent attempt (default: 2) */
9
+ multiplier: number;
10
+ /** Maximum delay cap in milliseconds (default: 300_000 — 5 min) */
11
+ maxDelay: number;
12
+ }
4
13
  export interface OutboxProcessorOptions {
5
14
  /** Maximum number of messages to process in one batch */
6
15
  batchSize?: number;
@@ -14,6 +23,63 @@ export interface OutboxProcessorOptions {
14
23
  messageTimeout?: number;
15
24
  /** Enable logging */
16
25
  enableLogging?: boolean;
26
+ /**
27
+ * Exponential backoff for retries.
28
+ * When set, failed messages are scheduled for future retry via
29
+ * `IOutboxRepository.scheduleRetry()` instead of immediately re-queued.
30
+ * If the repository's `scheduleRetry` is a no-op, messages fall back to
31
+ * immediate PENDING reset (legacy behavior).
32
+ * If omitted, immediate retry is used for all messages.
33
+ */
34
+ retryBackoff?: RetryBackoffConfig;
35
+ /**
36
+ * Optional allow-list of message types to process. When set, only messages
37
+ * of these types are fetched from the repository (passed through to
38
+ * `getUnprocessedMessages`). Lets multiple processors partition the outbox
39
+ * by type. Omit to process all types.
40
+ */
41
+ messageTypes?: string[];
42
+ /**
43
+ * When set, the processor periodically calls
44
+ * `IOutboxRepository.resetStaleProcessing()` every N ms to recover messages
45
+ * orphaned in `PROCESSING` by a crashed worker. Omit to disable crash recovery.
46
+ */
47
+ crashRecoveryIntervalMs?: number;
48
+ /**
49
+ * How old (in ms) a `PROCESSING` message must be before crash recovery resets
50
+ * it to `PENDING`. Must be `>= messageTimeout` so legitimately in-flight
51
+ * messages are never reset. Defaults to `max(300_000, messageTimeout)`.
52
+ * Setting this below `messageTimeout` throws a `RangeError` from the constructor.
53
+ */
54
+ crashRecoveryThresholdMs?: number;
55
+ /**
56
+ * When `true`, after a **full** batch (every fetched slot used) the processor
57
+ * re-polls immediately instead of waiting `processingInterval`, draining a
58
+ * backlog faster. Defaults to `false` (opt-in — no behavior change).
59
+ */
60
+ adaptiveRepoll?: boolean;
61
+ /**
62
+ * Livelock guard for `adaptiveRepoll`: after this many consecutive full
63
+ * batches the processor inserts an `adaptiveRepollPauseMs` pause instead of
64
+ * re-polling immediately, yielding the event loop. Defaults to `5`.
65
+ */
66
+ adaptiveRepollMaxConsecutive?: number;
67
+ /**
68
+ * Pause (ms) inserted once the `adaptiveRepollMaxConsecutive` guard trips.
69
+ * Defaults to `50`.
70
+ */
71
+ adaptiveRepollPauseMs?: number;
72
+ /**
73
+ * Random delay (0..N ms) added before the first poll on `start()`, to
74
+ * de-synchronize many processors that boot simultaneously. Defaults to `0`.
75
+ */
76
+ startupJitterMs?: number;
77
+ /**
78
+ * Observability callbacks (batch completion, message failure, permanent
79
+ * failure). Each invocation is isolated in a try/catch — a throwing hook is
80
+ * logged and never breaks the processing loop. Omit to disable.
81
+ */
82
+ hooks?: OutboxProcessorHooks;
17
83
  }
18
84
  export declare class OutboxProcessor {
19
85
  private readonly repository;
@@ -22,8 +88,14 @@ export declare class OutboxProcessor {
22
88
  private readonly middlewares;
23
89
  private isRunning;
24
90
  private processingTimer?;
91
+ private crashRecoveryTimer?;
25
92
  private readonly logger;
26
93
  constructor(repository: IOutboxRepository, options?: OutboxProcessorOptions);
94
+ /**
95
+ * Invokes an observability hook in isolation. A throwing hook is logged and
96
+ * swallowed so it can never break the processing loop (mitigation T3).
97
+ */
98
+ private safelyInvokeHook;
27
99
  /**
28
100
  * Registers a message handler for a specific message type
29
101
  */
@@ -61,13 +133,21 @@ export declare class OutboxProcessor {
61
133
  * **Quiescence:** if there are no pending messages, returns immediately
62
134
  * without invoking handlers.
63
135
  *
136
+ * @returns `{ processed, batchSize }` — `processed` is how many messages were
137
+ * dispatched this batch, `batchSize` the configured limit. A full batch
138
+ * (`processed >= batchSize`) signals a likely backlog. Callers that ignore
139
+ * the return value are unaffected (the value was previously `void`).
64
140
  * @public
65
141
  * @stable
66
142
  * @since 0.1.0
67
143
  */
68
- processBatch(): Promise<void>;
144
+ processBatch(): Promise<{
145
+ processed: number;
146
+ batchSize: number;
147
+ }>;
69
148
  /**
70
- * Processes a single message through the middleware pipeline
149
+ * Processes a single message through the middleware pipeline.
150
+ * @returns `true` if the message was processed successfully, `false` if it failed.
71
151
  */
72
152
  private processMessage;
73
153
  /**
@@ -79,9 +159,29 @@ export declare class OutboxProcessor {
79
159
  */
80
160
  private handleMessageError;
81
161
  /**
82
- * Schedules the next processing cycle
162
+ * Schedules the next processing cycle.
163
+ *
164
+ * With `adaptiveRepoll` enabled, a full batch re-polls immediately (`delay 0`)
165
+ * to drain a backlog — until `adaptiveRepollMaxConsecutive` consecutive full
166
+ * batches trip the livelock guard, after which an `adaptiveRepollPauseMs`
167
+ * pause is inserted. Any non-full batch resets the cadence to
168
+ * `processingInterval`.
169
+ *
170
+ * @param consecutiveFullBatches running count of back-to-back full batches
171
+ * @param delay milliseconds to wait before the next batch
83
172
  */
84
173
  private scheduleProcessing;
174
+ /**
175
+ * Schedules the next crash-recovery cycle. No-op unless
176
+ * `crashRecoveryIntervalMs` was configured.
177
+ */
178
+ private scheduleCrashRecovery;
179
+ /**
180
+ * Resets messages orphaned in `PROCESSING` (older than
181
+ * `crashRecoveryThresholdMs`) back to `PENDING` via the repository. Errors are
182
+ * logged, never thrown — crash recovery must not crash the processor.
183
+ */
184
+ private runCrashRecovery;
85
185
  /**
86
186
  * Logs messages if logging is enabled
87
187
  */
@@ -16,9 +16,13 @@ export declare abstract class IOutboxRepository {
16
16
  * Gets unprocessed messages from the outbox
17
17
  * @param limit Maximum number of messages to retrieve
18
18
  * @param priorityOrder Order of priority for processing (default: [CRITICAL, HIGH, NORMAL, LOW])
19
+ * @param messageTypes Optional allow-list of message types to fetch. When
20
+ * provided, only messages whose `messageType` is in this list are returned
21
+ * (e.g. `WHERE message_type IN (...)`). Omitting it fetches all types —
22
+ * this parameter is optional and backward-compatible.
19
23
  * @returns Unprocessed messages
20
24
  */
21
- abstract getUnprocessedMessages(limit?: number, priorityOrder?: MessagePriority[]): Promise<IOutboxMessage[]>;
25
+ abstract getUnprocessedMessages(limit?: number, priorityOrder?: MessagePriority[], messageTypes?: string[]): Promise<IOutboxMessage[]>;
22
26
  /**
23
27
  * Gets specific message by ID
24
28
  * @param id Message ID
@@ -58,4 +62,40 @@ export declare abstract class IOutboxRepository {
58
62
  * @returns ID of the saved message
59
63
  */
60
64
  abstract scheduleMessage<T = unknown>(message: IOutboxMessage<T>, processAfter: Date): Promise<string>;
65
+ /**
66
+ * Schedules a retry for a failed message at a specific future time.
67
+ *
68
+ * Default implementation is a no-op — repositories that do not support
69
+ * delayed processing will fall back to immediate retry (PENDING reset).
70
+ * Override this in concrete repositories to enable exponential backoff.
71
+ *
72
+ * **Multi-worker note:** `OutboxProcessor` calls `updateStatus(PENDING)` immediately
73
+ * before this method. In multi-worker environments, override this to perform
74
+ * an atomic `UPDATE ... SET status=PENDING, process_after=?` in a single statement,
75
+ * and ensure `getUnprocessedMessages` filters `WHERE process_after IS NULL OR process_after <= NOW()`
76
+ * to prevent duplicate dispatch during the window between the two calls.
77
+ *
78
+ * @param id Message ID
79
+ * @param processAfter When to retry the message
80
+ */
81
+ scheduleRetry(_id: string, _processAfter: Date): Promise<void>;
82
+ /**
83
+ * Resets messages stuck in `PROCESSING` back to `PENDING` so they can be
84
+ * re-dispatched. Used for crash recovery: if a worker dies mid-batch, its
85
+ * in-flight messages remain `PROCESSING` forever without this.
86
+ *
87
+ * Default implementation is a no-op returning `0` — repositories that do not
88
+ * track a per-message "processing since" timestamp simply opt out. Override
89
+ * in concrete repositories, e.g.:
90
+ * `UPDATE outbox_messages SET status='PENDING' WHERE status='PROCESSING' AND updated_at < olderThan`.
91
+ *
92
+ * **Safety:** implementations MUST only reset messages whose processing
93
+ * started strictly before `olderThan`. `OutboxProcessor` always passes a past
94
+ * timestamp (`now - crashRecoveryThresholdMs`, with the threshold validated to
95
+ * be `>= messageTimeout`), so legitimately in-flight messages are never reset.
96
+ *
97
+ * @param olderThan Reset only `PROCESSING` messages last touched before this instant
98
+ * @returns Number of messages reset to `PENDING`
99
+ */
100
+ resetStaleProcessing(_olderThan: Date): Promise<number>;
61
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vytches/ddd-messaging",
3
- "version": "0.27.0",
3
+ "version": "0.29.1",
4
4
  "description": "Outbox pattern and reliable message delivery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -36,8 +36,7 @@
36
36
  "typescript": "^5.0.0",
37
37
  "vite": "^7.3.2",
38
38
  "vite-plugin-dts": "^4.0.0",
39
- "vitest": "^2.0.0",
40
- "@vytches/ddd-testing": "0.27.0"
39
+ "vitest": "^4.0.0"
41
40
  },
42
41
  "nx": {
43
42
  "tags": [
@@ -51,7 +50,7 @@
51
50
  "access": "public"
52
51
  },
53
52
  "dependencies": {
54
- "@vytches/ddd-events": "0.27.0"
53
+ "@vytches/ddd-events": "0.29.1"
55
54
  },
56
55
  "scripts": {
57
56
  "build": "vite build",