@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.
@@ -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
+ }