From 1b5ae2b3ccb2e6e17cdad8c56ad97d8fad458b4d Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 18 Mar 2026 19:13:59 +0100 Subject: [PATCH] fix: backup progress use simple polling & cache #593 --- .../components/backup-progress-card.tsx | 37 ++++------------ .../modules/backups/routes/backup-details.tsx | 1 + .../__tests__/backups.controller.test.ts | 42 +++++++++++++++++++ .../modules/backups/backups.execution.ts | 15 +++---- app/server/utils/cache.ts | 3 ++ 5 files changed, 59 insertions(+), 39 deletions(-) diff --git a/app/client/modules/backups/components/backup-progress-card.tsx b/app/client/modules/backups/components/backup-progress-card.tsx index 8381d26c..0e6f4140 100644 --- a/app/client/modules/backups/components/backup-progress-card.tsx +++ b/app/client/modules/backups/components/backup-progress-card.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { ByteSize } from "~/client/components/bytes-size"; import { Card } from "~/client/components/ui/card"; import { Progress } from "~/client/components/ui/progress"; -import { useServerEvents } from "~/client/hooks/use-server-events"; +import { getBackupProgressOptions } from "~/client/api-client/@tanstack/react-query.gen"; import type { GetBackupProgressResponse } from "~/client/api-client/types.gen"; import { formatDuration } from "~/utils/utils"; import { formatBytes } from "~/utils/format-bytes"; @@ -13,34 +13,11 @@ type Props = { }; export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => { - const { addEventListener } = useServerEvents(); - const [progress, setProgress] = useState(initialProgress ?? null); - - useEffect(() => { - const abortController = new AbortController(); - - addEventListener( - "backup:progress", - (progressData) => { - if (progressData.scheduleId === scheduleShortId) { - setProgress(progressData); - } - }, - { signal: abortController.signal }, - ); - - addEventListener( - "backup:completed", - (completedData) => { - if (completedData.scheduleId === scheduleShortId) { - setProgress(null); - } - }, - { signal: abortController.signal }, - ); - - return () => abortController.abort(); - }, [addEventListener, scheduleShortId]); + const { data: progress } = useQuery({ + ...getBackupProgressOptions({ path: { shortId: scheduleShortId } }), + initialData: initialProgress, + refetchInterval: 1000, + }); const percentDone = progress ? Math.round(progress.percent_done * 100) : 0; const currentFile = progress?.current_files?.[0] || ""; diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 16ab75b9..7a521f90 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -76,6 +76,7 @@ export function ScheduleDetailsPage(props: Props) { const { data: schedule } = useSuspenseQuery({ ...getBackupScheduleOptions({ path: { shortId: scheduleId } }), + refetchInterval: 1000, }); const { diff --git a/app/server/modules/backups/__tests__/backups.controller.test.ts b/app/server/modules/backups/__tests__/backups.controller.test.ts index 17a3755c..655945e1 100644 --- a/app/server/modules/backups/__tests__/backups.controller.test.ts +++ b/app/server/modules/backups/__tests__/backups.controller.test.ts @@ -4,6 +4,7 @@ import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestVolume } from "~/test/helpers/volume"; import { createTestRepository } from "~/test/helpers/repository"; import { createTestBackupSchedule } from "~/test/helpers/backup"; +import { cache, cacheKeys } from "~/server/utils/cache"; const app = createApp(); @@ -81,6 +82,47 @@ describe("backups security", () => { }); describe("input validation", () => { + test("should return cached progress for a running backup", async () => { + const { headers, organizationId } = await createTestSession(); + const volume = await createTestVolume({ organizationId }); + const repository = await createTestRepository({ organizationId }); + const schedule = await createTestBackupSchedule({ + organizationId, + volumeId: volume.id, + repositoryId: repository.id, + }); + + cache.set(cacheKeys.backup.progress(schedule.id), { + scheduleId: schedule.shortId, + volumeName: volume.name, + repositoryName: repository.name, + message_type: "status", + seconds_elapsed: 12, + seconds_remaining: 24, + percent_done: 0.5, + total_files: 100, + files_done: 50, + total_bytes: 1024, + bytes_done: 512, + current_files: ["/mnt/data/file.txt"], + }); + + const res = await app.request(`/api/v1/backups/${schedule.shortId}/progress`, { + headers, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ + scheduleId: schedule.shortId, + percent_done: 0.5, + files_done: 50, + current_files: ["/mnt/data/file.txt"], + }); + + cache.del(cacheKeys.backup.progress(schedule.id)); + }); + test("should return a schedule when queried by short id", async () => { const { headers, organizationId } = await createTestSession(); const volume = await createTestVolume({ organizationId }); diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index f446514a..91a6cdca 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -16,10 +16,9 @@ import type { ResticBackupOutputDto } from "@zerobyte/core/restic"; import type { BackupProgressEventDto } from "~/schemas/events-dto"; const runningBackups = new Map(); -const progressCache = new Map(); export const getBackupProgress = (scheduleId: number): BackupProgressEventDto | undefined => - progressCache.get(scheduleId); + cache.get(cacheKeys.backup.progress(scheduleId)); interface BackupContext { schedule: BackupSchedule; @@ -123,18 +122,16 @@ const runBackupOperation = async (ctx: BackupContext, signal: AbortSignal) => { compressionMode: ctx.repository.compressionMode ?? "auto", organizationId: ctx.organizationId, onProgress: (progress) => { - progressCache.set(ctx.schedule.id, { + const progressEvent = { scheduleId: ctx.schedule.shortId, volumeName: ctx.volume.name, repositoryName: ctx.repository.name, ...progress, - }); + }; + cache.set(cacheKeys.backup.progress(ctx.schedule.id), progressEvent, 60 * 60); serverEvents.emit("backup:progress", { organizationId: ctx.organizationId, - scheduleId: ctx.schedule.shortId, - volumeName: ctx.volume.name, - repositoryName: ctx.repository.name, - ...progress, + ...progressEvent, }); }, }); @@ -297,7 +294,7 @@ const executeBackup = async (scheduleId: number, manual = false): Promise await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx); } finally { runningBackups.delete(scheduleId); - progressCache.delete(scheduleId); + cache.del(cacheKeys.backup.progress(scheduleId)); } }; diff --git a/app/server/utils/cache.ts b/app/server/utils/cache.ts index a5bda652..c628878e 100644 --- a/app/server/utils/cache.ts +++ b/app/server/utils/cache.ts @@ -106,6 +106,9 @@ export const createCache = (options: CacheOptions = {}) => { }; export const cacheKeys = { + backup: { + progress: (scheduleId: number) => `backup:${scheduleId}:progress`, + }, repository: { all: (repositoryId: string) => `repo:${repositoryId}:`, snapshots: (repositoryId: string, backupId = "all") => `repo:${repositoryId}:snapshots:${backupId}`,