@mastra/valkey-streams 0.5.1 → 0.5.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -53,17 +53,25 @@ export declare class ValkeyStreamsClient {
53
53
  COUNT?: number;
54
54
  BLOCK?: number;
55
55
  }): Promise<StreamReply[] | null>;
56
- xAutoClaim(key: string, group: string, consumer: string, minIdle: number, start: string, options?: {
57
- COUNT?: number;
56
+ /**
57
+ * XPENDING <key> <group> [IDLE ms] <start> + <count>. `afterId` makes the
58
+ * range exclusive so callers can page past entries they've already seen.
59
+ */
60
+ xPendingRange(key: string, group: string, count: number, options?: {
61
+ IDLE?: number;
62
+ afterId?: string;
58
63
  }): Promise<{
59
- nextId: string;
60
- messages: {
61
- id: string;
62
- message: {
63
- [k: string]: string;
64
- };
65
- }[];
66
- }>;
64
+ id: string;
65
+ consumer: string;
66
+ millisecondsSinceLastDelivery: number;
67
+ deliveriesCounter: number;
68
+ }[]>;
69
+ xClaim(key: string, group: string, consumer: string, minIdle: number, ids: string[]): Promise<{
70
+ id: string;
71
+ message: {
72
+ [k: string]: string;
73
+ };
74
+ }[]>;
67
75
  xAck(key: string, group: string, id: string): Promise<number>;
68
76
  del(key: string): Promise<number>;
69
77
  set(key: string, value: string, options?: {
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/index.cjs CHANGED
@@ -94,18 +94,32 @@ var ValkeyStreamsClient = class {
94
94
  }))
95
95
  })) ?? null;
96
96
  }
97
- async xAutoClaim(key, group, consumer, minIdle, start, options = {}) {
98
- const result = await (await this.getClient()).xautoclaim(key, group, consumer, minIdle, start, {
99
- count: options.COUNT,
100
- decoder: _valkey_valkey_glide.Decoder.String
101
- });
102
- return {
103
- nextId: text(result[0]),
104
- messages: Object.entries(result[1] ?? {}).map(([id, fields]) => ({
105
- id,
106
- message: Object.fromEntries(fields.map(([field, value]) => [text(field), text(value)]))
107
- }))
108
- };
97
+ /**
98
+ * XPENDING <key> <group> [IDLE ms] <start> + <count>. `afterId` makes the
99
+ * range exclusive so callers can page past entries they've already seen.
100
+ */
101
+ async xPendingRange(key, group, count, options = {}) {
102
+ return (await (await this.getClient()).xpendingWithOptions(key, group, {
103
+ start: options.afterId ? {
104
+ value: options.afterId,
105
+ isInclusive: false
106
+ } : _valkey_valkey_glide.InfBoundary.NegativeInfinity,
107
+ end: _valkey_valkey_glide.InfBoundary.PositiveInfinity,
108
+ count,
109
+ minIdleTime: options.IDLE
110
+ })).map(([id, consumer, millisecondsSinceLastDelivery, deliveriesCounter]) => ({
111
+ id: text(id),
112
+ consumer: text(consumer),
113
+ millisecondsSinceLastDelivery,
114
+ deliveriesCounter
115
+ }));
116
+ }
117
+ async xClaim(key, group, consumer, minIdle, ids) {
118
+ const result = await (await this.getClient()).xclaim(key, group, consumer, minIdle, ids, { decoder: _valkey_valkey_glide.Decoder.String });
119
+ return Object.entries(result ?? {}).map(([id, fields]) => ({
120
+ id,
121
+ message: Object.fromEntries(fields.map(([field, value]) => [text(field), text(value)]))
122
+ }));
109
123
  }
110
124
  async xAck(key, group, id) {
111
125
  return (await this.getClient()).xack(key, group, [id]);
@@ -160,6 +174,32 @@ var ValkeyStreamsClient = class {
160
174
  const createClient = (options) => new ValkeyStreamsClient(options);
161
175
  //#endregion
162
176
  //#region src/index.ts
177
+ /** Page size for the reclaim loop's XPENDING scan and the max entries claimed per tick. */
178
+ const RECLAIM_PAGE_SIZE = 100;
179
+ /**
180
+ * Atomically nack a pending entry only if it is still owned by the given
181
+ * consumer. Returns 1 if this consumer owned it and it was settled, 0 if the
182
+ * entry was not pending for this consumer (acked, or claimed by a sibling).
183
+ *
184
+ * KEYS[1] stream key
185
+ * ARGV[1] group, ARGV[2] consumer, ARGV[3] stream entry id,
186
+ * ARGV[4] republish payload ('' = drop without republish),
187
+ * ARGV[5] stream idle TTL in ms (0 = none)
188
+ */
189
+ const NACK_IF_OWNED_SCRIPT = `
190
+ local pending = redis.call("XPENDING", KEYS[1], ARGV[1], ARGV[3], ARGV[3], 1)
191
+ if #pending == 0 or pending[1][2] ~= ARGV[2] then
192
+ return 0
193
+ end
194
+ if ARGV[4] ~= "" then
195
+ redis.call("XADD", KEYS[1], "*", "event", ARGV[4])
196
+ if tonumber(ARGV[5]) > 0 then
197
+ redis.call("PEXPIRE", KEYS[1], ARGV[5])
198
+ end
199
+ end
200
+ redis.call("XACK", KEYS[1], ARGV[1], ARGV[3])
201
+ return 1
202
+ `;
163
203
  /**
164
204
  * Flatten an error into searchable text. node-redis MULTI failures throw a
165
205
  * `MultiErrorReply` whose own message is just "N commands failed…" — the real
@@ -190,6 +230,7 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
190
230
  #reclaimIntervalMs;
191
231
  #reclaimIdleMs;
192
232
  #maxDeliveryAttempts;
233
+ #inFlightTimeoutMs;
193
234
  #logger;
194
235
  #subscriptions = /* @__PURE__ */ new Map();
195
236
  #cbIds = /* @__PURE__ */ new WeakMap();
@@ -220,6 +261,9 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
220
261
  this.#maxDeliveryAttempts = Infinity;
221
262
  } else if (cap < 0 || Number.isNaN(cap)) throw new Error(`valkey-streams: maxDeliveryAttempts must be >= 1 or Infinity, got ${cap}`);
222
263
  else this.#maxDeliveryAttempts = cap;
264
+ const inFlightTimeout = options.inFlightTimeoutMs ?? 0;
265
+ if (!Number.isFinite(inFlightTimeout) || inFlightTimeout < 0) throw new Error(`valkey-streams: inFlightTimeoutMs must be a non-negative number (milliseconds), got ${inFlightTimeout}`);
266
+ this.#inFlightTimeoutMs = inFlightTimeout;
223
267
  }
224
268
  /**
225
269
  * Attach the `'error'` listener node-redis requires on every client. Without
@@ -275,6 +319,15 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
275
319
  this.#deliverLocal(topic, localEvent);
276
320
  return;
277
321
  }
322
+ const promise = this.#publishRemote(topic, event);
323
+ this.#pendingPublishes.add(promise);
324
+ try {
325
+ await promise;
326
+ } finally {
327
+ this.#pendingPublishes.delete(promise);
328
+ }
329
+ }
330
+ async #publishRemote(topic, event) {
278
331
  await this.#ensureWriterConnected();
279
332
  const id = (0, crypto.randomUUID)();
280
333
  const createdAt = /* @__PURE__ */ new Date();
@@ -291,18 +344,12 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
291
344
  threshold: this.#maxStreamLength
292
345
  };
293
346
  const streamKey = this.#streamKey(topic);
294
- const promise = this.#streamIdleTtlMs > 0 ? this.#writeClient.multi().xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions).pExpire(streamKey, this.#streamIdleTtlMs).exec().then((replies) => {
347
+ await (this.#streamIdleTtlMs > 0 ? this.#writeClient.multi().xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions).pExpire(streamKey, this.#streamIdleTtlMs).exec().then((replies) => {
295
348
  if (Number(replies[1]) !== 1) this.#logger?.warn?.("valkey-streams: PEXPIRE inside publish MULTI did not apply", {
296
349
  streamKey,
297
350
  reply: String(replies[1])
298
351
  });
299
- }) : this.#writeClient.xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions);
300
- this.#pendingPublishes.add(promise);
301
- try {
302
- await promise;
303
- } finally {
304
- this.#pendingPublishes.delete(promise);
305
- }
352
+ }) : this.#writeClient.xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions));
306
353
  }
307
354
  async subscribe(topic, cb, options) {
308
355
  if (this.#closed) throw new Error("ValkeyStreamsPubSub: cannot subscribe on closed client");
@@ -345,41 +392,91 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
345
392
  readClient,
346
393
  stopped: false,
347
394
  loop: void 0,
348
- reclaimTimer: void 0
395
+ reclaimTimer: void 0,
396
+ inFlight: /* @__PURE__ */ new Map()
349
397
  };
350
398
  this.#subscriptions.set(key, sub);
351
399
  sub.loop = this.#runReadLoop(sub);
352
400
  this.#startReclaimLoop(sub);
353
401
  }
354
402
  /**
355
- * Periodically run XAUTOCLAIM against this subscription's group so that
356
- * messages a crashed/stuck consumer read but never acked get redelivered to
357
- * a live sibling. Runs only for grouped subscriptions — fan-out groups are
358
- * private to one consumer, so there's no sibling to claim from.
403
+ * Periodically reclaim idle pending entries in this subscription's group so
404
+ * that messages a crashed/stuck consumer read but never acked get redelivered
405
+ * to a live sibling. Fan-out groups are private to one consumer, so there is
406
+ * no sibling to claim from and the XPENDING/XCLAIM scan is skipped for them;
407
+ * the loop still runs so `inFlightTimeoutMs` can recover a hung handler,
408
+ * which is the only recovery path a single-consumer group has.
359
409
  */
360
410
  #startReclaimLoop(sub) {
361
411
  if (this.#reclaimIntervalMs <= 0) return;
362
- if (!sub.isGrouped) return;
412
+ if (!sub.isGrouped && this.#inFlightTimeoutMs <= 0) return;
363
413
  const tick = async () => {
364
414
  if (sub.stopped || this.#closed) return;
365
415
  try {
366
- const messages = (await this.#writeClient.xAutoClaim(sub.streamKey, sub.group, sub.consumer, this.#reclaimIdleMs, "0-0", { COUNT: 100 }))?.messages ?? [];
367
- for (const entry of messages) {
416
+ await this.#expireInFlight(sub);
417
+ if (!sub.isGrouped) {
418
+ sub.reclaimTimer = setTimeout(runTick, this.#reclaimIntervalMs);
419
+ return;
420
+ }
421
+ const ids = [];
422
+ let afterId;
423
+ for (;;) {
424
+ const page = await this.#writeClient.xPendingRange(sub.streamKey, sub.group, RECLAIM_PAGE_SIZE, {
425
+ IDLE: this.#reclaimIdleMs,
426
+ afterId
427
+ });
428
+ for (const p of page) {
429
+ if (sub.inFlight.has(p.id)) continue;
430
+ ids.push(p.id);
431
+ if (ids.length >= RECLAIM_PAGE_SIZE) break;
432
+ }
433
+ if (page.length < RECLAIM_PAGE_SIZE || ids.length >= RECLAIM_PAGE_SIZE) break;
434
+ afterId = page[page.length - 1].id;
435
+ }
436
+ if (ids.length === 0 || sub.stopped || this.#closed) {
368
437
  if (sub.stopped || this.#closed) return;
369
- if (!entry) continue;
370
- await this.#deliverMessage(sub, entry.id, entry.message);
438
+ sub.reclaimTimer = setTimeout(runTick, this.#reclaimIntervalMs);
439
+ return;
371
440
  }
441
+ const claimed = await this.#writeClient.xClaim(sub.streamKey, sub.group, sub.consumer, this.#reclaimIdleMs, ids);
442
+ for (const entry of claimed) await this.#deliverMessage(sub, entry.id, entry.message);
372
443
  } catch (err) {
373
- this.#logger?.debug?.("valkey-streams: XAUTOCLAIM failed", {
444
+ this.#logger?.debug?.("valkey-streams: reclaim failed", {
374
445
  topic: sub.topic,
375
446
  group: sub.group,
376
447
  err: err instanceof Error ? err.message : err
377
448
  });
378
449
  }
379
450
  if (sub.stopped || this.#closed) return;
380
- sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
451
+ sub.reclaimTimer = setTimeout(runTick, this.#reclaimIntervalMs);
452
+ };
453
+ const runTick = () => {
454
+ sub.reclaimLoop = tick().finally(() => {
455
+ sub.reclaimLoop = void 0;
456
+ });
381
457
  };
382
- sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
458
+ sub.reclaimTimer = setTimeout(runTick, this.#reclaimIntervalMs);
459
+ }
460
+ /**
461
+ * Give up on local in-flight entries older than `inFlightTimeoutMs` by
462
+ * nacking them on the handler's behalf. This is the only way a
463
+ * single-consumer group recovers a hung handler: the reclaim loop never
464
+ * claims its own in-flight entries (see the tick above), so without a
465
+ * timeout the entry stays pending until the process restarts.
466
+ */
467
+ async #expireInFlight(sub) {
468
+ if (this.#inFlightTimeoutMs <= 0) return;
469
+ const cutoff = Date.now() - this.#inFlightTimeoutMs;
470
+ for (const [streamId, entry] of sub.inFlight) {
471
+ if (entry.since > cutoff) continue;
472
+ this.#logger?.warn?.("valkey-streams: handler exceeded inFlightTimeoutMs; nacking on its behalf", {
473
+ topic: sub.topic,
474
+ group: sub.group,
475
+ streamId,
476
+ inFlightMs: Date.now() - entry.since
477
+ });
478
+ await entry.expire();
479
+ }
383
480
  }
384
481
  async unsubscribe(topic, cb) {
385
482
  const localBucket = this.#localCallbacks.get(topic);
@@ -412,6 +509,7 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
412
509
  err: err instanceof Error ? err.message : err
413
510
  });
414
511
  }
512
+ await sub.reclaimLoop;
415
513
  if (!sub.isGrouped) try {
416
514
  await this.#writeClient.xGroupDestroy(sub.streamKey, sub.group);
417
515
  } catch (err) {
@@ -579,6 +677,7 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
579
677
  const subs = [...this.#subscriptions.values()];
580
678
  await Promise.all(subs.map((sub) => this.unsubscribe(sub.topic, sub.cb)));
581
679
  this.#localCallbacks.clear();
680
+ await this.flush();
582
681
  if (this.#writeClient.isOpen) try {
583
682
  await this.#writeClient.quit();
584
683
  } catch (err) {
@@ -663,6 +762,8 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
663
762
  topic: sub.topic,
664
763
  err: err instanceof Error ? err.message : err
665
764
  });
765
+ } finally {
766
+ sub.inFlight.delete(streamId);
666
767
  }
667
768
  };
668
769
  const nack = async () => {
@@ -684,6 +785,8 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
684
785
  topic: sub.topic,
685
786
  err: err instanceof Error ? err.message : err
686
787
  });
788
+ } finally {
789
+ sub.inFlight.delete(streamId);
687
790
  }
688
791
  return;
689
792
  }
@@ -701,6 +804,7 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
701
804
  err: err instanceof Error ? err.message : err
702
805
  });
703
806
  settled = false;
807
+ sub.inFlight.delete(streamId);
704
808
  return;
705
809
  }
706
810
  try {
@@ -710,8 +814,62 @@ var ValkeyStreamsPubSub = class extends _mastra_core_events.PubSub {
710
814
  topic: sub.topic,
711
815
  err: err instanceof Error ? err.message : err
712
816
  });
817
+ } finally {
818
+ sub.inFlight.delete(streamId);
819
+ }
820
+ };
821
+ const expire = async () => {
822
+ if (settled) return;
823
+ settled = true;
824
+ const attempt = event.deliveryAttempt ?? 1;
825
+ const drop = attempt >= this.#maxDeliveryAttempts;
826
+ const payload = drop ? "" : JSON.stringify({
827
+ ...event,
828
+ deliveryAttempt: attempt + 1
829
+ });
830
+ let owned;
831
+ try {
832
+ owned = await this.#writeClient.eval(NACK_IF_OWNED_SCRIPT, {
833
+ keys: [sub.streamKey],
834
+ arguments: [
835
+ sub.group,
836
+ sub.consumer,
837
+ streamId,
838
+ payload,
839
+ String(this.#streamIdleTtlMs)
840
+ ]
841
+ });
842
+ } catch (err) {
843
+ this.#logger?.warn?.("valkey-streams: timeout nack failed; leaving original pending for reclaim", {
844
+ topic: sub.topic,
845
+ eventId: event.id,
846
+ err: err instanceof Error ? err.message : err
847
+ });
848
+ settled = false;
849
+ sub.inFlight.delete(streamId);
850
+ return;
851
+ }
852
+ sub.inFlight.delete(streamId);
853
+ if (owned !== 1) {
854
+ this.#logger?.warn?.("valkey-streams: handler exceeded inFlightTimeoutMs but entry is no longer owned; disowning", {
855
+ topic: sub.topic,
856
+ group: sub.group,
857
+ streamId
858
+ });
859
+ return;
713
860
  }
861
+ if (drop) this.#logger?.warn?.("valkey-streams: dropping event after max delivery attempts", {
862
+ topic: sub.topic,
863
+ eventType: event.type,
864
+ eventId: event.id,
865
+ attempt,
866
+ max: this.#maxDeliveryAttempts
867
+ });
714
868
  };
869
+ sub.inFlight.set(streamId, {
870
+ since: Date.now(),
871
+ expire
872
+ });
715
873
  try {
716
874
  const result = sub.cb(event, ack, nack);
717
875
  if (result && typeof result.catch === "function") result.catch(async () => {