@mastra/redis-streams 0.3.0-alpha.0 → 0.3.1-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 CHANGED
@@ -1,623 +1,578 @@
1
- 'use strict';
2
-
3
- var crypto = require('crypto');
4
- var events = require('@mastra/core/events');
5
- var redis = require('redis');
6
-
7
- // src/index.ts
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let crypto = require("crypto");
3
+ let _mastra_core_events = require("@mastra/core/events");
4
+ let redis = require("redis");
5
+ //#region src/index.ts
6
+ /**
7
+ * Flatten an error into searchable text. node-redis MULTI failures throw a
8
+ * `MultiErrorReply` whose own message is just "N commands failed…" — the real
9
+ * per-command errors (e.g. BUSYGROUP) live in `err.replies`, so those are
10
+ * folded in too.
11
+ */
8
12
  function errorText(err) {
9
- const parts = [err instanceof Error ? err.message : String(err)];
10
- if (err instanceof Error && "replies" in err && Array.isArray(err.replies)) {
11
- parts.push(...err.replies.map((r) => String(r)));
12
- }
13
- return parts.join("; ");
13
+ const parts = [err instanceof Error ? err.message : String(err)];
14
+ if (err instanceof Error && "replies" in err && Array.isArray(err.replies)) parts.push(...err.replies.map((r) => String(r)));
15
+ return parts.join("; ");
14
16
  }
15
- var RedisStreamsPubSub = class extends events.PubSub {
16
- // Redis Streams is a pull transport: consumers issue XREADGROUP to read
17
- // events. Mastra reads this to know an OrchestrationWorker is required.
18
- get supportedModes() {
19
- return ["pull"];
20
- }
21
- #writeClient;
22
- #connectOptions;
23
- #keyPrefix;
24
- #blockMs;
25
- #maxStreamLength;
26
- #streamIdleTtlMs;
27
- #reclaimIntervalMs;
28
- #reclaimIdleMs;
29
- #maxDeliveryAttempts;
30
- #logger;
31
- // Keyed by `${topic}::${cbId}` so the same callback can be subscribed to
32
- // multiple topics independently. Without the topic in the key,
33
- // unsubscribe(otherTopic, cb) would tear down the wrong subscription.
34
- #subscriptions = /* @__PURE__ */ new Map();
35
- #cbIds = /* @__PURE__ */ new WeakMap();
36
- #pendingPublishes = /* @__PURE__ */ new Set();
37
- // `localOnly` publishes bypass Redis entirely so values carrying live
38
- // methods (e.g. `MastraModelOutput` returned from an evented agent run via
39
- // `workflows-finish`) survive intact. Mirrors the same contract honored by
40
- // `UnixSocketPubSub` so consumers like `Mastra.__registerInternalWorkflow`
41
- // get consistent semantics across transports.
42
- #localCallbacks = /* @__PURE__ */ new Map();
43
- #closed = false;
44
- constructor(options = {}) {
45
- super();
46
- const url = options.url ?? options.redisOptions?.url ?? "redis://localhost:6379";
47
- this.#connectOptions = { ...options.redisOptions, url };
48
- this.#writeClient = redis.createClient(this.#connectOptions);
49
- this.#logger = options.logger;
50
- this.#attachErrorLogger(this.#writeClient, "write");
51
- this.#keyPrefix = options.keyPrefix ?? "mastra:topic";
52
- this.#blockMs = options.blockMs ?? 1e3;
53
- this.#maxStreamLength = options.maxStreamLength ?? 1e4;
54
- const ttl = options.streamIdleTtlMs ?? 0;
55
- if (!Number.isInteger(ttl) || ttl < 0) {
56
- throw new Error(`redis-streams: streamIdleTtlMs must be a non-negative integer (milliseconds), got ${ttl}`);
57
- }
58
- this.#streamIdleTtlMs = ttl;
59
- this.#reclaimIntervalMs = options.reclaimIntervalMs ?? 3e4;
60
- this.#reclaimIdleMs = options.reclaimIdleMs ?? 6e4;
61
- const cap = options.maxDeliveryAttempts ?? 5;
62
- if (cap === 0) {
63
- options.logger?.warn?.(
64
- "redis-streams: maxDeliveryAttempts=0 is treated as Infinity for back-compat; pass Infinity to disable the cap explicitly."
65
- );
66
- this.#maxDeliveryAttempts = Infinity;
67
- } else if (cap < 0 || Number.isNaN(cap)) {
68
- throw new Error(`redis-streams: maxDeliveryAttempts must be >= 1 or Infinity, got ${cap}`);
69
- } else {
70
- this.#maxDeliveryAttempts = cap;
71
- }
72
- }
73
- /**
74
- * Attach the `'error'` listener node-redis requires on every client. Without
75
- * one, a mid-life socket close makes the client emit an unhandled `'error'`,
76
- * which (per EventEmitter semantics) throws from inside RedisSocket's error
77
- * handler BEFORE the built-in reconnect is scheduled — the process gets an
78
- * uncaughtException and the client is left permanently un-reconnected
79
- * (isOpen=true, isReady=false), hanging every subsequent command. Merely
80
- * having a listener lets node-redis's own reconnect run.
81
- *
82
- * Logged at warn so operators can see connection churn, throttled to one line
83
- * per 30s per client: node-redis re-emits `'error'` roughly every 500ms while
84
- * a server is unreachable, and with one reader client per subscription an
85
- * outage would otherwise flood the logs with thousands of identical lines.
86
- */
87
- #attachErrorLogger(client, role, extra = {}) {
88
- let lastWarnAt = 0;
89
- client.on("error", (err) => {
90
- const now = Date.now();
91
- if (now - lastWarnAt < 3e4) return;
92
- lastWarnAt = now;
93
- this.#logger?.warn?.(`redis-streams: ${role} client connection error (node-redis will reconnect)`, {
94
- ...extra,
95
- err: err instanceof Error ? err.message : err
96
- });
97
- });
98
- }
99
- #subKey(topic, cb) {
100
- let cbId = this.#cbIds.get(cb);
101
- if (!cbId) {
102
- cbId = crypto.randomUUID();
103
- this.#cbIds.set(cb, cbId);
104
- }
105
- return `${topic}::${cbId}`;
106
- }
107
- /** Lazily connect the shared writer client. Idempotent. */
108
- async #ensureWriterConnected() {
109
- if (this.#writeClient.isOpen) return;
110
- await this.#writeClient.connect();
111
- }
112
- #streamKey(topic) {
113
- return `${this.#keyPrefix}:${topic}`;
114
- }
115
- async publish(topic, event, options) {
116
- if (this.#closed) throw new Error("RedisStreamsPubSub: cannot publish on closed client");
117
- if (options?.localOnly) {
118
- const localEvent = {
119
- ...event,
120
- id: crypto.randomUUID(),
121
- createdAt: /* @__PURE__ */ new Date(),
122
- deliveryAttempt: event.deliveryAttempt ?? 1
123
- };
124
- this.#deliverLocal(topic, localEvent);
125
- return;
126
- }
127
- await this.#ensureWriterConnected();
128
- const id = crypto.randomUUID();
129
- const createdAt = /* @__PURE__ */ new Date();
130
- const payload = {
131
- ...event,
132
- id,
133
- createdAt,
134
- deliveryAttempt: event.deliveryAttempt ?? 1
135
- };
136
- const xaddOptions = {};
137
- if (this.#maxStreamLength > 0) {
138
- xaddOptions.TRIM = {
139
- strategy: "MAXLEN",
140
- strategyModifier: "~",
141
- threshold: this.#maxStreamLength
142
- };
143
- }
144
- const streamKey = this.#streamKey(topic);
145
- const promise = this.#streamIdleTtlMs > 0 ? this.#writeClient.multi().xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions).pExpire(streamKey, this.#streamIdleTtlMs).exec().then((replies) => {
146
- if (Number(replies[1]) !== 1) {
147
- this.#logger?.warn?.("redis-streams: PEXPIRE inside publish MULTI did not apply", {
148
- streamKey,
149
- reply: String(replies[1])
150
- });
151
- }
152
- }) : this.#writeClient.xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions);
153
- this.#pendingPublishes.add(promise);
154
- try {
155
- await promise;
156
- } finally {
157
- this.#pendingPublishes.delete(promise);
158
- }
159
- }
160
- async subscribe(topic, cb, options) {
161
- if (this.#closed) throw new Error("RedisStreamsPubSub: cannot subscribe on closed client");
162
- const key = this.#subKey(topic, cb);
163
- if (this.#subscriptions.has(key)) return;
164
- let localBucket = this.#localCallbacks.get(topic);
165
- if (!localBucket) {
166
- localBucket = /* @__PURE__ */ new Set();
167
- this.#localCallbacks.set(topic, localBucket);
168
- }
169
- localBucket.add(cb);
170
- await this.#ensureWriterConnected();
171
- const isGrouped = !!options?.group;
172
- const group = options?.group ?? `__fanout-${crypto.randomUUID()}`;
173
- const consumer = `${group}-${crypto.randomUUID()}`;
174
- const streamKey = this.#streamKey(topic);
175
- try {
176
- await this.#writeClient.xGroupCreate(streamKey, group, "0", { MKSTREAM: true });
177
- } catch (err) {
178
- const msg = err instanceof Error ? err.message : String(err);
179
- if (!msg.includes("BUSYGROUP")) throw err;
180
- this.#logger?.debug?.("redis-streams: consumer group already exists", { topic, group });
181
- }
182
- const readClient = redis.createClient(this.#connectOptions);
183
- this.#attachErrorLogger(readClient, "read", { topic });
184
- await readClient.connect();
185
- const sub = {
186
- cb,
187
- topic,
188
- streamKey,
189
- group,
190
- consumer,
191
- isGrouped,
192
- readClient,
193
- stopped: false,
194
- loop: void 0,
195
- reclaimTimer: void 0
196
- };
197
- this.#subscriptions.set(key, sub);
198
- sub.loop = this.#runReadLoop(sub);
199
- this.#startReclaimLoop(sub);
200
- }
201
- /**
202
- * Periodically run XAUTOCLAIM against this subscription's group so that
203
- * messages a crashed/stuck consumer read but never acked get redelivered to
204
- * a live sibling. Runs only for grouped subscriptions — fan-out groups are
205
- * private to one consumer, so there's no sibling to claim from.
206
- */
207
- #startReclaimLoop(sub) {
208
- if (this.#reclaimIntervalMs <= 0) return;
209
- if (!sub.isGrouped) return;
210
- const tick = async () => {
211
- if (sub.stopped || this.#closed) return;
212
- try {
213
- const reply = await this.#writeClient.xAutoClaim(
214
- sub.streamKey,
215
- sub.group,
216
- sub.consumer,
217
- this.#reclaimIdleMs,
218
- "0-0",
219
- { COUNT: 100 }
220
- );
221
- const messages = reply?.messages ?? [];
222
- for (const entry of messages) {
223
- if (sub.stopped || this.#closed) return;
224
- if (!entry) continue;
225
- await this.#deliverMessage(sub, entry.id, entry.message);
226
- }
227
- } catch (err) {
228
- this.#logger?.debug?.("redis-streams: XAUTOCLAIM failed", {
229
- topic: sub.topic,
230
- group: sub.group,
231
- err: err instanceof Error ? err.message : err
232
- });
233
- }
234
- if (sub.stopped || this.#closed) return;
235
- sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
236
- };
237
- sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
238
- }
239
- async unsubscribe(topic, cb) {
240
- const localBucket = this.#localCallbacks.get(topic);
241
- if (localBucket) {
242
- localBucket.delete(cb);
243
- if (localBucket.size === 0) this.#localCallbacks.delete(topic);
244
- }
245
- const key = this.#subKey(topic, cb);
246
- const sub = this.#subscriptions.get(key);
247
- if (!sub) return;
248
- this.#subscriptions.delete(key);
249
- sub.stopped = true;
250
- if (sub.reclaimTimer) {
251
- clearTimeout(sub.reclaimTimer);
252
- sub.reclaimTimer = void 0;
253
- }
254
- try {
255
- await sub.readClient.quit();
256
- } catch (err) {
257
- this.#logger?.debug?.("redis-streams: reader quit failed", {
258
- topic: sub.topic,
259
- err: err instanceof Error ? err.message : err
260
- });
261
- }
262
- if (sub.loop) {
263
- try {
264
- await sub.loop;
265
- } catch (err) {
266
- this.#logger?.debug?.("redis-streams: read loop exited with error", {
267
- topic: sub.topic,
268
- err: err instanceof Error ? err.message : err
269
- });
270
- }
271
- }
272
- if (!sub.isGrouped) {
273
- try {
274
- await this.#writeClient.xGroupDestroy(sub.streamKey, sub.group);
275
- } catch (err) {
276
- this.#logger?.debug?.("redis-streams: xGroupDestroy failed", {
277
- topic: sub.topic,
278
- group: sub.group,
279
- err: err instanceof Error ? err.message : err
280
- });
281
- }
282
- }
283
- }
284
- async flush() {
285
- if (this.#pendingPublishes.size > 0) {
286
- await Promise.allSettled([...this.#pendingPublishes]);
287
- }
288
- }
289
- /**
290
- * Delete a topic's stream, and with it every consumer group on that stream.
291
- * This is the Redis implementation of the optional `clearTopic` hook that
292
- * consumers (e.g. `DurableAgent`) call to free a topic once its lifecycle is
293
- * over — without it, a persistent backend accumulates every finished run's
294
- * stream until Redis exhausts its memory.
295
- *
296
- * Because it removes events for ALL consumers and destroys their groups, only
297
- * call it once nothing will read the topic again. A subscriber still attached
298
- * when the stream is deleted recovers on its own (the read loop recreates the
299
- * group on NOGROUP) but will have missed the deleted entries.
300
- *
301
- * Best-effort and non-throwing: callers commonly invoke this fire-and-forget
302
- * (`void clearTopic(...)`), so a rejection would surface as an
303
- * unhandledRejection. Failures are swallowed and logged; the `streamIdleTtlMs`
304
- * backstop (when configured) reclaims anything a failed delete leaves behind.
305
- */
306
- async clearTopic(topic) {
307
- if (this.#closed) return;
308
- try {
309
- await this.#ensureWriterConnected();
310
- await this.#writeClient.del(this.#streamKey(topic));
311
- } catch (err) {
312
- this.#logger?.warn?.("redis-streams: clearTopic failed", {
313
- topic,
314
- err: err instanceof Error ? err.message : err
315
- });
316
- }
317
- }
318
- /**
319
- * Lease key used in Redis. Distinct prefix from streams so leases and
320
- * streams can't collide on key namespace.
321
- */
322
- #leaseKey(key) {
323
- return `${this.#keyPrefix}:lease:${key}`;
324
- }
325
- /**
326
- * Atomic claim via SET NX PX. Idempotent for the same owner: if the
327
- * current value is already this owner, we refresh the TTL instead of
328
- * failing. Cross-process callers race here; Redis serializes them.
329
- */
330
- async acquireLease(key, owner, ttlMs) {
331
- if (this.#closed) return { acquired: false };
332
- await this.#ensureWriterConnected();
333
- const redisKey = this.#leaseKey(key);
334
- const result = await this.#writeClient.set(redisKey, owner, { NX: true, PX: ttlMs });
335
- if (result === "OK") return { acquired: true, owner };
336
- const script = `
17
+ var RedisStreamsPubSub = class extends _mastra_core_events.PubSub {
18
+ get supportedModes() {
19
+ return ["pull"];
20
+ }
21
+ #writeClient;
22
+ #connectOptions;
23
+ #keyPrefix;
24
+ #blockMs;
25
+ #maxStreamLength;
26
+ #streamIdleTtlMs;
27
+ #reclaimIntervalMs;
28
+ #reclaimIdleMs;
29
+ #maxDeliveryAttempts;
30
+ #logger;
31
+ #subscriptions = /* @__PURE__ */ new Map();
32
+ #cbIds = /* @__PURE__ */ new WeakMap();
33
+ #pendingPublishes = /* @__PURE__ */ new Set();
34
+ #localCallbacks = /* @__PURE__ */ new Map();
35
+ #closed = false;
36
+ constructor(options = {}) {
37
+ super();
38
+ const url = options.url ?? options.redisOptions?.url ?? "redis://localhost:6379";
39
+ this.#connectOptions = {
40
+ ...options.redisOptions,
41
+ url
42
+ };
43
+ this.#writeClient = (0, redis.createClient)(this.#connectOptions);
44
+ this.#logger = options.logger;
45
+ this.#attachErrorLogger(this.#writeClient, "write");
46
+ this.#keyPrefix = options.keyPrefix ?? "mastra:topic";
47
+ this.#blockMs = options.blockMs ?? 1e3;
48
+ this.#maxStreamLength = options.maxStreamLength ?? 1e4;
49
+ const ttl = options.streamIdleTtlMs ?? 0;
50
+ if (!Number.isInteger(ttl) || ttl < 0) throw new Error(`redis-streams: streamIdleTtlMs must be a non-negative integer (milliseconds), got ${ttl}`);
51
+ this.#streamIdleTtlMs = ttl;
52
+ this.#reclaimIntervalMs = options.reclaimIntervalMs ?? 3e4;
53
+ this.#reclaimIdleMs = options.reclaimIdleMs ?? 6e4;
54
+ const cap = options.maxDeliveryAttempts ?? 5;
55
+ if (cap === 0) {
56
+ options.logger?.warn?.("redis-streams: maxDeliveryAttempts=0 is treated as Infinity for back-compat; pass Infinity to disable the cap explicitly.");
57
+ this.#maxDeliveryAttempts = Infinity;
58
+ } else if (cap < 0 || Number.isNaN(cap)) throw new Error(`redis-streams: maxDeliveryAttempts must be >= 1 or Infinity, got ${cap}`);
59
+ else this.#maxDeliveryAttempts = cap;
60
+ }
61
+ /**
62
+ * Attach the `'error'` listener node-redis requires on every client. Without
63
+ * one, a mid-life socket close makes the client emit an unhandled `'error'`,
64
+ * which (per EventEmitter semantics) throws from inside RedisSocket's error
65
+ * handler BEFORE the built-in reconnect is scheduled — the process gets an
66
+ * uncaughtException and the client is left permanently un-reconnected
67
+ * (isOpen=true, isReady=false), hanging every subsequent command. Merely
68
+ * having a listener lets node-redis's own reconnect run.
69
+ *
70
+ * Logged at warn so operators can see connection churn, throttled to one line
71
+ * per 30s per client: node-redis re-emits `'error'` roughly every 500ms while
72
+ * a server is unreachable, and with one reader client per subscription an
73
+ * outage would otherwise flood the logs with thousands of identical lines.
74
+ */
75
+ #attachErrorLogger(client, role, extra = {}) {
76
+ let lastWarnAt = 0;
77
+ client.on("error", (err) => {
78
+ const now = Date.now();
79
+ if (now - lastWarnAt < 3e4) return;
80
+ lastWarnAt = now;
81
+ this.#logger?.warn?.(`redis-streams: ${role} client connection error (node-redis will reconnect)`, {
82
+ ...extra,
83
+ err: err instanceof Error ? err.message : err
84
+ });
85
+ });
86
+ }
87
+ #subKey(topic, cb) {
88
+ let cbId = this.#cbIds.get(cb);
89
+ if (!cbId) {
90
+ cbId = (0, crypto.randomUUID)();
91
+ this.#cbIds.set(cb, cbId);
92
+ }
93
+ return `${topic}::${cbId}`;
94
+ }
95
+ /** Lazily connect the shared writer client. Idempotent. */
96
+ async #ensureWriterConnected() {
97
+ if (this.#writeClient.isOpen) return;
98
+ await this.#writeClient.connect();
99
+ }
100
+ #streamKey(topic) {
101
+ return `${this.#keyPrefix}:${topic}`;
102
+ }
103
+ async publish(topic, event, options) {
104
+ if (this.#closed) throw new Error("RedisStreamsPubSub: cannot publish on closed client");
105
+ if (options?.localOnly) {
106
+ const localEvent = {
107
+ ...event,
108
+ id: (0, crypto.randomUUID)(),
109
+ createdAt: /* @__PURE__ */ new Date(),
110
+ deliveryAttempt: event.deliveryAttempt ?? 1
111
+ };
112
+ this.#deliverLocal(topic, localEvent);
113
+ return;
114
+ }
115
+ await this.#ensureWriterConnected();
116
+ const id = (0, crypto.randomUUID)();
117
+ const createdAt = /* @__PURE__ */ new Date();
118
+ const payload = {
119
+ ...event,
120
+ id,
121
+ createdAt,
122
+ deliveryAttempt: event.deliveryAttempt ?? 1
123
+ };
124
+ const xaddOptions = {};
125
+ if (this.#maxStreamLength > 0) xaddOptions.TRIM = {
126
+ strategy: "MAXLEN",
127
+ strategyModifier: "~",
128
+ threshold: this.#maxStreamLength
129
+ };
130
+ const streamKey = this.#streamKey(topic);
131
+ const promise = this.#streamIdleTtlMs > 0 ? this.#writeClient.multi().xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions).pExpire(streamKey, this.#streamIdleTtlMs).exec().then((replies) => {
132
+ if (Number(replies[1]) !== 1) this.#logger?.warn?.("redis-streams: PEXPIRE inside publish MULTI did not apply", {
133
+ streamKey,
134
+ reply: String(replies[1])
135
+ });
136
+ }) : this.#writeClient.xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions);
137
+ this.#pendingPublishes.add(promise);
138
+ try {
139
+ await promise;
140
+ } finally {
141
+ this.#pendingPublishes.delete(promise);
142
+ }
143
+ }
144
+ async subscribe(topic, cb, options) {
145
+ if (this.#closed) throw new Error("RedisStreamsPubSub: cannot subscribe on closed client");
146
+ const key = this.#subKey(topic, cb);
147
+ if (this.#subscriptions.has(key)) return;
148
+ let localBucket = this.#localCallbacks.get(topic);
149
+ if (!localBucket) {
150
+ localBucket = /* @__PURE__ */ new Set();
151
+ this.#localCallbacks.set(topic, localBucket);
152
+ }
153
+ localBucket.add(cb);
154
+ await this.#ensureWriterConnected();
155
+ const isGrouped = !!options?.group;
156
+ const group = options?.group ?? `__fanout-${(0, crypto.randomUUID)()}`;
157
+ const consumer = `${group}-${(0, crypto.randomUUID)()}`;
158
+ const streamKey = this.#streamKey(topic);
159
+ try {
160
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xGroupCreate(streamKey, group, "0", { MKSTREAM: true }).pExpire(streamKey, this.#streamIdleTtlMs).exec();
161
+ else await this.#writeClient.xGroupCreate(streamKey, group, "0", { MKSTREAM: true });
162
+ } catch (err) {
163
+ if (!errorText(err).includes("BUSYGROUP")) throw err;
164
+ this.#logger?.debug?.("redis-streams: consumer group already exists", {
165
+ topic,
166
+ group
167
+ });
168
+ }
169
+ const readClient = (0, redis.createClient)(this.#connectOptions);
170
+ this.#attachErrorLogger(readClient, "read", { topic });
171
+ await readClient.connect();
172
+ const sub = {
173
+ cb,
174
+ topic,
175
+ streamKey,
176
+ group,
177
+ consumer,
178
+ isGrouped,
179
+ readClient,
180
+ stopped: false,
181
+ loop: void 0,
182
+ reclaimTimer: void 0
183
+ };
184
+ this.#subscriptions.set(key, sub);
185
+ sub.loop = this.#runReadLoop(sub);
186
+ this.#startReclaimLoop(sub);
187
+ }
188
+ /**
189
+ * Periodically run XAUTOCLAIM against this subscription's group so that
190
+ * messages a crashed/stuck consumer read but never acked get redelivered to
191
+ * a live sibling. Runs only for grouped subscriptions — fan-out groups are
192
+ * private to one consumer, so there's no sibling to claim from.
193
+ */
194
+ #startReclaimLoop(sub) {
195
+ if (this.#reclaimIntervalMs <= 0) return;
196
+ if (!sub.isGrouped) return;
197
+ const tick = async () => {
198
+ if (sub.stopped || this.#closed) return;
199
+ try {
200
+ const messages = (await this.#writeClient.xAutoClaim(sub.streamKey, sub.group, sub.consumer, this.#reclaimIdleMs, "0-0", { COUNT: 100 }))?.messages ?? [];
201
+ for (const entry of messages) {
202
+ if (sub.stopped || this.#closed) return;
203
+ if (!entry) continue;
204
+ await this.#deliverMessage(sub, entry.id, entry.message);
205
+ }
206
+ } catch (err) {
207
+ this.#logger?.debug?.("redis-streams: XAUTOCLAIM failed", {
208
+ topic: sub.topic,
209
+ group: sub.group,
210
+ err: err instanceof Error ? err.message : err
211
+ });
212
+ }
213
+ if (sub.stopped || this.#closed) return;
214
+ sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
215
+ };
216
+ sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
217
+ }
218
+ async unsubscribe(topic, cb) {
219
+ const localBucket = this.#localCallbacks.get(topic);
220
+ if (localBucket) {
221
+ localBucket.delete(cb);
222
+ if (localBucket.size === 0) this.#localCallbacks.delete(topic);
223
+ }
224
+ const key = this.#subKey(topic, cb);
225
+ const sub = this.#subscriptions.get(key);
226
+ if (!sub) return;
227
+ this.#subscriptions.delete(key);
228
+ sub.stopped = true;
229
+ if (sub.reclaimTimer) {
230
+ clearTimeout(sub.reclaimTimer);
231
+ sub.reclaimTimer = void 0;
232
+ }
233
+ try {
234
+ await sub.readClient.quit();
235
+ } catch (err) {
236
+ this.#logger?.debug?.("redis-streams: reader quit failed", {
237
+ topic: sub.topic,
238
+ err: err instanceof Error ? err.message : err
239
+ });
240
+ }
241
+ if (sub.loop) try {
242
+ await sub.loop;
243
+ } catch (err) {
244
+ this.#logger?.debug?.("redis-streams: read loop exited with error", {
245
+ topic: sub.topic,
246
+ err: err instanceof Error ? err.message : err
247
+ });
248
+ }
249
+ if (!sub.isGrouped) try {
250
+ await this.#writeClient.xGroupDestroy(sub.streamKey, sub.group);
251
+ } catch (err) {
252
+ this.#logger?.debug?.("redis-streams: xGroupDestroy failed", {
253
+ topic: sub.topic,
254
+ group: sub.group,
255
+ err: err instanceof Error ? err.message : err
256
+ });
257
+ }
258
+ }
259
+ async flush() {
260
+ if (this.#pendingPublishes.size > 0) await Promise.allSettled([...this.#pendingPublishes]);
261
+ }
262
+ /**
263
+ * Delete a topic's stream, and with it every consumer group on that stream.
264
+ * This is the Redis implementation of the optional `clearTopic` hook that
265
+ * consumers (e.g. `DurableAgent`) call to free a topic once its lifecycle is
266
+ * over — without it, a persistent backend accumulates every finished run's
267
+ * stream until Redis exhausts its memory.
268
+ *
269
+ * Because it removes events for ALL consumers and destroys their groups, only
270
+ * call it once nothing will read the topic again. A subscriber still attached
271
+ * when the stream is deleted recovers on its own (the read loop recreates the
272
+ * group on NOGROUP) but will have missed the deleted entries.
273
+ *
274
+ * Best-effort and non-throwing: callers commonly invoke this fire-and-forget
275
+ * (`void clearTopic(...)`), so a rejection would surface as an
276
+ * unhandledRejection. Failures are swallowed and logged; the `streamIdleTtlMs`
277
+ * backstop (when configured) reclaims anything a failed delete leaves behind.
278
+ */
279
+ async clearTopic(topic) {
280
+ if (this.#closed) return;
281
+ try {
282
+ await this.#ensureWriterConnected();
283
+ await this.#writeClient.del(this.#streamKey(topic));
284
+ } catch (err) {
285
+ this.#logger?.warn?.("redis-streams: clearTopic failed", {
286
+ topic,
287
+ err: err instanceof Error ? err.message : err
288
+ });
289
+ }
290
+ }
291
+ /**
292
+ * Lease key used in Redis. Distinct prefix from streams so leases and
293
+ * streams can't collide on key namespace.
294
+ */
295
+ #leaseKey(key) {
296
+ return `${this.#keyPrefix}:lease:${key}`;
297
+ }
298
+ /**
299
+ * Atomic claim via SET NX PX. Idempotent for the same owner: if the
300
+ * current value is already this owner, we refresh the TTL instead of
301
+ * failing. Cross-process callers race here; Redis serializes them.
302
+ */
303
+ async acquireLease(key, owner, ttlMs) {
304
+ if (this.#closed) return { acquired: false };
305
+ await this.#ensureWriterConnected();
306
+ const redisKey = this.#leaseKey(key);
307
+ if (await this.#writeClient.set(redisKey, owner, {
308
+ NX: true,
309
+ PX: ttlMs
310
+ }) === "OK") return {
311
+ acquired: true,
312
+ owner
313
+ };
314
+ if (await this.#writeClient.eval(`
337
315
  local current = redis.call("GET", KEYS[1])
338
316
  if current == ARGV[1] then
339
317
  redis.call("PEXPIRE", KEYS[1], ARGV[2])
340
318
  return 1
341
319
  end
342
320
  return 0
343
- `;
344
- const refreshed = await this.#writeClient.eval(script, {
345
- keys: [redisKey],
346
- arguments: [owner, String(ttlMs)]
347
- });
348
- if (refreshed === 1) return { acquired: true, owner };
349
- return { acquired: false, owner: await this.#writeClient.get(redisKey) ?? void 0 };
350
- }
351
- async getLeaseOwner(key) {
352
- if (this.#closed) return void 0;
353
- await this.#ensureWriterConnected();
354
- const current = await this.#writeClient.get(this.#leaseKey(key));
355
- return current ?? void 0;
356
- }
357
- /**
358
- * Release only if we still own it. Implemented as GET+DEL with a Lua
359
- * script so the check-and-delete is atomic against concurrent renewals
360
- * from other processes.
361
- */
362
- async releaseLease(key, owner) {
363
- if (this.#closed) return;
364
- await this.#ensureWriterConnected();
365
- const script = `
321
+ `, {
322
+ keys: [redisKey],
323
+ arguments: [owner, String(ttlMs)]
324
+ }) === 1) return {
325
+ acquired: true,
326
+ owner
327
+ };
328
+ return {
329
+ acquired: false,
330
+ owner: await this.#writeClient.get(redisKey) ?? void 0
331
+ };
332
+ }
333
+ async getLeaseOwner(key) {
334
+ if (this.#closed) return void 0;
335
+ await this.#ensureWriterConnected();
336
+ return await this.#writeClient.get(this.#leaseKey(key)) ?? void 0;
337
+ }
338
+ /**
339
+ * Release only if we still own it. Implemented as GET+DEL with a Lua
340
+ * script so the check-and-delete is atomic against concurrent renewals
341
+ * from other processes.
342
+ */
343
+ async releaseLease(key, owner) {
344
+ if (this.#closed) return;
345
+ await this.#ensureWriterConnected();
346
+ await this.#writeClient.eval(`
366
347
  if redis.call("GET", KEYS[1]) == ARGV[1] then
367
348
  return redis.call("DEL", KEYS[1])
368
349
  else
369
350
  return 0
370
351
  end
371
- `;
372
- await this.#writeClient.eval(script, {
373
- keys: [this.#leaseKey(key)],
374
- arguments: [owner]
375
- });
376
- }
377
- /**
378
- * Extend the TTL only if we still own the lease. Returns false if the
379
- * lease was lost (expired or another owner took it).
380
- */
381
- async renewLease(key, owner, ttlMs) {
382
- if (this.#closed) return false;
383
- await this.#ensureWriterConnected();
384
- const script = `
352
+ `, {
353
+ keys: [this.#leaseKey(key)],
354
+ arguments: [owner]
355
+ });
356
+ }
357
+ /**
358
+ * Extend the TTL only if we still own the lease. Returns false if the
359
+ * lease was lost (expired or another owner took it).
360
+ */
361
+ async renewLease(key, owner, ttlMs) {
362
+ if (this.#closed) return false;
363
+ await this.#ensureWriterConnected();
364
+ return await this.#writeClient.eval(`
385
365
  if redis.call("GET", KEYS[1]) == ARGV[1] then
386
366
  return redis.call("PEXPIRE", KEYS[1], ARGV[2])
387
367
  else
388
368
  return 0
389
369
  end
390
- `;
391
- const result = await this.#writeClient.eval(script, {
392
- keys: [this.#leaseKey(key)],
393
- arguments: [owner, String(ttlMs)]
394
- });
395
- return result === 1;
396
- }
397
- /**
398
- * Atomically hand the lease from `fromOwner` to `toOwner`, refreshing the
399
- * TTL to the full `ttlMs`, without the key ever going empty.
400
- *
401
- * Implemented as a single Lua script (GET == fromOwner -> SET toOwner PX)
402
- * so a racing process cannot win the key between a release and a re-acquire.
403
- * Returns false if `fromOwner` no longer holds the lease (expired or taken),
404
- * in which case the caller should fall back to a fresh `acquireLease`.
405
- */
406
- async transferLease(key, fromOwner, toOwner, ttlMs) {
407
- if (this.#closed) return false;
408
- await this.#ensureWriterConnected();
409
- const script = `
370
+ `, {
371
+ keys: [this.#leaseKey(key)],
372
+ arguments: [owner, String(ttlMs)]
373
+ }) === 1;
374
+ }
375
+ /**
376
+ * Atomically hand the lease from `fromOwner` to `toOwner`, refreshing the
377
+ * TTL to the full `ttlMs`, without the key ever going empty.
378
+ *
379
+ * Implemented as a single Lua script (GET == fromOwner -> SET toOwner PX)
380
+ * so a racing process cannot win the key between a release and a re-acquire.
381
+ * Returns false if `fromOwner` no longer holds the lease (expired or taken),
382
+ * in which case the caller should fall back to a fresh `acquireLease`.
383
+ */
384
+ async transferLease(key, fromOwner, toOwner, ttlMs) {
385
+ if (this.#closed) return false;
386
+ await this.#ensureWriterConnected();
387
+ return await this.#writeClient.eval(`
410
388
  if redis.call("GET", KEYS[1]) == ARGV[1] then
411
389
  redis.call("SET", KEYS[1], ARGV[2], "PX", ARGV[3])
412
390
  return 1
413
391
  else
414
392
  return 0
415
393
  end
416
- `;
417
- const result = await this.#writeClient.eval(script, {
418
- keys: [this.#leaseKey(key)],
419
- arguments: [fromOwner, toOwner, String(ttlMs)]
420
- });
421
- return result === 1;
422
- }
423
- /**
424
- * Disconnect all clients and stop all subscription loops.
425
- */
426
- async close() {
427
- if (this.#closed) return;
428
- this.#closed = true;
429
- const subs = [...this.#subscriptions.values()];
430
- await Promise.all(subs.map((sub) => this.unsubscribe(sub.topic, sub.cb)));
431
- this.#localCallbacks.clear();
432
- if (this.#writeClient.isOpen) {
433
- try {
434
- await this.#writeClient.quit();
435
- } catch (err) {
436
- this.#logger?.debug?.("redis-streams: writer quit failed", {
437
- err: err instanceof Error ? err.message : err
438
- });
439
- }
440
- }
441
- }
442
- async #runReadLoop(sub) {
443
- while (!sub.stopped) {
444
- let result;
445
- try {
446
- result = await sub.readClient.xReadGroup(sub.group, sub.consumer, [{ key: sub.streamKey, id: ">" }], {
447
- COUNT: 10,
448
- BLOCK: this.#blockMs
449
- });
450
- } catch (err) {
451
- if (sub.stopped) return;
452
- const msg = err instanceof Error ? err.message : String(err);
453
- if (msg.includes("NOGROUP")) {
454
- try {
455
- if (this.#streamIdleTtlMs > 0) {
456
- await this.#writeClient.multi().xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
457
- } else {
458
- await this.#writeClient.xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true });
459
- }
460
- this.#logger?.debug?.("redis-streams: recreated consumer group after NOGROUP", {
461
- topic: sub.topic,
462
- group: sub.group
463
- });
464
- } catch (recreateErr) {
465
- const rmsg = errorText(recreateErr);
466
- if (!rmsg.includes("BUSYGROUP")) {
467
- this.#logger?.debug?.("redis-streams: consumer group re-create failed", { topic: sub.topic, err: rmsg });
468
- }
469
- }
470
- } else {
471
- this.#logger?.debug?.("redis-streams: xReadGroup failed", {
472
- topic: sub.topic,
473
- group: sub.group,
474
- err: msg
475
- });
476
- }
477
- await new Promise((r) => setTimeout(r, 100));
478
- continue;
479
- }
480
- if (!result || result.length === 0) continue;
481
- for (const stream of result) {
482
- for (const entry of stream.messages) {
483
- if (sub.stopped) return;
484
- await this.#deliverMessage(sub, entry.id, entry.message);
485
- }
486
- }
487
- }
488
- }
489
- async #deliverMessage(sub, streamId, fields) {
490
- let event;
491
- try {
492
- event = JSON.parse(fields.event ?? "{}");
493
- if (typeof event.createdAt === "string") {
494
- event.createdAt = new Date(event.createdAt);
495
- }
496
- } catch (err) {
497
- this.#logger?.debug?.("redis-streams: malformed payload, dropping", {
498
- topic: sub.topic,
499
- streamId,
500
- err: err instanceof Error ? err.message : err
501
- });
502
- try {
503
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
504
- } catch (ackErr) {
505
- this.#logger?.debug?.("redis-streams: xAck after malformed payload failed", {
506
- topic: sub.topic,
507
- err: ackErr instanceof Error ? ackErr.message : ackErr
508
- });
509
- }
510
- return;
511
- }
512
- let settled = false;
513
- const ack = async () => {
514
- if (settled) return;
515
- settled = true;
516
- try {
517
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
518
- } catch (err) {
519
- this.#logger?.debug?.("redis-streams: ack cleanup failed", {
520
- topic: sub.topic,
521
- err: err instanceof Error ? err.message : err
522
- });
523
- }
524
- };
525
- const nack = async () => {
526
- if (settled) return;
527
- settled = true;
528
- const attempt = event.deliveryAttempt ?? 1;
529
- if (attempt >= this.#maxDeliveryAttempts) {
530
- this.#logger?.warn?.("redis-streams: dropping event after max delivery attempts", {
531
- topic: sub.topic,
532
- eventType: event.type,
533
- eventId: event.id,
534
- attempt,
535
- max: this.#maxDeliveryAttempts
536
- });
537
- try {
538
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
539
- } catch (err) {
540
- this.#logger?.debug?.("redis-streams: ack on dropped poison message failed", {
541
- topic: sub.topic,
542
- err: err instanceof Error ? err.message : err
543
- });
544
- }
545
- return;
546
- }
547
- const next = {
548
- ...event,
549
- deliveryAttempt: attempt + 1
550
- };
551
- try {
552
- if (this.#streamIdleTtlMs > 0) {
553
- await this.#writeClient.multi().xAdd(sub.streamKey, "*", { event: JSON.stringify(next) }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
554
- } else {
555
- await this.#writeClient.xAdd(sub.streamKey, "*", { event: JSON.stringify(next) });
556
- }
557
- } catch (err) {
558
- this.#logger?.warn?.("redis-streams: nack republish failed; leaving original pending for reclaim", {
559
- topic: sub.topic,
560
- eventId: event.id,
561
- err: err instanceof Error ? err.message : err
562
- });
563
- settled = false;
564
- return;
565
- }
566
- try {
567
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
568
- } catch (err) {
569
- this.#logger?.debug?.("redis-streams: xAck after nack failed", {
570
- topic: sub.topic,
571
- err: err instanceof Error ? err.message : err
572
- });
573
- }
574
- };
575
- try {
576
- const result = sub.cb(event, ack, nack);
577
- if (result && typeof result.catch === "function") {
578
- result.catch(async () => {
579
- await nack();
580
- });
581
- }
582
- } catch {
583
- await nack();
584
- }
585
- }
586
- #deliverLocal(topic, event) {
587
- const callbacks = this.#localCallbacks.get(topic);
588
- if (!callbacks || callbacks.size === 0) return;
589
- for (const cb of callbacks) {
590
- this.#invokeLocalCallback(topic, event, cb);
591
- }
592
- }
593
- #invokeLocalCallback(topic, event, cb) {
594
- const ack = async () => {
595
- };
596
- const nack = async () => {
597
- };
598
- try {
599
- const result = cb(
600
- event,
601
- ack,
602
- nack
603
- );
604
- if (result && typeof result.catch === "function") {
605
- result.catch((err) => {
606
- this.#logger?.debug?.("redis-streams: local subscriber rejected", {
607
- topic,
608
- err: err instanceof Error ? err.message : err
609
- });
610
- });
611
- }
612
- } catch (err) {
613
- this.#logger?.debug?.("redis-streams: local subscriber threw", {
614
- topic,
615
- err: err instanceof Error ? err.message : err
616
- });
617
- }
618
- }
394
+ `, {
395
+ keys: [this.#leaseKey(key)],
396
+ arguments: [
397
+ fromOwner,
398
+ toOwner,
399
+ String(ttlMs)
400
+ ]
401
+ }) === 1;
402
+ }
403
+ /**
404
+ * Disconnect all clients and stop all subscription loops.
405
+ */
406
+ async close() {
407
+ if (this.#closed) return;
408
+ this.#closed = true;
409
+ const subs = [...this.#subscriptions.values()];
410
+ await Promise.all(subs.map((sub) => this.unsubscribe(sub.topic, sub.cb)));
411
+ this.#localCallbacks.clear();
412
+ if (this.#writeClient.isOpen) try {
413
+ await this.#writeClient.quit();
414
+ } catch (err) {
415
+ this.#logger?.debug?.("redis-streams: writer quit failed", { err: err instanceof Error ? err.message : err });
416
+ }
417
+ }
418
+ async #runReadLoop(sub) {
419
+ while (!sub.stopped) {
420
+ let result;
421
+ try {
422
+ result = await sub.readClient.xReadGroup(sub.group, sub.consumer, [{
423
+ key: sub.streamKey,
424
+ id: ">"
425
+ }], {
426
+ COUNT: 10,
427
+ BLOCK: this.#blockMs
428
+ });
429
+ } catch (err) {
430
+ if (sub.stopped) return;
431
+ const msg = err instanceof Error ? err.message : String(err);
432
+ if (msg.includes("NOGROUP")) try {
433
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
434
+ else await this.#writeClient.xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true });
435
+ this.#logger?.debug?.("redis-streams: recreated consumer group after NOGROUP", {
436
+ topic: sub.topic,
437
+ group: sub.group
438
+ });
439
+ } catch (recreateErr) {
440
+ const rmsg = errorText(recreateErr);
441
+ if (!rmsg.includes("BUSYGROUP")) this.#logger?.debug?.("redis-streams: consumer group re-create failed", {
442
+ topic: sub.topic,
443
+ err: rmsg
444
+ });
445
+ }
446
+ else this.#logger?.debug?.("redis-streams: xReadGroup failed", {
447
+ topic: sub.topic,
448
+ group: sub.group,
449
+ err: msg
450
+ });
451
+ await new Promise((r) => setTimeout(r, 100));
452
+ continue;
453
+ }
454
+ if (!result || result.length === 0) continue;
455
+ for (const stream of result) for (const entry of stream.messages) {
456
+ if (sub.stopped) return;
457
+ await this.#deliverMessage(sub, entry.id, entry.message);
458
+ }
459
+ }
460
+ }
461
+ async #deliverMessage(sub, streamId, fields) {
462
+ let event;
463
+ try {
464
+ event = JSON.parse(fields.event ?? "{}");
465
+ if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
466
+ } catch (err) {
467
+ this.#logger?.debug?.("redis-streams: malformed payload, dropping", {
468
+ topic: sub.topic,
469
+ streamId,
470
+ err: err instanceof Error ? err.message : err
471
+ });
472
+ try {
473
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
474
+ } catch (ackErr) {
475
+ this.#logger?.debug?.("redis-streams: xAck after malformed payload failed", {
476
+ topic: sub.topic,
477
+ err: ackErr instanceof Error ? ackErr.message : ackErr
478
+ });
479
+ }
480
+ return;
481
+ }
482
+ let settled = false;
483
+ const ack = async () => {
484
+ if (settled) return;
485
+ settled = true;
486
+ try {
487
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
488
+ } catch (err) {
489
+ this.#logger?.debug?.("redis-streams: ack cleanup failed", {
490
+ topic: sub.topic,
491
+ err: err instanceof Error ? err.message : err
492
+ });
493
+ }
494
+ };
495
+ const nack = async () => {
496
+ if (settled) return;
497
+ settled = true;
498
+ const attempt = event.deliveryAttempt ?? 1;
499
+ if (attempt >= this.#maxDeliveryAttempts) {
500
+ this.#logger?.warn?.("redis-streams: dropping event after max delivery attempts", {
501
+ topic: sub.topic,
502
+ eventType: event.type,
503
+ eventId: event.id,
504
+ attempt,
505
+ max: this.#maxDeliveryAttempts
506
+ });
507
+ try {
508
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
509
+ } catch (err) {
510
+ this.#logger?.debug?.("redis-streams: ack on dropped poison message failed", {
511
+ topic: sub.topic,
512
+ err: err instanceof Error ? err.message : err
513
+ });
514
+ }
515
+ return;
516
+ }
517
+ const next = {
518
+ ...event,
519
+ deliveryAttempt: attempt + 1
520
+ };
521
+ try {
522
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xAdd(sub.streamKey, "*", { event: JSON.stringify(next) }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
523
+ else await this.#writeClient.xAdd(sub.streamKey, "*", { event: JSON.stringify(next) });
524
+ } catch (err) {
525
+ this.#logger?.warn?.("redis-streams: nack republish failed; leaving original pending for reclaim", {
526
+ topic: sub.topic,
527
+ eventId: event.id,
528
+ err: err instanceof Error ? err.message : err
529
+ });
530
+ settled = false;
531
+ return;
532
+ }
533
+ try {
534
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
535
+ } catch (err) {
536
+ this.#logger?.debug?.("redis-streams: xAck after nack failed", {
537
+ topic: sub.topic,
538
+ err: err instanceof Error ? err.message : err
539
+ });
540
+ }
541
+ };
542
+ try {
543
+ const result = sub.cb(event, ack, nack);
544
+ if (result && typeof result.catch === "function") result.catch(async () => {
545
+ await nack();
546
+ });
547
+ } catch {
548
+ await nack();
549
+ }
550
+ }
551
+ #deliverLocal(topic, event) {
552
+ const callbacks = this.#localCallbacks.get(topic);
553
+ if (!callbacks || callbacks.size === 0) return;
554
+ for (const cb of callbacks) this.#invokeLocalCallback(topic, event, cb);
555
+ }
556
+ #invokeLocalCallback(topic, event, cb) {
557
+ const ack = async () => {};
558
+ const nack = async () => {};
559
+ try {
560
+ const result = cb(event, ack, nack);
561
+ if (result && typeof result.catch === "function") result.catch((err) => {
562
+ this.#logger?.debug?.("redis-streams: local subscriber rejected", {
563
+ topic,
564
+ err: err instanceof Error ? err.message : err
565
+ });
566
+ });
567
+ } catch (err) {
568
+ this.#logger?.debug?.("redis-streams: local subscriber threw", {
569
+ topic,
570
+ err: err instanceof Error ? err.message : err
571
+ });
572
+ }
573
+ }
619
574
  };
620
-
575
+ //#endregion
621
576
  exports.RedisStreamsPubSub = RedisStreamsPubSub;
622
- //# sourceMappingURL=index.cjs.map
577
+
623
578
  //# sourceMappingURL=index.cjs.map