@hile/redis-stream-queue 3.0.1
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 +167 -0
- package/SKILL.md +45 -0
- package/dist/define.d.ts +4 -0
- package/dist/define.js +28 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +25 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/queue.d.ts +47 -0
- package/dist/queue.js +348 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.js +1 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @hile/redis-stream-queue
|
|
2
|
+
|
|
3
|
+
Redis Streams backed durable job queue primitives for Hile applications.
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @hile/redis-stream-queue @hile/ioredis
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package accepts any Redis client matching `RedisStreamQueueLike`. In Hile apps the usual client comes from `@hile/ioredis`.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { RedisStreamQueue, defineQueue } from '@hile/redis-stream-queue'
|
|
19
|
+
|
|
20
|
+
type EmailPayload = {
|
|
21
|
+
template: 'welcome'
|
|
22
|
+
userId: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emailQueue = defineQueue<EmailPayload>('email')
|
|
26
|
+
const queue = new RedisStreamQueue(redis, { prefix: 'myapp:' })
|
|
27
|
+
|
|
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
|
+
})
|
|
36
|
+
|
|
37
|
+
queue.worker(emailQueue, async (job) => {
|
|
38
|
+
await sendEmail(job.data)
|
|
39
|
+
}, {
|
|
40
|
+
group: 'email-workers',
|
|
41
|
+
consumer: 'worker-1',
|
|
42
|
+
concurrency: 8,
|
|
43
|
+
}).start()
|
|
44
|
+
```
|
|
45
|
+
|
|
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
|
+
## Boundaries
|
|
150
|
+
|
|
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.
|
|
157
|
+
|
|
158
|
+
## Testing
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pnpm --filter @hile/redis-stream-queue test
|
|
162
|
+
pnpm --filter @hile/redis-stream-queue build
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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.
|
package/dist/define.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { InferQueueSchema, QueueDefinition, QueueSchema } from './types';
|
|
2
|
+
export declare function defineQueue<const TName extends string, TSchema extends QueueSchema<any>>(name: TName, schema: TSchema): QueueDefinition<InferQueueSchema<TSchema>, TName>;
|
|
3
|
+
export declare function defineQueue<TData = unknown, const TName extends string = string>(name: TName): QueueDefinition<TData, TName>;
|
|
4
|
+
export declare function parsePayload<TData>(definition: QueueDefinition<TData>, payload: unknown): TData;
|
package/dist/define.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { QueueSchemaError } from './errors.js';
|
|
2
|
+
const VALID_QUEUE_NAME = /^[a-zA-Z0-9:_-]+$/;
|
|
3
|
+
export function defineQueue(name, schema) {
|
|
4
|
+
if (!VALID_QUEUE_NAME.test(name)) {
|
|
5
|
+
throw new TypeError('Queue name must contain only letters, numbers, ":", "_" or "-"');
|
|
6
|
+
}
|
|
7
|
+
return schema ? { name, schema } : { name };
|
|
8
|
+
}
|
|
9
|
+
export function parsePayload(definition, payload) {
|
|
10
|
+
const schema = definition.schema;
|
|
11
|
+
if (!schema)
|
|
12
|
+
return payload;
|
|
13
|
+
try {
|
|
14
|
+
if ('parse' in schema && typeof schema.parse === 'function') {
|
|
15
|
+
return schema.parse(payload);
|
|
16
|
+
}
|
|
17
|
+
if (!('safeParse' in schema) || typeof schema.safeParse !== 'function') {
|
|
18
|
+
throw new TypeError('Queue schema must expose parse() or safeParse()');
|
|
19
|
+
}
|
|
20
|
+
const result = schema.safeParse(payload);
|
|
21
|
+
if (result.success)
|
|
22
|
+
return result.data;
|
|
23
|
+
throw result.error;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
throw new QueueSchemaError(definition.name, err);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class QueueError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class QueueSchemaError extends QueueError {
|
|
5
|
+
readonly queue: string;
|
|
6
|
+
readonly cause: unknown;
|
|
7
|
+
constructor(queue: string, cause: unknown);
|
|
8
|
+
}
|
|
9
|
+
export declare class QueueSerializationError extends QueueError {
|
|
10
|
+
readonly queue: string;
|
|
11
|
+
readonly cause: unknown;
|
|
12
|
+
constructor(queue: string, cause: unknown);
|
|
13
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class QueueError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = new.target.name;
|
|
5
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class QueueSchemaError extends QueueError {
|
|
9
|
+
queue;
|
|
10
|
+
cause;
|
|
11
|
+
constructor(queue, cause) {
|
|
12
|
+
super(`Invalid payload for queue "${queue}"`);
|
|
13
|
+
this.queue = queue;
|
|
14
|
+
this.cause = cause;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class QueueSerializationError extends QueueError {
|
|
18
|
+
queue;
|
|
19
|
+
cause;
|
|
20
|
+
constructor(queue, cause) {
|
|
21
|
+
super(`Queue job for "${queue}" must be JSON-serializable`);
|
|
22
|
+
this.queue = queue;
|
|
23
|
+
this.cause = cause;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { QueueError, QueueSchemaError, QueueSerializationError, } from './errors.js';
|
|
2
|
+
export { defineQueue, } from './define.js';
|
|
3
|
+
export { RedisStreamQueue, RedisStreamQueueWorker, } from './queue.js';
|
|
4
|
+
export type { InferQueueSchema, QueueAddOptions, QueueAddResult, QueueBackoff, QueueDeadLetter, QueueDefinition, QueueJob, QueueSafeParseResult, QueueSchema, QueueWorkerHandler, QueueWorkerOptions, ReadDeadLettersOptions, RedisPendingEntry, RedisStreamEntry, RedisStreamQueueLike, RedisStreamQueueOptions, RedisStreamReadResult, } from './types.js';
|
package/dist/index.js
ADDED
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { QueueAddOptions, QueueAddResult, QueueDeadLetter, QueueDefinition, QueueWorkerHandler, QueueWorkerOptions, ReadDeadLettersOptions, RedisStreamQueueLike, RedisStreamQueueOptions } from './types';
|
|
2
|
+
export declare class RedisStreamQueue {
|
|
3
|
+
private readonly redis;
|
|
4
|
+
private readonly prefix;
|
|
5
|
+
private readonly now;
|
|
6
|
+
constructor(redis: RedisStreamQueueLike, options?: RedisStreamQueueOptions);
|
|
7
|
+
add<TData>(definition: QueueDefinition<TData>, payload: TData, options?: QueueAddOptions): Promise<QueueAddResult>;
|
|
8
|
+
worker<TData>(definition: QueueDefinition<TData>, handler: QueueWorkerHandler<TData>, options?: QueueWorkerOptions): RedisStreamQueueWorker<TData>;
|
|
9
|
+
readDeadLetters<TData>(definition: QueueDefinition<TData>, options?: ReadDeadLettersOptions): Promise<Array<QueueDeadLetter<TData>>>;
|
|
10
|
+
runWorkerOnce<TData>(definition: QueueDefinition<TData>, handler: QueueWorkerHandler<TData>, options: RequiredWorkerOptions): Promise<number>;
|
|
11
|
+
private processEntry;
|
|
12
|
+
private handleFailure;
|
|
13
|
+
private createJob;
|
|
14
|
+
private promoteDelayed;
|
|
15
|
+
private claimStale;
|
|
16
|
+
private readNew;
|
|
17
|
+
private ensureGroup;
|
|
18
|
+
private decodeEntry;
|
|
19
|
+
private encodeJob;
|
|
20
|
+
private streamKey;
|
|
21
|
+
private delayedKey;
|
|
22
|
+
private deadLetterKey;
|
|
23
|
+
private dedupeKey;
|
|
24
|
+
}
|
|
25
|
+
export declare class RedisStreamQueueWorker<TData = unknown> {
|
|
26
|
+
private readonly queue;
|
|
27
|
+
private readonly definition;
|
|
28
|
+
private readonly handler;
|
|
29
|
+
private readonly options;
|
|
30
|
+
private running;
|
|
31
|
+
private loop?;
|
|
32
|
+
constructor(queue: RedisStreamQueue, definition: QueueDefinition<TData>, handler: QueueWorkerHandler<TData>, options: QueueWorkerOptions);
|
|
33
|
+
runOnce(): Promise<number>;
|
|
34
|
+
start(): this;
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
private runLoop;
|
|
37
|
+
}
|
|
38
|
+
type RequiredWorkerOptions = {
|
|
39
|
+
group: string;
|
|
40
|
+
consumer: string;
|
|
41
|
+
concurrency: number;
|
|
42
|
+
block: number;
|
|
43
|
+
pollInterval: number;
|
|
44
|
+
claimIdle: number;
|
|
45
|
+
claimCount: number;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isContextData, runWithContext, snapshotContext, } from '@hile/context';
|
|
3
|
+
import { QueueSerializationError } from './errors.js';
|
|
4
|
+
import { parsePayload } from './define.js';
|
|
5
|
+
const DEFAULT_MAX_ATTEMPTS = 1;
|
|
6
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
7
|
+
const DEFAULT_CLAIM_IDLE = 60_000;
|
|
8
|
+
const DEFAULT_POLL_INTERVAL = 1_000;
|
|
9
|
+
const DEFAULT_READ_BLOCK = 0;
|
|
10
|
+
export class RedisStreamQueue {
|
|
11
|
+
redis;
|
|
12
|
+
prefix;
|
|
13
|
+
now;
|
|
14
|
+
constructor(redis, options = {}) {
|
|
15
|
+
this.redis = redis;
|
|
16
|
+
this.prefix = options.prefix ?? '';
|
|
17
|
+
this.now = options.now ?? Date.now;
|
|
18
|
+
}
|
|
19
|
+
async add(definition, payload, options = {}) {
|
|
20
|
+
const data = parsePayload(definition, payload);
|
|
21
|
+
const now = this.now();
|
|
22
|
+
const delay = assertNonNegativeInteger(options.delay ?? 0, 'delay');
|
|
23
|
+
const maxAttempts = assertPositiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, 'maxAttempts');
|
|
24
|
+
const runAt = now + delay;
|
|
25
|
+
const id = randomUUID();
|
|
26
|
+
const jobId = options.jobId;
|
|
27
|
+
const context = snapshotContext();
|
|
28
|
+
const job = {
|
|
29
|
+
v: 1,
|
|
30
|
+
id,
|
|
31
|
+
queue: definition.name,
|
|
32
|
+
data,
|
|
33
|
+
createdAt: now,
|
|
34
|
+
runAt,
|
|
35
|
+
attempts: 0,
|
|
36
|
+
maxAttempts,
|
|
37
|
+
backoff: normalizeBackoff(options.backoff),
|
|
38
|
+
...(jobId ? { jobId } : {}),
|
|
39
|
+
...(Object.keys(context).length > 0 ? { context } : {}),
|
|
40
|
+
};
|
|
41
|
+
const encoded = this.encodeJob(definition, job);
|
|
42
|
+
let reservedDedupe = false;
|
|
43
|
+
if (jobId) {
|
|
44
|
+
const result = await this.redis.set(this.dedupeKey(definition, jobId), id, 'NX');
|
|
45
|
+
if (result !== 'OK') {
|
|
46
|
+
const existing = await this.redis.get(this.dedupeKey(definition, jobId));
|
|
47
|
+
return {
|
|
48
|
+
accepted: false,
|
|
49
|
+
duplicate: true,
|
|
50
|
+
id: existing ?? id,
|
|
51
|
+
jobId,
|
|
52
|
+
runAt,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
reservedDedupe = true;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
if (runAt > now) {
|
|
59
|
+
await this.redis.zadd(this.delayedKey(definition), runAt, encoded);
|
|
60
|
+
return {
|
|
61
|
+
accepted: true,
|
|
62
|
+
duplicate: false,
|
|
63
|
+
id,
|
|
64
|
+
...(jobId ? { jobId } : {}),
|
|
65
|
+
runAt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const streamId = await this.redis.xadd(this.streamKey(definition), '*', 'job', encoded);
|
|
69
|
+
return {
|
|
70
|
+
accepted: true,
|
|
71
|
+
duplicate: false,
|
|
72
|
+
id,
|
|
73
|
+
...(jobId ? { jobId } : {}),
|
|
74
|
+
streamId,
|
|
75
|
+
runAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (reservedDedupe && jobId) {
|
|
80
|
+
await this.redis.del(this.dedupeKey(definition, jobId)).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
worker(definition, handler, options = {}) {
|
|
86
|
+
return new RedisStreamQueueWorker(this, definition, handler, options);
|
|
87
|
+
}
|
|
88
|
+
async readDeadLetters(definition, options = {}) {
|
|
89
|
+
const entries = await this.redis.xrange(this.deadLetterKey(definition), '-', '+', 'COUNT', options.count ?? 100);
|
|
90
|
+
return entries.map(([streamId, fields]) => {
|
|
91
|
+
const stored = this.decodeEntry(definition, fields);
|
|
92
|
+
const data = parsePayload(definition, stored.data);
|
|
93
|
+
return {
|
|
94
|
+
id: stored.id,
|
|
95
|
+
queue: stored.queue,
|
|
96
|
+
data,
|
|
97
|
+
attempt: stored.attempts,
|
|
98
|
+
maxAttempts: stored.maxAttempts,
|
|
99
|
+
streamId,
|
|
100
|
+
...(stored.jobId ? { jobId: stored.jobId } : {}),
|
|
101
|
+
createdAt: stored.createdAt,
|
|
102
|
+
runAt: stored.runAt,
|
|
103
|
+
...(stored.context ? { context: stored.context } : {}),
|
|
104
|
+
...(stored.firstFailureReason ? { firstFailureReason: stored.firstFailureReason } : {}),
|
|
105
|
+
...(stored.lastFailureReason ? { lastFailureReason: stored.lastFailureReason } : {}),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async runWorkerOnce(definition, handler, options) {
|
|
110
|
+
await this.ensureGroup(definition, options.group);
|
|
111
|
+
await this.promoteDelayed(definition, options.concurrency);
|
|
112
|
+
const claimed = await this.claimStale(definition, options);
|
|
113
|
+
const entries = claimed.length > 0 ? claimed : await this.readNew(definition, options);
|
|
114
|
+
if (entries.length === 0)
|
|
115
|
+
return 0;
|
|
116
|
+
await Promise.all(entries.map(entry => this.processEntry(definition, handler, options, entry)));
|
|
117
|
+
return entries.length;
|
|
118
|
+
}
|
|
119
|
+
async processEntry(definition, handler, options, [streamId, fields]) {
|
|
120
|
+
const stored = this.decodeEntry(definition, fields);
|
|
121
|
+
const attempt = stored.attempts + 1;
|
|
122
|
+
try {
|
|
123
|
+
const job = this.createJob(definition, stored, streamId, attempt);
|
|
124
|
+
const run = () => Promise.resolve(handler(job));
|
|
125
|
+
if (stored.context && isContextData(stored.context)) {
|
|
126
|
+
await runWithContext(stored.context, run);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
await run();
|
|
130
|
+
}
|
|
131
|
+
await this.redis.xack(this.streamKey(definition), options.group, streamId);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
await this.handleFailure(definition, stored, streamId, attempt, options.group, err);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async handleFailure(definition, stored, streamId, attempt, group, err) {
|
|
138
|
+
const reason = errorReason(err);
|
|
139
|
+
const failed = {
|
|
140
|
+
...stored,
|
|
141
|
+
attempts: attempt,
|
|
142
|
+
firstFailureReason: stored.firstFailureReason ?? reason,
|
|
143
|
+
lastFailureReason: reason,
|
|
144
|
+
};
|
|
145
|
+
if (attempt < stored.maxAttempts) {
|
|
146
|
+
const delay = backoffDelay(stored.backoff, attempt);
|
|
147
|
+
failed.runAt = this.now() + delay;
|
|
148
|
+
await this.redis.zadd(this.delayedKey(definition), failed.runAt, this.encodeJob(definition, failed));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
await this.redis.xadd(this.deadLetterKey(definition), '*', 'job', this.encodeJob(definition, failed));
|
|
152
|
+
}
|
|
153
|
+
await this.redis.xack(this.streamKey(definition), group, streamId);
|
|
154
|
+
}
|
|
155
|
+
createJob(definition, stored, streamId, attempt) {
|
|
156
|
+
return {
|
|
157
|
+
id: stored.id,
|
|
158
|
+
queue: stored.queue,
|
|
159
|
+
data: parsePayload(definition, stored.data),
|
|
160
|
+
attempt,
|
|
161
|
+
maxAttempts: stored.maxAttempts,
|
|
162
|
+
streamId,
|
|
163
|
+
...(stored.jobId ? { jobId: stored.jobId } : {}),
|
|
164
|
+
createdAt: stored.createdAt,
|
|
165
|
+
runAt: stored.runAt,
|
|
166
|
+
...(stored.context ? { context: stored.context } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async promoteDelayed(definition, limit) {
|
|
170
|
+
const members = await this.redis.zrangebyscore(this.delayedKey(definition), '-inf', this.now(), 'LIMIT', 0, limit);
|
|
171
|
+
let promoted = 0;
|
|
172
|
+
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++;
|
|
178
|
+
}
|
|
179
|
+
return promoted;
|
|
180
|
+
}
|
|
181
|
+
async claimStale(definition, options) {
|
|
182
|
+
const pending = await this.redis.xpending(this.streamKey(definition), options.group, '-', '+', options.claimCount);
|
|
183
|
+
const ids = pending
|
|
184
|
+
.filter(([, , idle]) => idle >= options.claimIdle)
|
|
185
|
+
.slice(0, options.concurrency)
|
|
186
|
+
.map(([id]) => id);
|
|
187
|
+
if (ids.length === 0)
|
|
188
|
+
return [];
|
|
189
|
+
return await this.redis.xclaim(this.streamKey(definition), options.group, options.consumer, options.claimIdle, ...ids);
|
|
190
|
+
}
|
|
191
|
+
async readNew(definition, options) {
|
|
192
|
+
const args = [
|
|
193
|
+
'GROUP',
|
|
194
|
+
options.group,
|
|
195
|
+
options.consumer,
|
|
196
|
+
'COUNT',
|
|
197
|
+
options.concurrency,
|
|
198
|
+
];
|
|
199
|
+
if (options.block > 0) {
|
|
200
|
+
args.push('BLOCK', options.block);
|
|
201
|
+
}
|
|
202
|
+
args.push('STREAMS', this.streamKey(definition), '>');
|
|
203
|
+
const result = await this.redis.xreadgroup(...args);
|
|
204
|
+
return result?.[0]?.[1] ?? [];
|
|
205
|
+
}
|
|
206
|
+
async ensureGroup(definition, group) {
|
|
207
|
+
try {
|
|
208
|
+
await this.redis.xgroup('CREATE', this.streamKey(definition), group, '0', 'MKSTREAM');
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
if (!String(err.message ?? err).includes('BUSYGROUP'))
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
decodeEntry(definition, fields) {
|
|
216
|
+
const value = fieldsToObject(fields)['job'];
|
|
217
|
+
if (!value)
|
|
218
|
+
throw new QueueSerializationError(definition.name, new Error('missing job field'));
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(value);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
throw new QueueSerializationError(definition.name, err);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
encodeJob(definition, job) {
|
|
227
|
+
try {
|
|
228
|
+
const encoded = JSON.stringify(job);
|
|
229
|
+
if (encoded === undefined)
|
|
230
|
+
throw new TypeError('JSON.stringify returned undefined');
|
|
231
|
+
return encoded;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
throw new QueueSerializationError(definition.name, err);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
streamKey(definition) {
|
|
238
|
+
return `${this.prefix}queue:${definition.name}:stream`;
|
|
239
|
+
}
|
|
240
|
+
delayedKey(definition) {
|
|
241
|
+
return `${this.prefix}queue:${definition.name}:delayed`;
|
|
242
|
+
}
|
|
243
|
+
deadLetterKey(definition) {
|
|
244
|
+
return `${this.prefix}queue:${definition.name}:dlq`;
|
|
245
|
+
}
|
|
246
|
+
dedupeKey(definition, jobId) {
|
|
247
|
+
return `${this.prefix}queue:${definition.name}:job:${jobId}`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export class RedisStreamQueueWorker {
|
|
251
|
+
queue;
|
|
252
|
+
definition;
|
|
253
|
+
handler;
|
|
254
|
+
options;
|
|
255
|
+
running = false;
|
|
256
|
+
loop;
|
|
257
|
+
constructor(queue, definition, handler, options) {
|
|
258
|
+
this.queue = queue;
|
|
259
|
+
this.definition = definition;
|
|
260
|
+
this.handler = handler;
|
|
261
|
+
this.options = normalizeWorkerOptions(definition, options);
|
|
262
|
+
}
|
|
263
|
+
runOnce() {
|
|
264
|
+
return this.queue.runWorkerOnce(this.definition, this.handler, this.options);
|
|
265
|
+
}
|
|
266
|
+
start() {
|
|
267
|
+
if (this.running)
|
|
268
|
+
return this;
|
|
269
|
+
this.running = true;
|
|
270
|
+
this.loop = this.runLoop();
|
|
271
|
+
return this;
|
|
272
|
+
}
|
|
273
|
+
async stop() {
|
|
274
|
+
this.running = false;
|
|
275
|
+
await this.loop;
|
|
276
|
+
}
|
|
277
|
+
async runLoop() {
|
|
278
|
+
while (this.running) {
|
|
279
|
+
const processed = await this.runOnce();
|
|
280
|
+
if (processed === 0) {
|
|
281
|
+
await sleep(this.options.pollInterval);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function normalizeWorkerOptions(definition, options) {
|
|
287
|
+
const concurrency = assertPositiveInteger(options.concurrency ?? DEFAULT_CONCURRENCY, 'concurrency');
|
|
288
|
+
return {
|
|
289
|
+
group: options.group ?? `${definition.name}-workers`,
|
|
290
|
+
consumer: options.consumer ?? `${process.pid}-${randomUUID()}`,
|
|
291
|
+
concurrency,
|
|
292
|
+
block: assertNonNegativeInteger(options.block ?? DEFAULT_READ_BLOCK, 'block'),
|
|
293
|
+
pollInterval: assertNonNegativeInteger(options.pollInterval ?? DEFAULT_POLL_INTERVAL, 'pollInterval'),
|
|
294
|
+
claimIdle: assertNonNegativeInteger(options.claimIdle ?? DEFAULT_CLAIM_IDLE, 'claimIdle'),
|
|
295
|
+
claimCount: assertPositiveInteger(options.claimCount ?? concurrency, 'claimCount'),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function normalizeBackoff(backoff) {
|
|
299
|
+
if (backoff === undefined)
|
|
300
|
+
return { type: 'fixed', delay: 0 };
|
|
301
|
+
if (typeof backoff === 'number') {
|
|
302
|
+
return { type: 'fixed', delay: assertNonNegativeInteger(backoff, 'backoff') };
|
|
303
|
+
}
|
|
304
|
+
if (backoff.type === 'fixed') {
|
|
305
|
+
return { type: 'fixed', delay: assertNonNegativeInteger(backoff.delay, 'backoff.delay') };
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
type: 'exponential',
|
|
309
|
+
baseMs: assertNonNegativeInteger(backoff.baseMs, 'backoff.baseMs'),
|
|
310
|
+
...(backoff.maxMs !== undefined ? { maxMs: assertNonNegativeInteger(backoff.maxMs, 'backoff.maxMs') } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function backoffDelay(backoff, attempt) {
|
|
314
|
+
if (backoff.type === 'fixed')
|
|
315
|
+
return backoff.delay;
|
|
316
|
+
const delay = backoff.baseMs * 2 ** Math.max(0, attempt - 1);
|
|
317
|
+
return backoff.maxMs === undefined ? delay : Math.min(delay, backoff.maxMs);
|
|
318
|
+
}
|
|
319
|
+
function fieldsToObject(fields) {
|
|
320
|
+
const result = {};
|
|
321
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
322
|
+
const key = fields[i];
|
|
323
|
+
const value = fields[i + 1];
|
|
324
|
+
if (key !== undefined && value !== undefined)
|
|
325
|
+
result[key] = value;
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
function assertPositiveInteger(value, name) {
|
|
330
|
+
if (!Number.isFinite(value) || value <= 0 || Math.trunc(value) !== value) {
|
|
331
|
+
throw new TypeError(`${name} must be a positive integer`);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
function assertNonNegativeInteger(value, name) {
|
|
336
|
+
if (!Number.isFinite(value) || value < 0 || Math.trunc(value) !== value) {
|
|
337
|
+
throw new TypeError(`${name} must be a non-negative integer`);
|
|
338
|
+
}
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
341
|
+
function errorReason(err) {
|
|
342
|
+
if (err instanceof Error)
|
|
343
|
+
return err.message;
|
|
344
|
+
return String(err);
|
|
345
|
+
}
|
|
346
|
+
function sleep(ms) {
|
|
347
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
348
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ContextData, ContextInput } from '@hile/context';
|
|
2
|
+
export type QueueSafeParseResult<T> = {
|
|
3
|
+
success: true;
|
|
4
|
+
data: T;
|
|
5
|
+
} | {
|
|
6
|
+
success: false;
|
|
7
|
+
error: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type QueueSchema<T> = {
|
|
10
|
+
parse(value: unknown): T;
|
|
11
|
+
} | {
|
|
12
|
+
safeParse(value: unknown): QueueSafeParseResult<T>;
|
|
13
|
+
};
|
|
14
|
+
export type InferQueueSchema<TSchema> = TSchema extends {
|
|
15
|
+
parse(value: unknown): infer T;
|
|
16
|
+
} ? T : TSchema extends {
|
|
17
|
+
safeParse(value: unknown): QueueSafeParseResult<infer T>;
|
|
18
|
+
} ? T : never;
|
|
19
|
+
export type QueueDefinition<TData = unknown, TName extends string = string> = {
|
|
20
|
+
readonly name: TName;
|
|
21
|
+
readonly schema?: QueueSchema<TData>;
|
|
22
|
+
};
|
|
23
|
+
export type QueueBackoff = number | {
|
|
24
|
+
type: 'fixed';
|
|
25
|
+
delay: number;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'exponential';
|
|
28
|
+
baseMs: number;
|
|
29
|
+
maxMs?: number;
|
|
30
|
+
};
|
|
31
|
+
export type QueueAddOptions = {
|
|
32
|
+
jobId?: string;
|
|
33
|
+
delay?: number;
|
|
34
|
+
maxAttempts?: number;
|
|
35
|
+
backoff?: QueueBackoff;
|
|
36
|
+
};
|
|
37
|
+
export type QueueAddResult = {
|
|
38
|
+
accepted: boolean;
|
|
39
|
+
duplicate: boolean;
|
|
40
|
+
id: string;
|
|
41
|
+
jobId?: string;
|
|
42
|
+
streamId?: string;
|
|
43
|
+
runAt: number;
|
|
44
|
+
};
|
|
45
|
+
export type QueueJob<TData = unknown> = {
|
|
46
|
+
id: string;
|
|
47
|
+
queue: string;
|
|
48
|
+
data: TData;
|
|
49
|
+
attempt: number;
|
|
50
|
+
maxAttempts: number;
|
|
51
|
+
streamId: string;
|
|
52
|
+
jobId?: string;
|
|
53
|
+
createdAt: number;
|
|
54
|
+
runAt: number;
|
|
55
|
+
context?: ContextInput<ContextData>;
|
|
56
|
+
};
|
|
57
|
+
export type QueueDeadLetter<TData = unknown> = Omit<QueueJob<TData>, 'streamId'> & {
|
|
58
|
+
streamId: string;
|
|
59
|
+
firstFailureReason?: string;
|
|
60
|
+
lastFailureReason?: string;
|
|
61
|
+
};
|
|
62
|
+
export type QueueWorkerHandler<TData = unknown> = (job: QueueJob<TData>) => void | Promise<void>;
|
|
63
|
+
export type QueueWorkerOptions = {
|
|
64
|
+
group?: string;
|
|
65
|
+
consumer?: string;
|
|
66
|
+
concurrency?: number;
|
|
67
|
+
block?: number;
|
|
68
|
+
pollInterval?: number;
|
|
69
|
+
claimIdle?: number;
|
|
70
|
+
claimCount?: number;
|
|
71
|
+
};
|
|
72
|
+
export type RedisStreamEntry = [id: string, fields: string[]];
|
|
73
|
+
export type RedisStreamReadResult = Array<[stream: string, entries: RedisStreamEntry[]]> | null;
|
|
74
|
+
export type RedisPendingEntry = [id: string, consumer: string, idle: number, deliveries: number];
|
|
75
|
+
export interface RedisStreamQueueLike {
|
|
76
|
+
xgroup(...args: any[]): Promise<any>;
|
|
77
|
+
xadd(...args: any[]): Promise<any>;
|
|
78
|
+
xreadgroup(...args: any[]): Promise<any>;
|
|
79
|
+
xpending(...args: any[]): Promise<any>;
|
|
80
|
+
xclaim(...args: any[]): Promise<any>;
|
|
81
|
+
xack(...args: any[]): Promise<any>;
|
|
82
|
+
xrange(...args: any[]): Promise<any>;
|
|
83
|
+
zadd(...args: any[]): Promise<any>;
|
|
84
|
+
zrangebyscore(...args: any[]): Promise<any>;
|
|
85
|
+
zrem(...args: any[]): Promise<any>;
|
|
86
|
+
set(...args: any[]): Promise<any>;
|
|
87
|
+
get(...args: any[]): Promise<any>;
|
|
88
|
+
del(...args: any[]): Promise<any>;
|
|
89
|
+
}
|
|
90
|
+
export type RedisStreamQueueOptions = {
|
|
91
|
+
prefix?: string;
|
|
92
|
+
now?: () => number;
|
|
93
|
+
};
|
|
94
|
+
export type ReadDeadLettersOptions = {
|
|
95
|
+
count?: number;
|
|
96
|
+
};
|
|
97
|
+
export type StoredQueueBackoff = {
|
|
98
|
+
type: 'fixed';
|
|
99
|
+
delay: number;
|
|
100
|
+
} | {
|
|
101
|
+
type: 'exponential';
|
|
102
|
+
baseMs: number;
|
|
103
|
+
maxMs?: number;
|
|
104
|
+
};
|
|
105
|
+
export type StoredQueueJob = {
|
|
106
|
+
v: 1;
|
|
107
|
+
id: string;
|
|
108
|
+
queue: string;
|
|
109
|
+
data: unknown;
|
|
110
|
+
createdAt: number;
|
|
111
|
+
runAt: number;
|
|
112
|
+
attempts: number;
|
|
113
|
+
maxAttempts: number;
|
|
114
|
+
backoff: StoredQueueBackoff;
|
|
115
|
+
jobId?: string;
|
|
116
|
+
context?: ContextInput<ContextData>;
|
|
117
|
+
firstFailureReason?: string;
|
|
118
|
+
lastFailureReason?: string;
|
|
119
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hile/redis-stream-queue",
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "Redis Streams backed durable job queue primitives for Hile applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -b && fix-esm-import-path --preserve-import-type ./dist",
|
|
9
|
+
"dev": "tsc -b --watch",
|
|
10
|
+
"test": "vitest run && tsc -p tsconfig.typecheck.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"SKILL.md"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"fix-esm-import-path": "^1.10.3",
|
|
23
|
+
"ioredis": "^5.11.0",
|
|
24
|
+
"vitest": "^4.0.18"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@hile/context": "^3.0.1"
|
|
28
|
+
},
|
|
29
|
+
"gitHead": "762f9476b1b53f91899b985fe5bf437b618bb224"
|
|
30
|
+
}
|