@xen-orchestra/backups 0.68.2 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RemoteAdapter.mjs +3 -3
- package/_cleanVm.mjs +22 -4
- package/_runners/_vmRunners/FullRemote.mjs +3 -2
- package/_runners/_vmRunners/FullXapi.mjs +3 -1
- package/_runners/_vmRunners/IncrementalRemote.mjs +3 -2
- package/_runners/_vmRunners/IncrementalXapi.mjs +8 -1
- package/_runners/_vmRunners/_AbstractRemote.mjs +27 -12
- package/_runners/_vmRunners/_AbstractXapi.mjs +58 -22
- package/_runners/_writers/AggregatedFullRemoteWriter.mjs +38 -0
- package/_runners/_writers/AggregatedFullXapiWriter.mjs +31 -0
- package/_runners/_writers/AggregatedIncrementalRemoteWriter.mjs +78 -0
- package/_runners/_writers/AggregatedIncrementalXapiWriter.mjs +85 -0
- package/_runners/_writers/FullRemoteWriter.mjs +3 -3
- package/_runners/_writers/FullXapiWriter.mjs +3 -3
- package/_runners/_writers/IncrementalRemoteWriter.mjs +2 -2
- package/_runners/_writers/IncrementalXapiWriter.mjs +4 -2
- package/_runners/_writers/_AbstractAggregatedRemoteWriter.mjs +157 -0
- package/_runners/_writers/_AbstractAggregatedXapiWriter.mjs +114 -0
- package/_runners/_writers/_listReplicatedVms.mjs +10 -2
- package/disks/MergeRemoteDisk.mjs +317 -0
- package/disks/RemoteDisk.mjs +204 -0
- package/disks/RemoteVhdDisk.mjs +458 -0
- package/disks/RemoteVhdDiskChain.mjs +271 -0
- package/disks/openDiskChain.mjs +8 -3
- package/package.json +2 -2
- package/disks/RemoteVhd.mjs +0 -171
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { asyncEach } from '@vates/async-each'
|
|
2
|
+
import { getOldEntries } from '../../_getOldEntries.mjs'
|
|
3
|
+
import { compareTimestamp } from '../../RemoteAdapter.mjs'
|
|
4
|
+
import { createLogger } from '@xen-orchestra/log'
|
|
5
|
+
|
|
6
|
+
const { debug } = createLogger('xo:backups:AbstractAggregatedRemoteWriter')
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY = 2
|
|
9
|
+
export class AbstractAggregatedRemoteWriter {
|
|
10
|
+
#BackupWriter
|
|
11
|
+
#adapters
|
|
12
|
+
get adapters() {
|
|
13
|
+
return this.#adapters
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
mainWriter
|
|
17
|
+
#props
|
|
18
|
+
get props() {
|
|
19
|
+
return this.#props
|
|
20
|
+
}
|
|
21
|
+
#oldBackups
|
|
22
|
+
|
|
23
|
+
#writers = []
|
|
24
|
+
get writers() {
|
|
25
|
+
return this.#writers
|
|
26
|
+
}
|
|
27
|
+
constructor({ adapters, BackupWriter, ...props }) {
|
|
28
|
+
debug('instantiate AggregatedFullRemoteWriter')
|
|
29
|
+
if (Object.keys(adapters).length < 1) {
|
|
30
|
+
throw new Error('AggregatedRemoteWriter need at least one backup repository to work ')
|
|
31
|
+
}
|
|
32
|
+
this.#props = props
|
|
33
|
+
this.#adapters = adapters
|
|
34
|
+
this.#BackupWriter = BackupWriter
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getWriterByAdapter(adapter) {
|
|
38
|
+
for (const writer of this.#writers) {
|
|
39
|
+
if (writer._adapter === adapter) {
|
|
40
|
+
return writer
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error("Can't find writer for adapter")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* select the best adapter candidate to write this backup
|
|
48
|
+
* if all the adapter infos expose an available info use the one with the more free space
|
|
49
|
+
* if none of the adapter exposes this info : choose at random
|
|
50
|
+
* if mixed : throw an error
|
|
51
|
+
* @returns {RemoteAdapter}
|
|
52
|
+
*/
|
|
53
|
+
async computeAdapterCandidate() {
|
|
54
|
+
const adapters = Object.values(this.#adapters)
|
|
55
|
+
const infos = await Promise.all(
|
|
56
|
+
adapters.map(async adapter => {
|
|
57
|
+
const infos = await adapter._handler.getInfo()
|
|
58
|
+
return { adapter, infos }
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
const withLimit = infos.filter(({ infos: { available } }) => available > 0).length
|
|
62
|
+
if (withLimit !== 0 && withLimit !== infos.length) {
|
|
63
|
+
throw new Error(`an Aggregate remote can't use a mix of limited and unlimited storages`)
|
|
64
|
+
}
|
|
65
|
+
let candidates
|
|
66
|
+
if (withLimit === 0) {
|
|
67
|
+
debug('no storage limit , choose at random')
|
|
68
|
+
candidates = Object.values(this.adapters)
|
|
69
|
+
} else {
|
|
70
|
+
debug('storage limit , choose at random between those with the max space avilable')
|
|
71
|
+
let maxSpace = 0
|
|
72
|
+
infos.forEach(({ infos: { available } }) => {
|
|
73
|
+
if (available > maxSpace) {
|
|
74
|
+
maxSpace = available
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// candidates are the adapter with the same free space , equal to the max free space
|
|
79
|
+
candidates = infos.filter(({ infos }) => infos.available === maxSpace).map(({ adapter }) => adapter)
|
|
80
|
+
}
|
|
81
|
+
const adapterCandidate = candidates[Math.floor(Math.random() * candidates.length)]
|
|
82
|
+
debug('adapter candidate is selected ', adapterCandidate._handler._remote.id)
|
|
83
|
+
return adapterCandidate
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async setupWriters() {
|
|
87
|
+
debug('Setup writers ')
|
|
88
|
+
const settings = this.#props.settings
|
|
89
|
+
const allSettings = this.#props.job.settings
|
|
90
|
+
|
|
91
|
+
Object.entries(this.#adapters).forEach(([remoteId, adapter]) => {
|
|
92
|
+
const targetSettings = {
|
|
93
|
+
...settings,
|
|
94
|
+
...allSettings[remoteId],
|
|
95
|
+
skipDeleteOldEntries: true, // delete is handled globally
|
|
96
|
+
}
|
|
97
|
+
if (targetSettings.exportRetention !== 0) {
|
|
98
|
+
const writer = new this.#BackupWriter({
|
|
99
|
+
...this.#props,
|
|
100
|
+
adapter,
|
|
101
|
+
remoteId,
|
|
102
|
+
settings: targetSettings,
|
|
103
|
+
})
|
|
104
|
+
this.#writers.push(writer)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getEntriesPerAdapter(adapter) {
|
|
110
|
+
const scheduleId = this.#props.scheduleId
|
|
111
|
+
const vmUuid = this.#props.vmUuid
|
|
112
|
+
return (await adapter.listVmBackups(vmUuid, _ => _.scheduleId === scheduleId)).map(entry => ({
|
|
113
|
+
...entry,
|
|
114
|
+
adapter,
|
|
115
|
+
}))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async setOldBackups() {
|
|
119
|
+
const entriesPerAdapter = await Promise.all(
|
|
120
|
+
Object.values(this.#adapters).map(adapter => this.getEntriesPerAdapter(adapter))
|
|
121
|
+
)
|
|
122
|
+
const entries = entriesPerAdapter
|
|
123
|
+
.flat(1)
|
|
124
|
+
.filter(_ => !!_)
|
|
125
|
+
.sort(compareTimestamp)
|
|
126
|
+
|
|
127
|
+
const settings = this.#props.settings
|
|
128
|
+
this.#oldBackups = getOldEntries(settings.exportRetention - 1, entries, {
|
|
129
|
+
longTermRetention: settings.longTermRetention,
|
|
130
|
+
timezone: settings.timezone,
|
|
131
|
+
})
|
|
132
|
+
debug(`got ${this.#oldBackups.length} old backup(s) to delete `)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async deleteOldBackupsOnAdapter(adapter, backup) {
|
|
136
|
+
throw new Error('not implemented')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async deleteOldBackups() {
|
|
140
|
+
const byAdapters = new Map()
|
|
141
|
+
this.#oldBackups.forEach(({ adapter, ...backup }) => {
|
|
142
|
+
const current = byAdapters.has(adapter) ? byAdapters.get(adapter) : []
|
|
143
|
+
current.push(backup)
|
|
144
|
+
byAdapters.set(adapter, current)
|
|
145
|
+
})
|
|
146
|
+
await asyncEach(
|
|
147
|
+
byAdapters.entries(),
|
|
148
|
+
async ([adapter, backups]) => {
|
|
149
|
+
debug(`delete ${backups.length} old backup(s) to delete from ${adapter._handler._remote.id}`)
|
|
150
|
+
await this.deleteOldBackupsOnAdapter(adapter, backups)
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
concurrency: this.#props.deleteConcurrency ?? DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY,
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { getOldEntries } from '../../_getOldEntries.mjs'
|
|
2
|
+
import { createLogger } from '@xen-orchestra/log'
|
|
3
|
+
import { compareReplicatedVmDatetime, listReplicatedVms } from './_listReplicatedVms.mjs'
|
|
4
|
+
import { asyncMapSettled } from '@xen-orchestra/async-map'
|
|
5
|
+
|
|
6
|
+
const { debug } = createLogger('xo:backups:AbstractAggregatedXapiWriter')
|
|
7
|
+
export class AbstractAggregatedXapiWriter {
|
|
8
|
+
#ReplicationWriter
|
|
9
|
+
#storageRepositories
|
|
10
|
+
|
|
11
|
+
#writers = []
|
|
12
|
+
get writers() {
|
|
13
|
+
return this.#writers
|
|
14
|
+
}
|
|
15
|
+
mainWriter
|
|
16
|
+
#props
|
|
17
|
+
get props() {
|
|
18
|
+
return this.#props
|
|
19
|
+
}
|
|
20
|
+
#oldVmReplicaList = []
|
|
21
|
+
|
|
22
|
+
constructor({ srs, ReplicationWriter, ...props }) {
|
|
23
|
+
debug('instantiate AbstractAggregatedXapiWriter')
|
|
24
|
+
if (srs.length < 1) {
|
|
25
|
+
throw new Error('AggregatedXapiWriter need at least one storage repository to work ')
|
|
26
|
+
}
|
|
27
|
+
this.#props = props
|
|
28
|
+
this.#ReplicationWriter = ReplicationWriter
|
|
29
|
+
this.#storageRepositories = srs
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getWriterBySr(storageRepository) {
|
|
33
|
+
for (const writer of this.#writers) {
|
|
34
|
+
if (writer._sr === storageRepository) {
|
|
35
|
+
return writer
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error("Can't find writer for storage repository")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async computeSRCandidate() {
|
|
42
|
+
let maxSpace = 0
|
|
43
|
+
this.#storageRepositories.forEach(({ physical_utilisation, physical_size }) => {
|
|
44
|
+
if (physical_size - physical_utilisation > maxSpace) {
|
|
45
|
+
maxSpace = physical_size - physical_utilisation
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
debug(`computeSRCandidate found the max free space ${maxSpace} for vm${this.#props.vmUuid}`)
|
|
49
|
+
|
|
50
|
+
const srCandidates = this.#storageRepositories.filter(
|
|
51
|
+
({ physical_utilisation, physical_size }) => maxSpace === physical_size - physical_utilisation
|
|
52
|
+
)
|
|
53
|
+
// one at random from the sr with the most free space
|
|
54
|
+
const candidate = srCandidates[Math.floor(Math.random() * srCandidates.length)]
|
|
55
|
+
|
|
56
|
+
debug(
|
|
57
|
+
`computeSRCandidate found ${srCandidates.length} sr with ${maxSpace} free size, and chose ${candidate.name_label} for vm${this.#props.vmUuid}`
|
|
58
|
+
)
|
|
59
|
+
return candidate
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setOldReplicaList() {
|
|
63
|
+
debug(`setOldReplicaList for vm ${this.#props.vmUuid}`)
|
|
64
|
+
const scheduleId = this.#props.scheduleId
|
|
65
|
+
const vmUuid = this.#props.vmUuid
|
|
66
|
+
const settings = this.#props.settings
|
|
67
|
+
|
|
68
|
+
const replicatedVms = this.#storageRepositories
|
|
69
|
+
.map(sr => {
|
|
70
|
+
return listReplicatedVms(sr.$xapi, scheduleId, sr.uuid, vmUuid)
|
|
71
|
+
})
|
|
72
|
+
.flat(1)
|
|
73
|
+
.filter(_ => !!_)
|
|
74
|
+
.sort(compareReplicatedVmDatetime)
|
|
75
|
+
|
|
76
|
+
this.#oldVmReplicaList = getOldEntries(settings.copyRetention - 1, replicatedVms)
|
|
77
|
+
|
|
78
|
+
debug(
|
|
79
|
+
`setOldReplicaList found ${replicatedVms.length} replica, and will delete ${this.#oldVmReplicaList.length} (retention ${settings.copyRetention}) for vm ${this.#props.vmUuid}`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async deleteOldReplicas() {
|
|
84
|
+
debug(`deleteOldReplicas will delete ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
|
|
85
|
+
// destroy the VM on the right xapi
|
|
86
|
+
await asyncMapSettled(this.#oldVmReplicaList, async vm => {
|
|
87
|
+
debug('will delete old replica', vm.name_label)
|
|
88
|
+
await vm.$xapi.VM_destroy(vm.$ref)
|
|
89
|
+
})
|
|
90
|
+
debug(`deleteOldReplicas deleted ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async setupWriters() {
|
|
94
|
+
debug('Setup writers ')
|
|
95
|
+
const settings = this.#props.settings
|
|
96
|
+
const allSettings = this.#props.job.settings
|
|
97
|
+
|
|
98
|
+
this.#storageRepositories.forEach(storageRepository => {
|
|
99
|
+
const targetSettings = {
|
|
100
|
+
...settings,
|
|
101
|
+
...allSettings[storageRepository.uuid],
|
|
102
|
+
skipDeleteOldEntries: true, // delete is handled globally
|
|
103
|
+
}
|
|
104
|
+
if (targetSettings.copyRetention !== 0) {
|
|
105
|
+
const writer = new this.#ReplicationWriter({
|
|
106
|
+
...this.#props,
|
|
107
|
+
sr: storageRepository,
|
|
108
|
+
settings: targetSettings,
|
|
109
|
+
})
|
|
110
|
+
this.#writers.push(writer)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -5,10 +5,18 @@ const getReplicatedVmDatetime = vm => {
|
|
|
5
5
|
return datetime
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
|
|
8
|
+
export const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
*
|
|
12
|
+
* @param {Xapi} xapi
|
|
13
|
+
* @param {string} scheduleOrJobId
|
|
14
|
+
* @param {string} srUuid
|
|
15
|
+
* @param {string} vmUuid
|
|
16
|
+
* @returns {Array<XoVm>}
|
|
17
|
+
*/
|
|
10
18
|
export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
|
|
11
|
-
const
|
|
19
|
+
const all = xapi.objects.indexes.type.VM
|
|
12
20
|
const vms = {}
|
|
13
21
|
for (const key in all) {
|
|
14
22
|
const object = all[key]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
|
|
5
|
+
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from 'assert'
|
|
9
|
+
import { createLogger } from '@xen-orchestra/log'
|
|
10
|
+
|
|
11
|
+
import { basename, dirname } from 'path'
|
|
12
|
+
import { asyncEach } from '@vates/async-each'
|
|
13
|
+
|
|
14
|
+
// @ts-ignore
|
|
15
|
+
const { warn } = createLogger('remote-disk:merge')
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} MergeState
|
|
19
|
+
* @property {{ uuid: string }} child
|
|
20
|
+
* @property {{ uuid: string }} parent
|
|
21
|
+
* @property {number} currentBlock
|
|
22
|
+
* @property {number} mergedDataSize
|
|
23
|
+
* @property {'mergeBlocks' | 'cleanup'} step
|
|
24
|
+
* @property {number} diskSize
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export class MergeRemoteDisk {
|
|
28
|
+
/**
|
|
29
|
+
* @type {MergeState}
|
|
30
|
+
*/
|
|
31
|
+
#state = {
|
|
32
|
+
child: { uuid: '0' },
|
|
33
|
+
parent: { uuid: '0' },
|
|
34
|
+
currentBlock: 0,
|
|
35
|
+
mergedDataSize: 0,
|
|
36
|
+
step: 'mergeBlocks',
|
|
37
|
+
diskSize: 0,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @type {string}
|
|
42
|
+
*/
|
|
43
|
+
#statePath = ''
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @type {boolean}
|
|
47
|
+
*/
|
|
48
|
+
#isResuming
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @type {Logger | Function}
|
|
52
|
+
*/
|
|
53
|
+
#logInfo
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @type {number}
|
|
57
|
+
*/
|
|
58
|
+
#mergeBlockConcurrency
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @type {Function}
|
|
62
|
+
*/
|
|
63
|
+
#onProgress
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @type {boolean}
|
|
67
|
+
*/
|
|
68
|
+
#removeUnused
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @type {number}
|
|
72
|
+
*/
|
|
73
|
+
#writeStateDelay
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @type {number}
|
|
77
|
+
*/
|
|
78
|
+
#lastStateWrittenAt
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @type {FileAccessor}
|
|
82
|
+
*/
|
|
83
|
+
#handler
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {FileAccessor} handler
|
|
87
|
+
* @param {Object} params
|
|
88
|
+
* @param {Function} params.onProgress
|
|
89
|
+
* @param {Logger | Function} params.logInfo
|
|
90
|
+
* @param {boolean} params.removeUnused
|
|
91
|
+
* @param {number} params.mergeBlockConcurrency
|
|
92
|
+
* @param {number} params.writeStateDelay
|
|
93
|
+
*/
|
|
94
|
+
constructor(
|
|
95
|
+
handler,
|
|
96
|
+
{
|
|
97
|
+
onProgress = () => {},
|
|
98
|
+
logInfo = () => {},
|
|
99
|
+
removeUnused = false,
|
|
100
|
+
mergeBlockConcurrency = 2,
|
|
101
|
+
writeStateDelay = 10e3,
|
|
102
|
+
}
|
|
103
|
+
) {
|
|
104
|
+
this.#handler = handler
|
|
105
|
+
this.#logInfo = logInfo
|
|
106
|
+
this.#onProgress = onProgress
|
|
107
|
+
this.#removeUnused = removeUnused
|
|
108
|
+
this.#mergeBlockConcurrency = mergeBlockConcurrency
|
|
109
|
+
this.#writeStateDelay = writeStateDelay
|
|
110
|
+
|
|
111
|
+
this.#isResuming = false
|
|
112
|
+
this.#lastStateWrittenAt = 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {RemoteDisk} parentDisk
|
|
117
|
+
* @returns {Promise<boolean>} isResuming
|
|
118
|
+
*/
|
|
119
|
+
async isResuming(parentDisk) {
|
|
120
|
+
try {
|
|
121
|
+
await this.#handler.readFile(
|
|
122
|
+
dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
|
|
123
|
+
)
|
|
124
|
+
return true
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {RemoteDisk} parentDisk
|
|
132
|
+
* @param {RemoteDisk} childDisk
|
|
133
|
+
*/
|
|
134
|
+
async merge(parentDisk, childDisk) {
|
|
135
|
+
this.#statePath = dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const mergeStateContent = await this.#handler.readFile(this.#statePath)
|
|
139
|
+
this.#state = JSON.parse(mergeStateContent)
|
|
140
|
+
|
|
141
|
+
// work-around a bug introduce in 97d94b795
|
|
142
|
+
//
|
|
143
|
+
// currentBlock could be `null` due to the JSON.stringify of a `NaN` value
|
|
144
|
+
if (this.#state?.currentBlock === null) this.#state.currentBlock = 0
|
|
145
|
+
this.#isResuming = true
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// @ts-ignore
|
|
148
|
+
if (error.code !== 'ENOENT') {
|
|
149
|
+
warn('problem while checking the merge state', { error })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
/* eslint-disable no-fallthrough */
|
|
155
|
+
switch (this.#state?.step ?? 'mergeBlocks') {
|
|
156
|
+
case 'mergeBlocks':
|
|
157
|
+
await this.#step_mergeBlocks(parentDisk, childDisk)
|
|
158
|
+
case 'cleanup':
|
|
159
|
+
await this.#step_cleanup(parentDisk, childDisk)
|
|
160
|
+
return this.#cleanup(parentDisk, childDisk, true)
|
|
161
|
+
default:
|
|
162
|
+
warn(`Step ${this.#state?.step} is unknown`, { state: this.#state })
|
|
163
|
+
}
|
|
164
|
+
/* eslint-enable no-fallthrough */
|
|
165
|
+
} catch (error) {
|
|
166
|
+
await this.#cleanup(parentDisk, childDisk, false)
|
|
167
|
+
throw error
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async #writeState() {
|
|
172
|
+
try {
|
|
173
|
+
await this.#handler.writeFile(this.#statePath, JSON.stringify(this.#state), { flags: 'w' })
|
|
174
|
+
} catch (err) {
|
|
175
|
+
warn('failed to write merge state', { error: err })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async #writeStateThrottled() {
|
|
180
|
+
const now = Date.now()
|
|
181
|
+
if (now - this.#lastStateWrittenAt > this.#writeStateDelay) {
|
|
182
|
+
this.#lastStateWrittenAt = now
|
|
183
|
+
await this.#writeState()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {RemoteDisk} parentDisk
|
|
189
|
+
* @param {RemoteDisk} childDisk
|
|
190
|
+
*/
|
|
191
|
+
async #step_mergeBlocks(parentDisk, childDisk) {
|
|
192
|
+
const getMaxBlockCount = childDisk.getMaxBlockCount()
|
|
193
|
+
|
|
194
|
+
if (this.#isResuming) {
|
|
195
|
+
const alreadyMergedBlocks = []
|
|
196
|
+
for (let blockId = 0; blockId < this.#state.currentBlock; blockId++) {
|
|
197
|
+
if (childDisk.hasBlock(blockId)) {
|
|
198
|
+
alreadyMergedBlocks.push(blockId)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
parentDisk.setAllocatedBlocks(alreadyMergedBlocks)
|
|
203
|
+
} else {
|
|
204
|
+
this.#state.child = { uuid: childDisk.getUuid() ?? 0 }
|
|
205
|
+
this.#state.parent = { uuid: parentDisk.getUuid() ?? 0 }
|
|
206
|
+
|
|
207
|
+
// Finds first allocated block for the 2 following loops
|
|
208
|
+
while (this.#state.currentBlock < getMaxBlockCount && !childDisk.hasBlock(this.#state.currentBlock)) {
|
|
209
|
+
++this.#state.currentBlock
|
|
210
|
+
}
|
|
211
|
+
await this.#writeState()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await this.#mergeBlocks(parentDisk, childDisk)
|
|
215
|
+
await parentDisk.flushMetadata(childDisk)
|
|
216
|
+
parentDisk.mergeMetadata(childDisk)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {RemoteDisk} parentDisk
|
|
221
|
+
* @param {RemoteDisk} childDisk
|
|
222
|
+
*/
|
|
223
|
+
async #mergeBlocks(parentDisk, childDisk) {
|
|
224
|
+
this.#mergeBlockConcurrency =
|
|
225
|
+
(await parentDisk.canMergeConcurently()) && (await childDisk.canMergeConcurently())
|
|
226
|
+
? this.#mergeBlockConcurrency
|
|
227
|
+
: 1
|
|
228
|
+
|
|
229
|
+
const toMerge = []
|
|
230
|
+
for (let block = this.#state.currentBlock; block < childDisk.getMaxBlockCount(); block++) {
|
|
231
|
+
if (childDisk.hasBlock(block)) {
|
|
232
|
+
toMerge.push(block)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const nBlocks = toMerge.length
|
|
237
|
+
this.#onProgress({ total: nBlocks, done: 0 })
|
|
238
|
+
|
|
239
|
+
const merging = new Set()
|
|
240
|
+
let counter = 0
|
|
241
|
+
await asyncEach(
|
|
242
|
+
toMerge,
|
|
243
|
+
async blockId => {
|
|
244
|
+
merging.add(blockId)
|
|
245
|
+
|
|
246
|
+
const blockSize = await parentDisk.mergeBlock(childDisk, blockId, this.#isResuming)
|
|
247
|
+
this.#state.mergedDataSize += blockSize
|
|
248
|
+
|
|
249
|
+
this.#state.currentBlock = Math.min(...merging) - 1
|
|
250
|
+
merging.delete(blockId)
|
|
251
|
+
|
|
252
|
+
this.#onProgress({ total: nBlocks, done: counter + 1 })
|
|
253
|
+
counter++
|
|
254
|
+
await this.#writeStateThrottled()
|
|
255
|
+
},
|
|
256
|
+
{ concurrency: this.#mergeBlockConcurrency }
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
await this.#writeState()
|
|
260
|
+
|
|
261
|
+
this.#state.diskSize = childDisk.getSizeOnDisk()
|
|
262
|
+
|
|
263
|
+
this.#onProgress({ total: nBlocks, done: nBlocks })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {RemoteDisk} parentDisk
|
|
268
|
+
* @param {RemoteDisk} childDisk
|
|
269
|
+
*/
|
|
270
|
+
async #step_cleanup(parentDisk, childDisk) {
|
|
271
|
+
assert.notEqual(this.#state, undefined)
|
|
272
|
+
this.#state.step = 'cleanup'
|
|
273
|
+
await this.#writeState()
|
|
274
|
+
|
|
275
|
+
const mergeTargetPath = childDisk.getPath()
|
|
276
|
+
|
|
277
|
+
// delete intermediate children if needed
|
|
278
|
+
if (this.#removeUnused) {
|
|
279
|
+
await childDisk.unlink()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await parentDisk.rename(mergeTargetPath)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
// @ts-ignore
|
|
286
|
+
if (error.code === 'ENOENT' && this.#isResuming) {
|
|
287
|
+
// @ts-ignore
|
|
288
|
+
this.#logInfo(`the parent disk was already renamed`, {
|
|
289
|
+
parent: parentDisk.getPath(),
|
|
290
|
+
mergeTarget: mergeTargetPath,
|
|
291
|
+
})
|
|
292
|
+
} else {
|
|
293
|
+
throw error
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {RemoteDisk} parentDisk
|
|
300
|
+
* @param {RemoteDisk} childDisk
|
|
301
|
+
* @param {boolean} cleanStateFile
|
|
302
|
+
*
|
|
303
|
+
* @returns {Promise<{mergedDataSize: number, finalDiskSize: number}>} result
|
|
304
|
+
*/
|
|
305
|
+
async #cleanup(parentDisk, childDisk, cleanStateFile) {
|
|
306
|
+
const finalDiskSize = this.#state?.diskSize ?? 0
|
|
307
|
+
const mergedDataSize = this.#state?.mergedDataSize ?? 0
|
|
308
|
+
await parentDisk.close().catch(warn)
|
|
309
|
+
await childDisk.close().catch(warn)
|
|
310
|
+
|
|
311
|
+
if (cleanStateFile) {
|
|
312
|
+
await this.#handler.unlink(this.#statePath).catch(warn)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { mergedDataSize, finalDiskSize }
|
|
316
|
+
}
|
|
317
|
+
}
|