fix: cascade delete not correctly applied (#531)

This commit is contained in:
Nico 2026-02-16 21:36:07 +01:00 committed by GitHub
parent de1278a416
commit 99bb296866
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 175 additions and 18 deletions

View file

@ -94,14 +94,6 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
deleteVol.mutate({ path: { id: volumeId } });
};
if (!volumeId) {
return <div>Volume not found</div>;
}
if (!data) {
return <div>Loading...</div>;
}
const { volume, statfs } = data;
return (
@ -164,6 +156,9 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
<br />
<br />
All backup schedules associated with this volume will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">

View file

@ -1,12 +1,11 @@
import { logger } from "./server/utils/logger";
import { shutdown } from "./server/modules/lifecycle/shutdown";
import { runCLI } from "./server/cli";
import {
createStartHandler,
defaultStreamHandler,
defineHandlerCallback,
} from "@tanstack/react-start/server";
import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server";
import { createServerEntry } from "@tanstack/react-start/server-entry";
import { runDbMigrations } from "./server/db/db";
await runDbMigrations();
const cliRun = await runCLI(Bun.argv);
if (cliRun) {

View file

@ -17,7 +17,9 @@ if (fs.existsSync(path.join(path.dirname(DATABASE_URL), "ironmount.db")) && !fs.
const sqlite = new Database(DATABASE_URL);
export const db = drizzle({ client: sqlite, relations, schema });
export const runDbMigrations = () => {
let migrationsPromise: Promise<void> | undefined;
const runMigrations = async () => {
let migrationsFolder: string;
if (config.migrationsPath) {
@ -32,3 +34,12 @@ export const runDbMigrations = () => {
sqlite.run("PRAGMA foreign_keys = ON;");
};
export const runDbMigrations = () => {
if (migrationsPromise) {
return migrationsPromise;
}
migrationsPromise = runMigrations();
return migrationsPromise;
};

View file

@ -1,5 +1,6 @@
import waitForExpect from "wait-for-expect";
import { test, describe, mock, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { eq } from "drizzle-orm";
import { backupsService } from "../backups.service";
import { createTestVolume } from "~/test/helpers/volume";
import { createTestBackupSchedule } from "~/test/helpers/backup";
@ -7,6 +8,8 @@ import { createTestRepository } from "~/test/helpers/repository";
import { generateBackupOutput } from "~/test/helpers/restic";
import { faker } from "@faker-js/faker";
import * as spawnModule from "~/server/utils/spawn";
import { db } from "~/server/db/db";
import { backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "~/server/db/schema";
import { TEST_ORG_ID } from "~/test/helpers/organization";
import * as context from "~/server/core/request-context";
import { backupsExecutionService } from "../backups.execution";
@ -183,3 +186,100 @@ describe("getSchedulesToExecute", () => {
expect(schedulesToExecute).toContain(schedule.id);
});
});
describe("listSchedules", () => {
test("should ignore schedules with missing relations", async () => {
const healthyVolume = await createTestVolume();
const healthyRepository = await createTestRepository();
const healthySchedule = await createTestBackupSchedule({
volumeId: healthyVolume.id,
repositoryId: healthyRepository.id,
});
const orphanVolume = await createTestVolume();
const orphanRepository = await createTestRepository();
const orphanSchedule = await createTestBackupSchedule({
volumeId: orphanVolume.id,
repositoryId: orphanRepository.id,
});
await db.delete(volumesTable).where(eq(volumesTable.id, orphanVolume.id));
const schedules = await backupsService.listSchedules();
expect(schedules.map((schedule) => schedule.id)).toContain(healthySchedule.id);
expect(schedules.map((schedule) => schedule.id)).not.toContain(orphanSchedule.id);
});
test("should ignore schedules with missing repository relation", async () => {
const healthyVolume = await createTestVolume();
const healthyRepository = await createTestRepository();
const healthySchedule = await createTestBackupSchedule({
volumeId: healthyVolume.id,
repositoryId: healthyRepository.id,
});
const orphanVolume = await createTestVolume();
const orphanRepository = await createTestRepository();
const orphanSchedule = await createTestBackupSchedule({
volumeId: orphanVolume.id,
repositoryId: orphanRepository.id,
});
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, orphanRepository.id));
const schedules = await backupsService.listSchedules();
expect(schedules.map((schedule) => schedule.id)).toContain(healthySchedule.id);
expect(schedules.map((schedule) => schedule.id)).not.toContain(orphanSchedule.id);
});
});
describe("cleanupOrphanedSchedules", () => {
test("should delete orphaned schedules and their mirror assignments", async () => {
const healthyVolume = await createTestVolume();
const healthyRepository = await createTestRepository();
const healthySchedule = await createTestBackupSchedule({
volumeId: healthyVolume.id,
repositoryId: healthyRepository.id,
});
const orphanVolume = await createTestVolume();
const orphanRepository = await createTestRepository();
const orphanSchedule = await createTestBackupSchedule({
volumeId: orphanVolume.id,
repositoryId: orphanRepository.id,
});
const mirrorRepository = await createTestRepository();
await db.insert(backupScheduleMirrorsTable).values({
scheduleId: orphanSchedule.id,
repositoryId: mirrorRepository.id,
enabled: true,
});
await db.delete(volumesTable).where(eq(volumesTable.id, orphanVolume.id));
const cleanupResult = await backupsService.cleanupOrphanedSchedules();
expect(cleanupResult.deletedSchedules).toBeGreaterThanOrEqual(1);
const deletedSchedule = await db.query.backupSchedulesTable.findFirst({
where: { id: orphanSchedule.id },
columns: { id: true },
});
expect(deletedSchedule).toBeUndefined();
const remainingHealthySchedule = await db.query.backupSchedulesTable.findFirst({
where: { id: healthySchedule.id },
columns: { id: true },
});
expect(remainingHealthySchedule?.id).toBe(healthySchedule.id);
const orphanMirrors = await db.query.backupScheduleMirrorsTable.findMany({
where: { scheduleId: orphanSchedule.id },
columns: { id: true },
});
expect(orphanMirrors).toHaveLength(0);
});
});

View file

@ -1,8 +1,8 @@
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import cron from "node-cron";
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
import { db } from "../../db/db";
import { backupSchedulesTable, backupScheduleMirrorsTable } from "../../db/schema";
import { backupScheduleMirrorsTable, backupScheduleNotificationsTable, backupSchedulesTable } from "../../db/schema";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
@ -17,7 +17,7 @@ const listSchedules = async () => {
with: { volume: true, repository: true },
orderBy: { sortOrder: "asc", id: "asc" },
});
return schedules;
return schedules.filter((schedule) => schedule.volume && schedule.repository);
};
const getScheduleById = async (scheduleId: number) => {
@ -31,6 +31,10 @@ const getScheduleById = async (scheduleId: number) => {
throw new NotFoundError("Backup schedule not found");
}
if (!schedule.volume || !schedule.repository) {
throw new NotFoundError("Backup schedule not found");
}
return schedule;
};
@ -45,6 +49,10 @@ const getScheduleByShortId = async (shortId: string) => {
throw new NotFoundError("Backup schedule not found");
}
if (!schedule.volume || !schedule.repository) {
throw new NotFoundError("Backup schedule not found");
}
return schedule;
};
@ -190,6 +198,10 @@ const getScheduleForVolume = async (volumeId: number) => {
with: { volume: true, repository: true },
});
if (schedule && (!schedule.volume || !schedule.repository)) {
return null;
}
return schedule ?? null;
};
@ -331,6 +343,35 @@ const reorderSchedules = async (scheduleIds: number[]) => {
});
};
const cleanupOrphanedSchedules = async () => {
const schedules = await db.query.backupSchedulesTable.findMany({
with: { volume: true, repository: true },
columns: { id: true },
});
const orphanScheduleIds = schedules
.filter((schedule) => schedule.volume === null || schedule.repository === null)
.map((schedule) => schedule.id);
if (orphanScheduleIds.length === 0) {
return { deletedSchedules: 0 };
}
db.transaction((tx) => {
tx.delete(backupScheduleNotificationsTable)
.where(inArray(backupScheduleNotificationsTable.scheduleId, orphanScheduleIds))
.run();
tx.delete(backupScheduleMirrorsTable)
.where(inArray(backupScheduleMirrorsTable.scheduleId, orphanScheduleIds))
.run();
tx.delete(backupSchedulesTable).where(inArray(backupSchedulesTable.id, orphanScheduleIds)).run();
});
return { deletedSchedules: orphanScheduleIds.length };
};
export const backupsService = {
listSchedules,
getScheduleById,
@ -343,4 +384,5 @@ export const backupsService = {
getMirrorCompatibility,
reorderSchedules,
getScheduleByShortId,
cleanupOrphanedSchedules,
};

View file

@ -5,7 +5,7 @@ import { startup } from "./startup";
let bootstrapPromise: Promise<void> | undefined;
const runBootstrap = async () => {
runDbMigrations();
await runDbMigrations();
await runMigrations();
await startup();
};

View file

@ -13,6 +13,7 @@ import { notificationsService } from "../notifications/notifications.service";
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
import { cache } from "~/server/utils/cache";
import { withContext } from "~/server/core/request-context";
import { backupsService } from "../backups/backups.service";
const ensureLatestConfigurationSchema = async () => {
const volumes = await db.query.volumesTable.findMany({});
@ -54,6 +55,15 @@ export const startup = async () => {
await ensureLatestConfigurationSchema();
const { deletedSchedules } = await backupsService.cleanupOrphanedSchedules().catch((err) => {
logger.error(`Failed to cleanup orphaned backup schedules on startup: ${err.message}`);
return { deletedSchedules: 0 };
});
if (deletedSchedules > 0) {
logger.warn(`Removed ${deletedSchedules} orphaned backup schedule(s) during startup`);
}
const volumes = await db.query.volumesTable.findMany({
where: {
OR: [