Run whole-data archives and restores in the background
A data archive or restore ran inside its request, which sent nothing until tar finished: Bun closes a silent socket after 10s (its default idleTimeout in production) and a proxy cuts it at about 100s, so the page showed a network error while the archive kept going, and a retry said another backup was already running. The routes now start the run (startDataBackupRun, refused while the backup lock is held) and answer at once. The page follows it by polling api/admin/backups/data-status: an archive reloads once written, a restore once the status comes from a new process after its restart, and a failure shows its error. A run in progress survives a reload. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import createDataBackup from "@/src/functions/backend/backups/create-data-backup";
|
||||
import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { DataBackupRun } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Archives the whole `DATA_DIR` (Settings > Backups > Data archives).
|
||||
* Starts archiving the whole `DATA_DIR` (Settings > Backups > Data
|
||||
* archives) and answers at once: the page polls `data-status` until the
|
||||
* archive is written.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
}) => {
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<DataBackupRun>
|
||||
> = async ({ req }) => {
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
@@ -21,32 +26,43 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const backup = await createDataBackup();
|
||||
const ip_address = grabRequestIP(req);
|
||||
|
||||
await logEvent({
|
||||
title: "Data backup created",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} created a data backup (${backup.name}).`,
|
||||
metadata: { backup_name: backup.name },
|
||||
ip_address: grabRequestIP(req),
|
||||
try {
|
||||
const run = startDataBackupRun({
|
||||
kind: "archive",
|
||||
task: async () => {
|
||||
try {
|
||||
const backup = await createDataBackup();
|
||||
|
||||
await logEvent({
|
||||
title: "Data backup created",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} created a data backup (${backup.name}).`,
|
||||
metadata: { backup_name: backup.name },
|
||||
ip_address,
|
||||
});
|
||||
|
||||
return backup.name;
|
||||
} catch (error: any) {
|
||||
await logError({
|
||||
title: "Data backup failed",
|
||||
error,
|
||||
user_id: user.id,
|
||||
ip_address,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: run,
|
||||
};
|
||||
} catch (error: any) {
|
||||
await logError({
|
||||
title: "Data backup failed",
|
||||
error,
|
||||
user_id: user.id,
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabDataBackups from "@/src/functions/backend/backups/grab-data-backups";
|
||||
import { grabDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { DataBackupStatus } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* How the whole-data archive or restore this process started is going,
|
||||
* with the archives as they stand, for the backups page to poll.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<DataBackupStatus>
|
||||
> = async ({ req }) => {
|
||||
try {
|
||||
if (req.method != "GET") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: {
|
||||
run: grabDataBackupRun(),
|
||||
data_backups: grabDataBackups(),
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -1,21 +1,23 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import restoreDataBackup from "@/src/functions/backend/backups/restore-data-backup";
|
||||
import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||
import restartApp from "@/src/functions/backend/backups/restart-app";
|
||||
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { ApiReqParams, DataBackupRun } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Replaces the whole data volume with a saved archive, then restarts the
|
||||
* app so it opens the restored database.
|
||||
* Starts replacing the whole data volume with a saved archive and answers
|
||||
* at once. Once it is in, the app restarts so it opens the restored
|
||||
* database; the page polls `data-status` until it is back.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<DataBackupRun>
|
||||
> = async ({ req, body }) => {
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
@@ -26,41 +28,51 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
}
|
||||
|
||||
const backup_name = (body as ApiReqParams | undefined)?.backup_name;
|
||||
const ip_address = grabRequestIP(req);
|
||||
|
||||
try {
|
||||
const result = await restoreDataBackup({ backup_name });
|
||||
const run = startDataBackupRun({
|
||||
kind: "restore",
|
||||
task: async () => {
|
||||
try {
|
||||
const result = await restoreDataBackup({ backup_name });
|
||||
|
||||
await logEvent({
|
||||
title: "Data restored",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} restored the data volume from archive ${result.restored_name} (safety copy ${result.safety_name}). The app restarted.`,
|
||||
metadata: {
|
||||
backup_name: result.restored_name,
|
||||
safety_name: result.safety_name,
|
||||
await logEvent({
|
||||
title: "Data restored",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} restored the data volume from archive ${result.restored_name} (safety copy ${result.safety_name}). The app restarted.`,
|
||||
metadata: {
|
||||
backup_name: result.restored_name,
|
||||
safety_name: result.safety_name,
|
||||
},
|
||||
ip_address,
|
||||
});
|
||||
|
||||
restartApp();
|
||||
|
||||
return result.restored_name;
|
||||
} catch (error: any) {
|
||||
await logError({
|
||||
title: "Data restore failed",
|
||||
error,
|
||||
user_id: user.id,
|
||||
metadata: {
|
||||
backup_name,
|
||||
},
|
||||
ip_address,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
restartApp();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: run,
|
||||
};
|
||||
} catch (error: any) {
|
||||
await logError({
|
||||
title: "Data restore failed",
|
||||
error,
|
||||
user_id: user.id,
|
||||
metadata: {
|
||||
backup_name,
|
||||
},
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import restoreDataBackup from "@/src/functions/backend/backups/restore-data-backup";
|
||||
import { startDataBackupRun } from "@/src/functions/backend/backups/data-backup-run";
|
||||
import { isBackupRunning } from "@/src/functions/backend/backups/with-backup-run-lock";
|
||||
import restartApp from "@/src/functions/backend/backups/restart-app";
|
||||
import saveUploadedDataBackup from "@/src/functions/backend/backups/save-uploaded-data-backup";
|
||||
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { DataBackupRun } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
@@ -15,11 +20,14 @@ export const config: BunextServerRouteConfig = {
|
||||
|
||||
/**
|
||||
* Multipart upload (`file` field) of a whole-data archive. The file is
|
||||
* saved into the archive directory, then restored, and the app restarts.
|
||||
* saved into the archive directory within the request (its bytes keep the
|
||||
* connection busy), then the restore starts in the background and the
|
||||
* request answers: the app restarts once it is in, and the page polls
|
||||
* `data-status` until it is back.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
}) => {
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<DataBackupRun>
|
||||
> = async ({ req }) => {
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
@@ -29,40 +37,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const ip_address = grabRequestIP(req);
|
||||
let file_name: string | undefined;
|
||||
let backup_name: string | undefined;
|
||||
|
||||
try {
|
||||
const file = (await req.formData()).get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new Error(`No archive uploaded`);
|
||||
}
|
||||
|
||||
file_name = file.name;
|
||||
backup_name = await saveUploadedDataBackup({ file });
|
||||
|
||||
const result = await restoreDataBackup({ backup_name });
|
||||
|
||||
await logEvent({
|
||||
title: "Data restored from upload",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} restored the data volume from uploaded archive ${file_name} (saved as ${result.restored_name}, safety copy ${result.safety_name}). The app restarted.`,
|
||||
metadata: {
|
||||
file_name,
|
||||
backup_name: result.restored_name,
|
||||
safety_name: result.safety_name,
|
||||
},
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
restartApp();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const logFailure = async (error: unknown) => {
|
||||
await logError({
|
||||
title: "Data restore from upload failed",
|
||||
error,
|
||||
@@ -71,12 +50,69 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
file_name,
|
||||
backup_name,
|
||||
},
|
||||
ip_address: grabRequestIP(req),
|
||||
ip_address,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (isBackupRunning()) {
|
||||
throw new AppError(`Another backup or restore is already running`);
|
||||
}
|
||||
|
||||
const file = (await req.formData()).get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new AppError(`No archive uploaded`);
|
||||
}
|
||||
|
||||
file_name = file.name;
|
||||
backup_name = await saveUploadedDataBackup({ file });
|
||||
|
||||
const saved_name = backup_name;
|
||||
|
||||
const run = startDataBackupRun({
|
||||
kind: "restore",
|
||||
task: async () => {
|
||||
try {
|
||||
const result = await restoreDataBackup({
|
||||
backup_name: saved_name,
|
||||
});
|
||||
|
||||
await logEvent({
|
||||
title: "Data restored from upload",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} restored the data volume from uploaded archive ${file_name} (saved as ${result.restored_name}, safety copy ${result.safety_name}). The app restarted.`,
|
||||
metadata: {
|
||||
file_name,
|
||||
backup_name: result.restored_name,
|
||||
safety_name: result.safety_name,
|
||||
},
|
||||
ip_address,
|
||||
});
|
||||
|
||||
restartApp();
|
||||
|
||||
return result.restored_name;
|
||||
} catch (error: any) {
|
||||
await logFailure(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: run,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof AppError)) {
|
||||
await logFailure(error);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
msg: safeErrorMessage({ error, log: false }),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user