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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user