fix: wrong cache key for snapshot retention tags (#574)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Enhanced error handling in retention category calculations to gracefully handle missing or invalid repositories, ensuring the system returns safe defaults instead of failing
  * Optimized repository cache validation and lookup mechanisms for improved performance
  * Strengthened repository validation checks to prevent potential calculation failures
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Nico
2026-02-25 19:02:44 +01:00
committed by GitHub
parent 29967d7e4e
commit dd9083ce7a
2 changed files with 81 additions and 13 deletions

View File

@@ -10,6 +10,8 @@ import { repositoriesTable } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id";
import { restic } from "~/server/utils/restic";
import { createTestSession } from "~/test/helpers/auth";
import { createTestBackupSchedule } from "~/test/helpers/backup";
import { cache, cacheKeys } from "~/server/utils/cache";
import { repositoriesService } from "../repositories.service";
describe("repositoriesService.createRepository", () => {
@@ -303,3 +305,69 @@ describe("repositoriesService.dumpSnapshot", () => {
});
});
});
describe("repositoriesService.getRetentionCategories", () => {
afterEach(() => {
mock.restore();
});
test("recomputes retention categories after repository cache invalidation", async () => {
const { organizationId, user } = await createTestSession();
const schedule = await createTestBackupSchedule({ organizationId, retentionPolicy: { keepLast: 1 } });
const repository = await db.query.repositoriesTable.findFirst({ where: { id: schedule.repositoryId } });
expect(repository).toBeTruthy();
if (!repository) {
throw new Error("Repository should exist");
}
const oldSnapshotId = "snapshot-old";
const newSnapshotId = "snapshot-new";
const buildForgetResponse = (snapshotId: string) => ({
success: true,
data: [
{
tags: [schedule.shortId],
host: "host",
paths: ["/data"],
keep: [],
remove: null,
reasons: [
{
snapshot: {
id: snapshotId,
short_id: snapshotId,
time: new Date().toISOString(),
tree: "tree",
paths: ["/data"],
hostname: "host",
},
matches: ["last snapshot"],
},
],
},
],
});
const forgetSpy = spyOn(restic, "forget");
forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId));
forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId));
const firstCategories = await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
);
expect(firstCategories.get(oldSnapshotId)).toEqual(["last"]);
cache.delByPrefix(cacheKeys.repository.all(repository.id));
const secondCategories = await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
);
expect(secondCategories.get(newSnapshotId)).toEqual(["last"]);
expect(secondCategories.has(oldSnapshotId)).toBe(false);
expect(forgetSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -809,25 +809,25 @@ const getRetentionCategories = async (repositoryId: ShortId, scheduleId?: ShortI
return new Map<string, RetentionCategory[]>();
}
const cacheKey = cacheKeys.repository.retention(repositoryId, scheduleId);
const cached = cache.get<Record<string, RetentionCategory[]>>(cacheKey);
if (cached && Object.keys(cached).length > 0) {
return new Map(Object.entries(cached));
}
try {
const [schedule, repositoryResult] = await Promise.all([
backupsService.getScheduleByShortId(scheduleId),
repositoriesService.getRepository(repositoryId),
]);
const repository = await findRepository(repositoryId);
if (!repository) {
return new Map<string, RetentionCategory[]>();
}
const cacheKey = cacheKeys.repository.retention(repository.id, scheduleId);
const cached = cache.get<Record<string, RetentionCategory[]>>(cacheKey);
if (cached) {
return new Map(Object.entries(cached));
}
const schedule = await backupsService.getScheduleByShortId(scheduleId);
if (!schedule?.retentionPolicy) {
return new Map<string, RetentionCategory[]>();
}
const { repository } = repositoryResult;
const dryRunResults = await restic.forget(repository.config, schedule.retentionPolicy, {
tag: scheduleId,
organizationId: getOrganizationId(),