@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,427 @@
1
+ import { randomUUIDv7 } from "bun";
2
+ import { QueueShuttingDownError, QueueBatchingUnsupportedError } from "./errors.ts";
3
+ import type { QueueDriver, JobRecord } from "./drivers/QueueDriver.ts";
4
+ import type { Job } from "./Job.ts";
5
+ import type { PendingBatch } from "./PendingBatch.ts";
6
+ import type { SerializedJob } from "./Batch.ts";
7
+ import { Batch } from "./Batch.ts";
8
+ import { JobRegistry } from "./JobRegistry.ts";
9
+ import { SyncDriver } from "./drivers/SyncDriver.ts";
10
+ import { FrameworkEvents } from "@zerotal/core";
11
+ import { JobRan } from "./events.ts";
12
+ import { frameworkLog } from "@zerotal/core/logger";
13
+
14
+ export type JobStatus = "dispatched" | "completed" | "failed" | "retried";
15
+
16
+ interface JobEvent {
17
+ className: string;
18
+ queue: string;
19
+ status: JobStatus;
20
+ durationMs: number;
21
+ error?: string | undefined;
22
+ }
23
+
24
+ function _fireJobEvent(
25
+ className: string,
26
+ queue: string,
27
+ status: JobStatus,
28
+ durationMs: number,
29
+ error?: string,
30
+ ): void {
31
+ _recordStat({ className, queue, status, durationMs, error });
32
+ FrameworkEvents.emit(new JobRan(className, queue, status, durationMs, error));
33
+ }
34
+
35
+ // ── Throughput stats (in-memory, best-effort — powers the admin Queue panel) ──
36
+
37
+ interface JobStat {
38
+ t: number;
39
+ status: JobStatus;
40
+ }
41
+ const _stats: JobStat[] = [];
42
+ let _processedTotal = 0;
43
+ let _failedTotal = 0;
44
+
45
+ function _recordStat(e: JobEvent): void {
46
+ if (e.status === "dispatched") return;
47
+ const now = Date.now();
48
+ _stats.push({ t: now, status: e.status });
49
+ if (e.status === "completed") _processedTotal++;
50
+ else if (e.status === "failed") _failedTotal++;
51
+ const cutoff = now - 30 * 60 * 1000; // keep ~30 min of events
52
+ while (_stats.length && _stats[0]!.t < cutoff) _stats.shift();
53
+ if (_stats.length > 5000) _stats.splice(0, _stats.length - 5000);
54
+ }
55
+
56
+ export interface QueueStats {
57
+ processedTotal: number;
58
+ failedTotal: number;
59
+ processedLast5m: number;
60
+ failedLast5m: number;
61
+ }
62
+
63
+ /** Best-effort in-process queue throughput counters (since boot). */
64
+ export function queueStats(): QueueStats {
65
+ const cutoff = Date.now() - 5 * 60 * 1000;
66
+ let p = 0,
67
+ f = 0;
68
+ for (const s of _stats) {
69
+ if (s.t < cutoff) continue;
70
+ if (s.status === "completed") p++;
71
+ else if (s.status === "failed") f++;
72
+ }
73
+ return {
74
+ processedTotal: _processedTotal,
75
+ failedTotal: _failedTotal,
76
+ processedLast5m: p,
77
+ failedLast5m: f,
78
+ };
79
+ }
80
+
81
+ // ── QueueManager ──────────────────────────────────────────────────────────────
82
+
83
+ type JobExecutor = {
84
+ run(record: JobRecord): Promise<{ success: true } | { success: false; error: string }>;
85
+ };
86
+
87
+ export class QueueManager {
88
+ private _driver: QueueDriver;
89
+ private _isShuttingDown: boolean = false;
90
+ private _activeJobCount: number = 0;
91
+ private _drainResolve: (() => void) | undefined = undefined;
92
+ private _executor: JobExecutor | undefined = undefined;
93
+
94
+ constructor(driver: QueueDriver) {
95
+ this._driver = driver;
96
+ }
97
+
98
+ setWorkerPool(pool: JobExecutor): void {
99
+ this._executor = pool;
100
+ }
101
+
102
+ // ── Dispatch ───────────────────────────────────────────────────────────
103
+
104
+ async dispatch(job: Job): Promise<void> {
105
+ if (this._isShuttingDown) {
106
+ throw new QueueShuttingDownError(job.className);
107
+ }
108
+
109
+ if (this._isSyncDriver()) {
110
+ SyncDriver._pendingJob = job;
111
+ }
112
+
113
+ const payload = job.payload();
114
+ if (job._chain?.length) {
115
+ payload["__chain"] = job._chain;
116
+ }
117
+
118
+ await this._driver.push({
119
+ queue: job.queue,
120
+ className: job.className,
121
+ payload: JSON.stringify(payload),
122
+ attempts: 0,
123
+ maxAttempts: job.maxAttempts,
124
+ retryDelay: job.retryDelay,
125
+ availableAt: Math.floor(Date.now() / 1000),
126
+ batchId: job.batchId,
127
+ });
128
+
129
+ _fireJobEvent(job.className, job.queue, "dispatched", 0);
130
+
131
+ if (this._isSyncDriver()) {
132
+ const record = await this._driver.pop(job.queue);
133
+ if (record) await this._processRecord(record, job);
134
+ }
135
+ }
136
+
137
+ async dispatchBatch(pending: PendingBatch): Promise<Batch> {
138
+ if (!this._driver.createBatch) {
139
+ throw new QueueBatchingUnsupportedError();
140
+ }
141
+
142
+ // v7 UUID: time-ordered, so persisted batch IDs sort by creation and give
143
+ // better index locality than random v4 IDs.
144
+ const id = randomUUIDv7();
145
+ const options = pending.options;
146
+
147
+ await this._driver.createBatch(
148
+ id,
149
+ pending.batchName,
150
+ pending.jobs.length,
151
+ JSON.stringify(options),
152
+ );
153
+
154
+ for (const job of pending.jobs) {
155
+ job.batchId = id;
156
+ await this.dispatch(job);
157
+ }
158
+
159
+ const record = await this._driver.getBatch!(id);
160
+ return new Batch(record!);
161
+ }
162
+
163
+ // ── Single job processing ─────────────────────────────────────────────
164
+
165
+ async processNext(queue = "default"): Promise<boolean> {
166
+ const record = await this._driver.pop(queue);
167
+ if (!record) return false;
168
+
169
+ if (this._executor) {
170
+ this._activeJobCount++;
171
+ const _t0 = Date.now();
172
+ try {
173
+ const result = await this._executor.run(record);
174
+ if (result.success) {
175
+ await this._driver.delete(record);
176
+ _fireJobEvent(record.className, record.queue, "completed", Date.now() - _t0);
177
+ await this._onJobComplete(record);
178
+ } else if (record.attempts >= record.maxAttempts) {
179
+ await this._driver.fail(record, result.error);
180
+ _fireJobEvent(record.className, record.queue, "failed", Date.now() - _t0, result.error);
181
+ await this._onJobFailed(record);
182
+ frameworkLog("queue").error(
183
+ `Job ${record.className} permanently failed after ${record.attempts} attempts`,
184
+ { job: record.className, queue: record.queue, attempts: record.attempts },
185
+ result.error,
186
+ );
187
+ } else {
188
+ await this._driver.retry(record);
189
+ _fireJobEvent(record.className, record.queue, "retried", Date.now() - _t0, result.error);
190
+ frameworkLog("queue").warn(
191
+ `Job ${record.className} failed (attempt ${record.attempts}/${record.maxAttempts}) — retrying`,
192
+ { job: record.className, queue: record.queue, attempts: record.attempts },
193
+ result.error,
194
+ );
195
+ }
196
+ } finally {
197
+ this._activeJobCount--;
198
+ if (this._isShuttingDown && this._activeJobCount === 0) {
199
+ this._drainResolve?.();
200
+ }
201
+ }
202
+ return true;
203
+ }
204
+
205
+ await this._processRecord(record);
206
+ return true;
207
+ }
208
+
209
+ // ── Worker loop control ───────────────────────────────────────────────
210
+
211
+ get isShuttingDown(): boolean {
212
+ return this._isShuttingDown;
213
+ }
214
+ get activeJobCount(): number {
215
+ return this._activeJobCount;
216
+ }
217
+
218
+ async drain(): Promise<void> {
219
+ this._isShuttingDown = true;
220
+ if (this._activeJobCount === 0) return;
221
+ return new Promise((resolve) => {
222
+ this._drainResolve = resolve;
223
+ setTimeout(resolve, 25_000);
224
+ });
225
+ }
226
+
227
+ async size(queue = "default"): Promise<number> {
228
+ return this._driver.size(queue);
229
+ }
230
+
231
+ // ── Introspection / management (used by tooling & the admin Queue panel) ──
232
+
233
+ /** Pending (not-yet-processed) jobs, newest queues first. */
234
+ async pending(queue?: string, limit = 50, offset = 0): Promise<JobRecord[]> {
235
+ return this._driver.listPending ? this._driver.listPending(queue, limit, offset) : [];
236
+ }
237
+
238
+ /** Distinct queue names with their pending depth. */
239
+ async queues(): Promise<{ queue: string; pending: number }[]> {
240
+ return this._driver.queues ? this._driver.queues() : [];
241
+ }
242
+
243
+ /** Failed jobs, optionally for a single queue. */
244
+ async failed(queue?: string) {
245
+ return this._driver.listFailed(queue);
246
+ }
247
+
248
+ /** Re-dispatch a failed job and remove it from the failed list. */
249
+ async retryFailed(id: number): Promise<boolean> {
250
+ const rec = (await this._driver.listFailed()).find((r) => r.id === id);
251
+ if (!rec) return false;
252
+ await this._driver.push({
253
+ queue: rec.queue,
254
+ className: rec.className,
255
+ payload: rec.payload,
256
+ attempts: 0,
257
+ maxAttempts: 3,
258
+ retryDelay: 0,
259
+ availableAt: Math.floor(Date.now() / 1000),
260
+ batchId: undefined,
261
+ });
262
+ await this._driver.deleteFailedRecord(id);
263
+ return true;
264
+ }
265
+
266
+ /** Forget a single failed job. */
267
+ async forgetFailed(id: number): Promise<void> {
268
+ return this._driver.deleteFailedRecord(id);
269
+ }
270
+
271
+ /** Clear failed jobs, optionally for a single queue. */
272
+ async clearFailed(queue?: string): Promise<void> {
273
+ return this._driver.clearFailed(queue);
274
+ }
275
+
276
+ /** In-process throughput counters. */
277
+ stats(): QueueStats {
278
+ return queueStats();
279
+ }
280
+
281
+ async flush(): Promise<void> {
282
+ return this._driver.flush();
283
+ }
284
+
285
+ // ── Private ───────────────────────────────────────────────────────────
286
+
287
+ private async _processRecord(record: JobRecord, jobInstance?: Job): Promise<void> {
288
+ this._activeJobCount++;
289
+ const _t0 = Date.now();
290
+ let instance: Job | undefined = undefined;
291
+
292
+ try {
293
+ instance = jobInstance ?? this._instantiate(record);
294
+ if (!instance) {
295
+ await this._driver.fail(record, `Unknown job class: ${record.className}`);
296
+ return;
297
+ }
298
+
299
+ await instance.handle();
300
+ await this._driver.delete(record);
301
+ _fireJobEvent(record.className, record.queue, "completed", Date.now() - _t0);
302
+ await this._onJobComplete(record);
303
+ } catch (err) {
304
+ const message = err instanceof Error ? err.message : String(err);
305
+
306
+ if (record.attempts >= record.maxAttempts) {
307
+ await this._driver.fail(record, message);
308
+ _fireJobEvent(record.className, record.queue, "failed", Date.now() - _t0, message);
309
+ await this._onJobFailed(record);
310
+ frameworkLog("queue").error(
311
+ `Job ${record.className} permanently failed after ${record.attempts} attempts`,
312
+ { job: record.className, queue: record.queue, attempts: record.attempts },
313
+ message,
314
+ );
315
+ } else {
316
+ if (this._isSyncDriver() && instance) {
317
+ SyncDriver._pendingJob = instance;
318
+ }
319
+ await this._driver.retry(record);
320
+ _fireJobEvent(record.className, record.queue, "retried", Date.now() - _t0, message);
321
+ frameworkLog("queue").warn(
322
+ `Job ${record.className} failed (attempt ${record.attempts}/${record.maxAttempts}) — ` +
323
+ `retrying in ${record.retryDelay}ms`,
324
+ { job: record.className, queue: record.queue, attempts: record.attempts },
325
+ message,
326
+ );
327
+ if (this._isSyncDriver()) {
328
+ const nextRecord = await this._driver.pop(record.queue);
329
+ if (nextRecord) {
330
+ await this._processRecord(nextRecord, instance);
331
+ }
332
+ }
333
+ }
334
+ } finally {
335
+ this._activeJobCount--;
336
+ if (this._isShuttingDown && this._activeJobCount === 0) {
337
+ this._drainResolve?.();
338
+ }
339
+ }
340
+ }
341
+
342
+ private async _onJobComplete(record: JobRecord): Promise<void> {
343
+ const payload = JSON.parse(record.payload) as Record<string, unknown>;
344
+ const chain = payload["__chain"] as SerializedJob[] | undefined;
345
+ if (chain?.length) {
346
+ const next = chain[0]!;
347
+ const rest = chain.slice(1);
348
+ await this._driver.push({
349
+ queue: record.queue,
350
+ className: next.className,
351
+ payload: JSON.stringify(rest.length ? { ...next.payload, __chain: rest } : next.payload),
352
+ attempts: 0,
353
+ maxAttempts: record.maxAttempts,
354
+ retryDelay: record.retryDelay,
355
+ availableAt: Math.floor(Date.now() / 1000),
356
+ batchId: undefined,
357
+ });
358
+ }
359
+
360
+ if (record.batchId && this._driver.recordBatchJobComplete) {
361
+ const status = await this._driver.recordBatchJobComplete(record.batchId);
362
+ if (status !== "pending") {
363
+ await this._dispatchBatchCallbacks(record.batchId, status);
364
+ }
365
+ }
366
+ }
367
+
368
+ private async _onJobFailed(record: JobRecord): Promise<void> {
369
+ if (record.batchId && this._driver.recordBatchJobFailed) {
370
+ const status = await this._driver.recordBatchJobFailed(record.batchId, record.id);
371
+ if (status !== "pending") {
372
+ await this._dispatchBatchCallbacks(record.batchId, status);
373
+ }
374
+ }
375
+ }
376
+
377
+ private async _dispatchBatchCallbacks(
378
+ batchId: string,
379
+ status: "complete" | "failed",
380
+ ): Promise<void> {
381
+ if (!this._driver.getBatch) return;
382
+ const batchRecord = await this._driver.getBatch(batchId);
383
+ if (!batchRecord) return;
384
+
385
+ const opts = batchRecord.options;
386
+ if (status === "complete") {
387
+ for (const j of opts.thenJobs ?? []) await this._dispatchSerialized(j);
388
+ } else {
389
+ for (const j of opts.catchJobs ?? []) await this._dispatchSerialized(j);
390
+ }
391
+ for (const j of opts.finallyJobs ?? []) await this._dispatchSerialized(j);
392
+ }
393
+
394
+ private async _dispatchSerialized(serialized: SerializedJob): Promise<void> {
395
+ await this._driver.push({
396
+ queue: "default",
397
+ className: serialized.className,
398
+ payload: JSON.stringify(serialized.payload),
399
+ attempts: 0,
400
+ maxAttempts: 3,
401
+ retryDelay: 1000,
402
+ availableAt: Math.floor(Date.now() / 1000),
403
+ batchId: undefined,
404
+ });
405
+ }
406
+
407
+ private _instantiate(record: JobRecord): Job | undefined {
408
+ const JobClass = JobRegistry.resolve(record.className);
409
+ if (!JobClass) return undefined;
410
+
411
+ const payload = JSON.parse(record.payload) as Record<string, unknown>;
412
+ if (
413
+ "fromPayload" in JobClass &&
414
+ typeof (JobClass as unknown as { fromPayload?: (p: Record<string, unknown>) => Job })
415
+ .fromPayload === "function"
416
+ ) {
417
+ return (
418
+ JobClass as unknown as { fromPayload: (p: Record<string, unknown>) => Job }
419
+ ).fromPayload(payload);
420
+ }
421
+ return new (JobClass as new () => Job)();
422
+ }
423
+
424
+ private _isSyncDriver(): boolean {
425
+ return this._driver instanceof SyncDriver;
426
+ }
427
+ }
@@ -0,0 +1,242 @@
1
+ import type { JobRecord } from "./drivers/QueueDriver.ts";
2
+ import { frameworkLog } from "@zerotal/core/logger";
3
+
4
+ export interface WorkerPoolOptions {
5
+ /** Number of OS threads to spawn. */
6
+ size: number;
7
+ /**
8
+ * Optional absolute path or file URL to a module that imports+registers all job
9
+ * classes. When omitted, the worker thread discovers jobs by convention (imports
10
+ * every `app/jobs/*.ts`). Set it only to override that with an explicit bootstrap.
11
+ */
12
+ bootstrapPath?: string | undefined;
13
+ /**
14
+ * Most jobs allowed to wait in memory for a free thread. Default: `size * 64`.
15
+ *
16
+ * A job sitting in this backlog is already *claimed* — the driver has reserved it and is
17
+ * counting down its visibility timeout. Queue more than the pool can work through and
18
+ * those jobs are reclaimed and executed a second time while the first copy is still
19
+ * waiting its turn. Past the cap, `run()` reports failure immediately so the job goes
20
+ * back to the driver instead of aging out.
21
+ */
22
+ maxPending?: number | undefined;
23
+ /**
24
+ * How many times a slot may respawn after its worker dies before the pool gives up on it.
25
+ * Default: 5. A worker whose script throws on load would otherwise respawn forever.
26
+ */
27
+ maxRespawns?: number | undefined;
28
+ }
29
+
30
+ export type WorkerResult = { success: true } | { success: false; error: string };
31
+
32
+ interface WorkerState {
33
+ worker: Worker;
34
+ busy: boolean;
35
+ resolve?: (r: WorkerResult) => void;
36
+ /** Consecutive respawns for this slot; reset by a successful job. */
37
+ respawns: number;
38
+ /** Set once the slot has exhausted its respawns — it is skipped when handing out work. */
39
+ dead: boolean;
40
+ }
41
+
42
+ /**
43
+ * Manages a pool of Bun Web Worker threads for off-main-thread job execution.
44
+ *
45
+ * Each worker is a genuine OS thread (not just a separate V8 context), so
46
+ * CPU-intensive jobs run without stalling the HTTP event loop.
47
+ *
48
+ * Usage:
49
+ * const pool = new WorkerPool({ size: 2, bootstrapPath: '...' });
50
+ * await pool.start(); // spawns + bootstraps threads
51
+ * const result = await pool.run(record); // executes job on a free thread
52
+ * await pool.terminate(); // cleanly shuts threads down
53
+ */
54
+ export class WorkerPool {
55
+ private _workers: WorkerState[] = [];
56
+ private _pending: Array<{ record: JobRecord; resolve: (r: WorkerResult) => void }> = [];
57
+
58
+ constructor(private readonly opts: WorkerPoolOptions) {}
59
+
60
+ /** True once {@link terminate} has run, so a dying worker is not respawned. */
61
+ private _terminated = false;
62
+
63
+ /** Spawn all worker threads and wait until every thread has bootstrapped. */
64
+ async start(): Promise<void> {
65
+ this._terminated = false;
66
+ const readyPromises: Promise<void>[] = [];
67
+
68
+ for (let i = 0; i < this.opts.size; i++) {
69
+ const state: WorkerState = { worker: null!, busy: false, respawns: 0, dead: false };
70
+ this._workers.push(state);
71
+ readyPromises.push(this._spawn(state));
72
+ }
73
+
74
+ await Promise.all(readyPromises);
75
+ }
76
+
77
+ /**
78
+ * Bring up a worker in `state` and wire its full lifecycle.
79
+ *
80
+ * Every path that ends a worker settles the in-flight promise and clears `busy`. Without
81
+ * `error`/`close` listeners a thread that died mid-job left its caller's promise pending
82
+ * forever and its slot marked busy — permanently wedging a `size: 1` pool — and a
83
+ * bootstrap failure that surfaced as an error rather than a message hung `start()`, and
84
+ * with it `app.boot()`.
85
+ *
86
+ * @returns Resolves when the thread reports ready; rejects if it fails to bootstrap.
87
+ */
88
+ private _spawn(state: WorkerState): Promise<void> {
89
+ const scriptUrl = new URL("./queue-worker.ts", import.meta.url);
90
+ const worker = new Worker(scriptUrl);
91
+ state.worker = worker;
92
+ state.busy = false;
93
+
94
+ let settleReady: (() => void) | undefined;
95
+ let failReady: ((err: Error) => void) | undefined;
96
+ const ready = new Promise<void>((resolve, reject) => {
97
+ settleReady = resolve;
98
+ failReady = reject;
99
+ });
100
+
101
+ const onInit = (event: MessageEvent) => {
102
+ const msg = event.data as { type: string; message?: string };
103
+ if (msg.type === "ready") {
104
+ worker.removeEventListener("message", onInit as EventListener);
105
+ settleReady?.();
106
+ } else if (msg.type === "bootstrap-error") {
107
+ worker.removeEventListener("message", onInit as EventListener);
108
+ failReady?.(new Error(`[Zerotal Queue] Worker bootstrap failed: ${msg.message}`));
109
+ }
110
+ };
111
+ worker.addEventListener("message", onInit as EventListener);
112
+
113
+ // Job results.
114
+ worker.addEventListener("message", (event: MessageEvent) => {
115
+ const msg = event.data as { type: string; message?: string };
116
+ if (msg.type !== "done" && msg.type !== "error") return;
117
+
118
+ const result: WorkerResult =
119
+ msg.type === "done"
120
+ ? { success: true }
121
+ : { success: false, error: msg.message ?? "Unknown error" };
122
+
123
+ // A thread that completed a job is healthy, whatever the job's own outcome.
124
+ state.respawns = 0;
125
+ this._settle(state, result);
126
+ });
127
+
128
+ // The thread threw outside a job, or failed to load its script.
129
+ worker.addEventListener("error", (event: ErrorEvent) => {
130
+ const detail = event.message || "worker error";
131
+ failReady?.(new Error(`[Zerotal Queue] Worker failed to start: ${detail}`));
132
+ this._onWorkerLost(state, `Worker errored: ${detail}`);
133
+ });
134
+
135
+ // The thread exited — crashed, killed, or terminated by us.
136
+ worker.addEventListener("close", () => {
137
+ failReady?.(new Error("[Zerotal Queue] Worker exited before bootstrapping"));
138
+ this._onWorkerLost(state, "Worker exited before the job completed");
139
+ });
140
+
141
+ worker.postMessage({ type: "bootstrap", modulePath: this.opts.bootstrapPath });
142
+ return ready;
143
+ }
144
+
145
+ /** Release a worker's in-flight job with `result` and hand it the next one. */
146
+ private _settle(state: WorkerState, result: WorkerResult): void {
147
+ state.busy = false;
148
+ const resolve = state.resolve;
149
+ delete state.resolve;
150
+ resolve?.(result);
151
+ this._drainPending();
152
+ }
153
+
154
+ /**
155
+ * A worker died. Settle whatever it was running, then replace it so the pool does not
156
+ * quietly shrink toward zero throughput.
157
+ */
158
+ private _onWorkerLost(state: WorkerState, reason: string): void {
159
+ if (this._terminated || state.dead) return;
160
+ this._settle(state, { success: false, error: reason });
161
+
162
+ if (++state.respawns > (this.opts.maxRespawns ?? 5)) {
163
+ // Respawning a worker whose script throws on load is an infinite loop. Mark the slot
164
+ // dead and keep serving from the rest; a pool with no live slots fails jobs fast
165
+ // rather than accumulating them.
166
+ state.dead = true;
167
+ frameworkLog("queue").error(`Worker slot gave up after ${state.respawns} respawns`, {
168
+ respawns: state.respawns,
169
+ reason,
170
+ });
171
+ this._drainPending();
172
+ return;
173
+ }
174
+
175
+ void this._spawn(state).catch(() => {
176
+ // The replacement failed to bootstrap; its own error/close listener handles the
177
+ // next attempt, so there is nothing to do here but not crash.
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Send a job record to a free worker thread.
183
+ *
184
+ * When every thread is busy the job waits in memory, up to {@link WorkerPoolOptions.maxPending}.
185
+ * Beyond that it is reported as failed straight away: a waiting job is already claimed
186
+ * from the driver, and one that waits past its visibility timeout gets reclaimed and run
187
+ * a second time while the first copy is still queued here.
188
+ */
189
+ run(record: JobRecord): Promise<WorkerResult> {
190
+ const maxPending = this.opts.maxPending ?? this.opts.size * 64;
191
+ if (this._pending.length >= maxPending) {
192
+ return Promise.resolve({
193
+ success: false,
194
+ error: `WorkerPool backlog is full (${maxPending}) — job returned to the queue`,
195
+ });
196
+ }
197
+ if (this._workers.length > 0 && this._workers.every((w) => w.dead)) {
198
+ return Promise.resolve({
199
+ success: false,
200
+ error: "WorkerPool has no live workers — job returned to the queue",
201
+ });
202
+ }
203
+ return new Promise((resolve) => {
204
+ this._pending.push({ record, resolve });
205
+ this._drainPending();
206
+ });
207
+ }
208
+
209
+ /** Terminate all worker threads. Call after QueueManager.drain() completes. */
210
+ async terminate(): Promise<void> {
211
+ // Set first: terminate() closes every worker, and the close listener must not read
212
+ // that as a crash and respawn what we are shutting down.
213
+ this._terminated = true;
214
+
215
+ // Reject any callers still waiting for a worker — avoids hanging promises.
216
+ for (const { resolve } of this._pending) {
217
+ resolve({ success: false, error: "WorkerPool terminated before job could run" });
218
+ }
219
+ this._pending = [];
220
+
221
+ for (const state of this._workers) {
222
+ // Reject any job currently in-flight on this thread.
223
+ if (state.busy && state.resolve) {
224
+ state.resolve({ success: false, error: "WorkerPool terminated while job was running" });
225
+ delete state.resolve;
226
+ }
227
+ state.worker.terminate();
228
+ }
229
+ this._workers = [];
230
+ }
231
+
232
+ private _drainPending(): void {
233
+ while (this._pending.length > 0) {
234
+ const free = this._workers.find((w) => !w.busy && !w.dead);
235
+ if (!free) break;
236
+ const next = this._pending.shift()!;
237
+ free.busy = true;
238
+ free.resolve = next.resolve;
239
+ free.worker.postMessage({ type: "job", record: next.record });
240
+ }
241
+ }
242
+ }