@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.
package/dist/index.cjs CHANGED
@@ -202,6 +202,16 @@ var DelayedMessagesNotSupportedError = class extends MatadorError {
202
202
  transportName;
203
203
  description = "Delayed messages were requested but the transport does not support them. ACTION: For RabbitMQ, install the rabbitmq_delayed_message_exchange plugin. Run: rabbitmq-plugins enable rabbitmq_delayed_message_exchange Then restart RabbitMQ and reconnect. Alternatively, remove delayMs from your event options if delays are not required.";
204
204
  };
205
+ var LocalTransportNoActiveSubscriberError = class extends MatadorError {
206
+ constructor(queue) {
207
+ super(
208
+ `LocalTransport has no active subscriber for queue "${queue}"; message cannot be delivered.`
209
+ );
210
+ this.queue = queue;
211
+ }
212
+ queue;
213
+ description = "The LocalTransport has no active subscriber for this queue in the current process, so it cannot guarantee delivery. ACTION: If the subscriber is a SubscriberStub (implemented in another service), the local transport can never deliver it locally \u2014 a distributed transport like RabbitMQ is required. If the subscriber should run in this process, ensure matador.start() has subscribed to this queue before sending.";
214
+ };
205
215
  var EventNotRegisteredError = class extends MatadorError {
206
216
  constructor(eventKey) {
207
217
  super(
@@ -252,16 +262,6 @@ var SubscriberIsStubError = class extends MatadorError {
252
262
  subscriberName;
253
263
  description = "A SubscriberStub was registered in a consuming schema. ACTION: SubscriberStubs should only be used in producer schemas to declare that a subscriber exists in another service. In the consumer service, provide a full Subscriber with a callback function. Remove the stub from the consumer schema and add the actual implementation.";
254
264
  };
255
- var LocalTransportCannotProcessStubError = class extends MatadorError {
256
- constructor(subscriberName) {
257
- super(
258
- `LocalTransport cannot process stub subscriber "${subscriberName}". Stub subscribers require a distributed transport like RabbitMQ.`
259
- );
260
- this.subscriberName = subscriberName;
261
- }
262
- subscriberName;
263
- description = "The LocalTransport cannot process events for SubscriberStubs. ACTION: SubscriberStubs represent remote implementations that only RabbitMQ can route. If using LocalTransport for testing, provide mock implementations instead of stubs. For production fallback scenarios, be aware that stub-targeted events will be dropped.";
264
- };
265
265
  var UnknownQueueReferenceError = class extends MatadorError {
266
266
  constructor(queueName) {
267
267
  super(
@@ -801,6 +801,7 @@ function createSubscriberStub(input) {
801
801
  }
802
802
 
803
803
  // src/core/fanout.ts
804
+ var DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES = 10;
804
805
  var FanoutEngine = class {
805
806
  transport;
806
807
  schema;
@@ -808,9 +809,13 @@ var FanoutEngine = class {
808
809
  topology;
809
810
  defaultQueue;
810
811
  enqueuingCount = 0;
812
+ flushInFlightCount = 0;
811
813
  retryBuffer = [];
812
814
  maxRetryBufferSize;
815
+ maxRetryAttempts;
816
+ maxConsecutiveFlushFailures;
813
817
  disposeOnConnected;
818
+ retryTimer;
814
819
  constructor(config) {
815
820
  this.transport = config.transport;
816
821
  this.schema = config.schema;
@@ -818,18 +823,29 @@ var FanoutEngine = class {
818
823
  this.topology = config.topology;
819
824
  this.defaultQueue = config.defaultQueue;
820
825
  this.maxRetryBufferSize = config.maxRetryBufferSize ?? 5e3;
826
+ this.maxRetryAttempts = config.maxRetryAttempts;
827
+ const maxConsecutiveFlushFailures = config.maxConsecutiveFlushFailures ?? DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES;
828
+ this.maxConsecutiveFlushFailures = maxConsecutiveFlushFailures > 0 ? maxConsecutiveFlushFailures : Number.POSITIVE_INFINITY;
821
829
  this.disposeOnConnected = this.transport.onConnected?.(() => {
822
830
  void this.flushRetryBuffer();
823
831
  });
832
+ const retryIntervalMs = config.retryIntervalMs ?? 3e4;
833
+ if (retryIntervalMs > 0) {
834
+ this.retryTimer = setInterval(() => {
835
+ void this.flushRetryBuffer();
836
+ }, retryIntervalMs);
837
+ this.retryTimer.unref?.();
838
+ }
824
839
  }
825
840
  dispose() {
826
841
  this.disposeOnConnected?.();
842
+ clearInterval(this.retryTimer);
827
843
  }
828
844
  /**
829
845
  * Current count of events being enqueued.
830
846
  */
831
847
  get eventsBeingEnqueuedCount() {
832
- return this.enqueuingCount;
848
+ return this.enqueuingCount + this.flushInFlightCount;
833
849
  }
834
850
  /**
835
851
  * Sends an event to all registered subscribers.
@@ -883,10 +899,17 @@ var FanoutEngine = class {
883
899
  queue: qualifiedQueue,
884
900
  envelope,
885
901
  sendOptions,
886
- subscriberName: subscriber.name
902
+ subscriberName: subscriber.name,
903
+ attempts: 0
887
904
  });
888
905
  this.hooks.logger.warn(
889
- `[Matador] \u{1F7E1} Message for '${subscriber.name}' buffered for retry on reconnect (buffer: ${this.retryBuffer.length}/${this.maxRetryBufferSize}).`
906
+ `[Matador] \u{1F7E1} Message for '${subscriber.name}' buffered for retry on reconnect.`,
907
+ {
908
+ queue: qualifiedQueue,
909
+ subscriberName: subscriber.name,
910
+ bufferSize: this.retryBuffer.length,
911
+ maxBufferSize: this.maxRetryBufferSize
912
+ }
890
913
  );
891
914
  if (options.throwOnBufferedFailure) {
892
915
  const err = new TransportSendError(qualifiedQueue, cause);
@@ -930,6 +953,31 @@ var FanoutEngine = class {
930
953
  errors
931
954
  };
932
955
  }
956
+ /**
957
+ * Attempts a single buffered item during a flush pass
958
+ * @returns true if the attempt succeeded; false on failure
959
+ */
960
+ async attemptFlushItem(item) {
961
+ this.enqueuingCount++;
962
+ try {
963
+ const usedTransport = await this.transport.send(
964
+ item.queue,
965
+ item.envelope,
966
+ item.sendOptions
967
+ );
968
+ await this.hooks.onEnqueueSuccess({
969
+ envelope: item.envelope,
970
+ queue: item.queue,
971
+ transport: usedTransport
972
+ });
973
+ return true;
974
+ } catch (error) {
975
+ await this.handleFlushFailure(item, error);
976
+ return false;
977
+ } finally {
978
+ this.enqueuingCount--;
979
+ }
980
+ }
933
981
  /**
934
982
  * Flushes the retry buffer
935
983
  *
@@ -941,39 +989,36 @@ var FanoutEngine = class {
941
989
  `[Matador] \u23F3 Flushing ${this.retryBuffer.length} buffered message(s)...`
942
990
  );
943
991
  const toFlush = this.retryBuffer.splice(0);
944
- for (const item of toFlush) {
945
- this.enqueuingCount++;
946
- try {
947
- const usedTransport = await this.transport.send(
948
- item.queue,
949
- item.envelope,
950
- item.sendOptions
951
- );
952
- await this.hooks.onEnqueueSuccess({
953
- envelope: item.envelope,
954
- queue: item.queue,
955
- transport: usedTransport
956
- });
957
- } catch (error) {
958
- if (this.retryBuffer.length < this.maxRetryBufferSize) {
959
- this.retryBuffer.push(item);
960
- } else {
961
- const cause = error instanceof Error ? error : new Error(String(error));
962
- const err = new TransportSendError(item.queue, cause);
963
- await this.hooks.onEnqueueError({
964
- envelope: item.envelope,
965
- error: err,
966
- transport: this.transport.name
967
- });
992
+ this.flushInFlightCount += toFlush.length;
993
+ let consecutiveFailures = 0;
994
+ try {
995
+ for (let i = 0; i < toFlush.length; i++) {
996
+ const item = toFlush[i];
997
+ if (!item) {
998
+ this.flushInFlightCount--;
999
+ continue;
1000
+ }
1001
+ const succeeded = await this.attemptFlushItem(item);
1002
+ this.flushInFlightCount--;
1003
+ const randomMs = Math.random() * 10;
1004
+ await new Promise((resolve) => setTimeout(resolve, randomMs));
1005
+ consecutiveFailures = succeeded ? 0 : consecutiveFailures + 1;
1006
+ if (consecutiveFailures >= this.maxConsecutiveFlushFailures) {
1007
+ const untried = toFlush.slice(i + 1);
1008
+ this.flushInFlightCount -= untried.length;
1009
+ if (untried.length > 0) {
1010
+ await this.rebufferUntried(untried, consecutiveFailures);
1011
+ }
1012
+ break;
968
1013
  }
969
- } finally {
970
- this.enqueuingCount--;
971
1014
  }
1015
+ } finally {
1016
+ this.flushInFlightCount = 0;
972
1017
  }
973
1018
  const remaining = this.retryBuffer.length;
974
1019
  if (remaining > 0) {
975
1020
  this.hooks.logger.warn(
976
- `[Matador] \u{1F7E1} ${remaining} buffered message(s) could not be flushed; will retry on next reconnect.`
1021
+ `[Matador] \u{1F7E1} ${remaining} buffered message(s) could not be flushed; will retry later.`
977
1022
  );
978
1023
  } else {
979
1024
  this.hooks.logger.info(
@@ -981,6 +1026,67 @@ var FanoutEngine = class {
981
1026
  );
982
1027
  }
983
1028
  }
1029
+ /**
1030
+ * Re-buffers messages that were never attempted because a flush pass
1031
+ * stopped out early. Respects maxRetryBufferSize: concurrent sends can have
1032
+ * refilled the buffer while this flush was running, so anything beyond
1033
+ * remaining capacity is dropped and reported instead of silently growing
1034
+ * the buffer past its cap.
1035
+ */
1036
+ async rebufferUntried(untried, consecutiveFailures) {
1037
+ const capacity = Math.max(
1038
+ 0,
1039
+ this.maxRetryBufferSize - this.retryBuffer.length
1040
+ );
1041
+ const toRebuffer = untried.slice(0, capacity);
1042
+ const dropped = untried.slice(capacity);
1043
+ if (toRebuffer.length > 0) {
1044
+ this.retryBuffer.unshift(...toRebuffer);
1045
+ }
1046
+ const droppedSuffix = dropped.length > 0 ? `, ${dropped.length} dropped (buffer full).` : ".";
1047
+ this.hooks.logger.warn(
1048
+ `[Matador] \u{1F7E1} Stopping this flush pass after ${consecutiveFailures} consecutive failures; ${toRebuffer.length} untried message(s) re-buffered for the next attempt${droppedSuffix}`
1049
+ );
1050
+ for (const item of dropped) {
1051
+ this.hooks.logger.error(
1052
+ `[Matador] \u{1F534} Retry buffer full (${this.maxRetryBufferSize}). Message for '${item.subscriberName}' dropped and will not be retried.`
1053
+ );
1054
+ const err = new TransportSendError(
1055
+ item.queue,
1056
+ new Error("Retry buffer full")
1057
+ );
1058
+ await this.hooks.onEnqueueError({
1059
+ envelope: item.envelope,
1060
+ error: err,
1061
+ transport: this.transport.name
1062
+ });
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Handles a failed flush attempt for a single buffered item: drops it once
1067
+ * maxRetryAttempts is exceeded, otherwise re-buffers it (unless the buffer
1068
+ * is full, in which case it's dropped too).
1069
+ */
1070
+ async handleFlushFailure(item, error) {
1071
+ item.attempts++;
1072
+ const cause = error instanceof Error ? error : new Error(String(error));
1073
+ const exceededAttempts = this.maxRetryAttempts !== void 0 && item.attempts >= this.maxRetryAttempts;
1074
+ if (!exceededAttempts && this.retryBuffer.length < this.maxRetryBufferSize) {
1075
+ this.retryBuffer.push(item);
1076
+ return;
1077
+ }
1078
+ if (exceededAttempts) {
1079
+ this.hooks.logger.error(
1080
+ `[Matador] \u{1F534} Message for '${item.subscriberName}' exceeded max retry attempts (${this.maxRetryAttempts}) and will not be retried further.`
1081
+ );
1082
+ }
1083
+ const err = new TransportSendError(item.queue, cause);
1084
+ await this.hooks.onEnqueueError({
1085
+ envelope: item.envelope,
1086
+ error: err,
1087
+ transport: this.transport.name
1088
+ });
1089
+ }
984
1090
  async isSubscriberEnabled(subscriber) {
985
1091
  if (!subscriber.enabled) {
986
1092
  return true;
@@ -2273,7 +2379,11 @@ var Matador = class {
2273
2379
  schema: this.schema,
2274
2380
  hooks: this.hooks,
2275
2381
  topology: this.topology,
2276
- defaultQueue
2382
+ defaultQueue,
2383
+ maxRetryBufferSize: config.maxRetryBufferSize,
2384
+ maxRetryAttempts: config.maxRetryAttempts,
2385
+ retryIntervalMs: config.retryIntervalMs,
2386
+ maxConsecutiveFlushFailures: config.maxConsecutiveFlushFailures
2277
2387
  });
2278
2388
  this.shutdownManager = new ShutdownManager(
2279
2389
  () => this.fanout.eventsBeingEnqueuedCount,
@@ -2459,7 +2569,7 @@ var Matador = class {
2459
2569
  }
2460
2570
  async unsubscribeAll() {
2461
2571
  for (const subscription of this.subscriptions) {
2462
- await subscription.unsubscribe();
2572
+ await (subscription.pauseForShutdown ?? subscription.unsubscribe)();
2463
2573
  }
2464
2574
  this.subscriptions.length = 0;
2465
2575
  }
@@ -2621,6 +2731,7 @@ var localCapabilities = {
2621
2731
  ordering: "queue",
2622
2732
  priorities: false
2623
2733
  };
2734
+ var MAX_COMPLETED_MESSAGES = 1e3;
2624
2735
  var LocalTransport = class {
2625
2736
  name = "local";
2626
2737
  capabilities = localCapabilities;
@@ -2664,7 +2775,7 @@ var LocalTransport = class {
2664
2775
  topology.prefix
2665
2776
  );
2666
2777
  if (!this.queues.has(queueName)) {
2667
- this.queues.set(queueName, []);
2778
+ this.queues.set(queueName, /* @__PURE__ */ new Map());
2668
2779
  }
2669
2780
  }
2670
2781
  }
@@ -2696,6 +2807,18 @@ var LocalTransport = class {
2696
2807
  this.delayedTimers.add(timer);
2697
2808
  }
2698
2809
  async enqueue(queue, envelope) {
2810
+ const subs = this.subscriptions.get(queue);
2811
+ if (!subs || subs.length === 0) {
2812
+ throw new LocalTransportNoActiveSubscriberError(queue);
2813
+ }
2814
+ await this.storeAndDeliver(queue, envelope);
2815
+ }
2816
+ /**
2817
+ * Stores a message and delivers it to any active subscribers, without
2818
+ * requiring one to be present. Dead-letter queues are meant to hold
2819
+ * messages for later manual inspection, so they don't need a live consumer.
2820
+ */
2821
+ async storeAndDeliver(queue, envelope) {
2699
2822
  const messages = this.getOrCreateQueue(queue);
2700
2823
  const messageId = `${++this.messageIdCounter}`;
2701
2824
  const queuedMessage = {
@@ -2703,17 +2826,11 @@ var LocalTransport = class {
2703
2826
  id: messageId,
2704
2827
  completed: false
2705
2828
  };
2706
- messages.push(queuedMessage);
2829
+ messages.set(messageId, queuedMessage);
2707
2830
  await this.deliverToSubscribers(queue, queuedMessage);
2708
2831
  }
2709
2832
  async deliverToSubscribers(queue, message) {
2710
- const subs = this.subscriptions.get(queue);
2711
- if (!subs) {
2712
- this.logger.warn(
2713
- `[Matador][LocalTransport] \u{1F7E1} No subscriptions found for queue '${queue}', message will be lost.`
2714
- );
2715
- return;
2716
- }
2833
+ const subs = this.subscriptions.get(queue) ?? [];
2717
2834
  for (const sub of subs) {
2718
2835
  if (!sub.active || message.completed) continue;
2719
2836
  const receipt = {
@@ -2746,7 +2863,7 @@ var LocalTransport = class {
2746
2863
  const subs = this.subscriptions.get(queue) ?? [];
2747
2864
  subs.push(subscription);
2748
2865
  this.subscriptions.set(queue, subs);
2749
- const messages = this.queues.get(queue) ?? [];
2866
+ const messages = this.queues.get(queue)?.values() ?? [];
2750
2867
  for (const message of messages) {
2751
2868
  if (message.completed) continue;
2752
2869
  await this.deliverToSubscribers(queue, message);
@@ -2761,6 +2878,11 @@ var LocalTransport = class {
2761
2878
  this.subscriptions.delete(queue);
2762
2879
  }
2763
2880
  },
2881
+ // No-op: LocalTransport is only ever fed from within the process
2882
+ // So, when shutting down, it needs to continue and handle new messages until the end
2883
+ // As, an ongoing message (before shutdown) could create new (local) messages
2884
+ pauseForShutdown: async () => {
2885
+ },
2764
2886
  get isActive() {
2765
2887
  return subscription.active;
2766
2888
  }
@@ -2770,10 +2892,14 @@ var LocalTransport = class {
2770
2892
  const message = receipt.handle;
2771
2893
  message.completed = true;
2772
2894
  this.completedMessages.push(receipt);
2895
+ if (this.completedMessages.length > MAX_COMPLETED_MESSAGES) {
2896
+ this.completedMessages.shift();
2897
+ }
2898
+ this.queues.get(receipt.sourceQueue)?.delete(message.id);
2773
2899
  }
2774
2900
  async sendToDeadLetter(receipt, dlqName, envelope, _reason) {
2775
2901
  const dlqQueueName = `${receipt.sourceQueue}.${dlqName}`;
2776
- await this.enqueue(dlqQueueName, envelope);
2902
+ await this.storeAndDeliver(dlqQueueName, envelope);
2777
2903
  await this.complete(receipt);
2778
2904
  }
2779
2905
  // Test helpers
@@ -2781,9 +2907,7 @@ var LocalTransport = class {
2781
2907
  * Gets the current size of a queue.
2782
2908
  */
2783
2909
  getQueueSize(queue) {
2784
- const messages = this.queues.get(queue);
2785
- if (!messages) return 0;
2786
- return messages.filter((m) => !m.completed).length;
2910
+ return this.queues.get(queue)?.size ?? 0;
2787
2911
  }
2788
2912
  /**
2789
2913
  * Gets all completed message receipts.
@@ -2797,7 +2921,7 @@ var LocalTransport = class {
2797
2921
  getPendingMessages(queue) {
2798
2922
  const messages = this.queues.get(queue);
2799
2923
  if (!messages) return [];
2800
- return messages.filter((m) => !m.completed).map((m) => m.envelope);
2924
+ return Array.from(messages.values()).map((m) => m.envelope);
2801
2925
  }
2802
2926
  /**
2803
2927
  * Clears all state (for test isolation).
@@ -2819,7 +2943,7 @@ var LocalTransport = class {
2819
2943
  async receiveOne(queue) {
2820
2944
  const messages = this.queues.get(queue);
2821
2945
  if (!messages) return null;
2822
- const pending = messages.find((m) => !m.completed);
2946
+ const pending = messages.values().next().value;
2823
2947
  if (!pending) return null;
2824
2948
  const receipt = {
2825
2949
  handle: pending,
@@ -2834,7 +2958,7 @@ var LocalTransport = class {
2834
2958
  getOrCreateQueue(queue) {
2835
2959
  let messages = this.queues.get(queue);
2836
2960
  if (!messages) {
2837
- messages = [];
2961
+ messages = /* @__PURE__ */ new Map();
2838
2962
  this.queues.set(queue, messages);
2839
2963
  }
2840
2964
  return messages;
@@ -2977,6 +3101,11 @@ var MultiTransport = class {
2977
3101
  unsubscribe: async () => {
2978
3102
  await Promise.all(subscriptions.map((s) => s.unsubscribe()));
2979
3103
  },
3104
+ pauseForShutdown: async () => {
3105
+ await Promise.all(
3106
+ subscriptions.map((s) => (s.pauseForShutdown ?? s.unsubscribe)())
3107
+ );
3108
+ },
2980
3109
  get isActive() {
2981
3110
  return subscriptions.some((s) => s.isActive);
2982
3111
  }
@@ -3153,33 +3282,37 @@ var RabbitMQTransport = class {
3153
3282
  };
3154
3283
  this.subscriptionIntents.push(intent);
3155
3284
  await this.activateIntent(intent);
3156
- return {
3157
- unsubscribe: async () => {
3158
- intent.active = false;
3159
- const idx = this.subscriptionIntents.indexOf(intent);
3160
- if (idx !== -1) this.subscriptionIntents.splice(idx, 1);
3161
- const consumer = intent.currentConsumer;
3162
- if (consumer) {
3163
- consumer.active = false;
3164
- const queueChannel = this.queueChannels.get(queue);
3165
- if (queueChannel) {
3285
+ const unsubscribe = async () => {
3286
+ intent.active = false;
3287
+ const idx = this.subscriptionIntents.indexOf(intent);
3288
+ if (idx !== -1) this.subscriptionIntents.splice(idx, 1);
3289
+ const consumer = intent.currentConsumer;
3290
+ if (consumer) {
3291
+ consumer.active = false;
3292
+ const queueChannel = this.queueChannels.get(queue);
3293
+ if (queueChannel) {
3294
+ try {
3295
+ await queueChannel.channel.cancel(consumer.consumerTag);
3296
+ } catch {
3297
+ }
3298
+ const cIdx = queueChannel.consumers.indexOf(consumer);
3299
+ if (cIdx !== -1) queueChannel.consumers.splice(cIdx, 1);
3300
+ if (queueChannel.consumers.length === 0) {
3166
3301
  try {
3167
- await queueChannel.channel.cancel(consumer.consumerTag);
3302
+ await queueChannel.channel.close();
3168
3303
  } catch {
3169
3304
  }
3170
- const cIdx = queueChannel.consumers.indexOf(consumer);
3171
- if (cIdx !== -1) queueChannel.consumers.splice(cIdx, 1);
3172
- if (queueChannel.consumers.length === 0) {
3173
- try {
3174
- await queueChannel.channel.close();
3175
- } catch {
3176
- }
3177
- this.queueChannels.delete(queue);
3178
- }
3305
+ this.queueChannels.delete(queue);
3179
3306
  }
3180
- intent.currentConsumer = null;
3181
3307
  }
3182
- },
3308
+ intent.currentConsumer = null;
3309
+ }
3310
+ };
3311
+ return {
3312
+ unsubscribe,
3313
+ // Messages on this transport comes from outside (RabbitMQ), so, when shutting down, we need to unsubscribe directly
3314
+ // Otherwise, we could receive traffic forever, and an idle state would never be reached.
3315
+ pauseForShutdown: unsubscribe,
3183
3316
  get isActive() {
3184
3317
  return intent.active;
3185
3318
  }
@@ -3634,7 +3767,7 @@ exports.InvalidEventError = InvalidEventError;
3634
3767
  exports.InvalidSchemaError = InvalidSchemaError;
3635
3768
  exports.JsonCodec = JsonCodec;
3636
3769
  exports.LocalTransport = LocalTransport;
3637
- exports.LocalTransportCannotProcessStubError = LocalTransportCannotProcessStubError;
3770
+ exports.LocalTransportNoActiveSubscriberError = LocalTransportNoActiveSubscriberError;
3638
3771
  exports.Matador = Matador;
3639
3772
  exports.MatadorError = MatadorError;
3640
3773
  exports.MatadorEvent = MatadorEvent;