fix(cron): handle invalid schedule reload errors

This commit is contained in:
create 2026-03-20 14:34:22 +08:00
parent 2efa044a29
commit d1623edb18
2 changed files with 80 additions and 2 deletions

View File

@ -76,4 +76,70 @@ describe("forceReload repairs externally changed cron schedules", () => {
const persistedJob = persisted.jobs?.find((job) => job.id === jobId);
expect(persistedJob?.state?.nextRunAtMs).toBe(correctedNextRunAtMs);
});
it("records schedule errors instead of aborting reload when an external edit is invalid", async () => {
const store = await makeStorePath();
const nowMs = Date.parse("2026-03-19T01:44:00.000Z");
const jobId = "external-invalid-schedule";
const createJob = (expr: string): CronJob => ({
id: jobId,
name: "external invalid schedule",
enabled: true,
createdAtMs: Date.parse("2026-03-18T00:30:00.000Z"),
updatedAtMs: Date.parse("2026-03-19T01:44:00.000Z"),
schedule: { kind: "cron", expr, tz: "Asia/Shanghai", staggerMs: 0 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "tick" },
state: {
nextRunAtMs: Date.parse("2026-03-20T00:30:00.000Z"),
lastRunAtMs: Date.parse("2026-03-19T00:30:00.000Z"),
lastStatus: "ok",
lastRunStatus: "ok",
},
});
await writeCronStoreSnapshot({
storePath: store.storePath,
jobs: [createJob("30 8 * * *")],
});
const state = createCronServiceState({
cronEnabled: true,
storePath: store.storePath,
log: noopLogger,
nowMs: () => nowMs,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
await ensureLoaded(state, { skipRecompute: true });
await writeCronStoreSnapshot({
storePath: store.storePath,
jobs: [createJob("not a valid cron")],
});
await expect(
ensureLoaded(state, { forceReload: true, skipRecompute: true }),
).resolves.toBeUndefined();
const reloaded = state.store?.jobs.find((job) => job.id === jobId);
expect(reloaded?.state.nextRunAtMs).toBeUndefined();
expect(reloaded?.state.scheduleErrorCount).toBe(1);
expect(reloaded?.state.lastError).toMatch(/^schedule error:/);
const persisted = JSON.parse(await fs.readFile(store.storePath, "utf8")) as {
jobs?: Array<{
id: string;
state?: { scheduleErrorCount?: number; lastError?: string; nextRunAtMs?: number };
}>;
};
const persistedJob = persisted.jobs?.find((job) => job.id === jobId);
expect(persistedJob?.state?.scheduleErrorCount).toBe(1);
expect(persistedJob?.state?.lastError).toMatch(/^schedule error:/);
expect(persistedJob?.state?.nextRunAtMs).toBeUndefined();
});
});

View File

@ -2,7 +2,7 @@ import fs from "node:fs";
import { normalizeStoredCronJobs } from "../store-migration.js";
import { loadCronStore, saveCronStore } from "../store.js";
import type { CronJob } from "../types.js";
import { computeJobNextRunAtMs, recomputeNextRuns } from "./jobs.js";
import { computeJobNextRunAtMs, recordScheduleComputeError, recomputeNextRuns } from "./jobs.js";
import type { CronServiceState } from "./state.js";
async function getFileMtimeMs(path: string): Promise<number | null> {
@ -55,7 +55,19 @@ function repairNextRunsAfterExternalReload(params: {
continue;
}
const nextRunAtMs = job.enabled ? computeJobNextRunAtMs(job, now) : undefined;
let nextRunAtMs: number | undefined;
try {
nextRunAtMs = job.enabled ? computeJobNextRunAtMs(job, now) : undefined;
if (job.state.scheduleErrorCount !== undefined) {
job.state.scheduleErrorCount = undefined;
changed = true;
}
} catch (err) {
if (recordScheduleComputeError({ state, job, err })) {
changed = true;
}
continue;
}
if (job.state.nextRunAtMs !== nextRunAtMs) {
job.state.nextRunAtMs = nextRunAtMs;
changed = true;