@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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue → observer bridges. The queue emits its own `JobRan` framework event on
|
|
3
|
+
* the core `FrameworkEvents` bus; this module forwards it to whichever observer
|
|
4
|
+
* packages are installed.
|
|
5
|
+
*
|
|
6
|
+
* Each observer's write surface is resolved from the container by binding key and
|
|
7
|
+
* typed through a local structural interface, so the queue depends on none of the
|
|
8
|
+
* observer packages — installing or removing an observer requires no change here.
|
|
9
|
+
*/
|
|
10
|
+
import { FrameworkEvents, RequestContext } from "@zerotal/core";
|
|
11
|
+
import type { Application } from "@zerotal/core";
|
|
12
|
+
import { JobRan } from "./events.ts";
|
|
13
|
+
|
|
14
|
+
/** The subset of the telemetry tracer this bridge calls (bound as `telemetry`). */
|
|
15
|
+
interface TelemetrySink {
|
|
16
|
+
recordCompleted(
|
|
17
|
+
name: string,
|
|
18
|
+
durationMs: number,
|
|
19
|
+
options?: {
|
|
20
|
+
kind?: "internal" | "server" | "client" | "producer" | "consumer";
|
|
21
|
+
attributes?: Record<string, string | number | boolean>;
|
|
22
|
+
status?: "ok" | "error";
|
|
23
|
+
errorMessage?: string;
|
|
24
|
+
},
|
|
25
|
+
): unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
|
|
29
|
+
interface MonitorSink {
|
|
30
|
+
recordJob(job: {
|
|
31
|
+
status: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
queue?: string;
|
|
34
|
+
ms?: number;
|
|
35
|
+
error?: string | null;
|
|
36
|
+
}): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The subset of the devtools trace sink this bridge calls (bound as `devtools.trace`). */
|
|
40
|
+
interface DevtoolsSink {
|
|
41
|
+
bufferJob(
|
|
42
|
+
ctx: object,
|
|
43
|
+
j: { className: string; queue: string; status: string; durationMs: number; error?: string },
|
|
44
|
+
): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The subset of the logger this bridge calls (bound as `log`). */
|
|
48
|
+
interface LogSink {
|
|
49
|
+
warn(message: string, context?: Record<string, unknown>): void;
|
|
50
|
+
error(message: string, context?: Record<string, unknown>, error?: unknown): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Subscribe the queue's events to every installed observer. Returns a disposer
|
|
55
|
+
* that removes every subscription; call it from the queue provider's `onStopping()`.
|
|
56
|
+
*/
|
|
57
|
+
export function installQueueObservability(app: Application): () => void {
|
|
58
|
+
const unsubs: Array<() => void> = [];
|
|
59
|
+
|
|
60
|
+
const tracer = app.container.tryMake("telemetry" as never) as TelemetrySink | undefined;
|
|
61
|
+
if (tracer) {
|
|
62
|
+
unsubs.push(
|
|
63
|
+
FrameworkEvents.on(JobRan, (e) => {
|
|
64
|
+
if (e.status !== "completed" && e.status !== "failed") return;
|
|
65
|
+
void tracer.recordCompleted("queue.job", e.durationMs, {
|
|
66
|
+
kind: "consumer",
|
|
67
|
+
attributes: { "job.class": e.className, "job.queue": e.queue },
|
|
68
|
+
status: e.status === "failed" ? "error" : "ok",
|
|
69
|
+
...(e.error ? { errorMessage: e.error } : {}),
|
|
70
|
+
});
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const store = app.container.tryMake("monitor.store" as never) as MonitorSink | undefined;
|
|
76
|
+
if (store) {
|
|
77
|
+
unsubs.push(
|
|
78
|
+
FrameworkEvents.on(JobRan, (e) =>
|
|
79
|
+
store.recordJob({
|
|
80
|
+
status: e.status,
|
|
81
|
+
className: e.className,
|
|
82
|
+
queue: e.queue,
|
|
83
|
+
ms: e.durationMs,
|
|
84
|
+
error: e.error ?? null,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const trace = app.container.tryMake("devtools.trace" as never) as DevtoolsSink | undefined;
|
|
91
|
+
if (trace) {
|
|
92
|
+
unsubs.push(
|
|
93
|
+
FrameworkEvents.on(JobRan, (e) => {
|
|
94
|
+
const ctx = RequestContext.tryGet();
|
|
95
|
+
if (!ctx) return;
|
|
96
|
+
trace.bufferJob(ctx, {
|
|
97
|
+
className: e.className,
|
|
98
|
+
queue: e.queue,
|
|
99
|
+
status: e.status,
|
|
100
|
+
durationMs: e.durationMs,
|
|
101
|
+
...(e.error ? { error: e.error } : {}),
|
|
102
|
+
});
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const log = app.container.tryMake("log" as never) as LogSink | undefined;
|
|
108
|
+
if (log) {
|
|
109
|
+
unsubs.push(
|
|
110
|
+
FrameworkEvents.on(JobRan, (e) => {
|
|
111
|
+
if (e.status === "failed") {
|
|
112
|
+
log.error(
|
|
113
|
+
"Job failed",
|
|
114
|
+
{ className: e.className, queue: e.queue, durationMs: e.durationMs },
|
|
115
|
+
new Error(e.error ?? "Job execution failed"),
|
|
116
|
+
);
|
|
117
|
+
} else if (e.status === "retried") {
|
|
118
|
+
log.warn("Job retried", {
|
|
119
|
+
className: e.className,
|
|
120
|
+
queue: e.queue,
|
|
121
|
+
durationMs: e.durationMs,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
for (const unsub of unsubs) unsub();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { ServiceProvider } from "@zerotal/core";
|
|
2
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
3
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
4
|
+
import { _getConnection } from "@zerotal/orm";
|
|
5
|
+
import { QueueManager } from "../QueueManager.ts";
|
|
6
|
+
import { SqliteDriver } from "../drivers/SqliteDriver.ts";
|
|
7
|
+
import { SyncDriver } from "../drivers/SyncDriver.ts";
|
|
8
|
+
import { RedisDriver } from "../drivers/RedisDriver.ts";
|
|
9
|
+
import { WorkerPool } from "../WorkerPool.ts";
|
|
10
|
+
import { JobRegistry } from "../JobRegistry.ts";
|
|
11
|
+
import { CallQueuedListener } from "@zerotal/core";
|
|
12
|
+
import { Bus } from "../Bus.ts";
|
|
13
|
+
import { installQueueObservability } from "../observability.ts";
|
|
14
|
+
import { installQueueAdmin } from "../admin.ts";
|
|
15
|
+
import { validateQueueConfig } from "../config.ts";
|
|
16
|
+
import { frameworkLog } from "@zerotal/core/logger";
|
|
17
|
+
|
|
18
|
+
declare module "@zerotal/core" {
|
|
19
|
+
interface ContainerBindings {
|
|
20
|
+
queue: QueueManager;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class QueueProvider extends ServiceProvider {
|
|
25
|
+
static override provides = ["queue"] as const;
|
|
26
|
+
static override environments: AppEnvironment[] = ["web", "console", "worker", "test"];
|
|
27
|
+
|
|
28
|
+
private _workerInterval: ReturnType<typeof setInterval> | undefined = undefined;
|
|
29
|
+
private _workerPool: WorkerPool | undefined = undefined;
|
|
30
|
+
private _disposeObservability: (() => void) | undefined = undefined;
|
|
31
|
+
|
|
32
|
+
override onRegister(): void {
|
|
33
|
+
// Refuse a production boot on structural queue misconfiguration (unknown
|
|
34
|
+
// driver, workers without a bootstrap module). Runs in the boot-time config pass.
|
|
35
|
+
this.app.registerConfigValidator?.("queue", validateQueueConfig);
|
|
36
|
+
|
|
37
|
+
this.app.container.singleton("queue", () => {
|
|
38
|
+
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
39
|
+
const driver = config.get<string>("queue.driver", "sqlite");
|
|
40
|
+
|
|
41
|
+
switch (driver) {
|
|
42
|
+
case "redis":
|
|
43
|
+
return new QueueManager(new RedisDriver());
|
|
44
|
+
|
|
45
|
+
case "sync":
|
|
46
|
+
return new QueueManager(new SyncDriver());
|
|
47
|
+
|
|
48
|
+
case "sqlite":
|
|
49
|
+
default:
|
|
50
|
+
return new QueueManager(new SqliteDriver(_getConnection()));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override async onBooting(): Promise<void> {
|
|
56
|
+
JobRegistry.register(CallQueuedListener as never);
|
|
57
|
+
const manager = (await this.app.container.make("queue")) as QueueManager;
|
|
58
|
+
Bus.setManager(manager);
|
|
59
|
+
|
|
60
|
+
// Offer the queue console to the admin panel, if one is installed. This runs
|
|
61
|
+
// in the booting phase (not `onBooted`) because the panel mounts its routes
|
|
62
|
+
// once booting finishes — and `onBooted` runs in parallel, so contributing
|
|
63
|
+
// there would race the mount.
|
|
64
|
+
installQueueAdmin(this.app);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
override async onStarted(): Promise<void> {
|
|
68
|
+
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
69
|
+
const workerCount = config.get<number>("queue.workers", 0);
|
|
70
|
+
const env = this.app._env;
|
|
71
|
+
|
|
72
|
+
// Resolve the Bun Worker bootstrap module path. When `queue.workerBootstrap`
|
|
73
|
+
// isn't configured the worker thread discovers jobs by convention (imports
|
|
74
|
+
// every app/jobs/*.ts) — same as the `jobs` convention concern, no codegen.
|
|
75
|
+
const workerBootstrap = config.get<string | undefined>("queue.workerBootstrap", undefined);
|
|
76
|
+
|
|
77
|
+
// Start polling when:
|
|
78
|
+
// - running as a dedicated worker process (queue:work command), OR
|
|
79
|
+
// - running as the HTTP server with Bun Worker threads configured
|
|
80
|
+
const usingBunWorkers = env === "web" && workerCount > 0;
|
|
81
|
+
const isWorkerProcess = env === "worker";
|
|
82
|
+
if (!usingBunWorkers && !isWorkerProcess) return;
|
|
83
|
+
|
|
84
|
+
const queue = this.app.container.makeSync("queue") as QueueManager;
|
|
85
|
+
const pollMs = config.get<number>("queue.pollInterval", 500);
|
|
86
|
+
const queues = config.get<string[]>("queue.queues", ["default"]);
|
|
87
|
+
|
|
88
|
+
if (usingBunWorkers) {
|
|
89
|
+
// Spawn N Bun OS threads — CPU-heavy jobs run there, not on the HTTP loop.
|
|
90
|
+
this._workerPool = new WorkerPool({ size: workerCount, bootstrapPath: workerBootstrap });
|
|
91
|
+
await this._workerPool.start();
|
|
92
|
+
queue.setWorkerPool(this._workerPool);
|
|
93
|
+
frameworkLog("queue").info(`Spawned ${workerCount} Bun Worker thread(s) for job execution`, {
|
|
94
|
+
workers: workerCount,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
frameworkLog("queue").info(`Polling [${queues.join(", ")}] every ${pollMs}ms`, {
|
|
99
|
+
queues,
|
|
100
|
+
pollMs,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this._workerInterval = setInterval(async () => {
|
|
104
|
+
if (queue.isShuttingDown) return;
|
|
105
|
+
for (const q of queues) {
|
|
106
|
+
await queue.processNext(q).catch(console.error);
|
|
107
|
+
}
|
|
108
|
+
}, pollMs);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override async onStopping(): Promise<void> {
|
|
112
|
+
this._disposeObservability?.();
|
|
113
|
+
this._disposeObservability = undefined;
|
|
114
|
+
|
|
115
|
+
if (this._workerInterval !== undefined) {
|
|
116
|
+
clearInterval(this._workerInterval);
|
|
117
|
+
this._workerInterval = undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const queue = this.app.container.tryMake("queue") as QueueManager | undefined;
|
|
121
|
+
if (queue) {
|
|
122
|
+
frameworkLog("queue").info("Draining in-flight jobs");
|
|
123
|
+
await queue.drain();
|
|
124
|
+
frameworkLog("queue").info("All jobs drained");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (this._workerPool) {
|
|
128
|
+
await this._workerPool.terminate();
|
|
129
|
+
this._workerPool = undefined;
|
|
130
|
+
frameworkLog("queue").info("Bun Worker threads terminated");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
override async onBooted(): Promise<void> {
|
|
135
|
+
this._disposeObservability = installQueueObservability(this.app);
|
|
136
|
+
|
|
137
|
+
const runner = this.app.container.tryMake("commands");
|
|
138
|
+
if (!runner) return;
|
|
139
|
+
|
|
140
|
+
runner.registerLazy("queue:work", () =>
|
|
141
|
+
import("../commands/QueueWorkCommand.ts").then((m) => m.QueueWorkCommand),
|
|
142
|
+
);
|
|
143
|
+
runner.registerLazy("queue:failed", () =>
|
|
144
|
+
import("../commands/QueueFailedCommand.ts").then((m) => m.QueueFailedCommand),
|
|
145
|
+
);
|
|
146
|
+
runner.registerLazy("queue:retry", () =>
|
|
147
|
+
import("../commands/QueueRetryCommand.ts").then((m) => m.QueueRetryCommand),
|
|
148
|
+
);
|
|
149
|
+
runner.registerLazy("queue:flush", () =>
|
|
150
|
+
import("../commands/QueueFlushCommand.ts").then((m) => m.QueueFlushCommand),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun Worker thread script — runs in a separate OS thread.
|
|
3
|
+
*
|
|
4
|
+
* Protocol (messages from parent → worker):
|
|
5
|
+
* { type: 'bootstrap', modulePath: string }
|
|
6
|
+
* Dynamically import the module at `modulePath`. That module must register
|
|
7
|
+
* all job classes via JobRegistry.register(). Replies { type: 'ready' } on
|
|
8
|
+
* success or { type: 'bootstrap-error', message } on failure.
|
|
9
|
+
*
|
|
10
|
+
* { type: 'job', record: JobRecord }
|
|
11
|
+
* Instantiate and handle the job described by `record`. Replies
|
|
12
|
+
* { type: 'done' } on success or { type: 'error', message } on failure.
|
|
13
|
+
* The parent thread is responsible for delete / retry / fail storage ops.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { JobRegistry } from "./JobRegistry.ts";
|
|
17
|
+
import type { JobRecord } from "./drivers/QueueDriver.ts";
|
|
18
|
+
import type { Job } from "./Job.ts";
|
|
19
|
+
|
|
20
|
+
let bootstrapped = false;
|
|
21
|
+
|
|
22
|
+
self.onmessage = async (event: MessageEvent) => {
|
|
23
|
+
const msg = event.data as { type: string } & Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
if (msg.type === "bootstrap") {
|
|
26
|
+
try {
|
|
27
|
+
const modulePath = msg.modulePath as string | undefined;
|
|
28
|
+
if (modulePath) {
|
|
29
|
+
// Explicit bootstrap module (config: queue.workerBootstrap) — it must
|
|
30
|
+
// import/register every job class.
|
|
31
|
+
await import(modulePath);
|
|
32
|
+
} else {
|
|
33
|
+
// Convention: import every app/jobs/*.ts so each self-registers via
|
|
34
|
+
// JobRegistry.register(). Mirrors the `jobs` convention concern, run here
|
|
35
|
+
// because this bare worker thread does not boot a full Application.
|
|
36
|
+
// app/notifications/ comes along for the same reason: a queued
|
|
37
|
+
// notification names its class, and the class must have been imported
|
|
38
|
+
// here for SendNotificationJob to rebuild it.
|
|
39
|
+
const glob = new Bun.Glob("*.ts");
|
|
40
|
+
for (const dir of ["app/jobs", "app/notifications"]) {
|
|
41
|
+
try {
|
|
42
|
+
for await (const file of glob.scan({
|
|
43
|
+
cwd: `${process.cwd()}/${dir}`,
|
|
44
|
+
absolute: true,
|
|
45
|
+
})) {
|
|
46
|
+
await import(`file://${file.replace(/\\/g, "/")}`);
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* directory doesn't exist — nothing to register */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
bootstrapped = true;
|
|
54
|
+
self.postMessage({ type: "ready" });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
self.postMessage({ type: "bootstrap-error", message: String(err) });
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (msg.type === "job") {
|
|
62
|
+
if (!bootstrapped) {
|
|
63
|
+
self.postMessage({ type: "error", message: "Worker is not bootstrapped yet" });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const record = msg.record as JobRecord;
|
|
68
|
+
try {
|
|
69
|
+
const JobClass = JobRegistry.resolve(record.className);
|
|
70
|
+
if (!JobClass) {
|
|
71
|
+
self.postMessage({ type: "error", message: `Unknown job class: ${record.className}` });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const payload = JSON.parse(record.payload) as Record<string, unknown>;
|
|
76
|
+
let instance: Job;
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
"fromPayload" in JobClass &&
|
|
80
|
+
typeof (JobClass as Record<string, unknown>).fromPayload === "function"
|
|
81
|
+
) {
|
|
82
|
+
instance = (JobClass as { fromPayload(p: Record<string, unknown>): Job }).fromPayload(
|
|
83
|
+
payload,
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
instance = new (JobClass as new () => Job)();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await instance.handle();
|
|
90
|
+
self.postMessage({ type: "done" });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
self.postMessage({
|
|
93
|
+
type: "error",
|
|
94
|
+
message: err instanceof Error ? err.message : String(err),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|