mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): preserve adopted compose lineage
This commit is contained in:
@@ -49,6 +49,7 @@ export interface LocalSqliteRestoreOptions extends LocalSqliteDatabaseOptions {
|
||||
readonly replacedDatabasePath: string;
|
||||
readonly expectedCurrentSha256: string;
|
||||
readonly expectedSourceSha256: string;
|
||||
readonly preserveDatabaseIdentity?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalSqliteRestoreEvidence
|
||||
@@ -58,6 +59,7 @@ export interface LocalSqliteRestoreEvidence
|
||||
|
||||
export interface LocalSqliteRestoreDependencies {
|
||||
readonly copySnapshot?: (sourcePath: string, targetPath: string) => void;
|
||||
readonly rewriteSnapshot?: (sourcePath: string, targetPath: string) => void;
|
||||
}
|
||||
|
||||
export interface LocalSqliteChangeObserver {
|
||||
@@ -582,6 +584,239 @@ function sameSnapshot(
|
||||
);
|
||||
}
|
||||
|
||||
function rewriteSnapshotFilePreservingIdentity(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
): void {
|
||||
const targetIdentity = fs.lstatSync(targetPath, { bigint: true });
|
||||
let sourceDescriptor: number | undefined;
|
||||
let targetDescriptor: number | undefined;
|
||||
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||
try {
|
||||
sourceDescriptor = fs.openSync(
|
||||
sourcePath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
targetDescriptor = fs.openSync(
|
||||
targetPath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
const openedTarget = fs.fstatSync(targetDescriptor, { bigint: true });
|
||||
if (
|
||||
!openedTarget.isFile() ||
|
||||
openedTarget.dev !== targetIdentity.dev ||
|
||||
openedTarget.ino !== targetIdentity.ino ||
|
||||
openedTarget.nlink !== 1n
|
||||
) {
|
||||
configurationError('identity-preserving restore target drifted');
|
||||
}
|
||||
for (;;) {
|
||||
const count = fs.readSync(
|
||||
sourceDescriptor,
|
||||
buffer,
|
||||
0,
|
||||
buffer.byteLength,
|
||||
null,
|
||||
);
|
||||
if (count === 0) break;
|
||||
let offset = 0;
|
||||
while (offset < count) {
|
||||
const written = fs.writeSync(
|
||||
targetDescriptor,
|
||||
buffer,
|
||||
offset,
|
||||
count - offset,
|
||||
null,
|
||||
);
|
||||
if (written < 1) {
|
||||
configurationError('identity-preserving restore write stalled');
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
}
|
||||
fs.fsyncSync(targetDescriptor);
|
||||
const writtenTarget = fs.fstatSync(targetDescriptor, { bigint: true });
|
||||
if (
|
||||
writtenTarget.dev !== targetIdentity.dev ||
|
||||
writtenTarget.ino !== targetIdentity.ino ||
|
||||
writtenTarget.nlink !== 1n
|
||||
) {
|
||||
configurationError('identity-preserving restore target changed');
|
||||
}
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
if (targetDescriptor !== undefined) fs.closeSync(targetDescriptor);
|
||||
if (sourceDescriptor !== undefined) fs.closeSync(sourceDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSnapshotPreservingIdentity(
|
||||
options: Readonly<LocalSqliteRestoreOptions>,
|
||||
dependencies: LocalSqliteRestoreDependencies,
|
||||
input: Readonly<{
|
||||
uid: number;
|
||||
databasePath: string;
|
||||
sourceSnapshotPath: string;
|
||||
restoreStagePath: string;
|
||||
replacedDatabasePath: string;
|
||||
source: Readonly<LocalSqliteSnapshotEvidence>;
|
||||
}>,
|
||||
): Promise<Readonly<LocalSqliteRestoreEvidence>> {
|
||||
if (!fs.existsSync(input.databasePath)) {
|
||||
configurationError(
|
||||
'identity-preserving restore requires the activated database inode',
|
||||
);
|
||||
}
|
||||
const targetIdentity = fs.lstatSync(input.databasePath);
|
||||
if (
|
||||
!targetIdentity.isFile() ||
|
||||
targetIdentity.isSymbolicLink() ||
|
||||
targetIdentity.uid !== input.uid ||
|
||||
(targetIdentity.mode & 0o777) !== 0o600 ||
|
||||
targetIdentity.nlink !== 1 ||
|
||||
fs.realpathSync(input.databasePath) !== input.databasePath
|
||||
) {
|
||||
configurationError('identity-preserving restore database is invalid');
|
||||
}
|
||||
let current: Readonly<LocalSqliteSnapshotEvidence> | undefined;
|
||||
try {
|
||||
current = await inspectSnapshotFile(
|
||||
input.databasePath,
|
||||
options.profile,
|
||||
input.uid,
|
||||
'restore current database',
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!fs.existsSync(input.restoreStagePath) ||
|
||||
!fs.existsSync(input.replacedDatabasePath)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const restoredAtEntry =
|
||||
current !== undefined && sameSnapshot(current, input.source);
|
||||
if (
|
||||
current !== undefined &&
|
||||
!restoredAtEntry &&
|
||||
current.sha256 !== options.expectedCurrentSha256
|
||||
) {
|
||||
configurationError('restore current database drifted');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(input.restoreStagePath) && !restoredAtEntry) {
|
||||
try {
|
||||
(
|
||||
dependencies.copySnapshot ??
|
||||
((sourcePath: string, targetPath: string) =>
|
||||
fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL))
|
||||
)(input.sourceSnapshotPath, input.restoreStagePath);
|
||||
fs.chmodSync(input.restoreStagePath, 0o600);
|
||||
syncFile(input.restoreStagePath);
|
||||
syncDirectory(path.dirname(input.restoreStagePath));
|
||||
} catch (error) {
|
||||
if (fs.existsSync(input.restoreStagePath)) {
|
||||
try {
|
||||
fs.unlinkSync(input.restoreStagePath);
|
||||
syncDirectory(path.dirname(input.restoreStagePath));
|
||||
} catch {
|
||||
// A deterministic stage remains fail-closed for exact replay.
|
||||
}
|
||||
}
|
||||
configurationError('restore stage could not be created', error);
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(input.restoreStagePath)) {
|
||||
const staged = await inspectSnapshotFile(
|
||||
input.restoreStagePath,
|
||||
options.profile,
|
||||
input.uid,
|
||||
'restore stage',
|
||||
);
|
||||
if (!sameSnapshot(staged, input.source)) {
|
||||
configurationError('restore stage drifted');
|
||||
}
|
||||
}
|
||||
|
||||
if (!restoredAtEntry && !fs.existsSync(input.replacedDatabasePath)) {
|
||||
if (current === undefined) {
|
||||
configurationError('restore replacement evidence is unavailable');
|
||||
}
|
||||
try {
|
||||
fs.copyFileSync(
|
||||
input.databasePath,
|
||||
input.replacedDatabasePath,
|
||||
fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
fs.chmodSync(input.replacedDatabasePath, 0o600);
|
||||
syncFile(input.replacedDatabasePath);
|
||||
syncDirectory(path.dirname(input.replacedDatabasePath));
|
||||
} catch (error) {
|
||||
configurationError(
|
||||
'restore replacement evidence cannot be created',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(input.replacedDatabasePath)) {
|
||||
const replaced = await inspectSnapshotFile(
|
||||
input.replacedDatabasePath,
|
||||
options.profile,
|
||||
input.uid,
|
||||
'replaced database',
|
||||
);
|
||||
if (replaced.sha256 !== options.expectedCurrentSha256) {
|
||||
configurationError('replaced database evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
if (!restoredAtEntry) {
|
||||
try {
|
||||
(dependencies.rewriteSnapshot ?? rewriteSnapshotFilePreservingIdentity)(
|
||||
input.restoreStagePath,
|
||||
input.databasePath,
|
||||
);
|
||||
} catch (error) {
|
||||
configurationError(
|
||||
'identity-preserving restore write could not complete',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
const afterIdentity = validateDatabaseFile(
|
||||
input.databasePath,
|
||||
input.uid,
|
||||
'identity-preserving restored database',
|
||||
);
|
||||
if (
|
||||
afterIdentity.dev !== targetIdentity.dev ||
|
||||
afterIdentity.ino !== targetIdentity.ino
|
||||
) {
|
||||
configurationError('identity-preserving restore changed the target inode');
|
||||
}
|
||||
const restored = await inspectSnapshotFile(
|
||||
input.databasePath,
|
||||
options.profile,
|
||||
input.uid,
|
||||
'restored database',
|
||||
);
|
||||
if (!sameSnapshot(restored, input.source)) {
|
||||
configurationError('restored database drifted');
|
||||
}
|
||||
for (const evidencePath of [
|
||||
input.restoreStagePath,
|
||||
input.replacedDatabasePath,
|
||||
]) {
|
||||
if (!fs.existsSync(evidencePath)) continue;
|
||||
fs.unlinkSync(evidencePath);
|
||||
syncDirectory(path.dirname(evidencePath));
|
||||
}
|
||||
return Object.freeze({
|
||||
status: restoredAtEntry ? ('existing' as const) : ('restored' as const),
|
||||
...restored,
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreLocalSqliteSnapshot(
|
||||
options: Readonly<LocalSqliteRestoreOptions>,
|
||||
dependencies: LocalSqliteRestoreDependencies = {},
|
||||
@@ -608,6 +843,8 @@ export async function restoreLocalSqliteSnapshot(
|
||||
!DIGEST_PATTERN.test(options.expectedCurrentSha256) ||
|
||||
!DIGEST_PATTERN.test(options.expectedSourceSha256) ||
|
||||
options.expectedCurrentSha256 === options.expectedSourceSha256 ||
|
||||
(options.preserveDatabaseIdentity !== undefined &&
|
||||
typeof options.preserveDatabaseIdentity !== 'boolean') ||
|
||||
new Set([
|
||||
databasePath,
|
||||
sourceSnapshotPath,
|
||||
@@ -641,6 +878,17 @@ export async function restoreLocalSqliteSnapshot(
|
||||
}
|
||||
assertNoRestoreSidecars(databasePath, uid);
|
||||
|
||||
if (options.preserveDatabaseIdentity === true) {
|
||||
return restoreSnapshotPreservingIdentity(options, dependencies, {
|
||||
uid,
|
||||
databasePath,
|
||||
sourceSnapshotPath,
|
||||
restoreStagePath,
|
||||
replacedDatabasePath,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
let restoredAtEntry = false;
|
||||
if (fs.existsSync(databasePath)) {
|
||||
const current = await inspectSnapshotFile(
|
||||
|
||||
@@ -210,6 +210,72 @@ test('converges the moved-current restore window and cleans ENOSPC stage', async
|
||||
assert.equal(fs.existsSync(replacedDatabasePath), false);
|
||||
});
|
||||
|
||||
test('preserves an activated inode and recovers an ENOSPC partial rewrite', async (t) => {
|
||||
const state = fixture(t);
|
||||
await migrateLocalSqlitePath(state);
|
||||
const activated = fs.statSync(state.databasePath, { bigint: true });
|
||||
const source = await createLocalSqliteRolloutBackup(state);
|
||||
const writer = new DatabaseSync(state.databasePath);
|
||||
writer.exec('PRAGMA user_version = 29');
|
||||
writer.close();
|
||||
const current = await checkpointLocalSqliteForRestore(state);
|
||||
const restoreStagePath = path.join(state.root, '.identity.restore-stage');
|
||||
const replacedDatabasePath = path.join(
|
||||
path.dirname(state.backupPath),
|
||||
'identity.replaced.sqlite',
|
||||
);
|
||||
const restoreOptions = {
|
||||
databasePath: state.databasePath,
|
||||
profile: state.profile,
|
||||
sourceSnapshotPath: state.backupPath,
|
||||
restoreStagePath,
|
||||
replacedDatabasePath,
|
||||
expectedCurrentSha256: current.sha256,
|
||||
expectedSourceSha256: source.sha256,
|
||||
preserveDatabaseIdentity: true,
|
||||
};
|
||||
await assert.rejects(
|
||||
restoreLocalSqliteSnapshot(restoreOptions, {
|
||||
rewriteSnapshot(_sourcePath, targetPath) {
|
||||
const descriptor = fs.openSync(
|
||||
targetPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_TRUNC |
|
||||
fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
fs.writeSync(descriptor, Buffer.from('partial'));
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
throw Object.assign(new Error('injected identity restore ENOSPC'), {
|
||||
code: 'ENOSPC',
|
||||
});
|
||||
},
|
||||
}),
|
||||
/identity-preserving restore write could not complete/,
|
||||
);
|
||||
assert.equal(fs.existsSync(restoreStagePath), true);
|
||||
assert.equal(fs.existsSync(replacedDatabasePath), true);
|
||||
const partial = fs.statSync(state.databasePath, { bigint: true });
|
||||
assert.equal(partial.dev, activated.dev);
|
||||
assert.equal(partial.ino, activated.ino);
|
||||
|
||||
const recovered = await restoreLocalSqliteSnapshot(restoreOptions);
|
||||
assert.equal(recovered.status, 'restored');
|
||||
assert.equal(recovered.sha256, source.sha256);
|
||||
const restored = fs.statSync(state.databasePath, { bigint: true });
|
||||
assert.equal(restored.dev, activated.dev);
|
||||
assert.equal(restored.ino, activated.ino);
|
||||
assert.equal(fs.existsSync(restoreStagePath), false);
|
||||
assert.equal(fs.existsSync(replacedDatabasePath), false);
|
||||
assert.equal(
|
||||
(await restoreLocalSqliteSnapshot(restoreOptions)).status,
|
||||
'existing',
|
||||
);
|
||||
});
|
||||
|
||||
test('rollout safety subpath excludes DDL and mutable repositories', () => {
|
||||
const script = `
|
||||
const safety = require(${JSON.stringify(
|
||||
|
||||
Reference in New Issue
Block a user