@mastra/redis-streams 0.3.0 → 0.4.0-alpha.1

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