Skip to content

Commit 5784963

Browse files
fix cron store backup churn (#19484)
1 parent 0cc4658 commit 5784963

File tree

2 files changed

+68
-7
lines changed

2 files changed

+68
-7
lines changed

src/cron/store.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
5-
import { loadCronStore, resolveCronStorePath } from "./store.js";
5+
import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js";
6+
import type { CronStoreFile } from "./types.js";
67

78
async function makeStorePath() {
89
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-store-"));
@@ -15,6 +16,27 @@ async function makeStorePath() {
1516
};
1617
}
1718

19+
function makeStore(jobId: string, enabled: boolean): CronStoreFile {
20+
const now = Date.now();
21+
return {
22+
version: 1,
23+
jobs: [
24+
{
25+
id: jobId,
26+
name: `Job ${jobId}`,
27+
enabled,
28+
createdAtMs: now,
29+
updatedAtMs: now,
30+
schedule: { kind: "every", everyMs: 60_000 },
31+
sessionTarget: "main",
32+
wakeMode: "next-heartbeat",
33+
payload: { kind: "systemEvent", text: `tick-${jobId}` },
34+
state: {},
35+
},
36+
],
37+
};
38+
}
39+
1840
describe("resolveCronStorePath", () => {
1941
afterEach(() => {
2042
vi.unstubAllEnvs();
@@ -43,4 +65,30 @@ describe("cron store", () => {
4365
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
4466
await store.cleanup();
4567
});
68+
69+
it("does not create a backup file when saving unchanged content", async () => {
70+
const store = await makeStorePath();
71+
const payload = makeStore("job-1", true);
72+
73+
await saveCronStore(store.storePath, payload);
74+
await saveCronStore(store.storePath, payload);
75+
76+
await expect(fs.stat(`${store.storePath}.bak`)).rejects.toThrow();
77+
await store.cleanup();
78+
});
79+
80+
it("backs up previous content before replacing the store", async () => {
81+
const store = await makeStorePath();
82+
const first = makeStore("job-1", true);
83+
const second = makeStore("job-2", false);
84+
85+
await saveCronStore(store.storePath, first);
86+
await saveCronStore(store.storePath, second);
87+
88+
const currentRaw = await fs.readFile(store.storePath, "utf-8");
89+
const backupRaw = await fs.readFile(`${store.storePath}.bak`, "utf-8");
90+
expect(JSON.parse(currentRaw)).toEqual(second);
91+
expect(JSON.parse(backupRaw)).toEqual(first);
92+
await store.cleanup();
93+
});
4694
});

src/cron/store.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,26 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
5050
export async function saveCronStore(storePath: string, store: CronStoreFile) {
5151
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
5252
const { randomBytes } = await import("node:crypto");
53-
const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
5453
const json = JSON.stringify(store, null, 2);
55-
await fs.promises.writeFile(tmp, json, "utf-8");
56-
await fs.promises.rename(tmp, storePath);
54+
let previous: string | null = null;
5755
try {
58-
await fs.promises.copyFile(storePath, `${storePath}.bak`);
59-
} catch {
60-
// best-effort
56+
previous = await fs.promises.readFile(storePath, "utf-8");
57+
} catch (err) {
58+
if ((err as { code?: unknown }).code !== "ENOENT") {
59+
throw err;
60+
}
61+
}
62+
if (previous === json) {
63+
return;
6164
}
65+
const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
66+
await fs.promises.writeFile(tmp, json, "utf-8");
67+
if (previous !== null) {
68+
try {
69+
await fs.promises.copyFile(storePath, `${storePath}.bak`);
70+
} catch {
71+
// best-effort
72+
}
73+
}
74+
await fs.promises.rename(tmp, storePath);
6275
}

0 commit comments

Comments
 (0)