fix: backup skips volatile cache paths (#98879)

* fix: skip volatile backup cache paths

* fix(backup): tolerate disappearing volatile files

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Wynne668
2026-07-01 23:05:37 -07:00
committed by GitHub
co-authored by Vincent Koc
parent 75240f4385
commit e0970cfec5
2 changed files with 89 additions and 2 deletions
+48
View File
@@ -1,4 +1,5 @@
// Covers backup archive creation and verification filtering.
import { rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -20,6 +21,7 @@ import {
formatBackupCreateSummary,
type BackupCreateResult,
} from "./backup-create.js";
import { isVolatileBackupPath } from "./backup-volatile-filter.js";
import { requireNodeSqlite } from "./node-sqlite.js";
function makeResult(overrides: Partial<BackupCreateResult> = {}): BackupCreateResult {
@@ -291,6 +293,52 @@ describe("writeTarArchiveWithRetry", () => {
});
});
describe("createBackupVolatileStatCache", () => {
it("lets tar filter a volatile file that disappears before lstat", async () => {
await withOpenClawTestState(
{
layout: "state-only",
prefix: "openclaw-backup-volatile-stat-cache-",
scenario: "minimal",
},
async (state) => {
const volatilePath = await state.writeText("logs/gateway.log", "live log\n");
await state.writeText("settings.json", '{"keep":true}\n');
const archivePath = state.path("volatile-stat-cache.tar.gz");
const volatilePlan = { stateDirs: [state.stateDir] };
const statCache = backupCreateInternals.createBackupVolatileStatCache(volatilePlan);
const getCachedStat = statCache.get.bind(statCache);
let removedBeforeStat = false;
statCache.get = (key: string) => {
if (path.resolve(key) === path.resolve(volatilePath)) {
rmSync(volatilePath, { force: true });
removedBeforeStat = true;
}
return getCachedStat(key);
};
await tar.c(
{
file: archivePath,
gzip: true,
portable: true,
preservePaths: true,
statCache,
filter: (entryPath) => !isVolatileBackupPath(entryPath, volatilePlan),
},
[state.stateDir],
);
const entries = await listArchiveEntries(archivePath);
expect(removedBeforeStat).toBe(true);
expect(entries.some((entry) => entry.endsWith("/settings.json"))).toBe(true);
expect(entries.some((entry) => entry.endsWith("/logs/gateway.log"))).toBe(false);
},
);
});
});
describe("buildExtensionsNodeModulesFilter", () => {
it("excludes dependency trees only under state extensions", () => {
const filter = buildExtensionsNodeModulesFilter("/state/");
+41 -2
View File
@@ -1,6 +1,6 @@
// Creates backup archives while filtering volatile runtime state.
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { constants as fsConstants, type Stats } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -43,6 +43,40 @@ class BackupLinkCache extends Map<BackupLinkCacheKey, string> {
}
}
type VolatileFilterPlan = Parameters<typeof isVolatileBackupPath>[1];
const VOLATILE_BACKUP_SYNTHETIC_STAT = {
isBlockDevice: () => false,
isCharacterDevice: () => false,
isDirectory: () => false,
isFIFO: () => false,
isFile: () => false,
isSocket: () => false,
isSymbolicLink: () => false,
} as unknown as Stats;
class BackupVolatileStatCache extends Map<string, Stats> {
constructor(private readonly volatilePlan: VolatileFilterPlan) {
super();
}
override get(key: string): Stats | undefined {
const cached = super.get(key);
if (cached) {
return cached;
}
// node-tar checks this cache before lstat and applies the filter to a hit.
// A synthetic hit lets known volatile paths disappear without aborting the archive.
return isVolatileBackupPath(key, this.volatilePlan)
? VOLATILE_BACKUP_SYNTHETIC_STAT
: undefined;
}
}
function createBackupVolatileStatCache(volatilePlan: VolatileFilterPlan): Map<string, Stats> {
return new BackupVolatileStatCache(volatilePlan);
}
export type BackupCreateOptions = {
output?: string;
dryRun?: boolean;
@@ -192,7 +226,11 @@ async function writeTarArchiveWithRetry(params: {
throw new Error(`Backup archive write failed: ${final.message}${suffix}`, { cause: final });
}
export const testApi = { writeTarArchiveWithRetry, isTarEofRaceError };
export const testApi = {
writeTarArchiveWithRetry,
isTarEofRaceError,
createBackupVolatileStatCache,
};
export { testApi as __test };
async function resolveOutputPath(params: {
@@ -844,6 +882,7 @@ export async function createBackupArchive(
portable: true,
preservePaths: true,
linkCache: new BackupLinkCache(),
statCache: createBackupVolatileStatCache(volatilePlan),
filter: tarFilter,
onWriteEntry: (entry) => {
entry.path = remapArchiveEntryPath({