@mastra/redis-streams 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -53,6 +53,15 @@ function errorText(err) {
53
53
  if (err instanceof Error && "replies" in err && Array.isArray(err.replies)) parts.push(...err.replies.map((r) => String(r)));
54
54
  return parts.join("; ");
55
55
  }
56
+ /** Order two Redis stream ids (`<ms>-<seq>`). */
57
+ function compareStreamIds(a, b) {
58
+ const [aMs = "0", aSeq = "0"] = a.split("-");
59
+ const [bMs = "0", bSeq = "0"] = b.split("-");
60
+ const ms = BigInt(aMs) - BigInt(bMs);
61
+ if (ms !== 0n) return ms < 0n ? -1 : 1;
62
+ const seq = BigInt(aSeq) - BigInt(bSeq);
63
+ return seq === 0n ? 0 : seq < 0n ? -1 : 1;
64
+ }
56
65
  var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.PubSub {
57
66
  get supportedModes() {
58
67
  return ["pull"];
@@ -75,6 +84,8 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
75
84
  #inFlightTimeoutMs;
76
85
  #logger;
77
86
  #subscriptions = /* @__PURE__ */ new Map();
87
+ #fanoutReaders = /* @__PURE__ */ new Map();
88
+ #pendingFanoutReaders = /* @__PURE__ */ new Map();
78
89
  #pendingSubscribes = /* @__PURE__ */ new Map();
79
90
  #cbIds = /* @__PURE__ */ new WeakMap();
80
91
  #pendingPublishes = /* @__PURE__ */ new Set();
@@ -127,8 +138,9 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
127
138
  });
128
139
  }
129
140
  /**
130
- * A fresh, unconnected client with the writer's configuration. Each
131
- * subscription needs its own because XREADGROUP BLOCK holds the connection.
141
+ * A fresh, unconnected client with the writer's configuration. Each reader
142
+ * (one per fan-out topic, one per grouped subscription) needs its own
143
+ * because XREADGROUP BLOCK holds the connection.
132
144
  */
133
145
  #createReadClient() {
134
146
  return this.#writeClient.duplicate();
@@ -242,11 +254,89 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
242
254
  }
243
255
  localBucket.add(cb);
244
256
  await this.#ensureWriterConnected();
245
- const isGrouped = !!options?.group;
246
- const group = options?.group ?? `__fanout-${globalThis.crypto.randomUUID()}`;
257
+ const groupAnchor = options?.startFrom === "latest" ? "$" : "0";
258
+ if (options?.group) {
259
+ const sub = await this.#openSubscription(topic, cb, options.group, groupAnchor);
260
+ this.#subscriptions.set(key, sub);
261
+ sub.loop = this.#runReadLoop(sub);
262
+ this.#startReclaimLoop(sub);
263
+ return;
264
+ }
265
+ for (;;) {
266
+ const reader = this.#fanoutReaders.get(topic);
267
+ if (reader) return this.#joinFanoutReader(reader, cb, key, groupAnchor);
268
+ const opening = this.#pendingFanoutReaders.get(topic);
269
+ if (!opening) break;
270
+ await opening.catch(() => {});
271
+ }
272
+ const opening = this.#openSubscription(topic, cb, void 0, groupAnchor);
273
+ this.#pendingFanoutReaders.set(topic, opening);
274
+ try {
275
+ const sub = await opening;
276
+ this.#fanoutReaders.set(topic, sub);
277
+ this.#subscriptions.set(key, sub);
278
+ sub.loop = this.#runReadLoop(sub);
279
+ this.#startReclaimLoop(sub);
280
+ } finally {
281
+ this.#pendingFanoutReaders.delete(topic);
282
+ }
283
+ }
284
+ /**
285
+ * Add a callback to a topic's open fan-out reader. The reader may already
286
+ * have delivered entries this subscriber should see (`startFrom: 'earliest'`)
287
+ * or still be catching up on entries it should not (`startFrom: 'latest'`),
288
+ * so the new member buffers live entries while its starting point is
289
+ * resolved, replays what it missed, then switches to live delivery.
290
+ */
291
+ async #joinFanoutReader(sub, cb, key, anchor) {
292
+ const member = { joining: [] };
293
+ sub.members.set(cb, member);
294
+ this.#subscriptions.set(key, sub);
295
+ const active = () => sub.members.get(cb) === member && !sub.stopped;
296
+ try {
297
+ if (anchor === "$") {
298
+ const [tail] = await this.#writeClient.xRevRange(sub.streamKey, "+", "-", { COUNT: 1 });
299
+ member.skipThrough = tail?.id;
300
+ } else {
301
+ let boundary;
302
+ try {
303
+ const info = (await this.#writeClient.xInfoGroups(sub.streamKey)).find((g) => String(g.name) === sub.group);
304
+ boundary = info ? String(info["last-delivered-id"]) : void 0;
305
+ } catch (err) {
306
+ if (!errorText(err).match(/no such key|NOGROUP/i)) throw err;
307
+ }
308
+ if (boundary && boundary !== "0-0") {
309
+ member.skipThrough = boundary;
310
+ let start = "-";
311
+ while (active()) {
312
+ const page = await this.#writeClient.xRange(sub.streamKey, start, boundary, { COUNT: TRIM_PAGE_SIZE });
313
+ for (const entry of page) {
314
+ if (!active()) break;
315
+ this.#deliverDetached(sub, cb, entry.message);
316
+ }
317
+ if (page.length < TRIM_PAGE_SIZE) break;
318
+ start = `(${page[page.length - 1].id}`;
319
+ }
320
+ }
321
+ }
322
+ } catch (err) {
323
+ sub.members.delete(cb);
324
+ this.#subscriptions.delete(key);
325
+ throw err;
326
+ }
327
+ if (!active()) return;
328
+ const buffered = member.joining ?? [];
329
+ for (const entry of buffered) {
330
+ if (member.skipThrough && compareStreamIds(entry.id, member.skipThrough) <= 0) continue;
331
+ this.#deliverDetached(sub, cb, entry.fields);
332
+ }
333
+ member.joining = void 0;
334
+ }
335
+ async #openSubscription(topic, cb, groupName, groupAnchor) {
336
+ const isGrouped = groupName !== void 0;
337
+ const group = groupName ?? `__fanout-${globalThis.crypto.randomUUID()}`;
247
338
  const consumer = `${group}-${globalThis.crypto.randomUUID()}`;
248
339
  const streamKey = this.#streamKey(topic);
249
- const groupAnchor = options?.startFrom === "latest" ? "$" : "0";
250
340
  try {
251
341
  if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xGroupCreate(streamKey, group, groupAnchor, { MKSTREAM: true }).pExpire(streamKey, this.#streamIdleTtlMs).exec();
252
342
  else await this.#writeClient.xGroupCreate(streamKey, group, groupAnchor, { MKSTREAM: true });
@@ -260,8 +350,9 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
260
350
  const readClient = this.#createReadClient();
261
351
  this.#attachErrorLogger(readClient, "read", { topic });
262
352
  await readClient.connect();
263
- const sub = {
264
- cb,
353
+ return {
354
+ members: /* @__PURE__ */ new Map([[cb, {}]]),
355
+ settling: /* @__PURE__ */ new Map(),
265
356
  topic,
266
357
  streamKey,
267
358
  group,
@@ -275,9 +366,6 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
275
366
  reclaimTimer: void 0,
276
367
  inFlight: /* @__PURE__ */ new Map()
277
368
  };
278
- this.#subscriptions.set(key, sub);
279
- sub.loop = this.#runReadLoop(sub);
280
- this.#startReclaimLoop(sub);
281
369
  }
282
370
  /**
283
371
  * Periodically reclaim idle pending entries in this subscription's group so that
@@ -366,6 +454,12 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
366
454
  const sub = this.#subscriptions.get(key);
367
455
  if (!sub) return;
368
456
  if (sub.teardown) return sub.teardown;
457
+ if (sub.members.size > 1 && sub.members.delete(cb)) {
458
+ this.#subscriptions.delete(key);
459
+ for (const settle of [...sub.settling.values()]) settle(cb);
460
+ return;
461
+ }
462
+ if (this.#fanoutReaders.get(topic) === sub) this.#fanoutReaders.delete(topic);
369
463
  sub.stopped = true;
370
464
  if (sub.reclaimTimer) {
371
465
  clearTimeout(sub.reclaimTimer);
@@ -432,6 +526,7 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
432
526
  for (const sub of this.#subscriptions.values()) if (sub.topic === topic) {
433
527
  sub.lastId = void 0;
434
528
  sub.groupAnchor = "0";
529
+ for (const member of sub.members.values()) member.skipThrough = void 0;
435
530
  }
436
531
  } catch (err) {
437
532
  this.#logger?.warn?.("redis-streams: clearTopic failed", {
@@ -597,8 +692,8 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
597
692
  if (this.#closed) return;
598
693
  this.#closed = true;
599
694
  if (this.#pendingSubscribes.size > 0) await Promise.allSettled([...this.#pendingSubscribes.values()]);
600
- const subs = [...this.#subscriptions.values()];
601
- await Promise.all(subs.map((sub) => this.unsubscribe(sub.topic, sub.cb)));
695
+ const subs = new Set(this.#subscriptions.values());
696
+ await Promise.all([...subs].flatMap((sub) => [...sub.members.keys()].map((cb) => this.unsubscribe(sub.topic, cb))));
602
697
  this.#localCallbacks.clear();
603
698
  await this.flush();
604
699
  if (this.#writeClient.isOpen) try {
@@ -653,15 +748,11 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
653
748
  }
654
749
  }
655
750
  async #deliverMessage(sub, streamId, fields) {
656
- let event;
657
- try {
658
- event = JSON.parse(fields.event ?? "{}");
659
- if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
660
- } catch (err) {
751
+ const event = this.#parseEvent(fields);
752
+ if (!event) {
661
753
  this.#logger?.debug?.("redis-streams: malformed payload, dropping", {
662
754
  topic: sub.topic,
663
- streamId,
664
- err: err instanceof Error ? err.message : err
755
+ streamId
665
756
  });
666
757
  try {
667
758
  await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
@@ -743,6 +834,7 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
743
834
  const expire = async () => {
744
835
  if (settled) return;
745
836
  settled = true;
837
+ sub.settling.delete(streamId);
746
838
  const attempt = event.deliveryAttempt ?? 1;
747
839
  const drop = attempt >= this.#maxDeliveryAttempts;
748
840
  const payload = drop ? "" : JSON.stringify({
@@ -768,7 +860,12 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
768
860
  err: err instanceof Error ? err.message : err
769
861
  });
770
862
  settled = false;
771
- sub.inFlight.delete(streamId);
863
+ sub.settling.set(streamId, (cb) => settleMember(cb, false));
864
+ if (sub.isGrouped) sub.inFlight.delete(streamId);
865
+ else sub.inFlight.set(streamId, {
866
+ since: Date.now(),
867
+ expire
868
+ });
772
869
  return;
773
870
  }
774
871
  sub.inFlight.delete(streamId);
@@ -801,19 +898,115 @@ var RedisStreamsPubSub = class RedisStreamsPubSub extends _mastra_core_events.Pu
801
898
  ]
802
899
  });
803
900
  };
901
+ const waiting = /* @__PURE__ */ new Set();
902
+ let anyNack = false;
903
+ const settleMember = async (cb, nacked) => {
904
+ if (settled || !waiting.delete(cb)) return;
905
+ if (nacked) anyNack = true;
906
+ if (waiting.size > 0) return;
907
+ sub.settling.delete(streamId);
908
+ await (anyNack ? nack() : ack());
909
+ };
910
+ const targets = [];
911
+ for (const [cb, member] of sub.members) {
912
+ if (member.joining) {
913
+ member.joining.push({
914
+ id: streamId,
915
+ fields
916
+ });
917
+ continue;
918
+ }
919
+ if (member.skipThrough) {
920
+ if (compareStreamIds(streamId, member.skipThrough) <= 0) continue;
921
+ member.skipThrough = void 0;
922
+ }
923
+ waiting.add(cb);
924
+ targets.push(cb);
925
+ }
926
+ if (targets.length === 0) {
927
+ await ack();
928
+ return;
929
+ }
804
930
  sub.inFlight.set(streamId, {
805
931
  since: Date.now(),
806
932
  expire
807
933
  });
934
+ sub.settling.set(streamId, (cb) => settleMember(cb, false));
935
+ for (const cb of targets) {
936
+ const memberEvent = targets.length === 1 ? event : this.#parseEvent(fields);
937
+ this.#invokeCallback(cb, memberEvent, () => settleMember(cb, false), () => settleMember(cb, true), extend);
938
+ }
939
+ }
940
+ /**
941
+ * Invoke a handler without awaiting it. EventCallback is typed `=> void`
942
+ * but handlers commonly return a promise (TS allows Promise<void> to satisfy
943
+ * void); a rejection or synchronous throw routes to nack instead of silently
944
+ * dropping the message. We do NOT await — serializing messages on a
945
+ * subscription would deadlock orchestration callbacks that await their own
946
+ * future events.
947
+ */
948
+ #invokeCallback(cb, event, ack, nack, extend) {
808
949
  try {
809
- const result = sub.cb(event, ack, nack, extend);
950
+ const result = cb(event, ack, nack, extend);
810
951
  if (result && typeof result.catch === "function") result.catch(async () => {
811
952
  await nack();
812
953
  });
813
954
  } catch {
814
- await nack();
955
+ nack();
815
956
  }
816
957
  }
958
+ #parseEvent(fields) {
959
+ try {
960
+ const event = JSON.parse(fields.event ?? "{}");
961
+ if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
962
+ return event;
963
+ } catch {
964
+ return;
965
+ }
966
+ }
967
+ /**
968
+ * Deliver an entry to one fan-out member outside the shared group's PEL:
969
+ * entries the topic's reader handed out before the member joined. Ack is a
970
+ * no-op (the group settles the entry on its own); nack republishes, which
971
+ * redelivers to every member just as a nack on the shared entry would.
972
+ */
973
+ #deliverDetached(sub, cb, fields) {
974
+ const event = this.#parseEvent(fields);
975
+ if (!event) return;
976
+ let settled = false;
977
+ const nack = async () => {
978
+ if (settled) return;
979
+ settled = true;
980
+ const attempt = event.deliveryAttempt ?? 1;
981
+ if (attempt >= this.#maxDeliveryAttempts) {
982
+ this.#logger?.warn?.("redis-streams: dropping event after max delivery attempts", {
983
+ topic: sub.topic,
984
+ eventType: event.type,
985
+ eventId: event.id,
986
+ attempt,
987
+ max: this.#maxDeliveryAttempts
988
+ });
989
+ return;
990
+ }
991
+ const payload = { event: JSON.stringify({
992
+ ...event,
993
+ deliveryAttempt: attempt + 1
994
+ }) };
995
+ try {
996
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xAdd(sub.streamKey, "*", payload).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
997
+ else await this.#writeClient.xAdd(sub.streamKey, "*", payload);
998
+ } catch (err) {
999
+ this.#logger?.warn?.("redis-streams: nack republish failed for replayed event", {
1000
+ topic: sub.topic,
1001
+ eventId: event.id,
1002
+ err: err instanceof Error ? err.message : err
1003
+ });
1004
+ }
1005
+ };
1006
+ this.#invokeCallback(cb, event, async () => {
1007
+ settled = true;
1008
+ }, nack);
1009
+ }
817
1010
  #deliverLocal(topic, event) {
818
1011
  const callbacks = this.#localCallbacks.get(topic);
819
1012
  if (!callbacks || callbacks.size === 0) return;