@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 +18 -10
- package/dist/client.d.ts.map +1 -1
- package/dist/index.cjs +191 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +192 -34
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
355
|
-
* messages a crashed/stuck consumer read but never acked get redelivered
|
|
356
|
-
* a live sibling.
|
|
357
|
-
*
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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 () => {
|