@mastra/valkey-streams 0.5.3 → 0.5.4

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
@@ -2,6 +2,12 @@ import { PubSub } from "@mastra/core/events";
2
2
  import { Batch, Decoder, GlideClient, InfBoundary } from "@valkey/valkey-glide";
3
3
  //#region src/client.ts
4
4
  const text = (value) => Buffer.isBuffer(value) ? value.toString() : String(value);
5
+ const countArgs = (options) => options.COUNT === void 0 ? [] : ["COUNT", String(options.COUNT)];
6
+ /** Decode an XRANGE/XREVRANGE reply (`[{ key: id, value: [[field, value], …] }]`). */
7
+ const records = (value) => (value ?? []).map((entry) => ({
8
+ id: text(entry.key),
9
+ message: Object.fromEntries((entry.value ?? []).map(([field, fieldValue]) => [text(field), text(fieldValue)]))
10
+ }));
5
11
  var ValkeyMulti = class {
6
12
  getClient;
7
13
  #batch = new Batch(true);
@@ -119,6 +125,32 @@ var ValkeyStreamsClient = class {
119
125
  message: Object.fromEntries(fields.map(([field, value]) => [text(field), text(value)]))
120
126
  }));
121
127
  }
128
+ async xRange(key, start, end, options = {}) {
129
+ return records(await this.command([
130
+ "XRANGE",
131
+ key,
132
+ start,
133
+ end,
134
+ ...countArgs(options)
135
+ ]));
136
+ }
137
+ async xRevRange(key, end, start, options = {}) {
138
+ return records(await this.command([
139
+ "XREVRANGE",
140
+ key,
141
+ end,
142
+ start,
143
+ ...countArgs(options)
144
+ ]));
145
+ }
146
+ /** XINFO GROUPS <key>, one object per group keyed by field name. */
147
+ async xInfoGroups(key) {
148
+ return (await this.command([
149
+ "XINFO",
150
+ "GROUPS",
151
+ key
152
+ ]) ?? []).map((group) => Object.fromEntries(group.map(({ key, value }) => [text(key), value])));
153
+ }
122
154
  async xAck(key, group, id) {
123
155
  return (await this.getClient()).xack(key, group, [id]);
124
156
  }
@@ -174,6 +206,8 @@ const createClient = (options) => new ValkeyStreamsClient(options);
174
206
  //#region src/index.ts
175
207
  /** Page size for the reclaim loop's XPENDING scan and the max entries claimed per tick. */
176
208
  const RECLAIM_PAGE_SIZE = 100;
209
+ /** Page size for replaying existing entries to a fan-out subscriber joining a shared reader. */
210
+ const REPLAY_PAGE_SIZE = 500;
177
211
  /**
178
212
  * Heartbeat: reset a pending entry's idle time only if this consumer still
179
213
  * owns it, so a late heartbeat never takes an entry back from a sibling.
@@ -222,6 +256,15 @@ function errorText(err) {
222
256
  if (err instanceof Error && "replies" in err && Array.isArray(err.replies)) parts.push(...err.replies.map((r) => String(r)));
223
257
  return parts.join("; ");
224
258
  }
259
+ /** Order two stream ids (`<ms>-<seq>`). */
260
+ function compareStreamIds(a, b) {
261
+ const [aMs = "0", aSeq = "0"] = a.split("-");
262
+ const [bMs = "0", bSeq = "0"] = b.split("-");
263
+ const ms = BigInt(aMs) - BigInt(bMs);
264
+ if (ms !== 0n) return ms < 0n ? -1 : 1;
265
+ const seq = BigInt(aSeq) - BigInt(bSeq);
266
+ return seq === 0n ? 0 : seq < 0n ? -1 : 1;
267
+ }
225
268
  var ValkeyStreamsPubSub = class extends PubSub {
226
269
  get supportedModes() {
227
270
  return ["pull"];
@@ -244,6 +287,8 @@ var ValkeyStreamsPubSub = class extends PubSub {
244
287
  #inFlightTimeoutMs;
245
288
  #logger;
246
289
  #subscriptions = /* @__PURE__ */ new Map();
290
+ #fanoutReaders = /* @__PURE__ */ new Map();
291
+ #pendingFanoutReaders = /* @__PURE__ */ new Map();
247
292
  #cbIds = /* @__PURE__ */ new WeakMap();
248
293
  #pendingPublishes = /* @__PURE__ */ new Set();
249
294
  #localCallbacks = /* @__PURE__ */ new Map();
@@ -373,11 +418,93 @@ var ValkeyStreamsPubSub = class extends PubSub {
373
418
  }
374
419
  localBucket.add(cb);
375
420
  await this.#ensureWriterConnected();
376
- const isGrouped = !!options?.group;
377
- const group = options?.group ?? `__fanout-${globalThis.crypto.randomUUID()}`;
421
+ const groupAnchor = options?.startFrom === "latest" ? "$" : "0";
422
+ if (options?.group) {
423
+ const sub = await this.#openSubscription(topic, cb, options.group, groupAnchor);
424
+ this.#subscriptions.set(key, sub);
425
+ sub.loop = this.#runReadLoop(sub);
426
+ this.#startReclaimLoop(sub);
427
+ return;
428
+ }
429
+ for (;;) {
430
+ const reader = this.#fanoutReaders.get(topic);
431
+ if (reader) return this.#joinFanoutReader(reader, cb, key, groupAnchor);
432
+ const opening = this.#pendingFanoutReaders.get(topic);
433
+ if (!opening) break;
434
+ await opening.catch(() => {});
435
+ }
436
+ const opening = this.#openSubscription(topic, cb, void 0, groupAnchor);
437
+ this.#pendingFanoutReaders.set(topic, opening);
438
+ try {
439
+ const sub = await opening;
440
+ this.#fanoutReaders.set(topic, sub);
441
+ this.#subscriptions.set(key, sub);
442
+ sub.loop = this.#runReadLoop(sub);
443
+ this.#startReclaimLoop(sub);
444
+ } finally {
445
+ this.#pendingFanoutReaders.delete(topic);
446
+ }
447
+ }
448
+ /**
449
+ * Add a callback to a topic's open fan-out reader. The reader may already
450
+ * have delivered entries this subscriber should see (`startFrom: 'earliest'`)
451
+ * or still be catching up on entries it should not (`startFrom: 'latest'`),
452
+ * so the new member buffers live entries while its starting point is
453
+ * resolved, replays what it missed, then switches to live delivery.
454
+ */
455
+ async #joinFanoutReader(sub, cb, key, anchor) {
456
+ if (sub.members.has(cb)) {
457
+ this.#subscriptions.set(key, sub);
458
+ return;
459
+ }
460
+ const member = { joining: [] };
461
+ sub.members.set(cb, member);
462
+ this.#subscriptions.set(key, sub);
463
+ const active = () => sub.members.get(cb) === member && !sub.stopped;
464
+ try {
465
+ if (anchor === "$") {
466
+ const [tail] = await this.#writeClient.xRevRange(sub.streamKey, "+", "-", { COUNT: 1 });
467
+ member.skipThrough = tail?.id;
468
+ } else {
469
+ let boundary;
470
+ try {
471
+ const info = (await this.#writeClient.xInfoGroups(sub.streamKey)).find((g) => String(g.name) === sub.group);
472
+ boundary = info ? String(info["last-delivered-id"]) : void 0;
473
+ } catch (err) {
474
+ if (!errorText(err).match(/no such key|NOGROUP/i)) throw err;
475
+ }
476
+ if (boundary && boundary !== "0-0") {
477
+ member.skipThrough = boundary;
478
+ let start = "-";
479
+ while (active()) {
480
+ const page = await this.#writeClient.xRange(sub.streamKey, start, boundary, { COUNT: REPLAY_PAGE_SIZE });
481
+ for (const entry of page) {
482
+ if (!active()) break;
483
+ this.#deliverDetached(sub, cb, entry.message);
484
+ }
485
+ if (page.length < REPLAY_PAGE_SIZE) break;
486
+ start = `(${page[page.length - 1].id}`;
487
+ }
488
+ }
489
+ }
490
+ } catch (err) {
491
+ sub.members.delete(cb);
492
+ this.#subscriptions.delete(key);
493
+ throw err;
494
+ }
495
+ if (!active()) return;
496
+ const buffered = member.joining ?? [];
497
+ for (const entry of buffered) {
498
+ if (member.skipThrough && compareStreamIds(entry.id, member.skipThrough) <= 0) continue;
499
+ this.#deliverDetached(sub, cb, entry.fields);
500
+ }
501
+ member.joining = void 0;
502
+ }
503
+ async #openSubscription(topic, cb, groupName, groupAnchor) {
504
+ const isGrouped = groupName !== void 0;
505
+ const group = groupName ?? `__fanout-${globalThis.crypto.randomUUID()}`;
378
506
  const consumer = `${group}-${globalThis.crypto.randomUUID()}`;
379
507
  const streamKey = this.#streamKey(topic);
380
- const groupAnchor = options?.startFrom === "latest" ? "$" : "0";
381
508
  try {
382
509
  if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xGroupCreate(streamKey, group, groupAnchor, { MKSTREAM: true }).pExpire(streamKey, this.#streamIdleTtlMs).exec();
383
510
  else await this.#writeClient.xGroupCreate(streamKey, group, groupAnchor, { MKSTREAM: true });
@@ -391,8 +518,9 @@ var ValkeyStreamsPubSub = class extends PubSub {
391
518
  const readClient = createClient(this.#connectOptions);
392
519
  this.#attachErrorLogger(readClient, "read", { topic });
393
520
  await readClient.connect();
394
- const sub = {
395
- cb,
521
+ return {
522
+ members: /* @__PURE__ */ new Map([[cb, {}]]),
523
+ settling: /* @__PURE__ */ new Map(),
396
524
  topic,
397
525
  streamKey,
398
526
  group,
@@ -406,9 +534,6 @@ var ValkeyStreamsPubSub = class extends PubSub {
406
534
  reclaimTimer: void 0,
407
535
  inFlight: /* @__PURE__ */ new Map()
408
536
  };
409
- this.#subscriptions.set(key, sub);
410
- sub.loop = this.#runReadLoop(sub);
411
- this.#startReclaimLoop(sub);
412
537
  }
413
538
  /**
414
539
  * Periodically reclaim idle pending entries in this subscription's group so
@@ -499,11 +624,21 @@ var ValkeyStreamsPubSub = class extends PubSub {
499
624
  const sub = this.#subscriptions.get(key);
500
625
  if (!sub) return;
501
626
  this.#subscriptions.delete(key);
627
+ if (sub.teardown) return sub.teardown;
628
+ if (sub.members.size > 1 && sub.members.delete(cb)) {
629
+ for (const settle of [...sub.settling.values()]) settle(cb);
630
+ return;
631
+ }
632
+ if (this.#fanoutReaders.get(topic) === sub) this.#fanoutReaders.delete(topic);
502
633
  sub.stopped = true;
503
634
  if (sub.reclaimTimer) {
504
635
  clearTimeout(sub.reclaimTimer);
505
636
  sub.reclaimTimer = void 0;
506
637
  }
638
+ sub.teardown = this.#stopSubscription(sub);
639
+ return sub.teardown;
640
+ }
641
+ async #stopSubscription(sub) {
507
642
  try {
508
643
  await sub.readClient.quit();
509
644
  } catch (err) {
@@ -559,6 +694,7 @@ var ValkeyStreamsPubSub = class extends PubSub {
559
694
  for (const sub of this.#subscriptions.values()) if (sub.topic === topic) {
560
695
  sub.lastId = void 0;
561
696
  sub.groupAnchor = "0";
697
+ for (const member of sub.members.values()) member.skipThrough = void 0;
562
698
  }
563
699
  } catch (err) {
564
700
  this.#logger?.warn?.("valkey-streams: clearTopic failed", {
@@ -685,8 +821,8 @@ var ValkeyStreamsPubSub = class extends PubSub {
685
821
  async close() {
686
822
  if (this.#closed) return;
687
823
  this.#closed = true;
688
- const subs = [...this.#subscriptions.values()];
689
- await Promise.all(subs.map((sub) => this.unsubscribe(sub.topic, sub.cb)));
824
+ const subs = new Set(this.#subscriptions.values());
825
+ await Promise.all([...subs].flatMap((sub) => [...sub.members.keys()].map((cb) => this.unsubscribe(sub.topic, cb))));
690
826
  this.#localCallbacks.clear();
691
827
  await this.flush();
692
828
  if (this.#writeClient.isOpen) try {
@@ -742,15 +878,11 @@ var ValkeyStreamsPubSub = class extends PubSub {
742
878
  }
743
879
  }
744
880
  async #deliverMessage(sub, streamId, fields) {
745
- let event;
746
- try {
747
- event = JSON.parse(fields.event ?? "{}");
748
- if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
749
- } catch (err) {
881
+ const event = this.#parseEvent(fields);
882
+ if (!event) {
750
883
  this.#logger?.debug?.("valkey-streams: malformed payload, dropping", {
751
884
  topic: sub.topic,
752
- streamId,
753
- err: err instanceof Error ? err.message : err
885
+ streamId
754
886
  });
755
887
  try {
756
888
  await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
@@ -832,6 +964,7 @@ var ValkeyStreamsPubSub = class extends PubSub {
832
964
  const expire = async () => {
833
965
  if (settled) return;
834
966
  settled = true;
967
+ sub.settling.delete(streamId);
835
968
  const attempt = event.deliveryAttempt ?? 1;
836
969
  const drop = attempt >= this.#maxDeliveryAttempts;
837
970
  const payload = drop ? "" : JSON.stringify({
@@ -857,7 +990,12 @@ var ValkeyStreamsPubSub = class extends PubSub {
857
990
  err: err instanceof Error ? err.message : err
858
991
  });
859
992
  settled = false;
860
- sub.inFlight.delete(streamId);
993
+ sub.settling.set(streamId, (cb) => settleMember(cb, false));
994
+ if (sub.isGrouped) sub.inFlight.delete(streamId);
995
+ else sub.inFlight.set(streamId, {
996
+ since: Date.now(),
997
+ expire
998
+ });
861
999
  return;
862
1000
  }
863
1001
  sub.inFlight.delete(streamId);
@@ -890,19 +1028,115 @@ var ValkeyStreamsPubSub = class extends PubSub {
890
1028
  ]
891
1029
  });
892
1030
  };
1031
+ const waiting = /* @__PURE__ */ new Set();
1032
+ let anyNack = false;
1033
+ const settleMember = async (cb, nacked) => {
1034
+ if (settled || !waiting.delete(cb)) return;
1035
+ if (nacked) anyNack = true;
1036
+ if (waiting.size > 0) return;
1037
+ sub.settling.delete(streamId);
1038
+ await (anyNack ? nack() : ack());
1039
+ };
1040
+ const targets = [];
1041
+ for (const [cb, member] of sub.members) {
1042
+ if (member.joining) {
1043
+ member.joining.push({
1044
+ id: streamId,
1045
+ fields
1046
+ });
1047
+ continue;
1048
+ }
1049
+ if (member.skipThrough) {
1050
+ if (compareStreamIds(streamId, member.skipThrough) <= 0) continue;
1051
+ member.skipThrough = void 0;
1052
+ }
1053
+ waiting.add(cb);
1054
+ targets.push(cb);
1055
+ }
1056
+ if (targets.length === 0) {
1057
+ await ack();
1058
+ return;
1059
+ }
893
1060
  sub.inFlight.set(streamId, {
894
1061
  since: Date.now(),
895
1062
  expire
896
1063
  });
1064
+ sub.settling.set(streamId, (cb) => settleMember(cb, false));
1065
+ for (const cb of targets) {
1066
+ const memberEvent = targets.length === 1 ? event : this.#parseEvent(fields);
1067
+ this.#invokeCallback(cb, memberEvent, () => settleMember(cb, false), () => settleMember(cb, true), extend);
1068
+ }
1069
+ }
1070
+ /**
1071
+ * Invoke a handler without awaiting it. EventCallback is typed `=> void`
1072
+ * but handlers commonly return a promise (TS allows Promise<void> to satisfy
1073
+ * void); a rejection or synchronous throw routes to nack instead of silently
1074
+ * dropping the message. We do NOT await — serializing messages on a
1075
+ * subscription would deadlock orchestration callbacks that await their own
1076
+ * future events.
1077
+ */
1078
+ #invokeCallback(cb, event, ack, nack, extend) {
897
1079
  try {
898
- const result = sub.cb(event, ack, nack, extend);
1080
+ const result = cb(event, ack, nack, extend);
899
1081
  if (result && typeof result.catch === "function") result.catch(async () => {
900
1082
  await nack();
901
1083
  });
902
1084
  } catch {
903
- await nack();
1085
+ nack();
904
1086
  }
905
1087
  }
1088
+ #parseEvent(fields) {
1089
+ try {
1090
+ const event = JSON.parse(fields.event ?? "{}");
1091
+ if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
1092
+ return event;
1093
+ } catch {
1094
+ return;
1095
+ }
1096
+ }
1097
+ /**
1098
+ * Deliver an entry to one fan-out member outside the shared group's PEL:
1099
+ * entries the topic's reader handed out before the member joined. Ack is a
1100
+ * no-op (the group settles the entry on its own); nack republishes, which
1101
+ * redelivers to every member just as a nack on the shared entry would.
1102
+ */
1103
+ #deliverDetached(sub, cb, fields) {
1104
+ const event = this.#parseEvent(fields);
1105
+ if (!event) return;
1106
+ let settled = false;
1107
+ const nack = async () => {
1108
+ if (settled) return;
1109
+ settled = true;
1110
+ const attempt = event.deliveryAttempt ?? 1;
1111
+ if (attempt >= this.#maxDeliveryAttempts) {
1112
+ this.#logger?.warn?.("valkey-streams: dropping event after max delivery attempts", {
1113
+ topic: sub.topic,
1114
+ eventType: event.type,
1115
+ eventId: event.id,
1116
+ attempt,
1117
+ max: this.#maxDeliveryAttempts
1118
+ });
1119
+ return;
1120
+ }
1121
+ const payload = { event: JSON.stringify({
1122
+ ...event,
1123
+ deliveryAttempt: attempt + 1
1124
+ }) };
1125
+ try {
1126
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xAdd(sub.streamKey, "*", payload).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
1127
+ else await this.#writeClient.xAdd(sub.streamKey, "*", payload);
1128
+ } catch (err) {
1129
+ this.#logger?.warn?.("valkey-streams: nack republish failed for replayed event", {
1130
+ topic: sub.topic,
1131
+ eventId: event.id,
1132
+ err: err instanceof Error ? err.message : err
1133
+ });
1134
+ }
1135
+ };
1136
+ this.#invokeCallback(cb, event, async () => {
1137
+ settled = true;
1138
+ }, nack);
1139
+ }
906
1140
  #deliverLocal(topic, event) {
907
1141
  const callbacks = this.#localCallbacks.get(topic);
908
1142
  if (!callbacks || callbacks.size === 0) return;