@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 +303 -0
- package/README.md +50 -138
- package/dist/queue.d.ts +4 -0
- package/dist/queue.js +42 -12
- package/dist/types.d.ts +5 -0
- package/package.json +4 -4
- package/SKILL.md +0 -45
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
|
-
|
|
3
|
+
<!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
16
|
+
pnpm add @hile/redis-stream-queue
|
|
11
17
|
```
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
## Copy-Paste Example
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
Idempotent queue worker:
|
|
16
22
|
|
|
17
|
-
```
|
|
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
|
|
21
|
-
template: 'welcome'
|
|
22
|
-
userId: string
|
|
23
|
-
}
|
|
29
|
+
type EmailJob = { tenantId: string; userId: string; template: 'welcome' }
|
|
24
30
|
|
|
25
|
-
const emailQueue = defineQueue<
|
|
26
|
-
const queue = new RedisStreamQueue(redis, { prefix: 'myapp:' })
|
|
31
|
+
const emailQueue = defineQueue<EmailJob>('email')
|
|
27
32
|
|
|
28
|
-
await
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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:
|
|
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
|
-
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
154
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
##
|
|
75
|
+
## More Context
|
|
166
76
|
|
|
167
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
27
|
+
"@hile/context": "^3.0.2"
|
|
28
28
|
},
|
|
29
|
-
"gitHead": "
|
|
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.
|