Fix data archives and restores failing on their own lock
startDataBackupRun already holds the backup run lock, but the archive (createDataBackup) and restoreDataBackup took it again, so every run failed a few seconds in with "Another backup or restore is already running". The writer and the restore now take no lock of their own. The run's notice (archiving, restoring, or why it failed) moves to its own line under the actions row so the buttons don't shift, and archiving reads in gray rather than the warning colour. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
import withBackupRunLock from "./with-backup-run-lock";
|
||||
import writeDataBackupArchive from "./write-data-backup-archive";
|
||||
|
||||
/**
|
||||
* Archives the whole `DATA_DIR` (database, media, computer homes, bot and
|
||||
* workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as
|
||||
* `cobble-data-{timestamp}.tar.gz`, then keeps only the newest
|
||||
* `AppData.DataBackupsKept`. Every entry sits under one root folder named
|
||||
* after the archive (`cobble-data-{timestamp}/`), so extracting it lands
|
||||
* one folder, never a heap of files in the current directory. The
|
||||
* database's own snapshots in `db/.backups` are inside `DATA_DIR`, so they
|
||||
* ride along and are left alone on disk.
|
||||
*
|
||||
* The archive is a filesystem copy of a live volume, so the SQLite file
|
||||
* inside it is only as consistent as the moment tar read it; a consistent
|
||||
* database snapshot is what the `db/.backups` list is for. Restoring a
|
||||
* whole-data archive is `restoreDataBackup` (`restore-data-backup.ts`).
|
||||
*/
|
||||
export default async function createDataBackup() {
|
||||
return await withBackupRunLock({ task: writeDataBackupArchive });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { BootId, grabDataBackupRun, startDataBackupRun } from "./data-backup-run";
|
||||
import { isBackupRunning } from "./with-backup-run-lock";
|
||||
import restoreDataBackup from "./restore-data-backup";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (name: string) => void;
|
||||
@@ -80,4 +81,19 @@ describe("startDataBackupRun", () => {
|
||||
await settle();
|
||||
expect(grabDataBackupRun().status).toBe("done");
|
||||
});
|
||||
|
||||
test("a restore runs under the run's lock instead of taking it again", async () => {
|
||||
startDataBackupRun({
|
||||
kind: "restore",
|
||||
task: async () =>
|
||||
(await restoreDataBackup({ backup_name: "missing.tar.gz" }))
|
||||
.restored_name,
|
||||
});
|
||||
await settle();
|
||||
|
||||
const failed = grabDataBackupRun();
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.error).toBe("Archive not found");
|
||||
expect(isBackupRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppData } from "@/src/data/app-data";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import writeDataBackupArchive from "./write-data-backup-archive";
|
||||
import resolveDataBackupPath from "./resolve-data-backup-path";
|
||||
import withBackupRunLock from "./with-backup-run-lock";
|
||||
|
||||
export type DataBackupRestoreResult = {
|
||||
/**
|
||||
@@ -61,89 +60,86 @@ function findArchiveRoot({ staging_dir }: RootParams) {
|
||||
* is taken as a safety copy, then the data directory is emptied (the
|
||||
* database snapshots included) and filled from the archive.
|
||||
*
|
||||
* Runs only inside `startDataBackupRun`, which holds the backup run lock
|
||||
* for it (taking it again here would refuse every restore).
|
||||
*
|
||||
* The caller restarts the process afterwards: the open SQLite handle still
|
||||
* points at the replaced file, so nothing may keep reading it.
|
||||
*/
|
||||
export default async function restoreDataBackup({
|
||||
backup_name,
|
||||
}: Params): Promise<DataBackupRestoreResult> {
|
||||
return await withBackupRunLock({
|
||||
task: async () => {
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
const archive_path = resolveDataBackupPath({ backup_name });
|
||||
const archive_name = path.basename(archive_path);
|
||||
const staging_dir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "cobble-restore-"),
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
const archive_path = resolveDataBackupPath({ backup_name });
|
||||
const archive_name = path.basename(archive_path);
|
||||
const staging_dir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "cobble-restore-"),
|
||||
);
|
||||
|
||||
try {
|
||||
const extract = Bun.spawn(
|
||||
["tar", "-xzf", archive_path, "-C", staging_dir],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const [stdout, stderr, exit_code] = await Promise.all([
|
||||
new Response(extract.stdout).text(),
|
||||
new Response(extract.stderr).text(),
|
||||
extract.exited,
|
||||
]);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
throw new Error(
|
||||
stderr.trim() ||
|
||||
stdout.trim() ||
|
||||
`tar failed (exit ${exit_code})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const extract = Bun.spawn(
|
||||
["tar", "-xzf", archive_path, "-C", staging_dir],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
// The archive's own name may differ from its file name: an
|
||||
// upload is saved under a fresh timestamp. Its one root
|
||||
// folder is what holds the data, so find it rather than
|
||||
// assume it matches.
|
||||
const source_dir = findArchiveRoot({ staging_dir });
|
||||
|
||||
const [stdout, stderr, exit_code] = await Promise.all([
|
||||
new Response(extract.stdout).text(),
|
||||
new Response(extract.stderr).text(),
|
||||
extract.exited,
|
||||
]);
|
||||
if (!fs.existsSync(path.join(source_dir, "db"))) {
|
||||
throw new Error(
|
||||
`The archive has no database, so nothing was restored`,
|
||||
);
|
||||
}
|
||||
|
||||
if (exit_code !== 0) {
|
||||
throw new Error(
|
||||
stderr.trim() ||
|
||||
stdout.trim() ||
|
||||
`tar failed (exit ${exit_code})`,
|
||||
);
|
||||
}
|
||||
// The archive's snapshots would otherwise vanish with the
|
||||
// current ones, so the safety copy is taken before them.
|
||||
const safety = await writeDataBackupArchive();
|
||||
|
||||
// The archive's own name may differ from its file name: an
|
||||
// upload is saved under a fresh timestamp. Its one root
|
||||
// folder is what holds the data, so find it rather than
|
||||
// assume it matches.
|
||||
const source_dir = findArchiveRoot({ staging_dir });
|
||||
// Everything goes, the snapshots in `db/.backups` included:
|
||||
// the archive's own set replaces them.
|
||||
for (const entry of fs.readdirSync(DATA_DIR)) {
|
||||
fs.rmSync(path.join(DATA_DIR, entry), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(source_dir, "db"))) {
|
||||
throw new Error(
|
||||
`The archive has no database, so nothing was restored`,
|
||||
);
|
||||
}
|
||||
for (const entry of fs.readdirSync(source_dir)) {
|
||||
fs.cpSync(
|
||||
path.join(source_dir, entry),
|
||||
path.join(DATA_DIR, entry),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
}
|
||||
|
||||
// The archive's snapshots would otherwise vanish with the
|
||||
// current ones, so the safety copy is taken before them.
|
||||
// The run lock is already held here, hence the unlocked
|
||||
// writer rather than `createDataBackup`.
|
||||
const safety = await writeDataBackupArchive();
|
||||
|
||||
// Everything goes, the snapshots in `db/.backups` included:
|
||||
// the archive's own set replaces them.
|
||||
for (const entry of fs.readdirSync(DATA_DIR)) {
|
||||
fs.rmSync(path.join(DATA_DIR, entry), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(source_dir)) {
|
||||
fs.cpSync(
|
||||
path.join(source_dir, entry),
|
||||
path.join(DATA_DIR, entry),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
safety_name: safety.name,
|
||||
restored_name: archive_name,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(staging_dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
return {
|
||||
safety_name: safety.name,
|
||||
restored_name: archive_name,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(staging_dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,17 @@ export type CreatedDataBackup = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes one archive of the whole `DATA_DIR` and prunes the old ones.
|
||||
* Callers already hold the backup run lock (`withBackupRunLock`), so a
|
||||
* restore can take its safety copy without deadlocking on it.
|
||||
* Writes one archive of the whole `DATA_DIR` (database, media, computer
|
||||
* workspaces, bot and workspace files, MCP installs) into
|
||||
* `BACKUP_ARCHIVE_DIR` and keeps only the newest `AppData.DataBackupsKept`.
|
||||
* Every entry sits under one root folder named after the archive, so
|
||||
* extracting it lands one folder. The SQLite file inside is only as
|
||||
* consistent as the moment tar read it; `db/.backups` is for consistent
|
||||
* snapshots.
|
||||
*
|
||||
* It takes no lock: callers run inside `startDataBackupRun`, which holds
|
||||
* the backup run lock (`withBackupRunLock`) for the whole run, so the
|
||||
* archive route and a restore's safety copy never take it twice.
|
||||
*/
|
||||
export default async function writeDataBackupArchive(): Promise<CreatedDataBackup> {
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
|
||||
Reference in New Issue
Block a user