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
This commit is contained in:
Sid
2026-03-01 20:53:39 +08:00
committed by GitHub
parent d509a81a12
commit 504c1f3607
6 changed files with 147 additions and 6 deletions

View File

@@ -138,6 +138,25 @@ describe("normalizeCronJobCreate", () => {
expectNormalizedAtSchedule({ kind: "at", atMs: "2026-01-12T18:00:00" });
});
it("migrates legacy schedule.cron into schedule.expr", () => {
const normalized = normalizeCronJobCreate({
name: "legacy-cron-field",
enabled: true,
schedule: { kind: "cron", cron: "*/10 * * * *", tz: "UTC" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "tick",
},
}) as unknown as Record<string, unknown>;
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule.kind).toBe("cron");
expect(schedule.expr).toBe("*/10 * * * *");
expect(schedule.cron).toBeUndefined();
});
it("defaults cron stagger for recurring top-of-hour schedules", () => {
const normalized = normalizeCronJobCreate({
name: "hourly",

View File

@@ -25,6 +25,9 @@ function coerceSchedule(schedule: UnknownRecord) {
const next: UnknownRecord = { ...schedule };
const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : "";
const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" ? rawKind : undefined;
const exprRaw = typeof schedule.expr === "string" ? schedule.expr.trim() : "";
const legacyCronRaw = typeof schedule.cron === "string" ? schedule.cron.trim() : "";
const normalizedExpr = exprRaw || legacyCronRaw;
const atMsRaw = schedule.atMs;
const atRaw = schedule.at;
const atString = typeof atRaw === "string" ? atRaw.trim() : "";
@@ -48,7 +51,7 @@ function coerceSchedule(schedule: UnknownRecord) {
next.kind = "at";
} else if (typeof schedule.everyMs === "number") {
next.kind = "every";
} else if (typeof schedule.expr === "string") {
} else if (normalizedExpr) {
next.kind = "cron";
}
}
@@ -62,6 +65,15 @@ function coerceSchedule(schedule: UnknownRecord) {
delete next.atMs;
}
if (normalizedExpr) {
next.expr = normalizedExpr;
} else if ("expr" in next) {
delete next.expr;
}
if ("cron" in next) {
delete next.cron;
}
const staggerMs = normalizeCronStaggerMs(schedule.staggerMs);
if (staggerMs !== undefined) {
next.staggerMs = staggerMs;

View File

@@ -25,6 +25,19 @@ describe("cron schedule", () => {
).toThrow("invalid cron schedule: expr is required");
});
it("supports legacy cron field when expr is missing", () => {
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
const next = computeNextRunAtMs(
{
kind: "cron",
cron: "0 9 * * 3",
tz: "America/Los_Angeles",
} as unknown as { kind: "cron"; expr: string; tz?: string },
nowMs,
);
expect(next).toBe(Date.parse("2025-12-17T17:00:00.000Z"));
});
it("computes next run for every schedule", () => {
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
const now = anchor + 10_000;

View File

@@ -41,7 +41,8 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
return anchor + steps * everyMs;
}
const exprSource = (schedule as { expr?: unknown }).expr;
const cronSchedule = schedule as { expr?: unknown; cron?: unknown };
const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron;
if (typeof exprSource !== "string") {
throw new Error("invalid cron schedule: expr is required");
}

View File

@@ -148,4 +148,59 @@ describe("CronService store migrations", () => {
cron.stop();
await store.cleanup();
});
it("migrates legacy cron fields (jobId + schedule.cron) and defaults wakeMode", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
jobId: "legacy-cron-field-job",
name: "legacy cron field",
enabled: true,
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "tick" },
state: {},
},
],
},
null,
2,
),
"utf-8",
);
const cron = await createStartedCron(store.storePath).start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((entry) => entry.id === "legacy-cron-field-job");
expect(job).toBeDefined();
expect(job?.wakeMode).toBe("now");
expect(job?.schedule.kind).toBe("cron");
if (job?.schedule.kind === "cron") {
expect(job.schedule.expr).toBe("*/5 * * * *");
}
const persisted = JSON.parse(await fs.readFile(store.storePath, "utf-8")) as {
jobs: Array<Record<string, unknown>>;
};
const persistedJob = persisted.jobs.find((entry) => entry.id === "legacy-cron-field-job");
expect(persistedJob).toBeDefined();
expect(persistedJob?.jobId).toBeUndefined();
expect(persistedJob?.wakeMode).toBe("now");
const persistedSchedule =
persistedJob?.schedule && typeof persistedJob.schedule === "object"
? (persistedJob.schedule as Record<string, unknown>)
: null;
expect(persistedSchedule?.cron).toBeUndefined();
expect(persistedSchedule?.expr).toBe("*/5 * * * *");
cron.stop();
await store.cleanup();
});
});

View File

@@ -248,6 +248,20 @@ export async function ensureLoaded(
mutated = true;
}
const rawId = typeof raw.id === "string" ? raw.id.trim() : "";
const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : "";
if (!rawId && legacyJobId) {
raw.id = legacyJobId;
mutated = true;
} else if (rawId && raw.id !== rawId) {
raw.id = rawId;
mutated = true;
}
if ("jobId" in raw) {
delete raw.jobId;
mutated = true;
}
const nameRaw = raw.name;
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
raw.name = inferLegacyName({
@@ -279,6 +293,22 @@ export async function ensureLoaded(
mutated = true;
}
const wakeModeRaw = typeof raw.wakeMode === "string" ? raw.wakeMode.trim().toLowerCase() : "";
if (wakeModeRaw === "next-heartbeat") {
if (raw.wakeMode !== "next-heartbeat") {
raw.wakeMode = "next-heartbeat";
mutated = true;
}
} else if (wakeModeRaw === "now") {
if (raw.wakeMode !== "now") {
raw.wakeMode = "now";
mutated = true;
}
} else {
raw.wakeMode = "now";
mutated = true;
}
const payload = raw.payload;
if (
(!payload || typeof payload !== "object" || Array.isArray(payload)) &&
@@ -383,13 +413,24 @@ export async function ensureLoaded(
}
const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : "";
if (typeof sched.expr === "string" && sched.expr !== exprRaw) {
sched.expr = exprRaw;
const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : "";
let normalizedExpr = exprRaw;
if (!normalizedExpr && legacyCronRaw) {
normalizedExpr = legacyCronRaw;
sched.expr = normalizedExpr;
mutated = true;
}
if ((kind === "cron" || sched.kind === "cron") && exprRaw) {
if (typeof sched.expr === "string" && sched.expr !== normalizedExpr) {
sched.expr = normalizedExpr;
mutated = true;
}
if ("cron" in sched) {
delete sched.cron;
mutated = true;
}
if ((kind === "cron" || sched.kind === "cron") && normalizedExpr) {
const explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs);
const defaultStaggerMs = resolveDefaultCronStaggerMs(exprRaw);
const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr);
const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs;
if (targetStaggerMs === undefined) {
if ("staggerMs" in sched) {