fix cron store backup churn (#19484)

This commit is contained in:
Pierre
2026-03-01 05:10:53 -08:00
committed by GitHub
parent 0cc46589ac
commit 5784963608
2 changed files with 68 additions and 7 deletions

View File

@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadCronStore, resolveCronStorePath } from "./store.js";
import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js";
import type { CronStoreFile } from "./types.js";
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-store-"));
@@ -15,6 +16,27 @@ async function makeStorePath() {
};
}
function makeStore(jobId: string, enabled: boolean): CronStoreFile {
const now = Date.now();
return {
version: 1,
jobs: [
{
id: jobId,
name: `Job ${jobId}`,
enabled,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: `tick-${jobId}` },
state: {},
},
],
};
}
describe("resolveCronStorePath", () => {
afterEach(() => {
vi.unstubAllEnvs();
@@ -43,4 +65,30 @@ describe("cron store", () => {
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
await store.cleanup();
});
it("does not create a backup file when saving unchanged content", async () => {
const store = await makeStorePath();
const payload = makeStore("job-1", true);
await saveCronStore(store.storePath, payload);
await saveCronStore(store.storePath, payload);
await expect(fs.stat(`${store.storePath}.bak`)).rejects.toThrow();
await store.cleanup();
});
it("backs up previous content before replacing the store", async () => {
const store = await makeStorePath();
const first = makeStore("job-1", true);
const second = makeStore("job-2", false);
await saveCronStore(store.storePath, first);
await saveCronStore(store.storePath, second);
const currentRaw = await fs.readFile(store.storePath, "utf-8");
const backupRaw = await fs.readFile(`${store.storePath}.bak`, "utf-8");
expect(JSON.parse(currentRaw)).toEqual(second);
expect(JSON.parse(backupRaw)).toEqual(first);
await store.cleanup();
});
});

View File

@@ -50,13 +50,26 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
export async function saveCronStore(storePath: string, store: CronStoreFile) {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
const { randomBytes } = await import("node:crypto");
const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
const json = JSON.stringify(store, null, 2);
await fs.promises.writeFile(tmp, json, "utf-8");
await fs.promises.rename(tmp, storePath);
let previous: string | null = null;
try {
await fs.promises.copyFile(storePath, `${storePath}.bak`);
} catch {
// best-effort
previous = await fs.promises.readFile(storePath, "utf-8");
} catch (err) {
if ((err as { code?: unknown }).code !== "ENOENT") {
throw err;
}
}
if (previous === json) {
return;
}
const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
await fs.promises.writeFile(tmp, json, "utf-8");
if (previous !== null) {
try {
await fs.promises.copyFile(storePath, `${storePath}.bak`);
} catch {
// best-effort
}
}
await fs.promises.rename(tmp, storePath);
}