mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 22:30:22 +00:00
fix cron store backup churn (#19484)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user