mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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/");
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user