Skip to content

Commit 686b778

Browse files
fix(backup): isolate retry temp archives (#101449)
* fix(backup): isolate retry temp archives Closes #101382 * fix(backup): remove unused retry path initializer --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 6799b5b commit 686b778

3 files changed

Lines changed: 151 additions & 11 deletions

File tree

src/commands/backup.atomic.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,56 @@ describe("backupCreateCommand atomic archive write", () => {
8686
}
8787
});
8888

89+
it("cleans intermediate retry temp archives after cleanup races", async () => {
90+
const { archiveDir, outputPath, runtime } = await prepareAtomicBackupScenario({
91+
archivePrefix: "openclaw-backup-retry-cleanup-",
92+
});
93+
const realRm = fs.rm.bind(fs);
94+
const rmAttempts = new Map<string, number>();
95+
const attemptFiles: string[] = [];
96+
const rmSpy = vi.spyOn(fs, "rm").mockImplementation((async (
97+
targetPath: Parameters<typeof fs.rm>[0],
98+
options?: Parameters<typeof fs.rm>[1],
99+
) => {
100+
const key = String(targetPath);
101+
const attempt = (rmAttempts.get(key) ?? 0) + 1;
102+
rmAttempts.set(key, attempt);
103+
if ((key === attemptFiles[0] || key === attemptFiles[1]) && attempt === 1) {
104+
throw Object.assign(new Error("resource busy"), { code: "EBUSY" });
105+
}
106+
await realRm(targetPath, options);
107+
}) as typeof fs.rm);
108+
try {
109+
let tarAttempt = 0;
110+
tarCreateMock.mockImplementation(async ({ file }: { file: string }) => {
111+
tarAttempt += 1;
112+
attemptFiles.push(file);
113+
await fs.writeFile(file, `archive-attempt-${tarAttempt}`, "utf8");
114+
if (tarAttempt < 3) {
115+
throw Object.assign(new Error("did not encounter expected EOF"), {
116+
path: path.join(tempHome.home, ".openclaw", "state.txt"),
117+
});
118+
}
119+
});
120+
121+
const result = await backupCreateCommand(runtime, {
122+
output: outputPath,
123+
});
124+
125+
expect(result.archivePath).toBe(outputPath);
126+
expect(attemptFiles).toStrictEqual([
127+
attemptFiles[0],
128+
`${attemptFiles[0]}.retry-2`,
129+
`${attemptFiles[0]}.retry-3`,
130+
]);
131+
expect(rmAttempts.get(attemptFiles[1])).toBeGreaterThanOrEqual(2);
132+
expect((await fs.readdir(archiveDir)).toSorted()).toStrictEqual([path.basename(outputPath)]);
133+
} finally {
134+
rmSpy.mockRestore();
135+
await fs.rm(archiveDir, { recursive: true, force: true });
136+
}
137+
});
138+
89139
it("does not overwrite an archive created after readiness checks complete", async () => {
90140
const { archiveDir, outputPath, runtime } = await prepareAtomicBackupScenario({
91141
archivePrefix: "openclaw-backup-race-",

src/infra/backup-create.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,70 @@ describe("writeTarArchiveWithRetry", () => {
223223
expect(log).toHaveBeenCalledTimes(2);
224224
});
225225

226+
it("uses a fresh temp archive path when cleanup cannot remove a failed attempt", async () => {
227+
const eofErr = Object.assign(new Error("did not encounter expected EOF"), {
228+
path: "/state/sessions/s-abc/transcript.jsonl",
229+
});
230+
const tempArchivePath = "/tmp/backup.tar.gz.tmp";
231+
const runTar = vi
232+
.fn<(attemptTempArchivePath: string) => Promise<void>>()
233+
.mockRejectedValueOnce(eofErr)
234+
.mockResolvedValueOnce(undefined);
235+
const log = vi.fn();
236+
const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
237+
const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async () => {
238+
throw Object.assign(new Error("resource busy"), { code: "EBUSY" });
239+
});
240+
241+
try {
242+
const completedTempArchivePath = await backupCreateInternals.writeTarArchiveWithRetry({
243+
tempArchivePath,
244+
runTar,
245+
log,
246+
sleepMs: sleep,
247+
});
248+
249+
expect(runTar).toHaveBeenNthCalledWith(1, tempArchivePath);
250+
expect(runTar).toHaveBeenNthCalledWith(2, `${tempArchivePath}.retry-2`);
251+
expect(completedTempArchivePath).toBe(`${tempArchivePath}.retry-2`);
252+
expect(rmSpy).toHaveBeenCalledWith(tempArchivePath, { force: true });
253+
expect(log).toHaveBeenCalledWith(
254+
`Backup archiver could not remove temp archive ${tempArchivePath} between retries: EBUSY. Continuing.`,
255+
);
256+
} finally {
257+
rmSpy.mockRestore();
258+
}
259+
});
260+
261+
it("cleans retry temp archive paths when a later attempt fails", async () => {
262+
const eofErr = Object.assign(new Error("did not encounter expected EOF"), {
263+
path: "/state/sessions/s-abc/transcript.jsonl",
264+
});
265+
const tempArchivePath = "/tmp/backup.tar.gz.tmp";
266+
const runTar = vi
267+
.fn<(attemptTempArchivePath: string) => Promise<void>>()
268+
.mockRejectedValueOnce(eofErr)
269+
.mockRejectedValueOnce(new Error("permission denied"));
270+
const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
271+
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
272+
273+
try {
274+
await expect(
275+
backupCreateInternals.writeTarArchiveWithRetry({
276+
tempArchivePath,
277+
runTar,
278+
sleepMs: sleep,
279+
}),
280+
).rejects.toThrow(/permission denied/);
281+
282+
expect(runTar).toHaveBeenNthCalledWith(1, tempArchivePath);
283+
expect(runTar).toHaveBeenNthCalledWith(2, `${tempArchivePath}.retry-2`);
284+
expect(rmSpy).toHaveBeenCalledWith(`${tempArchivePath}.retry-2`, { force: true });
285+
} finally {
286+
rmSpy.mockRestore();
287+
}
288+
});
289+
226290
it("surfaces the offending path and attempt count after exhausting retries", async () => {
227291
const eofErr = Object.assign(new Error("did not encounter expected EOF"), {
228292
path: "/state/logs/gateway.jsonl",

src/infra/backup-create.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -170,30 +170,53 @@ function isTarEofRaceError(err: unknown): boolean {
170170

171171
export type BackupTarRetryLogger = (message: string) => void;
172172

173+
function resolveBackupTarAttemptTempPath(tempArchivePath: string, attempt: number): string {
174+
return attempt === 1 ? tempArchivePath : `${tempArchivePath}.retry-${attempt}`;
175+
}
176+
177+
function resolveBackupTarAttemptTempPaths(tempArchivePath: string): string[] {
178+
return Array.from({ length: BACKUP_TAR_MAX_ATTEMPTS }, (_value, index) =>
179+
resolveBackupTarAttemptTempPath(tempArchivePath, index + 1),
180+
);
181+
}
182+
183+
async function removeBackupTempArchiveBestEffort(tempArchivePath: string): Promise<void> {
184+
await fs.rm(tempArchivePath, { force: true }).catch(() => undefined);
185+
}
186+
173187
async function writeTarArchiveWithRetry(params: {
174188
tempArchivePath: string;
175-
runTar: () => Promise<void>;
189+
runTar: (tempArchivePath: string) => Promise<void>;
176190
log?: BackupTarRetryLogger;
177191
sleepMs?: (ms: number) => Promise<void>;
178-
}): Promise<void> {
192+
}): Promise<string> {
179193
const sleepFn = params.sleepMs ?? sleep;
180194
let lastErr: unknown;
195+
const attemptTempArchivePaths: string[] = [];
181196
for (let attempt = 1; attempt <= BACKUP_TAR_MAX_ATTEMPTS; attempt += 1) {
197+
const attemptTempArchivePath = resolveBackupTarAttemptTempPath(params.tempArchivePath, attempt);
198+
attemptTempArchivePaths.push(attemptTempArchivePath);
182199
try {
183-
await params.runTar();
184-
return;
200+
await params.runTar(attemptTempArchivePath);
201+
for (const staleTempArchivePath of attemptTempArchivePaths.slice(0, -1)) {
202+
await removeBackupTempArchiveBestEffort(staleTempArchivePath);
203+
}
204+
return attemptTempArchivePath;
185205
} catch (err) {
186206
lastErr = err;
187207
if (!isTarEofRaceError(err) || attempt === BACKUP_TAR_MAX_ATTEMPTS) {
208+
for (const staleTempArchivePath of attemptTempArchivePaths) {
209+
await removeBackupTempArchiveBestEffort(staleTempArchivePath);
210+
}
188211
break;
189212
}
190213
try {
191-
await fs.rm(params.tempArchivePath, { force: true });
214+
await fs.rm(attemptTempArchivePath, { force: true });
192215
} catch (cleanupErr) {
193216
const code = (cleanupErr as NodeJS.ErrnoException).code;
194217
if (code && code !== "ENOENT") {
195218
params.log?.(
196-
`Backup archiver could not remove temp archive ${params.tempArchivePath} between retries: ${code}. Continuing.`,
219+
`Backup archiver could not remove temp archive ${attemptTempArchivePath} between retries: ${code}. Continuing.`,
197220
);
198221
}
199222
}
@@ -778,6 +801,7 @@ export async function createBackupArchive(
778801
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-backup-"));
779802
const manifestPath = path.join(tempDir, "manifest.json");
780803
const tempArchivePath = buildTempArchivePath(outputPath);
804+
const tempArchiveCleanupPaths = resolveBackupTarAttemptTempPaths(tempArchivePath);
781805
const stateAsset = result.assets.find((asset) => asset.kind === "state");
782806
try {
783807
const stateSqliteBackup = stateAsset
@@ -855,18 +879,18 @@ export async function createBackupArchive(
855879
}
856880
return true;
857881
};
858-
await writeTarArchiveWithRetry({
882+
const completedTempArchivePath = await writeTarArchiveWithRetry({
859883
tempArchivePath,
860884
log: opts.log,
861-
runTar: async () => {
885+
runTar: async (attemptTempArchivePath) => {
862886
// tar.c re-walks the tree (and thus re-invokes tarFilter) on every
863887
// attempt, so reset the closure counter here or retries would report
864888
// cumulative skip counts across attempts instead of the final one.
865889
skippedVolatileCount = 0;
866890
unexpectedSqliteSourcePaths.length = 0;
867891
await tar.c(
868892
{
869-
file: tempArchivePath,
893+
file: attemptTempArchivePath,
870894
gzip: true,
871895
portable: true,
872896
preservePaths: true,
@@ -904,9 +928,11 @@ export async function createBackupArchive(
904928
} (live sessions, cron logs, queues, sockets, pid/tmp).`,
905929
);
906930
}
907-
await publishTempArchive({ tempArchivePath, outputPath });
931+
await publishTempArchive({ tempArchivePath: completedTempArchivePath, outputPath });
908932
} finally {
909-
await fs.rm(tempArchivePath, { force: true }).catch(() => undefined);
933+
for (const cleanupPath of tempArchiveCleanupPaths) {
934+
await removeBackupTempArchiveBestEffort(cleanupPath);
935+
}
910936
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
911937
}
912938

0 commit comments

Comments
 (0)