@zdavison/matador 4.0.0 → 4.0.2

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.
@@ -12,6 +12,7 @@ export interface FanoutConfig {
12
12
  readonly hooks: SafeHooks;
13
13
  readonly topology: Topology;
14
14
  readonly defaultQueue: string;
15
+ readonly maxRetryBufferSize?: number | undefined;
15
16
  }
16
17
  /**
17
18
  * Result of sending an event.
@@ -46,7 +47,11 @@ export declare class FanoutEngine {
46
47
  private readonly topology;
47
48
  private readonly defaultQueue;
48
49
  private enqueuingCount;
50
+ private readonly retryBuffer;
51
+ private readonly maxRetryBufferSize;
52
+ private readonly disposeOnConnected;
49
53
  constructor(config: FanoutConfig);
54
+ dispose(): void;
50
55
  /**
51
56
  * Current count of events being enqueued.
52
57
  */
@@ -55,6 +60,12 @@ export declare class FanoutEngine {
55
60
  * Sends an event to all registered subscribers.
56
61
  */
57
62
  send<T>(eventClass: EventClass<T>, event: Event<T>, options?: EventOptions): Promise<SendResult>;
63
+ /**
64
+ * Flushes the retry buffer
65
+ *
66
+ * This is called when the transport reconnects, and is used to retry any messages that were buffered while the transport was disconnected
67
+ */
68
+ private flushRetryBuffer;
58
69
  private isSubscriberEnabled;
59
70
  }
60
71
  //# sourceMappingURL=fanout.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fanout.d.ts","sourceRoot":"","sources":["../../src/core/fanout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAEV,KAAK,EACL,UAAU,EACV,YAAY,EACb,MAAM,mBAAmB,CAAC;AAG3B;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;CACvC;AAED;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,cAAc,CAAK;gBAEf,MAAM,EAAE,YAAY;IAQhC;;OAEG;IACH,IAAI,wBAAwB,IAAI,MAAM,CAErC;IAED;;OAEG;IACG,IAAI,CAAC,CAAC,EACV,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,EACzB,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,UAAU,CAAC;YAsFR,mBAAmB;CAelC"}
1
+ {"version":3,"file":"fanout.d.ts","sourceRoot":"","sources":["../../src/core/fanout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAGV,KAAK,EACL,UAAU,EACV,YAAY,EACb,MAAM,mBAAmB,CAAC;AAa3B;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClD;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;CACvC;AAED;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;IAC5C,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA2B;gBAElD,MAAM,EAAE,YAAY;IAehC,OAAO,IAAI,IAAI;IAKf;;OAEG;IACH,IAAI,wBAAwB,IAAI,MAAM,CAErC;IAED;;OAEG;IACG,IAAI,CAAC,CAAC,EACV,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,EACzB,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,UAAU,CAAC;IAyHtB;;;;OAIG;YACW,gBAAgB;YAsDhB,mBAAmB;CAelC"}
@@ -17,12 +17,25 @@ export class FanoutEngine {
17
17
  topology;
18
18
  defaultQueue;
19
19
  enqueuingCount = 0;
20
+ retryBuffer = [];
21
+ maxRetryBufferSize;
22
+ disposeOnConnected;
20
23
  constructor(config) {
21
24
  this.transport = config.transport;
22
25
  this.schema = config.schema;
23
26
  this.hooks = config.hooks;
24
27
  this.topology = config.topology;
25
28
  this.defaultQueue = config.defaultQueue;
29
+ this.maxRetryBufferSize = config.maxRetryBufferSize ?? 5000;
30
+ // If the transport supports it, add a callback to flush the retry buffer when the transport reconnects
31
+ // The returned value is a function to unsubscribe from the callback
32
+ this.disposeOnConnected = this.transport.onConnected?.(() => {
33
+ void this.flushRetryBuffer();
34
+ });
35
+ }
36
+ dispose() {
37
+ // Unsubscribe from the callback (that flushes the retry buffer when the transport reconnects)
38
+ this.disposeOnConnected?.();
26
39
  }
27
40
  /**
28
41
  * Current count of events being enqueued.
@@ -68,11 +81,10 @@ export class FanoutEngine {
68
81
  delayMs: options.delayMs,
69
82
  });
70
83
  // Send to transport
84
+ const sendOptions = options.delayMs !== undefined ? { delay: options.delayMs } : undefined;
71
85
  this.enqueuingCount++;
72
86
  try {
73
- const usedTransport = await this.transport.send(qualifiedQueue, envelope, options.delayMs !== undefined
74
- ? { delay: options.delayMs }
75
- : undefined);
87
+ const usedTransport = await this.transport.send(qualifiedQueue, envelope, sendOptions);
76
88
  sent++;
77
89
  await this.hooks.onEnqueueSuccess({
78
90
  envelope,
@@ -82,17 +94,46 @@ export class FanoutEngine {
82
94
  }
83
95
  catch (error) {
84
96
  const cause = error instanceof Error ? error : new Error(String(error));
85
- const err = new TransportSendError(qualifiedQueue, cause);
86
- errors.push({
87
- subscriberName: subscriber.name,
88
- queue: qualifiedQueue,
89
- error: err,
90
- });
91
- await this.hooks.onEnqueueError({
92
- envelope,
93
- error: err,
94
- transport: this.transport.name,
95
- });
97
+ const shouldBuffer = options.buffer !== false;
98
+ if (shouldBuffer && this.retryBuffer.length < this.maxRetryBufferSize) {
99
+ this.retryBuffer.push({
100
+ queue: qualifiedQueue,
101
+ envelope,
102
+ sendOptions,
103
+ subscriberName: subscriber.name,
104
+ });
105
+ this.hooks.logger.warn(`[Matador] 🟡 Message for '${subscriber.name}' buffered for retry on reconnect (buffer: ${this.retryBuffer.length}/${this.maxRetryBufferSize}).`);
106
+ if (options.throwOnBufferedFailure) {
107
+ const err = new TransportSendError(qualifiedQueue, cause);
108
+ // By adding the error, the main Send in Matador will throw
109
+ errors.push({
110
+ subscriberName: subscriber.name,
111
+ queue: qualifiedQueue,
112
+ error: err,
113
+ });
114
+ await this.hooks.onEnqueueError({
115
+ envelope,
116
+ error: err,
117
+ transport: this.transport.name,
118
+ });
119
+ }
120
+ }
121
+ else {
122
+ if (shouldBuffer) {
123
+ this.hooks.logger.error(`[Matador] 🔴 Retry buffer full (${this.maxRetryBufferSize}). Message for '${subscriber.name}' dropped and will not be retried.`);
124
+ }
125
+ const err = new TransportSendError(qualifiedQueue, cause);
126
+ errors.push({
127
+ subscriberName: subscriber.name,
128
+ queue: qualifiedQueue,
129
+ error: err,
130
+ });
131
+ await this.hooks.onEnqueueError({
132
+ envelope,
133
+ error: err,
134
+ transport: this.transport.name,
135
+ });
136
+ }
96
137
  }
97
138
  finally {
98
139
  this.enqueuingCount--;
@@ -105,6 +146,54 @@ export class FanoutEngine {
105
146
  errors,
106
147
  };
107
148
  }
149
+ /**
150
+ * Flushes the retry buffer
151
+ *
152
+ * This is called when the transport reconnects, and is used to retry any messages that were buffered while the transport was disconnected
153
+ */
154
+ async flushRetryBuffer() {
155
+ if (this.retryBuffer.length === 0)
156
+ return;
157
+ this.hooks.logger.info(`[Matador] ⏳ Flushing ${this.retryBuffer.length} buffered message(s)...`);
158
+ // Drain the buffer atomically so concurrent flush calls don't double-send.
159
+ const toFlush = this.retryBuffer.splice(0);
160
+ for (const item of toFlush) {
161
+ this.enqueuingCount++;
162
+ try {
163
+ const usedTransport = await this.transport.send(item.queue, item.envelope, item.sendOptions);
164
+ await this.hooks.onEnqueueSuccess({
165
+ envelope: item.envelope,
166
+ queue: item.queue,
167
+ transport: usedTransport,
168
+ });
169
+ }
170
+ catch (error) {
171
+ // Re-buffer on failure; it will be retried on the next reconnect.
172
+ if (this.retryBuffer.length < this.maxRetryBufferSize) {
173
+ this.retryBuffer.push(item);
174
+ }
175
+ else {
176
+ const cause = error instanceof Error ? error : new Error(String(error));
177
+ const err = new TransportSendError(item.queue, cause);
178
+ await this.hooks.onEnqueueError({
179
+ envelope: item.envelope,
180
+ error: err,
181
+ transport: this.transport.name,
182
+ });
183
+ }
184
+ }
185
+ finally {
186
+ this.enqueuingCount--;
187
+ }
188
+ }
189
+ const remaining = this.retryBuffer.length;
190
+ if (remaining > 0) {
191
+ this.hooks.logger.warn(`[Matador] 🟡 ${remaining} buffered message(s) could not be flushed; will retry on next reconnect.`);
192
+ }
193
+ else {
194
+ this.hooks.logger.info('[Matador] 🟢 All buffered messages flushed successfully.');
195
+ }
196
+ }
108
197
  async isSubscriberEnabled(subscriber) {
109
198
  if (!subscriber.enabled) {
110
199
  return true;
@@ -3,6 +3,7 @@ import { TransportSendError } from '../errors/index.js';
3
3
  import { SafeHooks } from '../hooks/index.js';
4
4
  import { SchemaRegistry } from '../schema/index.js';
5
5
  import { TopologyBuilder } from '../topology/index.js';
6
+ import { MultiTransport } from '../transport/index.js';
6
7
  import { MatadorEvent, createSubscriber, createSubscriberStub, } from '../types/index.js';
7
8
  import { FanoutEngine } from './fanout.js';
8
9
  class UserCreatedEvent extends MatadorEvent {
@@ -514,7 +515,7 @@ describe('FanoutEngine', () => {
514
515
  });
515
516
  });
516
517
  describe('error handling', () => {
517
- it('should capture error when transport.send fails', async () => {
518
+ it('should capture error when transport.send fails and buffer is disabled', async () => {
518
519
  const sendError = new Error('Network timeout');
519
520
  const failingTransport = {
520
521
  ...transport,
@@ -539,7 +540,7 @@ describe('FanoutEngine', () => {
539
540
  userId: '123',
540
541
  email: 'test@example.com',
541
542
  });
542
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
543
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
543
544
  expect(result.subscribersSent).toBe(0);
544
545
  expect(result.subscribersSkipped).toBe(0);
545
546
  expect(result.errors).toHaveLength(1);
@@ -581,7 +582,7 @@ describe('FanoutEngine', () => {
581
582
  userId: '123',
582
583
  email: 'test@example.com',
583
584
  });
584
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
585
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
585
586
  expect(result.subscribersSent).toBe(1);
586
587
  expect(result.subscribersSkipped).toBe(0);
587
588
  expect(result.errors).toHaveLength(1);
@@ -611,7 +612,7 @@ describe('FanoutEngine', () => {
611
612
  userId: '123',
612
613
  email: 'test@example.com',
613
614
  });
614
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
615
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
615
616
  expect(result.errors).toHaveLength(1);
616
617
  expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
617
618
  expect(result.errors[0]?.error.message).toContain('matador.test.events');
@@ -867,7 +868,9 @@ describe('FanoutEngine', () => {
867
868
  userId: '123',
868
869
  email: 'test@example.com',
869
870
  });
870
- await fanoutWithHooks.send(UserCreatedEvent, event);
871
+ await fanoutWithHooks.send(UserCreatedEvent, event, {
872
+ throwOnBufferedFailure: true,
873
+ });
871
874
  expect(onEnqueueError).toHaveBeenCalledTimes(1);
872
875
  expect(onEnqueueError).toHaveBeenCalledWith({
873
876
  envelope: expect.any(Object),
@@ -1069,4 +1072,373 @@ describe('FanoutEngine', () => {
1069
1072
  expect(envelope.docket.importance).toBe('should-investigate');
1070
1073
  });
1071
1074
  });
1075
+ describe('retry buffering', () => {
1076
+ it('should buffer a failed send and not return it as an error', async () => {
1077
+ const subscriber = createSubscriber({
1078
+ name: 'handle-user',
1079
+ description: 'Handles user events',
1080
+ callback: async () => { },
1081
+ });
1082
+ schema.register(UserCreatedEvent, [subscriber]);
1083
+ const failingTransport = {
1084
+ ...transport,
1085
+ send: mock(async () => {
1086
+ throw new Error('Connection lost');
1087
+ }),
1088
+ };
1089
+ const fanoutWithFailingTransport = new FanoutEngine({
1090
+ transport: failingTransport,
1091
+ schema,
1092
+ hooks,
1093
+ topology: testTopology,
1094
+ defaultQueue: 'events',
1095
+ });
1096
+ const event = new UserCreatedEvent({
1097
+ userId: '123',
1098
+ email: 'test@example.com',
1099
+ });
1100
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
1101
+ expect(result.errors).toHaveLength(0);
1102
+ expect(result.subscribersSent).toBe(0);
1103
+ });
1104
+ it('should buffer stub failures too', async () => {
1105
+ const stub = createSubscriberStub({ name: 'remote-service' });
1106
+ schema.register(UserCreatedEvent, [stub]);
1107
+ const failingTransport = {
1108
+ ...transport,
1109
+ send: mock(async () => {
1110
+ throw new Error('Connection lost');
1111
+ }),
1112
+ };
1113
+ const fanoutWithFailingTransport = new FanoutEngine({
1114
+ transport: failingTransport,
1115
+ schema,
1116
+ hooks,
1117
+ topology: testTopology,
1118
+ defaultQueue: 'events',
1119
+ });
1120
+ const event = new UserCreatedEvent({
1121
+ userId: '123',
1122
+ email: 'test@example.com',
1123
+ });
1124
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
1125
+ expect(result.errors).toHaveLength(0);
1126
+ expect(result.subscribersSent).toBe(0);
1127
+ });
1128
+ it('should flush buffered messages when onConnected fires', async () => {
1129
+ let connectedCallback;
1130
+ const failThenSucceed = mock(async () => {
1131
+ throw new Error('Connection lost');
1132
+ });
1133
+ const transportWithReconnect = {
1134
+ ...transport,
1135
+ send: failThenSucceed,
1136
+ onConnected: (cb) => {
1137
+ connectedCallback = cb;
1138
+ return () => { };
1139
+ },
1140
+ };
1141
+ const fanoutWithReconnect = new FanoutEngine({
1142
+ transport: transportWithReconnect,
1143
+ schema,
1144
+ hooks,
1145
+ topology: testTopology,
1146
+ defaultQueue: 'events',
1147
+ });
1148
+ const subscriber = createSubscriber({
1149
+ name: 'handle-user',
1150
+ description: 'Handles user events',
1151
+ callback: async () => { },
1152
+ });
1153
+ schema.register(UserCreatedEvent, [subscriber]);
1154
+ const event = new UserCreatedEvent({
1155
+ userId: '123',
1156
+ email: 'test@example.com',
1157
+ });
1158
+ // First send — transport fails, message should be buffered
1159
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1160
+ expect(failThenSucceed).toHaveBeenCalledTimes(1);
1161
+ // Simulate reconnect — now send succeeds
1162
+ transportWithReconnect.send.mockImplementation(async () => 'mock');
1163
+ expect(connectedCallback).toBeDefined();
1164
+ connectedCallback?.();
1165
+ // Give the async flush a chance to run
1166
+ await new Promise((resolve) => setTimeout(resolve, 0));
1167
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1168
+ });
1169
+ it('should re-buffer on flush failure and retry on next reconnect', async () => {
1170
+ let connectedCallback;
1171
+ const transportWithReconnect = {
1172
+ ...transport,
1173
+ send: mock(async () => {
1174
+ throw new Error('Still down');
1175
+ }),
1176
+ onConnected: (cb) => {
1177
+ connectedCallback = cb;
1178
+ return () => { };
1179
+ },
1180
+ };
1181
+ const fanoutWithReconnect = new FanoutEngine({
1182
+ transport: transportWithReconnect,
1183
+ schema,
1184
+ hooks,
1185
+ topology: testTopology,
1186
+ defaultQueue: 'events',
1187
+ });
1188
+ const subscriber = createSubscriber({
1189
+ name: 'handle-user',
1190
+ description: 'Handles user events',
1191
+ callback: async () => { },
1192
+ });
1193
+ schema.register(UserCreatedEvent, [subscriber]);
1194
+ const event = new UserCreatedEvent({
1195
+ userId: '123',
1196
+ email: 'test@example.com',
1197
+ });
1198
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1199
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(1);
1200
+ // Simulate reconnect — flush fails, item should be re-buffered
1201
+ connectedCallback?.();
1202
+ await new Promise((resolve) => setTimeout(resolve, 0));
1203
+ // send was attempted again during the flush
1204
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1205
+ // Simulate a second reconnect — should retry again
1206
+ connectedCallback?.();
1207
+ await new Promise((resolve) => setTimeout(resolve, 0));
1208
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(3);
1209
+ });
1210
+ it('should not buffer when transport works fine', async () => {
1211
+ let connectedCallback;
1212
+ const successTransport = {
1213
+ ...transport,
1214
+ onConnected: (cb) => {
1215
+ connectedCallback = cb;
1216
+ return () => { };
1217
+ },
1218
+ };
1219
+ const fanoutWithReconnect = new FanoutEngine({
1220
+ transport: successTransport,
1221
+ schema,
1222
+ hooks,
1223
+ topology: testTopology,
1224
+ defaultQueue: 'events',
1225
+ });
1226
+ const subscriber = createSubscriber({
1227
+ name: 'handle-user',
1228
+ description: 'Handles user events',
1229
+ callback: async () => { },
1230
+ });
1231
+ schema.register(UserCreatedEvent, [subscriber]);
1232
+ const event = new UserCreatedEvent({
1233
+ userId: '123',
1234
+ email: 'test@example.com',
1235
+ });
1236
+ const result = await fanoutWithReconnect.send(UserCreatedEvent, event);
1237
+ expect(result.subscribersSent).toBe(1);
1238
+ expect(result.errors).toHaveLength(0);
1239
+ // onConnected fires — nothing buffered so flush is a no-op
1240
+ connectedCallback?.();
1241
+ await new Promise((resolve) => setTimeout(resolve, 0));
1242
+ // Only one call total (the original send — not a spurious re-send)
1243
+ expect(successTransport.send).toHaveBeenCalledTimes(1);
1244
+ });
1245
+ it('should report error in result when buffer is false', async () => {
1246
+ const subscriber = createSubscriber({
1247
+ name: 'handle-user',
1248
+ description: 'Handles user events',
1249
+ callback: async () => { },
1250
+ });
1251
+ schema.register(UserCreatedEvent, [subscriber]);
1252
+ const failingTransport = {
1253
+ ...transport,
1254
+ send: mock(async () => {
1255
+ throw new Error('Connection lost');
1256
+ }),
1257
+ };
1258
+ const fanoutWithFailingTransport = new FanoutEngine({
1259
+ transport: failingTransport,
1260
+ schema,
1261
+ hooks,
1262
+ topology: testTopology,
1263
+ defaultQueue: 'events',
1264
+ });
1265
+ const event = new UserCreatedEvent({
1266
+ userId: '123',
1267
+ email: 'test@example.com',
1268
+ });
1269
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
1270
+ expect(result.errors).toHaveLength(1);
1271
+ expect(result.errors[0]?.subscriberName).toBe('handle-user');
1272
+ expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
1273
+ });
1274
+ it('should both buffer and report error when reportBufferedFailure is true', async () => {
1275
+ let connectedCallback;
1276
+ const failingTransport = {
1277
+ ...transport,
1278
+ send: mock(async () => {
1279
+ throw new Error('Connection lost');
1280
+ }),
1281
+ onConnected: (cb) => {
1282
+ connectedCallback = cb;
1283
+ return () => { };
1284
+ },
1285
+ };
1286
+ const fanoutWithReconnect = new FanoutEngine({
1287
+ transport: failingTransport,
1288
+ schema,
1289
+ hooks,
1290
+ topology: testTopology,
1291
+ defaultQueue: 'events',
1292
+ });
1293
+ const subscriber = createSubscriber({
1294
+ name: 'handle-user',
1295
+ description: 'Handles user events',
1296
+ callback: async () => { },
1297
+ });
1298
+ schema.register(UserCreatedEvent, [subscriber]);
1299
+ const event = new UserCreatedEvent({
1300
+ userId: '123',
1301
+ email: 'test@example.com',
1302
+ });
1303
+ const result = await fanoutWithReconnect.send(UserCreatedEvent, event, {
1304
+ throwOnBufferedFailure: true,
1305
+ });
1306
+ // Error is reported immediately
1307
+ expect(result.errors).toHaveLength(1);
1308
+ expect(result.errors[0]?.subscriberName).toBe('handle-user');
1309
+ // But it was also buffered — it should retry on reconnect
1310
+ failingTransport.send.mockImplementation(async () => 'mock');
1311
+ connectedCallback?.();
1312
+ await new Promise((resolve) => setTimeout(resolve, 0));
1313
+ expect(failingTransport.send).toHaveBeenCalledTimes(2);
1314
+ });
1315
+ it('should unsubscribe the onConnected listener when dispose() is called', () => {
1316
+ const unsubscribe = mock(() => { });
1317
+ const transportWithOnConnected = {
1318
+ ...transport,
1319
+ onConnected: mock((_cb) => unsubscribe),
1320
+ };
1321
+ const fanout = new FanoutEngine({
1322
+ transport: transportWithOnConnected,
1323
+ schema,
1324
+ hooks,
1325
+ topology: testTopology,
1326
+ defaultQueue: 'events',
1327
+ });
1328
+ expect(unsubscribe).not.toHaveBeenCalled();
1329
+ fanout.dispose();
1330
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
1331
+ });
1332
+ it('should drop and report error when retry buffer is full', async () => {
1333
+ const subscriber = createSubscriber({
1334
+ name: 'handle-user',
1335
+ description: 'Handles user events',
1336
+ callback: async () => { },
1337
+ });
1338
+ schema.register(UserCreatedEvent, [subscriber]);
1339
+ const failingTransport = {
1340
+ ...transport,
1341
+ send: mock(async () => {
1342
+ throw new Error('Connection lost');
1343
+ }),
1344
+ };
1345
+ const fanoutWithSmallBuffer = new FanoutEngine({
1346
+ transport: failingTransport,
1347
+ schema,
1348
+ hooks,
1349
+ topology: testTopology,
1350
+ defaultQueue: 'events',
1351
+ maxRetryBufferSize: 1,
1352
+ });
1353
+ const event = new UserCreatedEvent({
1354
+ userId: '123',
1355
+ email: 'test@example.com',
1356
+ });
1357
+ // First send fills the buffer
1358
+ const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1359
+ expect(first.errors).toHaveLength(0);
1360
+ // Second send hits the full buffer — must report an error
1361
+ const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1362
+ expect(second.errors).toHaveLength(1);
1363
+ expect(second.errors[0]?.error).toBeInstanceOf(TransportSendError);
1364
+ });
1365
+ it('should report buffer-full drop via onEnqueueError on initial send', async () => {
1366
+ const onEnqueueError = mock(async () => { });
1367
+ const hooksInstance = new SafeHooks({
1368
+ onEnqueueError,
1369
+ });
1370
+ const failingTransport = {
1371
+ ...transport,
1372
+ send: mock(async () => {
1373
+ throw new Error('Connection lost');
1374
+ }),
1375
+ };
1376
+ const subscriber = createSubscriber({
1377
+ name: 'handle-user',
1378
+ description: 'Handles user events',
1379
+ callback: async () => { },
1380
+ });
1381
+ schema.register(UserCreatedEvent, [subscriber]);
1382
+ const fanoutWithSmallBuffer = new FanoutEngine({
1383
+ transport: failingTransport,
1384
+ schema,
1385
+ hooks: hooksInstance,
1386
+ topology: testTopology,
1387
+ defaultQueue: 'events',
1388
+ maxRetryBufferSize: 1,
1389
+ });
1390
+ const event = new UserCreatedEvent({
1391
+ userId: '123',
1392
+ email: 'test@example.com',
1393
+ });
1394
+ // First send fills the buffer — no error reported to caller or hook
1395
+ const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1396
+ expect(first.errors).toHaveLength(0);
1397
+ expect(onEnqueueError).not.toHaveBeenCalled();
1398
+ // Second send finds the buffer full — onEnqueueError fires and error is returned
1399
+ const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1400
+ expect(second.errors).toHaveLength(1);
1401
+ expect(onEnqueueError).toHaveBeenCalledTimes(1);
1402
+ });
1403
+ it('should not buffer when a fallback transport succeeds after the primary fails', async () => {
1404
+ const rabbitMock = {
1405
+ ...transport,
1406
+ name: 'rabbitmq',
1407
+ send: mock(async () => {
1408
+ throw new Error('RabbitMQ down');
1409
+ }),
1410
+ };
1411
+ const localMock = {
1412
+ ...transport,
1413
+ name: 'local',
1414
+ send: mock(async () => 'local'),
1415
+ };
1416
+ const multiTransport = new MultiTransport({
1417
+ transports: [rabbitMock, localMock],
1418
+ });
1419
+ const fanoutWithMulti = new FanoutEngine({
1420
+ transport: multiTransport,
1421
+ schema,
1422
+ hooks,
1423
+ topology: testTopology,
1424
+ defaultQueue: 'events',
1425
+ });
1426
+ const subscriber = createSubscriber({
1427
+ name: 'handle-user',
1428
+ description: 'Handles user events',
1429
+ callback: async () => { },
1430
+ });
1431
+ schema.register(UserCreatedEvent, [subscriber]);
1432
+ const event = new UserCreatedEvent({
1433
+ userId: '123',
1434
+ email: 'test@example.com',
1435
+ });
1436
+ const result = await fanoutWithMulti.send(UserCreatedEvent, event);
1437
+ expect(result.subscribersSent).toBe(1);
1438
+ expect(result.errors).toHaveLength(0);
1439
+ expect(rabbitMock.send).toHaveBeenCalledTimes(1);
1440
+ expect(localMock.send).toHaveBeenCalledTimes(1);
1441
+ expect(fanoutWithMulti.eventsBeingEnqueuedCount).toBe(0);
1442
+ });
1443
+ });
1072
1444
  });
@@ -1 +1 @@
1
- {"version":3,"file":"matador.d.ts","sourceRoot":"","sources":["../../src/core/matador.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"matador.d.ts","sourceRoot":"","sources":["../../src/core/matador.ts"],"names":[],"mappings":"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"}
@@ -235,6 +235,7 @@ export class Matador {
235
235
  if (!this.started) {
236
236
  return;
237
237
  }
238
+ this.fanout.dispose();
238
239
  await this.shutdownManager.shutdown();
239
240
  this.started = false;
240
241
  }
@@ -317,7 +317,7 @@ describe('Matador', () => {
317
317
  throw sendError;
318
318
  });
319
319
  const thrown = await matador
320
- .send(new UserCreatedEvent({ userId: '123', email: 'test@example.com' }))
320
+ .send(new UserCreatedEvent({ userId: '123', email: 'test@example.com' }), { buffer: false })
321
321
  .catch((e) => e);
322
322
  expect(thrown).toBeInstanceOf(SomeSendError);
323
323
  expect(thrown.eventKey).toBe(UserCreatedEvent.key);