Skip to content

Commit 504c1f3

Browse files
author
Sid
authored
fix(cron): migrate legacy schedule cron fields on load (#28889)
Backfill legacy jobs that still use schedule.cron and jobId so upgraded instances keep firing existing cron schedules instead of failing silently. Closes #28861
1 parent d509a81 commit 504c1f3

File tree

6 files changed

+147
-6
lines changed

6 files changed

+147
-6
lines changed

src/cron/normalize.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ describe("normalizeCronJobCreate", () => {
138138
expectNormalizedAtSchedule({ kind: "at", atMs: "2026-01-12T18:00:00" });
139139
});
140140

141+
it("migrates legacy schedule.cron into schedule.expr", () => {
142+
const normalized = normalizeCronJobCreate({
143+
name: "legacy-cron-field",
144+
enabled: true,
145+
schedule: { kind: "cron", cron: "*/10 * * * *", tz: "UTC" },
146+
sessionTarget: "main",
147+
wakeMode: "next-heartbeat",
148+
payload: {
149+
kind: "systemEvent",
150+
text: "tick",
151+
},
152+
}) as unknown as Record<string, unknown>;
153+
154+
const schedule = normalized.schedule as Record<string, unknown>;
155+
expect(schedule.kind).toBe("cron");
156+
expect(schedule.expr).toBe("*/10 * * * *");
157+
expect(schedule.cron).toBeUndefined();
158+
});
159+
141160
it("defaults cron stagger for recurring top-of-hour schedules", () => {
142161
const normalized = normalizeCronJobCreate({
143162
name: "hourly",

src/cron/normalize.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ function coerceSchedule(schedule: UnknownRecord) {
2525
const next: UnknownRecord = { ...schedule };
2626
const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : "";
2727
const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : undefined;
28+
const exprRaw = typeof schedule.expr === "string" ? schedule.expr.trim() : "";
29+
const legacyCronRaw = typeof schedule.cron === "string" ? schedule.cron.trim() : "";
30+
const normalizedExpr = exprRaw || legacyCronRaw;
2831
const atMsRaw = schedule.atMs;
2932
const atRaw = schedule.at;
3033
const atString = typeof atRaw === "string" ? atRaw.trim() : "";
@@ -48,7 +51,7 @@ function coerceSchedule(schedule: UnknownRecord) {
4851
next.kind = "at";
4952
} else if (typeof schedule.everyMs === "number") {
5053
next.kind = "every";
51-
} else if (typeof schedule.expr === "string") {
54+
} else if (normalizedExpr) {
5255
next.kind = "cron";
5356
}
5457
}
@@ -62,6 +65,15 @@ function coerceSchedule(schedule: UnknownRecord) {
6265
delete next.atMs;
6366
}
6467

68+
if (normalizedExpr) {
69+
next.expr = normalizedExpr;
70+
} else if ("expr" in next) {
71+
delete next.expr;
72+
}
73+
if ("cron" in next) {
74+
delete next.cron;
75+
}
76+
6577
const staggerMs = normalizeCronStaggerMs(schedule.staggerMs);
6678
if (staggerMs !== undefined) {
6779
next.staggerMs = staggerMs;

src/cron/schedule.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ describe("cron schedule", () => {
2525
).toThrow("invalid cron schedule: expr is required");
2626
});
2727

28+
it("supports legacy cron field when expr is missing", () => {
29+
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
30+
const next = computeNextRunAtMs(
31+
{
32+
kind: "cron",
33+
cron: "0 9 * * 3",
34+
tz: "America/Los_Angeles",
35+
} as unknown as { kind: "cron"; expr: string; tz?: string },
36+
nowMs,
37+
);
38+
expect(next).toBe(Date.parse("2025-12-17T17:00:00.000Z"));
39+
});
40+
2841
it("computes next run for every schedule", () => {
2942
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
3043
const now = anchor + 10_000;

src/cron/schedule.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
4141
return anchor + steps * everyMs;
4242
}
4343

44-
const exprSource = (schedule as { expr?: unknown }).expr;
44+
const cronSchedule = schedule as { expr?: unknown; cron?: unknown };
45+
const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron;
4546
if (typeof exprSource !== "string") {
4647
throw new Error("invalid cron schedule: expr is required");
4748
}

src/cron/service.store-migration.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,59 @@ describe("CronService store migrations", () => {
148148
cron.stop();
149149
await store.cleanup();
150150
});
151+
152+
it("migrates legacy cron fields (jobId + schedule.cron) and defaults wakeMode", async () => {
153+
const store = await makeStorePath();
154+
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
155+
await fs.writeFile(
156+
store.storePath,
157+
JSON.stringify(
158+
{
159+
version: 1,
160+
jobs: [
161+
{
162+
jobId: "legacy-cron-field-job",
163+
name: "legacy cron field",
164+
enabled: true,
165+
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
166+
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
167+
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
168+
payload: { kind: "systemEvent", text: "tick" },
169+
state: {},
170+
},
171+
],
172+
},
173+
null,
174+
2,
175+
),
176+
"utf-8",
177+
);
178+
179+
const cron = await createStartedCron(store.storePath).start();
180+
const jobs = await cron.list({ includeDisabled: true });
181+
const job = jobs.find((entry) => entry.id === "legacy-cron-field-job");
182+
expect(job).toBeDefined();
183+
expect(job?.wakeMode).toBe("now");
184+
expect(job?.schedule.kind).toBe("cron");
185+
if (job?.schedule.kind === "cron") {
186+
expect(job.schedule.expr).toBe("*/5 * * * *");
187+
}
188+
189+
const persisted = JSON.parse(await fs.readFile(store.storePath, "utf-8")) as {
190+
jobs: Array<Record<string, unknown>>;
191+
};
192+
const persistedJob = persisted.jobs.find((entry) => entry.id === "legacy-cron-field-job");
193+
expect(persistedJob).toBeDefined();
194+
expect(persistedJob?.jobId).toBeUndefined();
195+
expect(persistedJob?.wakeMode).toBe("now");
196+
const persistedSchedule =
197+
persistedJob?.schedule && typeof persistedJob.schedule === "object"
198+
? (persistedJob.schedule as Record<string, unknown>)
199+
: null;
200+
expect(persistedSchedule?.cron).toBeUndefined();
201+
expect(persistedSchedule?.expr).toBe("*/5 * * * *");
202+
203+
cron.stop();
204+
await store.cleanup();
205+
});
151206
});

src/cron/service/store.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,20 @@ export async function ensureLoaded(
248248
mutated = true;
249249
}
250250

251+
const rawId = typeof raw.id === "string" ? raw.id.trim() : "";
252+
const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : "";
253+
if (!rawId && legacyJobId) {
254+
raw.id = legacyJobId;
255+
mutated = true;
256+
} else if (rawId && raw.id !== rawId) {
257+
raw.id = rawId;
258+
mutated = true;
259+
}
260+
if ("jobId" in raw) {
261+
delete raw.jobId;
262+
mutated = true;
263+
}
264+
251265
const nameRaw = raw.name;
252266
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
253267
raw.name = inferLegacyName({
@@ -279,6 +293,22 @@ export async function ensureLoaded(
279293
mutated = true;
280294
}
281295

296+
const wakeModeRaw = typeof raw.wakeMode === "string" ? raw.wakeMode.trim().toLowerCase() : "";
297+
if (wakeModeRaw === "next-heartbeat") {
298+
if (raw.wakeMode !== "next-heartbeat") {
299+
raw.wakeMode = "next-heartbeat";
300+
mutated = true;
301+
}
302+
} else if (wakeModeRaw === "now") {
303+
if (raw.wakeMode !== "now") {
304+
raw.wakeMode = "now";
305+
mutated = true;
306+
}
307+
} else {
308+
raw.wakeMode = "now";
309+
mutated = true;
310+
}
311+
282312
const payload = raw.payload;
283313
if (
284314
(!payload || typeof payload !== "object" || Array.isArray(payload)) &&
@@ -383,13 +413,24 @@ export async function ensureLoaded(
383413
}
384414

385415
const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : "";
386-
if (typeof sched.expr === "string" && sched.expr !== exprRaw) {
387-
sched.expr = exprRaw;
416+
const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : "";
417+
let normalizedExpr = exprRaw;
418+
if (!normalizedExpr && legacyCronRaw) {
419+
normalizedExpr = legacyCronRaw;
420+
sched.expr = normalizedExpr;
421+
mutated = true;
422+
}
423+
if (typeof sched.expr === "string" && sched.expr !== normalizedExpr) {
424+
sched.expr = normalizedExpr;
425+
mutated = true;
426+
}
427+
if ("cron" in sched) {
428+
delete sched.cron;
388429
mutated = true;
389430
}
390-
if ((kind === "cron" || sched.kind === "cron") && exprRaw) {
431+
if ((kind === "cron" || sched.kind === "cron") && normalizedExpr) {
391432
const explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs);
392-
const defaultStaggerMs = resolveDefaultCronStaggerMs(exprRaw);
433+
const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr);
393434
const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs;
394435
if (targetStaggerMs === undefined) {
395436
if ("staggerMs" in sched) {

0 commit comments

Comments
 (0)