@mastra/redis-streams 0.6.1-alpha.0 → 0.6.2-alpha.0

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