@furystack/redis-task-runner 1.0.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.
Files changed (47) hide show
  1. package/README.md +128 -0
  2. package/esm/cross-process-smoke.spec.d.ts +2 -0
  3. package/esm/cross-process-smoke.spec.d.ts.map +1 -0
  4. package/esm/cross-process-smoke.spec.js +463 -0
  5. package/esm/cross-process-smoke.spec.js.map +1 -0
  6. package/esm/define-redis-task-runner.d.ts +38 -0
  7. package/esm/define-redis-task-runner.d.ts.map +1 -0
  8. package/esm/define-redis-task-runner.js +60 -0
  9. package/esm/define-redis-task-runner.js.map +1 -0
  10. package/esm/index.d.ts +7 -0
  11. package/esm/index.d.ts.map +1 -0
  12. package/esm/index.js +4 -0
  13. package/esm/index.js.map +1 -0
  14. package/esm/multi-service-smoke.spec.d.ts +2 -0
  15. package/esm/multi-service-smoke.spec.d.ts.map +1 -0
  16. package/esm/multi-service-smoke.spec.js +468 -0
  17. package/esm/multi-service-smoke.spec.js.map +1 -0
  18. package/esm/redis-queue-adapter.d.ts +125 -0
  19. package/esm/redis-queue-adapter.d.ts.map +1 -0
  20. package/esm/redis-queue-adapter.js +566 -0
  21. package/esm/redis-queue-adapter.js.map +1 -0
  22. package/esm/redis-queue-adapter.unit.spec.d.ts +2 -0
  23. package/esm/redis-queue-adapter.unit.spec.d.ts.map +1 -0
  24. package/esm/redis-queue-adapter.unit.spec.js +860 -0
  25. package/esm/redis-queue-adapter.unit.spec.js.map +1 -0
  26. package/esm/redis-task-runner.d.ts +33 -0
  27. package/esm/redis-task-runner.d.ts.map +1 -0
  28. package/esm/redis-task-runner.integration.spec.d.ts +2 -0
  29. package/esm/redis-task-runner.integration.spec.d.ts.map +1 -0
  30. package/esm/redis-task-runner.integration.spec.js +536 -0
  31. package/esm/redis-task-runner.integration.spec.js.map +1 -0
  32. package/esm/redis-task-runner.js +43 -0
  33. package/esm/redis-task-runner.js.map +1 -0
  34. package/esm/redis-task-runner.unit.spec.d.ts +2 -0
  35. package/esm/redis-task-runner.unit.spec.d.ts.map +1 -0
  36. package/esm/redis-task-runner.unit.spec.js +97 -0
  37. package/esm/redis-task-runner.unit.spec.js.map +1 -0
  38. package/package.json +63 -0
  39. package/src/cross-process-smoke.spec.ts +459 -0
  40. package/src/define-redis-task-runner.ts +91 -0
  41. package/src/index.ts +8 -0
  42. package/src/multi-service-smoke.spec.ts +511 -0
  43. package/src/redis-queue-adapter.ts +733 -0
  44. package/src/redis-queue-adapter.unit.spec.ts +613 -0
  45. package/src/redis-task-runner.integration.spec.ts +440 -0
  46. package/src/redis-task-runner.ts +67 -0
  47. package/src/redis-task-runner.unit.spec.ts +110 -0
@@ -0,0 +1,125 @@
1
+ import type { ClaimedTask, EnqueueInput, IdempotencyLeaseInput, QueueAdapter, QueueAdapterCapabilities, WorkerSubscription } from '@furystack/task-runner';
2
+ import type { RedisClientType } from 'redis';
3
+ type RedisLikeClient = RedisClientType;
4
+ /**
5
+ * Options accepted by {@link RedisQueueAdapter}.
6
+ */
7
+ export type RedisQueueAdapterOptions = {
8
+ /**
9
+ * Caller-owned `redis` client. Must be connected before the adapter
10
+ * is used. The adapter never closes it — lifecycle stays with the
11
+ * application, mirroring `@furystack/redis-cross-node-bus` and
12
+ * `@furystack/s3-blob-store`.
13
+ */
14
+ client: RedisLikeClient;
15
+ /**
16
+ * Logical service name. Used in telemetry attribution and (when
17
+ * `topicPrefix` is left default) helps debug shared brokers.
18
+ */
19
+ serviceName: string;
20
+ /**
21
+ * Wire-level prefix applied to every stream key. Multi-service
22
+ * deployments pick a distinct prefix per service.
23
+ */
24
+ topicPrefix?: string;
25
+ /**
26
+ * Stable consumer group name. All workers join this group; PRD §7.4
27
+ * relies on consumer-group competing-consumer semantics for fan-out.
28
+ * Default: `runner`.
29
+ */
30
+ consumerGroup?: string;
31
+ /**
32
+ * Default visibility timeout (milliseconds). A handler attempt is
33
+ * considered stale when its PEL idle time exceeds this; XAUTOCLAIM
34
+ * reclaims it for another consumer. Apps can override per task type
35
+ * via {@link RedisQueueAdapterOptions.visibilityTimeoutByType}.
36
+ * Default: `60_000`.
37
+ */
38
+ visibilityTimeoutMs?: number;
39
+ /**
40
+ * Per-task-type visibility-timeout override map. Useful when one
41
+ * type does GPU-bound work and another short polls.
42
+ */
43
+ visibilityTimeoutByType?: Record<string, number>;
44
+ /**
45
+ * `XREADGROUP BLOCK` timeout (milliseconds). Lower values reduce
46
+ * wakeup latency on shutdown; higher values reduce broker chatter.
47
+ * Default: `200`.
48
+ */
49
+ blockTimeoutMs?: number;
50
+ /**
51
+ * Backoff between `XREADGROUP` attempts after a transport error so
52
+ * a disconnected broker does not produce a tight reconnect spin.
53
+ * Default: `250`.
54
+ */
55
+ retryBackoffMs?: number;
56
+ /**
57
+ * Idempotency-lease TTL (seconds) for
58
+ * {@link RedisQueueAdapter.acquireIdempotencyLease}. Default: 24h.
59
+ */
60
+ idempotencyTtlSec?: number;
61
+ /**
62
+ * Delayed-dispatch tick interval (milliseconds). Each tick atomically
63
+ * pops due entries from the scheduler ZSET and `XADD`s them onto the
64
+ * matching `(type, version)` stream. Lower values = lower delay
65
+ * jitter at the cost of broker chatter; higher values = looser
66
+ * floor on `notBefore` granularity. Default: `250`.
67
+ */
68
+ schedulerIntervalMs?: number;
69
+ /**
70
+ * Fleet-wide max-concurrent claims per task type. A claim whose lane
71
+ * is at its cap is released back to the stream and retried, so the
72
+ * limit holds across every worker process sharing the broker (PRD
73
+ * §11 fleet cap). Types absent from the map are uncapped. Enforced by
74
+ * a per-type ZSET of holder tokens scored by expiry (crash-safe TTL
75
+ * tied to the type's visibility timeout).
76
+ */
77
+ concurrencyLimits?: Record<string, number>;
78
+ };
79
+ /**
80
+ * Redis Streams implementation of {@link QueueAdapter}. Each
81
+ * `(type, handlerVersion)` lane gets its own stream
82
+ * (`${topicPrefix}tasks:queue:${type}:v${handlerVersion}`); workers
83
+ * register against the streams whose `(type, version)` they support
84
+ * via the {@link WorkerSubscription.compatibleVersions} map.
85
+ *
86
+ * **Concurrency.** Each subscription spawns
87
+ * {@link WorkerSubscription.concurrency} parallel claim slots. A slot
88
+ * is one consumer in the consumer group named
89
+ * `${workerId}-slot-${index}`; messages handed to a slot live in that
90
+ * consumer's PEL until ack. Multiple slots share the same group, so
91
+ * Redis itself enforces competing-consumer semantics.
92
+ *
93
+ * **Visibility reclaim.** Every slot iteration runs `XAUTOCLAIM` first
94
+ * (idle threshold = the type's `visibilityTimeoutMs`); reclaimed
95
+ * entries are delivered through the same `onClaim` path as fresh
96
+ * messages, with an attempt re-classification done by the runner core
97
+ * (the dataset still records the prior attempt as `'timed-out'`).
98
+ *
99
+ * **Heartbeat.** {@link RedisQueueAdapter.heartbeat} resets the PEL
100
+ * idle counter via `XCLAIM JUSTID` so long-running handlers don't get
101
+ * reclaimed, and refreshes the holder's fleet-cap lease.
102
+ *
103
+ * **Delayed dispatch.** `notBefore` parks the entry in a per-prefix
104
+ * scheduler ZSET; a timer atomically pops due entries onto the matching
105
+ * stream (`delayedDispatch: true`).
106
+ *
107
+ * **Fleet cap.** When a type appears in
108
+ * {@link RedisQueueAdapterOptions.concurrencyLimits}, each claim must
109
+ * admit a holder token into a per-type ZSET (scored by expiry) before
110
+ * the handler runs; a claim that finds its lane full is released back to
111
+ * the stream and retried, so the cap holds fleet-wide
112
+ * (`fleetCapEnforcement: true`).
113
+ */
114
+ export declare class RedisQueueAdapter implements QueueAdapter {
115
+ #private;
116
+ readonly capabilities: QueueAdapterCapabilities;
117
+ constructor(options: RedisQueueAdapterOptions);
118
+ enqueue(input: EnqueueInput): Promise<void>;
119
+ subscribe(subscription: WorkerSubscription): Disposable;
120
+ heartbeat(claim: ClaimedTask): Promise<void>;
121
+ acquireIdempotencyLease(input: IdempotencyLeaseInput): Promise<string>;
122
+ [Symbol.dispose](): void;
123
+ }
124
+ export {};
125
+ //# sourceMappingURL=redis-queue-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-queue-adapter.d.ts","sourceRoot":"","sources":["../src/redis-queue-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EAEX,YAAY,EACZ,qBAAqB,EACrB,YAAY,EACZ,wBAAwB,EACxB,kBAAkB,EACnB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,OAAO,CAAA;AAE5C,KAAK,eAAe,GAAG,eAAe,CAAA;AAEtC;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC;;;;;OAKG;IACH,MAAM,EAAE,eAAe,CAAA;IACvB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;OAGG;IACH,uBAAuB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChD;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC3C,CAAA;AA4GD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,qBAAa,iBAAkB,YAAW,YAAY;;IACpD,SAAgB,YAAY,EAAE,wBAAwB,CAAe;gBA+BzD,OAAO,EAAE,wBAAwB;IAkChC,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IA2BjD,SAAS,CAAC,YAAY,EAAE,kBAAkB,GAAG,UAAU;IAwCjD,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAe5C,uBAAuB,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;IAa5E,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CA0ThC"}
@@ -0,0 +1,566 @@
1
+ const CAPABILITIES = Object.freeze({
2
+ persistent: true,
3
+ distributed: true,
4
+ delayedDispatch: true,
5
+ fleetCapEnforcement: true,
6
+ brokerSideReclaim: true,
7
+ });
8
+ const DEFAULT_GROUP = 'runner';
9
+ const DEFAULT_VISIBILITY_MS = 60_000;
10
+ const DEFAULT_BLOCK_MS = 200;
11
+ const DEFAULT_RETRY_BACKOFF_MS = 250;
12
+ const DEFAULT_IDEMPOTENCY_TTL_SEC = 86_400;
13
+ const DEFAULT_SCHEDULER_INTERVAL_MS = 250;
14
+ const SCHEDULER_BATCH_LIMIT = 64;
15
+ /**
16
+ * Atomically: pop ZSET members whose score ≤ now, parse their JSON
17
+ * envelope, XADD the resulting message to the per-`(type, version)`
18
+ * stream, and ZREM the member. Single round-trip prevents two adapter
19
+ * instances from double-dispatching the same delayed task.
20
+ *
21
+ * `KEYS[1]` — scheduler ZSET key.
22
+ * `ARGV[1]` — current epoch ms (caller-supplied so the script's own
23
+ * server-clock drift cannot diverge from the runner's notion of `now`).
24
+ * `ARGV[2]` — batch limit.
25
+ * `ARGV[3]` — `topicPrefix` used to build the destination stream key.
26
+ *
27
+ * Returns the number of dispatched entries.
28
+ */
29
+ const SCHEDULER_DISPATCH_SCRIPT = `
30
+ local now = tonumber(ARGV[1])
31
+ local limit = tonumber(ARGV[2])
32
+ local prefix = ARGV[3]
33
+ local sched = KEYS[1]
34
+
35
+ local members = redis.call('ZRANGEBYSCORE', sched, '-inf', now, 'LIMIT', 0, limit)
36
+ local dispatched = 0
37
+ for _, member in ipairs(members) do
38
+ local removed = redis.call('ZREM', sched, member)
39
+ if removed == 1 then
40
+ local meta = cjson.decode(member)
41
+ local stream = prefix .. 'tasks:queue:' .. meta.type .. ':v' .. tostring(meta.handlerVersion)
42
+ redis.call('XADD', stream, '*',
43
+ 'taskId', meta.taskId,
44
+ 'type', meta.type,
45
+ 'handlerVersion', tostring(meta.handlerVersion))
46
+ dispatched = dispatched + 1
47
+ end
48
+ end
49
+ return dispatched
50
+ `;
51
+ /**
52
+ * Atomically admit one holder to a fleet-cap lane. Evicts expired
53
+ * holders (score ≤ now), then admits the caller's token only if the
54
+ * lane is below its cap.
55
+ *
56
+ * `KEYS[1]` — per-type cap ZSET key.
57
+ * `ARGV[1]` — current epoch ms (used to evict expired holders).
58
+ * `ARGV[2]` — cap (max concurrent holders).
59
+ * `ARGV[3]` — caller's holder token.
60
+ * `ARGV[4]` — holder expiry epoch ms (now + visibility timeout). The
61
+ * TTL frees a crashed holder's slot exactly when its claim becomes
62
+ * reclaimable.
63
+ *
64
+ * Returns `1` when admitted, `0` when the lane is full.
65
+ */
66
+ const CAP_ACQUIRE_SCRIPT = `
67
+ local now = tonumber(ARGV[1])
68
+ local cap = tonumber(ARGV[2])
69
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
70
+ if redis.call('ZCARD', KEYS[1]) < cap then
71
+ redis.call('ZADD', KEYS[1], ARGV[4], ARGV[3])
72
+ return 1
73
+ end
74
+ return 0
75
+ `;
76
+ /**
77
+ * Redis Streams implementation of {@link QueueAdapter}. Each
78
+ * `(type, handlerVersion)` lane gets its own stream
79
+ * (`${topicPrefix}tasks:queue:${type}:v${handlerVersion}`); workers
80
+ * register against the streams whose `(type, version)` they support
81
+ * via the {@link WorkerSubscription.compatibleVersions} map.
82
+ *
83
+ * **Concurrency.** Each subscription spawns
84
+ * {@link WorkerSubscription.concurrency} parallel claim slots. A slot
85
+ * is one consumer in the consumer group named
86
+ * `${workerId}-slot-${index}`; messages handed to a slot live in that
87
+ * consumer's PEL until ack. Multiple slots share the same group, so
88
+ * Redis itself enforces competing-consumer semantics.
89
+ *
90
+ * **Visibility reclaim.** Every slot iteration runs `XAUTOCLAIM` first
91
+ * (idle threshold = the type's `visibilityTimeoutMs`); reclaimed
92
+ * entries are delivered through the same `onClaim` path as fresh
93
+ * messages, with an attempt re-classification done by the runner core
94
+ * (the dataset still records the prior attempt as `'timed-out'`).
95
+ *
96
+ * **Heartbeat.** {@link RedisQueueAdapter.heartbeat} resets the PEL
97
+ * idle counter via `XCLAIM JUSTID` so long-running handlers don't get
98
+ * reclaimed, and refreshes the holder's fleet-cap lease.
99
+ *
100
+ * **Delayed dispatch.** `notBefore` parks the entry in a per-prefix
101
+ * scheduler ZSET; a timer atomically pops due entries onto the matching
102
+ * stream (`delayedDispatch: true`).
103
+ *
104
+ * **Fleet cap.** When a type appears in
105
+ * {@link RedisQueueAdapterOptions.concurrencyLimits}, each claim must
106
+ * admit a holder token into a per-type ZSET (scored by expiry) before
107
+ * the handler runs; a claim that finds its lane full is released back to
108
+ * the stream and retried, so the cap holds fleet-wide
109
+ * (`fleetCapEnforcement: true`).
110
+ */
111
+ export class RedisQueueAdapter {
112
+ capabilities = CAPABILITIES;
113
+ #client;
114
+ #topicPrefix;
115
+ #group;
116
+ #defaultVisibilityMs;
117
+ #visibilityByType;
118
+ #blockMs;
119
+ #retryBackoffMs;
120
+ #idempotencyTtlSec;
121
+ #schedulerIntervalMs;
122
+ #concurrencyLimits;
123
+ #subscriptions = new Set();
124
+ #ensuredGroups = new Set();
125
+ /**
126
+ * Dedicated connection for the blocking `XREADGROUP` / `XAUTOCLAIM`
127
+ * slot loops. A blocking command holds the connection's reply stream
128
+ * until it returns, so sharing the main client would stall every store
129
+ * read/write, `XADD`, and `XACK` behind an in-flight `BLOCK` for up to
130
+ * `blockTimeoutMs` (head-of-line blocking). Duplicating mirrors
131
+ * `RedisCrossNodeBus`; the caller's client stays free for everything else.
132
+ */
133
+ #readClient;
134
+ #readConnectPromise;
135
+ #schedulerTimer;
136
+ #schedulerRunning = false;
137
+ #disposed = false;
138
+ constructor(options) {
139
+ if (!options.serviceName || options.serviceName.length === 0) {
140
+ throw new Error('RedisQueueAdapter: `serviceName` is required');
141
+ }
142
+ this.#client = options.client;
143
+ this.#topicPrefix = options.topicPrefix ?? '';
144
+ this.#group = options.consumerGroup ?? DEFAULT_GROUP;
145
+ this.#defaultVisibilityMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_MS;
146
+ this.#visibilityByType = options.visibilityTimeoutByType ?? {};
147
+ this.#blockMs = options.blockTimeoutMs ?? DEFAULT_BLOCK_MS;
148
+ this.#retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
149
+ this.#idempotencyTtlSec = options.idempotencyTtlSec ?? DEFAULT_IDEMPOTENCY_TTL_SEC;
150
+ this.#schedulerIntervalMs = options.schedulerIntervalMs ?? DEFAULT_SCHEDULER_INTERVAL_MS;
151
+ this.#concurrencyLimits = options.concurrencyLimits ?? {};
152
+ this.#readClient = options.client.duplicate();
153
+ this.#readConnectPromise = this.#readClient
154
+ .connect()
155
+ .then(() => {
156
+ // Disposed before the connection finished coming up — close it now.
157
+ if (this.#disposed)
158
+ this.#safeDestroyReadClient();
159
+ })
160
+ .catch(() => {
161
+ // Read connection failed to come up; slot reads no-op and back off.
162
+ });
163
+ // `unref()` so an idle scheduler timer never holds the process
164
+ // open in single-shot scripts / tests.
165
+ this.#schedulerTimer = setInterval(() => void this.#runSchedulerTick(), this.#schedulerIntervalMs);
166
+ this.#schedulerTimer.unref?.();
167
+ }
168
+ // ── Public API ────────────────────────────────────────────────────
169
+ async enqueue(input) {
170
+ this.#ensureLive();
171
+ const stream = this.#streamKey(input.type, input.handlerVersion);
172
+ // Ensure the consumer group exists eagerly — workers subscribing
173
+ // later will pick up XADDs that happen between scheduler dispatch
174
+ // and the worker's group-create call only when the group already
175
+ // claims the stream's history.
176
+ await this.#ensureGroup(stream);
177
+ const dueAt = input.notBefore?.getTime();
178
+ if (dueAt !== undefined && dueAt > Date.now()) {
179
+ const member = JSON.stringify({
180
+ taskId: input.taskId,
181
+ type: input.type,
182
+ handlerVersion: input.handlerVersion,
183
+ });
184
+ await this.#client.zAdd(this.#schedulerKey(), { score: dueAt, value: member });
185
+ return;
186
+ }
187
+ await this.#client.xAdd(stream, '*', {
188
+ taskId: input.taskId,
189
+ type: input.type,
190
+ handlerVersion: String(input.handlerVersion),
191
+ });
192
+ }
193
+ subscribe(subscription) {
194
+ this.#ensureLive();
195
+ const streams = this.#streamsFor(subscription);
196
+ const ac = new AbortController();
197
+ const consumers = [];
198
+ const slotPromises = [];
199
+ const state = {
200
+ subscription,
201
+ consumers,
202
+ streams,
203
+ abortController: ac,
204
+ slotPromises,
205
+ };
206
+ this.#subscriptions.add(state);
207
+ void (async () => {
208
+ for (const stream of streams) {
209
+ await this.#ensureGroup(stream);
210
+ }
211
+ for (let i = 0; i < subscription.concurrency; i++) {
212
+ const consumerName = `${subscription.workerId}-slot-${i}`;
213
+ consumers.push(consumerName);
214
+ slotPromises.push(this.#runSlot(state, consumerName, ac.signal));
215
+ }
216
+ })().catch(() => {
217
+ // Setup failure leaves the subscription empty; disposal is still
218
+ // safe, and the caller will see no claims arrive — surfaceable
219
+ // via telemetry once the runner core wires it through.
220
+ });
221
+ return {
222
+ [Symbol.dispose]: () => {
223
+ ac.abort();
224
+ this.#subscriptions.delete(state);
225
+ },
226
+ };
227
+ }
228
+ async heartbeat(claim) {
229
+ if (this.#disposed)
230
+ return;
231
+ const receipt = claim.receipt;
232
+ try {
233
+ await this.#client.xClaimJustId(receipt.stream, this.#group, receipt.consumer, 0, [receipt.msgId]);
234
+ }
235
+ catch {
236
+ // Heartbeat is best-effort. A transient broker error means the
237
+ // PEL idle counter keeps ticking; XAUTOCLAIM will reclaim the
238
+ // attempt if the issue persists past the visibility timeout.
239
+ }
240
+ if (this.#concurrencyLimits[claim.type] !== undefined) {
241
+ await this.#refreshCap(claim.type, `${receipt.consumer}:${receipt.msgId}`);
242
+ }
243
+ }
244
+ async acquireIdempotencyLease(input) {
245
+ this.#ensureLive();
246
+ const key = this.#idempotencyKey(input.type, input.key);
247
+ const set = await this.#client.set(key, input.taskId, {
248
+ NX: true,
249
+ EX: this.#idempotencyTtlSec,
250
+ });
251
+ if (set === 'OK')
252
+ return input.taskId;
253
+ const existing = await this.#client.get(key);
254
+ return typeof existing === 'string' && existing.length > 0 ? existing : input.taskId;
255
+ }
256
+ [Symbol.dispose]() {
257
+ if (this.#disposed)
258
+ return;
259
+ this.#disposed = true;
260
+ clearInterval(this.#schedulerTimer);
261
+ for (const state of this.#subscriptions) {
262
+ state.abortController.abort();
263
+ }
264
+ this.#subscriptions.clear();
265
+ this.#safeDestroyReadClient();
266
+ }
267
+ #safeDestroyReadClient() {
268
+ try {
269
+ if (this.#readClient.isOpen)
270
+ this.#readClient.destroy();
271
+ }
272
+ catch {
273
+ // Best-effort: the duplicated connection is force-closed on dispose.
274
+ }
275
+ }
276
+ // ── Internal helpers ──────────────────────────────────────────────
277
+ #streamKey(type, version) {
278
+ return `${this.#topicPrefix}tasks:queue:${type}:v${version}`;
279
+ }
280
+ #idempotencyKey(type, key) {
281
+ return `${this.#topicPrefix}tasks:idem:${type}:${key}`;
282
+ }
283
+ #schedulerKey() {
284
+ return `${this.#topicPrefix}tasks:scheduler`;
285
+ }
286
+ /**
287
+ * Drains due delayed entries from the scheduler ZSET. Re-entrant
288
+ * guard prevents overlapping ticks on slow brokers; errors are
289
+ * swallowed so a transient `EVAL` failure does not poison the
290
+ * timer. The Lua script is the only authority for race-free
291
+ * dispatch — see {@link SCHEDULER_DISPATCH_SCRIPT}.
292
+ */
293
+ async #runSchedulerTick() {
294
+ if (this.#disposed || this.#schedulerRunning)
295
+ return;
296
+ this.#schedulerRunning = true;
297
+ try {
298
+ await this.#eval(SCHEDULER_DISPATCH_SCRIPT, [this.#schedulerKey()], [String(Date.now()), String(SCHEDULER_BATCH_LIMIT), this.#topicPrefix]);
299
+ }
300
+ catch {
301
+ // Best-effort: a transient broker failure means due entries stay
302
+ // in the ZSET and the next tick retries.
303
+ }
304
+ finally {
305
+ this.#schedulerRunning = false;
306
+ }
307
+ }
308
+ /**
309
+ * Runs a Lua script via the redis client's `eval` (keys/arguments
310
+ * option shape). The cast keeps {@link RedisLikeClient} opaque to the
311
+ * exact `redis` command typings.
312
+ */
313
+ #eval(script, keys, args) {
314
+ const evalFn = this.#client.eval;
315
+ return evalFn.call(this.#client, script, { keys, arguments: args });
316
+ }
317
+ // ── Fleet cap ─────────────────────────────────────────────────────
318
+ #capKey(type) {
319
+ return `${this.#topicPrefix}tasks:cap:${type}`;
320
+ }
321
+ /**
322
+ * Admit a holder token into the type's fleet-cap lane. Returns `false`
323
+ * when the lane is full (caller releases the claim back to the stream)
324
+ * or when the broker errors — failing closed avoids over-admitting.
325
+ */
326
+ async #acquireCap(type, token, cap) {
327
+ const now = Date.now();
328
+ const expiry = now + this.#visibilityFor(type);
329
+ try {
330
+ const result = await this.#eval(CAP_ACQUIRE_SCRIPT, [this.#capKey(type)], [String(now), String(cap), token, String(expiry)]);
331
+ return result === 1;
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ async #releaseCap(type, token) {
338
+ try {
339
+ await this.#client.zRem(this.#capKey(type), token);
340
+ }
341
+ catch {
342
+ // Best-effort: a leaked holder expires via its ZSET score TTL.
343
+ }
344
+ }
345
+ async #refreshCap(type, token) {
346
+ const expiry = Date.now() + this.#visibilityFor(type);
347
+ try {
348
+ await this.#client.zAdd(this.#capKey(type), { score: expiry, value: token }, { XX: true });
349
+ }
350
+ catch {
351
+ // Best-effort: a missed refresh only risks an early reclaim, which
352
+ // the reclaim path already handles idempotently.
353
+ }
354
+ }
355
+ #streamsFor(subscription) {
356
+ const streams = new Set();
357
+ for (const type of subscription.types) {
358
+ const versions = subscription.compatibleVersions[type];
359
+ const expanded = versions && versions.length > 0 ? Array.from(versions) : [1];
360
+ for (const version of expanded) {
361
+ streams.add(this.#streamKey(type, version));
362
+ }
363
+ }
364
+ return Array.from(streams);
365
+ }
366
+ #visibilityFor(type) {
367
+ const override = this.#visibilityByType[type];
368
+ return typeof override === 'number' ? override : this.#defaultVisibilityMs;
369
+ }
370
+ async #ensureGroup(stream) {
371
+ if (this.#ensuredGroups.has(stream))
372
+ return;
373
+ try {
374
+ await this.#client.xGroupCreate(stream, this.#group, '$', { MKSTREAM: true });
375
+ }
376
+ catch (error) {
377
+ // BUSYGROUP = group already exists (idempotent setup).
378
+ if (!isBusyGroupError(error))
379
+ throw error;
380
+ }
381
+ this.#ensuredGroups.add(stream);
382
+ }
383
+ async #runSlot(state, consumerName, signal) {
384
+ const { subscription, streams } = state;
385
+ // Block until the dedicated read connection is live before issuing any
386
+ // blocking command on it.
387
+ await this.#readConnectPromise;
388
+ while (!signal.aborted && !this.#disposed) {
389
+ if (subscription.shouldDrain())
390
+ return;
391
+ // 1. Try to reclaim any stale PEL entries (broker-side reclaim).
392
+ const reclaimed = await this.#tryReclaim(streams, consumerName, signal);
393
+ if (reclaimed) {
394
+ await this.#deliver(state, reclaimed.stream, reclaimed.entry, consumerName, signal);
395
+ continue;
396
+ }
397
+ if (signal.aborted || this.#disposed)
398
+ return;
399
+ if (subscription.shouldDrain())
400
+ return;
401
+ // 2. Read fresh entries.
402
+ const fresh = await this.#tryRead(streams, consumerName);
403
+ if (!fresh) {
404
+ // Broker error — short backoff to avoid spin.
405
+ await sleep(this.#retryBackoffMs, signal);
406
+ continue;
407
+ }
408
+ if (fresh.entry) {
409
+ await this.#deliver(state, fresh.stream, fresh.entry, consumerName, signal);
410
+ }
411
+ }
412
+ }
413
+ async #tryReclaim(streams, consumerName, signal) {
414
+ for (const stream of streams) {
415
+ if (signal.aborted || this.#disposed)
416
+ return undefined;
417
+ const minIdle = this.#visibilityForStream(stream);
418
+ let reply;
419
+ try {
420
+ reply = await this.#readClient.xAutoClaim(stream, this.#group, consumerName, minIdle, '0', {
421
+ COUNT: 1,
422
+ });
423
+ }
424
+ catch {
425
+ return undefined;
426
+ }
427
+ const entry = pickFirstEntry(reply?.messages);
428
+ if (entry)
429
+ return { stream, entry };
430
+ }
431
+ return undefined;
432
+ }
433
+ async #tryRead(streams, consumerName) {
434
+ let reply;
435
+ try {
436
+ reply = await this.#readClient.xReadGroup(this.#group, consumerName, streams.map((key) => ({ key, id: '>' })), { BLOCK: this.#blockMs, COUNT: 1 });
437
+ }
438
+ catch {
439
+ return undefined;
440
+ }
441
+ if (!reply || reply.length === 0)
442
+ return { stream: streams[0] ?? '', entry: undefined };
443
+ const first = reply[0];
444
+ if (!first)
445
+ return { stream: streams[0] ?? '', entry: undefined };
446
+ const entry = first.messages[0];
447
+ return { stream: first.name, entry };
448
+ }
449
+ async #deliver(state, stream, entry, consumerName, signal) {
450
+ const { taskId } = entry.message;
451
+ const { type } = entry.message;
452
+ if (typeof taskId !== 'string' || typeof type !== 'string') {
453
+ // Malformed entry — drop it. Without a taskId we can't route it,
454
+ // so leaving it in the PEL would block the consumer forever.
455
+ try {
456
+ await this.#client.xAck(stream, this.#group, entry.id);
457
+ }
458
+ catch {
459
+ // Swallow — the autoclaim loop will retry on the next cycle.
460
+ }
461
+ return;
462
+ }
463
+ const limit = this.#concurrencyLimits[type];
464
+ const capToken = `${consumerName}:${entry.id}`;
465
+ if (limit !== undefined) {
466
+ const acquired = await this.#acquireCap(type, capToken, limit);
467
+ if (!acquired) {
468
+ // Lane at fleet cap: release the entry back to the stream tail and
469
+ // back off so this slot does not hot-loop re-reading it. Ordering
470
+ // churn is the accepted trade-off of FIFO + fleet cap (PRD §11).
471
+ try {
472
+ await this.#client.xAck(stream, this.#group, entry.id);
473
+ await this.#client.xAdd(stream, '*', entry.message);
474
+ }
475
+ catch {
476
+ // Ack/re-add failed: entry stays in PEL, XAUTOCLAIM recovers it.
477
+ }
478
+ await sleep(this.#retryBackoffMs, signal);
479
+ return;
480
+ }
481
+ }
482
+ const claim = {
483
+ taskId,
484
+ type,
485
+ receipt: {
486
+ stream,
487
+ msgId: entry.id,
488
+ consumer: consumerName,
489
+ deliveryCount: 0,
490
+ },
491
+ };
492
+ try {
493
+ let outcome;
494
+ try {
495
+ outcome = await state.subscription.onClaim(claim);
496
+ }
497
+ catch {
498
+ outcome = { kind: 'requeue' };
499
+ }
500
+ try {
501
+ if (outcome.kind === 'requeue') {
502
+ await this.#client.xAck(stream, this.#group, entry.id);
503
+ await this.#client.xAdd(stream, '*', entry.message);
504
+ }
505
+ else {
506
+ await this.#client.xAck(stream, this.#group, entry.id);
507
+ }
508
+ }
509
+ catch {
510
+ // Ack failure leaves the entry in PEL; the next XAUTOCLAIM cycle
511
+ // will surface it for re-delivery once the visibility timeout
512
+ // expires. Better than silently leaking the message.
513
+ }
514
+ }
515
+ finally {
516
+ // Release the fleet-cap slot on every outcome — including
517
+ // `suspended` (awaitChildren frees the worker slot, so it must
518
+ // free the lane too, else a deep DAG deadlocks its own lane).
519
+ if (limit !== undefined)
520
+ await this.#releaseCap(type, capToken);
521
+ }
522
+ }
523
+ #visibilityForStream(stream) {
524
+ // Stream key shape: `${prefix}tasks:queue:${type}:v${version}`.
525
+ // Slice to recover the type for per-type override lookup.
526
+ const marker = 'tasks:queue:';
527
+ const idx = stream.indexOf(marker);
528
+ if (idx === -1)
529
+ return this.#defaultVisibilityMs;
530
+ const tail = stream.slice(idx + marker.length);
531
+ const versionIdx = tail.lastIndexOf(':v');
532
+ const type = versionIdx === -1 ? tail : tail.slice(0, versionIdx);
533
+ return this.#visibilityFor(type);
534
+ }
535
+ #ensureLive() {
536
+ if (this.#disposed) {
537
+ throw new Error('RedisQueueAdapter has been disposed');
538
+ }
539
+ }
540
+ }
541
+ const isBusyGroupError = (error) => error instanceof Error && /BUSYGROUP/i.test(error.message);
542
+ const pickFirstEntry = (messages) => {
543
+ if (!messages)
544
+ return undefined;
545
+ for (const entry of messages) {
546
+ if (entry)
547
+ return entry;
548
+ }
549
+ return undefined;
550
+ };
551
+ const sleep = (ms, signal) => new Promise((resolve) => {
552
+ if (signal.aborted) {
553
+ resolve();
554
+ return;
555
+ }
556
+ const timer = setTimeout(() => {
557
+ signal.removeEventListener('abort', onAbort);
558
+ resolve();
559
+ }, ms);
560
+ const onAbort = () => {
561
+ clearTimeout(timer);
562
+ resolve();
563
+ };
564
+ signal.addEventListener('abort', onAbort, { once: true });
565
+ });
566
+ //# sourceMappingURL=redis-queue-adapter.js.map