@zerotal/queue 1.13.1 → 1.13.3

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 CHANGED
@@ -8,6 +8,18 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.13.3] — 2026-08-31
12
+
13
+ ### Added
14
+
15
+ - **A `zt doctor` check for jobs waiting with no worker.** The same failure as an unrun
16
+ schedule, one layer over: the queue fills, nothing errors, and you find out when a
17
+ customer asks where their email went.
18
+
19
+ Keyed on the pending depth rather than on whether any job class is registered — an app
20
+ with an empty queue and no worker may be perfectly fine, and a doctor that says otherwise
21
+ is one people scroll past.
22
+
11
23
  ## [1.13.1] — 2026-08-31
12
24
 
13
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/queue",
3
- "version": "1.13.1",
3
+ "version": "1.13.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,8 +31,8 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.13.1",
35
- "@zerotal/orm": "1.13.1"
34
+ "@zerotal/core": "1.13.3",
35
+ "@zerotal/orm": "1.13.3"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
package/src/WorkerPool.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { JobRecord } from "./drivers/QueueDriver.ts";
2
2
  import { frameworkLog } from "@zerotal/core/logger";
3
+ import { Heartbeat } from "@zerotal/core/heartbeat";
3
4
 
4
5
  export interface WorkerPoolOptions {
5
6
  /** Number of OS threads to spawn. */
@@ -60,6 +61,9 @@ export class WorkerPool {
60
61
  /** True once {@link terminate} has run, so a dying worker is not respawned. */
61
62
  private _terminated = false;
62
63
 
64
+ /** Stops the liveness beat. Set while the pool is up. */
65
+ private _stopBeat: (() => void) | undefined;
66
+
63
67
  /** Spawn all worker threads and wait until every thread has bootstrapped. */
64
68
  async start(): Promise<void> {
65
69
  this._terminated = false;
@@ -72,6 +76,10 @@ export class WorkerPool {
72
76
  }
73
77
 
74
78
  await Promise.all(readyPromises);
79
+
80
+ // Beat once the pool is actually up, not when start() was called: a pool that
81
+ // failed to bootstrap should look like no worker, because that is what it is.
82
+ this._stopBeat = Heartbeat.start("queue", { detail: `${this.opts.size} thread(s)` });
75
83
  }
76
84
 
77
85
  /**
@@ -211,6 +219,8 @@ export class WorkerPool {
211
219
  // Set first: terminate() closes every worker, and the close listener must not read
212
220
  // that as a crash and respawn what we are shutting down.
213
221
  this._terminated = true;
222
+ this._stopBeat?.();
223
+ this._stopBeat = undefined;
214
224
 
215
225
  // Reject any callers still waiting for a worker — avoids hanging promises.
216
226
  for (const { resolve } of this._pending) {
@@ -14,6 +14,8 @@ import { installQueueObservability } from "../observability.ts";
14
14
  import { installQueueAdmin } from "../admin.ts";
15
15
  import { validateQueueConfig } from "../config.ts";
16
16
  import { frameworkLog } from "@zerotal/core/logger";
17
+ import { workerLivenessCheck } from "@zerotal/core/heartbeat";
18
+ import type { DoctorCheck } from "@zerotal/core";
17
19
 
18
20
  declare module "@zerotal/core" {
19
21
  interface ContainerBindings {
@@ -177,4 +179,35 @@ export class QueueProvider extends ServiceProvider {
177
179
  import("../commands/QueueFlushCommand.ts").then((m) => m.QueueFlushCommand),
178
180
  );
179
181
  }
182
+
183
+ /**
184
+ * Report jobs waiting with no worker running.
185
+ *
186
+ * Same failure as the scheduler's, one layer over: jobs accumulate, nothing
187
+ * errors, and you learn about it when a customer asks where their email went.
188
+ *
189
+ * Keyed on the *pending depth* rather than on whether any job class is
190
+ * registered — an app with an empty queue and no worker may be perfectly fine,
191
+ * and a doctor that says otherwise is one people scroll past.
192
+ */
193
+ override doctorChecks(): DoctorCheck[] {
194
+ return [
195
+ workerLivenessCheck({
196
+ id: "queue-worker-running",
197
+ label: "Queue worker",
198
+ name: "queue",
199
+ hasWork: async () => {
200
+ try {
201
+ const depth = (await this.app.container.tryMake("queue")?.size()) ?? 0;
202
+ return { has: depth > 0, summary: `${depth} job(s) waiting` };
203
+ } catch {
204
+ // An unreadable queue is not a finding about the worker.
205
+ return { has: false, summary: "" };
206
+ }
207
+ },
208
+ staleAfter: 15 * 60,
209
+ command: "bun zt worker",
210
+ }),
211
+ ];
212
+ }
180
213
  }