From 470bb2561fa4c2e6c1ac09e14576f9acb79610f1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 13:57:13 -0400 Subject: [PATCH] cron: recover missing split config file --- src/cron/store.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/cron/store.ts | 39 +++++++++++++++++++++++---------------- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 316c44fc1b5..dde5ca4b28e 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -238,6 +238,40 @@ describe("cron store", () => { expect(stateFile.jobs["job-1"].state.nextRunAtMs).toBe(payload.jobs[0].createdAtMs + 60_000); }); + it("recreates a missing config file without rewriting unchanged state", async () => { + const store = await makeStorePath(); + const statePath = store.storePath.replace(/\.json$/, "-state.json"); + const payload = makeStore("job-1", true); + payload.jobs[0].state = { nextRunAtMs: payload.jobs[0].createdAtMs + 60_000 }; + + await saveCronStore(store.storePath, payload); + await loadCronStore(store.storePath); + const stateRawBefore = await fs.readFile(statePath, "utf-8"); + await fs.rm(store.storePath); + + const renamedDestinations: string[] = []; + const origRename = fs.rename.bind(fs); + const spy = vi.spyOn(fs, "rename").mockImplementation(async (src, dest) => { + renamedDestinations.push(String(dest)); + return origRename(src, dest); + }); + + try { + await saveCronStore(store.storePath, payload); + } finally { + spy.mockRestore(); + } + + const config = JSON.parse(await fs.readFile(store.storePath, "utf-8")); + const stateRawAfter = await fs.readFile(statePath, "utf-8"); + + expect(config.jobs[0].id).toBe("job-1"); + expect(config.jobs[0].state).toEqual({}); + expect(stateRawAfter).toBe(stateRawBefore); + expect(renamedDestinations).toContain(store.storePath); + expect(renamedDestinations).not.toContain(statePath); + }); + it("migrates legacy inline state into the state sidecar", async () => { const store = await makeStorePath(); const statePath = store.storePath.replace(/\.json$/, "-state.json"); diff --git a/src/cron/store.ts b/src/cron/store.ts index 7b548edeed0..ad8075dcf17 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -232,6 +232,25 @@ async function atomicWrite(filePath: string, content: string, dirMode = 0o700): await setSecureFileMode(filePath); } +async function serializedFileNeedsWrite( + filePath: string, + expectedJson: string, + contentChanged: boolean, +): Promise { + if (contentChanged) { + return true; + } + try { + const diskJson = await fs.promises.readFile(filePath, "utf-8"); + return diskJson !== expectedJson; + } catch (err) { + if ((err as { code?: unknown })?.code === "ENOENT") { + return true; + } + throw err; + } +} + export async function saveCronStore( storePath: string, store: CronStoreFile, @@ -247,22 +266,10 @@ export async function saveCronStore( const configChanged = cache?.configJson !== configJson; const stateChanged = cache?.stateJson !== stateJson; const migrating = cache?.needsSplitMigration === true; - let stateNeedsWrite = stateChanged; + const configNeedsWrite = await serializedFileNeedsWrite(storePath, configJson, configChanged); + const stateNeedsWrite = await serializedFileNeedsWrite(statePath, stateJson, stateChanged); - if (!stateNeedsWrite) { - try { - const diskStateJson = await fs.promises.readFile(statePath, "utf-8"); - stateNeedsWrite = diskStateJson !== stateJson; - } catch (err) { - if ((err as { code?: unknown })?.code === "ENOENT") { - stateNeedsWrite = true; - } else { - throw err; - } - } - } - - if (!configChanged && !stateNeedsWrite && !migrating) { + if (!configNeedsWrite && !stateNeedsWrite && !migrating) { return; } @@ -274,7 +281,7 @@ export async function saveCronStore( updatedCache.stateJson = stateJson; } - if (configChanged || migrating) { + if (configNeedsWrite || migrating) { // Determine backup need: only when config actually changed (not migration-only). const skipBackup = opts?.skipBackup === true || !configChanged; if (!skipBackup) {