From d1623edb1863d49f9b25480e61deb5ecce06d02e Mon Sep 17 00:00:00 2001 From: create Date: Fri, 20 Mar 2026 14:34:22 +0800 Subject: [PATCH] fix(cron): handle invalid schedule reload errors --- ...external-reload-schedule-recompute.test.ts | 66 +++++++++++++++++++ src/cron/service/store.ts | 16 ++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/cron/service.external-reload-schedule-recompute.test.ts b/src/cron/service.external-reload-schedule-recompute.test.ts index 7c45d2cac29..ef45c1c6058 100644 --- a/src/cron/service.external-reload-schedule-recompute.test.ts +++ b/src/cron/service.external-reload-schedule-recompute.test.ts @@ -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(); + }); }); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 37942ccd367..47f5db9c8cb 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -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 { @@ -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;