cron: recover missing split config file

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 13:57:13 -04:00
parent 8c609ca411
commit 470bb2561f
2 changed files with 57 additions and 16 deletions

View File

@@ -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");

View File

@@ -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<boolean> {
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) {