@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.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
|
-
*
|
|
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
|
|
245
|
-
|
|
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
|
-
|
|
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 =
|
|
600
|
-
await Promise.all(subs.
|
|
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
|
-
|
|
656
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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;
|