@mastra/valkey-streams 0.5.1 → 0.5.2

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