Skip to content

Commit e0970cf

Browse files
fix: backup skips volatile cache paths (#98879)
* fix: skip volatile backup cache paths * fix(backup): tolerate disappearing volatile files --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 75240f4 commit e0970cf

2 files changed

Lines changed: 89 additions & 2 deletions

File tree

src/infra/backup-create.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Covers backup archive creation and verification filtering.
2+
import { rmSync } from "node:fs";
23
import fs from "node:fs/promises";
34
import os from "node:os";
45
import path from "node:path";
@@ -20,6 +21,7 @@ import {
2021
formatBackupCreateSummary,
2122
type BackupCreateResult,
2223
} from "./backup-create.js";
24+
import { isVolatileBackupPath } from "./backup-volatile-filter.js";
2325
import { requireNodeSqlite } from "./node-sqlite.js";
2426

2527
function makeResult(overrides: Partial<BackupCreateResult> = {}): BackupCreateResult {
@@ -291,6 +293,52 @@ describe("writeTarArchiveWithRetry", () => {
291293
});
292294
});
293295

296+
describe("createBackupVolatileStatCache", () => {
297+
it("lets tar filter a volatile file that disappears before lstat", async () => {
298+
await withOpenClawTestState(
299+
{
300+
layout: "state-only",
301+
prefix: "openclaw-backup-volatile-stat-cache-",
302+
scenario: "minimal",
303+
},
304+
async (state) => {
305+
const volatilePath = await state.writeText("logs/gateway.log", "live log\n");
306+
await state.writeText("settings.json", '{"keep":true}\n');
307+
const archivePath = state.path("volatile-stat-cache.tar.gz");
308+
const volatilePlan = { stateDirs: [state.stateDir] };
309+
const statCache = backupCreateInternals.createBackupVolatileStatCache(volatilePlan);
310+
const getCachedStat = statCache.get.bind(statCache);
311+
let removedBeforeStat = false;
312+
313+
statCache.get = (key: string) => {
314+
if (path.resolve(key) === path.resolve(volatilePath)) {
315+
rmSync(volatilePath, { force: true });
316+
removedBeforeStat = true;
317+
}
318+
return getCachedStat(key);
319+
};
320+
321+
await tar.c(
322+
{
323+
file: archivePath,
324+
gzip: true,
325+
portable: true,
326+
preservePaths: true,
327+
statCache,
328+
filter: (entryPath) => !isVolatileBackupPath(entryPath, volatilePlan),
329+
},
330+
[state.stateDir],
331+
);
332+
333+
const entries = await listArchiveEntries(archivePath);
334+
expect(removedBeforeStat).toBe(true);
335+
expect(entries.some((entry) => entry.endsWith("/settings.json"))).toBe(true);
336+
expect(entries.some((entry) => entry.endsWith("/logs/gateway.log"))).toBe(false);
337+
},
338+
);
339+
});
340+
});
341+
294342
describe("buildExtensionsNodeModulesFilter", () => {
295343
it("excludes dependency trees only under state extensions", () => {
296344
const filter = buildExtensionsNodeModulesFilter("/state/");

src/infra/backup-create.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Creates backup archives while filtering volatile runtime state.
22
import { randomUUID } from "node:crypto";
3-
import { constants as fsConstants } from "node:fs";
3+
import { constants as fsConstants, type Stats } from "node:fs";
44
import fs from "node:fs/promises";
55
import os from "node:os";
66
import path from "node:path";
@@ -43,6 +43,40 @@ class BackupLinkCache extends Map<BackupLinkCacheKey, string> {
4343
}
4444
}
4545

46+
type VolatileFilterPlan = Parameters<typeof isVolatileBackupPath>[1];
47+
48+
const VOLATILE_BACKUP_SYNTHETIC_STAT = {
49+
isBlockDevice: () => false,
50+
isCharacterDevice: () => false,
51+
isDirectory: () => false,
52+
isFIFO: () => false,
53+
isFile: () => false,
54+
isSocket: () => false,
55+
isSymbolicLink: () => false,
56+
} as unknown as Stats;
57+
58+
class BackupVolatileStatCache extends Map<string, Stats> {
59+
constructor(private readonly volatilePlan: VolatileFilterPlan) {
60+
super();
61+
}
62+
63+
override get(key: string): Stats | undefined {
64+
const cached = super.get(key);
65+
if (cached) {
66+
return cached;
67+
}
68+
// node-tar checks this cache before lstat and applies the filter to a hit.
69+
// A synthetic hit lets known volatile paths disappear without aborting the archive.
70+
return isVolatileBackupPath(key, this.volatilePlan)
71+
? VOLATILE_BACKUP_SYNTHETIC_STAT
72+
: undefined;
73+
}
74+
}
75+
76+
function createBackupVolatileStatCache(volatilePlan: VolatileFilterPlan): Map<string, Stats> {
77+
return new BackupVolatileStatCache(volatilePlan);
78+
}
79+
4680
export type BackupCreateOptions = {
4781
output?: string;
4882
dryRun?: boolean;
@@ -192,7 +226,11 @@ async function writeTarArchiveWithRetry(params: {
192226
throw new Error(`Backup archive write failed: ${final.message}${suffix}`, { cause: final });
193227
}
194228

195-
export const testApi = { writeTarArchiveWithRetry, isTarEofRaceError };
229+
export const testApi = {
230+
writeTarArchiveWithRetry,
231+
isTarEofRaceError,
232+
createBackupVolatileStatCache,
233+
};
196234
export { testApi as __test };
197235

198236
async function resolveOutputPath(params: {
@@ -844,6 +882,7 @@ export async function createBackupArchive(
844882
portable: true,
845883
preservePaths: true,
846884
linkCache: new BackupLinkCache(),
885+
statCache: createBackupVolatileStatCache(volatilePlan),
847886
filter: tarFilter,
848887
onWriteEntry: (entry) => {
849888
entry.path = remapArchiveEntryPath({

0 commit comments

Comments
 (0)