feat(backup): add gzip compression and history filtering
This commit is contained in:
36 files changed
+1144
-137
No files matched your search
@@ -8,12 +8,15 @@ const mocks = vi.hoisted(() => ({
|
||||
rollbackSnapshot: vi.fn(async () => {}),
|
||||
deleteFiles: vi.fn(async () => 0),
|
||||
cancelExport: vi.fn(async () => {}),
|
||||
clearExportCancellation: vi.fn(async () => {}),
|
||||
exportDatabase: vi.fn(),
|
||||
runDatabaseExport: vi.fn(),
|
||||
addTask: vi.fn(),
|
||||
registerCancel: vi.fn(),
|
||||
unregisterCancel: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
markTaskCancelling: vi.fn(),
|
||||
restoreTaskRunning: vi.fn(),
|
||||
nextId: 0,
|
||||
}));
|
||||
|
||||
@@ -23,6 +26,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
rollbackManualTransaction: mocks.rollbackSnapshot,
|
||||
deleteDatabaseBackupFiles: mocks.deleteFiles,
|
||||
cancelDatabaseExport: mocks.cancelExport,
|
||||
clearDatabaseExportCancellation: mocks.clearExportCancellation,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/export/databaseExport", () => ({
|
||||
@@ -45,6 +49,8 @@ vi.mock("@/composables/useExportTracker", () => ({
|
||||
registerTaskCancelHandler: mocks.registerCancel,
|
||||
unregisterTaskCancelHandler: mocks.unregisterCancel,
|
||||
updateDatabaseExportTask: mocks.updateTask,
|
||||
markDatabaseExportTaskCancelling: mocks.markTaskCancelling,
|
||||
restoreDatabaseExportTaskRunning: mocks.restoreTaskRunning,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -60,6 +66,7 @@ const config: DatabaseBackupExecutionConfig = {
|
||||
includeData: true,
|
||||
includeObjects: true,
|
||||
dropTableIfExists: false,
|
||||
outputCompression: "none",
|
||||
};
|
||||
|
||||
describe("useScheduledDatabaseBackups one-shot execution", () => {
|
||||
@@ -126,12 +133,72 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
|
||||
expect(activeRunId).toBeTruthy();
|
||||
|
||||
await backup.cancelRun(activeRunId!);
|
||||
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
|
||||
expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
|
||||
expect(mocks.markTaskCancelling).toHaveBeenCalledWith(activeRunId);
|
||||
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(true);
|
||||
await backup.cancelRun(activeRunId!);
|
||||
expect(mocks.cancelExport).toHaveBeenCalledTimes(2);
|
||||
resolveExport({ status: "Cancelled", objectIndex: 0, totalObjects: 1, currentObject: "app" });
|
||||
|
||||
const finishedRun = await pendingRun;
|
||||
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
|
||||
expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)]);
|
||||
expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
|
||||
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops after cancelling during database discovery without creating a snapshot", async () => {
|
||||
mocks.beginSnapshot.mockClear();
|
||||
mocks.cancelExport.mockClear();
|
||||
let resolveDatabases!: (databases: Array<{ name: string }>) => void;
|
||||
mocks.listDatabases.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveDatabases = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const backup = useScheduledDatabaseBackups();
|
||||
const pendingRun = backup.runOneShot(config, "One-time backup");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const activeRunId = [...backup.activeRunIds][0];
|
||||
expect(activeRunId).toBeTruthy();
|
||||
|
||||
await backup.cancelRun(activeRunId!);
|
||||
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
|
||||
resolveDatabases([{ name: "app" }]);
|
||||
|
||||
const finishedRun = await pendingRun;
|
||||
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
|
||||
expect(mocks.beginSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals the backup run while snapshot creation is pending", async () => {
|
||||
mocks.beginSnapshot.mockClear();
|
||||
mocks.cancelExport.mockClear();
|
||||
let resolveSnapshot!: (snapshot: { sessionId: string }) => void;
|
||||
mocks.beginSnapshot.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSnapshot = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const backup = useScheduledDatabaseBackups();
|
||||
const pendingRun = backup.runOneShot(config, "One-time backup");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const activeRunId = [...backup.activeRunIds][0];
|
||||
expect(activeRunId).toBeTruthy();
|
||||
expect(mocks.beginSnapshot).toHaveBeenCalledWith("mysql-1", "app", activeRunId);
|
||||
|
||||
await backup.cancelRun(activeRunId!);
|
||||
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
|
||||
resolveSnapshot({ sessionId: "snapshot-pending" });
|
||||
|
||||
const finishedRun = await pendingRun;
|
||||
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
|
||||
expect(mocks.rollbackSnapshot).toHaveBeenCalledWith("snapshot-pending");
|
||||
expect(mocks.clearExportCancellation).toHaveBeenCalledWith(activeRunId);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import * as api from "@/lib/backend/api";
|
||||
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
|
||||
|
||||
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer" | "multi-db-execution";
|
||||
export type BackgroundTaskStatus = "Running" | "Writing" | "Done" | "Error" | "Cancelled";
|
||||
export type BackgroundTaskStatus = "Running" | "Writing" | "Cancelling" | "Done" | "Error" | "Cancelled";
|
||||
export type DatabaseExportSource = "manual" | "scheduled";
|
||||
|
||||
export interface DataTransferFailure {
|
||||
@@ -249,7 +249,7 @@ function findActiveOverlappingTransfer(request: api.TransferRequest): string[] {
|
||||
export function useExportTracker() {
|
||||
const tasks = computed(() => Array.from(taskMap.values()));
|
||||
|
||||
const activeCount = computed(() => tasks.value.filter((t) => t.status === "Running" || t.status === "Writing").length);
|
||||
const activeCount = computed(() => tasks.value.filter((t) => t.status === "Running" || t.status === "Writing" || t.status === "Cancelling").length);
|
||||
|
||||
const hasActive = computed(() => activeCount.value > 0);
|
||||
|
||||
@@ -470,7 +470,8 @@ export function useExportTracker() {
|
||||
task.preparing = !!progress.preparing;
|
||||
task.rowsExported = progress.rowsExported;
|
||||
task.totalRows = progress.totalRows;
|
||||
task.status = normalizeExportStatus(progress.status);
|
||||
const nextStatus = normalizeExportStatus(progress.status);
|
||||
task.status = task.status === "Cancelling" && nextStatus === "Running" ? "Cancelling" : nextStatus;
|
||||
task.errorMessage = progress.error || null;
|
||||
task.objectIndex = progress.objectIndex;
|
||||
task.totalObjects = progress.totalObjects;
|
||||
@@ -480,6 +481,18 @@ export function useExportTracker() {
|
||||
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") finishExportTask(task);
|
||||
}
|
||||
|
||||
function markDatabaseExportTaskCancelling(exportId: string) {
|
||||
const task = taskMap.get(exportId);
|
||||
if (!task || task.kind !== "database-export" || task.status === "Done" || task.status === "Error" || task.status === "Cancelled") return;
|
||||
task.status = "Cancelling";
|
||||
task.preparing = false;
|
||||
}
|
||||
|
||||
function restoreDatabaseExportTaskRunning(exportId: string) {
|
||||
const task = taskMap.get(exportId);
|
||||
if (task?.kind === "database-export" && task.status === "Cancelling") task.status = "Running";
|
||||
}
|
||||
|
||||
function updateSqlFileTask(executionId: string, progress: api.SqlFileProgress) {
|
||||
const task = taskMap.get(executionId);
|
||||
if (!task) return;
|
||||
@@ -577,6 +590,8 @@ export function useExportTracker() {
|
||||
startDataTransferTask,
|
||||
updateTableExportTask,
|
||||
updateDatabaseExportTask,
|
||||
markDatabaseExportTaskCancelling,
|
||||
restoreDatabaseExportTaskRunning,
|
||||
updateSqlFileTask,
|
||||
updateDataTransferTask,
|
||||
registerTaskCancelHandler,
|
||||
|
||||
@@ -42,6 +42,7 @@ const schedules = ref<DatabaseBackupSchedule[]>(readDatabaseBackupSchedules());
|
||||
const runs = ref<DatabaseBackupRun[]>(readDatabaseBackupRuns());
|
||||
const activeScheduleIds = reactive(new Set<string>());
|
||||
const activeRunIds = reactive(new Set<string>());
|
||||
const cancellingRunIds = reactive(new Set<string>());
|
||||
const activeExportIds = new Map<string, string>();
|
||||
const cancellationRequested = new Set<string>();
|
||||
|
||||
@@ -84,7 +85,7 @@ function refreshFromStorage() {
|
||||
|
||||
export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {}) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const { addDatabaseExportTask, registerTaskCancelHandler, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
|
||||
const { addDatabaseExportTask, markDatabaseExportTaskCancelling, registerTaskCancelHandler, restoreDatabaseExportTaskRunning, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
|
||||
|
||||
const activeRuns = computed(() => runs.value.filter((run) => activeRunIds.has(run.id)));
|
||||
|
||||
@@ -156,11 +157,23 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRun(runId: string) {
|
||||
if (!activeRunIds.has(runId)) return;
|
||||
async function cancelRun(runId: string): Promise<boolean> {
|
||||
if (!activeRunIds.has(runId)) return false;
|
||||
if (cancellationRequested.has(runId)) return true;
|
||||
cancellationRequested.add(runId);
|
||||
const exportId = activeExportIds.get(runId);
|
||||
if (exportId) await api.cancelDatabaseExport(exportId).catch(() => {});
|
||||
cancellingRunIds.add(runId);
|
||||
markDatabaseExportTaskCancelling(runId);
|
||||
const exportIds = new Set([runId, activeExportIds.get(runId)].filter((id): id is string => Boolean(id)));
|
||||
try {
|
||||
await Promise.all([...exportIds].map((exportId) => api.cancelDatabaseExport(exportId)));
|
||||
} catch (error) {
|
||||
appendDebugLog("warn", "[DBX][database-backup:cancel-request-error]", error);
|
||||
cancellationRequested.delete(runId);
|
||||
cancellingRunIds.delete(runId);
|
||||
restoreDatabaseExportTaskRunning(runId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string }): Promise<FinishedDatabaseBackupRun | null> {
|
||||
@@ -186,7 +199,9 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
activeRunIds.add(runId);
|
||||
cancellationRequested.delete(runId);
|
||||
addDatabaseExportTask(runId, backupName, config.destinationDirectory, request.source === "scheduled" ? "scheduled" : "manual");
|
||||
registerTaskCancelHandler(runId, () => cancelRun(runId));
|
||||
registerTaskCancelHandler(runId, async () => {
|
||||
await cancelRun(runId);
|
||||
});
|
||||
|
||||
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
|
||||
let finalError = "";
|
||||
@@ -201,7 +216,15 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
}
|
||||
if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
|
||||
await connectionStore.ensureConnected(config.connectionId);
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
return;
|
||||
}
|
||||
const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
return;
|
||||
}
|
||||
const selectedDatabases = resolveScheduledDatabaseBackupTargets(config.databases, availableDatabases, connection.db_type);
|
||||
if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
|
||||
|
||||
@@ -213,6 +236,10 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
} catch (error) {
|
||||
appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error);
|
||||
}
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let exportIndex = 0;
|
||||
@@ -223,6 +250,10 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
break;
|
||||
}
|
||||
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
break;
|
||||
}
|
||||
const databasePlan = buildAllDatabaseExportPlan({
|
||||
databases: [database],
|
||||
schemaAware: connection.db_type === "postgres",
|
||||
@@ -236,13 +267,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
continue;
|
||||
}
|
||||
const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
break;
|
||||
}
|
||||
const scope = resolveScheduledDatabaseBackupTableScope(config.tableFilterMode, config.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
|
||||
includedTableCount += scope.includedTables.length;
|
||||
if (scope.includedTables.length === 0) continue;
|
||||
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
|
||||
}
|
||||
if (finalStatus === "cancelled") break;
|
||||
|
||||
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database);
|
||||
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database, runId);
|
||||
let snapshotCompleted = false;
|
||||
try {
|
||||
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
|
||||
@@ -253,7 +289,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
exportIndex += 1;
|
||||
const childExportId = `${runId}-${exportIndex}`;
|
||||
activeExportIds.set(runId, childExportId);
|
||||
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId);
|
||||
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression);
|
||||
generatedPaths.push(filePath);
|
||||
const terminal = await runDatabaseExportUntilTerminal(
|
||||
{
|
||||
@@ -268,6 +304,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
includeData: config.includeData,
|
||||
includeObjects: config.includeObjects,
|
||||
dropTableIfExists: config.dropTableIfExists,
|
||||
outputCompression: config.outputCompression,
|
||||
failOnError: true,
|
||||
snapshotSessionId: snapshot.sessionId,
|
||||
batchSize: 1000,
|
||||
@@ -332,6 +369,12 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
|
||||
finalError = error?.message || String(error);
|
||||
} finally {
|
||||
// `runId` is also the cancellation key while a snapshot is waiting
|
||||
// for a pool connection. Unlike child exports, it has no exporter
|
||||
// task that can clear that key after completion.
|
||||
await api.clearDatabaseExportCancellation(runId).catch((error) => {
|
||||
appendDebugLog("warn", "[DBX][database-backup:cancel-clear-error]", error);
|
||||
});
|
||||
if (finalStatus !== "success" && generatedPaths.length > 0) {
|
||||
try {
|
||||
await api.deleteDatabaseBackupFiles(generatedPaths);
|
||||
@@ -345,6 +388,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
const completedAt = new Date();
|
||||
activeExportIds.delete(runId);
|
||||
cancellationRequested.delete(runId);
|
||||
cancellingRunIds.delete(runId);
|
||||
activeRunIds.delete(runId);
|
||||
unregisterTaskCancelHandler(runId);
|
||||
|
||||
@@ -471,6 +515,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
||||
runs,
|
||||
activeScheduleIds,
|
||||
activeRunIds,
|
||||
cancellingRunIds,
|
||||
activeRuns,
|
||||
saveSchedule,
|
||||
setScheduleEnabled,
|
||||
|
||||
Reference in new issue
Block a user