mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 16:43:43 +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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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() {
|
async function makeStorePath() {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-store-"));
|
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", () => {
|
describe("resolveCronStorePath", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -43,4 +65,30 @@ describe("cron store", () => {
|
|||||||
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
|
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
|
||||||
await store.cleanup();
|
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) {
|
export async function saveCronStore(storePath: string, store: CronStoreFile) {
|
||||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
const { randomBytes } = await import("node:crypto");
|
const { randomBytes } = await import("node:crypto");
|
||||||
const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
||||||
const json = JSON.stringify(store, null, 2);
|
const json = JSON.stringify(store, null, 2);
|
||||||
await fs.promises.writeFile(tmp, json, "utf-8");
|
let previous: string | null = null;
|
||||||
await fs.promises.rename(tmp, storePath);
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.copyFile(storePath, `${storePath}.bak`);
|
previous = await fs.promises.readFile(storePath, "utf-8");
|
||||||
} catch {
|
} catch (err) {
|
||||||
// best-effort
|
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