From ed046f284d0bd6940555b3272acfd3eae0b93add Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 12 Apr 2026 10:39:14 +0200 Subject: [PATCH] fix(backup): reset failure count on cancel --- .../__tests__/backups.execution.test.ts | 53 +++++++++++++++++++ .../backups/helpers/backup-lifecycle.ts | 6 +-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index 2aafe12c..ccb0df7f 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -233,6 +233,59 @@ describe("stop backup", () => { expect(resticBackupMock).not.toHaveBeenCalled(); }); + test("should clear failureRetryCount when a scheduled retry is cancelled", async () => { + const { resticBackupMock } = setup(); + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + cronExpression: "0 0 1 1 *", + maxRetries: 3, + retryDelay: 60 * 1000, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 1, summary: generateBackupOutput(), error: "retry me" }), + ); + + await backupsExecutionService.executeBackup(schedule.id); + + const failedSchedule = await backupsService.getScheduleById(schedule.id); + expect(failedSchedule.failureRetryCount).toBe(1); + + resticBackupMock.mockImplementationOnce(({ signal }: SafeSpawnParams) => { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve({ exitCode: 1, summary: "", error: "" }); + return; + } + + signal?.addEventListener( + "abort", + () => { + resolve({ exitCode: 1, summary: "", error: "" }); + }, + { once: true }, + ); + }); + }); + + const executePromise = backupsExecutionService.executeBackup(schedule.id); + + await waitForExpect(async () => { + const retryingSchedule = await backupsService.getScheduleById(schedule.id); + expect(retryingSchedule.lastBackupStatus).toBe("in_progress"); + }); + + await backupsExecutionService.stopBackup(schedule.id); + await executePromise; + + const cancelledSchedule = await backupsService.getScheduleById(schedule.id); + expect(cancelledSchedule.lastBackupStatus).toBe("warning"); + expect(cancelledSchedule.failureRetryCount).toBe(0); + }); + test("should throw ConflictError when trying to stop non-running backup", async () => { // arrange setup(); diff --git a/app/server/modules/backups/helpers/backup-lifecycle.ts b/app/server/modules/backups/helpers/backup-lifecycle.ts index c859a348..bc9c74d6 100644 --- a/app/server/modules/backups/helpers/backup-lifecycle.ts +++ b/app/server/modules/backups/helpers/backup-lifecycle.ts @@ -235,16 +235,13 @@ export async function handleBackupFailure( if (!manual && shouldRetry && nextRetryBackupAt < nextScheduledBackupAt) { await scheduleQueries.updateStatus(scheduleId, organizationId, { - lastBackupAt: Date.now(), - lastBackupStatus: "error", - lastBackupError: errorDetails, nextBackupAt: nextRetryBackupAt, failureRetryCount: currentRetryCount + 1, }); const delayMinutes = Math.round((schedule.retryDelay / (60 * 1000)) * 10) / 10; - logger.warn( + logger.error( `Backup ${schedule.name} failed. Scheduling retry ${currentRetryCount + 1}/${maxRetries} for ${delayMinutes} minutes from now: ${errorMessage}`, ); @@ -322,5 +319,6 @@ export async function handleBackupCancellation( lastBackupAt: shouldSetLastBackupAt ? Date.now() : undefined, lastBackupStatus: "warning", lastBackupError: message ?? "Backup was stopped by the user", + failureRetryCount: 0, }); }