fix(backups): fall back to a full backup if it's possible that the base is broken

This commit is contained in:
Florent BEAUCHAMP
2026-07-24 10:07:59 -04:00
committed by Florent BEAUCHAMP
parent f8df78c03d
commit 453ffc98d2
4 changed files with 96 additions and 14 deletions

View File

@@ -73,7 +73,9 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
writer =>
writer.transfer({
deltaExport: forkDeltaExport(deltaExport, writer.constructor.name),
// freshly exported from XAPI by fixed code → disk data is known-good
// freshly exported from XAPI by code that has the fix and the force-full gate
// (writer.checkBaseVdis ran before this transfer), so the chain is verified/forced
// clean; the tip's flag lets the next run skip re-probing
includeNonNbdQcow2Fix: true,
isVhdDifferencing,
timestamp,

View File

@@ -50,15 +50,8 @@ export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteW
await Promise.all(this.writers.map(writer => writer.prepare({ isFull })))
}
// write only on the main writer
transfer({ includeNonNbdQcow2Fix, isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
return this.mainWriter.transfer({
includeNonNbdQcow2Fix,
isVhdDifferencing,
timestamp,
deltaExport,
vm,
vmSnapshot,
})
transfer(args) {
return this.mainWriter.transfer(args)
}
// remove the backups and remove the entries

View File

@@ -16,6 +16,8 @@ import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
import { checkVhd } from './_checkVhd.mjs'
import { packUuid } from './_packUuid.mjs'
import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
import { VDI_FORMAT_QCOW2 } from '@xen-orchestra/xapi'
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
@@ -61,12 +63,95 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
if (parentDestPath === undefined) {
baseUuidToSrcVdi.delete(baseUuid)
} else {
this.#parentVdiPaths[vhdDir] = parentDestPath
this.#parentUuids[vhdDir] = parentUuid
try {
// A delta is only chained onto a qcow2 base when NBD is enabled; without NBD a qcow2
// export falls back to a full, so there is no suspect base to worry about.
if (this._settings.preferNbd && !(await this.#isBaseChainable(parentDestPath))) {
Task.warning('forcing a full: qcow2 base predates the non-NBD qcow2 fix and looks corrupt', {
parentDestPath,
})
baseUuidToSrcVdi.delete(baseUuid)
return
}
this.#parentVdiPaths[vhdDir] = parentDestPath
this.#parentUuids[vhdDir] = parentUuid
} catch (error) {
warn('checkBaseVdis: unexpected error, forcing a full', { error, parentDestPath })
baseUuidToSrcVdi.delete(baseUuid)
}
}
})
}
// Decide whether it is safe to chain a delta onto `parentDestPath` (the chain tip).
// A qcow2 full exported before the DiskLargerBlock 20260724 fix can be silently corrupt (block 0
// valid, following allocated blocks all zero); chaining a delta onto it would extend a
// broken chain, so we detect that case and let the caller fall back to a full.
async #isBaseChainable(parentDestPath) {
// parentDestPath is the chain tip = most-recent backup; its filename is stable under
// merges (merges rename the OLDER disks), so unlike the root it maps reliably to its
// metadata json.
const parentDate = basename(parentDestPath).replace(/\.(alias\.)?vhd$/, '')
let metadata
try {
metadata = await this._adapter.readVmBackupMetadata(`${this._vmBackupDir}/${parentDate}.json`)
} catch (error) {
warn('checkBaseVdis: cannot read parent metadata, will probe', { error, parentDestPath })
}
if (metadata?.includeNonNbdQcow2Fix === true) {
// written by code that has the fix and this gate, so its chain was already
// verified/forced clean — no disk open needed
// we can't have includeNonNbdQcow2Fix = false since it would have restarted a clean chain
return true
}
const isQcow2 =
metadata === undefined ||
Object.values(metadata.vdis ?? {}).some(vdi => vdi?.sm_config?.['image-format'] === VDI_FORMAT_QCOW2)
if (!isQcow2) {
// only qcow2 exports can carry this corruption
return true
}
return this.#baseHasData(parentDestPath)
}
// Probe the first data blocks of the chain root for the DiskLargerBlock corruption
// signature. Returns false only when enough allocated blocks past the first are all zero.
async #baseHasData(parentDestPath) {
const { handler } = this._adapter
// open the chain (with block allocation tables) and probe its root full. This only runs
// once per chain — afterwards the includeNonNbdQcow2Fix flag on the tip skips it — so
// reading the deltas' BATs here is negligible and lets us reuse the root disk directly.
const chain = await openDiskChain({ handler, path: parentDestPath })
try {
const root = chain.getRootDisk()
if (root === undefined) {
throw new Error(`can't resolve root of chain ${parentDestPath}`)
}
const empty = Buffer.alloc(root.getBlockSize(), 0)
const MAX_EMPTY = 10
let nbEmpty = 0
for (let i = 1; i < root.getMaxBlockCount(); i++) {
if (!root.hasBlock(i)) {
continue
}
const { data } = await root.readBlock(i)
if (!data.equals(empty)) {
// real data → not the corruption signature
return true
}
if (++nbEmpty > MAX_EMPTY) {
// enough allocated-but-zero blocks → looks corrupt
return false
}
}
// too few allocated blocks past the first to conclude (also covers sparse/tiny disks)
// a full should not be too costly
return false
} finally {
await chain.close()
}
}
async beforeBackup() {
await super.beforeBackup()
return this._cleanVm({ merge: true, remove: true })

View File

@@ -15,8 +15,8 @@
> Users must be able to say: "I had this issue, happy to know it's fixed"
- [Backups] Fix qcow2 transfer without NBD (PR [#10164](https://github.com/vatesfr/xen-orchestra/pull/10164))
- [Backups] Force a full backup if any suspect is detected on qcow2 without nbd (PR [#10164](https://github.com/vatesfr/xen-orchestra/pull/10164))
- [Backups] Fix aggregated backup failing instead of falling back to a full backup (PR [#10164](https://github.com/vatesfr/xen-orchestra/pull/10164))
### Packages to release
@@ -33,5 +33,7 @@
> Keep this list alphabetically ordered to avoid merge conflicts
<!--packages-start-->
- @xen-orchestra/backup-archive patch
- @xen-orchestra/backups patch
- @xen-orchestra/disk-transform patch
<!--packages-end-->