@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
package/src/admin.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue → admin panel contribution.
|
|
3
|
+
*
|
|
4
|
+
* The queue already knows how to introspect itself — failed jobs, pending depth
|
|
5
|
+
* per queue, retry, forget, flush. This module describes that as a console the
|
|
6
|
+
* admin panel renders, so operators get the same operations the CLI commands
|
|
7
|
+
* expose without an SSH session.
|
|
8
|
+
*
|
|
9
|
+
* The panel's write surface is resolved from the container by binding key and
|
|
10
|
+
* typed through a local structural interface, exactly as the observer bridges in
|
|
11
|
+
* `observability.ts` are: the queue depends on `@zerotal/admin` not at all, and
|
|
12
|
+
* an app running the queue without the panel pulls in nothing extra — the binding
|
|
13
|
+
* simply isn't there and this returns.
|
|
14
|
+
*/
|
|
15
|
+
import type { Application } from "@zerotal/core";
|
|
16
|
+
import { queueStats } from "./QueueManager.ts";
|
|
17
|
+
import { Queue } from "./facades/Queue.ts";
|
|
18
|
+
|
|
19
|
+
/** The slice of the admin panel's contribution surface this module uses. */
|
|
20
|
+
interface AdminPanelSink {
|
|
21
|
+
enabled(id: string): boolean;
|
|
22
|
+
console(contribution: ConsoleSpec): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Row = Record<string, unknown>;
|
|
26
|
+
type Tone = "primary" | "success" | "muted" | "destructive" | "default";
|
|
27
|
+
|
|
28
|
+
interface ConsoleSpec {
|
|
29
|
+
slug: string;
|
|
30
|
+
title: string;
|
|
31
|
+
ability: string;
|
|
32
|
+
navigationIcon?: string;
|
|
33
|
+
navigationGroup?: string;
|
|
34
|
+
navigationSort?: number;
|
|
35
|
+
navigationBadge?: () => Promise<string | number | null>;
|
|
36
|
+
navigationBadgeColor?: Tone;
|
|
37
|
+
tabs: Array<{
|
|
38
|
+
key: string;
|
|
39
|
+
label: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
columns: Array<{
|
|
42
|
+
key: string;
|
|
43
|
+
label: string;
|
|
44
|
+
align?: "start" | "center" | "end";
|
|
45
|
+
mono?: boolean;
|
|
46
|
+
format?: (value: unknown, row: Row) => string;
|
|
47
|
+
badge?: (value: unknown, row: Row) => Tone | null;
|
|
48
|
+
}>;
|
|
49
|
+
rows: () => Promise<Row[]>;
|
|
50
|
+
rowKey?: string;
|
|
51
|
+
rowActions?: Array<{
|
|
52
|
+
key: string;
|
|
53
|
+
label: string;
|
|
54
|
+
icon?: string;
|
|
55
|
+
danger?: boolean;
|
|
56
|
+
confirm?: string;
|
|
57
|
+
run: (row: Row) => Promise<string | void>;
|
|
58
|
+
}>;
|
|
59
|
+
headerActions?: Array<{
|
|
60
|
+
key: string;
|
|
61
|
+
label: string;
|
|
62
|
+
icon?: string;
|
|
63
|
+
danger?: boolean;
|
|
64
|
+
confirm?: string;
|
|
65
|
+
run: () => Promise<string | void>;
|
|
66
|
+
}>;
|
|
67
|
+
empty?: string;
|
|
68
|
+
badge?: () => Promise<number | null>;
|
|
69
|
+
}>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The ability an operator needs to see and act on the queue console. */
|
|
73
|
+
const ABILITY = "queue.view";
|
|
74
|
+
|
|
75
|
+
/** Trim a stack trace down to its first line — the table shows the summary. */
|
|
76
|
+
function firstLine(value: unknown): string {
|
|
77
|
+
const text = value === null || value === undefined ? "" : String(value);
|
|
78
|
+
const line = text.split("\n")[0]?.trim() ?? "";
|
|
79
|
+
return line.length > 140 ? `${line.slice(0, 137)}…` : line || "—";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Strip the namespace off a job class so the column stays readable. */
|
|
83
|
+
function shortClass(value: unknown): string {
|
|
84
|
+
const text = String(value ?? "");
|
|
85
|
+
return text.split(/[\\/.]/).pop() || text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatTime(value: unknown): string {
|
|
89
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
90
|
+
const date = typeof value === "number" ? new Date(value * 1000) : new Date(String(value));
|
|
91
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Contribute the queue console to the admin panel, when one is installed.
|
|
96
|
+
*
|
|
97
|
+
* Call from `QueueProvider.onBooting()` — the panel binds its surface during the
|
|
98
|
+
* registration phase, so it is reachable from any provider's booting phase
|
|
99
|
+
* regardless of the order the two were registered in.
|
|
100
|
+
*/
|
|
101
|
+
export function installQueueAdmin(app: Application): void {
|
|
102
|
+
const panel = app.container.tryMake("admin.panel" as never) as AdminPanelSink | undefined;
|
|
103
|
+
if (!panel?.enabled("queue")) return;
|
|
104
|
+
|
|
105
|
+
panel.console({
|
|
106
|
+
slug: "jobs",
|
|
107
|
+
title: "Jobs",
|
|
108
|
+
ability: ABILITY,
|
|
109
|
+
navigationIcon: "collection",
|
|
110
|
+
navigationGroup: "Operations",
|
|
111
|
+
navigationBadgeColor: "destructive",
|
|
112
|
+
// A failed-job count in the sidebar is the whole point of putting the queue in
|
|
113
|
+
// front of an operator: it is the number you want to notice without looking.
|
|
114
|
+
navigationBadge: async () => {
|
|
115
|
+
const failed = await Queue.failed();
|
|
116
|
+
return failed.length > 0 ? failed.length : null;
|
|
117
|
+
},
|
|
118
|
+
tabs: [
|
|
119
|
+
{
|
|
120
|
+
key: "failed",
|
|
121
|
+
label: "Failed",
|
|
122
|
+
description: "Jobs that exhausted their attempts. Retry puts one back on its queue.",
|
|
123
|
+
empty: "No failed jobs.",
|
|
124
|
+
badge: async () => (await Queue.failed()).length,
|
|
125
|
+
columns: [
|
|
126
|
+
{ key: "id", label: "ID", mono: true },
|
|
127
|
+
{ key: "queue", label: "Queue", badge: () => "muted" },
|
|
128
|
+
{ key: "className", label: "Job", format: shortClass },
|
|
129
|
+
{ key: "attempts", label: "Attempts", align: "end" },
|
|
130
|
+
{ key: "error", label: "Error", mono: true, format: firstLine },
|
|
131
|
+
{ key: "failedAt", label: "Failed", format: formatTime },
|
|
132
|
+
],
|
|
133
|
+
// Projected field by field rather than passed straight through: the
|
|
134
|
+
// driver record carries the serialized job payload, which has no place
|
|
135
|
+
// in a table an operator reads over someone's shoulder.
|
|
136
|
+
rows: async () =>
|
|
137
|
+
(await Queue.failed()).map((r) => ({
|
|
138
|
+
id: r.id,
|
|
139
|
+
queue: r.queue,
|
|
140
|
+
className: r.className,
|
|
141
|
+
attempts: r.attempts,
|
|
142
|
+
error: r.error,
|
|
143
|
+
failedAt: r.failedAt,
|
|
144
|
+
})),
|
|
145
|
+
rowActions: [
|
|
146
|
+
{
|
|
147
|
+
key: "retry",
|
|
148
|
+
label: "Retry",
|
|
149
|
+
icon: "undo",
|
|
150
|
+
run: async (row) => {
|
|
151
|
+
const ok = await Queue.retryFailed(Number(row["id"]));
|
|
152
|
+
if (!ok) throw new Error("That job is no longer in the failed table.");
|
|
153
|
+
return `Job ${String(row["id"])} queued for retry.`;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
key: "forget",
|
|
158
|
+
label: "Forget",
|
|
159
|
+
icon: "trash",
|
|
160
|
+
danger: true,
|
|
161
|
+
confirm: "Delete this failed job? It cannot be retried afterwards.",
|
|
162
|
+
run: async (row) => {
|
|
163
|
+
await Queue.forgetFailed(Number(row["id"]));
|
|
164
|
+
return `Job ${String(row["id"])} deleted.`;
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
headerActions: [
|
|
169
|
+
{
|
|
170
|
+
key: "clear-failed",
|
|
171
|
+
label: "Clear failed",
|
|
172
|
+
icon: "trash",
|
|
173
|
+
danger: true,
|
|
174
|
+
confirm: "Delete every failed job? They cannot be retried afterwards.",
|
|
175
|
+
run: async () => {
|
|
176
|
+
await Queue.clearFailed();
|
|
177
|
+
return "Failed jobs cleared.";
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
key: "pending",
|
|
184
|
+
label: "Pending",
|
|
185
|
+
description: "Jobs waiting to be picked up by a worker.",
|
|
186
|
+
empty: "Nothing waiting.",
|
|
187
|
+
badge: async () => (await Queue.pending()).length,
|
|
188
|
+
columns: [
|
|
189
|
+
{ key: "id", label: "ID", mono: true },
|
|
190
|
+
{ key: "queue", label: "Queue", badge: () => "muted" },
|
|
191
|
+
{ key: "className", label: "Job", format: shortClass },
|
|
192
|
+
{ key: "attempts", label: "Attempts", align: "end" },
|
|
193
|
+
{ key: "availableAt", label: "Available", format: formatTime },
|
|
194
|
+
{ key: "createdAt", label: "Queued", format: formatTime },
|
|
195
|
+
],
|
|
196
|
+
rows: async () =>
|
|
197
|
+
(await Queue.pending()).map((r) => ({
|
|
198
|
+
id: r.id,
|
|
199
|
+
queue: r.queue,
|
|
200
|
+
className: r.className,
|
|
201
|
+
attempts: r.attempts,
|
|
202
|
+
availableAt: r.availableAt,
|
|
203
|
+
createdAt: r.createdAt,
|
|
204
|
+
})),
|
|
205
|
+
headerActions: [
|
|
206
|
+
{
|
|
207
|
+
key: "flush",
|
|
208
|
+
label: "Flush",
|
|
209
|
+
icon: "trash",
|
|
210
|
+
danger: true,
|
|
211
|
+
confirm: "Discard every pending job? This cannot be undone.",
|
|
212
|
+
run: async () => {
|
|
213
|
+
await Queue.flush();
|
|
214
|
+
return "Pending jobs flushed.";
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
key: "queues",
|
|
221
|
+
label: "Queues",
|
|
222
|
+
description: "Pending depth per queue, plus totals since this process started.",
|
|
223
|
+
empty: "No queues have been used yet.",
|
|
224
|
+
columns: [
|
|
225
|
+
{ key: "queue", label: "Queue" },
|
|
226
|
+
{ key: "pending", label: "Pending", align: "end" },
|
|
227
|
+
],
|
|
228
|
+
rowKey: "queue",
|
|
229
|
+
rows: async () =>
|
|
230
|
+
(await Queue.queues()).map((q) => ({ queue: q.queue, pending: q.pending })),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
key: "throughput",
|
|
234
|
+
label: "Throughput",
|
|
235
|
+
description: "Counters kept by this process since it booted — not persisted history.",
|
|
236
|
+
empty: "Nothing processed yet.",
|
|
237
|
+
columns: [
|
|
238
|
+
{ key: "metric", label: "Metric" },
|
|
239
|
+
{ key: "value", label: "Count", align: "end" },
|
|
240
|
+
],
|
|
241
|
+
rowKey: "metric",
|
|
242
|
+
rows: async () => {
|
|
243
|
+
const s = queueStats();
|
|
244
|
+
return [
|
|
245
|
+
{ metric: "Processed (total)", value: s.processedTotal },
|
|
246
|
+
{ metric: "Processed (last 5m)", value: s.processedLast5m },
|
|
247
|
+
{ metric: "Failed (total)", value: s.failedTotal },
|
|
248
|
+
{ metric: "Failed (last 5m)", value: s.failedLast5m },
|
|
249
|
+
];
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Application } from "@zerotal/core";
|
|
2
|
+
import { Command } from "@zerotal/core";
|
|
3
|
+
import type { QueueManager } from "../QueueManager.ts";
|
|
4
|
+
import type { QueueDriver } from "../drivers/QueueDriver.ts";
|
|
5
|
+
|
|
6
|
+
export class QueueFailedCommand extends Command {
|
|
7
|
+
static override commandName = "queue:failed";
|
|
8
|
+
static override description = "List all failed jobs";
|
|
9
|
+
static override needsApp = true;
|
|
10
|
+
|
|
11
|
+
static override flags = [
|
|
12
|
+
{
|
|
13
|
+
name: "queue",
|
|
14
|
+
short: "q",
|
|
15
|
+
type: "string" as const,
|
|
16
|
+
description: "Filter by queue name",
|
|
17
|
+
default: "",
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
async run(): Promise<void> {
|
|
22
|
+
const app = this.app as Application | undefined;
|
|
23
|
+
const driver = this._driver(app);
|
|
24
|
+
if (!driver) return;
|
|
25
|
+
|
|
26
|
+
const queueName = (this.flags["queue"] as string | undefined) ?? "";
|
|
27
|
+
const records = await driver.listFailed(queueName || undefined);
|
|
28
|
+
|
|
29
|
+
if (records.length === 0) {
|
|
30
|
+
this.info("No failed jobs found.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.section(`Failed jobs (${records.length})`);
|
|
35
|
+
for (const r of records) {
|
|
36
|
+
const err = r.error.length > 70 ? r.error.slice(0, 70) + "…" : r.error;
|
|
37
|
+
this.table([
|
|
38
|
+
["ID", String(r.id)],
|
|
39
|
+
["Queue", r.queue],
|
|
40
|
+
["Class", r.className],
|
|
41
|
+
["Attempts", String(r.attempts)],
|
|
42
|
+
["Failed at", r.failedAt],
|
|
43
|
+
["Error", err],
|
|
44
|
+
]);
|
|
45
|
+
this.newLine();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private _driver(app: Application | undefined): QueueDriver | null {
|
|
50
|
+
const manager = app?.container.tryMake("queue") as QueueManager | undefined;
|
|
51
|
+
if (!manager) {
|
|
52
|
+
this.error("Queue driver not registered.");
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return (manager as unknown as { _driver: QueueDriver })._driver;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Application } from "@zerotal/core";
|
|
2
|
+
import { Command } from "@zerotal/core";
|
|
3
|
+
import type { QueueManager } from "../QueueManager.ts";
|
|
4
|
+
import type { QueueDriver } from "../drivers/QueueDriver.ts";
|
|
5
|
+
|
|
6
|
+
export class QueueFlushCommand extends Command {
|
|
7
|
+
static override commandName = "queue:flush";
|
|
8
|
+
static override description = "Delete all failed jobs from the database";
|
|
9
|
+
static override needsApp = true;
|
|
10
|
+
|
|
11
|
+
static override flags = [
|
|
12
|
+
{
|
|
13
|
+
name: "queue",
|
|
14
|
+
short: "q",
|
|
15
|
+
type: "string" as const,
|
|
16
|
+
description: "Only flush failed jobs from the given queue",
|
|
17
|
+
default: "",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "force",
|
|
21
|
+
type: "boolean" as const,
|
|
22
|
+
description: "Skip confirmation prompt",
|
|
23
|
+
default: false,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
async run(): Promise<void> {
|
|
28
|
+
const app = this.app as Application | undefined;
|
|
29
|
+
const driver = this._driver(app);
|
|
30
|
+
if (!driver) return;
|
|
31
|
+
|
|
32
|
+
const queueName = (this.flags["queue"] as string | undefined) ?? "";
|
|
33
|
+
const force = (this.flags["force"] as boolean | undefined) ?? false;
|
|
34
|
+
|
|
35
|
+
if (!force) {
|
|
36
|
+
const target = queueName ? `queue "${queueName}"` : "all queues";
|
|
37
|
+
const ok = await this.confirm(
|
|
38
|
+
`This will permanently delete all failed jobs for ${target}. Proceed?`,
|
|
39
|
+
);
|
|
40
|
+
if (!ok) {
|
|
41
|
+
this.info("Aborted.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await driver.clearFailed(queueName || undefined);
|
|
47
|
+
|
|
48
|
+
this.info(
|
|
49
|
+
queueName ? `Flushed failed jobs for queue "${queueName}".` : "Flushed all failed jobs.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private _driver(app: Application | undefined): QueueDriver | null {
|
|
54
|
+
const manager = app?.container.tryMake("queue") as QueueManager | undefined;
|
|
55
|
+
if (!manager) {
|
|
56
|
+
this.error("Queue driver not registered.");
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return (manager as unknown as { _driver: QueueDriver })._driver;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Application } from "@zerotal/core";
|
|
2
|
+
import { Command } from "@zerotal/core";
|
|
3
|
+
import type { QueueManager } from "../QueueManager.ts";
|
|
4
|
+
import type { QueueDriver } from "../drivers/QueueDriver.ts";
|
|
5
|
+
import { JobRegistry } from "../JobRegistry.ts";
|
|
6
|
+
import type { Job } from "../Job.ts";
|
|
7
|
+
|
|
8
|
+
export class QueueRetryCommand extends Command {
|
|
9
|
+
static override commandName = "queue:retry";
|
|
10
|
+
static override description = "Retry a failed job by ID (or 'all' to retry everything)";
|
|
11
|
+
static override needsApp = true;
|
|
12
|
+
|
|
13
|
+
static override args = [{ name: "id", required: true, description: "Failed job ID, or 'all'" }];
|
|
14
|
+
|
|
15
|
+
async run(): Promise<void> {
|
|
16
|
+
const app = this.app as Application | undefined;
|
|
17
|
+
const driver = this._driver(app);
|
|
18
|
+
if (!driver) return;
|
|
19
|
+
|
|
20
|
+
const idArg = this.args["id"] as string;
|
|
21
|
+
const all = idArg === "all";
|
|
22
|
+
const id = all ? null : Number(idArg);
|
|
23
|
+
|
|
24
|
+
if (!all && isNaN(id!)) {
|
|
25
|
+
this.error(`Invalid ID "${idArg}". Pass a numeric ID or "all".`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const failed = await driver.listFailed();
|
|
30
|
+
const targets = all ? failed : failed.filter((r) => r.id === id);
|
|
31
|
+
|
|
32
|
+
if (targets.length === 0) {
|
|
33
|
+
this.error(all ? "No failed jobs found." : `No failed job found with ID ${id}.`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let retried = 0;
|
|
38
|
+
for (const record of targets) {
|
|
39
|
+
const JobClass = JobRegistry.resolve(record.className);
|
|
40
|
+
|
|
41
|
+
let instance: Job;
|
|
42
|
+
if (!JobClass) {
|
|
43
|
+
this.warn(`Unknown job class "${record.className}" — skipping ID ${record.id}.`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const payload = JSON.parse(record.payload) as Record<string, unknown>;
|
|
48
|
+
if (
|
|
49
|
+
"fromPayload" in JobClass &&
|
|
50
|
+
typeof (JobClass as Record<string, unknown>)["fromPayload"] === "function"
|
|
51
|
+
) {
|
|
52
|
+
instance = (JobClass as { fromPayload(p: Record<string, unknown>): Job }).fromPayload(
|
|
53
|
+
payload,
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
instance = new (JobClass as new () => Job)();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await driver.push({
|
|
60
|
+
queue: record.queue,
|
|
61
|
+
className: record.className,
|
|
62
|
+
payload: record.payload,
|
|
63
|
+
attempts: 0,
|
|
64
|
+
maxAttempts: instance.maxAttempts,
|
|
65
|
+
retryDelay: instance.retryDelay,
|
|
66
|
+
availableAt: Math.floor(Date.now() / 1000),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await driver.deleteFailedRecord(record.id);
|
|
70
|
+
retried++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.info(`Retried ${retried} job(s).`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private _driver(app: Application | undefined): QueueDriver | null {
|
|
77
|
+
const manager = app?.container.tryMake("queue") as QueueManager | undefined;
|
|
78
|
+
if (!manager) {
|
|
79
|
+
this.error("Queue driver not registered.");
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return (manager as unknown as { _driver: QueueDriver })._driver;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Application } from "@zerotal/core";
|
|
2
|
+
import { Command } from "@zerotal/core";
|
|
3
|
+
import type { QueueManager } from "../QueueManager.ts";
|
|
4
|
+
|
|
5
|
+
export class QueueWorkCommand extends Command {
|
|
6
|
+
static commandName = "queue:work";
|
|
7
|
+
static description = "Process jobs from the queue (run this as a daemon in production)";
|
|
8
|
+
static needsApp = true;
|
|
9
|
+
|
|
10
|
+
static flags = [
|
|
11
|
+
{
|
|
12
|
+
name: "queue",
|
|
13
|
+
short: "q",
|
|
14
|
+
type: "string" as const,
|
|
15
|
+
description: "Queue name to process",
|
|
16
|
+
default: "default",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "once",
|
|
20
|
+
type: "boolean" as const,
|
|
21
|
+
description: "Process one job then exit",
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
async run(): Promise<void> {
|
|
27
|
+
const queueName = this.flags["queue"] as string;
|
|
28
|
+
const once = this.flags["once"] as boolean;
|
|
29
|
+
const app = this.app as Application | undefined;
|
|
30
|
+
if (!app) {
|
|
31
|
+
this.error("Application not available.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const manager = app.container.makeSync("queue") as QueueManager;
|
|
36
|
+
|
|
37
|
+
if (once) {
|
|
38
|
+
const processed = await manager.processNext(queueName);
|
|
39
|
+
this.info(processed ? "Processed 1 job." : "Queue is empty.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.info(`Processing queue "${queueName}" — Ctrl+C to stop.`);
|
|
44
|
+
while (!manager.isShuttingDown) {
|
|
45
|
+
// Sleep whenever nothing was *claimable* this tick — not merely when the
|
|
46
|
+
// queue is empty. size() counts not-yet-due delayed jobs and jobs reserved
|
|
47
|
+
// by other workers, so keying the sleep off size() busy-spins at 100% CPU
|
|
48
|
+
// whenever a delayed/reserved job exists but nothing is ready.
|
|
49
|
+
const processed = await manager
|
|
50
|
+
.processNext(queueName)
|
|
51
|
+
.catch((err) => (console.error(err), false));
|
|
52
|
+
if (!processed) {
|
|
53
|
+
await new Promise<void>((r) => setTimeout(r, 500));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
this.info("Queue worker stopped.");
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { deepMerge } from "@zerotal/core";
|
|
2
|
+
import type { ConfigValidator, ConfigIssue } from "@zerotal/core/config";
|
|
3
|
+
|
|
4
|
+
export interface QueueConfigShape {
|
|
5
|
+
/** Queue driver. Default: 'sqlite' */
|
|
6
|
+
driver: "sqlite" | "redis" | "sync";
|
|
7
|
+
/** How often the worker polls for new jobs (ms). Default: 500 */
|
|
8
|
+
pollInterval: number;
|
|
9
|
+
/** Queue names the worker listens on. Default: ['default'] */
|
|
10
|
+
queues: string[];
|
|
11
|
+
/**
|
|
12
|
+
* Number of Bun Worker threads to spawn for job execution.
|
|
13
|
+
* When > 0, the HTTP server process processes jobs in background OS threads
|
|
14
|
+
* instead of on the main event loop - preserving HTTP throughput.
|
|
15
|
+
* Default: 0 (main-thread only; use queue:work command instead).
|
|
16
|
+
*/
|
|
17
|
+
workers: number;
|
|
18
|
+
/**
|
|
19
|
+
* Absolute path or file URL to a module that imports all job classes.
|
|
20
|
+
* Each imported file must call `JobRegistry.register(MyJob)` (jobs do this automatically
|
|
21
|
+
* via the self-registration pattern). Required when `workers > 0`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* workerBootstrap: new URL('../bootstrap/queue-worker.ts', import.meta.url).href
|
|
25
|
+
*/
|
|
26
|
+
workerBootstrap?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaults: QueueConfigShape = {
|
|
30
|
+
driver: "sqlite",
|
|
31
|
+
pollInterval: 500,
|
|
32
|
+
queues: ["default"],
|
|
33
|
+
workers: 0,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a typed queue configuration object with defaults.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* import { QueueConfig } from '@zerotal/queue';
|
|
41
|
+
* export default QueueConfig({ driver: 'sqlite', queues: ['default', 'emails'] });
|
|
42
|
+
*/
|
|
43
|
+
export function QueueConfig(options: Partial<QueueConfigShape> = {}): QueueConfigShape {
|
|
44
|
+
return deepMerge(defaults, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DRIVERS = new Set<string>(["sqlite", "redis", "sync"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate the `queue` config namespace at boot. Structural mistakes (an
|
|
51
|
+
* unknown driver, a worker pool with no bootstrap module, a poll interval that
|
|
52
|
+
* would busy-loop) are errors in any environment; a `sync` driver in production
|
|
53
|
+
* — where every dispatched job runs inline on the request — is flagged as a
|
|
54
|
+
* warning. Registered by {@link QueueProvider} via
|
|
55
|
+
* `app.registerConfigValidator("queue", …)`.
|
|
56
|
+
*/
|
|
57
|
+
export const validateQueueConfig: ConfigValidator = (value, { isProduction }) => {
|
|
58
|
+
const cfg = value as Partial<QueueConfigShape> | undefined;
|
|
59
|
+
const issues: ConfigIssue[] = [];
|
|
60
|
+
const driver = cfg?.driver ?? "sqlite";
|
|
61
|
+
|
|
62
|
+
if (!DRIVERS.has(driver)) {
|
|
63
|
+
issues.push({
|
|
64
|
+
level: "error",
|
|
65
|
+
message: `queue.driver "${driver}" is unknown — use "sqlite", "redis", or "sync".`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const poll = cfg?.pollInterval;
|
|
70
|
+
if (poll !== undefined && (!Number.isFinite(poll) || poll < 1)) {
|
|
71
|
+
issues.push({
|
|
72
|
+
level: "error",
|
|
73
|
+
message: `queue.pollInterval must be a positive number of milliseconds, got ${String(poll)}.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const workers = cfg?.workers;
|
|
78
|
+
if (workers !== undefined && (!Number.isInteger(workers) || workers < 0)) {
|
|
79
|
+
issues.push({
|
|
80
|
+
level: "error",
|
|
81
|
+
message: `queue.workers must be a non-negative integer, got ${String(workers)}.`,
|
|
82
|
+
});
|
|
83
|
+
} else if ((workers ?? 0) > 0 && !cfg?.workerBootstrap) {
|
|
84
|
+
issues.push({
|
|
85
|
+
level: "error",
|
|
86
|
+
message:
|
|
87
|
+
"queue.workers is set but queue.workerBootstrap is not — worker threads cannot " +
|
|
88
|
+
"deserialize jobs without a bootstrap module that imports every job class.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (cfg?.queues !== undefined && cfg.queues.length === 0) {
|
|
93
|
+
issues.push({
|
|
94
|
+
level: "warning",
|
|
95
|
+
message:
|
|
96
|
+
"queue.queues is an empty list — a worker started with this config listens on " +
|
|
97
|
+
"nothing and every queued job waits forever.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (isProduction && driver === "sync") {
|
|
102
|
+
issues.push({
|
|
103
|
+
level: "warning",
|
|
104
|
+
message:
|
|
105
|
+
'queue.driver is "sync" in production — every dispatched job runs inline on the ' +
|
|
106
|
+
"request that queued it. Use the sqlite or redis driver for real background work.",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return issues;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Register this package's config namespace for typed config() dot-paths.
|
|
114
|
+
declare module "@zerotal/core" {
|
|
115
|
+
interface ConfigRegistry {
|
|
116
|
+
queue: QueueConfigShape;
|
|
117
|
+
}
|
|
118
|
+
}
|