diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 379b9edb..6c29f739 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -6,6 +6,7 @@ import { db } from "../../db/db"; import { backupSchedulesTable, backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "../../db/schema"; import { restic } from "../../utils/restic"; import { logger } from "../../utils/logger"; +import { cache } from "../../utils/cache"; import { getVolumePath } from "../volumes/helpers"; import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto"; import { toMessage } from "../../utils/errors"; @@ -329,6 +330,8 @@ const executeBackup = async (scheduleId: number, manual = false) => { const finalStatus = exitCode === 0 ? "success" : "warning"; + cache.delByPrefix(`snapshots:${repository.id}:`); + const nextBackupAt = calculateNextRun(schedule.cronExpression); await db .update(backupSchedulesTable) @@ -488,6 +491,7 @@ const runForget = async (scheduleId: number, repositoryId?: string) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`); try { await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId }); + cache.delByPrefix(`snapshots:${repository.id}:`); } finally { releaseLock(); } @@ -619,6 +623,7 @@ const copyToMirrors = async ( try { await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId }); + cache.delByPrefix(`snapshots:${mirror.repository.id}:`); } finally { releaseSource(); releaseMirror(); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index ea0bf792..ea713699 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -7,6 +7,7 @@ import { toMessage } from "../../utils/errors"; import { generateShortId } from "../../utils/id"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; +import { cache } from "../../utils/cache"; import { repoMutex } from "../../core/repository-mutex"; import { repositoryConfigSchema, @@ -143,6 +144,9 @@ const deleteRepository = async (id: string) => { // TODO: Add cleanup logic for the actual restic repository files await db.delete(repositoriesTable).where(eq(repositoriesTable.id, repository.id)); + + cache.delByPrefix(`snapshots:${repository.id}:`); + cache.delByPrefix(`ls:${repository.id}:`); }; /** @@ -160,6 +164,12 @@ const listSnapshots = async (id: string, backupId?: string) => { throw new NotFoundError("Repository not found"); } + const cacheKey = `snapshots:${repository.id}:${backupId || "all"}`; + const cached = cache.get>>(cacheKey); + if (cached) { + return cached; + } + const releaseLock = await repoMutex.acquireShared(repository.id, "snapshots"); try { let snapshots = []; @@ -170,6 +180,8 @@ const listSnapshots = async (id: string, backupId?: string) => { snapshots = await restic.snapshots(repository.config); } + cache.set(cacheKey, snapshots); + return snapshots; } finally { releaseLock(); @@ -183,6 +195,15 @@ const listSnapshotFiles = async (id: string, snapshotId: string, path?: string) throw new NotFoundError("Repository not found"); } + const cacheKey = `ls:${repository.id}:${snapshotId}:${path || "root"}`; + const cached = cache.get>>(cacheKey); + if (cached?.snapshot) { + return { + snapshot: cached.snapshot, + files: cached.nodes, + }; + } + const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`); try { const result = await restic.ls(repository.config, snapshotId, path); @@ -191,7 +212,7 @@ const listSnapshotFiles = async (id: string, snapshotId: string, path?: string) throw new NotFoundError("Snapshot not found or empty"); } - return { + const response = { snapshot: { id: result.snapshot.id, short_id: result.snapshot.short_id, @@ -201,6 +222,10 @@ const listSnapshotFiles = async (id: string, snapshotId: string, path?: string) }, files: result.nodes, }; + + cache.set(cacheKey, result); + + return response; } finally { releaseLock(); } @@ -248,19 +273,26 @@ const getSnapshotDetails = async (id: string, snapshotId: string) => { throw new NotFoundError("Repository not found"); } - const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`); - try { - const snapshots = await restic.snapshots(repository.config); - const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId); + const cacheKey = `snapshots:${repository.id}:all`; + let snapshots = cache.get>>(cacheKey); - if (!snapshot) { - throw new NotFoundError("Snapshot not found"); + if (!snapshots) { + const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`); + try { + snapshots = await restic.snapshots(repository.config); + cache.set(cacheKey, snapshots); + } finally { + releaseLock(); } - - return snapshot; - } finally { - releaseLock(); } + + const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId); + + if (!snapshot) { + throw new NotFoundError("Snapshot not found"); + } + + return snapshot; }; const checkHealth = async (repositoryId: string) => { @@ -388,6 +420,8 @@ const deleteSnapshot = async (id: string, snapshotId: string) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`); try { await restic.deleteSnapshot(repository.config, snapshotId); + cache.delByPrefix(`snapshots:${repository.id}:`); + cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`); } finally { releaseLock(); } @@ -403,6 +437,10 @@ const deleteSnapshots = async (id: string, snapshotIds: string[]) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`); try { await restic.deleteSnapshots(repository.config, snapshotIds); + cache.delByPrefix(`snapshots:${repository.id}:`); + for (const snapshotId of snapshotIds) { + cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`); + } } finally { releaseLock(); } @@ -422,6 +460,10 @@ const tagSnapshots = async ( const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`); try { await restic.tagSnapshots(repository.config, snapshotIds, tags); + cache.delByPrefix(`snapshots:${repository.id}:`); + for (const snapshotId of snapshotIds) { + cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`); + } } finally { releaseLock(); } diff --git a/app/server/utils/cache.ts b/app/server/utils/cache.ts index 77c7e48e..f402c20f 100644 --- a/app/server/utils/cache.ts +++ b/app/server/utils/cache.ts @@ -54,6 +54,11 @@ export const createCache = (options: CacheOptions = {}) => { stmt.run(key); }; + const delByPrefix = (prefix: string) => { + const stmt = db.prepare("DELETE FROM cache WHERE key LIKE ?"); + stmt.run(`${prefix}%`); + }; + const getByPrefix = (prefix: string): { key: string; value: T }[] => { const stmt = db.prepare("SELECT key, value, expiration FROM cache WHERE key LIKE ?"); const rows = stmt.all(`${prefix}%`) as { key: string; value: string; expiration: number }[]; @@ -88,6 +93,7 @@ export const createCache = (options: CacheOptions = {}) => { set, get, del, + delByPrefix, getByPrefix, clear, };