@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/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
+ }
@@ -0,0 +1,4 @@
1
+ export { QueueWorkCommand } from "./QueueWorkCommand.ts";
2
+ export { QueueFailedCommand } from "./QueueFailedCommand.ts";
3
+ export { QueueRetryCommand } from "./QueueRetryCommand.ts";
4
+ export { QueueFlushCommand } from "./QueueFlushCommand.ts";
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
+ }