@12-apps/jobs 4.5.0 → 4.6.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/ADOPTING.md CHANGED
@@ -157,6 +157,7 @@ default from the environment, which is what makes the mount one line:
157
157
  | `events` | — | `JobEvents` — dead-letters, completions and removed schedules, for the host to wire to its own notifier / audit / realtime. See below. |
158
158
  | `retention` | package default | How long finished jobs are kept: a day of successes, a week of failures. Override for a longer support window. All four numbers must be positive and finite — see the warning below. |
159
159
  | `defaultConcurrency` | `5` | Per-queue concurrency when no job on the queue states one. A stated `concurrency: 1` still wins. |
160
+ | `stall` | BullMQ's numbers | `JobStallConfig` — `lockDurationMs` (30 s), `stalledIntervalMs` (30 s) and `maxStalledCount` (1), for every queue and per queue under `queues`. See "When a worker stalls" below. |
160
161
  | `db` | — | `() => SweepLeaseDb` — enables `withSweepLease`. Without it the lease throws on first use (loud, never a silent skip). |
161
162
  | `installShutdownHooks` | `true` | `SIGTERM`/`SIGINT` drain in-flight jobs (workers only). Off in tests. |
162
163
 
@@ -208,6 +209,9 @@ An observer that throws or rejects is logged and swallowed — somebody else's
208
209
  code must never be able to fail the job it is watching, nor the reconcile that
209
210
  was cleaning up a stale schedule.
210
211
 
212
+ `onJobStalled` fires when a running job lost its lock and was put back to run
213
+ again; see the next section.
214
+
211
215
  `onScheduleRemoved` fires only for REMOVAL, not installation. Installation is
212
216
  an idempotent upsert that runs on every boot of every worker, so auditing it
213
217
  would write one row per schedule per deploy; removal is a deploy permanently
@@ -278,6 +282,56 @@ owns the schema folder:
278
282
  published as generic, and it closes three fail-open paths that were invisible
279
283
  by construction.
280
284
 
285
+ ### When a worker stalls
286
+
287
+ A worker holds a lock on every job it runs and renews it every half
288
+ `lockDurationMs`. If its event loop is blocked, the process is paused or Redis
289
+ stops answering for longer than the lock, the lock expires. The worker then
290
+ logs `could not renew lock for job …` and, when the handler returns,
291
+ `Missing lock for job … moveToFinished`, because it can no longer record the
292
+ result. The stalled checker, every `stalledIntervalMs`, finds the job without a
293
+ lock and moves it back to `wait`, and it runs again. **A stall is a re-run,
294
+ never a lost run.** That is one more reason handlers must be idempotent.
295
+
296
+ What the driver does about it:
297
+
298
+ - **Every stall is an ERROR line** through the host's logger, naming the job,
299
+ its run id, how many times it has been started and how many times it has stalled, and it fires
300
+ `events.onJobStalled`. The two lock errors above stay errors too. The job
301
+ recovers on its own, but the stall that caused it hits every job on that
302
+ worker, and it needs looking at.
303
+ - **Every failed attempt is an ERROR line** naming the job, its run id, the
304
+ attempt out of the budget, and whether it will be retried.
305
+ - **A job that stalls more than `maxStalledCount` times is failed**, as
306
+ unrecoverable. It lands in the failed set (kept for `retention.failed`) and
307
+ reaches `onJobFailed` with `terminal: true`, the dead-letter.
308
+ - **Scheduled jobs are bounded too.** BullMQ never applies `maxStalledCount` to
309
+ a job-scheduler job, so a tick whose handler takes the worker down every time
310
+ would be restarted forever. The driver sets BullMQ's `maxStartedAttempts` to
311
+ the queue's largest `attempts` plus `maxStalledCount`. The start that goes
312
+ over it is failed as a terminal dead-letter, and the next tick of the
313
+ schedule is a fresh job. One consequence for anyone retrying a failed job by
314
+ hand: its earlier starts still count toward that cap, so a job retried by
315
+ hand can be refused as it starts. Retry with
316
+ `job.retry("failed", { resetAttemptsMade: true, resetAttemptsStarted: true })`
317
+ to give it a fresh budget.
318
+
319
+ A queue whose handlers can legitimately hold the event loop for a while (a
320
+ single-flight sweep queue doing batch work, say) can take a longer lock without
321
+ changing the others:
322
+
323
+ ```ts
324
+ createApiJobs({
325
+ jobs: () => import("./lib/jobs"),
326
+ stall: { queues: { [SWEEP_QUEUE]: { lockDurationMs: 60_000 } } },
327
+ });
328
+ ```
329
+
330
+ A longer lock is also a slower recovery when a worker really dies, since the
331
+ job is only put back once its lock has expired. Every number is validated:
332
+ `createApiJobs` refuses a bad one at assembly with `InvalidJobStallError`, and
333
+ `createBullMqJobDriver` refuses it again.
334
+
281
335
  ## What changed in behaviour
282
336
 
283
337
  Nothing was **removed** from the API — every 2.0.0 export still exists with the
@@ -304,6 +358,7 @@ from success.
304
358
  | `events?: JobEvents` on `createApiJobs`, and on both driver factories | Dead-letters (`onJobFailed` with `terminal`), completions (`onJobCompleted`) and removed schedules (`onScheduleRemoved`). The package still notifies/audits/publishes nothing itself. |
305
359
  | `retention?: JobRetention`, `defaultConcurrency?: number` | The two operational numbers that were hardcoded. The defaults are unchanged (a day / a week; concurrency 5), so omitting them is a no-op. `retention` is validated at assembly and again in the driver — a non-positive window stops bounding the backend rather than shrinking it. |
306
360
  | `assertValidRetention`, `InvalidJobRetentionError` | The retention check, exported so a host that assembles its own window can run it. Lives in `core`, so importing it never pulls `bullmq` into a bundle that only enqueues. |
361
+ | `stall?: JobStallConfig`, `events.onJobStalled`, `DEFAULT_STALL_POLICY`, `assertValidStall`, `InvalidJobStallError` | The worker's lock and stall settings, which used to be BullMQ's implicit defaults, now spelled out and configurable per queue. Each stall is reported as an error and to the host. A job, scheduled or not, that keeps stalling is failed as a dead-letter instead of being restarted forever. The defaults are BullMQ's own numbers, so omitting `stall` changes nothing but the new bound on restarts. |
307
362
  | `DEFAULT_QUEUE` | The queue name a definition falls back to, exported instead of duplicated as a literal in every host. |
308
363
  | `resolveRegisteredJob`, `InvalidJobDefinitionError`, `NoJobsRegisteredError`, `JobsConfigError` | The gate and the three refusals, so a host can catch them by type. |
309
364
 
package/README.md CHANGED
@@ -103,6 +103,13 @@ simply stops happening:
103
103
  scheduler removed from Redis, instead of firing forever at a handler that no
104
104
  longer exists. That removal is reported to `events.onScheduleRemoved`,
105
105
  because it is destructive and a deploy did it.
106
+ - **A stalled job is re-run, and reported.** When a worker's lock on a job
107
+ expires (a blocked event loop, a paused process, a silent Redis link), BullMQ
108
+ puts the job back and runs it again. Each stall is an error line and
109
+ `events.onJobStalled`. A job that keeps stalling (more than `maxStalledCount`
110
+ times, or, for a scheduled job, more starts than its attempts plus that) is
111
+ failed as a terminal dead-letter instead of being restarted forever. The
112
+ lock and stall numbers are configurable per queue through `stall`.
106
113
  - The `inline` driver honours `attempts` but not delays or schedules, and both
107
114
  omissions are logged rather than silent. It is refused in production —
108
115
  by name, by env var and by instance.
@@ -130,6 +137,8 @@ createApiJobs({
130
137
  if (terminal) void notifyOperators(name, error);
131
138
  },
132
139
  onJobCompleted: ({ name, runId }) => void publishRealtime(name, runId),
140
+ // A job lost its lock mid-run and was put back to run again.
141
+ onJobStalled: ({ name, runId, stalledCount }) => void notifyOperators(name, runId, stalledCount),
133
142
  // A deploy just cancelled a recurring job, permanently.
134
143
  onScheduleRemoved: ({ name, queue }) => void audit("schedule.removed", { name, queue }),
135
144
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/jobs",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Generic background-job library: a typed job registry with retries, exponential backoff and cron schedules, behind a swappable driver port (BullMQ/Redis in production, inline execution in tests and zero-config dev). The runtime half (./server) is one factory — createApiJobs: driver resolution, worker bootstrap, graceful drain, the single-writer sweep lease and a health endpoint — with a Hono adapter (./hono) and the package-owned SweepLease Prisma partial + migrations. Knows nothing about the host app's domain, ORM or transport.",
@@ -0,0 +1,91 @@
1
+ import type { JobStallConfig, JobStallPolicy } from "./types";
2
+
3
+ /**
4
+ * The stall settings a host may configure: how long a worker's lock
5
+ * on a running job lasts, how often the stalled checker looks, and how many
6
+ * re-runs a stalled job gets before it is failed.
7
+ *
8
+ * Lives in `core`, beside `retention`, for the same reason: the root entry
9
+ * point re-exports the error, and nothing here may pull `bullmq` into a bundle
10
+ * that only enqueues. It is arithmetic on three numbers.
11
+ */
12
+
13
+ /**
14
+ * BullMQ's own defaults, spelled out. The driver passes them explicitly
15
+ * rather than leaving them implicit, so the numbers a stall is measured
16
+ * against are readable in this package instead of inside BullMQ's worker
17
+ * constructor, and a BullMQ upgrade that changed them could not move them
18
+ * silently.
19
+ */
20
+ export const DEFAULT_STALL_POLICY: JobStallPolicy = {
21
+ lockDurationMs: 30_000,
22
+ stalledIntervalMs: 30_000,
23
+ maxStalledCount: 1,
24
+ };
25
+
26
+ /** Raised for a stall setting BullMQ would reject, or one that cannot work. */
27
+ export class InvalidJobStallError extends Error {
28
+ constructor(field: string, value: unknown, rule: string) {
29
+ super(`stall.${field} must be ${rule}, got ${JSON.stringify(value)}.`);
30
+ this.name = "InvalidJobStallError";
31
+ }
32
+ }
33
+
34
+ function assertPolicyFields(where: string, policy: Partial<JobStallPolicy>): void {
35
+ for (const field of ["lockDurationMs", "stalledIntervalMs"] as const) {
36
+ const value = policy[field];
37
+ if (value === undefined) continue;
38
+ // A zero or NaN lock is renewed never and expires at once: every job
39
+ // would be reported stalled and re-run while it is still running.
40
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
41
+ throw new InvalidJobStallError(`${where}${field}`, value, "a positive integer (ms)");
42
+ }
43
+ }
44
+ const count = policy.maxStalledCount;
45
+ if (count === undefined) return;
46
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0) {
47
+ throw new InvalidJobStallError(`${where}maxStalledCount`, count, "a non-negative integer");
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Refuse a stall config that cannot work. `undefined` means "the defaults";
53
+ * only the values a host actually spells out are checked.
54
+ */
55
+ export function assertValidStall(stall: JobStallConfig | undefined): void {
56
+ if (stall === undefined) return;
57
+ if (typeof stall !== "object" || stall === null) {
58
+ throw new InvalidJobStallError("<root>", stall, "an object");
59
+ }
60
+ assertPolicyFields("", stall);
61
+ for (const [queue, policy] of Object.entries(stall.queues ?? {})) {
62
+ if (typeof policy !== "object" || policy === null) {
63
+ throw new InvalidJobStallError(`queues.${queue}`, policy, "an object");
64
+ }
65
+ assertPolicyFields(`queues.${queue}.`, policy);
66
+ }
67
+ }
68
+
69
+ function definedFields(policy: Partial<JobStallPolicy> | undefined): Partial<JobStallPolicy> {
70
+ const out: Partial<JobStallPolicy> = {};
71
+ if (!policy) return out;
72
+ if (policy.lockDurationMs !== undefined) out.lockDurationMs = policy.lockDurationMs;
73
+ if (policy.stalledIntervalMs !== undefined) out.stalledIntervalMs = policy.stalledIntervalMs;
74
+ if (policy.maxStalledCount !== undefined) out.maxStalledCount = policy.maxStalledCount;
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * The policy one queue's worker runs with: the package defaults, then the
80
+ * host's values for every queue, then the host's values for THIS queue.
81
+ */
82
+ export function resolveStallPolicy(
83
+ queue: string,
84
+ stall: JobStallConfig | undefined,
85
+ ): JobStallPolicy {
86
+ return {
87
+ ...DEFAULT_STALL_POLICY,
88
+ ...definedFields(stall),
89
+ ...definedFields(stall?.queues?.[queue]),
90
+ };
91
+ }
package/src/core/types.ts CHANGED
@@ -186,6 +186,33 @@ export interface JobFailedEvent extends JobRunEvent {
186
186
  terminal: boolean;
187
187
  }
188
188
 
189
+ /**
190
+ * A job whose worker lost its lock before the job finished.
191
+ *
192
+ * The worker's event loop or its Redis link stalled for longer than the lock,
193
+ * so the backend could no longer tell whether anyone was still running the
194
+ * job. It has been moved back to `wait` and WILL run again: a stall is a
195
+ * re-run, never a lost run, which is why handlers must be idempotent. A job
196
+ * that stalls past its budget is failed with `terminal: true` through
197
+ * {@link JobEvents.onJobFailed}, like any other dead-letter.
198
+ */
199
+ export interface JobStalledEvent {
200
+ /** The job's name, or `undefined` when the job was already gone. */
201
+ name: string | undefined;
202
+ queue: string;
203
+ /** The driver's id for the run that stalled. */
204
+ runId: string;
205
+ /**
206
+ * How many times this run had been started when the stall was reported.
207
+ * Read back after BullMQ moved the job, so it may already count the re-run
208
+ * that the stall caused.
209
+ */
210
+ startedCount: number;
211
+ maxAttempts: number;
212
+ /** How many times this run has stalled so far, this one included. */
213
+ stalledCount: number;
214
+ }
215
+
189
216
  /** A cron schedule that existed in the backend and no longer exists in code. */
190
217
  export interface ScheduleRemovedEvent {
191
218
  /** The scheduler key, which is the name of the job that installed it. */
@@ -214,6 +241,12 @@ export interface JobEvents {
214
241
  * only one of the two most hosts want to act on.
215
242
  */
216
243
  onJobFailed?(event: JobFailedEvent): void | Promise<void>;
244
+ /**
245
+ * A running job lost its lock and was put back to run again. Always worth a
246
+ * look: the job itself recovers, but the stall that caused it (a blocked
247
+ * event loop, a Redis link that went quiet) hits every job on that worker.
248
+ */
249
+ onJobStalled?(event: JobStalledEvent): void | Promise<void>;
217
250
  /**
218
251
  * A schedule was removed from the backend because no code declares it any
219
252
  * more. Destructive and unrecoverable from the queue's side, which is why it
@@ -241,6 +274,37 @@ export interface JobRetention {
241
274
  failed: JobRetentionWindow;
242
275
  }
243
276
 
277
+ /**
278
+ * How a worker holds, and gives up, a running job.
279
+ *
280
+ * A worker holds a LOCK on every job it runs and renews it every half
281
+ * `lockDurationMs`. When the renewal cannot happen in time (the event loop was
282
+ * blocked, the process was paused, Redis did not answer), the lock expires and
283
+ * the stalled checker, every `stalledIntervalMs`, moves the job back to `wait`
284
+ * so it runs again. `maxStalledCount` is how many of those re-runs a job gets
285
+ * before it is failed as a dead-letter instead.
286
+ */
287
+ export interface JobStallPolicy {
288
+ /** How long a lock lasts without a renewal. BullMQ's default is 30 s. */
289
+ lockDurationMs: number;
290
+ /** How often the stalled checker runs. BullMQ's default is 30 s. */
291
+ stalledIntervalMs: number;
292
+ /**
293
+ * Re-runs after a stall before the job is failed. `0` fails it on its first
294
+ * stall. BullMQ's default is 1.
295
+ */
296
+ maxStalledCount: number;
297
+ }
298
+
299
+ /**
300
+ * The host's stall settings: values for every queue, and per-queue values over
301
+ * them. A long-running or single-flight queue usually wants a longer lock than
302
+ * a queue of quick sends.
303
+ */
304
+ export interface JobStallConfig extends Partial<JobStallPolicy> {
305
+ queues?: Record<string, Partial<JobStallPolicy>>;
306
+ }
307
+
244
308
  /**
245
309
  * The driver port. `inline` and `bullmq` implement it; a third (SQS, pg-boss)
246
310
  * would need no change above this line.
@@ -1,7 +1,7 @@
1
1
  import { UnrecoverableError, type JobsOptions } from "bullmq";
2
2
 
3
3
  import { assertValidRetention } from "../core/retention";
4
- import type { AnyJobDefinition, JobRetention } from "../core/types";
4
+ import type { AnyJobDefinition, JobRetention, JobStallPolicy } from "../core/types";
5
5
 
6
6
  /**
7
7
  * The BullMQ driver's three policy decisions, kept together and out of the
@@ -84,3 +84,50 @@ export function isTerminalFailure(
84
84
  ): boolean {
85
85
  return attemptsMade >= maxAttempts || error instanceof UnrecoverableError;
86
86
  }
87
+
88
+ /**
89
+ * The most times one run of a job may be STARTED on this queue before the
90
+ * worker fails it as a dead-letter: every attempt it is allowed,
91
+ * plus every stall it is allowed to recover from.
92
+ *
93
+ * This is the only bound a SCHEDULED job has. BullMQ never applies
94
+ * `maxStalledCount` to a job-scheduler job (`moveStalledJobsToWait`'s
95
+ * `isRepeatableJob` branch), so a tick whose handler takes the worker down
96
+ * every time it runs, a poison pill, would otherwise be put back and started
97
+ * again forever, taking every other job on the queue down with it. With the
98
+ * cap, the start that goes over it is failed as unrecoverable, reaches the
99
+ * failed set and `onJobFailed` with `terminal: true`, and the next tick of the
100
+ * schedule is a fresh job with its own budget.
101
+ *
102
+ * Worker-wide, so it takes the largest attempt budget on the queue: a cap
103
+ * below one job's legitimate retries would dead-letter it early.
104
+ */
105
+ export function maxStartedAttemptsFor(
106
+ group: readonly AnyJobDefinition[],
107
+ maxStalledCount: number,
108
+ ): number {
109
+ const attempts = group.map((definition) => Math.max(1, definition.attempts ?? 1));
110
+ return Math.max(1, ...attempts) + maxStalledCount;
111
+ }
112
+
113
+ /**
114
+ * A queue's lock and stall settings, in BullMQ's option names. Spelled out
115
+ * rather than left to BullMQ's defaults: the numbers a stall is judged by
116
+ * belong where the host can read and configure them.
117
+ */
118
+ export function workerStallOptions(
119
+ group: readonly AnyJobDefinition[],
120
+ stall: JobStallPolicy,
121
+ ): {
122
+ lockDuration: number;
123
+ stalledInterval: number;
124
+ maxStalledCount: number;
125
+ maxStartedAttempts: number;
126
+ } {
127
+ return {
128
+ lockDuration: stall.lockDurationMs,
129
+ stalledInterval: stall.stalledIntervalMs,
130
+ maxStalledCount: stall.maxStalledCount,
131
+ maxStartedAttempts: maxStartedAttemptsFor(group, stall.maxStalledCount),
132
+ };
133
+ }
@@ -0,0 +1,127 @@
1
+ import type { Job } from "bullmq";
2
+
3
+ import type { EmitJobEvent } from "../core/events";
4
+ import type { JobLogger } from "../core/types";
5
+
6
+ import { isTerminalFailure } from "./bullmq-policy";
7
+
8
+ /**
9
+ * What the BullMQ driver says when a job goes wrong: one ERROR line per failed
10
+ * attempt and per stall, each naming the job, its run and its attempt, plus
11
+ * the matching `JobEvents` hook for the host.
12
+ *
13
+ * Every failure gets its own line on purpose. A host's logger is usually its
14
+ * error reporter too, and a failure that is only counted, or only reported
15
+ * when it is the last one, leaves nothing to investigate when the pattern is
16
+ * the problem: a job that fails twice a day and succeeds on retry, or a
17
+ * worker that stalls every night and recovers.
18
+ */
19
+ interface ReportingState {
20
+ logger: JobLogger;
21
+ emit: EmitJobEvent;
22
+ }
23
+
24
+ /** The fields of a BullMQ job a failure report reads. */
25
+ type FailedJob = Pick<Job, "name" | "id" | "attemptsMade" | "opts">;
26
+
27
+ /** Report one failed attempt, and `onJobFailed` with whether it was the last. */
28
+ export function reportFailure(
29
+ state: ReportingState,
30
+ queueName: string,
31
+ job: FailedJob | undefined,
32
+ error: unknown,
33
+ ): void {
34
+ if (!job) {
35
+ // BullMQ passes no job when the failure is not tied to one it can read.
36
+ state.logger.error(`a job on queue "${queueName}" failed (run ?):`, error);
37
+ return;
38
+ }
39
+ const maxAttempts = job.opts.attempts ?? 1;
40
+ const terminal = isTerminalFailure(job.attemptsMade, maxAttempts, error);
41
+ const runId = job.id ?? `${job.name}:unknown`;
42
+ // The outcome is stated rather than left to be inferred from the attempt
43
+ // count: a stall-limit or no-handler failure is terminal on attempt 1 of 3.
44
+ state.logger.error(
45
+ `job "${job.name}" failed (run ${runId}, attempt ${job.attemptsMade}/${maxAttempts}, ` +
46
+ `${terminal ? "no retry left" : "will retry"}):`,
47
+ error,
48
+ );
49
+ state.emit((events) =>
50
+ events.onJobFailed?.({
51
+ name: job.name,
52
+ queue: queueName,
53
+ runId,
54
+ attempt: job.attemptsMade,
55
+ maxAttempts,
56
+ error,
57
+ terminal,
58
+ }),
59
+ );
60
+ }
61
+
62
+ /** The fields of a BullMQ job a stall report reads. */
63
+ type StalledJob = Pick<
64
+ Job,
65
+ "name" | "attemptsStarted" | "stalledCounter" | "opts" | "repeatJobKey"
66
+ >;
67
+
68
+ /** Read the stalled job back; a failed read is `undefined`, never a throw. */
69
+ async function readStalledJob(
70
+ getJob: (id: string) => Promise<StalledJob | undefined>,
71
+ jobId: string,
72
+ ): Promise<StalledJob | undefined> {
73
+ try {
74
+ return await getJob(jobId);
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ /** What the stall report says happens next. */
81
+ function nextAfterStall(job: StalledJob | undefined, maxStalledCount: number): string {
82
+ const stalledCount = job?.stalledCounter ?? 0;
83
+ if (job && !job.repeatJobKey && stalledCount > maxStalledCount) {
84
+ return `it has stalled more than the ${maxStalledCount} time(s) allowed, so it is failed as a dead-letter.`;
85
+ }
86
+ return "it was moved back to wait and will run again.";
87
+ }
88
+
89
+ /**
90
+ * Report one stalled job, and `onJobStalled`.
91
+ *
92
+ * BullMQ's `stalled` event carries only the id, so the job is read back for
93
+ * its name and counters. That read is best-effort: a job already trimmed, or a
94
+ * Redis that did not answer, still produces the line, with the id alone.
95
+ *
96
+ * The line says what happens next. A job over its stall budget is failed as a
97
+ * dead-letter rather than re-run, except a scheduled one: BullMQ never applies
98
+ * `maxStalledCount` to those, and they are bounded by `maxStartedAttempts`
99
+ * when they next start instead.
100
+ */
101
+ export async function reportStall(
102
+ state: ReportingState,
103
+ queueName: string,
104
+ jobId: string,
105
+ getJob: (id: string) => Promise<StalledJob | undefined>,
106
+ maxStalledCount: number,
107
+ ): Promise<void> {
108
+ const job = await readStalledJob(getJob, jobId);
109
+ const maxAttempts = job?.opts.attempts ?? 1;
110
+ const startedCount = job?.attemptsStarted ?? 0;
111
+ const stalledCount = job?.stalledCounter ?? 0;
112
+ state.logger.error(
113
+ `job "${job?.name ?? "?"}" on queue "${queueName}" stalled (run ${jobId}, ` +
114
+ `started ${startedCount} time(s) of ${maxAttempts} attempt(s), stalled ${stalledCount} ` +
115
+ `time(s)): its lock expired before it finished; ${nextAfterStall(job, maxStalledCount)}`,
116
+ );
117
+ state.emit((events) =>
118
+ events.onJobStalled?.({
119
+ name: job?.name,
120
+ queue: queueName,
121
+ runId: jobId,
122
+ startedCount,
123
+ maxAttempts,
124
+ stalledCount,
125
+ }),
126
+ );
127
+ }
@@ -28,6 +28,7 @@ import { Queue, UnrecoverableError, Worker, type JobsOptions } from "bullmq";
28
28
  import { createEventEmitter, type EmitJobEvent } from "../core/events";
29
29
  import { DEFAULT_QUEUE } from "../core/queues";
30
30
  import { resolveRegisteredJob } from "../core/registry";
31
+ import { assertValidStall, resolveStallPolicy } from "../core/stall";
31
32
  import type {
32
33
  AnyJobDefinition,
33
34
  EnqueueOptions,
@@ -37,15 +38,19 @@ import type {
37
38
  JobEvents,
38
39
  JobLogger,
39
40
  JobRetention,
41
+ JobStallConfig,
40
42
  } from "../core/types";
41
43
 
42
44
  import {
43
45
  DEFAULT_CONCURRENCY,
44
46
  DEFAULT_JOB_RETENTION,
45
47
  isTerminalFailure,
48
+ maxStartedAttemptsFor,
46
49
  resolveConcurrency,
47
50
  retentionOptions,
51
+ workerStallOptions,
48
52
  } from "./bullmq-policy";
53
+ import { reportFailure, reportStall } from "./bullmq-reporting";
49
54
  import { parseRedisUrl, type RedisConnectionOptions } from "./redis-url";
50
55
 
51
56
  /** The one ioredis command this driver reads outside BullMQ's own surface. */
@@ -66,8 +71,13 @@ export interface BullMqJobDriverOptions {
66
71
  retention?: JobRetention;
67
72
  /** Per-queue concurrency when no definition on the queue states one. */
68
73
  defaultConcurrency?: number;
69
- /** Where completions, dead-letters and removed schedules are reported. */
74
+ /** Where completions, dead-letters, stalls and removed schedules are reported. */
70
75
  events?: JobEvents;
76
+ /**
77
+ * Lock and stall settings, for every queue and per queue. Defaults to
78
+ * BullMQ's own numbers, spelled out in `DEFAULT_STALL_POLICY`.
79
+ */
80
+ stall?: JobStallConfig;
71
81
  }
72
82
 
73
83
  /** Everything the driver's helpers need, threaded instead of closed over. */
@@ -77,6 +87,7 @@ interface DriverState {
77
87
  prefix?: string;
78
88
  retention: Pick<JobsOptions, "removeOnComplete" | "removeOnFail">;
79
89
  defaultConcurrency: number;
90
+ stall: JobStallConfig | undefined;
80
91
  /** Reports to the host's observer; see `core/events`. Never throws. */
81
92
  emit: EmitJobEvent;
82
93
  queues: Map<string, Queue>;
@@ -203,6 +214,7 @@ function startWorker(
203
214
  ): Worker {
204
215
  const onThisQueue = new Set(group.map((definition) => definition.name));
205
216
  const concurrency = resolveConcurrency(group, state.defaultConcurrency);
217
+ const stall = resolveStallPolicy(queueName, state.stall);
206
218
 
207
219
  const worker = new Worker(
208
220
  queueName,
@@ -240,30 +252,19 @@ function startWorker(
240
252
  connection: state.connection,
241
253
  concurrency,
242
254
  ...(state.prefix ? { prefix: state.prefix } : {}),
255
+ ...workerStallOptions(group, stall),
243
256
  },
244
257
  );
245
258
 
246
- worker.on("failed", (job, error) => {
247
- const maxAttempts = job?.opts.attempts ?? 1;
248
- const attempts = job ? `${job.attemptsMade}/${maxAttempts}` : "?";
249
- state.logger.error(
250
- `job "${job?.name ?? queueName}" failed (attempt ${attempts}):`,
251
- error,
252
- );
253
- if (!job) return;
254
- const terminal = isTerminalFailure(job.attemptsMade, maxAttempts, error);
255
- state.emit((events) =>
256
- events.onJobFailed?.({
257
- name: job.name,
258
- queue: queueName,
259
- runId: job.id ?? `${job.name}:unknown`,
260
- attempt: job.attemptsMade,
261
- maxAttempts,
262
- error,
263
- terminal,
264
- }),
265
- );
259
+ worker.on("failed", (job, error) => reportFailure(state, queueName, job, error));
260
+ worker.on("stalled", (jobId) => {
261
+ const getJob = (id: string) => queueFor(state, queueName).getJob(id);
262
+ void reportStall(state, queueName, jobId, getJob, stall.maxStalledCount);
266
263
  });
264
+ // Lock-renewal and lost-lock errors ("could not renew lock for job …",
265
+ // "Missing lock for job … moveToFinished") arrive here. They stay ERRORS:
266
+ // each one is a stall somebody should look at, even though the job itself
267
+ // recovers.
267
268
  worker.on("error", (error) =>
268
269
  state.logger.error(`worker "${queueName}" error:`, error),
269
270
  );
@@ -285,6 +286,9 @@ function groupByQueue(
285
286
  }
286
287
 
287
288
  export function createBullMqJobDriver(options: BullMqJobDriverOptions): JobDriver {
289
+ // The same backstop as retention: a host building the driver directly off
290
+ // `@12-apps/jobs/bullmq` must not reach BullMQ with a lock of 0 or NaN.
291
+ assertValidStall(options.stall);
288
292
  const state: DriverState = {
289
293
  connection: parseRedisUrl(options.redisUrl),
290
294
  logger: options.logger,
@@ -294,6 +298,7 @@ export function createBullMqJobDriver(options: BullMqJobDriverOptions): JobDrive
294
298
  typeof options.defaultConcurrency === "number" && options.defaultConcurrency > 0
295
299
  ? options.defaultConcurrency
296
300
  : DEFAULT_CONCURRENCY,
301
+ stall: options.stall,
297
302
  emit: createEventEmitter(options.events, options.logger),
298
303
  queues: new Map(),
299
304
  workers: [],
@@ -353,5 +358,8 @@ export function createBullMqJobDriver(options: BullMqJobDriverOptions): JobDrive
353
358
  export const __testables = {
354
359
  resolveConcurrency,
355
360
  isTerminalFailure,
361
+ maxStartedAttemptsFor,
362
+ reportFailure,
363
+ reportStall,
356
364
  DEFAULT_CONCURRENCY,
357
365
  };
package/src/index.ts CHANGED
@@ -98,12 +98,17 @@ export type {
98
98
  JobRetentionWindow,
99
99
  JobRunEvent,
100
100
  JobSchedule,
101
+ JobStallConfig,
102
+ JobStalledEvent,
103
+ JobStallPolicy,
101
104
  ScheduleRemovedEvent,
102
105
  } from "./core/types";
103
106
 
104
107
  // Retention validation lives in `core` so this barrel can carry it without
105
108
  // pulling `bullmq` (and ioredis) into a bundle that only ever enqueues.
106
109
  export { assertValidRetention, InvalidJobRetentionError } from "./core/retention";
110
+ // The stall settings' check and defaults, for the same reason.
111
+ export { assertValidStall, DEFAULT_STALL_POLICY, InvalidJobStallError } from "./core/stall";
107
112
 
108
113
  export { createInlineJobDriver } from "./drivers/inline";
109
114
  export type { InlineJobDriver, InlineJobRun } from "./drivers/inline";
@@ -4,6 +4,7 @@ import type {
4
4
  JobEvents,
5
5
  JobLogger,
6
6
  JobRetention,
7
+ JobStallConfig,
7
8
  } from "../core/types";
8
9
  import type { SweepLeaseDbProvider } from "../lease/sweep-lease";
9
10
 
@@ -100,6 +101,13 @@ export interface JobsServerConfig {
100
101
  * that states `concurrency: 1` still gets 1 — a stated value always wins.
101
102
  */
102
103
  defaultConcurrency?: number;
104
+ /**
105
+ * How a worker holds a running job, and how many times a job that lost that
106
+ * hold (a stall) is run again before it is failed. For every queue, with
107
+ * per-queue values over them. Defaults to BullMQ's own numbers (a 30 s lock,
108
+ * a 30 s stalled check, one re-run). Validated at assembly.
109
+ */
110
+ stall?: JobStallConfig;
103
111
  /**
104
112
  * Where the `sweep_leases` table lives — enables `withSweepLease` on the
105
113
  * factory's return. Omit it and the lease helper rejects on first use,
@@ -161,6 +169,7 @@ export interface ResolvedConfig {
161
169
  events: JobEvents | undefined;
162
170
  retention: JobRetention | undefined;
163
171
  defaultConcurrency: number | undefined;
172
+ stall: JobStallConfig | undefined;
164
173
  }
165
174
 
166
175
  /**
@@ -179,5 +188,6 @@ export function resolveConfig(config: JobsServerConfig): ResolvedConfig {
179
188
  events: config.events,
180
189
  retention: config.retention,
181
190
  defaultConcurrency: config.defaultConcurrency,
191
+ stall: config.stall,
182
192
  };
183
193
  }
@@ -1,5 +1,6 @@
1
1
  import { assertJobsRegistered, listJobs } from "../core/registry";
2
2
  import { assertValidRetention } from "../core/retention";
3
+ import { assertValidStall } from "../core/stall";
3
4
  import {
4
5
  configureJobs,
5
6
  getJobDriver,
@@ -339,6 +340,9 @@ export function createApiJobs(config: JobsServerConfig): JobsApi {
339
340
  // all — silently, weeks before anyone notices. The driver re-checks it, for
340
341
  // a host that builds one directly off `@12-apps/jobs/bullmq`.
341
342
  assertValidRetention(config?.retention);
343
+ // And the stall settings: a lock of 0 or NaN expires at once, and every job
344
+ // would be reported stalled and re-run while it was still running.
345
+ assertValidStall(config?.stall);
342
346
 
343
347
  const state: RuntimeState = {
344
348
  starting: false,
@@ -32,4 +32,5 @@ export type { JobsDriverChoice, JobsServerConfig, JobsSource } from "./config";
32
32
  // and `start()` throw without also reaching for the root entry point.
33
33
  export { NoJobsRegisteredError } from "../core/registry";
34
34
  export { InvalidJobRetentionError } from "../core/retention";
35
- export type { JobEvents, JobRetention } from "../core/types";
35
+ export { InvalidJobStallError } from "../core/stall";
36
+ export type { JobEvents, JobRetention, JobStallConfig } from "../core/types";
@@ -144,6 +144,7 @@ async function resolveBullMq(
144
144
  events: resolved.events,
145
145
  retention: resolved.retention,
146
146
  defaultConcurrency: resolved.defaultConcurrency,
147
+ stall: resolved.stall,
147
148
  }),
148
149
  deliberatelyOff: false,
149
150
  };