mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
backup restore slopfix
This commit is contained in:
@@ -17,6 +17,20 @@ const DATABASE_NAMES = ['configuration.sqlite', 'identity.sqlite', 'fleet-report
|
||||
const DATABASE_FILES = new Set(DATABASE_NAMES.flatMap((name) => [name, `${name}-wal`, `${name}-shm`]));
|
||||
const FILE_COPY_ATTEMPTS = 3;
|
||||
|
||||
async function removeSnapshotSidecars(payloadDir) {
|
||||
/*
|
||||
SQLite online backup produces a complete standalone main database file.
|
||||
Reopening that snapshot to inspect its schema can still create empty WAL
|
||||
and shared-memory coordination files because the database retains WAL as
|
||||
its journal mode. Those files describe no durable backup content and must
|
||||
be removed before the manifest inventory and tar archive are produced.
|
||||
*/
|
||||
await Promise.all(DATABASE_NAMES.flatMap((name) => [
|
||||
fsp.rm(path.join(payloadDir, `${name}-wal`), { force: true }),
|
||||
fsp.rm(path.join(payloadDir, `${name}-shm`), { force: true }),
|
||||
]));
|
||||
}
|
||||
|
||||
function readDatabaseSchemaVersions(payloadDir) {
|
||||
const configuration = new Database(path.join(payloadDir, 'configuration.sqlite'), { readonly: true });
|
||||
const identity = new Database(path.join(payloadDir, 'identity.sqlite'), { readonly: true });
|
||||
@@ -148,13 +162,20 @@ async function createFullBackup({ configurationDatabase, identityService, fleetR
|
||||
fleetReportService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[2])),
|
||||
]);
|
||||
const skippedUnstableFiles = await copyDurableTree(dataDir, payloadDir);
|
||||
/*
|
||||
Finish every operation that can create a staged file before inventorying
|
||||
the payload. Production databases use WAL mode, so schema inspection must
|
||||
precede both sidecar cleanup and the final immutable file list.
|
||||
*/
|
||||
const databaseSchemaVersions = readDatabaseSchemaVersions(payloadDir);
|
||||
await removeSnapshotSidecars(payloadDir);
|
||||
const files = await listManifestFiles(payloadDir);
|
||||
const manifest = {
|
||||
format: 'multirover-full-backup',
|
||||
formatVersion: FORMAT_VERSION,
|
||||
applicationVersion: packageInfo.version,
|
||||
createdAt: Date.now(),
|
||||
databaseSchemaVersions: readDatabaseSchemaVersions(payloadDir),
|
||||
databaseSchemaVersions,
|
||||
files,
|
||||
skippedUnstableFiles,
|
||||
};
|
||||
@@ -173,5 +194,6 @@ module.exports = {
|
||||
FORMAT_VERSION,
|
||||
createFullBackup,
|
||||
readDatabaseSchemaVersions,
|
||||
removeSnapshotSidecars,
|
||||
sha256File,
|
||||
};
|
||||
|
||||
@@ -19,8 +19,16 @@ const startupRestore = require('./startupRestore');
|
||||
const VALID_RESTORE_ID = 'a'.repeat(64);
|
||||
|
||||
function createSourceDatabase(name) {
|
||||
const filePath = path.join(temporaryRoot, `source-${name}`);
|
||||
/*
|
||||
Real service databases use WAL mode. Keeping fixture sources under the
|
||||
excluded runtime directory both mirrors that behavior and ensures their
|
||||
own live WAL files are not mistaken for ordinary durable backup content.
|
||||
*/
|
||||
const sourceDirectory = path.join(temporaryRoot, 'runtime', 'database-sources');
|
||||
fs.mkdirSync(sourceDirectory, { recursive: true });
|
||||
const filePath = path.join(sourceDirectory, name);
|
||||
const database = new Database(filePath);
|
||||
database.pragma('journal_mode = WAL');
|
||||
database.exec('CREATE TABLE example (value TEXT NOT NULL); INSERT INTO example VALUES (\'preserved\');');
|
||||
if (name === 'configuration.sqlite') {
|
||||
database.exec('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); INSERT INTO schema_migrations VALUES (1);');
|
||||
@@ -58,6 +66,7 @@ test('creates and validates a complete backup while excluding runtime and contro
|
||||
assert.ok(result.manifest.files.some((entry) => entry.path === 'replays/complete.mp4'));
|
||||
assert.equal(result.manifest.files.some((entry) => entry.path.includes('runtime')), false);
|
||||
assert.equal(result.manifest.files.some((entry) => entry.path.includes('backup-restore')), false);
|
||||
assert.equal(result.manifest.files.some((entry) => entry.path.endsWith('-wal') || entry.path.endsWith('-shm')), false);
|
||||
|
||||
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
|
||||
await fsp.mkdir(restoreJob, { recursive: true });
|
||||
@@ -65,6 +74,8 @@ test('creates and validates a complete backup while excluding runtime and contro
|
||||
await fsp.copyFile(result.archivePath, uploadedArchive);
|
||||
const summary = await prepareRestoreArchive({ archivePath: uploadedArchive, jobDir: restoreJob, actor: 'test' });
|
||||
assert.equal(summary.fileCount, result.manifest.files.length);
|
||||
const restoredNames = await fsp.readdir(path.join(restoreJob, 'extracted', 'data'));
|
||||
assert.equal(restoredNames.some((name) => name.endsWith('-wal') || name.endsWith('-shm')), false);
|
||||
|
||||
sources.forEach((database) => database.close());
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
DATABASE_NAMES,
|
||||
FORMAT_VERSION,
|
||||
readDatabaseSchemaVersions,
|
||||
removeSnapshotSidecars,
|
||||
sha256File,
|
||||
} = require('./backup');
|
||||
|
||||
@@ -128,7 +129,22 @@ async function validateExtractedRestore(extractDir) {
|
||||
|
||||
const expected = [...manifest.files].sort((left, right) => String(left.path).localeCompare(String(right.path)));
|
||||
const actual = await listExtractedFiles(payloadDir);
|
||||
if (expected.length !== actual.length) throw new Error('Backup file inventory does not match the archive.');
|
||||
if (expected.length !== actual.length) {
|
||||
/*
|
||||
Keep the strict complete-inventory check, but identify a few differences
|
||||
so an operator can distinguish a missing file from an unexpected archive
|
||||
entry without weakening restore validation or exposing file contents.
|
||||
*/
|
||||
const expectedPaths = new Set(expected.map((entry) => entry.path));
|
||||
const actualPaths = new Set(actual.map((entry) => entry.path));
|
||||
const missing = expected.filter((entry) => !actualPaths.has(entry.path)).map((entry) => entry.path).slice(0, 5);
|
||||
const unexpected = actual.filter((entry) => !expectedPaths.has(entry.path)).map((entry) => entry.path).slice(0, 5);
|
||||
const details = [
|
||||
missing.length ? `missing: ${missing.join(', ')}` : '',
|
||||
unexpected.length ? `unexpected: ${unexpected.join(', ')}` : '',
|
||||
].filter(Boolean).join('; ');
|
||||
throw new Error(`Backup file inventory does not match the archive${details ? ` (${details})` : ''}.`);
|
||||
}
|
||||
for (let index = 0; index < expected.length; index += 1) {
|
||||
const wanted = expected[index];
|
||||
const found = actual[index];
|
||||
@@ -155,6 +171,12 @@ async function validateExtractedRestore(extractDir) {
|
||||
throw new Error(`Backup ${databaseName} database is newer than this application supports.`);
|
||||
}
|
||||
}
|
||||
/*
|
||||
Integrity and schema reads can create fresh WAL coordination files even
|
||||
though the uploaded snapshot initially matched its manifest exactly. Remove
|
||||
those validation-only files so startup applies only inventoried content.
|
||||
*/
|
||||
await removeSnapshotSidecars(payloadDir);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user