Skip to content

Commit 39c4cf6

Browse files
fix(backup): add staleness guard to prevent deleting active temp archives
Only remove .tmp files whose mtime is older than a 30 s staleness threshold. This protects against concurrently running backup processes that target the same output path — their actively-written temp archive will be preserved. Also reports how many active temp archives were skipped in the cleanup log message for observability.
1 parent f254646 commit 39c4cf6

2 files changed

Lines changed: 93 additions & 5 deletions

File tree

src/infra/backup-create.test.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,10 @@ describe("cleanupStaleTempArchives", () => {
311311
await fs.writeFile(finalArchive, "final archive", "utf8");
312312

313313
const log = vi.fn();
314-
await cleanupStaleTempArchives({ outputPath, log });
314+
// Use a future nowMs so all newly-written files appear older than the
315+
// default 30 s staleness threshold.
316+
const futureNow = Date.now() + 60_000;
317+
await cleanupStaleTempArchives({ outputPath, log, nowMs: futureNow });
315318

316319
// Stale files removed
317320
await expect(fs.access(stale1)).rejects.toMatchObject({ code: "ENOENT" });
@@ -338,7 +341,7 @@ describe("cleanupStaleTempArchives", () => {
338341
await fs.writeFile(outputPath, "final archive", "utf8");
339342

340343
const log = vi.fn();
341-
await cleanupStaleTempArchives({ outputPath, log });
344+
await cleanupStaleTempArchives({ outputPath, log, nowMs: Date.now() + 60_000 });
342345

343346
// Final archive preserved
344347
await expect(fs.access(outputPath)).resolves.toBeUndefined();
@@ -375,7 +378,8 @@ describe("cleanupStaleTempArchives", () => {
375378
await fs.writeFile(myStale, "my stale", "utf8");
376379

377380
const log = vi.fn();
378-
await cleanupStaleTempArchives({ outputPath, log });
381+
const futureNow = Date.now() + 60_000;
382+
await cleanupStaleTempArchives({ outputPath, log, nowMs: futureNow });
379383

380384
// Only our .tmp removed
381385
await expect(fs.access(myStale)).rejects.toMatchObject({ code: "ENOENT" });
@@ -398,14 +402,80 @@ describe("cleanupStaleTempArchives", () => {
398402
await fs.writeFile(stale1, largeContent);
399403

400404
const log = vi.fn();
401-
await cleanupStaleTempArchives({ outputPath, log });
405+
const futureNow = Date.now() + 60_000;
406+
await cleanupStaleTempArchives({ outputPath, log, nowMs: futureNow });
402407

403408
expect(log).toHaveBeenCalledTimes(1);
404409
expect(log.mock.calls[0][0]).toContain("(2.0MB)");
405410
} finally {
406411
await fs.rm(dir, { recursive: true, force: true });
407412
}
408413
});
414+
415+
it("skips temp archives whose mtime is within the staleness threshold", async () => {
416+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cleanup-"));
417+
try {
418+
const outputPath = path.join(dir, "backup.tar.gz");
419+
// This file was just created — it simulates an active backup writer
420+
const activeTmp = `${outputPath}.a1b2c3d4-e5f6-7890-abcd-ef1234567890.tmp`;
421+
// This file is much older — it simulates a stale leftover
422+
const staleTmp = `${outputPath}.12345678-90ab-cdef-1234-567890abcdef.tmp`;
423+
424+
await fs.writeFile(activeTmp, "active backup", "utf8");
425+
await fs.writeFile(staleTmp, "stale leftover", "utf8");
426+
427+
// Set the stale file's mtime far in the past
428+
const staleMtime = new Date(Date.now() - 120_000); // 2 minutes ago
429+
await fs.utimes(staleTmp, staleMtime, staleMtime);
430+
431+
const log = vi.fn();
432+
// Use current time as nowMs — the active file (mtime ~now) should be
433+
// within the default 30 s threshold and skipped; the stale file
434+
// (mtime 2 min ago) should be removed.
435+
await cleanupStaleTempArchives({ outputPath, log });
436+
437+
// Active file preserved
438+
await expect(fs.access(activeTmp)).resolves.toBeUndefined();
439+
// Stale file removed
440+
await expect(fs.access(staleTmp)).rejects.toMatchObject({ code: "ENOENT" });
441+
442+
expect(log).toHaveBeenCalledTimes(1);
443+
expect(log.mock.calls[0][0]).toContain("Backup cleaned up 1 stale temp archive");
444+
expect(log.mock.calls[0][0]).toContain("1 active temp archive skipped");
445+
} finally {
446+
await fs.rm(dir, { recursive: true, force: true });
447+
}
448+
});
449+
450+
it("removes all matching temp archives when none are recent", async () => {
451+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cleanup-"));
452+
try {
453+
const outputPath = path.join(dir, "backup.tar.gz");
454+
const tmp1 = `${outputPath}.a1b2c3d4-e5f6-7890-abcd-ef1234567890.tmp`;
455+
const tmp2 = `${outputPath}.12345678-90ab-cdef-1234-567890abcdef.tmp`;
456+
457+
await fs.writeFile(tmp1, "stale 1", "utf8");
458+
await fs.writeFile(tmp2, "stale 2", "utf8");
459+
460+
// Make both files very old
461+
const oldTime = new Date(Date.now() - 300_000); // 5 minutes ago
462+
await fs.utimes(tmp1, oldTime, oldTime);
463+
await fs.utimes(tmp2, oldTime, oldTime);
464+
465+
const log = vi.fn();
466+
await cleanupStaleTempArchives({ outputPath, log });
467+
468+
await expect(fs.access(tmp1)).rejects.toMatchObject({ code: "ENOENT" });
469+
await expect(fs.access(tmp2)).rejects.toMatchObject({ code: "ENOENT" });
470+
471+
expect(log).toHaveBeenCalledTimes(1);
472+
expect(log.mock.calls[0][0]).toContain("Backup cleaned up 2 stale temp archives");
473+
// No active-skipped note when nothing was skipped
474+
expect(log.mock.calls[0][0]).not.toContain("active temp archive");
475+
} finally {
476+
await fs.rm(dir, { recursive: true, force: true });
477+
}
478+
});
409479
});
410480

411481
describe("buildExtensionsNodeModulesFilter", () => {

src/infra/backup-create.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,16 @@ function buildTempArchiveGlobPattern(outputPath: string): RegExp {
252252
return new RegExp(`^${escaped}\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.tmp$`);
253253
}
254254

255+
const BACKUP_TEMP_STALE_AGE_MS = 30_000;
256+
255257
async function cleanupStaleTempArchives(params: {
256258
outputPath: string;
259+
staleAgeMs?: number;
260+
nowMs?: number;
257261
log?: (message: string) => void;
258262
}): Promise<void> {
263+
const now = params.nowMs ?? Date.now();
264+
const staleAge = params.staleAgeMs ?? BACKUP_TEMP_STALE_AGE_MS;
259265
const dir = path.dirname(params.outputPath);
260266
const pattern = buildTempArchiveGlobPattern(params.outputPath);
261267
let entries: import("node:fs").Dirent[];
@@ -276,6 +282,7 @@ async function cleanupStaleTempArchives(params: {
276282
}
277283
let cleaned = 0;
278284
let cleanedBytes = 0;
285+
let skippedActive = 0;
279286
for (const entry of entries) {
280287
if (!entry.isFile() || !pattern.test(entry.name)) {
281288
continue;
@@ -287,6 +294,13 @@ async function cleanupStaleTempArchives(params: {
287294
if (!stat.isFile()) {
288295
continue;
289296
}
297+
// Only remove temp archives whose mtime is older than the staleness
298+
// threshold. This protects against concurrently running backup processes
299+
// that are actively writing to a temp archive for the same output path.
300+
if (now - stat.mtimeMs < staleAge) {
301+
skippedActive += 1;
302+
continue;
303+
}
290304
const size = stat.size;
291305
await fs.rm(fullPath);
292306
cleaned += 1;
@@ -307,10 +321,14 @@ async function cleanupStaleTempArchives(params: {
307321
: cleanedBytes >= 1_048_576
308322
? `${(cleanedBytes / 1_048_576).toFixed(1)}MB`
309323
: `${cleanedBytes} bytes`;
324+
const skippedNote =
325+
skippedActive > 0
326+
? ` (${skippedActive} active temp archive${skippedActive === 1 ? "" : "s"} skipped)`
327+
: "";
310328
params.log?.(
311329
`Backup cleaned up ${cleaned} stale temp archive${
312330
cleaned === 1 ? "" : "s"
313-
} (${sizeLabel}) from a previous interrupted run.`,
331+
} (${sizeLabel}) from a previous interrupted run.${skippedNote}`,
314332
);
315333
}
316334
}

0 commit comments

Comments
 (0)