@zdavison/matador 4.0.1 → 4.0.3
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/core/fanout.d.ts +62 -0
- package/dist/core/fanout.d.ts.map +1 -1
- package/dist/core/fanout.js +217 -15
- package/dist/core/fanout.test.js +904 -6
- package/dist/core/matador.d.ts +33 -0
- package/dist/core/matador.d.ts.map +1 -1
- package/dist/core/matador.js +6 -1
- package/dist/core/matador.test.js +243 -7
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +2 -2
- package/dist/errors/matador-errors.d.ts +10 -8
- package/dist/errors/matador-errors.d.ts.map +1 -1
- package/dist/errors/matador-errors.js +18 -14
- package/dist/index.cjs +345 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/transport/local/local-transport.d.ts +6 -0
- package/dist/transport/local/local-transport.d.ts.map +1 -1
- package/dist/transport/local/local-transport.js +35 -18
- package/dist/transport/local/local-transport.test.js +40 -4
- package/dist/transport/multi/multi-transport.d.ts +6 -0
- package/dist/transport/multi/multi-transport.d.ts.map +1 -1
- package/dist/transport/multi/multi-transport.js +20 -0
- package/dist/transport/multi/multi-transport.test.js +82 -0
- package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +45 -6
- package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +6 -0
- package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
- package/dist/transport/rabbitmq/rabbitmq-transport.js +83 -40
- package/dist/transport/transport.d.ts +12 -0
- package/dist/transport/transport.d.ts.map +1 -1
- package/dist/types/event.d.ts +17 -0
- package/dist/types/event.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/core/fanout.test.js
CHANGED
|
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
|
2
2
|
import { TransportSendError } from '../errors/index.js';
|
|
3
3
|
import { SafeHooks } from '../hooks/index.js';
|
|
4
4
|
import { SchemaRegistry } from '../schema/index.js';
|
|
5
|
-
import { TopologyBuilder } from '../topology/index.js';
|
|
5
|
+
import { TopologyBuilder, resolveTargetQueueName } from '../topology/index.js';
|
|
6
|
+
import { LocalTransport, 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 {
|
|
@@ -34,6 +35,20 @@ class UserCreatedEventWithMetadata extends MatadorEvent {
|
|
|
34
35
|
this.metadata = metadata;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Waits for a triggered retry-buffer flush to fully settle, including the
|
|
40
|
+
* inter-item throttle sleeps — a single macrotask tick isn't enough once the
|
|
41
|
+
* flush loop sleeps between items.
|
|
42
|
+
*/
|
|
43
|
+
async function waitForFlush(fanout) {
|
|
44
|
+
const deadline = Date.now() + 1000;
|
|
45
|
+
while (fanout.eventsBeingEnqueuedCount > 0) {
|
|
46
|
+
if (Date.now() > deadline) {
|
|
47
|
+
throw new Error('waitForFlush: timed out waiting for flush to settle');
|
|
48
|
+
}
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
37
52
|
const testTopology = TopologyBuilder.create()
|
|
38
53
|
.withNamespace('test')
|
|
39
54
|
.addQueue('events')
|
|
@@ -514,7 +529,7 @@ describe('FanoutEngine', () => {
|
|
|
514
529
|
});
|
|
515
530
|
});
|
|
516
531
|
describe('error handling', () => {
|
|
517
|
-
it('should capture error when transport.send fails', async () => {
|
|
532
|
+
it('should capture error when transport.send fails and buffer is disabled', async () => {
|
|
518
533
|
const sendError = new Error('Network timeout');
|
|
519
534
|
const failingTransport = {
|
|
520
535
|
...transport,
|
|
@@ -539,7 +554,7 @@ describe('FanoutEngine', () => {
|
|
|
539
554
|
userId: '123',
|
|
540
555
|
email: 'test@example.com',
|
|
541
556
|
});
|
|
542
|
-
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
|
|
557
|
+
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
|
|
543
558
|
expect(result.subscribersSent).toBe(0);
|
|
544
559
|
expect(result.subscribersSkipped).toBe(0);
|
|
545
560
|
expect(result.errors).toHaveLength(1);
|
|
@@ -581,7 +596,7 @@ describe('FanoutEngine', () => {
|
|
|
581
596
|
userId: '123',
|
|
582
597
|
email: 'test@example.com',
|
|
583
598
|
});
|
|
584
|
-
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
|
|
599
|
+
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
|
|
585
600
|
expect(result.subscribersSent).toBe(1);
|
|
586
601
|
expect(result.subscribersSkipped).toBe(0);
|
|
587
602
|
expect(result.errors).toHaveLength(1);
|
|
@@ -611,7 +626,7 @@ describe('FanoutEngine', () => {
|
|
|
611
626
|
userId: '123',
|
|
612
627
|
email: 'test@example.com',
|
|
613
628
|
});
|
|
614
|
-
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
|
|
629
|
+
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
|
|
615
630
|
expect(result.errors).toHaveLength(1);
|
|
616
631
|
expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
|
|
617
632
|
expect(result.errors[0]?.error.message).toContain('matador.test.events');
|
|
@@ -867,7 +882,9 @@ describe('FanoutEngine', () => {
|
|
|
867
882
|
userId: '123',
|
|
868
883
|
email: 'test@example.com',
|
|
869
884
|
});
|
|
870
|
-
await fanoutWithHooks.send(UserCreatedEvent, event
|
|
885
|
+
await fanoutWithHooks.send(UserCreatedEvent, event, {
|
|
886
|
+
throwOnBufferedFailure: true,
|
|
887
|
+
});
|
|
871
888
|
expect(onEnqueueError).toHaveBeenCalledTimes(1);
|
|
872
889
|
expect(onEnqueueError).toHaveBeenCalledWith({
|
|
873
890
|
envelope: expect.any(Object),
|
|
@@ -1069,4 +1086,885 @@ describe('FanoutEngine', () => {
|
|
|
1069
1086
|
expect(envelope.docket.importance).toBe('should-investigate');
|
|
1070
1087
|
});
|
|
1071
1088
|
});
|
|
1089
|
+
describe('retry buffering', () => {
|
|
1090
|
+
it('should buffer a failed send and not return it as an error', async () => {
|
|
1091
|
+
const subscriber = createSubscriber({
|
|
1092
|
+
name: 'handle-user',
|
|
1093
|
+
description: 'Handles user events',
|
|
1094
|
+
callback: async () => { },
|
|
1095
|
+
});
|
|
1096
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1097
|
+
const failingTransport = {
|
|
1098
|
+
...transport,
|
|
1099
|
+
send: mock(async () => {
|
|
1100
|
+
throw new Error('Connection lost');
|
|
1101
|
+
}),
|
|
1102
|
+
};
|
|
1103
|
+
const fanoutWithFailingTransport = new FanoutEngine({
|
|
1104
|
+
transport: failingTransport,
|
|
1105
|
+
schema,
|
|
1106
|
+
hooks,
|
|
1107
|
+
topology: testTopology,
|
|
1108
|
+
defaultQueue: 'events',
|
|
1109
|
+
});
|
|
1110
|
+
const event = new UserCreatedEvent({
|
|
1111
|
+
userId: '123',
|
|
1112
|
+
email: 'test@example.com',
|
|
1113
|
+
});
|
|
1114
|
+
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
|
|
1115
|
+
expect(result.errors).toHaveLength(0);
|
|
1116
|
+
expect(result.subscribersSent).toBe(0);
|
|
1117
|
+
});
|
|
1118
|
+
it('should buffer stub failures too', async () => {
|
|
1119
|
+
const stub = createSubscriberStub({ name: 'remote-service' });
|
|
1120
|
+
schema.register(UserCreatedEvent, [stub]);
|
|
1121
|
+
const failingTransport = {
|
|
1122
|
+
...transport,
|
|
1123
|
+
send: mock(async () => {
|
|
1124
|
+
throw new Error('Connection lost');
|
|
1125
|
+
}),
|
|
1126
|
+
};
|
|
1127
|
+
const fanoutWithFailingTransport = new FanoutEngine({
|
|
1128
|
+
transport: failingTransport,
|
|
1129
|
+
schema,
|
|
1130
|
+
hooks,
|
|
1131
|
+
topology: testTopology,
|
|
1132
|
+
defaultQueue: 'events',
|
|
1133
|
+
});
|
|
1134
|
+
const event = new UserCreatedEvent({
|
|
1135
|
+
userId: '123',
|
|
1136
|
+
email: 'test@example.com',
|
|
1137
|
+
});
|
|
1138
|
+
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
|
|
1139
|
+
expect(result.errors).toHaveLength(0);
|
|
1140
|
+
expect(result.subscribersSent).toBe(0);
|
|
1141
|
+
});
|
|
1142
|
+
it('should buffer instead of reporting success when a stub subscriber has no local subscriber', async () => {
|
|
1143
|
+
// LocalTransport rejects sends to a queue with no active subscriber in
|
|
1144
|
+
// this process. A stub subscriber's real implementation lives in another
|
|
1145
|
+
// service, so this queue will never have one here — the send must fail
|
|
1146
|
+
// and be buffered, not reported as delivered.
|
|
1147
|
+
const stub = createSubscriberStub({ name: 'remote-service' });
|
|
1148
|
+
schema.register(UserCreatedEvent, [stub]);
|
|
1149
|
+
const onEnqueueSuccess = mock(async () => { });
|
|
1150
|
+
const hooksInstance = new SafeHooks({ onEnqueueSuccess });
|
|
1151
|
+
const localTransport = new LocalTransport();
|
|
1152
|
+
await localTransport.connect();
|
|
1153
|
+
await localTransport.applyTopology(testTopology);
|
|
1154
|
+
const fanoutWithLocal = new FanoutEngine({
|
|
1155
|
+
transport: localTransport,
|
|
1156
|
+
schema,
|
|
1157
|
+
hooks: hooksInstance,
|
|
1158
|
+
topology: testTopology,
|
|
1159
|
+
defaultQueue: 'events',
|
|
1160
|
+
// Fast interval so the test doesn't have to wait on the 30s default
|
|
1161
|
+
// to prove the message is actually held for retry, not dropped.
|
|
1162
|
+
retryIntervalMs: 10,
|
|
1163
|
+
});
|
|
1164
|
+
const event = new UserCreatedEvent({
|
|
1165
|
+
userId: '123',
|
|
1166
|
+
email: 'test@example.com',
|
|
1167
|
+
});
|
|
1168
|
+
const result = await fanoutWithLocal.send(UserCreatedEvent, event);
|
|
1169
|
+
expect(result.errors).toHaveLength(0);
|
|
1170
|
+
expect(result.subscribersSent).toBe(0);
|
|
1171
|
+
expect(onEnqueueSuccess).not.toHaveBeenCalled();
|
|
1172
|
+
// The message never reached LocalTransport's own storage (enqueue()
|
|
1173
|
+
// throws before storing), so it can only still exist in-memory if
|
|
1174
|
+
// FanoutEngine actually buffered it. Prove that by attaching a
|
|
1175
|
+
// subscriber and letting the periodic flush retry the send — if the
|
|
1176
|
+
// message had been dropped instead of buffered, nothing would arrive.
|
|
1177
|
+
const received = [];
|
|
1178
|
+
await localTransport.subscribe(resolveTargetQueueName(testTopology, 'events'), async (envelope) => {
|
|
1179
|
+
received.push(envelope);
|
|
1180
|
+
});
|
|
1181
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
1182
|
+
expect(received).toHaveLength(1);
|
|
1183
|
+
expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
|
|
1184
|
+
fanoutWithLocal.dispose();
|
|
1185
|
+
});
|
|
1186
|
+
it('should flush buffered messages when onConnected fires', async () => {
|
|
1187
|
+
let connectedCallback;
|
|
1188
|
+
const failThenSucceed = mock(async () => {
|
|
1189
|
+
throw new Error('Connection lost');
|
|
1190
|
+
});
|
|
1191
|
+
const transportWithReconnect = {
|
|
1192
|
+
...transport,
|
|
1193
|
+
send: failThenSucceed,
|
|
1194
|
+
onConnected: (cb) => {
|
|
1195
|
+
connectedCallback = cb;
|
|
1196
|
+
return () => { };
|
|
1197
|
+
},
|
|
1198
|
+
};
|
|
1199
|
+
const fanoutWithReconnect = new FanoutEngine({
|
|
1200
|
+
transport: transportWithReconnect,
|
|
1201
|
+
schema,
|
|
1202
|
+
hooks,
|
|
1203
|
+
topology: testTopology,
|
|
1204
|
+
defaultQueue: 'events',
|
|
1205
|
+
});
|
|
1206
|
+
const subscriber = createSubscriber({
|
|
1207
|
+
name: 'handle-user',
|
|
1208
|
+
description: 'Handles user events',
|
|
1209
|
+
callback: async () => { },
|
|
1210
|
+
});
|
|
1211
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1212
|
+
const event = new UserCreatedEvent({
|
|
1213
|
+
userId: '123',
|
|
1214
|
+
email: 'test@example.com',
|
|
1215
|
+
});
|
|
1216
|
+
// First send — transport fails, message should be buffered
|
|
1217
|
+
await fanoutWithReconnect.send(UserCreatedEvent, event);
|
|
1218
|
+
expect(failThenSucceed).toHaveBeenCalledTimes(1);
|
|
1219
|
+
// Simulate reconnect — now send succeeds
|
|
1220
|
+
transportWithReconnect.send.mockImplementation(async () => 'mock');
|
|
1221
|
+
expect(connectedCallback).toBeDefined();
|
|
1222
|
+
connectedCallback?.();
|
|
1223
|
+
// Give the async flush a chance to run
|
|
1224
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1225
|
+
expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
|
|
1226
|
+
});
|
|
1227
|
+
it('should re-buffer on flush failure and retry on next reconnect', async () => {
|
|
1228
|
+
let connectedCallback;
|
|
1229
|
+
const transportWithReconnect = {
|
|
1230
|
+
...transport,
|
|
1231
|
+
send: mock(async () => {
|
|
1232
|
+
throw new Error('Still down');
|
|
1233
|
+
}),
|
|
1234
|
+
onConnected: (cb) => {
|
|
1235
|
+
connectedCallback = cb;
|
|
1236
|
+
return () => { };
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
const fanoutWithReconnect = new FanoutEngine({
|
|
1240
|
+
transport: transportWithReconnect,
|
|
1241
|
+
schema,
|
|
1242
|
+
hooks,
|
|
1243
|
+
topology: testTopology,
|
|
1244
|
+
defaultQueue: 'events',
|
|
1245
|
+
});
|
|
1246
|
+
const subscriber = createSubscriber({
|
|
1247
|
+
name: 'handle-user',
|
|
1248
|
+
description: 'Handles user events',
|
|
1249
|
+
callback: async () => { },
|
|
1250
|
+
});
|
|
1251
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1252
|
+
const event = new UserCreatedEvent({
|
|
1253
|
+
userId: '123',
|
|
1254
|
+
email: 'test@example.com',
|
|
1255
|
+
});
|
|
1256
|
+
await fanoutWithReconnect.send(UserCreatedEvent, event);
|
|
1257
|
+
expect(transportWithReconnect.send).toHaveBeenCalledTimes(1);
|
|
1258
|
+
// Simulate reconnect — flush fails, item should be re-buffered
|
|
1259
|
+
connectedCallback?.();
|
|
1260
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1261
|
+
// send was attempted again during the flush
|
|
1262
|
+
expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
|
|
1263
|
+
// Simulate a second reconnect — should retry again
|
|
1264
|
+
connectedCallback?.();
|
|
1265
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1266
|
+
expect(transportWithReconnect.send).toHaveBeenCalledTimes(3);
|
|
1267
|
+
});
|
|
1268
|
+
it('should not buffer when transport works fine', async () => {
|
|
1269
|
+
let connectedCallback;
|
|
1270
|
+
const successTransport = {
|
|
1271
|
+
...transport,
|
|
1272
|
+
onConnected: (cb) => {
|
|
1273
|
+
connectedCallback = cb;
|
|
1274
|
+
return () => { };
|
|
1275
|
+
},
|
|
1276
|
+
};
|
|
1277
|
+
const fanoutWithReconnect = new FanoutEngine({
|
|
1278
|
+
transport: successTransport,
|
|
1279
|
+
schema,
|
|
1280
|
+
hooks,
|
|
1281
|
+
topology: testTopology,
|
|
1282
|
+
defaultQueue: 'events',
|
|
1283
|
+
});
|
|
1284
|
+
const subscriber = createSubscriber({
|
|
1285
|
+
name: 'handle-user',
|
|
1286
|
+
description: 'Handles user events',
|
|
1287
|
+
callback: async () => { },
|
|
1288
|
+
});
|
|
1289
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1290
|
+
const event = new UserCreatedEvent({
|
|
1291
|
+
userId: '123',
|
|
1292
|
+
email: 'test@example.com',
|
|
1293
|
+
});
|
|
1294
|
+
const result = await fanoutWithReconnect.send(UserCreatedEvent, event);
|
|
1295
|
+
expect(result.subscribersSent).toBe(1);
|
|
1296
|
+
expect(result.errors).toHaveLength(0);
|
|
1297
|
+
// onConnected fires — nothing buffered so flush is a no-op
|
|
1298
|
+
connectedCallback?.();
|
|
1299
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1300
|
+
// Only one call total (the original send — not a spurious re-send)
|
|
1301
|
+
expect(successTransport.send).toHaveBeenCalledTimes(1);
|
|
1302
|
+
});
|
|
1303
|
+
it('should report error in result when buffer is false', async () => {
|
|
1304
|
+
const subscriber = createSubscriber({
|
|
1305
|
+
name: 'handle-user',
|
|
1306
|
+
description: 'Handles user events',
|
|
1307
|
+
callback: async () => { },
|
|
1308
|
+
});
|
|
1309
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1310
|
+
const failingTransport = {
|
|
1311
|
+
...transport,
|
|
1312
|
+
send: mock(async () => {
|
|
1313
|
+
throw new Error('Connection lost');
|
|
1314
|
+
}),
|
|
1315
|
+
};
|
|
1316
|
+
const fanoutWithFailingTransport = new FanoutEngine({
|
|
1317
|
+
transport: failingTransport,
|
|
1318
|
+
schema,
|
|
1319
|
+
hooks,
|
|
1320
|
+
topology: testTopology,
|
|
1321
|
+
defaultQueue: 'events',
|
|
1322
|
+
});
|
|
1323
|
+
const event = new UserCreatedEvent({
|
|
1324
|
+
userId: '123',
|
|
1325
|
+
email: 'test@example.com',
|
|
1326
|
+
});
|
|
1327
|
+
const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
|
|
1328
|
+
expect(result.errors).toHaveLength(1);
|
|
1329
|
+
expect(result.errors[0]?.subscriberName).toBe('handle-user');
|
|
1330
|
+
expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
|
|
1331
|
+
});
|
|
1332
|
+
it('should both buffer and report error when reportBufferedFailure is true', async () => {
|
|
1333
|
+
let connectedCallback;
|
|
1334
|
+
const failingTransport = {
|
|
1335
|
+
...transport,
|
|
1336
|
+
send: mock(async () => {
|
|
1337
|
+
throw new Error('Connection lost');
|
|
1338
|
+
}),
|
|
1339
|
+
onConnected: (cb) => {
|
|
1340
|
+
connectedCallback = cb;
|
|
1341
|
+
return () => { };
|
|
1342
|
+
},
|
|
1343
|
+
};
|
|
1344
|
+
const fanoutWithReconnect = new FanoutEngine({
|
|
1345
|
+
transport: failingTransport,
|
|
1346
|
+
schema,
|
|
1347
|
+
hooks,
|
|
1348
|
+
topology: testTopology,
|
|
1349
|
+
defaultQueue: 'events',
|
|
1350
|
+
});
|
|
1351
|
+
const subscriber = createSubscriber({
|
|
1352
|
+
name: 'handle-user',
|
|
1353
|
+
description: 'Handles user events',
|
|
1354
|
+
callback: async () => { },
|
|
1355
|
+
});
|
|
1356
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1357
|
+
const event = new UserCreatedEvent({
|
|
1358
|
+
userId: '123',
|
|
1359
|
+
email: 'test@example.com',
|
|
1360
|
+
});
|
|
1361
|
+
const result = await fanoutWithReconnect.send(UserCreatedEvent, event, {
|
|
1362
|
+
throwOnBufferedFailure: true,
|
|
1363
|
+
});
|
|
1364
|
+
// Error is reported immediately
|
|
1365
|
+
expect(result.errors).toHaveLength(1);
|
|
1366
|
+
expect(result.errors[0]?.subscriberName).toBe('handle-user');
|
|
1367
|
+
// But it was also buffered — it should retry on reconnect
|
|
1368
|
+
failingTransport.send.mockImplementation(async () => 'mock');
|
|
1369
|
+
connectedCallback?.();
|
|
1370
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1371
|
+
expect(failingTransport.send).toHaveBeenCalledTimes(2);
|
|
1372
|
+
});
|
|
1373
|
+
it('should unsubscribe the onConnected listener when dispose() is called', () => {
|
|
1374
|
+
const unsubscribe = mock(() => { });
|
|
1375
|
+
const transportWithOnConnected = {
|
|
1376
|
+
...transport,
|
|
1377
|
+
onConnected: mock((_cb) => unsubscribe),
|
|
1378
|
+
};
|
|
1379
|
+
const fanout = new FanoutEngine({
|
|
1380
|
+
transport: transportWithOnConnected,
|
|
1381
|
+
schema,
|
|
1382
|
+
hooks,
|
|
1383
|
+
topology: testTopology,
|
|
1384
|
+
defaultQueue: 'events',
|
|
1385
|
+
});
|
|
1386
|
+
expect(unsubscribe).not.toHaveBeenCalled();
|
|
1387
|
+
fanout.dispose();
|
|
1388
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
|
1389
|
+
});
|
|
1390
|
+
it('should drop and report error when retry buffer is full', async () => {
|
|
1391
|
+
const subscriber = createSubscriber({
|
|
1392
|
+
name: 'handle-user',
|
|
1393
|
+
description: 'Handles user events',
|
|
1394
|
+
callback: async () => { },
|
|
1395
|
+
});
|
|
1396
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1397
|
+
const failingTransport = {
|
|
1398
|
+
...transport,
|
|
1399
|
+
send: mock(async () => {
|
|
1400
|
+
throw new Error('Connection lost');
|
|
1401
|
+
}),
|
|
1402
|
+
};
|
|
1403
|
+
const fanoutWithSmallBuffer = new FanoutEngine({
|
|
1404
|
+
transport: failingTransport,
|
|
1405
|
+
schema,
|
|
1406
|
+
hooks,
|
|
1407
|
+
topology: testTopology,
|
|
1408
|
+
defaultQueue: 'events',
|
|
1409
|
+
maxRetryBufferSize: 1,
|
|
1410
|
+
});
|
|
1411
|
+
const event = new UserCreatedEvent({
|
|
1412
|
+
userId: '123',
|
|
1413
|
+
email: 'test@example.com',
|
|
1414
|
+
});
|
|
1415
|
+
// First send fills the buffer
|
|
1416
|
+
const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
|
|
1417
|
+
expect(first.errors).toHaveLength(0);
|
|
1418
|
+
// Second send hits the full buffer — must report an error
|
|
1419
|
+
const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
|
|
1420
|
+
expect(second.errors).toHaveLength(1);
|
|
1421
|
+
expect(second.errors[0]?.error).toBeInstanceOf(TransportSendError);
|
|
1422
|
+
});
|
|
1423
|
+
it('should report buffer-full drop via onEnqueueError on initial send', async () => {
|
|
1424
|
+
const onEnqueueError = mock(async () => { });
|
|
1425
|
+
const hooksInstance = new SafeHooks({
|
|
1426
|
+
onEnqueueError,
|
|
1427
|
+
});
|
|
1428
|
+
const failingTransport = {
|
|
1429
|
+
...transport,
|
|
1430
|
+
send: mock(async () => {
|
|
1431
|
+
throw new Error('Connection lost');
|
|
1432
|
+
}),
|
|
1433
|
+
};
|
|
1434
|
+
const subscriber = createSubscriber({
|
|
1435
|
+
name: 'handle-user',
|
|
1436
|
+
description: 'Handles user events',
|
|
1437
|
+
callback: async () => { },
|
|
1438
|
+
});
|
|
1439
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1440
|
+
const fanoutWithSmallBuffer = new FanoutEngine({
|
|
1441
|
+
transport: failingTransport,
|
|
1442
|
+
schema,
|
|
1443
|
+
hooks: hooksInstance,
|
|
1444
|
+
topology: testTopology,
|
|
1445
|
+
defaultQueue: 'events',
|
|
1446
|
+
maxRetryBufferSize: 1,
|
|
1447
|
+
});
|
|
1448
|
+
const event = new UserCreatedEvent({
|
|
1449
|
+
userId: '123',
|
|
1450
|
+
email: 'test@example.com',
|
|
1451
|
+
});
|
|
1452
|
+
// First send fills the buffer — no error reported to caller or hook
|
|
1453
|
+
const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
|
|
1454
|
+
expect(first.errors).toHaveLength(0);
|
|
1455
|
+
expect(onEnqueueError).not.toHaveBeenCalled();
|
|
1456
|
+
// Second send finds the buffer full — onEnqueueError fires and error is returned
|
|
1457
|
+
const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
|
|
1458
|
+
expect(second.errors).toHaveLength(1);
|
|
1459
|
+
expect(onEnqueueError).toHaveBeenCalledTimes(1);
|
|
1460
|
+
});
|
|
1461
|
+
it('should not buffer when a fallback transport succeeds after the primary fails', async () => {
|
|
1462
|
+
const rabbitMock = {
|
|
1463
|
+
...transport,
|
|
1464
|
+
name: 'rabbitmq',
|
|
1465
|
+
send: mock(async () => {
|
|
1466
|
+
throw new Error('RabbitMQ down');
|
|
1467
|
+
}),
|
|
1468
|
+
};
|
|
1469
|
+
const localMock = {
|
|
1470
|
+
...transport,
|
|
1471
|
+
name: 'local',
|
|
1472
|
+
send: mock(async () => 'local'),
|
|
1473
|
+
};
|
|
1474
|
+
const multiTransport = new MultiTransport({
|
|
1475
|
+
transports: [rabbitMock, localMock],
|
|
1476
|
+
});
|
|
1477
|
+
const fanoutWithMulti = new FanoutEngine({
|
|
1478
|
+
transport: multiTransport,
|
|
1479
|
+
schema,
|
|
1480
|
+
hooks,
|
|
1481
|
+
topology: testTopology,
|
|
1482
|
+
defaultQueue: 'events',
|
|
1483
|
+
});
|
|
1484
|
+
const subscriber = createSubscriber({
|
|
1485
|
+
name: 'handle-user',
|
|
1486
|
+
description: 'Handles user events',
|
|
1487
|
+
callback: async () => { },
|
|
1488
|
+
});
|
|
1489
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1490
|
+
const event = new UserCreatedEvent({
|
|
1491
|
+
userId: '123',
|
|
1492
|
+
email: 'test@example.com',
|
|
1493
|
+
});
|
|
1494
|
+
const result = await fanoutWithMulti.send(UserCreatedEvent, event);
|
|
1495
|
+
expect(result.subscribersSent).toBe(1);
|
|
1496
|
+
expect(result.errors).toHaveLength(0);
|
|
1497
|
+
expect(rabbitMock.send).toHaveBeenCalledTimes(1);
|
|
1498
|
+
expect(localMock.send).toHaveBeenCalledTimes(1);
|
|
1499
|
+
expect(fanoutWithMulti.eventsBeingEnqueuedCount).toBe(0);
|
|
1500
|
+
});
|
|
1501
|
+
it('should buffer (not report success) when a real MultiTransport falls back to local for a stub subscriber', async () => {
|
|
1502
|
+
const stub = createSubscriberStub({ name: 'remote-service' });
|
|
1503
|
+
schema.register(UserCreatedEvent, [stub]);
|
|
1504
|
+
const rabbitMock = {
|
|
1505
|
+
...transport,
|
|
1506
|
+
name: 'rabbitmq',
|
|
1507
|
+
send: mock(async () => {
|
|
1508
|
+
throw new Error('RabbitMQ down');
|
|
1509
|
+
}),
|
|
1510
|
+
};
|
|
1511
|
+
const localTransport = new LocalTransport();
|
|
1512
|
+
await localTransport.connect();
|
|
1513
|
+
await localTransport.applyTopology(testTopology);
|
|
1514
|
+
const multiTransport = new MultiTransport({
|
|
1515
|
+
transports: [rabbitMock, localTransport],
|
|
1516
|
+
});
|
|
1517
|
+
const onEnqueueSuccess = mock(async () => { });
|
|
1518
|
+
const hooksInstance = new SafeHooks({ onEnqueueSuccess });
|
|
1519
|
+
const fanoutWithMulti = new FanoutEngine({
|
|
1520
|
+
transport: multiTransport,
|
|
1521
|
+
schema,
|
|
1522
|
+
hooks: hooksInstance,
|
|
1523
|
+
topology: testTopology,
|
|
1524
|
+
defaultQueue: 'events',
|
|
1525
|
+
// Fast interval so the test doesn't have to wait on the 30s default
|
|
1526
|
+
// to prove the message is actually held for retry, not dropped.
|
|
1527
|
+
retryIntervalMs: 10,
|
|
1528
|
+
});
|
|
1529
|
+
const event = new UserCreatedEvent({
|
|
1530
|
+
userId: '123',
|
|
1531
|
+
email: 'test@example.com',
|
|
1532
|
+
});
|
|
1533
|
+
const result = await fanoutWithMulti.send(UserCreatedEvent, event);
|
|
1534
|
+
expect(result.subscribersSent).toBe(0);
|
|
1535
|
+
expect(result.errors).toHaveLength(0);
|
|
1536
|
+
expect(onEnqueueSuccess).not.toHaveBeenCalled();
|
|
1537
|
+
expect(rabbitMock.send).toHaveBeenCalledTimes(1);
|
|
1538
|
+
// Neither rabbitmq nor local ever stored the message durably (rabbit
|
|
1539
|
+
// threw, local's enqueue() throws with no subscriber), so it can only
|
|
1540
|
+
// still be in memory if FanoutEngine actually buffered it. Prove that
|
|
1541
|
+
// by attaching a local subscriber and letting the periodic flush retry.
|
|
1542
|
+
const received = [];
|
|
1543
|
+
await localTransport.subscribe(resolveTargetQueueName(testTopology, 'events'), async (envelope) => {
|
|
1544
|
+
received.push(envelope);
|
|
1545
|
+
});
|
|
1546
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
1547
|
+
expect(received).toHaveLength(1);
|
|
1548
|
+
expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
|
|
1549
|
+
fanoutWithMulti.dispose();
|
|
1550
|
+
});
|
|
1551
|
+
it('should re-buffer a stub message that falls back to local again on flush, then deliver once the primary recovers', async () => {
|
|
1552
|
+
const stub = createSubscriberStub({ name: 'remote-service' });
|
|
1553
|
+
schema.register(UserCreatedEvent, [stub]);
|
|
1554
|
+
let rabbitUp = false;
|
|
1555
|
+
let connectedCallback;
|
|
1556
|
+
const rabbitMock = {
|
|
1557
|
+
...transport,
|
|
1558
|
+
name: 'rabbitmq',
|
|
1559
|
+
send: mock(async () => {
|
|
1560
|
+
if (!rabbitUp)
|
|
1561
|
+
throw new Error('RabbitMQ down');
|
|
1562
|
+
return 'rabbitmq';
|
|
1563
|
+
}),
|
|
1564
|
+
onConnected: (cb) => {
|
|
1565
|
+
connectedCallback = cb;
|
|
1566
|
+
return () => { };
|
|
1567
|
+
},
|
|
1568
|
+
};
|
|
1569
|
+
const localTransport = new LocalTransport();
|
|
1570
|
+
await localTransport.connect();
|
|
1571
|
+
await localTransport.applyTopology(testTopology);
|
|
1572
|
+
const multiTransport = new MultiTransport({
|
|
1573
|
+
transports: [rabbitMock, localTransport],
|
|
1574
|
+
});
|
|
1575
|
+
const onEnqueueSuccess = mock(async () => { });
|
|
1576
|
+
const hooksInstance = new SafeHooks({ onEnqueueSuccess });
|
|
1577
|
+
const fanoutWithMulti = new FanoutEngine({
|
|
1578
|
+
transport: multiTransport,
|
|
1579
|
+
schema,
|
|
1580
|
+
hooks: hooksInstance,
|
|
1581
|
+
topology: testTopology,
|
|
1582
|
+
defaultQueue: 'events',
|
|
1583
|
+
});
|
|
1584
|
+
const event = new UserCreatedEvent({
|
|
1585
|
+
userId: '123',
|
|
1586
|
+
email: 'test@example.com',
|
|
1587
|
+
});
|
|
1588
|
+
// Initial send: rabbit down, falls back to local, buffered (not delivered).
|
|
1589
|
+
await fanoutWithMulti.send(UserCreatedEvent, event);
|
|
1590
|
+
expect(onEnqueueSuccess).not.toHaveBeenCalled();
|
|
1591
|
+
// Reconnect fires while rabbit is still down — flush falls back to local
|
|
1592
|
+
// again and must re-buffer, not report success.
|
|
1593
|
+
connectedCallback?.();
|
|
1594
|
+
await waitForFlush(fanoutWithMulti);
|
|
1595
|
+
expect(onEnqueueSuccess).not.toHaveBeenCalled();
|
|
1596
|
+
// Rabbit recovers — next flush should deliver for real via rabbitmq.
|
|
1597
|
+
rabbitUp = true;
|
|
1598
|
+
connectedCallback?.();
|
|
1599
|
+
await waitForFlush(fanoutWithMulti);
|
|
1600
|
+
expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
|
|
1601
|
+
expect(onEnqueueSuccess).toHaveBeenCalledWith(expect.objectContaining({ transport: 'rabbitmq' }));
|
|
1602
|
+
});
|
|
1603
|
+
it('should retry via the periodic interval even without a reconnect event', async () => {
|
|
1604
|
+
// Covers the case where the transport stays connected the whole time
|
|
1605
|
+
// but an individual publish keeps failing — onConnected never fires
|
|
1606
|
+
// again for that, so only the interval can recover it.
|
|
1607
|
+
const subscriber = createSubscriber({
|
|
1608
|
+
name: 'handle-user',
|
|
1609
|
+
description: 'Handles user events',
|
|
1610
|
+
callback: async () => { },
|
|
1611
|
+
});
|
|
1612
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1613
|
+
let shouldFail = true;
|
|
1614
|
+
const flakyTransport = {
|
|
1615
|
+
...transport,
|
|
1616
|
+
send: mock(async () => {
|
|
1617
|
+
if (shouldFail)
|
|
1618
|
+
throw new Error('Publish nacked');
|
|
1619
|
+
return 'mock';
|
|
1620
|
+
}),
|
|
1621
|
+
// Deliberately no onConnected — the transport never disconnects.
|
|
1622
|
+
};
|
|
1623
|
+
const fanoutWithInterval = new FanoutEngine({
|
|
1624
|
+
transport: flakyTransport,
|
|
1625
|
+
schema,
|
|
1626
|
+
hooks,
|
|
1627
|
+
topology: testTopology,
|
|
1628
|
+
defaultQueue: 'events',
|
|
1629
|
+
retryIntervalMs: 20,
|
|
1630
|
+
});
|
|
1631
|
+
const event = new UserCreatedEvent({
|
|
1632
|
+
userId: '123',
|
|
1633
|
+
email: 'test@example.com',
|
|
1634
|
+
});
|
|
1635
|
+
await fanoutWithInterval.send(UserCreatedEvent, event);
|
|
1636
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(1);
|
|
1637
|
+
shouldFail = false;
|
|
1638
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1639
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(2);
|
|
1640
|
+
fanoutWithInterval.dispose();
|
|
1641
|
+
});
|
|
1642
|
+
it('should stop retrying the rest of the buffer after 10 consecutive failures in a flush pass', async () => {
|
|
1643
|
+
let connectedCallback;
|
|
1644
|
+
let shouldFail = true;
|
|
1645
|
+
const flakyTransport = {
|
|
1646
|
+
...transport,
|
|
1647
|
+
send: mock(async () => {
|
|
1648
|
+
if (shouldFail)
|
|
1649
|
+
throw new Error('Broker rejecting everything');
|
|
1650
|
+
return 'mock';
|
|
1651
|
+
}),
|
|
1652
|
+
onConnected: (cb) => {
|
|
1653
|
+
connectedCallback = cb;
|
|
1654
|
+
return () => { };
|
|
1655
|
+
},
|
|
1656
|
+
};
|
|
1657
|
+
const fanoutWithReconnect = new FanoutEngine({
|
|
1658
|
+
transport: flakyTransport,
|
|
1659
|
+
schema,
|
|
1660
|
+
hooks,
|
|
1661
|
+
topology: testTopology,
|
|
1662
|
+
defaultQueue: 'events',
|
|
1663
|
+
});
|
|
1664
|
+
const subscriber = createSubscriber({
|
|
1665
|
+
name: 'handle-user',
|
|
1666
|
+
description: 'Handles user events',
|
|
1667
|
+
callback: async () => { },
|
|
1668
|
+
});
|
|
1669
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1670
|
+
const event = new UserCreatedEvent({
|
|
1671
|
+
userId: '123',
|
|
1672
|
+
email: 'test@example.com',
|
|
1673
|
+
});
|
|
1674
|
+
// 12 sends buffered; flush bails after the 10th consecutive failure,
|
|
1675
|
+
// leaving 2 untried.
|
|
1676
|
+
for (let i = 0; i < 12; i++) {
|
|
1677
|
+
await fanoutWithReconnect.send(UserCreatedEvent, event);
|
|
1678
|
+
}
|
|
1679
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(12);
|
|
1680
|
+
connectedCallback?.();
|
|
1681
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1682
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(22);
|
|
1683
|
+
// Recovery: next flush delivers all 12 re-buffered/untried items.
|
|
1684
|
+
shouldFail = false;
|
|
1685
|
+
connectedCallback?.();
|
|
1686
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1687
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(34);
|
|
1688
|
+
});
|
|
1689
|
+
it('should respect a custom maxConsecutiveFlushFailures threshold', async () => {
|
|
1690
|
+
let connectedCallback;
|
|
1691
|
+
const flakyTransport = {
|
|
1692
|
+
...transport,
|
|
1693
|
+
send: mock(async () => {
|
|
1694
|
+
throw new Error('Broker rejecting everything');
|
|
1695
|
+
}),
|
|
1696
|
+
onConnected: (cb) => {
|
|
1697
|
+
connectedCallback = cb;
|
|
1698
|
+
return () => { };
|
|
1699
|
+
},
|
|
1700
|
+
};
|
|
1701
|
+
const fanoutWithCustomThreshold = new FanoutEngine({
|
|
1702
|
+
transport: flakyTransport,
|
|
1703
|
+
schema,
|
|
1704
|
+
hooks,
|
|
1705
|
+
topology: testTopology,
|
|
1706
|
+
defaultQueue: 'events',
|
|
1707
|
+
maxConsecutiveFlushFailures: 3,
|
|
1708
|
+
});
|
|
1709
|
+
const subscriber = createSubscriber({
|
|
1710
|
+
name: 'handle-user',
|
|
1711
|
+
description: 'Handles user events',
|
|
1712
|
+
callback: async () => { },
|
|
1713
|
+
});
|
|
1714
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1715
|
+
const event = new UserCreatedEvent({
|
|
1716
|
+
userId: '123',
|
|
1717
|
+
email: 'test@example.com',
|
|
1718
|
+
});
|
|
1719
|
+
// 5 sends buffered; flush should bail after the 3rd consecutive
|
|
1720
|
+
// failure, leaving 2 untried, instead of the default threshold of 10.
|
|
1721
|
+
for (let i = 0; i < 5; i++) {
|
|
1722
|
+
await fanoutWithCustomThreshold.send(UserCreatedEvent, event);
|
|
1723
|
+
}
|
|
1724
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(5);
|
|
1725
|
+
connectedCallback?.();
|
|
1726
|
+
await waitForFlush(fanoutWithCustomThreshold);
|
|
1727
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(8);
|
|
1728
|
+
});
|
|
1729
|
+
it('should never bail out of a flush pass when maxConsecutiveFlushFailures is disabled (0)', async () => {
|
|
1730
|
+
let connectedCallback;
|
|
1731
|
+
const flakyTransport = {
|
|
1732
|
+
...transport,
|
|
1733
|
+
send: mock(async () => {
|
|
1734
|
+
throw new Error('Broker rejecting everything');
|
|
1735
|
+
}),
|
|
1736
|
+
onConnected: (cb) => {
|
|
1737
|
+
connectedCallback = cb;
|
|
1738
|
+
return () => { };
|
|
1739
|
+
},
|
|
1740
|
+
};
|
|
1741
|
+
const fanoutWithBreakerDisabled = new FanoutEngine({
|
|
1742
|
+
transport: flakyTransport,
|
|
1743
|
+
schema,
|
|
1744
|
+
hooks,
|
|
1745
|
+
topology: testTopology,
|
|
1746
|
+
defaultQueue: 'events',
|
|
1747
|
+
maxConsecutiveFlushFailures: 0,
|
|
1748
|
+
});
|
|
1749
|
+
const subscriber = createSubscriber({
|
|
1750
|
+
name: 'handle-user',
|
|
1751
|
+
description: 'Handles user events',
|
|
1752
|
+
callback: async () => { },
|
|
1753
|
+
});
|
|
1754
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1755
|
+
const event = new UserCreatedEvent({
|
|
1756
|
+
userId: '123',
|
|
1757
|
+
email: 'test@example.com',
|
|
1758
|
+
});
|
|
1759
|
+
// 15 sends buffered, well past the default breaker threshold of 10;
|
|
1760
|
+
// with the breaker disabled every single one should still be attempted.
|
|
1761
|
+
for (let i = 0; i < 15; i++) {
|
|
1762
|
+
await fanoutWithBreakerDisabled.send(UserCreatedEvent, event);
|
|
1763
|
+
}
|
|
1764
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(15);
|
|
1765
|
+
connectedCallback?.();
|
|
1766
|
+
await waitForFlush(fanoutWithBreakerDisabled);
|
|
1767
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(30);
|
|
1768
|
+
});
|
|
1769
|
+
it('should not trip the failure breaker on isolated failures interspersed with successes', async () => {
|
|
1770
|
+
let connectedCallback;
|
|
1771
|
+
let phase = 'buffering';
|
|
1772
|
+
let flushCallIndex = 0;
|
|
1773
|
+
const onEnqueueSuccess = mock(async () => { });
|
|
1774
|
+
const hooksInstance = new SafeHooks({ onEnqueueSuccess });
|
|
1775
|
+
const mostlyHealthyTransport = {
|
|
1776
|
+
...transport,
|
|
1777
|
+
send: mock(async () => {
|
|
1778
|
+
if (phase === 'buffering') {
|
|
1779
|
+
throw new Error('Initially down');
|
|
1780
|
+
}
|
|
1781
|
+
flushCallIndex++;
|
|
1782
|
+
// Every 3rd flushed item fails — isolated, never two in a row.
|
|
1783
|
+
if (flushCallIndex % 3 === 0)
|
|
1784
|
+
throw new Error('Occasional nack');
|
|
1785
|
+
return 'mock';
|
|
1786
|
+
}),
|
|
1787
|
+
onConnected: (cb) => {
|
|
1788
|
+
connectedCallback = cb;
|
|
1789
|
+
return () => { };
|
|
1790
|
+
},
|
|
1791
|
+
};
|
|
1792
|
+
const fanoutWithReconnect = new FanoutEngine({
|
|
1793
|
+
transport: mostlyHealthyTransport,
|
|
1794
|
+
schema,
|
|
1795
|
+
hooks: hooksInstance,
|
|
1796
|
+
topology: testTopology,
|
|
1797
|
+
defaultQueue: 'events',
|
|
1798
|
+
});
|
|
1799
|
+
const subscriber = createSubscriber({
|
|
1800
|
+
name: 'handle-user',
|
|
1801
|
+
description: 'Handles user events',
|
|
1802
|
+
callback: async () => { },
|
|
1803
|
+
});
|
|
1804
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1805
|
+
const event = new UserCreatedEvent({
|
|
1806
|
+
userId: '123',
|
|
1807
|
+
email: 'test@example.com',
|
|
1808
|
+
});
|
|
1809
|
+
for (let i = 0; i < 9; i++) {
|
|
1810
|
+
await fanoutWithReconnect.send(UserCreatedEvent, event);
|
|
1811
|
+
}
|
|
1812
|
+
expect(mostlyHealthyTransport.send).toHaveBeenCalledTimes(9);
|
|
1813
|
+
// Every 3rd flushed item fails, but never two in a row, so the
|
|
1814
|
+
// breaker (threshold 10) never trips and all 9 get attempted.
|
|
1815
|
+
phase = 'flushing';
|
|
1816
|
+
connectedCallback?.();
|
|
1817
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1818
|
+
expect(mostlyHealthyTransport.send).toHaveBeenCalledTimes(9 + 9);
|
|
1819
|
+
expect(onEnqueueSuccess).toHaveBeenCalledTimes(9 - 3);
|
|
1820
|
+
});
|
|
1821
|
+
it('should drop untried messages that no longer fit once maxRetryBufferSize is hit by concurrent buffering', async () => {
|
|
1822
|
+
let connectedCallback;
|
|
1823
|
+
let phase = 'buffering';
|
|
1824
|
+
let flushCallIndex = 0;
|
|
1825
|
+
let triggeredConcurrentBuffering = false;
|
|
1826
|
+
let fanoutWithReconnect;
|
|
1827
|
+
const onEnqueueError = mock(async () => { });
|
|
1828
|
+
const hooksInstance = new SafeHooks({ onEnqueueError });
|
|
1829
|
+
const subscriber = createSubscriber({
|
|
1830
|
+
name: 'handle-user',
|
|
1831
|
+
description: 'Handles user events',
|
|
1832
|
+
callback: async () => { },
|
|
1833
|
+
});
|
|
1834
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1835
|
+
const event = new UserCreatedEvent({
|
|
1836
|
+
userId: '123',
|
|
1837
|
+
email: 'test@example.com',
|
|
1838
|
+
});
|
|
1839
|
+
const flakyTransport = {
|
|
1840
|
+
...transport,
|
|
1841
|
+
send: mock(async () => {
|
|
1842
|
+
if (phase === 'buffering') {
|
|
1843
|
+
throw new Error('Initially down');
|
|
1844
|
+
}
|
|
1845
|
+
flushCallIndex++;
|
|
1846
|
+
// Simulate a concurrent send() buffering more items mid-flush.
|
|
1847
|
+
if (flushCallIndex === 5 && !triggeredConcurrentBuffering) {
|
|
1848
|
+
triggeredConcurrentBuffering = true;
|
|
1849
|
+
for (let i = 0; i < 4; i++) {
|
|
1850
|
+
await fanoutWithReconnect.send(UserCreatedEvent, event);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
throw new Error('Still down');
|
|
1854
|
+
}),
|
|
1855
|
+
onConnected: (cb) => {
|
|
1856
|
+
connectedCallback = cb;
|
|
1857
|
+
return () => { };
|
|
1858
|
+
},
|
|
1859
|
+
};
|
|
1860
|
+
fanoutWithReconnect = new FanoutEngine({
|
|
1861
|
+
transport: flakyTransport,
|
|
1862
|
+
schema,
|
|
1863
|
+
hooks: hooksInstance,
|
|
1864
|
+
topology: testTopology,
|
|
1865
|
+
defaultQueue: 'events',
|
|
1866
|
+
maxRetryBufferSize: 13,
|
|
1867
|
+
});
|
|
1868
|
+
// 11 sends buffered under the cap of 13 — nothing dropped yet.
|
|
1869
|
+
for (let i = 0; i < 11; i++) {
|
|
1870
|
+
await fanoutWithReconnect.send(UserCreatedEvent, event);
|
|
1871
|
+
}
|
|
1872
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(11);
|
|
1873
|
+
// Flush: 10 consecutive failures trip the breaker, leaving the 11th
|
|
1874
|
+
// item untried. The 4 concurrently-buffered items (injected mid-pass)
|
|
1875
|
+
// fill the buffer to its cap of 13 by the time the streak trips, so
|
|
1876
|
+
// both the 10th flush item (existing buffer-full path) and the
|
|
1877
|
+
// untried 11th item (rebufferUntried's buffer-full path) get dropped.
|
|
1878
|
+
phase = 'flushing';
|
|
1879
|
+
connectedCallback?.();
|
|
1880
|
+
await waitForFlush(fanoutWithReconnect);
|
|
1881
|
+
// 11 initial + 10 flush-loop attempts + 4 injected.
|
|
1882
|
+
expect(flakyTransport.send).toHaveBeenCalledTimes(11 + 10 + 4);
|
|
1883
|
+
expect(onEnqueueError).toHaveBeenCalledTimes(2);
|
|
1884
|
+
});
|
|
1885
|
+
it('should stop the periodic retry timer after dispose()', async () => {
|
|
1886
|
+
const subscriber = createSubscriber({
|
|
1887
|
+
name: 'handle-user',
|
|
1888
|
+
description: 'Handles user events',
|
|
1889
|
+
callback: async () => { },
|
|
1890
|
+
});
|
|
1891
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1892
|
+
const failingTransport = {
|
|
1893
|
+
...transport,
|
|
1894
|
+
send: mock(async () => {
|
|
1895
|
+
throw new Error('Connection lost');
|
|
1896
|
+
}),
|
|
1897
|
+
};
|
|
1898
|
+
const fanoutWithInterval = new FanoutEngine({
|
|
1899
|
+
transport: failingTransport,
|
|
1900
|
+
schema,
|
|
1901
|
+
hooks,
|
|
1902
|
+
topology: testTopology,
|
|
1903
|
+
defaultQueue: 'events',
|
|
1904
|
+
retryIntervalMs: 20,
|
|
1905
|
+
});
|
|
1906
|
+
const event = new UserCreatedEvent({
|
|
1907
|
+
userId: '123',
|
|
1908
|
+
email: 'test@example.com',
|
|
1909
|
+
});
|
|
1910
|
+
await fanoutWithInterval.send(UserCreatedEvent, event);
|
|
1911
|
+
expect(failingTransport.send).toHaveBeenCalledTimes(1);
|
|
1912
|
+
fanoutWithInterval.dispose();
|
|
1913
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1914
|
+
// No further flush attempts after dispose, despite the buffered message.
|
|
1915
|
+
expect(failingTransport.send).toHaveBeenCalledTimes(1);
|
|
1916
|
+
});
|
|
1917
|
+
it('should drop a message after exceeding maxRetryAttempts instead of retrying forever', async () => {
|
|
1918
|
+
const subscriber = createSubscriber({
|
|
1919
|
+
name: 'handle-user',
|
|
1920
|
+
description: 'Handles user events',
|
|
1921
|
+
callback: async () => { },
|
|
1922
|
+
});
|
|
1923
|
+
schema.register(UserCreatedEvent, [subscriber]);
|
|
1924
|
+
let connectedCallback;
|
|
1925
|
+
const failingTransport = {
|
|
1926
|
+
...transport,
|
|
1927
|
+
send: mock(async () => {
|
|
1928
|
+
throw new Error('Still down');
|
|
1929
|
+
}),
|
|
1930
|
+
onConnected: (cb) => {
|
|
1931
|
+
connectedCallback = cb;
|
|
1932
|
+
return () => { };
|
|
1933
|
+
},
|
|
1934
|
+
};
|
|
1935
|
+
const onEnqueueError = mock(async () => { });
|
|
1936
|
+
const hooksInstance = new SafeHooks({
|
|
1937
|
+
onEnqueueError,
|
|
1938
|
+
});
|
|
1939
|
+
const fanoutWithMaxAttempts = new FanoutEngine({
|
|
1940
|
+
transport: failingTransport,
|
|
1941
|
+
schema,
|
|
1942
|
+
hooks: hooksInstance,
|
|
1943
|
+
topology: testTopology,
|
|
1944
|
+
defaultQueue: 'events',
|
|
1945
|
+
maxRetryAttempts: 2,
|
|
1946
|
+
});
|
|
1947
|
+
const event = new UserCreatedEvent({
|
|
1948
|
+
userId: '123',
|
|
1949
|
+
email: 'test@example.com',
|
|
1950
|
+
});
|
|
1951
|
+
// Initial send fails and buffers (attempts: 0).
|
|
1952
|
+
await fanoutWithMaxAttempts.send(UserCreatedEvent, event);
|
|
1953
|
+
expect(onEnqueueError).not.toHaveBeenCalled();
|
|
1954
|
+
// First flush fails: attempts becomes 1, still under the cap, re-buffered.
|
|
1955
|
+
connectedCallback?.();
|
|
1956
|
+
await waitForFlush(fanoutWithMaxAttempts);
|
|
1957
|
+
expect(onEnqueueError).not.toHaveBeenCalled();
|
|
1958
|
+
// Second flush fails: attempts becomes 2, meets the cap, dropped for good.
|
|
1959
|
+
connectedCallback?.();
|
|
1960
|
+
await waitForFlush(fanoutWithMaxAttempts);
|
|
1961
|
+
expect(onEnqueueError).toHaveBeenCalledTimes(1);
|
|
1962
|
+
// A further reconnect must not retry it again — it's gone.
|
|
1963
|
+
connectedCallback?.();
|
|
1964
|
+
await waitForFlush(fanoutWithMaxAttempts);
|
|
1965
|
+
expect(onEnqueueError).toHaveBeenCalledTimes(1);
|
|
1966
|
+
// 1 initial + 2 flush attempts = 3 total send() calls; no 4th.
|
|
1967
|
+
expect(failingTransport.send).toHaveBeenCalledTimes(3);
|
|
1968
|
+
});
|
|
1969
|
+
});
|
|
1072
1970
|
});
|