From e537cb04013d7bf4fd0fb125fb72f42f5a51917f Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Tue, 21 Jul 2026 21:23:12 +0800 Subject: [PATCH] fix(jobs): classify BullMQ stall (UnrecoverableError) as operational (#610) BullMQ raises UnrecoverableError when a job loses its lock (a stall), e.g. a heavy upscale under memory pressure. We never throw it ourselves, so classifyError now treats it as operational (one warning per hour) instead of a bug. ReplyError stays a bug. --- apps/api/src/lib/error-report.ts | 6 ++++++ tests/unit/api/error-report.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/api/src/lib/error-report.ts b/apps/api/src/lib/error-report.ts index 05c63738..c60eb058 100644 --- a/apps/api/src/lib/error-report.ts +++ b/apps/api/src/lib/error-report.ts @@ -64,6 +64,12 @@ export function classifyError(err: unknown, source?: ReportContext["source"]): E // a worker-side ZodError is schema drift (our bug); only expected on http. if (e?.name === "ZodError") return "expected"; } + // BullMQ raises UnrecoverableError from its own worker run loop when a job + // loses its lock (a stall), e.g. a heavy upscale under CPU/memory pressure. + // We never throw it ourselves, so it always means the instance could not keep + // the job's lock alive: an environmental strain, not a defect in our code. + // Operational (one warning/hour) instead of bug spam (NODE-27). + if (e?.name === "UnrecoverableError") return "operational"; if (isSafeMessageError(err)) return err.kind === "bug" ? "bug" : "operational"; if (connectivityClass(err)) return "operational"; if (isEnvironmentalDbError(err)) return "operational"; diff --git a/tests/unit/api/error-report.test.ts b/tests/unit/api/error-report.test.ts index 65e5c15d..3a1dc683 100644 --- a/tests/unit/api/error-report.test.ts +++ b/tests/unit/api/error-report.test.ts @@ -99,6 +99,18 @@ describe("classifyError", () => { expect(classifyError(e, "http")).toBe("expected"); expect(classifyError(e)).toBe("expected"); }); + it("operational: a BullMQ UnrecoverableError (stalled/lock-lost job) is a strained instance, not our bug", () => { + // BullMQ raises UnrecoverableError from its own worker loop when a job loses + // its lock (a stall), e.g. a heavy `upscale` under CPU/memory pressure. We + // never throw it ourselves, so it always means the instance could not keep + // the job's lock alive -- environmental, worth one warning/hour not bug spam + // (NODE-27). A ReplyError (a real Redis command failure) stays a bug. + const stalled = Object.assign(new Error("Missing lock for job 42. moveToFinished"), { + name: "UnrecoverableError", + }); + expect(classifyError(stalled, "worker")).toBe("operational"); + expect(classifyError(stalled)).toBe("operational"); + }); }); describe("throttle", () => {