@mastra/redis-streams 0.6.1 → 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.cjs +215 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +215 -22
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
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
|
-
*
|
|
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
|
|
246
|
-
|
|
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
|
-
|
|
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 =
|
|
601
|
-
await Promise.all(subs.
|
|
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
|
-
|
|
657
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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;
|