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