From 955f77bb0b82ca4f9740376e5ff84990310cc3d7 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 4 Apr 2026 14:51:04 +0200 Subject: [PATCH] fix: serialize mirror copy operations per destination repository --- .../__tests__/backups.execution.test.ts | 56 +++++++++++++++++++ .../backups/helpers/backup-maintenance.ts | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index feb444ee..2aafe12c 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -525,4 +525,60 @@ describe("mirror operations", () => { // assert expect(resticForgetMock).not.toHaveBeenCalled(); }); + + test("should serialize mirror copies for schedules that share the same mirror repository", async () => { + const { resticCopyMock } = setup(); + const sourceRepository = await createTestRepository(); + const mirrorRepository = await createTestRepository(); + const firstVolume = await createTestVolume(); + const secondVolume = await createTestVolume(); + const firstSchedule = await createTestBackupSchedule({ + volumeId: firstVolume.id, + repositoryId: sourceRepository.id, + }); + const secondSchedule = await createTestBackupSchedule({ + volumeId: secondVolume.id, + repositoryId: sourceRepository.id, + }); + + await createTestBackupScheduleMirror(firstSchedule.id, mirrorRepository.id); + await createTestBackupScheduleMirror(secondSchedule.id, mirrorRepository.id); + + let releaseFirstCopy = () => {}; + let resolveFirstCopyStarted = () => {}; + const firstCopyStarted = new Promise((resolve) => { + resolveFirstCopyStarted = resolve; + }); + + resticCopyMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstCopyStarted(); + releaseFirstCopy = () => resolve({ success: true, output: "" }); + }), + ); + resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" })); + + const firstCopyPromise = backupsExecutionService.copyToMirrors(firstSchedule.id, sourceRepository, null); + await firstCopyStarted; + + const secondCopyPromise = backupsExecutionService.copyToMirrors(secondSchedule.id, sourceRepository, null); + + try { + const secondCopyState = await Promise.race<"resolved" | "timeout">([ + secondCopyPromise.then(() => "resolved"), + new Promise((resolve) => { + setTimeout(() => resolve("timeout"), 50); + }), + ]); + + expect(secondCopyState).toBe("timeout"); + expect(resticCopyMock).toHaveBeenCalledTimes(1); + } finally { + releaseFirstCopy(); + await Promise.all([firstCopyPromise, secondCopyPromise]); + } + + expect(resticCopyMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/app/server/modules/backups/helpers/backup-maintenance.ts b/app/server/modules/backups/helpers/backup-maintenance.ts index 77f6c797..6ab4d7a5 100644 --- a/app/server/modules/backups/helpers/backup-maintenance.ts +++ b/app/server/modules/backups/helpers/backup-maintenance.ts @@ -93,7 +93,7 @@ async function copyToSingleMirror( }); const releaseSource = await repoMutex.acquireShared(sourceRepository.id, `mirror_source:${scheduleId}`); - const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`); + const releaseMirror = await repoMutex.acquireExclusive(mirror.repository.id, `mirror:${scheduleId}`); try { await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });