@zdavison/matador 4.0.2 → 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.
@@ -2,8 +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';
6
- import { MultiTransport } from '../transport/index.js';
5
+ import { TopologyBuilder, resolveTargetQueueName } from '../topology/index.js';
6
+ import { LocalTransport, MultiTransport } from '../transport/index.js';
7
7
  import { MatadorEvent, createSubscriber, createSubscriberStub, } from '../types/index.js';
8
8
  import { FanoutEngine } from './fanout.js';
9
9
  class UserCreatedEvent extends MatadorEvent {
@@ -35,6 +35,20 @@ class UserCreatedEventWithMetadata extends MatadorEvent {
35
35
  this.metadata = metadata;
36
36
  }
37
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
+ }
38
52
  const testTopology = TopologyBuilder.create()
39
53
  .withNamespace('test')
40
54
  .addQueue('events')
@@ -1125,6 +1139,50 @@ describe('FanoutEngine', () => {
1125
1139
  expect(result.errors).toHaveLength(0);
1126
1140
  expect(result.subscribersSent).toBe(0);
1127
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
+ });
1128
1186
  it('should flush buffered messages when onConnected fires', async () => {
1129
1187
  let connectedCallback;
1130
1188
  const failThenSucceed = mock(async () => {
@@ -1163,7 +1221,7 @@ describe('FanoutEngine', () => {
1163
1221
  expect(connectedCallback).toBeDefined();
1164
1222
  connectedCallback?.();
1165
1223
  // Give the async flush a chance to run
1166
- await new Promise((resolve) => setTimeout(resolve, 0));
1224
+ await waitForFlush(fanoutWithReconnect);
1167
1225
  expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1168
1226
  });
1169
1227
  it('should re-buffer on flush failure and retry on next reconnect', async () => {
@@ -1199,12 +1257,12 @@ describe('FanoutEngine', () => {
1199
1257
  expect(transportWithReconnect.send).toHaveBeenCalledTimes(1);
1200
1258
  // Simulate reconnect — flush fails, item should be re-buffered
1201
1259
  connectedCallback?.();
1202
- await new Promise((resolve) => setTimeout(resolve, 0));
1260
+ await waitForFlush(fanoutWithReconnect);
1203
1261
  // send was attempted again during the flush
1204
1262
  expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1205
1263
  // Simulate a second reconnect — should retry again
1206
1264
  connectedCallback?.();
1207
- await new Promise((resolve) => setTimeout(resolve, 0));
1265
+ await waitForFlush(fanoutWithReconnect);
1208
1266
  expect(transportWithReconnect.send).toHaveBeenCalledTimes(3);
1209
1267
  });
1210
1268
  it('should not buffer when transport works fine', async () => {
@@ -1238,7 +1296,7 @@ describe('FanoutEngine', () => {
1238
1296
  expect(result.errors).toHaveLength(0);
1239
1297
  // onConnected fires — nothing buffered so flush is a no-op
1240
1298
  connectedCallback?.();
1241
- await new Promise((resolve) => setTimeout(resolve, 0));
1299
+ await waitForFlush(fanoutWithReconnect);
1242
1300
  // Only one call total (the original send — not a spurious re-send)
1243
1301
  expect(successTransport.send).toHaveBeenCalledTimes(1);
1244
1302
  });
@@ -1309,7 +1367,7 @@ describe('FanoutEngine', () => {
1309
1367
  // But it was also buffered — it should retry on reconnect
1310
1368
  failingTransport.send.mockImplementation(async () => 'mock');
1311
1369
  connectedCallback?.();
1312
- await new Promise((resolve) => setTimeout(resolve, 0));
1370
+ await waitForFlush(fanoutWithReconnect);
1313
1371
  expect(failingTransport.send).toHaveBeenCalledTimes(2);
1314
1372
  });
1315
1373
  it('should unsubscribe the onConnected listener when dispose() is called', () => {
@@ -1440,5 +1498,473 @@ describe('FanoutEngine', () => {
1440
1498
  expect(localMock.send).toHaveBeenCalledTimes(1);
1441
1499
  expect(fanoutWithMulti.eventsBeingEnqueuedCount).toBe(0);
1442
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
+ });
1443
1969
  });
1444
1970
  });
@@ -33,6 +33,39 @@ export interface MatadorConfig {
33
33
  * won't persist across retries.
34
34
  */
35
35
  readonly checkpointStore?: CheckpointStore | undefined;
36
+ /**
37
+ * Maximum number of in-memory buffered sends held for retry when the
38
+ * transport is unavailable.
39
+ *
40
+ * @default 5000
41
+ */
42
+ readonly maxRetryBufferSize?: number | undefined;
43
+ /**
44
+ * Maximum number of retry attempts for a buffered message before it's
45
+ * dropped and reported via onEnqueueError, instead of being retried forever.
46
+ *
47
+ * @default undefined (no limit — retries until the buffer flushes successfully)
48
+ */
49
+ readonly maxRetryAttempts?: number | undefined;
50
+ /**
51
+ * How often (in ms) to attempt flushing the retry buffer, independent of
52
+ * transport reconnect events. Covers the case where the transport stays
53
+ * connected but an individual publish keeps failing.
54
+ *
55
+ * @default 30000. Pass 0 to disable the interval and rely on
56
+ * onConnected/manual flushes only.
57
+ */
58
+ readonly retryIntervalMs?: number | undefined;
59
+ /**
60
+ * Number of consecutive flush failures tolerated within a single flush
61
+ * pass before bailing out early and re-buffering the untried remainder,
62
+ * instead of retrying every buffered message against a broker that's
63
+ * rejecting everything.
64
+ *
65
+ * @default 10. Pass 0 (or a negative number) to disable the breaker and
66
+ * always attempt every buffered message on every flush pass.
67
+ */
68
+ readonly maxConsecutiveFlushFailures?: number | undefined;
36
69
  }
37
70
  /**
38
71
  * Matador - Transport-agnostic event processing library.
@@ -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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"}
@@ -66,6 +66,10 @@ export class Matador {
66
66
  hooks: this.hooks,
67
67
  topology: this.topology,
68
68
  defaultQueue,
69
+ maxRetryBufferSize: config.maxRetryBufferSize,
70
+ maxRetryAttempts: config.maxRetryAttempts,
71
+ retryIntervalMs: config.retryIntervalMs,
72
+ maxConsecutiveFlushFailures: config.maxConsecutiveFlushFailures,
69
73
  });
70
74
  // Create shutdown manager
71
75
  this.shutdownManager = new ShutdownManager(() => this.fanout.eventsBeingEnqueuedCount, () => this.unsubscribeAll(), () => this.transport.disconnect(), config.shutdownConfig);
@@ -247,7 +251,7 @@ export class Matador {
247
251
  }
248
252
  async unsubscribeAll() {
249
253
  for (const subscription of this.subscriptions) {
250
- await subscription.unsubscribe();
254
+ await (subscription.pauseForShutdown ?? subscription.unsubscribe)();
251
255
  }
252
256
  this.subscriptions.length = 0;
253
257
  }