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