@hile/redis-stream-queue 3.0.1 → 3.0.4

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/AI.md ADDED
@@ -0,0 +1,303 @@
1
+ # AI Guide For @hile/redis-stream-queue
2
+
3
+
4
+
5
+ <!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
6
+
7
+
8
+
9
+ Purpose: Persist and process background work with Redis Streams, retries, delayed jobs, pending recovery, and DLQ.
10
+
11
+
12
+
13
+ Use this file when an AI agent installs the npm package and needs package-local examples, package selection rules, boundaries, and verification steps.
14
+
15
+
16
+
17
+ ## Package Selection
18
+
19
+
20
+
21
+ | User asks for | Use | Also read |
22
+ |---|---|---|
23
+ | Persist and retry background jobs | `@hile/redis-stream-queue` | `packages/redis-reliability.md`, `recipes/queue-worker-idempotency.md` |
24
+
25
+
26
+
27
+ # Redis Reliability
28
+
29
+ Packages: `@hile/redis-lock`, `@hile/redis-idempotency`, `@hile/redis-rate-limit`, `@hile/redis-stream-queue`.
30
+
31
+ ## Copy-Paste Example
32
+
33
+ Idempotent queue worker:
34
+
35
+ ```ts
36
+ import { loadService } from '@hile/core'
37
+ import redisService from '@hile/ioredis'
38
+ import { RedisIdempotency, stableHash } from '@hile/redis-idempotency'
39
+ import { RedisStreamQueue, defineQueue } from '@hile/redis-stream-queue'
40
+
41
+ type EmailJob = { tenantId: string; userId: string; template: 'welcome' }
42
+
43
+ const emailQueue = defineQueue<EmailJob>('email')
44
+
45
+ const redis = await loadService(redisService)
46
+ const queue = new RedisStreamQueue(redis, { prefix: 'app:' })
47
+ const idempotency = new RedisIdempotency(redis)
48
+
49
+ queue.worker(emailQueue, async (job) => {
50
+ await idempotency.run(
51
+ `idem:email:${job.data.tenantId}:${job.jobId ?? job.id}`,
52
+ () => sendEmail(job.data),
53
+ {
54
+ lockTtl: 60_000,
55
+ resultTtl: 86_400_000,
56
+ fingerprint: stableHash(job.data),
57
+ },
58
+ )
59
+ }, {
60
+ group: 'email-workers',
61
+ consumer: process.env.HOSTNAME ?? `${process.pid}`,
62
+ concurrency: 8,
63
+ }).start()
64
+ ```
65
+
66
+ ## More Examples
67
+
68
+ Distributed lock with fencing:
69
+
70
+ ```ts
71
+ import { RedisLock } from '@hile/redis-lock'
72
+
73
+ const locks = new RedisLock(redis, {
74
+ prefix: 'billing:',
75
+ defaultTtl: 30_000,
76
+ })
77
+
78
+ await locks.withLock('lock:account:acct_1', {
79
+ wait: 2_000,
80
+ fencing: true,
81
+ renew: true,
82
+ }, async ({ fencingToken }) => {
83
+ await accountRepo.updateIfFencingTokenIsNewer('acct_1', changes, fencingToken!)
84
+ })
85
+ ```
86
+
87
+ Rate limit HTTP middleware:
88
+
89
+ ```ts
90
+ import { defineLimit, RedisRateLimiter, rateLimitHttp } from '@hile/redis-rate-limit'
91
+
92
+ const loginLimit = defineLimit('rl:login:{ip:string}', {
93
+ algorithm: 'sliding-window',
94
+ limit: 5,
95
+ window: 60_000,
96
+ })
97
+
98
+ const limiter = new RedisRateLimiter(redis, { prefix: 'app:' })
99
+
100
+ http.use(rateLimitHttp(loginLimit, {
101
+ limiter,
102
+ key: (ctx) => ({ ip: ctx.ip }),
103
+ }))
104
+ ```
105
+
106
+ Queue add with delayed retry policy:
107
+
108
+ ```ts
109
+ await queue.add(emailQueue, payload, {
110
+ jobId: `welcome:${payload.tenantId}:${payload.userId}`,
111
+ delay: 10_000,
112
+ maxAttempts: 5,
113
+ backoff: { type: 'exponential', baseMs: 1_000, maxMs: 60_000 },
114
+ })
115
+ ```
116
+
117
+ ## Use When
118
+
119
+ Use these packages for distributed lease locks, duplicate execution protection, shared quotas, and durable Redis Streams job processing.
120
+
121
+ ## Do Not Use When
122
+
123
+ - Do not claim exactly-once behavior.
124
+ - Do not use locks as a replacement for database constraints.
125
+ - Do not use queue `jobId` as the only idempotency boundary for side effects.
126
+ - Do not use rate limits as authorization or authentication.
127
+
128
+ ## Install
129
+
130
+ ```bash
131
+ pnpm add @hile/redis-lock @hile/redis-idempotency @hile/redis-rate-limit @hile/redis-stream-queue
132
+ ```
133
+
134
+ ## Imports
135
+
136
+ ```ts
137
+ import { RedisLock } from '@hile/redis-lock'
138
+ import { RedisIdempotency, stableHash, withIdempotency, idempotent } from '@hile/redis-idempotency'
139
+ import { RedisRateLimiter, defineLimit, rateLimitHttp, rateLimitModel } from '@hile/redis-rate-limit'
140
+ import { RedisStreamQueue, defineQueue } from '@hile/redis-stream-queue'
141
+ ```
142
+
143
+ ## Compose With
144
+
145
+ - Use `@hile/ioredis` for the Redis client.
146
+ - Use `idempotent()` and `rateLimitModel()` in `@hile/model` pipelines.
147
+ - Use `@hile/context` with queues; active context is snapshotted during enqueue and restored in handlers.
148
+ - Use `@hile/cache` singleflight to reduce stampedes.
149
+
150
+ ## Runtime And Lifecycle Notes
151
+
152
+ - `RedisLock.withLock()` asserts ownership before returning a successful callback result.
153
+ - `tryLock()` returns `undefined` if the key is already locked.
154
+ - `RedisIdempotency.run()` stores `IN_FLIGHT` and `DONE` states in Redis and uses Redis locks for ownership.
155
+ - Idempotency requires a stable `fingerprint`.
156
+ - `stableHash()` is for plain DTOs and rejects unsupported shapes.
157
+ - Rate limit algorithms: `fixed-window`, `sliding-window`, `token-bucket`.
158
+ - `dryRun` rate limit checks do not mutate Redis.
159
+ - Queue workers use Redis Streams consumer groups, delayed sorted sets, pending claim recovery, retry backoff, and DLQ streams.
160
+ - Queue payload schema can expose `parse()` or `safeParse()`.
161
+
162
+ ## Anti-Patterns
163
+
164
+ - Random UUID per retry as idempotency key.
165
+ - Returning `Date`, `BigInt`, or class instances from idempotent functions without a `resultCodec`.
166
+ - Using `stream()` or RPC retries without idempotency around irreversible handlers.
167
+ - Ignoring DLQ after max attempts.
168
+
169
+ ## Verification Checklist
170
+
171
+ - Lock TTL is longer than normal critical-section time.
172
+ - Fencing is enabled when stale owners can write to external resources.
173
+ - Idempotency keys use business identifiers.
174
+ - Queue handlers are idempotent or wrapped in idempotency.
175
+ - Rate limit `Retry-After` is exposed in seconds for HTTP.
176
+ - DLQ is monitored.
177
+
178
+
179
+
180
+ # Related Recipes
181
+
182
+
183
+
184
+ # Queue Worker With Idempotency
185
+
186
+ ## Complete Example
187
+
188
+ ```ts
189
+ // src/services/email-worker.boot.ts
190
+ import { defineService, loadService } from '@hile/core'
191
+ import redisService from '@hile/ioredis'
192
+ import { RedisIdempotency, stableHash } from '@hile/redis-idempotency'
193
+ import { RedisStreamQueue, defineQueue } from '@hile/redis-stream-queue'
194
+
195
+ type EmailPayload = {
196
+ tenantId: string
197
+ userId: string
198
+ template: 'welcome'
199
+ }
200
+
201
+ const emailQueue = defineQueue<EmailPayload>('email')
202
+
203
+ export default defineService('email.worker', async (shutdown) => {
204
+ const redis = await loadService(redisService)
205
+ const queue = new RedisStreamQueue(redis, { prefix: 'app:' })
206
+ const idempotency = new RedisIdempotency(redis)
207
+
208
+ const worker = queue.worker(emailQueue, async (job) => {
209
+ await idempotency.run(
210
+ `idem:email:${job.data.tenantId}:${job.jobId ?? job.id}`,
211
+ () => sendEmail(job.data),
212
+ {
213
+ lockTtl: 60_000,
214
+ resultTtl: 86_400_000,
215
+ fingerprint: stableHash(job.data),
216
+ },
217
+ )
218
+ }, {
219
+ group: 'email-workers',
220
+ consumer: process.env.HOSTNAME ?? `${process.pid}`,
221
+ concurrency: 8,
222
+ claimIdle: 60_000,
223
+ })
224
+
225
+ worker.start()
226
+ shutdown(() => worker.stop())
227
+
228
+ return { queue, worker }
229
+ })
230
+ ```
231
+
232
+ Enqueue:
233
+
234
+ ```ts
235
+ await queue.add(emailQueue, {
236
+ tenantId: 't1',
237
+ userId: 'u1',
238
+ template: 'welcome',
239
+ }, {
240
+ jobId: 'welcome:t1:u1',
241
+ maxAttempts: 5,
242
+ backoff: { type: 'exponential', baseMs: 1_000, maxMs: 60_000 },
243
+ })
244
+ ```
245
+
246
+ ## File Layout
247
+
248
+ ```text
249
+ src/
250
+ services/email-worker.boot.ts
251
+ queues/email.queue.ts
252
+ ```
253
+
254
+ ## User Intent
255
+
256
+ Use this recipe for at-least-once background work where side effects must survive retries safely.
257
+
258
+ ## Packages To Use
259
+
260
+ - `@hile/redis-stream-queue`
261
+ - `@hile/redis-idempotency`
262
+ - `@hile/ioredis`
263
+ - `@hile/core`
264
+
265
+ ## Implementation Steps
266
+
267
+ 1. Define the queue and payload type.
268
+ 2. Enqueue with a stable `jobId`.
269
+ 3. Wrap side effects with `RedisIdempotency.run()`.
270
+ 4. Use business identifiers in idempotency keys.
271
+ 5. Monitor DLQ with `readDeadLetters()`.
272
+
273
+ ## Failure And Cleanup Behavior
274
+
275
+ - Queue delivery is at-least-once.
276
+ - Failed attempts retry until `maxAttempts`, then move to DLQ.
277
+ - `jobId` dedupes enqueue, not side effects.
278
+ - Idempotency caches successful results but is not exactly-once.
279
+
280
+ ## Verification Checklist
281
+
282
+ - Handler is idempotent.
283
+ - Idempotency key is business-derived.
284
+ - Worker stop is registered with `shutdown`.
285
+ - DLQ read path exists.
286
+
287
+
288
+
289
+ # Global Guardrails
290
+
291
+
292
+
293
+ ## Never Generate These Patterns
294
+
295
+ - Do not call `loadService()` at module top level; it starts resources during import.
296
+ - Do not default-export plain functions from `*.boot.*` files; `hile start` expects a Hile service.
297
+ - Do not set `ctx.body` and also return a controller value.
298
+ - Do not assume `@hile/http` Zod validation mutates or coerces `ctx.query`, `ctx.params`, or `ctx.request.body`.
299
+ - Do not put reusable business logic only in controllers, pages, queue workers, or message handlers.
300
+ - Do not use old message examples that append a secondary response getter; current request APIs return promises directly.
301
+ - Do not claim exactly-once delivery or execution from Redis locks, queues, idempotency, or rate limits.
302
+ - Do not use queue `jobId` as the only side-effect idempotency boundary.
303
+ - Do not log the entire async context by default.
package/README.md CHANGED
@@ -1,167 +1,79 @@
1
1
  # @hile/redis-stream-queue
2
2
 
3
- Redis Streams backed durable job queue primitives for Hile applications.
3
+ <!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
4
4
 
5
- Use this package when work should not block the current request but still needs to be persisted, retried, recovered after worker crashes, and inspected when it finally fails.
5
+ Persist and process background work with Redis Streams, retries, delayed jobs, pending recovery, and DLQ.
6
+
7
+ This README is intentionally short and example-first. The complete AI-facing guide ships in `AI.md` in this package.
8
+
9
+ ## When To Use
10
+
11
+ Use these packages for distributed lease locks, duplicate execution protection, shared quotas, and durable Redis Streams job processing.
6
12
 
7
13
  ## Install
8
14
 
9
15
  ```bash
10
- pnpm add @hile/redis-stream-queue @hile/ioredis
16
+ pnpm add @hile/redis-stream-queue
11
17
  ```
12
18
 
13
- The package accepts any Redis client matching `RedisStreamQueueLike`. In Hile apps the usual client comes from `@hile/ioredis`.
19
+ ## Copy-Paste Example
14
20
 
15
- ## Quick Start
21
+ Idempotent queue worker:
16
22
 
17
- ```typescript
23
+ ```ts
24
+ import { loadService } from '@hile/core'
25
+ import redisService from '@hile/ioredis'
26
+ import { RedisIdempotency, stableHash } from '@hile/redis-idempotency'
18
27
  import { RedisStreamQueue, defineQueue } from '@hile/redis-stream-queue'
19
28
 
20
- type EmailPayload = {
21
- template: 'welcome'
22
- userId: string
23
- }
29
+ type EmailJob = { tenantId: string; userId: string; template: 'welcome' }
24
30
 
25
- const emailQueue = defineQueue<EmailPayload>('email')
26
- const queue = new RedisStreamQueue(redis, { prefix: 'myapp:' })
31
+ const emailQueue = defineQueue<EmailJob>('email')
27
32
 
28
- await queue.add(emailQueue, {
29
- template: 'welcome',
30
- userId: 'user-1',
31
- }, {
32
- jobId: 'welcome:user-1',
33
- maxAttempts: 5,
34
- backoff: { type: 'exponential', baseMs: 1_000 },
35
- })
33
+ const redis = await loadService(redisService)
34
+ const queue = new RedisStreamQueue(redis, { prefix: 'app:' })
35
+ const idempotency = new RedisIdempotency(redis)
36
36
 
37
37
  queue.worker(emailQueue, async (job) => {
38
- await sendEmail(job.data)
38
+ await idempotency.run(
39
+ `idem:email:${job.data.tenantId}:${job.jobId ?? job.id}`,
40
+ () => sendEmail(job.data),
41
+ {
42
+ lockTtl: 60_000,
43
+ resultTtl: 86_400_000,
44
+ fingerprint: stableHash(job.data),
45
+ },
46
+ )
39
47
  }, {
40
48
  group: 'email-workers',
41
- consumer: 'worker-1',
49
+ consumer: process.env.HOSTNAME ?? `${process.pid}`,
42
50
  concurrency: 8,
43
51
  }).start()
44
52
  ```
45
53
 
46
- ## Core Concepts
47
-
48
- ### defineQueue(name, schema?)
49
-
50
- `defineQueue()` declares the queue name and optional payload schema.
51
-
52
- ```typescript
53
- const imageQueue = defineQueue('image-resize', {
54
- parse(value) {
55
- if (typeof value !== 'object' || value === null) throw new Error('invalid payload')
56
- return value as { imageId: string; size: 'small' | 'large' }
57
- },
58
- })
59
- ```
60
-
61
- The schema can expose `parse(value)` or `safeParse(value)`. Payloads are validated before enqueueing and again when workers read them.
62
-
63
- ### queue.add(queue, payload, options?)
64
-
65
- Adds a durable job.
66
-
67
- ```typescript
68
- await queue.add(emailQueue, payload, {
69
- jobId: 'welcome:user-1',
70
- delay: 30_000,
71
- maxAttempts: 5,
72
- backoff: { type: 'fixed', delay: 5_000 },
73
- })
74
- ```
75
-
76
- Options:
77
-
78
- | Option | Meaning |
79
- |---|---|
80
- | `jobId` | Deduplication key. A second add with the same `jobId` returns `{ duplicate: true }`. |
81
- | `delay` | Milliseconds before the job becomes visible to workers. |
82
- | `maxAttempts` | Maximum attempts before the job goes to DLQ. Defaults to `1`. |
83
- | `backoff` | Retry delay. Use a number, `{ type: 'fixed', delay }`, or `{ type: 'exponential', baseMs, maxMs }`. |
84
-
85
- ### queue.worker(queue, handler, options?)
86
-
87
- Creates a worker around a Redis consumer group.
88
-
89
- ```typescript
90
- const worker = queue.worker(emailQueue, async (job) => {
91
- await sendEmail(job.data)
92
- }, {
93
- group: 'email-workers',
94
- consumer: process.env.HOSTNAME ?? 'local',
95
- concurrency: 4,
96
- claimIdle: 60_000,
97
- })
98
-
99
- worker.start()
100
- ```
101
-
102
- `runOnce()` is available for tests and controlled scripts:
103
-
104
- ```typescript
105
- await worker.runOnce()
106
- await worker.stop()
107
- ```
108
-
109
- ### readDeadLetters(queue)
110
-
111
- Reads failed jobs from the dead-letter stream:
112
-
113
- ```typescript
114
- const failed = await queue.readDeadLetters(emailQueue, { count: 20 })
115
- ```
116
-
117
- ## State Machine
118
-
119
- | State | Redis structure | Meaning |
120
- |---|---|---|
121
- | `scheduled` | sorted set `{prefix}queue:{name}:delayed` | Job has a future `runAt`. |
122
- | `ready` | stream `{prefix}queue:{name}:stream` | Job is visible to the consumer group. |
123
- | `pending` | Redis Streams PEL | A worker received the job but has not acked it yet. |
124
- | `retrying` | delayed sorted set | A failed job is waiting for backoff before another attempt. |
125
- | `done` | acked stream entry | Handler completed and the pending entry was acked. |
126
- | `dead-lettered` | stream `{prefix}queue:{name}:dlq` | Attempts were exhausted. |
127
-
128
- ## Crash Recovery
129
-
130
- Workers use Redis consumer groups. If a worker reads a job and dies before `XACK`, the job remains in the pending entries list. Another worker can claim it after `claimIdle` milliseconds and continue processing.
131
-
132
- ## Context Propagation
133
-
134
- If `@hile/context` has an active context when a job is enqueued, the queue stores a snapshot and restores it while the worker handler runs.
135
-
136
- ```typescript
137
- await runWithContext<AppContext>({ shopId: 'shop-1' }, async () => {
138
- await queue.add(emailQueue, payload)
139
- })
140
- ```
141
-
142
- ```typescript
143
- queue.worker(emailQueue, async () => {
144
- const context = getContext<AppContext>()
145
- console.log(context.shopId)
146
- })
147
- ```
148
-
149
54
  ## Boundaries
150
55
 
151
- - This package is for background jobs, not request/response RPC.
152
- - It provides at-least-once delivery. Handlers must be idempotent.
153
- - It does not promise exactly-once execution.
154
- - Delayed jobs and retries are promoted when workers poll the queue.
155
- - Payload and context values must be JSON-serializable.
156
- - `jobId` prevents duplicate enqueue for the same key; it does not make the handler side effect exactly-once.
56
+ - Do not claim exactly-once behavior.
57
+ - Do not use locks as a replacement for database constraints.
58
+ - Do not use queue `jobId` as the only idempotency boundary for side effects.
59
+ - Do not use rate limits as authorization or authentication.
157
60
 
158
- ## Testing
61
+ - Random UUID per retry as idempotency key.
62
+ - Returning `Date`, `BigInt`, or class instances from idempotent functions without a `resultCodec`.
63
+ - Using `stream()` or RPC retries without idempotency around irreversible handlers.
64
+ - Ignoring DLQ after max attempts.
159
65
 
160
- ```bash
161
- pnpm --filter @hile/redis-stream-queue test
162
- pnpm --filter @hile/redis-stream-queue build
163
- ```
66
+ ## Verify
67
+
68
+ - Lock TTL is longer than normal critical-section time.
69
+ - Fencing is enabled when stale owners can write to external resources.
70
+ - Idempotency keys use business identifiers.
71
+ - Queue handlers are idempotent or wrapped in idempotency.
72
+ - Rate limit `Retry-After` is exposed in seconds for HTTP.
73
+ - DLQ is monitored.
164
74
 
165
- ## License
75
+ ## More Context
166
76
 
167
- MIT
77
+ - `AI.md` in this package: full package-local AI guide.
78
+ - Root `llms-full.txt`: full monorepo AI context.
79
+ - Root `references/`: source files copied from `docs/ai`.
package/dist/queue.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare class RedisStreamQueue {
9
9
  readDeadLetters<TData>(definition: QueueDefinition<TData>, options?: ReadDeadLettersOptions): Promise<Array<QueueDeadLetter<TData>>>;
10
10
  runWorkerOnce<TData>(definition: QueueDefinition<TData>, handler: QueueWorkerHandler<TData>, options: RequiredWorkerOptions): Promise<number>;
11
11
  private processEntry;
12
+ private acknowledge;
12
13
  private handleFailure;
13
14
  private createJob;
14
15
  private promoteDelayed;
@@ -41,7 +42,10 @@ type RequiredWorkerOptions = {
41
42
  concurrency: number;
42
43
  block: number;
43
44
  pollInterval: number;
45
+ errorRetryInterval: number;
44
46
  claimIdle: number;
45
47
  claimCount: number;
48
+ removeOnAck: boolean;
49
+ onError?: (err: unknown) => void | Promise<void>;
46
50
  };
47
51
  export {};
package/dist/queue.js CHANGED
@@ -6,7 +6,16 @@ const DEFAULT_MAX_ATTEMPTS = 1;
6
6
  const DEFAULT_CONCURRENCY = 1;
7
7
  const DEFAULT_CLAIM_IDLE = 60_000;
8
8
  const DEFAULT_POLL_INTERVAL = 1_000;
9
+ const DEFAULT_ERROR_RETRY_INTERVAL = 1_000;
9
10
  const DEFAULT_READ_BLOCK = 0;
11
+ const PROMOTE_DELAYED_SCRIPT = `
12
+ if redis.call('ZSCORE', KEYS[1], ARGV[1]) == false then
13
+ return 0
14
+ end
15
+ local streamId = redis.call('XADD', KEYS[2], '*', 'job', ARGV[1])
16
+ redis.call('ZREM', KEYS[1], ARGV[1])
17
+ return streamId
18
+ `;
10
19
  export class RedisStreamQueue {
11
20
  redis;
12
21
  prefix;
@@ -128,13 +137,20 @@ export class RedisStreamQueue {
128
137
  else {
129
138
  await run();
130
139
  }
131
- await this.redis.xack(this.streamKey(definition), options.group, streamId);
132
140
  }
133
141
  catch (err) {
134
- await this.handleFailure(definition, stored, streamId, attempt, options.group, err);
142
+ await this.handleFailure(definition, stored, streamId, attempt, options, err);
143
+ return;
135
144
  }
145
+ await this.acknowledge(definition, options, streamId);
136
146
  }
137
- async handleFailure(definition, stored, streamId, attempt, group, err) {
147
+ async acknowledge(definition, options, streamId) {
148
+ await this.redis.xack(this.streamKey(definition), options.group, streamId);
149
+ if (options.removeOnAck) {
150
+ await this.redis.xdel(this.streamKey(definition), streamId);
151
+ }
152
+ }
153
+ async handleFailure(definition, stored, streamId, attempt, options, err) {
138
154
  const reason = errorReason(err);
139
155
  const failed = {
140
156
  ...stored,
@@ -150,7 +166,7 @@ export class RedisStreamQueue {
150
166
  else {
151
167
  await this.redis.xadd(this.deadLetterKey(definition), '*', 'job', this.encodeJob(definition, failed));
152
168
  }
153
- await this.redis.xack(this.streamKey(definition), group, streamId);
169
+ await this.acknowledge(definition, options, streamId);
154
170
  }
155
171
  createJob(definition, stored, streamId, attempt) {
156
172
  return {
@@ -170,11 +186,9 @@ export class RedisStreamQueue {
170
186
  const members = await this.redis.zrangebyscore(this.delayedKey(definition), '-inf', this.now(), 'LIMIT', 0, limit);
171
187
  let promoted = 0;
172
188
  for (const member of members) {
173
- const removed = await this.redis.zrem(this.delayedKey(definition), member);
174
- if (removed === 0)
175
- continue;
176
- await this.redis.xadd(this.streamKey(definition), '*', 'job', member);
177
- promoted++;
189
+ const streamId = await this.redis.eval(PROMOTE_DELAYED_SCRIPT, 2, this.delayedKey(definition), this.streamKey(definition), member);
190
+ if (streamId !== 0)
191
+ promoted++;
178
192
  }
179
193
  return promoted;
180
194
  }
@@ -276,9 +290,22 @@ export class RedisStreamQueueWorker {
276
290
  }
277
291
  async runLoop() {
278
292
  while (this.running) {
279
- const processed = await this.runOnce();
280
- if (processed === 0) {
281
- await sleep(this.options.pollInterval);
293
+ try {
294
+ const processed = await this.runOnce();
295
+ if (processed === 0) {
296
+ await sleep(this.options.pollInterval);
297
+ }
298
+ }
299
+ catch (err) {
300
+ try {
301
+ await this.options.onError?.(err);
302
+ }
303
+ catch {
304
+ // Error observers must not stop a worker that is already recovering.
305
+ }
306
+ if (this.running) {
307
+ await sleep(this.options.errorRetryInterval);
308
+ }
282
309
  }
283
310
  }
284
311
  }
@@ -291,8 +318,11 @@ function normalizeWorkerOptions(definition, options) {
291
318
  concurrency,
292
319
  block: assertNonNegativeInteger(options.block ?? DEFAULT_READ_BLOCK, 'block'),
293
320
  pollInterval: assertNonNegativeInteger(options.pollInterval ?? DEFAULT_POLL_INTERVAL, 'pollInterval'),
321
+ errorRetryInterval: assertNonNegativeInteger(options.errorRetryInterval ?? DEFAULT_ERROR_RETRY_INTERVAL, 'errorRetryInterval'),
294
322
  claimIdle: assertNonNegativeInteger(options.claimIdle ?? DEFAULT_CLAIM_IDLE, 'claimIdle'),
295
323
  claimCount: assertPositiveInteger(options.claimCount ?? concurrency, 'claimCount'),
324
+ removeOnAck: options.removeOnAck ?? false,
325
+ onError: options.onError,
296
326
  };
297
327
  }
298
328
  function normalizeBackoff(backoff) {
package/dist/types.d.ts CHANGED
@@ -66,8 +66,11 @@ export type QueueWorkerOptions = {
66
66
  concurrency?: number;
67
67
  block?: number;
68
68
  pollInterval?: number;
69
+ errorRetryInterval?: number;
69
70
  claimIdle?: number;
70
71
  claimCount?: number;
72
+ removeOnAck?: boolean;
73
+ onError?: (err: unknown) => void | Promise<void>;
71
74
  };
72
75
  export type RedisStreamEntry = [id: string, fields: string[]];
73
76
  export type RedisStreamReadResult = Array<[stream: string, entries: RedisStreamEntry[]]> | null;
@@ -79,10 +82,12 @@ export interface RedisStreamQueueLike {
79
82
  xpending(...args: any[]): Promise<any>;
80
83
  xclaim(...args: any[]): Promise<any>;
81
84
  xack(...args: any[]): Promise<any>;
85
+ xdel(...args: any[]): Promise<any>;
82
86
  xrange(...args: any[]): Promise<any>;
83
87
  zadd(...args: any[]): Promise<any>;
84
88
  zrangebyscore(...args: any[]): Promise<any>;
85
89
  zrem(...args: any[]): Promise<any>;
90
+ eval(...args: any[]): Promise<any>;
86
91
  set(...args: any[]): Promise<any>;
87
92
  get(...args: any[]): Promise<any>;
88
93
  del(...args: any[]): Promise<any>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/redis-stream-queue",
3
- "version": "3.0.1",
3
+ "version": "3.0.4",
4
4
  "description": "Redis Streams backed durable job queue primitives for Hile applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -12,7 +12,7 @@
12
12
  "files": [
13
13
  "dist",
14
14
  "README.md",
15
- "SKILL.md"
15
+ "AI.md"
16
16
  ],
17
17
  "license": "MIT",
18
18
  "publishConfig": {
@@ -24,7 +24,7 @@
24
24
  "vitest": "^4.0.18"
25
25
  },
26
26
  "dependencies": {
27
- "@hile/context": "^3.0.1"
27
+ "@hile/context": "^3.0.2"
28
28
  },
29
- "gitHead": "762f9476b1b53f91899b985fe5bf437b618bb224"
29
+ "gitHead": "f5d5970c964440047dda973d3f4eda92116ca809"
30
30
  }
package/SKILL.md DELETED
@@ -1,45 +0,0 @@
1
- ---
2
- name: redis-stream-queue
3
- description: Use when implementing Redis Streams backed durable job queues, background workers, retry/backoff, delayed jobs, consumer-group pending recovery, DLQ handling, or Hile queue context propagation.
4
- ---
5
-
6
- # Redis Stream Queue
7
-
8
- Use `@hile/redis-stream-queue` when work should be persisted and processed asynchronously by background workers.
9
-
10
- ## Core Rule
11
-
12
- This package provides at-least-once background job execution. Handlers must be idempotent; do not claim exactly-once delivery.
13
-
14
- ```typescript
15
- const emailQueue = defineQueue<EmailPayload>('email')
16
-
17
- await queue.add(emailQueue, payload, {
18
- jobId: `welcome:${payload.userId}`,
19
- maxAttempts: 5,
20
- backoff: { type: 'exponential', baseMs: 1_000 },
21
- })
22
-
23
- queue.worker(emailQueue, async (job) => {
24
- await sendEmail(job.data)
25
- }, { concurrency: 8 }).start()
26
- ```
27
-
28
- ## Design Boundaries
29
-
30
- - Use Redis Streams consumer groups for ready jobs.
31
- - Use the pending entries list plus `XCLAIM` for worker crash recovery.
32
- - Use a delayed sorted set for delayed jobs and retry backoff.
33
- - Move exhausted jobs to `{prefix}queue:{name}:dlq`.
34
- - Store job attempts, first/last failure reason, and context metadata.
35
- - Validate payloads through the queue schema before enqueueing and before handling.
36
- - `jobId` is enqueue dedupe only; side effects still need idempotency.
37
-
38
- ## Testing Priorities
39
-
40
- - Pending job claimed by another worker after `claimIdle`.
41
- - Failed job retries with configured backoff.
42
- - Exhausted job appears in DLQ.
43
- - Duplicate `jobId` enqueues only one job.
44
- - Worker concurrency caps simultaneous handlers.
45
- - Active `@hile/context` is restored inside handlers.