@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,366 @@
|
|
|
1
|
+
import type { SQLInstance } from "@zerotal/orm";
|
|
2
|
+
import type { QueueDriver, JobRecord, FailedRecord, BatchStatus } from "./QueueDriver.ts";
|
|
3
|
+
import type { BatchRecord, BatchOptions } from "../Batch.ts";
|
|
4
|
+
|
|
5
|
+
type JobRow = {
|
|
6
|
+
id: number;
|
|
7
|
+
queue: string;
|
|
8
|
+
class_name: string;
|
|
9
|
+
payload: string;
|
|
10
|
+
attempts: number;
|
|
11
|
+
max_attempts: number;
|
|
12
|
+
retry_delay: number;
|
|
13
|
+
available_at: number;
|
|
14
|
+
reserved_at: number | null;
|
|
15
|
+
created_at: string;
|
|
16
|
+
batch_id: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type FailedRow = {
|
|
20
|
+
id: number;
|
|
21
|
+
queue: string;
|
|
22
|
+
class_name: string;
|
|
23
|
+
payload: string;
|
|
24
|
+
attempts: number;
|
|
25
|
+
error: string;
|
|
26
|
+
failed_at: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type BatchRow = {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
total_jobs: number;
|
|
33
|
+
pending_jobs: number;
|
|
34
|
+
failed_jobs: number;
|
|
35
|
+
failed_job_ids: string;
|
|
36
|
+
options: string;
|
|
37
|
+
created_at: string;
|
|
38
|
+
finished_at: string | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class SqliteDriver implements QueueDriver {
|
|
42
|
+
private _sql: SQLInstance;
|
|
43
|
+
private _ready: Promise<void>;
|
|
44
|
+
/** Seconds a claimed job stays invisible before it is considered abandoned. */
|
|
45
|
+
private _visibilityTimeout: number;
|
|
46
|
+
|
|
47
|
+
constructor(sql: SQLInstance, options: { visibilityTimeout?: number } = {}) {
|
|
48
|
+
this._sql = sql;
|
|
49
|
+
// 90s is a sensible retry_after default. Must exceed the longest
|
|
50
|
+
// expected job runtime, or a slow (but alive) worker's job will be
|
|
51
|
+
// re-claimed by another worker.
|
|
52
|
+
this._visibilityTimeout = options.visibilityTimeout ?? 90;
|
|
53
|
+
this._ready = this._ensureTables();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async _ensureTables(): Promise<void> {
|
|
57
|
+
await this._sql`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS zerotal_jobs (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
queue TEXT NOT NULL DEFAULT 'default',
|
|
61
|
+
class_name TEXT NOT NULL,
|
|
62
|
+
payload TEXT NOT NULL,
|
|
63
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
65
|
+
retry_delay INTEGER NOT NULL DEFAULT 1000,
|
|
66
|
+
available_at INTEGER NOT NULL,
|
|
67
|
+
reserved_at INTEGER,
|
|
68
|
+
created_at TEXT NOT NULL,
|
|
69
|
+
batch_id TEXT
|
|
70
|
+
)
|
|
71
|
+
`;
|
|
72
|
+
await this._sql`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS zerotal_failed_jobs (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
queue TEXT NOT NULL,
|
|
76
|
+
class_name TEXT NOT NULL,
|
|
77
|
+
payload TEXT NOT NULL,
|
|
78
|
+
attempts INTEGER NOT NULL,
|
|
79
|
+
error TEXT NOT NULL,
|
|
80
|
+
failed_at TEXT NOT NULL
|
|
81
|
+
)
|
|
82
|
+
`;
|
|
83
|
+
await this._sql`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS zerotal_job_batches (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
name TEXT NOT NULL DEFAULT '',
|
|
87
|
+
total_jobs INTEGER NOT NULL DEFAULT 0,
|
|
88
|
+
pending_jobs INTEGER NOT NULL DEFAULT 0,
|
|
89
|
+
failed_jobs INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
failed_job_ids TEXT NOT NULL DEFAULT '[]',
|
|
91
|
+
options TEXT NOT NULL DEFAULT '{}',
|
|
92
|
+
created_at TEXT NOT NULL,
|
|
93
|
+
finished_at TEXT
|
|
94
|
+
)
|
|
95
|
+
`;
|
|
96
|
+
// Add batch_id column to existing databases that predate this migration.
|
|
97
|
+
try {
|
|
98
|
+
await this._sql`ALTER TABLE zerotal_jobs ADD COLUMN batch_id TEXT`;
|
|
99
|
+
} catch {
|
|
100
|
+
// Column already exists — safe to ignore.
|
|
101
|
+
}
|
|
102
|
+
// Add reserved_at column to existing databases that predate this migration.
|
|
103
|
+
try {
|
|
104
|
+
await this._sql`ALTER TABLE zerotal_jobs ADD COLUMN reserved_at INTEGER`;
|
|
105
|
+
} catch {
|
|
106
|
+
// Column already exists — safe to ignore.
|
|
107
|
+
}
|
|
108
|
+
await this._sql`
|
|
109
|
+
CREATE INDEX IF NOT EXISTS zerotal_jobs_pop_idx
|
|
110
|
+
ON zerotal_jobs (queue, available_at, reserved_at)
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private _mapRow(row: JobRow): JobRecord {
|
|
115
|
+
return {
|
|
116
|
+
id: row.id,
|
|
117
|
+
queue: row.queue,
|
|
118
|
+
className: row.class_name,
|
|
119
|
+
payload: row.payload,
|
|
120
|
+
attempts: row.attempts,
|
|
121
|
+
maxAttempts: row.max_attempts,
|
|
122
|
+
retryDelay: row.retry_delay,
|
|
123
|
+
availableAt: row.available_at,
|
|
124
|
+
createdAt: row.created_at,
|
|
125
|
+
batchId: row.batch_id ?? undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private _mapBatchRow(row: BatchRow): BatchRecord {
|
|
130
|
+
return {
|
|
131
|
+
id: row.id,
|
|
132
|
+
name: row.name,
|
|
133
|
+
totalJobs: row.total_jobs,
|
|
134
|
+
pendingJobs: row.pending_jobs,
|
|
135
|
+
failedJobs: row.failed_jobs,
|
|
136
|
+
failedJobIds: JSON.parse(row.failed_job_ids) as number[],
|
|
137
|
+
options: JSON.parse(row.options) as BatchOptions,
|
|
138
|
+
createdAt: row.created_at,
|
|
139
|
+
finishedAt: row.finished_at ?? undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async push(record: Omit<JobRecord, "id" | "createdAt">): Promise<void> {
|
|
144
|
+
await this._ready;
|
|
145
|
+
const now = new Date().toISOString();
|
|
146
|
+
const batchId = record.batchId ?? null;
|
|
147
|
+
await this._sql`
|
|
148
|
+
INSERT INTO zerotal_jobs
|
|
149
|
+
(queue, class_name, payload, attempts, max_attempts, retry_delay, available_at, created_at, batch_id)
|
|
150
|
+
VALUES
|
|
151
|
+
(${record.queue}, ${record.className}, ${record.payload},
|
|
152
|
+
${record.attempts}, ${record.maxAttempts}, ${record.retryDelay},
|
|
153
|
+
${record.availableAt}, ${now}, ${batchId})
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async pop(queue = "default"): Promise<JobRecord | null> {
|
|
158
|
+
await this._ready;
|
|
159
|
+
const now = Math.floor(Date.now() / 1000);
|
|
160
|
+
const staleBefore = now - this._visibilityTimeout;
|
|
161
|
+
|
|
162
|
+
// Claim exactly one due job with a single atomic UPDATE … RETURNING.
|
|
163
|
+
// A claimed row (reserved_at set) is invisible to other workers until the
|
|
164
|
+
// visibility timeout elapses, so concurrent `queue:work` processes can
|
|
165
|
+
// never double-process a job. If a worker crashes mid-job, reserved_at
|
|
166
|
+
// goes stale and the job becomes claimable again automatically.
|
|
167
|
+
// Statement-level atomicity in SQLite makes an explicit transaction
|
|
168
|
+
// unnecessary — the repeated reserved_at predicate on the outer UPDATE
|
|
169
|
+
// guards the SELECT-then-UPDATE window.
|
|
170
|
+
const rows = await this._sql<JobRow>`
|
|
171
|
+
UPDATE zerotal_jobs
|
|
172
|
+
SET attempts = attempts + 1,
|
|
173
|
+
reserved_at = ${now}
|
|
174
|
+
WHERE id = (
|
|
175
|
+
SELECT id FROM zerotal_jobs
|
|
176
|
+
WHERE queue = ${queue}
|
|
177
|
+
AND available_at <= ${now}
|
|
178
|
+
AND (reserved_at IS NULL OR reserved_at <= ${staleBefore})
|
|
179
|
+
ORDER BY id ASC
|
|
180
|
+
LIMIT 1
|
|
181
|
+
)
|
|
182
|
+
AND (reserved_at IS NULL OR reserved_at <= ${staleBefore})
|
|
183
|
+
RETURNING *
|
|
184
|
+
`;
|
|
185
|
+
|
|
186
|
+
const row = rows[0];
|
|
187
|
+
return row ? this._mapRow(row) : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async fail(record: JobRecord, error: string): Promise<void> {
|
|
191
|
+
await this._ready;
|
|
192
|
+
await this._sql`
|
|
193
|
+
INSERT INTO zerotal_failed_jobs
|
|
194
|
+
(queue, class_name, payload, attempts, error, failed_at)
|
|
195
|
+
VALUES
|
|
196
|
+
(${record.queue}, ${record.className}, ${record.payload},
|
|
197
|
+
${record.attempts}, ${error}, ${new Date().toISOString()})
|
|
198
|
+
`;
|
|
199
|
+
await this._sql`DELETE FROM zerotal_jobs WHERE id = ${record.id}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async retry(record: JobRecord): Promise<void> {
|
|
203
|
+
await this._ready;
|
|
204
|
+
const nextAvailable = Math.floor(Date.now() / 1000) + Math.floor(record.retryDelay / 1000);
|
|
205
|
+
// Release the reservation so the job is claimable once its delay elapses.
|
|
206
|
+
await this._sql`
|
|
207
|
+
UPDATE zerotal_jobs
|
|
208
|
+
SET available_at = ${nextAvailable},
|
|
209
|
+
reserved_at = NULL
|
|
210
|
+
WHERE id = ${record.id}
|
|
211
|
+
`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async delete(record: JobRecord): Promise<void> {
|
|
215
|
+
await this._ready;
|
|
216
|
+
await this._sql`DELETE FROM zerotal_jobs WHERE id = ${record.id}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async size(queue = "default"): Promise<number> {
|
|
220
|
+
await this._ready;
|
|
221
|
+
const rows = await this._sql<{ count: number }>`
|
|
222
|
+
SELECT COUNT(*) as count FROM zerotal_jobs
|
|
223
|
+
WHERE queue = ${queue}
|
|
224
|
+
`;
|
|
225
|
+
return Number(rows[0]?.count ?? 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async flush(): Promise<void> {
|
|
229
|
+
await this._ready;
|
|
230
|
+
await this._sql`DELETE FROM zerotal_jobs`;
|
|
231
|
+
await this._sql`DELETE FROM zerotal_failed_jobs`;
|
|
232
|
+
await this._sql`DELETE FROM zerotal_job_batches`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async listPending(queue?: string, limit = 50, offset = 0): Promise<JobRecord[]> {
|
|
236
|
+
await this._ready;
|
|
237
|
+
const rows: JobRow[] = queue
|
|
238
|
+
? await this._sql<JobRow>`
|
|
239
|
+
SELECT * FROM zerotal_jobs WHERE queue = ${queue}
|
|
240
|
+
ORDER BY id ASC LIMIT ${limit} OFFSET ${offset}`
|
|
241
|
+
: await this._sql<JobRow>`
|
|
242
|
+
SELECT * FROM zerotal_jobs
|
|
243
|
+
ORDER BY id ASC LIMIT ${limit} OFFSET ${offset}`;
|
|
244
|
+
return rows.map((r) => this._mapRow(r));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async queues(): Promise<{ queue: string; pending: number }[]> {
|
|
248
|
+
await this._ready;
|
|
249
|
+
const rows = await this._sql<{ queue: string; count: number }>`
|
|
250
|
+
SELECT queue, COUNT(*) as count FROM zerotal_jobs GROUP BY queue ORDER BY queue`;
|
|
251
|
+
return rows.map((r) => ({ queue: r.queue, pending: Number(r.count) }));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async listFailed(queue?: string): Promise<FailedRecord[]> {
|
|
255
|
+
await this._ready;
|
|
256
|
+
const rows: FailedRow[] = queue
|
|
257
|
+
? await this._sql<FailedRow>`
|
|
258
|
+
SELECT id, queue, class_name, payload, attempts, error, failed_at
|
|
259
|
+
FROM zerotal_failed_jobs WHERE queue = ${queue} ORDER BY failed_at DESC`
|
|
260
|
+
: await this._sql<FailedRow>`
|
|
261
|
+
SELECT id, queue, class_name, payload, attempts, error, failed_at
|
|
262
|
+
FROM zerotal_failed_jobs ORDER BY failed_at DESC`;
|
|
263
|
+
|
|
264
|
+
return rows.map((r) => ({
|
|
265
|
+
id: r.id,
|
|
266
|
+
queue: r.queue,
|
|
267
|
+
className: r.class_name,
|
|
268
|
+
payload: r.payload,
|
|
269
|
+
attempts: r.attempts,
|
|
270
|
+
error: r.error,
|
|
271
|
+
failedAt: r.failed_at,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async deleteFailedRecord(id: number): Promise<void> {
|
|
276
|
+
await this._ready;
|
|
277
|
+
await this._sql`DELETE FROM zerotal_failed_jobs WHERE id = ${id}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async clearFailed(queue?: string): Promise<void> {
|
|
281
|
+
await this._ready;
|
|
282
|
+
if (queue) {
|
|
283
|
+
await this._sql`DELETE FROM zerotal_failed_jobs WHERE queue = ${queue}`;
|
|
284
|
+
} else {
|
|
285
|
+
await this._sql`DELETE FROM zerotal_failed_jobs`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Batch support ──────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
async createBatch(
|
|
292
|
+
id: string,
|
|
293
|
+
name: string,
|
|
294
|
+
totalJobs: number,
|
|
295
|
+
optionsJson: string,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
await this._ready;
|
|
298
|
+
const now = new Date().toISOString();
|
|
299
|
+
await this._sql`
|
|
300
|
+
INSERT INTO zerotal_job_batches
|
|
301
|
+
(id, name, total_jobs, pending_jobs, failed_jobs, failed_job_ids, options, created_at)
|
|
302
|
+
VALUES
|
|
303
|
+
(${id}, ${name}, ${totalJobs}, ${totalJobs}, 0, '[]', ${optionsJson}, ${now})
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async recordBatchJobComplete(batchId: string): Promise<BatchStatus> {
|
|
308
|
+
await this._ready;
|
|
309
|
+
return this._sql.begin(async (tx: SQLInstance) => {
|
|
310
|
+
await tx`
|
|
311
|
+
UPDATE zerotal_job_batches
|
|
312
|
+
SET pending_jobs = pending_jobs - 1
|
|
313
|
+
WHERE id = ${batchId}
|
|
314
|
+
`;
|
|
315
|
+
const rows = await tx<{ pending_jobs: number; failed_jobs: number }>`
|
|
316
|
+
SELECT pending_jobs, failed_jobs FROM zerotal_job_batches WHERE id = ${batchId}
|
|
317
|
+
`;
|
|
318
|
+
const row = rows[0];
|
|
319
|
+
if (!row || row.pending_jobs > 0) return "pending";
|
|
320
|
+
|
|
321
|
+
await tx`
|
|
322
|
+
UPDATE zerotal_job_batches SET finished_at = ${new Date().toISOString()} WHERE id = ${batchId}
|
|
323
|
+
`;
|
|
324
|
+
return row.failed_jobs > 0 ? "failed" : "complete";
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async recordBatchJobFailed(batchId: string, jobId: number): Promise<BatchStatus> {
|
|
329
|
+
await this._ready;
|
|
330
|
+
return this._sql.begin(async (tx: SQLInstance) => {
|
|
331
|
+
const current = await tx<{ failed_job_ids: string }>`
|
|
332
|
+
SELECT failed_job_ids FROM zerotal_job_batches WHERE id = ${batchId}
|
|
333
|
+
`;
|
|
334
|
+
if (!current[0]) return "pending";
|
|
335
|
+
|
|
336
|
+
const ids = JSON.parse(current[0].failed_job_ids) as number[];
|
|
337
|
+
ids.push(jobId);
|
|
338
|
+
const idsJson = JSON.stringify(ids);
|
|
339
|
+
|
|
340
|
+
await tx`
|
|
341
|
+
UPDATE zerotal_job_batches
|
|
342
|
+
SET pending_jobs = pending_jobs - 1,
|
|
343
|
+
failed_jobs = failed_jobs + 1,
|
|
344
|
+
failed_job_ids = ${idsJson}
|
|
345
|
+
WHERE id = ${batchId}
|
|
346
|
+
`;
|
|
347
|
+
const rows = await tx<{ pending_jobs: number }>`
|
|
348
|
+
SELECT pending_jobs FROM zerotal_job_batches WHERE id = ${batchId}
|
|
349
|
+
`;
|
|
350
|
+
if (!rows[0] || rows[0].pending_jobs > 0) return "pending";
|
|
351
|
+
|
|
352
|
+
await tx`
|
|
353
|
+
UPDATE zerotal_job_batches SET finished_at = ${new Date().toISOString()} WHERE id = ${batchId}
|
|
354
|
+
`;
|
|
355
|
+
return "failed";
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async getBatch(batchId: string): Promise<BatchRecord | null> {
|
|
360
|
+
await this._ready;
|
|
361
|
+
const rows = await this._sql<BatchRow>`
|
|
362
|
+
SELECT * FROM zerotal_job_batches WHERE id = ${batchId}
|
|
363
|
+
`;
|
|
364
|
+
return rows[0] ? this._mapBatchRow(rows[0]) : null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { QueueDriver, JobRecord, FailedRecord } from "./QueueDriver.ts";
|
|
2
|
+
import type { Job } from "../Job.ts";
|
|
3
|
+
|
|
4
|
+
type SyncQueueEntry = { record: JobRecord; job: Job | undefined };
|
|
5
|
+
|
|
6
|
+
type FailedEntry = { record: JobRecord; error: string };
|
|
7
|
+
|
|
8
|
+
export class SyncDriver implements QueueDriver {
|
|
9
|
+
private _queue: SyncQueueEntry[] = [];
|
|
10
|
+
private _failed: FailedEntry[] = [];
|
|
11
|
+
|
|
12
|
+
// Side channel — QueueManager sets this before calling push()
|
|
13
|
+
static _pendingJob: Job | undefined = undefined;
|
|
14
|
+
|
|
15
|
+
async push(record: Omit<JobRecord, "id" | "createdAt">): Promise<void> {
|
|
16
|
+
const entry: SyncQueueEntry = {
|
|
17
|
+
record: {
|
|
18
|
+
...record,
|
|
19
|
+
id: this._queue.length + 1,
|
|
20
|
+
createdAt: new Date().toISOString(),
|
|
21
|
+
},
|
|
22
|
+
job: SyncDriver._pendingJob,
|
|
23
|
+
};
|
|
24
|
+
this._queue.push(entry);
|
|
25
|
+
SyncDriver._pendingJob = undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async pop(): Promise<JobRecord | null> {
|
|
29
|
+
const item = this._queue.shift();
|
|
30
|
+
if (!item) return null;
|
|
31
|
+
item.record.attempts += 1;
|
|
32
|
+
return item.record;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async fail(record: JobRecord, error: string): Promise<void> {
|
|
36
|
+
this._failed.push({ record, error });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async retry(record: JobRecord): Promise<void> {
|
|
40
|
+
this._queue.unshift({ record, job: SyncDriver._pendingJob });
|
|
41
|
+
SyncDriver._pendingJob = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async delete(_record: JobRecord): Promise<void> {
|
|
45
|
+
// Already consumed by pop() — nothing to delete
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async size(): Promise<number> {
|
|
49
|
+
return this._queue.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async flush(): Promise<void> {
|
|
53
|
+
this._queue = [];
|
|
54
|
+
this._failed = [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get failedJobs(): FailedEntry[] {
|
|
58
|
+
return this._failed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async listFailed(queue?: string): Promise<FailedRecord[]> {
|
|
62
|
+
const src = queue ? this._failed.filter((f) => f.record.queue === queue) : this._failed;
|
|
63
|
+
return src.map((f, i) => ({
|
|
64
|
+
id: i + 1,
|
|
65
|
+
queue: f.record.queue,
|
|
66
|
+
className: f.record.className,
|
|
67
|
+
payload: f.record.payload,
|
|
68
|
+
attempts: f.record.attempts,
|
|
69
|
+
error: f.error,
|
|
70
|
+
failedAt: new Date().toISOString(),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async deleteFailedRecord(id: number): Promise<void> {
|
|
75
|
+
this._failed.splice(id - 1, 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async clearFailed(queue?: string): Promise<void> {
|
|
79
|
+
if (queue) {
|
|
80
|
+
this._failed = this._failed.filter((f) => f.record.queue !== queue);
|
|
81
|
+
} else {
|
|
82
|
+
this._failed = [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for all @zerotal/queue errors. */
|
|
4
|
+
export class QueueError extends ZerotalError {
|
|
5
|
+
constructor(message: string, code = "E_QUEUE", status = 500, context?: Record<string, unknown>) {
|
|
6
|
+
super(message, code, status, context);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Thrown when the Bus facade is used before QueueProvider is registered. */
|
|
11
|
+
export class QueueNotInitializedError extends QueueError {
|
|
12
|
+
constructor() {
|
|
13
|
+
super(
|
|
14
|
+
"[Zerotal] Bus is not initialized. Register QueueProvider first.",
|
|
15
|
+
"E_QUEUE_NOT_INITIALIZED",
|
|
16
|
+
500,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Thrown when dispatching while the manager is shutting down. */
|
|
22
|
+
export class QueueShuttingDownError extends QueueError {
|
|
23
|
+
constructor(className: string) {
|
|
24
|
+
super(
|
|
25
|
+
`Cannot dispatch ${className} — QueueManager is shutting down.`,
|
|
26
|
+
"E_QUEUE_SHUTTING_DOWN",
|
|
27
|
+
503,
|
|
28
|
+
{ className },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Thrown when batching is requested on a driver that does not support it. */
|
|
34
|
+
export class QueueBatchingUnsupportedError extends QueueError {
|
|
35
|
+
constructor() {
|
|
36
|
+
super(
|
|
37
|
+
"[Zerotal] The current queue driver does not support job batching. Use SqliteDriver.",
|
|
38
|
+
"E_QUEUE_BATCHING_UNSUPPORTED",
|
|
39
|
+
500,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The queue package's framework events, emitted on core's {@link FrameworkEvents}
|
|
3
|
+
* bus. Observability packages subscribe to them by kind (their class name).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Emitted at each stage of a background job's life — `status` is one of
|
|
8
|
+
* `dispatched`, `completed`, `failed`, or `retried`, with `error` set on
|
|
9
|
+
* failure. A single job fires several of these over its lifetime.
|
|
10
|
+
*
|
|
11
|
+
* @category Queue
|
|
12
|
+
*/
|
|
13
|
+
export class JobRan {
|
|
14
|
+
constructor(
|
|
15
|
+
readonly className: string,
|
|
16
|
+
readonly queue: string,
|
|
17
|
+
readonly status: "dispatched" | "completed" | "failed" | "retried",
|
|
18
|
+
readonly durationMs: number,
|
|
19
|
+
readonly error?: string,
|
|
20
|
+
) {}
|
|
21
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Ambient declarations specific to this package.
|
|
2
|
+
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
+
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
+
|
|
5
|
+
// ── Bun globals ───────────────────────────────────────────────────────────
|
|
6
|
+
// Bun extends Request with native route params (e.g. /users/:id → { id: '42' })
|
|
7
|
+
interface Request {
|
|
8
|
+
readonly params?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── SQLInstance: callable tagged-template + DB methods ────────────────────
|
|
12
|
+
interface SQLInstance {
|
|
13
|
+
<T = Record<string, unknown>>(
|
|
14
|
+
strings: TemplateStringsArray,
|
|
15
|
+
...values: unknown[]
|
|
16
|
+
): Promise<T[]>;
|
|
17
|
+
begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
|
|
18
|
+
end(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── RedisInstance: minimal Bun.redis surface ──────────────────────────────
|
|
22
|
+
interface RedisInstance {
|
|
23
|
+
get(key: string): Promise<string | null>;
|
|
24
|
+
set(key: string, value: string): Promise<void>;
|
|
25
|
+
set(key: string, value: string, options: { ex: number }): Promise<void>;
|
|
26
|
+
lpush(key: string, value: string): Promise<number>;
|
|
27
|
+
rpop(key: string): Promise<string | null>;
|
|
28
|
+
lrange(key: string, start: number, stop: number): Promise<string[]>;
|
|
29
|
+
llen(key: string): Promise<number>;
|
|
30
|
+
del(...keys: string[]): Promise<number>;
|
|
31
|
+
expire(key: string, seconds: number): Promise<number>;
|
|
32
|
+
keys(pattern: string): Promise<string[]>;
|
|
33
|
+
flushdb(): Promise<void>;
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { Job } from "./Job.ts";
|
|
2
|
+
export { JobRegistry } from "./JobRegistry.ts";
|
|
3
|
+
export { QueueManager, queueStats } from "./QueueManager.ts";
|
|
4
|
+
export type { JobStatus, QueueStats } from "./QueueManager.ts";
|
|
5
|
+
export { WorkerPool } from "./WorkerPool.ts";
|
|
6
|
+
export type { WorkerPoolOptions, WorkerResult } from "./WorkerPool.ts";
|
|
7
|
+
export { QueueFake } from "./QueueFake.ts";
|
|
8
|
+
export { QueueProvider } from "./provider/QueueProvider.ts";
|
|
9
|
+
export { Queue } from "./facades/Queue.ts";
|
|
10
|
+
export type { QueueDriver, JobRecord, BatchStatus } from "./drivers/QueueDriver.ts";
|
|
11
|
+
export { SqliteDriver } from "./drivers/SqliteDriver.ts";
|
|
12
|
+
export { SyncDriver } from "./drivers/SyncDriver.ts";
|
|
13
|
+
export { RedisDriver } from "./drivers/RedisDriver.ts";
|
|
14
|
+
|
|
15
|
+
// Batch & chain orchestration
|
|
16
|
+
export { Bus } from "./Bus.ts";
|
|
17
|
+
export { Batch } from "./Batch.ts";
|
|
18
|
+
export { PendingBatch } from "./PendingBatch.ts";
|
|
19
|
+
export type { BatchRecord, BatchOptions, SerializedJob } from "./Batch.ts";
|
|
20
|
+
|
|
21
|
+
// Config factory
|
|
22
|
+
export { QueueConfig } from "./config.ts";
|
|
23
|
+
export type { QueueConfigShape } from "./config.ts";
|
|
24
|
+
|
|
25
|
+
// Typed error vocabulary
|
|
26
|
+
export * from "./errors.ts";
|
|
27
|
+
|
|
28
|
+
// Framework instrumentation events (emitted on the core FrameworkEvents bus)
|
|
29
|
+
export { JobRan } from "./events.ts";
|