fix: backup progress use simple polling & cache

#593
This commit is contained in:
Nicolas Meienberger 2026-03-18 19:13:59 +01:00 committed by Nico
parent 429b69ec92
commit 1b5ae2b3cc
5 changed files with 59 additions and 39 deletions

View file

@ -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<GetBackupProgressResponse>(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] || "";

View file

@ -76,6 +76,7 @@ export function ScheduleDetailsPage(props: Props) {
const { data: schedule } = useSuspenseQuery({
...getBackupScheduleOptions({ path: { shortId: scheduleId } }),
refetchInterval: 1000,
});
const {

View file

@ -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 });

View file

@ -16,10 +16,9 @@ import type { ResticBackupOutputDto } from "@zerobyte/core/restic";
import type { BackupProgressEventDto } from "~/schemas/events-dto";
const runningBackups = new Map<number, AbortController>();
const progressCache = new Map<number, BackupProgressEventDto>();
export const getBackupProgress = (scheduleId: number): BackupProgressEventDto | undefined =>
progressCache.get(scheduleId);
cache.get<BackupProgressEventDto>(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<void>
await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx);
} finally {
runningBackups.delete(scheduleId);
progressCache.delete(scheduleId);
cache.del(cacheKeys.backup.progress(scheduleId));
}
};

View file

@ -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}`,