Skip to content

Commit 0aef3aa

Browse files
therealbradclaude
andauthored
fix(scheduler): skip legacy keyless entries in reconciliation (#403)
* fix(scheduler): skip legacy keyless entries in reconciliation Pre-BullMQ-5 repeat zset members (MD5-style, no scheduler hash — ~95 in prod) make getJobSchedulers() yield undefined/keyless entries. The first v0.34.11 prod boot TypeError'd on one, aborting the whole reconciliation pass (caught by the outer guard, so boot was unaffected, but reconciliation was inert). Skip such entries — they are exactly the "foreign, never touch" case — and keep processing real entries after them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: prettier Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 57f50d9 commit 0aef3aa

2 files changed

Lines changed: 54 additions & 3 deletions

File tree

testplanit/scheduler.reconcile.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,47 @@ describe("reconcileStaleSchedulers", () => {
177177
)
178178
).resolves.toBeUndefined();
179179
});
180+
181+
it("skips undefined/keyless entries from legacy repeat-zset members", async () => {
182+
// Pre-BullMQ-5 repeat members (MD5-style, no scheduler hash) make
183+
// getJobSchedulers() yield undefined or keyless entries. Prod incident
184+
// 2026-06-05: one undefined entry TypeError'd the whole reconciliation
185+
// pass. Stale entries AFTER the bad ones must still be processed.
186+
const removed: string[] = [];
187+
const q = {
188+
name: "forecast-updates",
189+
getJobSchedulers: vi.fn(async () => [
190+
undefined,
191+
null,
192+
{ name: JOB }, // keyless
193+
{ key: null, name: JOB },
194+
{ key: `${JOB}-gone`, name: JOB }, // real stale entry after the junk
195+
]),
196+
removeJobScheduler: vi.fn(async (id: string) => {
197+
removed.push(id);
198+
}),
199+
};
200+
await expect(
201+
reconcileStaleSchedulers(
202+
[{ queue: q as never, jobNames: [JOB] }],
203+
new Set<string>()
204+
)
205+
).resolves.toBeUndefined();
206+
expect(removed).toEqual([`${JOB}-gone`]);
207+
});
208+
209+
it("tolerates getJobSchedulers resolving to null", async () => {
210+
const q = {
211+
name: "forecast-updates",
212+
getJobSchedulers: vi.fn(async () => null),
213+
removeJobScheduler: vi.fn(),
214+
};
215+
await expect(
216+
reconcileStaleSchedulers(
217+
[{ queue: q as never, jobNames: [JOB] }],
218+
new Set<string>()
219+
)
220+
).resolves.toBeUndefined();
221+
expect(q.removeJobScheduler).not.toHaveBeenCalled();
222+
});
180223
});

testplanit/scheduler.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ export async function reconcileStaleSchedulers(
5656
start?: number,
5757
end?: number,
5858
asc?: boolean
59-
) => Promise<Array<{ key: string; name: string }>>;
59+
) => Promise<
60+
Array<{ key?: string | null; name?: string } | undefined | null>
61+
>;
6062
removeJobScheduler: (id: string) => Promise<unknown>;
6163
name: string;
6264
} | null;
@@ -78,8 +80,14 @@ export async function reconcileStaleSchedulers(
7880
continue;
7981
}
8082

81-
for (const scheduler of schedulers) {
82-
const schedulerId = scheduler.key;
83+
for (const scheduler of schedulers ?? []) {
84+
// Legacy (pre-BullMQ-5) repeat zset members have no scheduler hash, so
85+
// getJobSchedulers() yields undefined/keyless entries for them — seen
86+
// in prod 2026-06-05 (95 MD5-style members), where one such entry
87+
// TypeError'd the whole reconciliation pass. Skip them; they are
88+
// exactly the "foreign — never touch" case.
89+
const schedulerId = scheduler?.key;
90+
if (typeof schedulerId !== "string" || schedulerId.length === 0) continue;
8391
// Match the longest job name first so e.g. a hypothetical
8492
// "send-daily-digest-summary" job is not mistaken for
8593
// "send-daily-digest" with tenant "summary".

0 commit comments

Comments
 (0)