diff --git a/app/client/components/file-browsers/snapshot-tree-browser.tsx b/app/client/components/file-browsers/snapshot-tree-browser.tsx index 6491e45f..cf16b3d8 100644 --- a/app/client/components/file-browsers/snapshot-tree-browser.tsx +++ b/app/client/components/file-browsers/snapshot-tree-browser.tsx @@ -5,7 +5,6 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { parseError } from "~/client/lib/errors"; import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils"; -import { logger } from "~/client/lib/logger"; function createPathPrefixFns(basePath: string) { return { @@ -84,16 +83,6 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => { }), ); }, - prefetchFolder: (displayPath) => { - void queryClient - .prefetchQuery( - listSnapshotFilesOptions({ - path: { shortId: repositoryId, snapshotId }, - query: { path: displayPath, offset: 0, limit: pageSize }, - }), - ) - .catch((e) => logger.error(e)); - }, pathTransform: displayPathFns, }); diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 20086559..75173a3a 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -63,9 +63,7 @@ export function ScheduleDetailsPage(props: Props) { const queryClient = useQueryClient(); const navigate = useNavigate(); const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" }); - const [selectedSnapshotId, setSelectedSnapshotId] = useState( - initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id, - ); + const [selectedSnapshotId, setSelectedSnapshotId] = useState(initialSnapshotId); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [snapshotToDelete, setSnapshotToDelete] = useState(null); diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index b5c6ccc4..2395529c 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -208,6 +208,95 @@ describe("repositoriesService repository stats", () => { }); }); +describe("repositoriesService.listSnapshotFiles", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("limits concurrent restic ls commands per repository", async () => { + const repository = await createTestRepository(session.organizationId); + let active = 0; + let maxActive = 0; + let releaseAll = false; + const releaseWaiters: Array<() => void> = []; + + const releaseWaitingCommands = () => { + const waiters = releaseWaiters.splice(0); + for (const release of waiters) { + release(); + } + }; + + const lsSpy = vi.spyOn(restic, "ls").mockImplementation((_config, snapshotId, _path, options) => + Effect.promise(async () => { + active++; + maxActive = Math.max(maxActive, active); + + try { + if (!releaseAll) { + await new Promise((resolve) => releaseWaiters.push(resolve)); + } + + return { + snapshot: { + id: snapshotId, + short_id: snapshotId, + time: new Date().toISOString(), + tree: "tree", + paths: ["/"], + hostname: "host", + struct_type: "snapshot" as const, + message_type: "snapshot" as const, + }, + nodes: [], + pagination: { + offset: options.offset ?? 0, + limit: options.limit ?? 500, + total: 0, + hasMore: false, + }, + }; + } finally { + active--; + } + }), + ); + + const calls = Array.from({ length: 4 }, (_, index) => + withContext({ organizationId: session.organizationId, userId: session.user.id }, () => + repositoriesService.listSnapshotFiles(repository.shortId, `snapshot-${index}`, "/", { + offset: 0, + limit: 100, + }), + ), + ); + + try { + await waitForExpect(() => { + expect(releaseWaiters).toHaveLength(2); + }); + expect(maxActive).toBe(2); + + releaseWaitingCommands(); + + await waitForExpect(() => { + expect(releaseWaiters).toHaveLength(2); + }); + expect(maxActive).toBe(2); + + releaseWaitingCommands(); + await Promise.all(calls); + } finally { + releaseAll = true; + releaseWaitingCommands(); + await Promise.allSettled(calls); + } + + expect(lsSpy).toHaveBeenCalledTimes(4); + expect(maxActive).toBeLessThanOrEqual(2); + }); +}); + describe("repositoriesService.dumpSnapshot", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 5e04404c..1b9ce296 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -42,6 +42,7 @@ import type { ParsedTask, TaskInput } from "../tasks/tasks.schemas"; import { Effect } from "effect"; const runningDoctors = new Map(); +const lsLimiters = new Map(); const RESTORE_TASK_RESOURCE_TYPE = "repository"; type RestoreTaskInput = Extract; @@ -99,6 +100,15 @@ const updateActiveRestoreTask = (restoreId: string, eventName: string, update: ( } }; +const getLsLimiter = (repositoryId: string) => { + let limiter = lsLimiters.get(repositoryId); + if (!limiter) { + limiter = Effect.unsafeMakeSemaphore(2); + lsLimiters.set(repositoryId, limiter); + } + return limiter; +}; + const findActiveRestoreTask = ( organizationId: string, repositoryShortId: string, @@ -472,7 +482,9 @@ const listSnapshotFiles = async ( const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`); try { const result = await runEffectPromise( - restic.ls(repository.config, snapshotId, path, { organizationId, offset, limit }), + restic + .ls(repository.config, snapshotId, path, { organizationId, offset, limit }) + .pipe(getLsLimiter(repository.id).withPermits(1)), ); if (!result.snapshot) {