@zerotal/queue 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +57 -0
- package/src/Batch.ts +63 -0
- package/src/Bus.ts +49 -0
- package/src/Job.ts +37 -0
- package/src/JobRegistry.ts +24 -0
- package/src/PendingBatch.ts +63 -0
- package/src/QueueFake.ts +126 -0
- package/src/QueueManager.ts +427 -0
- package/src/WorkerPool.ts +242 -0
- package/src/admin.ts +254 -0
- package/src/commands/QueueFailedCommand.ts +57 -0
- package/src/commands/QueueFlushCommand.ts +61 -0
- package/src/commands/QueueRetryCommand.ts +84 -0
- package/src/commands/QueueWorkCommand.ts +58 -0
- package/src/commands/index.ts +4 -0
- package/src/config.ts +118 -0
- package/src/drivers/QueueDriver.ts +65 -0
- package/src/drivers/RedisDriver.ts +301 -0
- package/src/drivers/SqliteDriver.ts +366 -0
- package/src/drivers/SyncDriver.ts +85 -0
- package/src/errors.ts +42 -0
- package/src/events.ts +21 -0
- package/src/facades/Queue.ts +3 -0
- package/src/global.d.ts +34 -0
- package/src/index.ts +29 -0
- package/src/observability.ts +131 -0
- package/src/provider/QueueProvider.ts +153 -0
- package/src/queue-worker.ts +98 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface JobRecord {
|
|
2
|
+
id: number;
|
|
3
|
+
queue: string;
|
|
4
|
+
className: string;
|
|
5
|
+
payload: string;
|
|
6
|
+
attempts: number;
|
|
7
|
+
maxAttempts: number;
|
|
8
|
+
retryDelay: number;
|
|
9
|
+
availableAt: number;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
batchId?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Result of recording a batch job outcome.
|
|
16
|
+
* - `'pending'` — batch still has unfinished jobs
|
|
17
|
+
* - `'complete'` — all jobs finished, none failed
|
|
18
|
+
* - `'failed'` — all jobs finished, one or more failed
|
|
19
|
+
*/
|
|
20
|
+
export type BatchStatus = "pending" | "complete" | "failed";
|
|
21
|
+
|
|
22
|
+
export interface FailedRecord {
|
|
23
|
+
id: number;
|
|
24
|
+
queue: string;
|
|
25
|
+
className: string;
|
|
26
|
+
payload: string; // JSON string
|
|
27
|
+
attempts: number;
|
|
28
|
+
error: string;
|
|
29
|
+
failedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QueueDriver {
|
|
33
|
+
// ── Active queue ──────────────────────────────────────────────────────────
|
|
34
|
+
push(record: Omit<JobRecord, "id" | "createdAt">): Promise<void>;
|
|
35
|
+
pop(queue?: string): Promise<JobRecord | null>;
|
|
36
|
+
fail(record: JobRecord, error: string): Promise<void>;
|
|
37
|
+
retry(record: JobRecord): Promise<void>;
|
|
38
|
+
delete(record: JobRecord): Promise<void>;
|
|
39
|
+
size(queue?: string): Promise<number>;
|
|
40
|
+
flush(): Promise<void>;
|
|
41
|
+
|
|
42
|
+
// ── Introspection (optional — powers tooling / the admin Queue panel) ──────
|
|
43
|
+
/** List pending jobs (paged), optionally filtered to a single queue. */
|
|
44
|
+
listPending?(queue?: string, limit?: number, offset?: number): Promise<JobRecord[]>;
|
|
45
|
+
/** Distinct queue names with their pending depth. */
|
|
46
|
+
queues?(): Promise<{ queue: string; pending: number }[]>;
|
|
47
|
+
|
|
48
|
+
// ── Failed job management ─────────────────────────────────────────────────
|
|
49
|
+
/** Return all failed jobs, optionally filtered to a single queue. */
|
|
50
|
+
listFailed(queue?: string): Promise<FailedRecord[]>;
|
|
51
|
+
/** Remove a single failed job by its ID. */
|
|
52
|
+
deleteFailedRecord(id: number): Promise<void>;
|
|
53
|
+
/** Delete all failed jobs, optionally filtered to a single queue. */
|
|
54
|
+
clearFailed(queue?: string): Promise<void>;
|
|
55
|
+
|
|
56
|
+
// ── Batch support (optional — only implemented by SqliteDriver) ───────────
|
|
57
|
+
/** Create a new batch row and return immediately. */
|
|
58
|
+
createBatch?(id: string, name: string, totalJobs: number, optionsJson: string): Promise<void>;
|
|
59
|
+
/** Decrement pending_jobs for a completed job; mark finished if last. */
|
|
60
|
+
recordBatchJobComplete?(batchId: string): Promise<BatchStatus>;
|
|
61
|
+
/** Decrement pending_jobs + increment failed_jobs; mark finished if last. */
|
|
62
|
+
recordBatchJobFailed?(batchId: string, jobId: number): Promise<BatchStatus>;
|
|
63
|
+
/** Look up a batch row by ID. */
|
|
64
|
+
getBatch?(batchId: string): Promise<import("../Batch.ts").BatchRecord | null>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { QueueDriver, JobRecord, FailedRecord } from "./QueueDriver.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redis queue driver, at semantic parity with {@link SqliteDriver}:
|
|
5
|
+
*
|
|
6
|
+
* - **Delayed jobs** (`availableAt` in the future) live in a sorted set scored
|
|
7
|
+
* by their epoch-second due time; `pop()` promotes due members onto the
|
|
8
|
+
* ready list before claiming, so delayed jobs no longer run immediately.
|
|
9
|
+
* - **Visibility timeout**: a popped job is parked in a `:reserved` sorted set
|
|
10
|
+
* scored by its reservation expiry. If the worker crashes before calling
|
|
11
|
+
* `delete()`/`retry()`/`fail()`, the job is reclaimed onto the ready list
|
|
12
|
+
* once the timeout elapses — no double-processing across workers, no loss.
|
|
13
|
+
* - **`retry()` honours `retryDelay`** by rescheduling through the delayed set
|
|
14
|
+
* instead of pushing straight back onto the ready list.
|
|
15
|
+
* - **Real job IDs** minted from a Redis counter, so failed jobs are
|
|
16
|
+
* individually addressable and `queue:retry <id>` / `deleteFailedRecord`
|
|
17
|
+
* work (previously a no-op).
|
|
18
|
+
* - **`flush()` clears every queue** ever pushed to (tracked in a queue-name
|
|
19
|
+
* set), not just `default` — mirroring SqliteDriver's full wipe.
|
|
20
|
+
*
|
|
21
|
+
* The claim (RPOP the ready list → ZADD the reserved set) runs as a single
|
|
22
|
+
* atomic Lua `EVAL`, so a worker that dies mid-claim can never lose the job:
|
|
23
|
+
* either it is still on the ready list, or it is parked in `:reserved` and
|
|
24
|
+
* reclaimed after the visibility timeout. The reserved member is the exact
|
|
25
|
+
* value popped from the ready list, and reservations are removed by job **id**
|
|
26
|
+
* (not by byte-exact re-encoding), so `delete`/`retry`/`fail` are robust even
|
|
27
|
+
* if the record round-trips through a slightly different JSON encoding.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The subset of Bun's Redis client this driver uses.
|
|
31
|
+
*
|
|
32
|
+
* Declared as a parameter rather than reached for directly so the driver can be driven
|
|
33
|
+
* against a stand-in. `mock.module("bun", …)` cannot intercept a Bun builtin, which is why
|
|
34
|
+
* the suite that tried it reached a real Redis, found none, and timed out — leaving the
|
|
35
|
+
* production queue backend with no working coverage while appearing to have plenty.
|
|
36
|
+
*/
|
|
37
|
+
export interface RedisClientLike {
|
|
38
|
+
incr(key: string): Promise<number | bigint>;
|
|
39
|
+
lpush(key: string, value: string): Promise<unknown>;
|
|
40
|
+
llen(key: string): Promise<number | bigint>;
|
|
41
|
+
lrange(key: string, start: number, stop: number): Promise<string[]>;
|
|
42
|
+
sadd(key: string, member: string): Promise<unknown>;
|
|
43
|
+
smembers(key: string): Promise<string[]>;
|
|
44
|
+
send(command: string, args: string[]): Promise<unknown>;
|
|
45
|
+
del(...keys: string[]): Promise<unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class RedisDriver implements QueueDriver {
|
|
49
|
+
private _prefix: string;
|
|
50
|
+
private _visibilityTimeout: number;
|
|
51
|
+
private readonly _client: RedisClientLike | undefined;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param prefix - Key prefix for every key family this driver owns.
|
|
55
|
+
* @param options.visibilityTimeout - Seconds a claimed job stays invisible before being
|
|
56
|
+
* reclaimed. Defaults to 90, matching `SqliteDriver`.
|
|
57
|
+
* @param options.client - Redis client to use. Defaults to Bun's built-in `redis`.
|
|
58
|
+
*/
|
|
59
|
+
constructor(
|
|
60
|
+
prefix = "zerotal:jobs:",
|
|
61
|
+
options: { visibilityTimeout?: number; client?: RedisClientLike } = {},
|
|
62
|
+
) {
|
|
63
|
+
this._prefix = prefix;
|
|
64
|
+
this._visibilityTimeout = options.visibilityTimeout ?? 90;
|
|
65
|
+
this._client = options.client;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The Redis client: the injected one, or Bun's built-in. */
|
|
69
|
+
private async _redis(): Promise<RedisClientLike> {
|
|
70
|
+
if (this._client) return this._client;
|
|
71
|
+
const { redis } = await import("bun");
|
|
72
|
+
return redis;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private _key(queue: string): string {
|
|
76
|
+
return `${this._prefix}${queue}`;
|
|
77
|
+
}
|
|
78
|
+
private _delayedKey(queue: string): string {
|
|
79
|
+
return `${this._prefix}${queue}:delayed`;
|
|
80
|
+
}
|
|
81
|
+
private _reservedKey(queue: string): string {
|
|
82
|
+
return `${this._prefix}${queue}:reserved`;
|
|
83
|
+
}
|
|
84
|
+
private _failKey(queue: string): string {
|
|
85
|
+
return `${this._prefix}${queue}:failed`;
|
|
86
|
+
}
|
|
87
|
+
/** Set of queue names seen by push() — lets flush() find every key family. */
|
|
88
|
+
private _queuesKey(): string {
|
|
89
|
+
return `${this._prefix}_queues`;
|
|
90
|
+
}
|
|
91
|
+
private _idKey(): string {
|
|
92
|
+
return `${this._prefix}_id`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static _now(): number {
|
|
96
|
+
return Math.floor(Date.now() / 1000);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async push(record: Omit<JobRecord, "id" | "createdAt">): Promise<void> {
|
|
100
|
+
const redis = await this._redis();
|
|
101
|
+
const id = Number(await redis.incr(this._idKey()));
|
|
102
|
+
const payload = JSON.stringify({
|
|
103
|
+
...record,
|
|
104
|
+
id,
|
|
105
|
+
createdAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
await redis.sadd(this._queuesKey(), record.queue);
|
|
108
|
+
if (record.availableAt > RedisDriver._now()) {
|
|
109
|
+
// Not due yet — park in the delayed sorted set, scored by due time.
|
|
110
|
+
await redis.send("ZADD", [
|
|
111
|
+
this._delayedKey(record.queue),
|
|
112
|
+
String(record.availableAt),
|
|
113
|
+
payload,
|
|
114
|
+
]);
|
|
115
|
+
} else {
|
|
116
|
+
await redis.lpush(this._key(record.queue), payload);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Move every member of sorted set `from` with score ≤ `maxScore` onto the
|
|
122
|
+
* ready list. `ZREM` returns 0 when another worker won the race for a
|
|
123
|
+
* member, so each job is promoted exactly once even under concurrency.
|
|
124
|
+
*/
|
|
125
|
+
private async _promoteDue(from: string, queue: string, maxScore: number): Promise<void> {
|
|
126
|
+
const redis = await this._redis();
|
|
127
|
+
const due = (await redis.send("ZRANGEBYSCORE", [from, "-inf", String(maxScore)])) as
|
|
128
|
+
string[] | null;
|
|
129
|
+
if (!due || due.length === 0) return;
|
|
130
|
+
for (const member of due) {
|
|
131
|
+
const removed = Number(await redis.send("ZREM", [from, member]));
|
|
132
|
+
if (removed === 1) await redis.lpush(this._key(queue), member);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Atomically pop the tail of the ready list and park it in the reserved set,
|
|
138
|
+
* in a single Lua step so a crash can't happen *between* the two commands.
|
|
139
|
+
* Returns the popped raw value (or a falsey reply when the list is empty).
|
|
140
|
+
*/
|
|
141
|
+
private static readonly _CLAIM_LUA =
|
|
142
|
+
"local v = redis.call('RPOP', KEYS[1]) " +
|
|
143
|
+
"if not v then return false end " +
|
|
144
|
+
// Increment attempts *inside* the claim, so the value parked in the reserved set is the
|
|
145
|
+
// post-increment one. Incrementing only the returned object left the stored member at
|
|
146
|
+
// its pre-increment count, so a SIGKILLed worker's job was reclaimed with attempts back
|
|
147
|
+
// at 0 — forever, re-poisoning a worker every visibility timeout and never reaching
|
|
148
|
+
// maxAttempts. SqliteDriver persists it, so this was also a break in the parity the
|
|
149
|
+
// class documents.
|
|
150
|
+
"local ok, obj = pcall(cjson.decode, v) " +
|
|
151
|
+
"if ok and obj then obj.attempts = (obj.attempts or 0) + 1 v = cjson.encode(obj) end " +
|
|
152
|
+
"redis.call('ZADD', KEYS[2], ARGV[1], v) " +
|
|
153
|
+
"return v";
|
|
154
|
+
|
|
155
|
+
async pop(queue = "default"): Promise<JobRecord | null> {
|
|
156
|
+
const redis = await this._redis();
|
|
157
|
+
const now = RedisDriver._now();
|
|
158
|
+
|
|
159
|
+
// Promote delayed jobs that have come due, and reclaim reservations whose
|
|
160
|
+
// visibility timeout expired (worker crashed mid-job).
|
|
161
|
+
await this._promoteDue(this._delayedKey(queue), queue, now);
|
|
162
|
+
await this._promoteDue(this._reservedKey(queue), queue, now);
|
|
163
|
+
|
|
164
|
+
// Single atomic claim: RPOP ready → bump attempts → ZADD reserved. The reserved member
|
|
165
|
+
// carries the incremented count, so a job reclaimed after a worker crash resumes from
|
|
166
|
+
// where it was rather than restarting at zero.
|
|
167
|
+
const raw = (await redis.send("EVAL", [
|
|
168
|
+
RedisDriver._CLAIM_LUA,
|
|
169
|
+
"2",
|
|
170
|
+
this._key(queue),
|
|
171
|
+
this._reservedKey(queue),
|
|
172
|
+
String(now + this._visibilityTimeout),
|
|
173
|
+
])) as string | null | false;
|
|
174
|
+
if (!raw) return null;
|
|
175
|
+
|
|
176
|
+
return JSON.parse(raw) as JobRecord;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Remove a job's reservation by id (members are keyed logically by job id). */
|
|
180
|
+
private async _release(record: JobRecord): Promise<void> {
|
|
181
|
+
const redis = await this._redis();
|
|
182
|
+
const reservedKey = this._reservedKey(record.queue);
|
|
183
|
+
const members = (await redis.send("ZRANGE", [reservedKey, "0", "-1"])) as string[] | null;
|
|
184
|
+
if (!members) return;
|
|
185
|
+
for (const member of members) {
|
|
186
|
+
try {
|
|
187
|
+
if ((JSON.parse(member) as JobRecord).id === record.id) {
|
|
188
|
+
await redis.send("ZREM", [reservedKey, member]);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// Skip an unparseable member rather than aborting the release.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async fail(record: JobRecord, error: string): Promise<void> {
|
|
198
|
+
const redis = await this._redis();
|
|
199
|
+
await this._release(record);
|
|
200
|
+
await redis.lpush(
|
|
201
|
+
this._failKey(record.queue),
|
|
202
|
+
JSON.stringify({ ...record, error, failedAt: new Date().toISOString() }),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async retry(record: JobRecord): Promise<void> {
|
|
207
|
+
const redis = await this._redis();
|
|
208
|
+
await this._release(record);
|
|
209
|
+
const nextAvailable = RedisDriver._now() + Math.floor(record.retryDelay / 1000);
|
|
210
|
+
const payload = JSON.stringify({ ...record, availableAt: nextAvailable });
|
|
211
|
+
if (nextAvailable > RedisDriver._now()) {
|
|
212
|
+
await redis.send("ZADD", [this._delayedKey(record.queue), String(nextAvailable), payload]);
|
|
213
|
+
} else {
|
|
214
|
+
await redis.lpush(this._key(record.queue), payload);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async delete(record: JobRecord): Promise<void> {
|
|
219
|
+
// Consumed from the ready list by RPOP — only the reservation remains.
|
|
220
|
+
await this._release(record);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async size(queue = "default"): Promise<number> {
|
|
224
|
+
const redis = await this._redis();
|
|
225
|
+
// Parity with SqliteDriver: count ready + delayed + in-flight.
|
|
226
|
+
const ready = Number(await redis.llen(this._key(queue)));
|
|
227
|
+
const delayed = Number(await redis.send("ZCARD", [this._delayedKey(queue)]));
|
|
228
|
+
const reserved = Number(await redis.send("ZCARD", [this._reservedKey(queue)]));
|
|
229
|
+
return ready + delayed + reserved;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async flush(): Promise<void> {
|
|
233
|
+
const redis = await this._redis();
|
|
234
|
+
const queues = (await redis.smembers(this._queuesKey())) as string[] | null;
|
|
235
|
+
const names = new Set<string>(["default", ...(queues ?? [])]);
|
|
236
|
+
for (const queue of names) {
|
|
237
|
+
await redis.del(this._key(queue));
|
|
238
|
+
await redis.del(this._delayedKey(queue));
|
|
239
|
+
await redis.del(this._reservedKey(queue));
|
|
240
|
+
await redis.del(this._failKey(queue));
|
|
241
|
+
}
|
|
242
|
+
await redis.del(this._queuesKey());
|
|
243
|
+
await redis.del(this._idKey());
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Every queue name push() has seen, plus `default`. */
|
|
247
|
+
private async _allQueueNames(): Promise<string[]> {
|
|
248
|
+
const redis = await this._redis();
|
|
249
|
+
const queues = (await redis.smembers(this._queuesKey())) as string[] | null;
|
|
250
|
+
return [...new Set<string>(["default", ...(queues ?? [])])];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async listFailed(queue?: string): Promise<FailedRecord[]> {
|
|
254
|
+
const redis = await this._redis();
|
|
255
|
+
// Parity with SqliteDriver: with no queue given, list failures across ALL
|
|
256
|
+
// queues, not just `default` — otherwise jobs failed on a named queue are
|
|
257
|
+
// invisible to `queue:failed`/`queue:retry` and grow unbounded.
|
|
258
|
+
const queues = queue ? [queue] : await this._allQueueNames();
|
|
259
|
+
const out: FailedRecord[] = [];
|
|
260
|
+
for (const q of queues) {
|
|
261
|
+
const raws = (await redis.lrange(this._failKey(q), 0, -1)) as string[];
|
|
262
|
+
for (const raw of raws) {
|
|
263
|
+
const r = JSON.parse(raw) as JobRecord & { error?: string; failedAt?: string };
|
|
264
|
+
out.push({
|
|
265
|
+
id: r.id,
|
|
266
|
+
queue: r.queue,
|
|
267
|
+
className: r.className,
|
|
268
|
+
payload: r.payload,
|
|
269
|
+
attempts: r.attempts,
|
|
270
|
+
error: r.error ?? "",
|
|
271
|
+
failedAt: r.failedAt ?? r.createdAt,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async deleteFailedRecord(id: number, queue?: string): Promise<void> {
|
|
279
|
+
const redis = await this._redis();
|
|
280
|
+
// Failed members carry real IDs now — find the encoding and LREM it. Search
|
|
281
|
+
// every queue's failed list (not just `default`) so a named-queue failure
|
|
282
|
+
// can actually be forgotten/retried by id.
|
|
283
|
+
const queues = queue ? [queue] : await this._allQueueNames();
|
|
284
|
+
for (const q of queues) {
|
|
285
|
+
const key = this._failKey(q);
|
|
286
|
+
const raws = (await redis.lrange(key, 0, -1)) as string[];
|
|
287
|
+
for (const raw of raws) {
|
|
288
|
+
if ((JSON.parse(raw) as JobRecord).id === id) {
|
|
289
|
+
await redis.send("LREM", [key, "1", raw]);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async clearFailed(queue?: string): Promise<void> {
|
|
297
|
+
const redis = await this._redis();
|
|
298
|
+
const queues = queue ? [queue] : await this._allQueueNames();
|
|
299
|
+
for (const q of queues) await redis.del(this._failKey(q));
|
|
300
|
+
}
|
|
301
|
+
}
|