Compare commits

...

25 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
79865673da feat; filter block with zeroes ont incremental replication 2026-10-05 06:33:44 +00:00
Florent BEAUCHAMP
7432823adb better handling of starting a progress handler 2026-10-04 18:21:45 +00:00
Florent BEAUCHAMP
eb284cda81 optimize zero filter by doing 2 passes 2026-10-03 20:02:18 +00:00
Florent BEAUCHAMP
bb3d0017c1 remove sync traces by default 2026-10-03 19:39:26 +00:00
Florent BEAUCHAMP
e55cb82ec5 implement a fast mode for compression removing only zéroes 2026-10-03 19:21:24 +00:00
Florent BEAUCHAMP
7334b170b8 use onRead callback instead of readcjunk =>+10% 2026-10-03 19:21:01 +00:00
Florent BEAUCHAMP
9fbc16edaa tune up compression buffers 2026-10-03 17:49:44 +00:00
Florent BEAUCHAMP
d0e6097086 feat: add progress for nbd import 2026-10-03 17:12:27 +00:00
Florent BEAUCHAMP
c984e29108 changelog 2026-10-03 14:01:51 +00:00
Florent BEAUCHAMP
03b44eef37 more cleanup in qa test 2026-10-03 14:01:01 +00:00
Florent BEAUCHAMP
b833581a9c remove zeroes compression (useless) and cleanup 2026-10-03 13:51:28 +00:00
Florent BEAUCHAMP
cc74b2383d add a fast comrpession mode filtering out zeroes 2026-10-03 13:03:48 +00:00
Florent BEAUCHAMP
90d838450b increase chunk size to compress/decompress a full block at once 2026-10-03 12:50:25 +00:00
Florent BEAUCHAMP
0bd578aee9 add zstd support 2026-10-03 12:46:58 +00:00
Florent BEAUCHAMP
35b2c5ca8b feat: add NBD for restore/import 2026-10-03 12:24:18 +00:00
Florent BEAUCHAMP
0b14824883 put a 10MB high watermark on full mirror 2026-10-03 10:50:07 +00:00
Florent BEAUCHAMP
44a137bb0c checksum is expensive, reuse it when possible during mirror full 2026-10-03 10:43:20 +00:00
Florent BEAUCHAMP
93f434a9a1 mirror: don't decompress and recompress block if possibleé 2026-10-03 10:11:37 +00:00
Florent BEAUCHAMP
6ac515be8a one more buffer concat removed 2026-10-03 10:00:49 +00:00
Florent BEAUCHAMP
f180f9730d also improve encryption outputfile 2026-10-03 09:06:38 +00:00
Florent BEAUCHAMP
5123eb443d push the block concat to the consumer 2026-10-03 08:52:40 +00:00
Florent BEAUCHAMP
f010299af8 remove prefix, usse writev instead 2026-10-03 08:40:17 +00:00
Florent BEAUCHAMP
c5556f5f68 feat: add mode without any write 2026-10-01 15:15:27 +00:00
Florent BEAUCHAMP
ca2522e04b fix; properly support insecure nbd 2026-10-01 14:58:07 +00:00
Florent BEAUCHAMP
66bea770b1 feat(backups): improve performances
don't recreate buffer
don't increase the size of buffer for the bitmap
use custom plugin for the export
2026-10-01 13:45:42 +00:00
69 changed files with 4547 additions and 349 deletions

View File

@@ -42,6 +42,24 @@ const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
### Writes
If the export is not read-only, the clients can also write. The capabilities announced by the server are known
once connected: `client.readOnly`, `client.canFlush`, `client.canTrim` and `client.canWriteZeroes`.
```js
await client.connect()
// data must be BlockSize long, shorter for the last block of the export
await client.writeBlock(blockIndex, data, BlockSize)
await client.writeZeroes(otherBlockIndex, BlockSize) // zeroes, without sending them
await client.flush() // the acknowledged writes are on a permanent storage
await client.disconnect()
```
A write refused by the server rejects with an error whose `code` is the errno name (`ENOSPC`, `EPERM`, ...) and
`nbdError` its value: the connection stays usable. A write which failed because of the connection is retried after a
reconnection, like a read.
There is no TLS here: the transport is a pair of pipes, there is nothing to
encrypt.

View File

@@ -1,14 +1,22 @@
import assert from 'node:assert'
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { pRetry, pDelay, pTimeout, pFromCallback, TimeoutError } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
INIT_PASSWD,
NBD_CMD_DISC,
NBD_CMD_FLUSH,
NBD_CMD_READ,
NBD_CMD_TRIM,
NBD_CMD_WRITE,
NBD_CMD_WRITE_ZEROES,
NBD_DEFAULT_BLOCK_SIZE,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_FLAG_READ_ONLY,
NBD_FLAG_SEND_FLUSH,
NBD_FLAG_SEND_TRIM,
NBD_FLAG_SEND_WRITE_ZEROES,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_REPLY_ACK,
@@ -21,6 +29,34 @@ const { debug, warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
// what is received once a connection can't be parsed anymore is written there, then ignored
const DISCARD_BUFFER = Buffer.alloc(64 * 1024)
// error codes of the protocol, the same values as the Linux errno
const NBD_ERRORS = {
1: 'EPERM',
5: 'EIO',
12: 'ENOMEM',
22: 'EINVAL',
28: 'ENOSPC',
75: 'EOVERFLOW',
95: 'ENOTSUP',
108: 'ESHUTDOWN',
}
/**
* The server refused a request, the connection is still usable
*
* @param {number} nbdError
*/
function createServerError(nbdError) {
const code = NBD_ERRORS[nbdError]
const error = new Error(`the NBD server answered the error ${nbdError} (${code ?? 'unknown'})`)
error.code = code
error.nbdError = nbdError
return error
}
/**
* A pair of streams carrying the NBD protocol.
*
@@ -30,8 +66,207 @@ const { debug, warn } = createLogger('vates:nbd-client')
* @typedef {object} NbdTransport
* @property {import('node:stream').Readable} readable - where the server answers are read from
* @property {import('node:stream').Writable} writable - where the client queries are written to
* @property {(receiver: (chunk: Buffer, length: number) => void) => void} [setReceiver] - optional: after the
* handshake, the transport hands the received data directly to `receiver` instead of emitting it through
* `readable` (no allocation per chunk). `chunk` may be reused once `receiver` returns: it must be consumed
* synchronously. Data still buffered in `readable` must be handed to `receiver` first.
* @property {(receiver: {nextReadTarget(): Buffer, received(length: number): void, feed(chunk: Buffer, length: number): void}) => void} [setDirectReceiver]
* - optional, preferred to `setReceiver`: after the handshake, the transport writes each received data straight
* into the buffer given by `receiver.nextReadTarget()` (the rest of a header, or of the payload of a read, no copy),
* then calls `receiver.received(length)`. Data still buffered in `readable` must be handed to `receiver.feed()` first.
*/
/**
* Parses the answers of the transmission phase of one connection, and copies
* their payload straight into the buffer of the matching query.
*
* One receiver per connection: data received late on a previous connection
* can't be mixed with the queries of the current one.
*
* Only simple replies are expected: with a payload for NBD_CMD_READ, without for
* the other commands.
*/
class ReplyReceiver {
// AFAIK, there is no guaranty the server answers in the same order as the queries
// map of command waiting for a response queryId => { size/*in byte*/, target, resolve, reject}
#backlog = new Map()
#header = Buffer.alloc(16)
#headerLength = 0
/** @type {{ query: object, buffer: Buffer, filled: number } | undefined} */
#current
/** @type {Error | undefined} once failed, the stream can't be parsed anymore */
#error
#messageTimeout
/** @type {NodeJS.Timeout | undefined} */
#timer
constructor(messageTimeout) {
this.#messageTimeout = messageTimeout
}
get error() {
return this.#error
}
add(queryId, query) {
if (this.#error !== undefined) {
query.reject(this.#error)
return
}
this.#backlog.set(queryId, query)
this.#watch()
}
delete(queryId) {
this.#backlog.delete(queryId)
this.#watch()
}
// reject everything pending and stop parsing: the next answers are not trustworthy anymore
fail(error) {
if (this.#error === undefined) {
this.#error = error
}
const current = this.#current
this.#current = undefined
current?.query.reject(error)
this.#backlog.forEach(({ reject }) => reject(error))
this.#backlog.clear()
this.#watch()
}
/**
* @param {Buffer} chunk
* @param {number} length - only the first `length` bytes of `chunk` are data
*/
feed(chunk, length) {
let offset = 0
while (offset < length && this.#error === undefined) {
const current = this.#current
if (current === undefined) {
const n = Math.min(16 - this.#headerLength, length - offset)
chunk.copy(this.#header, this.#headerLength, offset, offset + n)
this.#headerLength += n
offset += n
if (this.#headerLength === 16) {
this.#headerLength = 0
this.#onHeader()
}
} else {
const n = Math.min(current.buffer.length - current.filled, length - offset)
chunk.copy(current.buffer, current.filled, offset, offset + n)
current.filled += n
offset += n
if (current.filled === current.buffer.length) {
this.#current = undefined
current.query.resolve(current.buffer)
}
}
}
this.#watch()
}
/**
* Direct reception (see NbdTransport#setDirectReceiver): where the next received bytes must be written, the rest
* of the header being received or the rest of the payload, straight into the buffer of its query
*
* @returns {Buffer} never empty
*/
nextReadTarget() {
if (this.#error !== undefined) {
// the stream can't be parsed anymore: what comes is discarded
return DISCARD_BUFFER
}
const current = this.#current
return current === undefined ? this.#header.subarray(this.#headerLength) : current.buffer.subarray(current.filled)
}
/**
* Direct reception: `length` bytes have been written into the buffer returned by nextReadTarget()
*
* @param {number} length
*/
received(length) {
if (this.#error === undefined) {
const current = this.#current
if (current === undefined) {
this.#headerLength += length
if (this.#headerLength === 16) {
this.#headerLength = 0
this.#onHeader()
}
} else {
current.filled += length
if (current.filled === current.buffer.length) {
this.#current = undefined
current.query.resolve(current.buffer)
}
}
}
this.#watch()
}
#onHeader() {
const header = this.#header
const magic = header.readInt32BE(0)
if (magic !== NBD_REPLY_MAGIC) {
this.fail(new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`))
return
}
const error = header.readInt32BE(4)
const queryId = header.readBigUInt64BE(8)
const query = this.#backlog.get(queryId)
if (error !== 0) {
if (query?.noPayload) {
// nothing follows the header: only this query failed, the next answers can still be parsed
this.#backlog.delete(queryId)
query.reject(createServerError(error))
return
}
// a simple reply to a read does not tell if its data follow: the stream can't be parsed anymore
this.fail(new Error(`GOT ERROR CODE : ${error}`))
return
}
if (query === undefined) {
this.fail(new Error(` no query associated with id ${queryId}`))
return
}
this.#backlog.delete(queryId)
if (query.noPayload) {
query.resolve()
return
}
// the payload is written once, at its final place
const buffer = query.target ?? Buffer.allocUnsafe(query.size)
if (buffer.length === 0) {
query.resolve(buffer)
} else {
this.#current = { query, buffer, filled: 0 }
}
}
// the server must make progress while some queries are waiting for their answer
#watch() {
const waiting = this.#current !== undefined || this.#backlog.size > 0
if (!waiting) {
clearTimeout(this.#timer)
this.#timer = undefined
} else if (this.#timer === undefined) {
this.#timer = setTimeout(() => {
this.#timer = undefined
this.fail(new TimeoutError())
}, this.#messageTimeout)
} else {
this.#timer.refresh()
}
}
}
/**
* Transport agnostic NBD client: it implements the handshake and the
* transmission phases, and delegates the opening/closing of the underlying
@@ -45,6 +280,7 @@ const { debug, warn } = createLogger('vates:nbd-client')
export default class AbstractNbdClient {
#exportName
#exportSize
#transmissionFlags = 0
/** @type {NbdTransport|undefined} */
#transport
@@ -60,13 +296,9 @@ export default class AbstractNbdClient {
#connectTimeout
#messageTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listener waiting for a response
#nextCommandQueryId = BigInt(0)
// map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
#commandQueryBacklog = new Map()
/** @type {ReplyReceiver|undefined} parses the answers of the current connection */
#receiver
#connected = false
#reconnectingPromise
@@ -78,7 +310,7 @@ export default class AbstractNbdClient {
* @param {number} [options.connectTimeout]
* @param {number} [options.messageTimeout]
* @param {number} [options.waitBeforeReconnect]
* @param {number} [options.readBlockRetries]
* @param {number} [options.readBlockRetries] - tries of a read or a write, reconnecting between them
* @param {number} [options.reconnectRetry]
*/
constructor(
@@ -107,6 +339,23 @@ export default class AbstractNbdClient {
return this.#exportName
}
// the capabilities announced by the server for this export, known once connected
get readOnly() {
return (this.#transmissionFlags & NBD_FLAG_READ_ONLY) !== 0
}
get canFlush() {
return (this.#transmissionFlags & NBD_FLAG_SEND_FLUSH) !== 0
}
get canTrim() {
return (this.#transmissionFlags & NBD_FLAG_SEND_TRIM) !== 0
}
get canWriteZeroes() {
return (this.#transmissionFlags & NBD_FLAG_SEND_WRITE_ZEROES) !== 0
}
get connected() {
return this.#connected
}
@@ -202,7 +451,7 @@ export default class AbstractNbdClient {
* connection
* ------------------------------------------------------------------------- */
#onTransportError = error => {
#onTransportError(error, receiver) {
// without this listener an error on the transport would be thrown as an
// uncaught exception, and pending reads would only fail on message timeout
//
@@ -211,13 +460,38 @@ export default class AbstractNbdClient {
// through the connect()/readBlock() rejection
const log = this.#connected ? warn : debug
log('error on the nbd transport', { error })
this.#rejectAll(error)
receiver.fail(error)
}
#watchTransport(transport) {
transport.readable.on('error', this.#onTransportError)
#watchTransport(transport, receiver) {
const onError = error => this.#onTransportError(error, receiver)
transport.readable.on('error', onError)
if (transport.writable !== transport.readable) {
transport.writable.on('error', this.#onTransportError)
transport.writable.on('error', onError)
}
}
// transmission phase: the answers are parsed as they come, without waiting for a read() of their size
#startReceiving(transport, receiver) {
const onClose = () => {
const error = new Error('the nbd transport has been closed')
error.code = 'NBD_TRANSPORT_CLOSED'
receiver.fail(error)
}
transport.readable.on('end', onClose)
transport.readable.on('close', onClose)
if (transport.writable !== transport.readable) {
transport.writable.on('close', onClose)
}
if (transport.setDirectReceiver !== undefined) {
transport.setDirectReceiver(receiver)
} else if (transport.setReceiver !== undefined) {
transport.setReceiver((chunk, length) => receiver.feed(chunk, length))
} else {
transport.readable.on('data', chunk => receiver.feed(chunk, chunk.length))
// the handshake used read() with 'readable' listeners: explicitly switch the stream to flowing
transport.readable.resume()
}
}
@@ -227,10 +501,12 @@ export default class AbstractNbdClient {
// not cancel on timeout, so a superseded attempt must not be able to use,
// replace or destroy the transport of the attempt which replaced it
let transport = await this._openTransport()
this.#watchTransport(transport)
// each connection has its own receiver: late answers on a previous one can't reach the queries of this one
const receiver = new ReplyReceiver(this.#messageTimeout)
this.#watchTransport(transport, receiver)
try {
// the transport can be replaced during the handshake (TLS upgrade)
transport = await this.#handshake(transport)
transport = await this.#handshake(transport, receiver)
if (generation !== this.#connectGeneration) {
const error = new Error('this connection has been superseded by a newer one')
@@ -244,10 +520,9 @@ export default class AbstractNbdClient {
throw error
}
this.#transport = transport
this.#receiver = receiver
this.#connected = true
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
this.#startReceiving(transport, receiver)
}
async connect() {
@@ -366,9 +641,10 @@ export default class AbstractNbdClient {
//
/**
* @param {NbdTransport} transport
* @param {ReplyReceiver} receiver
* @returns {Promise<NbdTransport>}
*/
async #handshake(transport) {
async #handshake(transport, receiver) {
assert((await this.#read(transport, 8)).equals(INIT_PASSWD))
assert((await this.#read(transport, 8)).equals(OPTS_MAGIC))
const flagsBuffer = await this.#read(transport, 2)
@@ -379,7 +655,7 @@ export default class AbstractNbdClient {
// let the subclass upgrade the transport if it needs to (TLS)
const secured = await this._secureTransport(transport)
if (secured !== transport) {
this.#watchTransport(secured)
this.#watchTransport(secured, receiver)
transport = secured
}
@@ -397,8 +673,8 @@ export default class AbstractNbdClient {
this.#exportSize = answer.readBigUInt64BE(0)
const transmissionFlags = answer.readInt16BE(8)
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
this.#transmissionFlags = transmissionFlags
return transport
}
@@ -461,57 +737,7 @@ export default class AbstractNbdClient {
* transmission
* ------------------------------------------------------------------------- */
// when one read fail ,stop everything
// (sync: it must be usable from an event handler without leaving a floating promise)
#rejectAll(error) {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
}
async #readBlockResponse() {
// ensure at most one read occur in parallel
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const transport = this.#getTransport()
const buffer = await this.#read(transport, 16)
const magic = buffer.readInt32BE(0)
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = buffer.readInt32BE(4)
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = buffer.readBigUInt64BE(8)
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
}
this.#commandQueryBacklog.delete(blockQueryId)
const data = await this.#read(transport, query.size)
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
// we don't need to call readBlockResponse on failure
// since we will empty the backlog
this.#rejectAll(error)
}
}
async #readBlock(index, size) {
async #readBlock(index, size, target) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
@@ -543,30 +769,164 @@ export default class AbstractNbdClient {
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
const transport = this.#getTransport()
const receiver = this.#receiver
// the answer can come in any order, the receiver matches it with this query
receiver.add(queryId, {
size,
// the payload is copied directly into it, the last block of the export may be shorter
target: target?.subarray(0, size),
resolve,
reject: decoratedReject,
})
// really send the command to the server
this.#write(this.#getTransport(), buffer).catch(decoratedReject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
this.#write(transport, buffer).catch(error => {
receiver.delete(queryId)
decoratedReject(error)
})
})
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
return pRetry(() => this.#readBlock(index, size), {
/**
* @param {number} index
* @param {number} [size]
* @param {Buffer} [target] - optional, where to write the data instead of a newly allocated Buffer,
* must be at least `size` long. The returned Buffer is then a view on it
* @returns {Promise<Buffer>}
*/
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE, target) {
if (target !== undefined) {
assert.ok(target.length >= size, `target is too small: ${target.length} < ${size}`)
}
return this.#retry(() => this.#readBlock(index, size, target), `reading block ${index}`)
}
/**
* Sends a command whose answer has no payload
*
* @param {number} type - NBD_CMD_*
* @param {bigint} offset
* @param {number} length
* @param {Buffer} [data] - sent after the request (NBD_CMD_WRITE)
* @returns {Promise<void>}
*/
async #command(type, offset, length, data) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
const header = Buffer.alloc(28)
header.writeInt32BE(NBD_REQUEST_MAGIC, 0)
header.writeInt16BE(0, 4) // no command flags
header.writeInt16BE(type, 6)
header.writeBigUInt64BE(queryId, 8)
header.writeBigUInt64BE(offset, 16)
header.writeInt32BE(length, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.offset = offset
error.size = length
reject(error)
}
const transport = this.#getTransport()
const receiver = this.#receiver
receiver.add(queryId, { noPayload: true, resolve, reject: decoratedReject })
// the request and its data are queued at once: no other query can come in between
const sent =
data === undefined
? this.#write(transport, header)
: Promise.all([this.#write(transport, header), this.#write(transport, data)])
sent.catch(error => {
receiver.delete(queryId)
decoratedReject(error)
})
})
}
#retry(fn, description) {
return pRetry(fn, {
tries: this.#readBlockRetries,
when: error => error.code !== 'ERR_ABORTED',
// the server refused the request (nbdError): it would refuse it again
when: error => error.code !== 'ERR_ABORTED' && error.nbdError === undefined,
onRetry: async err => {
warn('will retry reading block ', index, err)
warn(`will retry ${description}`, err)
await this.reconnect()
},
})
}
// the bytes of the block `index`, the last block of the export may be shorter
#blockRange(index, size) {
const offset = BigInt(index) * BigInt(size)
assert.ok(offset < this.#exportSize, `block ${index} is beyond the end of the export`)
const remaining = this.#exportSize - offset
return { offset, length: remaining < BigInt(size) ? Number(remaining) : size }
}
#assertCapability(isCapable, description) {
if (!isCapable) {
const error = new Error(`the NBD export does not allow to ${description}`)
error.code = this.readOnly ? 'EROFS' : 'ENOTSUP'
throw error
}
}
/**
* A write is idempotent: it is retried after a reconnection like a read
*
* @param {number} index
* @param {Buffer} data - the data of the block, shorter for the last block of the export. It must not be
* modified until the returned promise is settled
* @param {number} [size] - the size of the blocks: the data are written at `index * size`
* @returns {Promise<void>}
*/
async writeBlock(index, data, size = NBD_DEFAULT_BLOCK_SIZE) {
this.#assertCapability(!this.readOnly, `write block ${index}`)
const { offset, length } = this.#blockRange(index, size)
assert.strictEqual(data.length, length, `block ${index} must be ${length} bytes, got ${data.length}`)
return this.#retry(() => this.#command(NBD_CMD_WRITE, offset, length, data), `writing block ${index}`)
}
/**
* Writes zeroes on the block `index` without sending them
*
* @param {number} index
* @param {number} [size]
* @returns {Promise<void>}
*/
async writeZeroes(index, size = NBD_DEFAULT_BLOCK_SIZE) {
this.#assertCapability(!this.readOnly && this.canWriteZeroes, `write zeroes on block ${index}`)
const { offset, length } = this.#blockRange(index, size)
return this.#retry(() => this.#command(NBD_CMD_WRITE_ZEROES, offset, length), `writing zeroes on block ${index}`)
}
/**
* Tells the server the data of the block `index` are not needed anymore, reading it afterwards gives
* undefined data
*
* @param {number} index
* @param {number} [size]
* @returns {Promise<void>}
*/
async trim(index, size = NBD_DEFAULT_BLOCK_SIZE) {
this.#assertCapability(!this.readOnly && this.canTrim, `trim block ${index}`)
const { offset, length } = this.#blockRange(index, size)
return this.#retry(() => this.#command(NBD_CMD_TRIM, offset, length), `trimming block ${index}`)
}
/**
* Resolves once all the writes acknowledged before have reached a permanent storage
*
* @returns {Promise<void>}
*/
async flush() {
this.#assertCapability(this.canFlush, 'flush')
return this.#retry(() => this.#command(NBD_CMD_FLUSH, 0n, 0), 'flushing')
}
}

View File

@@ -4,6 +4,7 @@ import { setTimeout as pSleep } from 'node:timers/promises'
import { describe, it } from 'node:test'
import AbstractNbdClient from './AbstractNbdClient.mjs'
import { NBD_CMD_WRITE } from './constants.mjs'
import { serveNbd } from './tests/fake-nbd-server.mjs'
const BLOCK_SIZE = 512
@@ -166,6 +167,57 @@ describe('AbstractNbdClient', () => {
}
})
it('reads into a given target', async () => {
const client = new InMemoryNbdClient()
await client.connect()
try {
const target = Buffer.alloc(BLOCK_SIZE + 8, 0xaa)
const block = await client.readBlock(1, BLOCK_SIZE, target.subarray(8))
assert.equal(block.buffer, target.buffer, 'the data is written in the target memory')
assert.ok(block.equals(DATA.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE)))
assert.equal(target.readUInt32BE(0), 0xaaaaaaaa, 'nothing written before the target')
// the last block of a non aligned export is shorter than the target
const last = await client.readBlock(3, BLOCK_SIZE, Buffer.alloc(BLOCK_SIZE))
assert.equal(last.length, DATA.length - 3 * BLOCK_SIZE)
assert.ok(last.equals(DATA.subarray(3 * BLOCK_SIZE)))
await assert.rejects(client.readBlock(0, BLOCK_SIZE, Buffer.alloc(BLOCK_SIZE - 1)), /target is too small/)
} finally {
await client.disconnect()
}
})
it('reassembles answers split in arbitrary chunks', async () => {
for (const chunkSize of [1, 7, 16, 17, BLOCK_SIZE + 3]) {
const client = new InMemoryNbdClient({ chunkSize, answerInReverse: true })
await client.connect()
try {
const blocks = await Promise.all([0, 1, 2, 3].map(index => client.readBlock(index, BLOCK_SIZE)))
blocks.forEach((block, index) =>
assert.ok(block.equals(DATA.subarray(index * BLOCK_SIZE, (index + 1) * BLOCK_SIZE)), `chunk ${chunkSize}`)
)
} finally {
await client.disconnect()
}
}
})
it('times out when the server stops answering', async () => {
const client = new InMemoryNbdClient(
{ stopAnsweringAfter: 1 },
{ messageTimeout: 100, readBlockRetries: 1, reconnectRetry: 1, waitBeforeReconnect: 0 }
)
await client.connect()
try {
const block = await client.readBlock(0, BLOCK_SIZE)
assert.ok(block.equals(DATA.subarray(0, BLOCK_SIZE)))
await assert.rejects(client.readBlock(1, BLOCK_SIZE), /timed out/)
} finally {
await client.disconnect()
}
})
it('does not support getMap()', async () => {
const client = new InMemoryNbdClient()
await assert.rejects(client.getMap(), ({ code }) => code === 'NBD_MAP_UNSUPPORTED')
@@ -175,3 +227,204 @@ describe('AbstractNbdClient', () => {
await new InMemoryNbdClient().disconnect()
})
})
// the first connection stops answering after the handshake, the next ones are normal
class StopAnsweringOnceClient extends AbstractNbdClient {
data
connections = 0
constructor(data, options) {
super({}, options)
this.data = data
}
async _openTransport() {
const toServer = new PassThrough()
const toClient = new PassThrough()
const stopAnsweringAfter = this.connections++ === 0 ? 0 : Infinity
serveNbd({ readable: toServer, writable: toClient, data: this.data, allowWrites: true, stopAnsweringAfter }).catch(
error => toClient.destroy(error)
)
return { readable: toClient, writable: toServer }
}
}
describe('AbstractNbdClient writes', () => {
const block = fill => Buffer.alloc(BLOCK_SIZE, fill)
it('refuses to write on a read-only export, without sending anything', async () => {
const client = new InMemoryNbdClient()
await client.connect()
try {
assert.equal(client.readOnly, true)
await assert.rejects(client.writeBlock(0, block(1), BLOCK_SIZE), { code: 'EROFS' })
await assert.rejects(client.writeZeroes(0, BLOCK_SIZE), { code: 'EROFS' })
await assert.rejects(client.flush(), { code: 'EROFS' })
assert.ok((await client.readBlock(0, BLOCK_SIZE)).equals(DATA.subarray(0, BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('writes blocks, including the shorter last one', async () => {
const data = Buffer.from(DATA)
const client = new InMemoryNbdClient({ data, allowWrites: true })
await client.connect()
try {
assert.equal(client.readOnly, false)
assert.equal(client.canFlush, true)
await client.writeBlock(1, block(0xaa), BLOCK_SIZE)
await client.writeBlock(3, Buffer.alloc(100, 0xbb), BLOCK_SIZE)
await client.flush()
assert.ok(data.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE).equals(block(0xaa)))
assert.ok((await client.readBlock(1, BLOCK_SIZE)).equals(block(0xaa)))
assert.ok((await client.readBlock(3, BLOCK_SIZE)).equals(Buffer.alloc(100, 0xbb)))
// untouched
assert.ok(data.subarray(0, BLOCK_SIZE).equals(DATA.subarray(0, BLOCK_SIZE)))
await assert.rejects(client.writeBlock(3, block(1), BLOCK_SIZE), /must be 100 bytes/)
await assert.rejects(client.writeBlock(4, block(1), BLOCK_SIZE), /beyond the end/)
} finally {
await client.disconnect()
}
})
it('handles concurrent writes and reads answered out of order and in chunks', async () => {
const data = Buffer.from(DATA)
const client = new InMemoryNbdClient({ data, allowWrites: true, answerInReverse: true, chunkSize: 7 })
await client.connect()
try {
await Promise.all([client.writeBlock(0, block(1), BLOCK_SIZE), client.writeBlock(2, block(3), BLOCK_SIZE)])
const [first, second] = await Promise.all([client.readBlock(0, BLOCK_SIZE), client.readBlock(2, BLOCK_SIZE)])
assert.ok(first.equals(block(1)))
assert.ok(second.equals(block(3)))
} finally {
await client.disconnect()
}
})
it('writes zeroes and trims', async () => {
const data = Buffer.from(DATA)
const client = new InMemoryNbdClient({ data, allowWrites: true })
await client.connect()
try {
assert.equal(client.canWriteZeroes, true)
assert.equal(client.canTrim, true)
await client.writeZeroes(0, BLOCK_SIZE)
await client.trim(1, BLOCK_SIZE)
assert.ok(data.subarray(0, 2 * BLOCK_SIZE).equals(Buffer.alloc(2 * BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('reports a write refused by the server without retrying, the connection stays usable', async () => {
const data = Buffer.from(DATA)
const client = new InMemoryNbdClient(
{ data, allowWrites: true, errorCode: ({ type }) => (type === NBD_CMD_WRITE ? 28 /* ENOSPC */ : 0) },
{ readBlockRetries: 3, reconnectRetry: 1, waitBeforeReconnect: 0 }
)
await client.connect()
try {
await assert.rejects(client.writeBlock(0, block(1), BLOCK_SIZE), { code: 'ENOSPC', nbdError: 28 })
assert.equal(client.transports.length, 1, 'not retried')
assert.ok((await client.readBlock(0, BLOCK_SIZE)).equals(DATA.subarray(0, BLOCK_SIZE)))
await client.flush()
} finally {
await client.disconnect()
}
})
it('retries a write after a reconnection when the server stops answering', async () => {
const data = Buffer.from(DATA)
const client = new StopAnsweringOnceClient(data, {
messageTimeout: 100,
readBlockRetries: 2,
reconnectRetry: 1,
waitBeforeReconnect: 0,
})
await client.connect()
try {
await client.writeBlock(2, block(9), BLOCK_SIZE)
assert.equal(client.connections, 2)
assert.ok(data.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE).equals(block(9)))
} finally {
await client.disconnect()
}
})
})
// like the onread transport of NbdTcpClient: once the handshake is done, the received data are written straight into
// the buffers asked by the parser, in pieces of variable sizes (like the reads of the kernel)
class DirectReceptionClient extends AbstractNbdClient {
#serverOptions
directReads = 0
constructor(serverOptions, options) {
super({}, options)
this.#serverOptions = serverOptions
}
async _openTransport() {
const toServer = new PassThrough()
const toClient = new PassThrough()
serveNbd({ readable: toServer, writable: toClient, data: DATA, ...this.#serverOptions }).catch(error =>
toClient.destroy(error)
)
const transport = {
readable: toClient,
writable: toServer,
setDirectReceiver: receiver => {
let chunk
while ((chunk = toClient.read()) !== null) {
receiver.feed(chunk, chunk.length)
}
let size = 1
toClient.on('data', chunk => {
for (let offset = 0; offset < chunk.length; ) {
const target = receiver.nextReadTarget()
// pieces of 1, 3, 9, 27... bytes
size = size > 4096 ? 1 : size * 3
const n = Math.min(target.length, chunk.length - offset, size)
chunk.copy(target, 0, offset, offset + n)
this.directReads++
receiver.received(n)
offset += n
}
})
},
}
return transport
}
}
describe('AbstractNbdClient direct reception', () => {
it('reads straight into the given buffers, answered out of order', async () => {
const client = new DirectReceptionClient({ answerInReverse: true, chunkSize: 1000 })
await client.connect()
try {
const targets = [0, 1, 2, 3].map(() => Buffer.alloc(BLOCK_SIZE))
const blocks = await Promise.all(targets.map((target, index) => client.readBlock(index, BLOCK_SIZE, target)))
blocks.forEach((block, index) => {
assert.equal(block.buffer, targets[index].buffer, 'the data are in the given buffer')
assert.ok(block.equals(DATA.subarray(index * BLOCK_SIZE, (index + 1) * BLOCK_SIZE)))
})
assert.ok(client.directReads > 0)
} finally {
await client.disconnect()
}
})
it('handles the answers without payload of the writes', async () => {
const data = Buffer.from(DATA)
const client = new DirectReceptionClient({ data, allowWrites: true })
await client.connect()
try {
await Promise.all([client.writeBlock(0, Buffer.alloc(BLOCK_SIZE, 7), BLOCK_SIZE), client.flush()])
assert.ok((await client.readBlock(0, BLOCK_SIZE)).equals(Buffer.alloc(BLOCK_SIZE, 7)))
} finally {
await client.disconnect()
}
})
})

View File

@@ -3,6 +3,68 @@ import { connect } from 'node:tls'
import { spawn } from 'node:child_process'
import AbstractNbdClient from './AbstractNbdClient.mjs'
import { NBD_DEFAULT_PORT, NBD_OPT_STARTTLS } from './constants.mjs'
import { PassThrough } from 'node:stream'
// the kernel writes the received data into this buffer, reused for every read
const ONREAD_BUFFER_SIZE = 1024 * 1024
/**
* Receives the data of `socket` through its `onread` option, only used without TLS (`tls.connect` ignores it).
*
* During the handshake the (small and rare) chunks are copied into `readable` from a reused buffer. Then the
* parser of the answers tells, before each read, where the data must go (`setDirectReceiver()`): the kernel writes
* the payload of the reads straight into the buffers of their queries, without any copy.
*
* `onread` must be given to the constructor of the socket, which is then given to `attach()`
*/
function createOnReadTransport() {
const readable = new PassThrough()
const handshakeBuffer = Buffer.allocUnsafeSlow(ONREAD_BUFFER_SIZE)
let receiver
// the buffer of a read is chosen before the read: the last one given by the receiver
let receiverTarget
const transport = {
readable,
writable: undefined,
setDirectReceiver(directReceiver) {
// what came with the end of the handshake must be parsed first
let chunk
while ((chunk = readable.read()) !== null) {
directReceiver.feed(chunk, chunk.length)
}
receiver = directReceiver
},
}
return {
onread: {
// called before each read
buffer: () => {
if (receiver === undefined) {
return handshakeBuffer
}
receiverTarget = receiver.nextReadTarget()
return receiverTarget
},
callback(length, buffer) {
if (receiver === undefined) {
readable.write(Buffer.from(buffer.subarray(0, length)))
} else if (buffer === receiverTarget) {
receiver.received(length)
} else {
// a read started before the receiver was set, into the handshake buffer
receiver.feed(buffer, length)
}
},
},
attach(socket) {
transport.writable = socket
// with `onread` the socket does not emit its data, but its end must still reach the readers
socket.once('end', () => readable.end())
socket.once('close', () => readable.destroy())
},
transport,
}
}
/**
* NBD client talking to a server over TCP, optionally upgrading the connection
@@ -33,7 +95,12 @@ export default class NbdTcpClient extends AbstractNbdClient {
// mandatory , at least to start the handshake: the connection always starts
// unsecured, and is upgraded to TLS during the handshake
async _openTransport() {
const socket = new Socket()
// without TLS, the kernel writes the answers into a reused buffer instead of a new Buffer per chunk:
// this is what keeps the GC quiet at high throughput (TLS sockets ignore `onread`)
const onRead = this.#serverCert === undefined ? createOnReadTransport() : undefined
// `onread` is only taken into account by the constructor
const socket = new Socket({ onread: onRead?.onread })
onRead?.attach(socket)
await new Promise((resolve, reject) => {
socket.connect(this.#serverPort, this.#serverAddress)
socket.once('error', reject)
@@ -42,7 +109,7 @@ export default class NbdTcpClient extends AbstractNbdClient {
resolve()
})
})
return { readable: socket, writable: socket }
return onRead?.transport ?? { readable: socket, writable: socket }
}
async _secureTransport(transport) {

View File

@@ -58,6 +58,24 @@ const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
### Writes
If the export is not read-only, the clients can also write. The capabilities announced by the server are known
once connected: `client.readOnly`, `client.canFlush`, `client.canTrim` and `client.canWriteZeroes`.
```js
await client.connect()
// data must be BlockSize long, shorter for the last block of the export
await client.writeBlock(blockIndex, data, BlockSize)
await client.writeZeroes(otherBlockIndex, BlockSize) // zeroes, without sending them
await client.flush() // the acknowledged writes are on a permanent storage
await client.disconnect()
```
A write refused by the server rejects with an error whose `code` is the errno name (`ENOSPC`, `EPERM`, ...) and
`nbdError` its value: the connection stays usable. A write which failed because of the connection is retried after a
reconnection, like a read.
There is no TLS here: the transport is a pair of pipes, there is nothing to
encrypt.

View File

@@ -14,6 +14,7 @@ export const NBD_FLAG_SEND_FLUSH = 1 << 2
export const NBD_FLAG_SEND_FUA = 1 << 3
export const NBD_FLAG_ROTATIONAL = 1 << 4
export const NBD_FLAG_SEND_TRIM = 1 << 5
export const NBD_FLAG_SEND_WRITE_ZEROES = 1 << 6
export const NBD_FLAG_FIXED_NEWSTYLE = 1 << 0

View File

@@ -95,13 +95,14 @@ export default class MultiNbdClient {
*
* @param {number} index
* @param {number} size
* @param {Buffer} [target] - where to write the data instead of a new Buffer, see AbstractNbdClient#readBlock
* @returns {Promise<Buffer>}
*/
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE, target) {
const clientId = this.#nextClient++ % this.#clients.length
const client = this.#clients[clientId]
try {
return await client.readBlock(index, size)
return await client.readBlock(index, size, target)
} catch (err) {
// client.readBlock() already exhausted its own retries/reconnects: this connection is dead.
// Evict it so future reads stop being routed to it, and retry this read on a surviving
@@ -111,7 +112,7 @@ export default class MultiNbdClient {
throw err
}
warn(`evicted a dead nbd client, retrying block ${index} on a remaining client`, { err })
return this.readBlock(index, size)
return this.readBlock(index, size, target)
}
}

View File

@@ -4,10 +4,17 @@ import { readChunk, readChunkStrict } from '@vates/read-chunk'
import {
INIT_PASSWD,
NBD_CMD_DISC,
NBD_CMD_FLUSH,
NBD_CMD_READ,
NBD_CMD_TRIM,
NBD_CMD_WRITE,
NBD_CMD_WRITE_ZEROES,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_FLAG_READ_ONLY,
NBD_FLAG_SEND_FLUSH,
NBD_FLAG_SEND_TRIM,
NBD_FLAG_SEND_WRITE_ZEROES,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_REPLY_ACK,
@@ -16,10 +23,11 @@ import {
OPTS_MAGIC,
} from '../constants.mjs'
const NBD_EPERM = 1
const NBD_EINVAL = 22
/**
* Minimal read-only newstyle NBD server, used by the tests only.
* Minimal newstyle NBD server, used by the tests only.
*
* It is transport agnostic on purpose: the unit tests drive it through two
* in-memory `PassThrough`, the integration tests through the standard streams
@@ -28,13 +36,26 @@ const NBD_EINVAL = 22
* @param {object} options
* @param {import('node:stream').Readable} options.readable - where the client queries are read from
* @param {import('node:stream').Writable} options.writable - where the answers are written to
* @param {Buffer} options.data - the content of the export
* @param {Buffer} options.data - the content of the export, modified by the writes of a writable export
* @param {boolean} [options.allowWrites] - accept writes, flushes, trims and write zeroes, the export is read-only otherwise
* @param {string} [options.exportName] - the export name the client must ask for
* @param {boolean} [options.answerInReverse] - answer by pairs, in reverse order, to check the client handles out of order answers
* @param {(request: {offset: bigint, length: number, index: number}) => number} [options.errorCode] - non zero to answer an error to this read
* @param {(request: {type: number, offset: bigint, length: number, index: number}) => number} [options.errorCode] - non zero to answer an error to this request
* @param {number} [options.chunkSize] - split the answers in chunks of this size, to check the client reassembles them
* @param {number} [options.stopAnsweringAfter] - stop answering (without closing) after this number of answers
* @returns {Promise<void>} resolves when the client disconnected or closed the connection
*/
export async function serveNbd({ readable, writable, data, exportName = '', answerInReverse = false, errorCode }) {
export async function serveNbd({
readable,
writable,
data,
exportName = '',
allowWrites = false,
answerInReverse = false,
errorCode,
chunkSize,
stopAnsweringAfter = Infinity,
}) {
const write = buffer => pFromCallback(cb => writable.write(buffer, cb))
// handshake: server flags
@@ -60,7 +81,11 @@ export async function serveNbd({ readable, writable, data, exportName = '', answ
// 8 (size) + 2 (transmission flags) + 124 zeroes
const answer = Buffer.alloc(134)
answer.writeBigUInt64BE(BigInt(data.length), 0)
answer.writeInt16BE(NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY, 8)
answer.writeInt16BE(
NBD_FLAG_HAS_FLAGS |
(allowWrites ? NBD_FLAG_SEND_FLUSH | NBD_FLAG_SEND_TRIM | NBD_FLAG_SEND_WRITE_ZEROES : NBD_FLAG_READ_ONLY),
8
)
await write(answer)
selected = true
} else {
@@ -78,15 +103,26 @@ export async function serveNbd({ readable, writable, data, exportName = '', answ
let index = 0
let pending = []
const answer = async ({ handle, offset, length, index }, forcedCode) => {
const code = forcedCode ?? errorCode?.({ handle, offset, length, index }) ?? 0
let nbAnswers = 0
const answer = async ({ type = NBD_CMD_READ, handle, offset, length, index }, forcedCode) => {
if (nbAnswers++ >= stopAnsweringAfter) {
return
}
const code = forcedCode ?? errorCode?.({ type, handle, offset, length, index }) ?? 0
const header = Buffer.alloc(16)
header.writeInt32BE(NBD_REPLY_MAGIC, 0)
header.writeInt32BE(code, 4)
header.writeBigUInt64BE(handle, 8)
await write(header)
if (code === 0) {
await write(data.subarray(Number(offset), Number(offset) + length))
let message = header
if (code === 0 && type === NBD_CMD_READ) {
message = Buffer.concat([header, data.subarray(Number(offset), Number(offset) + length)])
}
if (chunkSize === undefined) {
await write(message)
} else {
for (let i = 0; i < message.length; i += chunkSize) {
await write(message.subarray(i, i + chunkSize))
}
}
}
@@ -113,6 +149,34 @@ export async function serveNbd({ readable, writable, data, exportName = '', answ
break
}
if (type === NBD_CMD_WRITE || type === NBD_CMD_FLUSH || type === NBD_CMD_TRIM || type === NBD_CMD_WRITE_ZEROES) {
const command = {
type,
handle,
offset: request.readBigUInt64BE(16),
length: request.readInt32BE(24),
index: index++,
}
// the data of a write must be read, even when it is refused
const payload = type === NBD_CMD_WRITE ? await readChunkStrict(readable, command.length) : undefined
if (!allowWrites) {
await answer(command, NBD_EPERM)
continue
}
if (errorCode?.(command) ?? 0) {
await answer(command)
continue
}
const start = Number(command.offset)
if (type === NBD_CMD_WRITE) {
payload.copy(data, start)
} else if (type !== NBD_CMD_FLUSH) {
data.fill(0, start, start + command.length)
}
await answer(command, 0)
continue
}
if (type !== NBD_CMD_READ) {
await answer({ handle, offset: 0n, length: 0, index: index++ }, NBD_EINVAL)
continue

View File

@@ -7,6 +7,7 @@ import { Socket } from 'node:net'
import { NBD_DEFAULT_PORT } from '../constants.mjs'
import assert from 'node:assert'
import MultiNbdClient from '../multi.mjs'
import NbdTcpClient from '../NbdTcpClient.mjs'
const CHUNK_SIZE = 1024 * 1024 // non default size
const FILE_SIZE = 1024 * 1024 * 9.5 // non aligned file size
@@ -22,7 +23,7 @@ async function createTempFile(size) {
return tmpPath
}
async function spawnNbdKit(path) {
async function spawnNbdKit(path, { readOnly = true } = {}) {
let tries = 5
// wait for server to be ready
@@ -33,7 +34,7 @@ async function spawnNbdKit(path) {
path,
'--newstyle', //
'--exit-with-parent',
'--read-only',
...(readOnly ? ['--read-only'] : []),
'--export-name=MY_SECRET_EXPORT',
'--tls=on',
'--tls-certificates=./tests/',
@@ -168,6 +169,96 @@ CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
await fs.unlink(path)
})
test('it works on an unsecured network, reading into given buffers', async tap => {
const path = await createTempFile(FILE_SIZE)
// without cert: the TCP client receives through `onread`, without allocation per chunk
await spawnNbdKit(path)
const client = new MultiNbdClient({ address: '127.0.0.1', exportname: 'MY_SECRET_EXPORT' }, { nbdConcurrency: 2 })
await client.connect()
tap.equal(client.exportSize, BigInt(FILE_SIZE))
const nbBlocks = Math.ceil(FILE_SIZE / CHUNK_SIZE)
// twice the content, with some reads in flight when the server is restarted
for (let pass = 0; pass < 2; pass++) {
const targets = Array.from({ length: nbBlocks }, () => Buffer.alloc(CHUNK_SIZE + 16, 0xaa))
const blocks = await Promise.all(
targets.map((target, index) => client.readBlock(index, CHUNK_SIZE, target.subarray(16)))
)
blocks.forEach((block, i) => {
const expectedLength = Math.min(CHUNK_SIZE, FILE_SIZE - CHUNK_SIZE * i)
let blockOk = block.length === expectedLength && block.buffer === targets[i].buffer
for (let j = 0; j < block.length && blockOk; j += 4) {
blockOk = block.readUInt32BE(j) === i * CHUNK_SIZE + j
}
// nothing written outside of the target
blockOk = blockOk && targets[i].readUInt32BE(0) === 0xaaaaaaaa
tap.ok(blockOk, `check block ${i} of pass ${pass}`, {
length: block.length,
sameMemory: block.buffer === targets[i].buffer,
first: block.readUInt32BE(0),
guard: targets[i].readUInt32BE(0),
})
})
await killNbdKit()
await spawnNbdKit(path)
}
await client.disconnect()
await killNbdKit()
await fs.unlink(path)
})
test('writes, with and without TLS', async tap => {
const path = await createTempFile(FILE_SIZE)
const expected = await fs.readFile(path)
const nbBlocks = Math.ceil(FILE_SIZE / CHUNK_SIZE)
await spawnNbdKit(path, { readOnly: false })
const cert = await fs.readFile('./tests/server-cert.pem', 'utf8')
for (const [label, settings] of [
['plain', { address: '127.0.0.1', exportname: 'MY_SECRET_EXPORT' }],
['TLS', { address: '127.0.0.1', exportname: 'MY_SECRET_EXPORT', cert }],
]) {
const client = new NbdTcpClient(settings)
await client.connect()
tap.equal(client.readOnly, false, `${label}: writable export`)
tap.equal(client.canFlush, true, `${label}: can flush`)
// every block, the last one is shorter, written concurrently
const fill = label === 'plain' ? 1 : 101
await Promise.all(
Array.from({ length: nbBlocks }, (_, index) => {
const length = Math.min(CHUNK_SIZE, FILE_SIZE - index * CHUNK_SIZE)
const data = Buffer.alloc(length, fill + index)
data.copy(expected, index * CHUNK_SIZE)
return client.writeBlock(index, data, CHUNK_SIZE)
})
)
if (client.canWriteZeroes) {
await client.writeZeroes(1, CHUNK_SIZE)
expected.fill(0, CHUNK_SIZE, 2 * CHUNK_SIZE)
}
await client.flush()
for (let index = 0; index < nbBlocks; index++) {
const block = await client.readBlock(index, CHUNK_SIZE)
tap.ok(block.equals(expected.subarray(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)), `${label}: block ${index}`)
}
await client.disconnect()
tap.ok((await fs.readFile(path)).equals(expected), `${label}: the file has been written`)
}
await killNbdKit()
// a read-only export refuses the writes
await spawnNbdKit(path)
const client = new NbdTcpClient({ address: '127.0.0.1', exportname: 'MY_SECRET_EXPORT' })
await client.connect()
tap.equal(client.readOnly, true)
await assert.rejects(client.writeBlock(0, Buffer.alloc(CHUNK_SIZE), CHUNK_SIZE), { code: 'EROFS' })
await client.disconnect()
await killNbdKit()
await fs.unlink(path)
})
test('fails if server does not answer', async () => {
const client = new MultiNbdClient(
{

View File

@@ -95,6 +95,32 @@ describe('tests RemoteVhdDisk', { concurrency: 1 }, () => {
assert.equal(readBlock.data.length, 2097152, 'block should be readable')
})
test('RemoteVhdDisk returns the block files as stored once useRawBlocks() is called', async () => {
await generateVhd(`${basePath}/disk.vhd`, { blocks: [0, 1], mode: 'directory', useAlias: true })
const disk = new RemoteVhdDisk({ handler, path: `${basePath}/disk.vhd.alias.vhd` })
await disk.init({ force: false })
assert.equal(disk.useRawBlocks('gzip'), false, 'only with the compression of the VHD directory')
assert.equal((await disk.readBlock(1)).vhdBlockCompression, undefined)
assert.equal(disk.useRawBlocks('none'), true)
const block = await disk.readBlock(1)
assert.equal(block.vhdBlockCompression, 'none')
assert.equal(block.data.length, 2 * 1024 * 1024 + 512, 'bitmap + data')
assert.ok(block.data.every(byte => byte === 1))
// a block file is never written as data
await assert.rejects(disk.writeBlock(block), /block file/)
})
test('RemoteVhdDisk does not return block files for a VHD file', async () => {
await generateVhd(`${basePath}/disk.vhd`, { blocks: [0, 1] })
const disk = new RemoteVhdDisk({ handler, path: `${basePath}/disk.vhd` })
await disk.init({ force: false })
assert.equal(disk.useRawBlocks('none'), false)
assert.equal((await disk.readBlock(1)).vhdBlockCompression, undefined)
})
test('RemoteVhdDisk should write block', async () => {
// Create VHD disk
await generateVhd(`${basePath}/disk.vhd`, { blocks: [0, 1] })
@@ -1048,119 +1074,3 @@ describe('tests MergeRemoteDisk', { concurrency: 1 }, () => {
assert.deepEqual(parentIndexes, expectedIndexes, 'all child blocks should be merged into parent')
})
})
/**
* Benchmarks
*
* These tests always pass. They log merge throughput so regressions are visible.
* Warning emitted when throughput drops below threshold.
*
* Block count can be overriden via BENCH_MERGE_BLOCKS env var (1 block = 2 MB):
* BENCH_MERGE_BLOCKS=2560 node --test RemoteDisk.test.mjs
*/
describe('benchmark MergeRemoteDisk', { concurrency: 1 }, () => {
// Block size as written by generateVhd: 2 MB of data + 512 B bitmap
const BLOCK_DATA_BYTES = 2 * 1024 * 1024
const BENCH_BLOCKS = process.env.BENCH_MERGE_BLOCKS ?? 500
const WARN_THRESHOLD_FILE_MB_S = 500
const WARN_THRESHOLD_DIRECTORY_MB_S = 5000
test('mergeRemoteDisk throughput - VHD file mode', async t => {
const childBlocks = Array.from({ length: BENCH_BLOCKS }, (_, i) => i + 2)
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1] })
await generateVhd(`${basePath}/child.vhd`, { blocks: childBlocks })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const start = performance.now()
await mergeRemoteDisk.merge(parent, child)
const elapsedMs = performance.now() - start
const totalMB = (BENCH_BLOCKS * BLOCK_DATA_BYTES) / (1024 * 1024)
const throughputMBs = totalMB / (elapsedMs / 1000)
t.diagnostic(
`VHD file merge: ${BENCH_BLOCKS} blocks (${totalMB.toFixed(0)} MB) in ${(elapsedMs / 1000).toFixed(2)}s: ${throughputMBs.toFixed(1)} MB/s`
)
if (throughputMBs < WARN_THRESHOLD_FILE_MB_S) {
t.diagnostic(
`WARNING: throughput ${throughputMBs.toFixed(1)} MB/s is below the ${WARN_THRESHOLD_FILE_MB_S} MB/s threshold`
)
}
})
test('mergeRemoteDisk throughput - VHD directory mode (direct disk, reference)', async t => {
const childBlocks = Array.from({ length: BENCH_BLOCKS }, (_, i) => i + 2)
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1], mode: 'directory', useAlias: true })
await generateVhd(`${basePath}/child.vhd`, { blocks: childBlocks, mode: 'directory', useAlias: true })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { removeUnused: true })
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
await parent.init({ force: isResumingMerge })
await child.init({ force: isResumingMerge })
const start = performance.now()
await mergeRemoteDisk.merge(parent, child)
const elapsedMs = performance.now() - start
const totalMB = (BENCH_BLOCKS * BLOCK_DATA_BYTES) / (1024 * 1024)
const throughputMBs = totalMB / (elapsedMs / 1000)
t.diagnostic(
`VHD directory merge (direct disk): ${BENCH_BLOCKS} blocks (${totalMB.toFixed(0)} MB) in ${(elapsedMs / 1000).toFixed(2)}s: ${throughputMBs.toFixed(1)} MB/s`
)
if (throughputMBs < WARN_THRESHOLD_DIRECTORY_MB_S) {
t.diagnostic(
`WARNING: chain merge throughput ${throughputMBs.toFixed(1)} MB/s is below the ${WARN_THRESHOLD_DIRECTORY_MB_S} MB/s threshold`
)
}
})
test('mergeRemoteDisk throughput - VHD directory mode (chain, cleanVm path)', async t => {
const childBlocks = Array.from({ length: BENCH_BLOCKS }, (_, i) => i + 2)
await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1], mode: 'directory', useAlias: true })
await generateVhd(`${basePath}/child.vhd`, { blocks: childBlocks, mode: 'directory', useAlias: true })
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd.alias.vhd` })
// Replicate _mergeVhdChain exactly: child disks are wrapped in a RemoteVhdDiskChain
const childDisk = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd.alias.vhd` })
const childChain = new RemoteVhdDiskChain({ disks: [childDisk] })
const mergeRemoteDisk = new MergeRemoteDisk(handler, { removeUnused: true })
// init pattern matches _mergeVhdChain: parent individually, chain for all children
const isResumingMerge = await mergeRemoteDisk.isResuming(parent)
await parent.init({ force: isResumingMerge })
await childChain.init({ force: isResumingMerge })
const start = performance.now()
await mergeRemoteDisk.merge(parent, childChain)
const elapsedMs = performance.now() - start
const totalMB = (BENCH_BLOCKS * BLOCK_DATA_BYTES) / (1024 * 1024)
const throughputMBs = totalMB / (elapsedMs / 1000)
t.diagnostic(
`VHD directory merge (chain / cleanVm path): ${BENCH_BLOCKS} blocks (${totalMB.toFixed(0)} MB) in ${(elapsedMs / 1000).toFixed(2)}s: ${throughputMBs.toFixed(1)} MB/s`
)
if (throughputMBs < WARN_THRESHOLD_DIRECTORY_MB_S) {
t.diagnostic(
`WARNING: chain merge throughput ${throughputMBs.toFixed(1)} MB/s is below the ${WARN_THRESHOLD_DIRECTORY_MB_S} MB/s threshold`
)
}
})
})

View File

@@ -35,6 +35,12 @@ export class RemoteVhdDisk extends RemoteDisk {
*/
#vhd
/**
* set by useRawBlocks(): the compression of the block files returned by readBlock()
* @type {string | undefined}
*/
#rawBlocksCompression
/**
* @type {boolean | undefined}
*/
@@ -45,11 +51,6 @@ export class RemoteVhdDisk extends RemoteDisk {
*/
#blockSize = 2 * 1024 * 1024
/**
* @type {number}
*/
#bitmapSize = 512
/**
* @type {() => any}
*/
@@ -263,10 +264,10 @@ export class RemoteVhdDisk extends RemoteDisk {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
await this.#vhd.writeEntireBlock({
id: diskBlock.index,
buffer: Buffer.concat([Buffer.alloc(this.#bitmapSize, 255), diskBlock.data]),
})
if (diskBlock.vhdBlockCompression !== undefined) {
throw new Error(`can't write block ${diskBlock.index}: it is a block file, not the data of the block`)
}
await this.#vhd.writeBlockData(diskBlock.index, diskBlock.data)
return this.getBlockSize()
}
@@ -280,6 +281,13 @@ export class RemoteVhdDisk extends RemoteDisk {
if (this.#vhd === undefined) {
throw new Error(`can't call readBlock of a RemoteVhdDisk before init`)
}
if (this.#rawBlocksCompression !== undefined) {
return {
index,
data: await /** @type {VhdDirectory} */ (this.#vhd).readRawBlock(index),
vhdBlockCompression: this.#rawBlocksCompression,
}
}
const { data } = await this.#vhd.readBlock(index)
return {
index,
@@ -287,6 +295,25 @@ export class RemoteVhdDisk extends RemoteDisk {
}
}
/**
* From now on, readBlock() returns the block files as stored (bitmap + data, compressed with
* `compressionType`) instead of the data of the blocks: a VHD directory using the same compression can write
* them as is, without decompressing nor recompressing them.
* Only possible for a VHD directory using `compressionType`.
* @param {string} compressionType
* @returns {boolean} whether readBlock() now returns block files
*/
useRawBlocks(compressionType) {
if (this.#vhd === undefined) {
throw new Error(`can't call useRawBlocks of a RemoteVhdDisk before init`)
}
if (!(this.#vhd instanceof VhdDirectory) || this.#vhd.compressionType !== compressionType) {
return false
}
this.#rawBlocksCompression = compressionType
return true
}
/**
* Reads a specific block from the child disk to copy/move it to this disk.
* @param {RemoteDisk} childDisk

View File

@@ -16,6 +16,7 @@ import { openVhd, VhdFile, VhdAbstract, Constants, VhdDirectory } from 'vhd-lib'
import computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
import { dirname, basename } from 'node:path'
import { rimraf } from 'rimraf'
import { Readable } from 'node:stream'
const { beforeEach, afterEach, describe } = test
@@ -323,3 +324,147 @@ describe('RemoteAdapter#writeVhd validated by checkDisk', { concurrency: 1 }, ()
})
}
})
// incremental mirror: the source disk is read from a remote then written on another one
describe('mirror between VHD directory remotes', { concurrency: 1 }, () => {
const KEYS = ['73c1838d7d8a6088ca2317fb5f29cd91', '0123456789abcdef0123456789abcdef']
const dirs = []
const handlers = []
beforeEach(async () => {
// the global hooks expect it
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
})
afterEach(async () => {
await Promise.all(handlers.splice(0).map(handler => handler.forget()))
await Promise.all(dirs.splice(0).map(dir => rimraf(dir)))
})
async function openRemote({ compressionType, encryptionKey }) {
const dir = await pFromCallback(cb => tmp.dir(cb))
dirs.push(dir)
const remoteHandler = getHandler({ url: `file://${dir}`, useVhdDirectory: true, compressionType, encryptionKey })
await remoteHandler.sync()
handlers.push(remoteHandler)
return new RemoteAdapter(remoteHandler)
}
async function mirror(source, target, fillByte) {
const path = `${basePath}/${source.getVhdFileName('base')}`
await source.writeVhd(path, new MockDisk(3, [0, 2], fillByte), { uuid: uniqueIdBuffer() })
const disk = await source._createVhdDisk(source.handler, path, {
useChain: false,
rawBlocksCompression: target.getVhdDirectoryCompression(),
})
const { vhdBlockCompression } = await disk.readBlock(0)
await target.writeVhd(path, disk, {
validator: tmpPath => checkDisk(target.handler, tmpPath),
uuid: uniqueIdBuffer(),
})
await Disposable.use(openVhd(target.handler, path), async vhd => {
await vhd.readBlockAllocationTable()
assert.equal(vhd.compressionType, target.getVhdDirectoryCompression())
assert.equal(vhd.containsBlock(1), false)
for (const id of [0, 2]) {
const { bitmap, data } = await vhd.readBlock(id)
assert.ok(bitmap.every(byte => byte === 0xff))
assert.ok(data.every(byte => byte === fillByte))
}
})
return vhdBlockCompression
}
for (const [label, sourceOptions, targetOptions] of [
['same compression', { compressionType: 'gzip' }, { compressionType: 'gzip' }],
['without compression', { compressionType: 'none' }, { compressionType: 'none' }],
[
'same compression, different encryption keys',
{ compressionType: 'gzip', encryptionKey: KEYS[0] },
{ compressionType: 'gzip', encryptionKey: KEYS[1] },
],
[
'same compression, encrypted source only',
{ compressionType: 'gzip', encryptionKey: KEYS[0] },
{ compressionType: 'gzip' },
],
]) {
test(`${label}: block files are copied as stored`, async () => {
const source = await openRemote(sourceOptions)
const target = await openRemote(targetOptions)
assert.equal(await mirror(source, target, 0x5a), targetOptions.compressionType)
})
}
test('different compressions: blocks are decompressed and recompressed', async () => {
const source = await openRemote({ compressionType: 'gzip' })
const target = await openRemote({ compressionType: 'none' })
assert.equal(await mirror(source, target, 0x3c), undefined)
})
})
// full mirror: the XVA is read from a remote then written on another one
describe('full mirror checksum', { concurrency: 1 }, () => {
const KEY = '73c1838d7d8a6088ca2317fb5f29cd91'
const XVA = `${rootPath}/20240101T000000Z.xva`
const metadata = { _filename: `${rootPath}/20240101T000000Z.json`, xva: './20240101T000000Z.xva' }
const data = Buffer.alloc(1024 * 1024, 0x77)
const dirs = []
const handlers = []
beforeEach(async () => {
// the global hooks expect it
handler = getHandler({ url: `file://${tempDir}` })
await handler.sync()
})
afterEach(async () => {
await Promise.all(handlers.splice(0).map(handler => handler.forget()))
await Promise.all(dirs.splice(0).map(dir => rimraf(dir)))
})
async function openRemote(encryptionKey) {
const dir = await pFromCallback(cb => tmp.dir(cb))
dirs.push(dir)
const remoteHandler = getHandler({ url: `file://${dir}`, encryptionKey })
await remoteHandler.sync()
handlers.push(remoteHandler)
return new RemoteAdapter(remoteHandler)
}
async function readChecksum(adapter) {
return String(await adapter.handler.readFile(XVA + '.checksum')).trim()
}
test('an unencrypted copy stores the checksum of the source, which stays valid', async () => {
const source = await openRemote()
const target = await openRemote()
await source.outputStream(XVA, Readable.from([data]))
const checksum = await source.readFullVmBackupChecksum(metadata)
assert.equal(checksum, await readChecksum(source))
await target.outputStream(XVA, await source.readFullVmBackup(metadata), { checksum })
assert.equal(await readChecksum(target), checksum)
const chunks = []
for await (const chunk of await target.handler.createReadStream(XVA, { checksum: true })) {
chunks.push(chunk)
}
assert.deepEqual(Buffer.concat(chunks), data)
})
test('an encrypted copy stores no checksum', async () => {
const source = await openRemote()
const target = await openRemote(KEY)
await source.outputStream(XVA, Readable.from([data]))
const checksum = await source.readFullVmBackupChecksum(metadata)
await target.outputStream(XVA, await source.readFullVmBackup(metadata), { checksum })
await assert.rejects(target.handler.readFile(XVA + '.checksum'), { code: 'ENOENT' })
assert.deepEqual(await target.handler.readFile(XVA), data)
})
test('an encrypted source gives no checksum', async () => {
const source = await openRemote(KEY)
await source.outputStream(XVA, Readable.from([data]))
assert.equal(await source.readFullVmBackupChecksum(metadata), undefined)
})
})

View File

@@ -36,6 +36,7 @@ import { watchStreamSize } from './_watchStreamSize.mjs'
import { RemoteVhdDisk, openDiskChain, openDisposableDisk } from '@xen-orchestra/backup-archive/disks'
import { toVhdStream, writeToVhdDirectory } from 'vhd-lib/disk-consumer/index.mjs'
import { ReadAhead } from '@xen-orchestra/disk-transform'
import { DISCARD_BLOCKS, DiscardedDisk } from './_discardedDisk.mjs'
export const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
@@ -317,6 +318,14 @@ export class RemoteAdapter {
return this.handler.getConfig('useVhdDirectory')
}
/**
* @returns {string | undefined} the compression of the VHD directories written on this remote, undefined if it
* does not write VHD directories
*/
getVhdDirectoryCompression() {
return this.useVhdDirectory() ? (this.handler.getConfig('compressionType') ?? 'brotli') : undefined // compatibility layer
}
#useAlias() {
return this.useVhdDirectory()
}
@@ -666,6 +675,16 @@ export class RemoteAdapter {
}
async writeVhd(path, disk, { validator = noop, writeBlockConcurrency, uuid, parentUuid, parentPath } = {}) {
if (DISCARD_BLOCKS) {
const discarded = new DiscardedDisk(disk)
await this.#writeVhd(path, discarded, { validator, writeBlockConcurrency, uuid, parentUuid, parentPath })
// what was read, for the transfer speed of the task to mean something
return discarded.bytesRead
}
return this.#writeVhd(path, disk, { validator, writeBlockConcurrency, uuid, parentUuid, parentPath })
}
async #writeVhd(path, disk, { validator, writeBlockConcurrency, uuid, parentUuid, parentPath }) {
const handler = this._handler
if (this.useVhdDirectory()) {
@@ -676,7 +695,7 @@ export class RemoteAdapter {
path,
concurrency: writeBlockConcurrency,
validator,
compression: handler.getConfig('compressionType') ?? 'brotli', // compatibility layer
compression: this.getVhdDirectoryCompression(),
uuid,
parentUuid,
parentPath,
@@ -702,7 +721,8 @@ export class RemoteAdapter {
) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
// a known checksum is the one of unencrypted data: an encrypted remote stores no checksum
checksum: typeof checksum === 'string' && this._handler.isEncrypted ? false : checksum,
dirMode: this._dirMode,
maxStreamLength,
streamLength,
@@ -715,19 +735,30 @@ export class RemoteAdapter {
}
// open the hierarchy of ancestors until we find a full one
async _createVhdDisk(handler, path, { useChain }) {
async _createVhdDisk(handler, path, { useChain, rawBlocksCompression }) {
let disk
if (useChain) {
disk = await openDiskChain({ handler, path })
} else {
disk = new RemoteVhdDisk({ handler, path })
await disk.init()
if (rawBlocksCompression !== undefined) {
// each block file is copied as stored if this disk is a VHD directory with the same compression
const raw = disk.useRawBlocks(rawBlocksCompression)
debug('reading block files as stored', { path, raw, rawBlocksCompression })
}
}
disk = new ReadAhead(disk)
return disk
}
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true } = {}) {
/**
* @param {object} [options]
* @param {boolean} [options.useChain=true]
* @param {string} [options.rawBlocksCompression] - without chain: the disks which are VHD directories with this
* compression return their block files as stored (see RemoteVhdDisk#useRawBlocks)
*/
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true, rawBlocksCompression } = {}) {
const handler = this._handler
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
const dir = dirname(metadata._filename)
@@ -735,7 +766,7 @@ export class RemoteAdapter {
const disks = {}
await asyncMapSettled(Object.keys(vdis), async ref => {
delete vdis[ref].baseVdi
disks[ref] = await this._createVhdDisk(handler, join(dir, vhds[ref]), { useChain })
disks[ref] = await this._createVhdDisk(handler, join(dir, vhds[ref]), { useChain, rawBlocksCompression })
})
return {
@@ -753,6 +784,28 @@ export class RemoteAdapter {
return this._handler.createReadStream(resolve('/', dirname(metadata._filename), metadata.xva))
}
/**
* @returns {Promise<string | undefined>} the checksum of the XVA as stored, undefined if it is unknown (no checksum
* file, or encrypted remote: the stored data are not the data read)
*/
async readFullVmBackupChecksum(metadata) {
if (this._handler.isEncrypted) {
return
}
try {
const checksum = String(
await this._handler.readFile(resolve('/', dirname(metadata._filename), metadata.xva) + '.checksum')
).trim()
// $<algorithm id>$<salt>$<hash>
return checksum.startsWith('$') ? checksum : undefined
} catch (error) {
if (error.code === 'ENOENT') {
return
}
throw error
}
}
async readVmBackupMetadata(path) {
let json
let isImmutable = false

View File

@@ -0,0 +1,205 @@
// Local benchmark of the NBD → VhdDirectory (block mode) backup pipeline.
// Mimics XapiDiskSource#formatSourceDisk + IncrementalXapi._copy + IncrementalRemoteWriter
//
// env:
// MODE=full|read|write full: NBD → remote, read: NBD only, write: file → remote
// NBD_PORT=10809 NBD_CONC=1 (connections) READAHEAD=10 (blocks in flight in ReadAhead)
// TLS=1 (use STARTTLS, like XAPI NBD) WBC=16 (writeBlockConcurrency)
// COMP=brotli|gzip|none ENC=1 (encrypted remote) PCT=100 (% of blocks exported)
import { readFileSync, openSync, readSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { performance } from 'node:perf_hooks'
import {
RandomAccessDisk,
ReadAhead,
SynchronizedDisk,
ThrottledDisk,
TimeoutDisk,
} from '@xen-orchestra/disk-transform'
import { Throttle } from '@vates/generator-toolbox'
import MultiNbdClient from '@vates/nbd-client/multi.mjs'
import { getHandler } from '@xen-orchestra/fs'
import { writeToVhdDirectory } from 'vhd-lib/disk-consumer/index.mjs'
const env = process.env
const MODE = env.MODE ?? 'full'
const BLOCK = 2 * 1024 * 1024
const RAW = env.RAW ?? '/data/bench/mixed.raw'
const SIZE = Number(env.SIZE_GB ?? 2) * 1024 * 1024 * 1024
const PCT = Number(env.PCT ?? 100)
const indexes = []
// BLOCKS_FILE: the indexes to read, one per line (the allocated blocks of a real chain), MAX_BLOCKS of them
const fromFile = env.BLOCKS_FILE
? (await import('node:fs')).readFileSync(env.BLOCKS_FILE, 'utf8').split('\n').filter(Boolean).map(Number).slice(0, Number(env.MAX_BLOCKS ?? Infinity))
: undefined
const CYCLE = env.CYCLE_MB ? Number(env.CYCLE_MB) / 2 : Infinity // read the same range again and again (server cache)
for (let i = 0; fromFile === undefined && i < SIZE / BLOCK; i++) {
if (CYCLE !== Infinity) {
indexes.push(i % CYCLE)
continue
}
// deterministic sparse selection
if ((i * 37) % 100 < PCT) indexes.push(i)
}
if (fromFile !== undefined) indexes.push(...fromFile)
class BenchDisk extends RandomAccessDisk {
#read
#close
constructor(read, close = async () => {}) {
super()
this.#read = read
this.#close = close
}
async init() {}
async close() {
await this.#close()
}
getVirtualSize() {
return SIZE
}
getBlockSize() {
return BLOCK
}
getBlockIndexes() {
return indexes
}
hasBlock(i) {
return indexes.includes(i)
}
isDifferencing() {
return false
}
async readBlock(index) {
return { index, data: await this.#read(index) }
}
}
// real XAPI: env HOST, XAPI_PASSWORD, VDI, NBD_ADDRESS (optional filter)
async function getXapiNbdInfos(vdiUuid) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
let id = 0
const call = async (method, ...params) => {
const res = await fetch(`https://${env.HOST}/jsonrpc`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: ++id }),
})
const json = await res.json()
if (json.error) throw new Error(JSON.stringify(json.error))
return json.result
}
const session = await call('session.login_with_password', 'root', env.XAPI_PASSWORD, '1.0', 'bench')
const ref = await call('VDI.get_by_uuid', session, vdiUuid)
let infos = await call('VDI.get_nbd_info', session, ref)
if (env.NBD_ADDRESS) infos = infos.filter(_ => _.address === env.NBD_ADDRESS)
return infos
}
async function openSource() {
if (MODE === 'write' && env.SRC === 'mem') {
// no disk nor network read: 64 real blocks preloaded in memory, cycled
const fd = openSync(RAW, 'r')
const pool = []
for (let i = 0; i < 64; i++) {
const b = Buffer.allocUnsafe(BLOCK)
readSync(fd, b, 0, BLOCK, ((i * 13) % 1024) * BLOCK)
pool.push(b)
}
return new BenchDisk(async index => pool[index % 64])
}
if (MODE === 'write') {
const fd = openSync(RAW, 'r')
return new BenchDisk(async index => {
const b = Buffer.allocUnsafe(BLOCK)
readSync(fd, b, 0, BLOCK, index * BLOCK)
return b
})
}
let infos
if (env.VDI !== undefined) {
infos = await getXapiNbdInfos(env.VDI)
} else {
const info = { address: env.NBD_HOST ?? '127.0.0.1', port: Number(env.NBD_PORT ?? 10809), exportname: env.EXPORTNAME ?? '' }
if (env.TLS === '1') info.cert = readFileSync('/data/bench/certs/server-cert.pem', 'utf8')
infos = [info]
}
const client = new MultiNbdClient(infos, { nbdConcurrency: Number(env.NBD_CONC ?? 1) })
await client.connect()
if (env.POOL === '1') {
// same path as XapiStreamNbdSource/XapiVhdCbtSource: pooled buffers
const { readNbdBlock } = await import('@xen-orchestra/xapi/disks/utils.mjs')
const disk = new BenchDisk(
() => {},
() => client.disconnect()
)
disk.readBlock = index => readNbdBlock(client, index, BLOCK)
return disk
}
return new BenchDisk(
index => client.readBlock(index, BLOCK),
() => client.disconnect()
)
}
const t0 = performance.now()
const cpu0 = process.cpuUsage()
let source = await openSource()
// same stack as XapiDiskSource#formatSourceDisk
source = new TimeoutDisk(new ReadAhead(source, { maxNumber: Number(env.READAHEAD ?? 10) }), 20 * 60e3)
let bytes = 0
if (MODE === 'read') {
for await (const { data } of source.diskBlocks()) bytes += data.length
} else {
// same stack as IncrementalXapi._copy
const throttled = new ThrottledDisk(source, new Throttle(0))
const sync = new SynchronizedDisk(throttled)
const disk = sync.fork('IncrementalRemoteWriter')
await rm(env.ENC === '1' ? '/data/bench/remote-enc/vm' : '/data/bench/remote/vm', { recursive: true, force: true })
const handler = getHandler({
url: env.ENC === '1' ? 'file:///data/bench/remote-enc' : 'file:///data/bench/remote',
encryptionKey: env.ENC === '1' ? '73c1838d7d8a6088ca2317fb5f29cd91' : undefined,
})
await handler.sync()
if (env.REMOTE === 'null') {
// keep compression + encryption (done before _outputFile), drop the block data I/O
const outputFile = handler._outputFile.bind(handler)
handler._outputFile = async (file, data, opts) => {
if (file.includes('/blocks/')) return
return outputFile(file, data, opts)
}
}
bytes = indexes.length * BLOCK
await writeToVhdDirectory({
disk,
target: {
handler,
path: 'vm/disk.alias.vhd',
concurrency: Number(env.WBC ?? 16),
compression: env.COMP === 'none' ? undefined : (env.COMP ?? 'brotli'),
validator: async () => {},
},
})
await handler.forget()
}
const s = (performance.now() - t0) / 1e3
const cpu = process.cpuUsage(cpu0)
console.log(
JSON.stringify({
MODE,
POOL: env.POOL ?? 0,
NBD_CONC: env.NBD_CONC ?? 1,
READAHEAD: env.READAHEAD ?? 10,
TLS: env.TLS ?? 0,
WBC: env.WBC ?? 16,
COMP: env.COMP ?? 'brotli',
ENC: env.ENC ?? 0,
UV: env.UV_THREADPOOL_SIZE ?? 4,
REMOTE: env.REMOTE ?? 'file',
SRC: env.SRC ?? (MODE === 'write' ? 'file' : 'nbd'),
MBps: Math.round(bytes / 1048576 / s),
sec: +s.toFixed(1),
cpuPct: Math.round(((cpu.user + cpu.system) / 1e6 / s) * 100),
})
)
process.exit(0)

View File

@@ -0,0 +1,34 @@
// checks that a discarded disk produces a valid empty VHD in both modes and reports the bytes read
import { getHandler } from '@xen-orchestra/fs'
import { RandomAccessDisk } from '@xen-orchestra/disk-transform'
import { rm } from 'node:fs/promises'
const { RemoteAdapter } = await import('../RemoteAdapter.mjs')
const BLOCK = 2 << 20
class Mem extends RandomAccessDisk {
async init() {}
async close() {}
getVirtualSize() { return 64 * BLOCK }
getBlockSize() { return BLOCK }
getBlockIndexes() { return [0, 3, 7, 8] }
hasBlock(i) { return [0, 3, 7, 8].includes(i) }
isDifferencing() { return false }
async readBlock(index) { return { index, data: Buffer.alloc(BLOCK, index) } }
}
for (const mode of ['block', 'file']) {
const dir = '/data/bench/discard-' + mode
await rm(dir, { recursive: true, force: true })
const handler = getHandler({ url: 'file://' + dir, useVhdDirectory: mode === 'block' ? 'true' : undefined })
await handler.sync()
const adapter = new RemoteAdapter(handler, { dirMode: 0o700 })
const path = mode === 'block' ? 'vm/disk.alias.vhd' : 'vm/disk.vhd'
const size = await adapter.writeVhd(path, new Mem(), { validator: async () => {} })
const { openVhd } = await import('vhd-lib')
const { Disposable } = await import('promise-toolbox')
await Disposable.use(openVhd(handler, path), async vhd => {
await vhd.readBlockAllocationTable()
let present = 0
for (let i = 0; i < 64; i++) if (vhd.containsBlock(i)) present++
console.log(JSON.stringify({ mode, useVhdDirectory: adapter.useVhdDirectory(), reportedSize: size, expected: 4 * BLOCK, blocksInVhd: present }))
})
await handler.forget()
}

View File

@@ -0,0 +1,26 @@
// nbdcheck.mjs : reads a whole NBD export by 2 MiB blocks, prints its sha256 and the rate
// env: NBD_HOST NBD_PORT EXPORTNAME TLS=1 CERT=path NBD_CONC
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import MultiNbdClient from '@vates/nbd-client/multi.mjs'
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const env = process.env
const info = { address: env.NBD_HOST ?? '127.0.0.1', port: Number(env.NBD_PORT), exportname: env.EXPORTNAME }
if (env.TLS === '1') info.cert = readFileSync(env.CERT ?? '/data/bench/certs/server-cert.pem', 'utf8')
const client = new MultiNbdClient([info], { nbdConcurrency: Number(env.NBD_CONC ?? 1) })
await client.connect()
const BLOCK = 2 * 1024 * 1024
const size = Number(client.exportSize)
const count = Math.ceil(size / BLOCK)
const hash = createHash('sha256')
const t0 = performance.now()
// 16 reads in flight, hashed in order
const pending = []
let next = 0
for (let i = 0; i < count; i++) {
while (next < count && next < i + 16) pending.push(client.readBlock(next++, BLOCK))
hash.update(await pending.shift())
}
const s = (performance.now() - t0) / 1e3
await client.disconnect()
console.log(JSON.stringify({ sha: hash.digest('hex').slice(0, 16), MBps: Math.round(size / 1048576 / s) }))

View File

@@ -0,0 +1,99 @@
// Minimal plain NBD read client using net.Socket `onread`: the kernel writes into a reused
// buffer, no per-chunk allocation. Measures how fast the client side can go.
// env: NBD_HOST NBD_PORT QD (in-flight requests) SIZE_GB CYCLE_MB ALLOC=new|pool
import net from 'node:net'
import { performance } from 'node:perf_hooks'
const env = process.env
const BLOCK = 2 * 1024 * 1024
const QD = Number(env.QD ?? 16)
const TOTAL = Number(env.SIZE_GB ?? 8) * 1024
const CYCLE = Number(env.CYCLE_MB ?? 512) / 2
const ALLOC = env.ALLOC ?? 'new'
const pool = Array.from({ length: QD * 2 }, () => Buffer.allocUnsafeSlow(BLOCK))
let poolIdx = 0
const getBuffer = () => (ALLOC === 'pool' ? pool[poolIdx++ % pool.length] : Buffer.allocUnsafe(BLOCK))
const readBuf = Buffer.allocUnsafeSlow(4 * 1024 * 1024)
let state = 'handshake'
let hs = Buffer.alloc(0)
let hdr = Buffer.alloc(16)
let hdrLen = 0
let cur // { dest, filled }
let done = 0
let sent = 0
const nbBlocks = TOTAL / 2
let resolveEnd
const end = new Promise(r => (resolveEnd = r))
const t0 = performance.now()
const cpu0 = process.cpuUsage()
const sock = net.connect({
host: env.NBD_HOST,
port: Number(env.NBD_PORT),
onread: {
buffer: readBuf,
callback(n, buf) {
let off = 0
if (state === 'handshake') {
hs = Buffer.concat([hs, buf.subarray(0, n)])
if (hs.length >= 18 && hs.length < 18 + 134) {
if (!this.sentOpt) {
this.sentOpt = true
const b = Buffer.alloc(4 + 16)
b.writeUInt32BE(1, 0) // client flags: FIXED_NEWSTYLE
b.write('IHAVEOPT', 4)
b.writeUInt32BE(1, 12) // NBD_OPT_EXPORT_NAME
b.writeUInt32BE(0, 16)
sock.write(b)
}
}
if (hs.length >= 18 + 134) {
state = 'data'
for (let i = 0; i < QD; i++) sendReq()
}
return
}
while (off < n) {
if (cur === undefined) {
const k = Math.min(16 - hdrLen, n - off)
buf.copy(hdr, hdrLen, off, off + k)
hdrLen += k
off += k
if (hdrLen === 16) {
hdrLen = 0
cur = { dest: getBuffer(), filled: 0 }
}
continue
}
const k = Math.min(BLOCK - cur.filled, n - off)
buf.copy(cur.dest, cur.filled, off, off + k)
cur.filled += k
off += k
if (cur.filled === BLOCK) {
cur = undefined
done++
if (sent < nbBlocks) sendReq()
else if (done === nbBlocks) resolveEnd()
}
}
},
},
})
const req = Buffer.alloc(28)
function sendReq() {
const i = sent++ % CYCLE
const b = Buffer.allocUnsafe(28)
b.writeUInt32BE(0x25609513, 0)
b.writeUInt16BE(0, 4)
b.writeUInt16BE(0, 6)
b.writeBigUInt64BE(BigInt(sent), 8)
b.writeBigUInt64BE(BigInt(i) * BigInt(BLOCK), 16)
b.writeUInt32BE(BLOCK, 24)
sock.write(b)
}
await end
const s = (performance.now() - t0) / 1e3
const cpu = process.cpuUsage(cpu0)
console.log(JSON.stringify({ client: 'onread', ALLOC, QD, MBps: Math.round(TOTAL / s), cpuPct: Math.round(((cpu.user + cpu.system) / 1e6 / s) * 100) }))
process.exit(0)

View File

@@ -0,0 +1,120 @@
// Minimal plain NBD read client using net.Socket `onread`: the kernel writes into a reused
// buffer, no per-chunk allocation. Measures how fast the client side can go.
// env: NBD_HOST NBD_PORT QD (in-flight requests) SIZE_GB CYCLE_MB ALLOC=new|pool
import net from 'node:net'
import tls from 'node:tls'
import { once } from 'node:events'
import { performance } from 'node:perf_hooks'
const env = process.env
const BLOCK = 2 * 1024 * 1024
const QD = Number(env.QD ?? 16)
const TOTAL = Number(env.SIZE_GB ?? 8) * 1024
const CYCLE = Number(env.CYCLE_MB ?? 512) / 2
const ALLOC = env.ALLOC ?? 'new'
const pool = Array.from({ length: QD * 2 }, () => Buffer.allocUnsafeSlow(BLOCK))
let poolIdx = 0
const getBuffer = () => (ALLOC === 'pool' ? pool[poolIdx++ % pool.length] : Buffer.allocUnsafe(BLOCK))
const readBuf = Buffer.allocUnsafeSlow(4 * 1024 * 1024)
let hdr = Buffer.alloc(16)
let hdrLen = 0
let cur // { dest, filled }
let done = 0
let sent = 0
const nbBlocks = TOTAL / 2
let resolveEnd
const end = new Promise(r => (resolveEnd = r))
const t0 = performance.now()
const cpu0 = process.cpuUsage()
function onData(buf, n) {
let off = 0
while (off < n) {
if (cur === undefined) {
const k = Math.min(16 - hdrLen, n - off)
buf.copy(hdr, hdrLen, off, off + k)
hdrLen += k
off += k
if (hdrLen === 16) {
hdrLen = 0
cur = { dest: getBuffer(), filled: 0 }
}
continue
}
const k = Math.min(BLOCK - cur.filled, n - off)
buf.copy(cur.dest, cur.filled, off, off + k)
cur.filled += k
off += k
if (cur.filled === BLOCK) {
cur = undefined
done++
if (sent < nbBlocks) sendReq()
else if (done === nbBlocks) resolveEnd()
}
}
}
// handshake on a paused raw socket
const raw = net.connect({ host: env.NBD_HOST, port: Number(env.NBD_PORT) })
await once(raw, 'connect')
const readRaw = async n => {
while (raw.readableLength < n) await once(raw, 'readable')
return raw.read(n)
}
await readRaw(18)
let sock
const opt = (o, sockw) => {
const b = Buffer.alloc(16)
b.write('IHAVEOPT', 0)
b.writeUInt32BE(o, 8)
b.writeUInt32BE(0, 12)
sockw.write(b)
}
const flags = Buffer.alloc(4)
flags.writeUInt32BE(1)
raw.write(flags)
if (env.TLS === '1') {
opt(5, raw)
await readRaw(20)
sock = tls.connect({ socket: raw, rejectUnauthorized: false })
await once(sock, 'secureConnect')
opt(1, sock)
let hs = Buffer.alloc(0)
await new Promise(resolve => {
const onHs = d => {
hs = Buffer.concat([hs, d])
if (hs.length >= 134) {
sock.off('data', onHs)
if (hs.length > 134) throw new Error('unexpected data')
resolve()
}
}
sock.on('data', onHs)
})
sock.on('data', d => onData(d, d.length))
} else {
opt(1, raw)
await readRaw(134)
sock = raw
// plain: zero-allocation reads into a reused buffer is only possible through `onread` at connect time,
// here (already connected) we just use 'data' events unless ONREAD=1 handled by the legacy path
sock.on('data', d => onData(d, d.length))
}
for (let i = 0; i < QD; i++) sendReq()
const req = Buffer.alloc(28)
function sendReq() {
const i = sent++ % CYCLE
const b = Buffer.allocUnsafe(28)
b.writeUInt32BE(0x25609513, 0)
b.writeUInt16BE(0, 4)
b.writeUInt16BE(0, 6)
b.writeBigUInt64BE(BigInt(sent), 8)
b.writeBigUInt64BE(BigInt(i) * BigInt(BLOCK), 16)
b.writeUInt32BE(BLOCK, 24)
sock.write(b)
}
await end
const s = (performance.now() - t0) / 1e3
const cpu = process.cpuUsage(cpu0)
console.log(JSON.stringify({ client: env.TLS === '1' ? 'tls-data' : 'plain-data', ALLOC, QD, MBps: Math.round(TOTAL / s), cpuPct: Math.round(((cpu.user + cpu.system) / 1e6 / s) * 100) }))
process.exit(0)

View File

@@ -0,0 +1,67 @@
// Open N NBD connections in parallel (spread over several VDIs) on a real XAPI host, hold them,
// report how many succeed and the connection latency
// env: HOST, XAPI_PASSWORD, N, VDIS (comma separated uuids), NBD_ADDRESS (filter), SERIAL=1 to connect one by one
import NbdClient from '@vates/nbd-client'
import { performance } from 'node:perf_hooks'
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const { HOST, XAPI_PASSWORD, N = '20', VDIS, NBD_ADDRESS, SERIAL } = process.env
let id = 0
async function call(method, ...params) {
const res = await fetch(`https://${HOST}/jsonrpc`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: ++id }),
})
const json = await res.json()
if (json.error) throw new Error(JSON.stringify(json.error))
return json.result
}
const session = await call('session.login_with_password', 'root', XAPI_PASSWORD, '1.0', 'nbd-limit-test')
const infos = []
for (const uuid of VDIS.split(',')) {
const ref = await call('VDI.get_by_uuid', session, uuid)
let nbd = await call('VDI.get_nbd_info', session, ref)
if (infos.length === 0) console.log('nbd_info addresses', nbd.map(_ => _.address))
if (NBD_ADDRESS) nbd = nbd.filter(_ => _.address === NBD_ADDRESS)
infos.push(nbd[0])
}
const clients = []
const results = []
const t0 = performance.now()
async function open(i) {
const info = infos[i % infos.length]
const client = new NbdClient(info, { connectTimeout: 120e3, readBlockRetries: 1, reconnectRetry: 1 })
const start = performance.now()
try {
await client.connect()
await client.readBlock(0, 2 * 1024 * 1024)
clients.push(client)
results.push({ i, ok: true, ms: Math.round(performance.now() - start) })
} catch (error) {
results.push({ i, ok: false, ms: Math.round(performance.now() - start), error: error.code ?? error.message })
client.disconnect().catch(() => {})
}
}
if (SERIAL === '1') {
for (let i = 0; i < Number(N); i++) await open(i)
} else {
await Promise.all(Array.from({ length: Number(N) }, (_, i) => open(i)))
}
const ok = results.filter(_ => _.ok)
const ko = results.filter(_ => !_.ok)
console.log(
JSON.stringify({
N: Number(N),
ok: ok.length,
failed: ko.length,
totalMs: Math.round(performance.now() - t0),
connectMs: ok.map(_ => _.ms).sort((a, b) => a - b),
errors: [...new Set(ko.map(_ => _.error))],
})
)
const td = performance.now()
await Promise.all(clients.map(c => c.disconnect().catch(() => {})))
console.log('disconnect all ms', Math.round(performance.now() - td))
await call('session.logout', session)
process.exit(0)

View File

@@ -0,0 +1,36 @@
// does tls.connect honour `onread`? STARTTLS on a NBD server then count onread vs 'data'
import net from 'node:net'
import tls from 'node:tls'
import { once } from 'node:events'
const port = Number(process.env.NBD_PORT ?? 10810)
const raw = net.connect({ host: '127.0.0.1', port })
await once(raw, 'connect')
const read = async n => {
while (raw.readableLength < n) await once(raw, 'readable')
return raw.read(n)
}
await read(18)
const opt = Buffer.alloc(20)
opt.writeUInt32BE(1, 0)
opt.write('IHAVEOPT', 4)
opt.writeUInt32BE(5, 12) // STARTTLS
opt.writeUInt32BE(0, 16)
raw.write(opt)
await read(20)
let onreadCalls = 0
let dataEvents = 0
const s = tls.connect({
socket: raw,
rejectUnauthorized: false,
onread: { buffer: Buffer.alloc(65536), callback: () => void onreadCalls++ },
})
s.on('data', () => dataEvents++)
await once(s, 'secureConnect')
const b = Buffer.alloc(16)
b.write('IHAVEOPT', 0)
b.writeUInt32BE(1, 8) // EXPORT_NAME ''
b.writeUInt32BE(0, 12)
s.write(b)
await new Promise(r => setTimeout(r, 500))
console.log({ onreadCalls, dataEvents })
process.exit(0)

View File

@@ -0,0 +1,57 @@
// Reads a whole VDI through XapiDiskSource (NBD), like a full backup, and reports which path was used
// env: VDI, BASE, PREFER_NBD, NBD_CONC (default 1), XAPI_PASSWORD, XAPI_URL, MAX_S (stop after N seconds)
import { Xapi } from '@xen-orchestra/xapi'
import { XapiDiskSource } from '@xen-orchestra/xapi/disks/Xapi.mjs'
const env = process.env
const xapi = new Xapi({
url: env.XAPI_URL ?? 'https://192.168.1.5',
allowUnauthorized: true,
auth: { user: 'root', password: env.XAPI_PASSWORD },
preferNbd: env.PREFER_NBD !== '0',
})
await xapi.connect()
await xapi.objectsFetched
async function pluginExports() {
try {
return JSON.parse(await xapi.call('host.call_plugin', xapi.pool.master, 'xo-nbd', 'status', {})).length
} catch (error) {
return error.code ?? error.message
}
}
const vdiRef = await xapi.call('VDI.get_by_uuid', env.VDI)
// BASE: uuid of the base VDI for a delta read; PREFER_NBD=0: data through the XAPI HTTP export
const baseRef = env.BASE ? await xapi.call('VDI.get_by_uuid', env.BASE) : undefined
const preferNbd = env.PREFER_NBD !== '0'
const source = new XapiDiskSource({ xapi, vdiRef, baseRef, preferNbd, nbdConcurrency: Number(env.NBD_CONC ?? 1) })
const t0 = performance.now()
await source.init()
const initMs = Math.round(performance.now() - t0)
const exportsDuringTransfer = await pluginExports()
let bytes = 0
let blocks = 0
const t1 = performance.now()
const maxMs = Number(env.MAX_S ?? Infinity) * 1e3 // optional time limit: stop after MAX_S seconds
for await (const block of source.diskBlocks()) {
bytes += block.data.length
blocks++
block.release?.()
if (performance.now() - t1 > maxMs) break
}
const s = (performance.now() - t1) / 1e3
const exportsAfter = await pluginExports()
console.log(
JSON.stringify({
useNbd: source.useNbd(),
pluginExportsDuringTransfer: exportsDuringTransfer,
pluginExportsAfterClose: exportsAfter,
initMs,
blocks,
MB: Math.round(bytes / 1048576),
MBps: Math.round(bytes / 1048576 / s),
})
)
await xapi.disconnect()
process.exit(0)

View File

@@ -0,0 +1,137 @@
// xoworker.mjs : prototype, the NBD/TLS source runs in a worker_thread, the main thread only writes the VhdDirectory
// The worker reads with XapiDiskSource (plugin or xapi-nbd), copies each block into a
// recycled ArrayBuffer and transfers it (no copy) to the main thread; the main thread gives it back once the
// block file is written. CREDITS bounds the blocks in flight (memory: CREDITS x 2 MiB).
// env: VDI, XAPI_URL, XAPI_PASSWORD, NBD_CONC, REMOTE (path or null), WBC, CREDITS (default 16), MAX_S
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
import { readFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
const BLOCK = 2 * 1024 * 1024
if (!isMainThread) {
// ---------------------------------------------------------------- worker: the source
const env = workerData
const { Xapi } = await import('@xen-orchestra/xapi')
const { XapiDiskSource } = await import('@xen-orchestra/xapi/disks/Xapi.mjs')
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const xapi = new Xapi({ url: env.XAPI_URL, allowUnauthorized: true, auth: { user: 'root', password: env.XAPI_PASSWORD }, preferNbd: true })
await xapi.connect()
await xapi.objectsFetched
const vdiRef = await xapi.call('VDI.get_by_uuid', env.VDI)
const source = new XapiDiskSource({ xapi, vdiRef, preferNbd: true, nbdConcurrency: Number(env.NBD_CONC ?? 1) })
await source.init()
const free = []
let credits = Number(env.CREDITS ?? 16)
let wake
parentPort.on('message', ab => {
free.push(ab)
credits++
wake?.()
})
parentPort.postMessage({ type: 'meta', size: source.getVirtualSize(), blockSize: source.getBlockSize(), indexes: source.getBlockIndexes() })
const t0 = performance.now()
const maxMs = Number(env.MAX_S ?? Infinity) * 1e3
for await (const block of source.diskBlocks()) {
while (credits === 0) await new Promise(resolve => (wake = resolve))
credits--
const ab = free.pop() ?? new ArrayBuffer(BLOCK)
block.data.copy(Buffer.from(ab))
block.release?.()
parentPort.postMessage({ type: 'block', index: block.index, length: block.data.length, ab }, [ab])
if (performance.now() - t0 > maxMs) break
}
parentPort.postMessage({ type: 'end', elu: +performance.eventLoopUtilization().utilization.toFixed(2) })
await xapi.disconnect()
} else {
// ---------------------------------------------------------------- main: the writer
const env = process.env
const { RandomAccessDisk, ThrottledDisk, SynchronizedDisk } = await import('@xen-orchestra/disk-transform')
const { Throttle } = await import('@vates/generator-toolbox')
const { getHandler } = await import('@xen-orchestra/fs')
const { writeToVhdDirectory } = await import('vhd-lib/disk-consumer/index.mjs')
const worker = new Worker(new URL(import.meta.url), { workerData: { ...env } })
const queue = []
let waiting
let meta
const metaReady = new Promise(resolve => {
worker.on('message', message => {
if (message.type === 'meta') return resolve((meta = message))
queue.push(message)
waiting?.()
})
})
worker.on('error', error => {
console.error(error)
process.exit(1)
})
await metaReady
let bytes = 0
let workerElu
const source = new (class extends RandomAccessDisk {
async init() {}
async close() {}
getVirtualSize() { return meta.size }
getBlockSize() { return meta.blockSize }
getBlockIndexes() { return meta.indexes }
hasBlock(i) { return meta.indexes.includes(i) }
isDifferencing() { return false }
async readBlock() { throw new Error('sequential only') }
async *buildDiskBlockGenerator() {
while (true) {
while (queue.length === 0) await new Promise(resolve => (waiting = resolve))
const message = queue.shift()
if (message.type === 'end') {
workerElu = message.elu
return
}
bytes += message.length
let released = false
yield {
index: message.index,
data: Buffer.from(message.ab, 0, message.length),
release: () => {
if (!released) {
released = true
worker.postMessage(message.ab, [message.ab])
}
},
}
}
}
})()
const remote = env.REMOTE ?? '/data/bench/remote'
const url = remote === 'null' ? 'file:///data/bench/remote' : `file://${remote}`
await rm(`${url.slice(7)}/vm`, { recursive: true, force: true })
const handler = getHandler({ url })
await handler.sync()
if (remote === 'null') {
const outputFile = handler._outputFile.bind(handler)
handler._outputFile = async (file, data, opts) => (file.includes('/blocks/') ? undefined : outputFile(file, data, opts))
}
const disk = new SynchronizedDisk(new ThrottledDisk(source, new Throttle(0))).fork('IncrementalRemoteWriter')
const rss0 = process.memoryUsage().rss
let rssPeak = rss0
const rssTimer = setInterval(() => (rssPeak = Math.max(rssPeak, process.memoryUsage().rss)), 200)
const cpu0 = process.cpuUsage()
const elu0 = performance.eventLoopUtilization()
const t0 = performance.now()
await writeToVhdDirectory({
disk,
target: { handler, path: 'vm/disk.alias.vhd', concurrency: Number(env.WBC ?? 16), compression: undefined, validator: async () => {} },
})
const s = (performance.now() - t0) / 1e3
clearInterval(rssTimer)
const cpu = process.cpuUsage(cpu0)
const elu = performance.eventLoopUtilization(elu0)
await handler.forget()
console.log(JSON.stringify({
SRC: 'vdi-worker', REMOTE: remote, CREDITS: Number(env.CREDITS ?? 16), MB: Math.round(bytes / 1048576), sec: +s.toFixed(1),
MBps: Math.round(bytes / 1048576 / s), processCpuPct: Math.round(((cpu.user + cpu.system) / 1e6 / s) * 100),
mainElu: +elu.utilization.toFixed(2), workerElu, rssMB: Math.round(rss0 / 1048576), rssPeakMB: Math.round(rssPeak / 1048576),
}))
process.exit(0)
}

View File

@@ -0,0 +1,110 @@
// xowrite.mjs : XO writing a VhdDirectory on a local remote, with the real stack of IncrementalXapi
// (ThrottledDisk -> SynchronizedDisk fork -> writeToVhdDirectory), no compression, no encryption.
// env:
// SRC=mem 64 real 2 MiB blocks cycled from memory: no read cost, the writer alone (SIZE_GB of data)
// SRC=vdi VDI=uuid XapiDiskSource on XAPI_URL (plugin or xapi-nbd like the reads), NBD_CONC
// REMOTE=/path (default /data/bench/remote) or null (block files not written)
// WBC=16 (writeBlockConcurrency), MAX_S (stop the source after N seconds)
import { openSync, readSync, readFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { RandomAccessDisk, ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
import { Throttle } from '@vates/generator-toolbox'
import { getHandler } from '@xen-orchestra/fs'
import { writeToVhdDirectory } from 'vhd-lib/disk-consumer/index.mjs'
const env = process.env
const BLOCK = 2 * 1024 * 1024
let source
let xapi
if (env.SRC === 'vdi') {
const { Xapi } = await import('@xen-orchestra/xapi')
const { XapiDiskSource } = await import('@xen-orchestra/xapi/disks/Xapi.mjs')
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
xapi = new Xapi({ url: env.XAPI_URL, allowUnauthorized: true, auth: { user: 'root', password: env.XAPI_PASSWORD }, preferNbd: true })
await xapi.connect()
await xapi.objectsFetched
const vdiRef = await xapi.call('VDI.get_by_uuid', env.VDI)
source = new XapiDiskSource({ xapi, vdiRef, preferNbd: true, nbdConcurrency: Number(env.NBD_CONC ?? 1) })
await source.init()
} else {
const size = Number(env.SIZE_GB ?? 16) * 1024 ** 3
const fd = openSync('/data/bench/mixed.raw', 'r')
const pool = []
for (let i = 0; i < 64; i++) {
const b = Buffer.allocUnsafe(BLOCK)
readSync(fd, b, 0, BLOCK, ((i * 13) % 1024) * BLOCK)
pool.push(b)
}
const indexes = Array.from({ length: size / BLOCK }, (_, i) => i)
source = new (class extends RandomAccessDisk {
async init() {}
async close() {}
getVirtualSize() { return size }
getBlockSize() { return BLOCK }
getBlockIndexes() { return indexes }
hasBlock() { return true }
isDifferencing() { return false }
async readBlock(index) { return { index, data: pool[index % 64] } }
})()
}
// MAX_S: stop the source after N seconds (the VHD is still finalized)
const maxMs = Number(env.MAX_S ?? Infinity) * 1e3
let bytes = 0
const counted = new (class extends RandomAccessDisk {
async init() {}
async close() { return source.close() }
getVirtualSize() { return source.getVirtualSize() }
getBlockSize() { return source.getBlockSize() }
getBlockIndexes() { return source.getBlockIndexes() }
hasBlock(i) { return source.hasBlock(i) }
isDifferencing() { return false }
async *buildDiskBlockGenerator() {
for await (const block of await source.buildDiskBlockGenerator()) {
bytes += block.data.length
yield block
if (performance.now() - t0 > maxMs) break
}
}
async readBlock(i) { return source.readBlock(i) }
})()
const remote = env.REMOTE ?? '/data/bench/remote'
const url = remote === 'null' ? 'file:///data/bench/remote' : `file://${remote}`
await rm(`${url.slice(7)}/vm`, { recursive: true, force: true })
const handler = getHandler({ url })
await handler.sync()
if (remote === 'null') {
const outputFile = handler._outputFile.bind(handler)
handler._outputFile = async (file, data, opts) => (file.includes('/blocks/') ? undefined : outputFile(file, data, opts))
}
const disk = new SynchronizedDisk(new ThrottledDisk(counted, new Throttle(0))).fork('IncrementalRemoteWriter')
// what the VM's block device really wrote, from /proc/diskstats (sectors written)
const dev = env.DEV ?? 'xvdc'
const written = () => Number(readFileSync('/proc/diskstats', 'utf8').split('\n').find(l => l.trim().split(/\s+/)[2] === dev).trim().split(/\s+/)[9]) * 512
const rss0 = process.memoryUsage().rss
let rssPeak = rss0
const rssTimer = setInterval(() => (rssPeak = Math.max(rssPeak, process.memoryUsage().rss)), 200)
const w0 = written()
const cpu0 = process.cpuUsage()
const elu0 = performance.eventLoopUtilization()
const t0 = performance.now()
await writeToVhdDirectory({
disk,
target: { handler, path: 'vm/disk.alias.vhd', concurrency: Number(env.WBC ?? 16), compression: undefined, validator: async () => {} },
})
const s = (performance.now() - t0) / 1e3
clearInterval(rssTimer)
const cpu = process.cpuUsage(cpu0)
const elu = performance.eventLoopUtilization(elu0)
await handler.forget()
await xapi?.disconnect()
console.log(JSON.stringify({
SRC: env.SRC ?? 'mem', REMOTE: remote, WBC: Number(env.WBC ?? 16), UV: Number(env.UV_THREADPOOL_SIZE ?? 4),
MB: Math.round(bytes / 1048576), sec: +s.toFixed(1), MBps: Math.round(bytes / 1048576 / s),
diskMBps: Math.round((written() - w0) / 1048576 / s), cpuPct: Math.round(((cpu.user + cpu.system) / 1e6 / s) * 100),
elu: +elu.utilization.toFixed(2),
rssMB: Math.round(rss0 / 1048576), rssPeakMB: Math.round(rssPeak / 1048576),
}))
process.exit(0)

View File

@@ -0,0 +1,40 @@
import { DiskPassthrough } from '@xen-orchestra/disk-transform'
import { createLogger } from '@xen-orchestra/log'
const { warn } = createLogger('xo:backups:discardedDisk')
// BENCHMARK ONLY: with XO_BENCH_DISCARD_BLOCKS=1, the disks are read entirely from their source then their
// blocks are dropped, so the read path can be measured without being limited by the remote.
// The backups written are VALID BUT EMPTY VHDs (no block in the BAT): use a throwaway remote.
export const DISCARD_BLOCKS = process.env.XO_BENCH_DISCARD_BLOCKS === '1'
/**
* Exposes no block while reading the whole source, so that any consumer writes a consistent empty VHD
*/
export class DiscardedDisk extends DiskPassthrough {
#bytesRead = 0
get bytesRead() {
return this.#bytesRead
}
getBlockIndexes() {
return []
}
getBlockIndexesCount() {
return 0
}
hasBlock() {
return false
}
async *diskBlocks() {
warn('XO_BENCH_DISCARD_BLOCKS is set: the blocks of this disk are read then dropped, the backup is EMPTY')
for await (const block of this.source.diskBlocks()) {
this.#bytesRead += block.data.length
block.release?.()
}
}
}

View File

@@ -5,15 +5,18 @@ import { CancelToken } from 'promise-toolbox'
import { compareVersions } from 'compare-versions'
import { defer } from 'golike-defer'
import { Task } from '@vates/task'
import { createLogger } from '@xen-orchestra/log'
import { cancelableMap } from './_cancelableMap.mjs'
import pick from 'lodash/pick.js'
import { BASE_DELTA_VDI, CONTENT_KEY, COPY_OF, VM_UUID } from './_otherConfig.mjs'
import { VHD_MAX_SIZE, XapiDiskSource } from '@xen-orchestra/xapi'
import { VHD_MAX_SIZE, XapiDiskSource, openNbdDiskWriter } from '@xen-orchestra/xapi'
import { toVhdStream } from 'vhd-lib/disk-consumer/index.mjs'
import { toQcow2Stream } from '@xen-orchestra/qcow2'
const { warn } = createLogger('xo:backups:incrementalVm')
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
// The dynamic memory range MUST respect this inequality at any moment: static_min <= dynamic_min <= dynamic_max <= static_max.
@@ -335,11 +338,42 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
await Promise.all([
// Import VDI contents.
cancelableMap(cancelToken, Object.entries(newVdis), async (cancelToken, [id, vdi]) => {
for (const disk of ensureArray(disks[id])) {
if (disk === null) {
// we restore a backup and reuse completely a local snapshot
continue
// null: we restore a backup and reuse completely a local snapshot
const vdiDisks = ensureArray(disks[id]).filter(disk => disk !== null)
if (vdiDisks.length === 0) {
return
}
// through NBD, the blocks are written at their place in the VDI, several at a time, whatever its format
const nbdWriter = await openNbdDiskWriter(xapi, vdi.$ref).catch(error => {
warn('NBD write unusable, importing through XAPI', { vdi: vdi.uuid, error })
})
if (nbdWriter !== undefined) {
// without a base (and outside of a live mount), the VDI has just been created and reads as zeroes:
// a full disk written on it doesn't need its zeroes. The zeroes of a differencing disk may erase
// data and are always written
const { baseVdi, liveMountedVdiRef } = vdiRecords[id]
const skipZeroBlocks =
baseVdi?.$ref === undefined &&
liveMountedVdiRef === undefined &&
vdiDisks.length === 1 &&
!vdiDisks[0].isDifferencing()
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
try {
for (const disk of vdiDisks) {
await nbdWriter.writeDisk(disk, { cancelToken, skipZeroBlocks })
}
} catch (error) {
await nbdWriter.abort()
throw error
}
// the written data are only guaranteed once the export is closed
await nbdWriter.close()
await xapi.setField('VDI', vdi.$ref, 'name_label', vdiRecords[id].name_label)
return
}
for (const disk of vdiDisks) {
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
let stream, format

View File

@@ -21,11 +21,14 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
for (const metadata of transferList) {
const stream = this._throttleStream(await this._sourceRemoteAdapter.readFullVmBackup(metadata))
const sizeContainer = watchStreamSize(stream)
// the unencrypted destinations store the same data as the source: no need to compute their checksum again
const checksum = await this._sourceRemoteAdapter.readFullVmBackupChecksum(metadata)
// @todo shouldn't transfer backup if it will be deleted by retention policy (higher retention on source than destination)
await this._callWriters(
writer =>
writer.run({
checksum,
stream: forkStreamUnpipe(stream),
// stream will be forked and transformed, it's not safe to attach additional properties to it
streamLength: stream.length,

View File

@@ -62,14 +62,26 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
})
// yeah , let's go
}
/**
* the block files can be copied as stored, never decompressed nor recompressed, if all the writers write VHD
* directories with the same compression (the source disks must also use it, see RemoteVhdDisk#useRawBlocks)
* @returns {string | undefined}
*/
#getRawBlocksCompression() {
const compressions = new Set([...this._writers].map(writer => writer.getVhdDirectoryCompression()))
return compressions.size === 1 ? [...compressions][0] : undefined
}
async _run() {
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
const rawBlocksCompression = this.#getRawBlocksCompression()
const nbTransferrableVms = transferList.length
let nbTransferredVms = 0
for (const metadata of transferList) {
assert.strictEqual(metadata.mode, 'delta')
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
useChain: false,
rawBlocksCompression,
})
// don't trust metadata too much
// recompute if it's a base backup

View File

@@ -1,5 +1,14 @@
import { AbstractAggregatedRemoteWriter } from './_AbstractAggregatedRemoteWriter.mjs'
export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteWriter {
/**
* the remote written is only chosen during the backup: only defined if all of them use the same compression
* @returns {string | undefined}
*/
getVhdDirectoryCompression() {
const compressions = new Set(Object.values(this.adapters).map(adapter => adapter.getVhdDirectoryCompression()))
return compressions.size === 1 ? [...compressions][0] : undefined
}
async deleteOldBackupsOnAdapter(adapter, backups) {
await adapter.deleteDeltaVmBackups(backups)
}

View File

@@ -25,7 +25,11 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
)
}
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
/**
* @param {object} params
* @param {string} [params.checksum] - checksum of the stream data if known (full mirror of an unencrypted backup)
*/
async _run({ checksum, maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
const settings = this._settings
const job = this._job
const scheduleId = this._schedule.id
@@ -67,6 +71,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
await Task.run({ properties: { name: 'transfer' } }, async () => {
await adapter.outputStream(dataFilename, stream, {
checksum,
maxStreamLength,
streamLength,
validator: tmpPath => adapter.isValidXva(tmpPath),

View File

@@ -23,6 +23,14 @@ import { diskHasData } from './_diskHasData.mjs'
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
/**
* @returns {string | undefined} the compression of the VHD directories this writer writes, undefined if it does
* not write VHD directories
*/
getVhdDirectoryCompression() {
return this._adapter.getVhdDirectoryCompression()
}
#parentVdiPaths
#parentUuids
async checkBaseVdis(baseUuidToSrcVdi) {

View File

@@ -1,9 +1,18 @@
import { AbstractWriter } from './_AbstractWriter.mjs'
export class AbstractFullWriter extends AbstractWriter {
async run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
async run({ checksum, maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
try {
return await this._run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot })
return await this._run({
checksum,
maxStreamLength,
timestamp,
sizeContainer,
stream,
streamLength,
vm,
vmSnapshot,
})
} finally {
// ensure stream is properly closed
stream.destroy()

View File

@@ -0,0 +1,64 @@
/**
* Recycles the memory of the blocks: allocating a new 2MB Buffer per block makes V8 run a major GC dozens of
* times per second at high throughput (each Buffer is external memory), whereas reusing them keeps it idle.
*
* The pool never blocks nor fails: when no buffer is free, a new one is allocated, and buffers which are never
* released are simply garbage collected.
*/
export class BlockBufferPool {
#blockSize: number
#free: Buffer[] = []
#maxFree: number
#allocated = 0
#reused = 0
get allocated(): number {
return this.#allocated
}
get reused(): number {
return this.#reused
}
get blockSize(): number {
return this.#blockSize
}
/**
* @param params.blockSize length of the buffers
* @param params.maxFree maximum number of buffers kept for reuse, the others are left to the GC
*/
constructor({ blockSize, maxFree = 32 }: { blockSize: number; maxFree?: number }) {
this.#blockSize = blockSize
this.#maxFree = maxFree
}
/**
* @returns `data` is a `blockSize` bytes buffer, `release` gives it back to the pool (only the first call is
* taken into account)
*/
acquire(): { data: Buffer; release: () => void } {
let data = this.#free.pop()
if (data === undefined) {
// not from the shared Buffer pool of Node, this memory is ours
data = Buffer.allocUnsafeSlow(this.#blockSize)
this.#allocated++
} else {
this.#reused++
}
const acquired = data
let released = false
return {
data: acquired,
release: () => {
// a double release would hand the same memory to two users
if (released) {
return
}
released = true
if (this.#free.length < this.#maxFree) {
this.#free.push(acquired)
}
},
}
}
}

View File

@@ -0,0 +1,125 @@
import { test } from 'node:test'
import assert from 'node:assert'
import { BlockBufferPool } from './BlockBufferPool.mjs'
import { DebugDisk } from './utils/DebugDisk.mjs'
import { DiskBlock } from './Disk.mjs'
import { SynchronizedDisk } from './SynchronizedDisk.mjs'
import { ThrottledDisk } from './Throttled.mjs'
import { Throttle } from '@vates/generator-toolbox'
test('BlockBufferPool', async t => {
await t.test('reuses released buffers', () => {
const pool = new BlockBufferPool({ blockSize: 64 })
const first = pool.acquire()
assert.strictEqual(first.data.length, 64)
first.release()
const second = pool.acquire()
assert.strictEqual(second.data, first.data, 'the released buffer is reused')
assert.strictEqual(pool.allocated, 1)
assert.strictEqual(pool.reused, 1)
})
await t.test('ignores a double release', () => {
const pool = new BlockBufferPool({ blockSize: 8 })
const block = pool.acquire()
block.release()
block.release()
const a = pool.acquire()
const b = pool.acquire()
assert.notStrictEqual(a.data, b.data, 'the same memory must never be handed out twice')
})
await t.test('keeps at most maxFree buffers', () => {
const pool = new BlockBufferPool({ blockSize: 8, maxFree: 1 })
const a = pool.acquire()
const b = pool.acquire()
a.release()
b.release()
pool.acquire()
pool.acquire()
assert.strictEqual(pool.allocated, 3)
assert.strictEqual(pool.reused, 1)
})
})
// a disk whose blocks come from a pool, and which tracks the releases
class PooledDisk extends DebugDisk {
pool: BlockBufferPool
released = 0
constructor(nbBlocks: number, blockSize: number) {
super({ nbBlocks, blockSize, fillRate: 100 })
this.pool = new BlockBufferPool({ blockSize })
}
async readBlock(index: number): Promise<DiskBlock> {
const { data, release } = this.pool.acquire()
data.fill(index)
return {
index,
data,
release: () => {
this.released++
release()
},
}
}
}
test('release through the disk transforms', async t => {
await t.test('ThrottledDisk keeps release', async () => {
const source = new PooledDisk(4, 32)
await source.init()
const disk = new ThrottledDisk(source, new Throttle(0))
for await (const block of disk.diskBlocks()) {
assert.strictEqual(typeof block.release, 'function')
block.release!()
}
assert.strictEqual(source.released, 4)
})
await t.test('SynchronizedDisk releases a block once every fork released it', async () => {
const nbBlocks = 10
const source = new PooledDisk(nbBlocks, 32)
await source.init()
const disk = new SynchronizedDisk(source)
const forks = [disk.fork('a'), disk.fork('b')]
const seen: Array<Array<number>> = [[], []]
await Promise.all(
forks.map(async (fork, i) => {
for await (const block of fork.diskBlocks()) {
// the data must still be valid here, even if the other fork already released it
assert.strictEqual(block.data[0], block.index)
seen[i].push(block.index)
// releasing twice from the same fork must count once
block.release!()
block.release!()
}
})
)
assert.deepStrictEqual(seen[0], seen[1])
assert.strictEqual(source.released, nbBlocks, 'each block released once, after both forks')
})
await t.test('SynchronizedDisk does not release while a fork still holds the block', async () => {
const source = new PooledDisk(3, 32)
await source.init()
const disk = new SynchronizedDisk(source)
const forks = [disk.fork('a'), disk.fork('b')]
const held: Array<DiskBlock> = []
await Promise.all([
(async () => {
for await (const block of forks[0].diskBlocks()) {
block.release!()
}
})(),
(async () => {
for await (const block of forks[1].diskBlocks()) {
held.push(block)
}
})(),
])
assert.strictEqual(source.released, 0, 'fork b never released')
held.forEach(block => block.release!())
assert.strictEqual(source.released, 3)
})
})

View File

@@ -4,6 +4,18 @@ export type DiskBlockData = Buffer
export type DiskBlock = {
index: number // the index of the block. Offset in raw disk is index * blockSize
data: DiskBlockData // thue Buffer like data of this block. Must be blockSize length
/**
* optional: gives the memory of `data` back to its pool once this block has been consumed,
* `data` must not be used after calling it.
* Never calling it is always safe: the memory will be garbage collected
*/
release?: () => void
/**
* optional: when set, `data` is not the data of the block but a block file of a VHD directory as stored
* (bitmap + data, compressed with this algorithm). Only a consumer writing VHD directory blocks with the same
* compression can use it, any other consumer must refuse the block
*/
vhdBlockCompression?: string
}
export type BytesLength = number

View File

@@ -40,9 +40,9 @@ export class DiskLargerBlock extends RandomAccessDisk {
const blockRatio = this.#blockSize / source.getBlockSize()
const firstSourceBlockIndex = index * blockRatio
for (let i = firstSourceBlockIndex; i < firstSourceBlockIndex + blockRatio; i++) {
let data: Buffer | undefined
let sourceBlock: DiskBlock | undefined
if (source.hasBlock(i)) {
data = (await source.readBlock(i)).data
sourceBlock = await source.readBlock(i)
} else {
if (this.isDifferencing()) {
if (this.#parent === undefined) {
@@ -51,11 +51,13 @@ export class DiskLargerBlock extends RandomAccessDisk {
this.#parent = chain
}
const parent = this.#parent!
data = (await parent.readBlock(i)).data
sourceBlock = await parent.readBlock(i)
}
}
if (data !== undefined) {
data.copy(destinationBlockData, (i - firstSourceBlockIndex) * source.getBlockSize())
if (sourceBlock !== undefined) {
sourceBlock.data.copy(destinationBlockData, (i - firstSourceBlockIndex) * source.getBlockSize())
// the data has been copied, the source memory can be reused
sourceBlock.release?.()
}
}
return {

View File

@@ -2,6 +2,16 @@ import { DiskPassthrough } from './DiskPassthrough.mjs'
import { Synchronized } from '@vates/generator-toolbox'
import { Disk, DiskBlock } from './Disk.mjs'
function once(fn: () => void): () => void {
let called = false
return () => {
if (!called) {
called = true
fn()
}
}
}
class ForkedDisk extends DiskPassthrough {
#generator: AsyncGenerator<DiskBlock, any, any>
#generatedDiskBlocks = 0
@@ -19,7 +29,8 @@ class ForkedDisk extends DiskPassthrough {
try {
for await (const block of this.#generator) {
this.#generatedDiskBlocks++
yield block
// the block is shared with the other forks: a consumer releasing it twice must count only once
yield block.release === undefined ? block : { ...block, release: once(block.release) }
}
} finally {
await this.progressHandler?.done()
@@ -34,6 +45,7 @@ class ForkedDisk extends DiskPassthrough {
export class SynchronizedDisk {
#synchronized: Synchronized<DiskBlock, any, any> | undefined
#source: Disk
#nbForks = 0
constructor(source: Disk) {
this.#source = source
@@ -41,10 +53,34 @@ export class SynchronizedDisk {
fork(uid: string): ForkedDisk {
if (this.#synchronized === undefined) {
const generator = this.#source.diskBlocks()
const generator = this.#withSharedRelease(this.#source.diskBlocks())
this.#synchronized = new Synchronized(generator)
}
return new ForkedDisk(this.#source, this.#synchronized.fork(uid) as AsyncGenerator<DiskBlock, any, any>)
// Synchronized forbids forking once the data is flowing: #nbForks is final when the first block is read
const fork = this.#synchronized.fork(uid) as AsyncGenerator<DiskBlock, any, any>
this.#nbForks++
return new ForkedDisk(this.#source, fork)
}
// every fork gets the same block: its memory can only go back to its pool once all of them released it
// a fork which stops early never releases the next blocks, they are then left to the garbage collector
async *#withSharedRelease(generator: AsyncGenerator<DiskBlock>): AsyncGenerator<DiskBlock> {
for await (const block of generator) {
const { release } = block
if (release === undefined) {
yield block
} else {
let remaining = this.#nbForks
yield {
...block,
release: () => {
if (--remaining === 0) {
release()
}
},
}
}
}
}
close() {
return this.#source.close()

View File

@@ -12,11 +12,11 @@ export class ThrottledDisk extends DiskPassthrough {
const generator = await this.source.buildDiskBlockGenerator()
//throttle want to be able to know the length of the data
async function* generatorWithLength() {
for await (const { index, data } of generator) {
for await (const block of generator) {
// keep the optional properties (release)
yield {
index,
data,
length: data.length,
...block,
length: block.data.length,
}
}
}

View File

@@ -1,4 +1,5 @@
export { Disk, DiskBlock, RandomAccessDisk } from './Disk.mjs'
export { BlockBufferPool } from './BlockBufferPool.mjs'
export { DiskChain } from './DiskChain.mjs'
export { DiskLargerBlock } from './DiskLargerBlock.mjs'
export { DiskSmallerBlock } from './DiskSmallerBlock.mjs'

View File

@@ -28,6 +28,7 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
ivLength: 0,
authTagLength: 0,
encryptData: buffer => buffer,
encryptDataParts: buffer => buffer,
encryptStream: stream => stream,
decryptData: buffer => buffer,
decryptStream: stream => stream,
@@ -109,11 +110,17 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
)
}
function encryptData(buffer) {
// data can be an array of buffers, encrypted as their concatenation
// returns the encrypted file as an array of buffers, to be written one after the other
function encryptDataParts(data) {
const iv = crypto.randomBytes(ivLength)
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
const encrypted = cipher.update(buffer)
return Buffer.concat([iv, encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)])
const encrypted = Array.isArray(data) ? data.map(buffer => cipher.update(buffer)) : [cipher.update(data)]
return [iv, ...encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)]
}
function encryptData(data) {
return Buffer.concat(encryptDataParts(data))
}
function decryptData(buffer) {
@@ -128,7 +135,10 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
encrypted = buffer.slice(ivLength)
}
const decrypted = decipher.update(encrypted)
return Buffer.concat([decrypted, decipher.final()])
// final() must always be called: it checks the auth tag
const final = decipher.final()
// nothing is left with stream modes (like GCM): no need to copy the data
return final.length === 0 ? decrypted : Buffer.concat([decrypted, final])
}
return {
@@ -138,6 +148,7 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
authTagLength,
ivLength,
encryptData,
encryptDataParts,
encryptStream,
decryptData,
decryptStream,

View File

@@ -36,6 +36,29 @@ algorithms.forEach(algorithm => {
assert.equal(decrypted.equals(buffer), true)
})
it('rejects tampered data', { skip: algorithm !== 'aes-256-gcm' }, () => {
const encrypted = encryptor.encryptData(buffer)
encrypted[encrypted.length >> 1] ^= 1
assert.throws(() => encryptor.decryptData(encrypted))
})
it('encrypts into parts which concatenated are the encrypted data', () => {
const parts = encryptor.encryptDataParts([buffer.subarray(0, 512), buffer.subarray(512)])
const encrypted = Array.isArray(parts) ? Buffer.concat(parts) : Buffer.concat([parts])
assert.equal(encryptor.decryptData(encrypted).equals(buffer), true)
})
it('handle an array of buffers', () => {
let encrypted = encryptor.encryptData([buffer.subarray(0, 512), buffer.subarray(512)])
// without encryption, the array is kept as is
if (Array.isArray(encrypted)) {
assert.equal(algorithm, 'none')
encrypted = Buffer.concat(encrypted)
}
const decrypted = encryptor.decryptData(encrypted)
assert.equal(decrypted.equals(buffer), true)
})
it('handle stream', async () => {
const stream = Readable.from(buffer)
stream.length = buffer.length

View File

@@ -26,6 +26,10 @@ const computeRate = (hrtime, size) => {
const DEFAULT_TIMEOUT = 12e5 // 20 min
const DEFAULT_MAX_PARALLEL_OPERATIONS = 10
// size of the chunks of the file streams, same as the backup streams: with the 64 KiB of Node, each chunk costs a
// round trip to the thread pool, which limits a sequential read to about 1.5 GB/s
const DEFAULT_HIGH_WATER_MARK = 10 * 1024 * 1024
const ENCRYPTION_DESC_FILENAME = 'encryption.json'
const ENCRYPTION_METADATA_FILENAME = 'metadata.json'
@@ -158,7 +162,7 @@ export default class RemoteHandlerAbstract {
}
}
;({
highWaterMark: this._highWaterMark,
highWaterMark: this._highWaterMark = DEFAULT_HIGH_WATER_MARK,
timeout: this._timeout = DEFAULT_TIMEOUT,
withLimit: this._withLimit = WITH_LIMIT,
withTimeout: this._withTimeout = WITH_TIMEOUT,
@@ -293,8 +297,19 @@ export default class RemoteHandlerAbstract {
* @param {number} [options.dirMode]
* @param {(this: RemoteHandlerAbstract, path: string) => Promise<undefined>} [options.validator] Function that will be called before the data is commited to the remote, if it fails, file should not exist
*/
/**
* @param {object} [options]
* @param {boolean|string} [options.checksum=true] - whether to compute and store the checksum of the stored data,
* or this checksum itself when it is already known (an unencrypted file copied from another remote): it is then
* stored as is, without reading the data
*/
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
path = normalizePath(path)
const knownChecksum = typeof checksum === 'string' ? checksum : undefined
if (knownChecksum !== undefined) {
// the checksum is the one of the stored data, which are different once encrypted
assert.strictEqual(this.isEncrypted, false, `a known checksum can't be used on an encrypted remote for ${path}`)
}
let checksumStream
info('will output stream ', { path, streamLength, maxStreamLength, isEncrypted: this.isEncrypted })
if (this.isEncrypted) {
@@ -305,7 +320,7 @@ export default class RemoteHandlerAbstract {
info('stream is updated ', { path, streamLength, maxStreamLength, overhead })
}
input = this.#encryptor.encryptStream(input)
if (checksum) {
if (checksum === true) {
checksumStream = createChecksumStream()
pipeline(input, checksumStream, noop)
input = checksumStream
@@ -319,7 +334,10 @@ export default class RemoteHandlerAbstract {
if (checksum) {
// using _outputFile means the checksum will NOT be encrypted
// it is by design to allow checking of encrypted files without the key
await this._outputFile(checksumFile(path), await checksumStream.checksum, { dirMode, flags: 'wx' })
await this._outputFile(checksumFile(path), knownChecksum ?? (await checksumStream.checksum), {
dirMode,
flags: 'wx',
})
}
}
@@ -383,8 +401,15 @@ export default class RemoteHandlerAbstract {
await this._mktree(normalizePath(dir), { mode })
}
// data can be an array of buffers, the file is then their concatenation
async outputFile(file, data, { dirMode, flags = 'wx' } = {}) {
const encryptedData = this.#encryptor.encryptData(data)
// the encrypted parts are not concatenated when the handler can write them as is
let encryptedData = this._writesBufferArrays
? this.#encryptor.encryptDataParts(data)
: this.#encryptor.encryptData(data)
if (Array.isArray(encryptedData) && !this._writesBufferArrays) {
encryptedData = Buffer.concat(encryptedData)
}
await this._outputFile(normalizePath(file), encryptedData, { dirMode, flags })
}
@@ -789,6 +814,11 @@ export default class RemoteHandlerAbstract {
throw new Error('Not implemented')
}
// whether _writeFile accepts an array of buffers (written one after the other, without concatenating them)
get _writesBufferArrays() {
return false
}
get isEncrypted() {
return this.#encryptor.id !== 'NULL_ENCRYPTOR'
}

View File

@@ -9,6 +9,7 @@ import { rimraf } from 'rimraf'
import AbstractHandler from './abstract'
import fs from 'fs-extra'
import tmp from 'tmp'
import { Readable } from 'node:stream'
const TIMEOUT = 12e5
@@ -202,6 +203,29 @@ describe('encryption', () => {
})
)
it(
'outputStream refuses a known checksum on an encrypted remote',
Disposable.wrap(async function* () {
const handler = yield getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` })
await assert.rejects(
handler.outputStream('file', Readable.from([Buffer.from('data')]), { checksum: '$1$$known' }),
/encrypted remote/
)
})
)
it(
'outputFile writes an array of buffers on an encrypted remote',
Disposable.wrap(async function* () {
const handler = yield getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` })
const data = Buffer.from('some data, written in several parts')
await handler.outputFile('dir/file', [data.subarray(0, 4), data.subarray(4)])
// encrypted on disk
assert.equal((await fs.readFile(`${dir}/dir/file`)).includes(data), false)
assert.deepEqual(await handler.readFile('dir/file'), data)
})
)
it('sync should fail when changing key on non empty remote ', async () => {
const encryptor = _getEncryptor(DEFAULT_ENCRYPTION_ALGORITHM, '73c1838d7d8a6088ca2317fb5f29cd91')

View File

@@ -4,6 +4,7 @@ import { strict as assert } from 'assert'
import 'dotenv/config'
import { forOwn, random } from 'lodash'
import { tmpdir } from 'os'
import { Readable } from 'node:stream'
import { getHandler } from '.'
@@ -173,12 +174,57 @@ handlers.forEach(url => {
})
})
describe('#createReadStream()', () => {
it('reads 10 MiB chunks', { skip: !url.startsWith('file:') }, async () => {
const data = unsecureRandomBytes(3 * 10 * 1024 * 1024 + 7)
await handler.outputFile('file', data)
const chunks = []
for await (const chunk of await handler.createReadStream('file')) {
chunks.push(chunk)
}
assert.deepEqual(
chunks.map(chunk => chunk.length),
[10 * 1024 * 1024, 10 * 1024 * 1024, 10 * 1024 * 1024, 7]
)
assert.deepEqual(Buffer.concat(chunks), data)
})
})
describe('#outputStream()', () => {
it('stores the checksum of the data', async () => {
await handler.outputStream('file', Readable.from([TEST_DATA]))
assert.match(String(await handler.readFile('file.checksum')), /^\$1\$\$[0-9a-f]{32}$/)
// the stored checksum is valid
const chunks = []
for await (const chunk of await handler.createReadStream('file', { checksum: true })) {
chunks.push(chunk)
}
assert.deepEqual(Buffer.concat(chunks), TEST_DATA)
})
it('stores a known checksum as is', async () => {
await handler.outputStream('file', Readable.from([TEST_DATA]), { checksum: '$1$$known' })
assert.equal(String(await handler.readFile('file.checksum')), '$1$$known')
assert.deepEqual(await handler.readFile('file'), TEST_DATA)
})
it('stores no checksum when disabled', async () => {
await handler.outputStream('file', Readable.from([TEST_DATA]), { checksum: false })
assert.equal((await rejectionOf(handler.readFile('file.checksum'))).code, 'ENOENT')
})
})
describe('#outputFile()', () => {
it('writes data to a file', async () => {
await handler.outputFile('file', TEST_DATA)
assert.deepEqual(await handler.readFile('file'), TEST_DATA)
})
it('writes an array of buffers as their concatenation', async () => {
await handler.outputFile('dir/file', [TEST_DATA.subarray(0, 10), Buffer.alloc(0), TEST_DATA.subarray(10)])
assert.deepEqual(await handler.readFile('dir/file'), TEST_DATA)
})
it('throws on existing files', { skip: skipFsNotInAzure() }, async () => {
await handler.outputFile('file', '')
const error = await rejectionOf(handler.outputFile('file', ''))

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import fromCallback from 'promise-toolbox/fromCallback'
import fs from 'fs-extra'
import fsPromises from 'node:fs/promises'
import lockfile from 'proper-lockfile'
import { createLogger } from '@xen-orchestra/log'
import { execFile } from 'node:child_process'
@@ -51,6 +52,29 @@ function dontAddSyncStackTrace(fn, ...args) {
return fn.apply(this, args)
}
// writes the buffers one after the other with writev: no need to concatenate them
async function writeBuffers(path, buffers, flags) {
const handle = await fsPromises.open(path, flags)
try {
let position = 0
while (buffers.length !== 0) {
const { bytesWritten } = await handle.writev(buffers, position)
position += bytesWritten
// a partial write: skip what has been written
let written = bytesWritten
while (buffers.length !== 0 && written >= buffers[0].length) {
written -= buffers[0].length
buffers = buffers.slice(1)
}
if (written !== 0) {
buffers = [buffers[0].subarray(written), ...buffers.slice(1)]
}
}
} finally {
await handle.close()
}
}
export default class LocalHandler extends RemoteHandlerAbstract {
#addSyncStackTrace
#retriesOnEagain
@@ -58,7 +82,7 @@ export default class LocalHandler extends RemoteHandlerAbstract {
constructor(remote, opts = {}) {
super(remote, opts)
this.#addSyncStackTrace = (opts.syncStackTraces ?? true) ? addSyncStackTrace : dontAddSyncStackTrace
this.#addSyncStackTrace = (opts.syncStackTraces ?? false) ? addSyncStackTrace : dontAddSyncStackTrace
this.#retriesOnEagain = {
delay: 1e3,
retries: 9,
@@ -250,6 +274,13 @@ export default class LocalHandler extends RemoteHandlerAbstract {
}
_writeFile(file, data, { flags }) {
if (Array.isArray(data)) {
return this.#addSyncStackTrace(writeBuffers, this.getFilePath(file), data, flags)
}
return this.#addSyncStackTrace(fs.writeFile, this.getFilePath(file), data, { flag: flags })
}
get _writesBufferArrays() {
return true
}
}

View File

@@ -136,7 +136,7 @@ export abstract class RemoteHandlerAbstract {
abstract read(file: FileDescriptor, buffer: Buffer, position?: number): Promise<ReadResult>
abstract outputStream(path: string, input: Readable, options?: OutputStreamOptions): Promise<void>
abstract outputFile(file: string, data: string | Buffer, options?: OutputFileOptions): Promise<void>
abstract outputFile(file: string, data: string | Buffer | Buffer[], options?: OutputFileOptions): Promise<void>
abstract writeFile(file: string, data: string | Buffer, options?: WriteFileOptions): Promise<void>
abstract write(file: FileArg, buffer: Buffer, position?: number): Promise<void>

View File

@@ -0,0 +1,87 @@
// TEMPORARY: restore of the "QA scale" backup (3 x 20 GiB + system), CPU per thread of the xo-server process
import fs from 'node:fs'
import { execSync } from 'node:child_process'
import { xoConnection } from './client/xoLibClient.js'
const VM = '8f50a1fa-f7a9-b9ab-1d71-f26d6017b35b'
const TARGET_SR = process.argv[2] ?? '29b9f7db-25e9-e068-65e1-0afd793927ff' // diskor Local storage
const runs = Number(process.argv[3] ?? 2)
const xo = await xoConnection({ xoUrl: 'http://localhost', username: 'admin@admin.net', password: 'admin' })
const remote = (await xo.call('remote.getAll')).find(r => r.name === (process.env.REMOTE_NAME ?? 'Test backup QA'))
const jobId = await xo.call('backupNg.createJob', {
name: 'QA scale restore source',
mode: 'delta',
vms: { id: VM },
remotes: { id: remote.id },
schedules: { tmp: { cron: '0 0 1 1 *', enabled: false, name: '' } },
settings: {
'': { preferNbd: true, nbdConcurrency: 1, timezone: 'Europe/Paris' },
tmp: { exportRetention: 1, fullInterval: 1 },
},
})
const schedule = (await xo.call('schedule.getAll')).find(s => s.jobId === jobId)
let backup
try {
await xo.call('backupNg.runJob', { id: jobId, schedule: schedule.id })
backup = (await xo.call('backupNg.listVmBackups', { remotes: [remote.id] }))[remote.id]?.[VM]?.[0]
} finally {
if (backup === undefined) await xo.call('backupNg.deleteJob', { id: jobId })
}
if (backup === undefined) throw new Error('the backup failed')
const pid = execSync('systemctl show -p MainPID --value xo-server').toString().trim()
const threads = () => {
const out = new Map()
for (const tid of fs.readdirSync(`/proc/${pid}/task`)) {
try {
const comm = fs.readFileSync(`/proc/${pid}/task/${tid}/comm`, 'utf8').trim()
const f = fs.readFileSync(`/proc/${pid}/task/${tid}/stat`, 'utf8').split(') ')[1].split(' ')
out.set(tid, { comm, ticks: Number(f[11]) + Number(f[12]) })
} catch {}
}
return out
}
try {
for (let i = 0; i < runs; i++) {
const before = threads()
const t0 = performance.now()
const restored = await xo.call('backupNg.importVmBackup', { id: backup.id, sr: TARGET_SR, settings: {} })
const wall = (performance.now() - t0) / 1e3
const after = threads()
const groups = {}
for (const [tid, { comm, ticks }] of after) {
const g = comm.startsWith('V8Worker')
? 'gc/V8Worker'
: comm.startsWith('libuv')
? 'libuv'
: tid === pid
? 'main'
: 'other'
groups[g] = (groups[g] ?? 0) + (ticks - (before.get(tid)?.ticks ?? 0)) / 100
}
const log = Object.values(await xo.call('backupNg.getLogs', {}))
.filter(l => l.message === 'restore')
.sort((a, b) => b.start - a.start)[0]
const transfer = (log.tasks ?? []).find(t => t.message === 'transfer') ?? log
const sec = (transfer.end - transfer.start) / 1e3
const size = transfer.result?.size ?? log.result?.size
console.log(
JSON.stringify({
run: i + 1,
status: log.status,
GiB: +(size / 2 ** 30).toFixed(1),
transferSec: +sec.toFixed(1),
MiBps: Math.round(size / 2 ** 20 / sec),
wallSec: +wall.toFixed(1),
cpuPctOfTransfer: Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, Math.round((v / sec) * 100)])),
})
)
await xo.call('vm.delete', { id: restored, deleteDisks: true })
}
} finally {
await xo.call('backupNg.deleteJob', { id: jobId })
for (const s of Object.values(await xo.call('xo.getAllObjects', { filter: { type: 'VM-snapshot' } })).filter(
s => s.$snapshot_of === VM
))
await xo.call('vm.delete', { id: s.id })
execSync(`sudo -n rm -rf ${new URL(remote.url).pathname}/xo-vm-backups`)
await xo.close()
}

View File

@@ -0,0 +1,100 @@
// TEMPORARY: backup of the "QA scale" VM with N disks in parallel, CPU per thread of the backup worker
// usage: node _scale.tmp.mjs <diskPerVmConcurrency> [label]
import fs from 'node:fs'
import { execSync } from 'node:child_process'
import { xoConnection } from './client/xoLibClient.js'
const VM = '8f50a1fa-f7a9-b9ab-1d71-f26d6017b35b'
const [concurrency = '1', label = `disks=${concurrency}`] = process.argv.slice(2)
const xo = await xoConnection({ xoUrl: 'http://localhost', username: 'admin@admin.net', password: 'admin' })
const remote = (await xo.call('remote.getAll')).find(r => r.name === (process.env.REMOTE_NAME ?? 'Test backup QA'))
const jobId = await xo.call('backupNg.createJob', {
name: `QA scale ${label}`,
mode: 'delta',
vms: { id: VM },
remotes: { id: remote.id },
schedules: { tmp: { cron: '0 0 1 1 *', enabled: false, name: '' } },
settings: {
'': { preferNbd: true, nbdConcurrency: 1, diskPerVmConcurrency: Number(concurrency), timezone: 'Europe/Paris' },
tmp: { exportRetention: 1, fullInterval: 1 },
},
})
const schedule = (await xo.call('schedule.getAll')).find(s => s.jobId === jobId)
// CPU time (user + system ticks) per thread name of the backup worker, last value seen
const cpu = new Map()
let workerPid
const sample = () => {
try {
// the newest node process running the backup worker
workerPid ??= fs
.readdirSync('/proc')
.filter(pid => /^\d+$/.test(pid))
.filter(pid => {
try {
const argv = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')
return argv[0].endsWith('node') && argv.some(a => a.endsWith('_backupWorker.mjs'))
} catch {
return false
}
})
.sort((a, b) => b - a)[0]
if (workerPid === undefined) return
for (const tid of fs.readdirSync(`/proc/${workerPid}/task`)) {
const comm = fs.readFileSync(`/proc/${workerPid}/task/${tid}/comm`, 'utf8').trim()
const f = fs.readFileSync(`/proc/${workerPid}/task/${tid}/stat`, 'utf8').split(') ')[1].split(' ')
cpu.set(tid, { comm, ticks: Number(f[11]) + Number(f[12]) })
}
} catch {}
}
const timer = setInterval(sample, 500)
const t0 = performance.now()
try {
await xo.call('backupNg.runJob', { id: jobId, schedule: schedule.id })
} finally {
clearInterval(timer)
}
const wall = (performance.now() - t0) / 1e3
// transfer size and duration from the job log
const log = Object.values(await xo.call('backupNg.getLogs', { jobId })).sort((a, b) => b.start - a.start)[0]
const transfers = []
const walk = task => {
if (task.message === 'transfer' && task.result?.size) transfers.push(task)
;(task.tasks ?? []).forEach(walk)
}
walk(log)
const size = transfers.reduce((a, t) => a + t.result.size, 0)
const tStart = Math.min(...transfers.map(t => t.start))
const tEnd = Math.max(...transfers.map(t => t.end))
const groups = {}
for (const { comm, ticks } of cpu.values()) {
const g = comm.startsWith('V8Worker')
? 'gc/V8Worker'
: comm.startsWith('libuv')
? 'libuv'
: comm.startsWith('node') || comm.startsWith('MainThread')
? 'main'
: 'other'
groups[g] = (groups[g] ?? 0) + ticks / 100
}
const transferSec = (tEnd - tStart) / 1e3
console.log(
JSON.stringify({
label,
status: log.status,
GiB: +(size / 2 ** 30).toFixed(1),
transferSec: +transferSec.toFixed(1),
MiBps: Math.round(size / 2 ** 20 / transferSec),
wallSec: +wall.toFixed(1),
cpuSec: Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, +v.toFixed(1)])),
cpuPctOfTransfer: Object.fromEntries(
Object.entries(groups).map(([k, v]) => [k, Math.round((v / transferSec) * 100)])
),
})
)
await xo.call('backupNg.deleteJob', { id: jobId })
for (const s of Object.values(await xo.call('xo.getAllObjects', { filter: { type: 'VM-snapshot' } })).filter(
s => s.$snapshot_of === VM
))
await xo.call('vm.delete', { id: s.id })
execSync(`sudo -n rm -rf ${new URL(remote.url).pathname}/xo-vm-backups`)
await xo.close()

View File

@@ -8,45 +8,55 @@ import { BACKUP_JOB_NAME_PREFIX, getRequiredEnv } from '../utils/index.js'
const log = createLogger('cleanup')
/**
* Allowed paths for automatic backup cleanup of file:// remotes.
* SECURITY: Only test-scoped paths containing 'test', 'qa', or 'tmp/xo'.
* Non-file:// remotes return an empty list (cleanup is delegated to XO API).
* Local path of a file:// remote URL, without its options (query string)
*
* Computed lazily so that `process.env.BACKUP_REPOSITORY_URL` is read at call
* @param {string} repoUrl
* @returns {string|undefined} undefined for an invalid or non-local URL
*/
const getLocalPath = repoUrl => {
// Non-local remotes have no local path to safety-check
if (!repoUrl?.startsWith('file://')) return undefined
try {
return new URL(repoUrl).pathname
} catch {
log.warn('Rejected cleanup URL: invalid URL', { repoUrl })
return undefined
}
}
/**
* Allowed paths for automatic backup cleanup of file:// remotes: the backup repository and the mirror destination.
* SECURITY: Only test-scoped paths containing 'test', 'qa', or 'tmp/xo'.
* Non-file:// remotes are not listed (cleanup is delegated to XO API).
*
* Computed lazily so that the environment variables are read at call
* time rather than module-load time — otherwise ESM import hoisting would make
* it evaluate before any in-code env loading runs.
*
* @returns {Array<string>}
*/
const getAllowedCleanupPaths = () => {
const repoUrl = process.env.BACKUP_REPOSITORY_URL
const allowed = []
for (const repoUrl of [process.env.BACKUP_REPOSITORY_URL, process.env.MIRROR_DESTINATION_REPOSITORY_URL]) {
const repoPath = getLocalPath(repoUrl)
if (repoPath === undefined) continue
// Non-local remotes have no local path to safety-check
if (!repoUrl?.startsWith('file://')) return []
// SECURITY: Reject paths containing path traversal sequences
if (repoPath.includes('..')) {
log.warn('Rejected cleanup path: contains path traversal', { repoPath })
continue
}
let repoPath
try {
repoPath = new URL(repoUrl).pathname
} catch {
log.warn('Rejected cleanup URL: invalid URL', { repoUrl })
return []
const normalized = path.resolve(repoPath).toLowerCase()
const isTestPath = ['test', 'qa', 'tmp/xo'].some(marker => normalized.includes(marker))
if (!isTestPath) {
log.warn('Rejected cleanup path: not a test path', { repoPath })
continue
}
allowed.push(path.resolve(repoPath))
}
// SECURITY: Reject paths containing path traversal sequences
if (repoPath?.includes('..')) {
log.warn('Rejected cleanup path: contains path traversal', { repoPath })
return []
}
const normalized = repoPath ? path.resolve(repoPath).toLowerCase() : ''
const isTestPath = ['test', 'qa', 'tmp/xo'].some(marker => normalized.includes(marker))
if (!isTestPath) {
if (repoPath) log.warn('Rejected cleanup path: not a test path', { repoPath })
return []
}
return [path.resolve(repoPath)]
return allowed
}
/**
@@ -571,16 +581,17 @@ export class CleanupClient {
let repoPath = null
try {
const repoDetails = await this.dispatchClient.backupRepository.details(backupRepositoryId)
if (repoDetails?.url?.startsWith('file://')) {
repoPath = repoDetails.url.replace('file://', '')
}
repoPath = getLocalPath(repoDetails?.url) ?? null
} catch (error) {
log.warn('Could not get repository details', { error })
}
// Clean up backup files if path is allowed
const normalizedPath = repoPath ? path.normalize(repoPath) : null
const isAllowed = normalizedPath && getAllowedCleanupPaths().some(p => normalizedPath.startsWith(p))
const normalizedPath = repoPath ? path.resolve(repoPath) : null
// the allowed directory itself or below it, not a sibling sharing its prefix
const isAllowed =
normalizedPath &&
getAllowedCleanupPaths().some(p => normalizedPath === p || normalizedPath.startsWith(p + path.sep))
if (isAllowed) {
try {

View File

@@ -0,0 +1,140 @@
import assert from 'node:assert'
import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
const { warn } = createLogger('xo:xapi:disks:nbd-writer')
// the xo-nbd plugin accepts at most 4 MiB per request: the disks are written by requests of 2 MiB at most
export const NBD_WRITE_SIZE = 2 * 1024 * 1024
// writes in flight on the single connection of a disk, tapdisk handles 8 requests at a time per connection
export const NBD_WRITE_CONCURRENCY = 8
const isZero = buffer => buffer.every(byte => byte === 0)
/**
* Writes disks into a NBD export opened in write mode, through a single connection
*/
export class NbdDiskWriter {
#client
#closeExport
/** @type {Promise<void>|undefined} */
#exportClosed
#progressHandler
/**
* @param {import('@vates/nbd-client').AbstractNbdClient} client - connected to a writable export
* @param {() => Promise<void>} closeExport - ends the export: the written data are only guaranteed once it resolved
* @param {object} [options]
* @param {{setProgress(progress: number): Promise<void>, done(): Promise<void>, fail(): Promise<void>}} [options.progressHandler]
* - the progress of the disk being written (between 0 and 1), done once the export is closed
*/
constructor(client, closeExport, { progressHandler } = {}) {
this.#client = client
this.#closeExport = closeExport
this.#progressHandler = progressHandler
}
// the progress must never fail nor slow down the import
#report(method, ...args) {
this.#progressHandler?.[method](...args)?.catch?.(error => warn(`NBD writer: progress ${method} failed`, { error }))
}
// called once, whoever asks first
#endExport() {
if (this.#exportClosed === undefined) {
this.#exportClosed = this.#closeExport()
}
return this.#exportClosed
}
/**
* Writes the blocks of `disk` at their place, the rest of the export is not modified: a full disk on a blank
* VDI, or a differencing disk on a copy of its parent
*
* @param {import('@xen-orchestra/disk-transform').Disk} disk
* @param {object} [options]
* @param {{throwIfRequested(): void}} [options.cancelToken]
* @param {number} [options.concurrency] - blocks written at the same time
* @returns {Promise<number>} the number of bytes written
*/
async writeDisk(disk, { cancelToken, concurrency = NBD_WRITE_CONCURRENCY } = {}) {
const client = this.#client
const exportSize = BigInt(client.exportSize)
const blockSize = disk.getBlockSize()
// a block is written by one request, or split in requests of NBD_WRITE_SIZE
const requestSize = Math.min(blockSize, NBD_WRITE_SIZE)
assert.strictEqual(blockSize % requestSize, 0, `can't split blocks of ${blockSize} bytes in requests`)
const requestsPerBlock = blockSize / requestSize
const nbBlocks = disk.getBlockIndexes().length
let nbWrittenBlocks = 0
let written = 0
await asyncEach(
disk.diskBlocks(),
async ({ index, data, release }) => {
cancelToken?.throwIfRequested()
for (let i = 0; i < requestsPerBlock && i * requestSize < data.length; i++) {
const requestIndex = index * requestsPerBlock + i
const offset = BigInt(requestIndex) * BigInt(requestSize)
let chunk = data.subarray(i * requestSize, (i + 1) * requestSize)
if (offset >= exportSize) {
// the disk may be a bit larger than the VDI (VHD geometry): only padding can be beyond the export
assert.ok(isZero(chunk), `block ${index} has data beyond the end of the export`)
continue
}
const remaining = exportSize - offset
const expected = remaining < BigInt(requestSize) ? Number(remaining) : requestSize
if (chunk.length > expected) {
assert.ok(isZero(chunk.subarray(expected)), `block ${index} has data beyond the end of the export`)
chunk = chunk.subarray(0, expected)
} else if (chunk.length < expected) {
// the last block of a disk smaller than the VDI: the rest is zeroes
chunk = Buffer.concat([chunk], expected)
}
await client.writeBlock(requestIndex, chunk, requestSize)
written += chunk.length
}
// the block has been written, its memory can be reused
release?.()
this.#report('setProgress', ++nbWrittenBlocks / nbBlocks)
},
{ concurrency }
)
return written
}
/**
* Ends the export: the written data are only guaranteed once this resolved, a failure means the disk import
* failed
*/
async close() {
const client = this.#client
try {
try {
if (client.canFlush) {
await client.flush()
}
} finally {
try {
await client.disconnect()
} finally {
await this.#endExport()
}
}
} catch (error) {
this.#report('fail')
throw error
}
this.#report('done')
}
/**
* After a failure: frees the connection and the export, without throwing
*/
async abort() {
this.#report('fail')
await this.#client.disconnect().catch(error => warn('NBD writer: disconnection failed', { error }))
await this.#endExport().catch(error => warn('NBD writer: closing the export failed, it will expire', { error }))
}
}

View File

@@ -0,0 +1,195 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { setTimeout as pDelay } from 'node:timers/promises'
import { NBD_WRITE_CONCURRENCY, NBD_WRITE_SIZE, NbdDiskWriter } from './NbdDiskWriter.mjs'
const MiB = 1024 * 1024
// an export in memory, which records what is done to it
class FakeClient {
calls = []
inFlight = 0
maxInFlight = 0
canFlush = true
constructor(exportSize, { failFlush = false } = {}) {
this.data = Buffer.alloc(exportSize, 0xee)
this.exportSize = BigInt(exportSize)
this.failFlush = failFlush
}
async writeBlock(index, data, size) {
const offset = index * size
assert.ok(offset + data.length <= this.data.length, 'write beyond the export')
assert.ok(data.length <= NBD_WRITE_SIZE, 'request too large')
this.maxInFlight = Math.max(this.maxInFlight, ++this.inFlight)
await pDelay(1)
data.copy(this.data, offset)
this.inFlight--
}
async flush() {
this.calls.push('flush')
if (this.failFlush) {
throw new Error('flush failed')
}
}
async disconnect() {
this.calls.push('disconnect')
}
}
// the disk-transform interface used by the writer
function fakeDisk(blockSize, blocks) {
return {
released: 0,
getBlockSize: () => blockSize,
getBlockIndexes: () => blocks.map(([index]) => index),
async *diskBlocks() {
for (const [index, data] of blocks) {
yield { index, data, release: () => this.released++ }
}
},
}
}
describe('NbdDiskWriter', () => {
it('writes the blocks at their place, several at a time, and leaves the rest untouched', async () => {
const client = new FakeClient(64 * MiB)
const writer = new NbdDiskWriter(client, async () => {})
const indexes = Array.from({ length: 20 }, (_, i) => i + 5)
const disk = fakeDisk(
2 * MiB,
indexes.map(index => [index, Buffer.alloc(2 * MiB, index)])
)
assert.equal(await writer.writeDisk(disk), 20 * 2 * MiB)
assert.equal(client.maxInFlight, NBD_WRITE_CONCURRENCY)
assert.equal(disk.released, 20)
for (const index of indexes) {
assert.ok(client.data.subarray(index * 2 * MiB, (index + 1) * 2 * MiB).every(byte => byte === index))
}
assert.ok(
client.data.subarray(0, 5 * 2 * MiB).every(byte => byte === 0xee),
'not written'
)
})
it('splits the blocks larger than a request', async () => {
const client = new FakeClient(16 * MiB)
const writer = new NbdDiskWriter(client, async () => {})
const data = Buffer.concat([Buffer.alloc(2 * MiB, 1), Buffer.alloc(2 * MiB, 2)])
await writer.writeDisk(fakeDisk(4 * MiB, [[2, data]]))
assert.ok(client.data.subarray(8 * MiB, 12 * MiB).equals(data))
})
it('handles a disk a bit larger or smaller than the export', async () => {
// larger: only zeroes can be beyond the export
const small = new FakeClient(3 * MiB)
const writer = new NbdDiskWriter(small, async () => {})
const tail = Buffer.concat([Buffer.alloc(MiB, 7), Buffer.alloc(MiB)])
await writer.writeDisk(
fakeDisk(2 * MiB, [
[1, tail],
[2, Buffer.alloc(2 * MiB)],
])
)
assert.ok(small.data.subarray(2 * MiB).every(byte => byte === 7))
await assert.rejects(
writer.writeDisk(fakeDisk(2 * MiB, [[1, Buffer.alloc(2 * MiB, 1)]])),
/beyond the end of the export/
)
// smaller: the last block is completed with zeroes
const large = new FakeClient(6 * MiB)
await new NbdDiskWriter(large, async () => {}).writeDisk(fakeDisk(2 * MiB, [[1, Buffer.alloc(MiB, 3)]]))
assert.ok(large.data.subarray(2 * MiB, 3 * MiB).every(byte => byte === 3))
assert.ok(large.data.subarray(3 * MiB, 4 * MiB).every(byte => byte === 0))
assert.ok(
large.data.subarray(4 * MiB).every(byte => byte === 0xee),
'not written'
)
})
it('stops when the import is canceled', async () => {
const client = new FakeClient(8 * MiB)
const writer = new NbdDiskWriter(client, async () => {})
const cancelToken = {
throwIfRequested() {
throw new Error('canceled')
},
}
await assert.rejects(
writer.writeDisk(fakeDisk(2 * MiB, [[0, Buffer.alloc(2 * MiB, 1)]]), { cancelToken }),
/canceled/
)
assert.ok(client.data.every(byte => byte === 0xee))
})
it('flushes, disconnects then closes the export, and reports any failure', async () => {
const calls = []
const client = new FakeClient(2 * MiB)
client.calls = calls
await new NbdDiskWriter(client, async () => calls.push('close')).close()
assert.deepEqual(calls, ['flush', 'disconnect', 'close'])
// the data are only guaranteed by a successful close of the export
const failingClose = new NbdDiskWriter(new FakeClient(2 * MiB), async () => {
throw new Error('close failed')
})
await assert.rejects(failingClose.close(), /close failed/)
// a failed flush still frees the connection and the export
const failingFlush = new FakeClient(2 * MiB, { failFlush: true })
const exportCalls = []
failingFlush.calls = exportCalls
await assert.rejects(new NbdDiskWriter(failingFlush, async () => exportCalls.push('close')).close(), /flush failed/)
assert.deepEqual(exportCalls, ['flush', 'disconnect', 'close'])
})
it('aborts without throwing, closing the export only once', async () => {
let closes = 0
const writer = new NbdDiskWriter(new FakeClient(2 * MiB), async () => {
closes++
throw new Error('close failed')
})
await writer.abort()
await writer.abort()
assert.equal(closes, 1)
})
it('reports the progress, then the end of the task', async () => {
const events = []
const progressHandler = {
setProgress: async progress => events.push(progress),
done: async () => events.push('done'),
fail: async () => events.push('fail'),
}
const writer = new NbdDiskWriter(new FakeClient(8 * MiB), async () => {}, { progressHandler })
const disk = fakeDisk(
2 * MiB,
[0, 1, 2, 3].map(index => [index, Buffer.alloc(2 * MiB, 1)])
)
disk.getBlockIndexes = () => [0, 1, 2, 3]
await writer.writeDisk(disk, { concurrency: 1 })
await writer.close()
assert.deepEqual(events, [0.25, 0.5, 0.75, 1, 'done'])
// a failed import fails the task, a failed close too
const failed = []
const failingHandler = {
setProgress: async () => {},
done: async () => failed.push('done'),
fail: async () => failed.push('fail'),
}
await new NbdDiskWriter(new FakeClient(2 * MiB), async () => {}, { progressHandler: failingHandler }).abort()
await assert.rejects(
new NbdDiskWriter(new FakeClient(2 * MiB, { failFlush: true }), async () => {}, {
progressHandler: failingHandler,
}).close()
)
assert.deepEqual(failed, ['fail', 'fail'])
})
})

View File

@@ -37,9 +37,10 @@ export class XapiProgressHandler {
*/
#taskRef
/**
* @type {boolean}
* the creation of the task, shared by the calls made while it is in progress
* @type {Promise<void>|undefined}
*/
#starting = false
#started
/**
* @type {any}
*/
@@ -65,13 +66,36 @@ export class XapiProgressHandler {
this.#minThresholdPercentBetweenProgressEmit = minThresholdPercentBetweenProgressEmit
this.#xapi = xapi
}
async start() {
this.#starting = true
this.#taskRef = await this.#xapi.call('task_create', this.#label, '')
this.#starting = false
// creates the task once: progress is reported by several writers at a time, each call must not create its own
// task, the others would never be ended
start() {
if (this.#started === undefined) {
this.#started = this.#xapi.call('task_create', this.#label, '').then(
taskRef => {
this.#taskRef = taskRef
},
error => {
// the next progress call tries again
this.#started = undefined
throw error
}
)
}
return this.#started
}
async done() {
this.#taskRef && (await this.#xapi.call('task_set_status', this.#taskRef, 'success'))
// the task may still be being created
async #setStatus(status) {
await this.#started?.catch(() => {})
this.#taskRef && (await this.#xapi.call('task_set_status', this.#taskRef, status))
}
done() {
return this.#setStatus('success')
}
fail() {
return this.#setStatus('failure')
}
/**
@@ -81,12 +105,8 @@ export class XapiProgressHandler {
*/
async setProgress(progress) {
if (this.#taskRef === undefined) {
await this.start()
return
}
if (this.#starting === true) {
// the data will be updated on next progress call
return
return this.start()
}
if (progress < 0 || progress > 1) {
return

View File

@@ -8,7 +8,7 @@
import { RandomDiskPassthrough } from '@xen-orchestra/disk-transform'
import { createLogger } from '@xen-orchestra/log'
import { connectNbdClientIfPossible } from './utils.mjs'
import { connectNbdClientIfPossible, readNbdBlock } from './utils.mjs'
const { warn } = createLogger('xo:xapi:XapiStreamNbd')
@@ -80,8 +80,7 @@ export class XapiStreamNbdSource extends RandomDiskPassthrough {
if (this.#nbdClient === undefined) {
throw new Error(`Can't use the nbd client of a XapiVhdStreamNbdSource before init`)
}
const data = await this.#nbdClient.readBlock(index, this.getBlockSize())
return { index, data }
return readNbdBlock(this.#nbdClient, index, this.getBlockSize())
}
async *buildDiskBlockGenerator() {

View File

@@ -5,7 +5,7 @@
*/
import assert from 'node:assert'
import { RandomAccessDisk } from '@xen-orchestra/disk-transform'
import { connectNbdClientIfPossible } from './utils.mjs'
import { connectNbdClientIfPossible, readNbdBlock } from './utils.mjs'
/**
* @typedef {Error & { code: string }} ErrorWithCode
@@ -129,8 +129,7 @@ export class XapiVhdCbtSource extends RandomAccessDisk {
if (this.#onlyListChangedBlocks) {
throw new Error(`Disk ${this.#ref} from ${this.#baseRef} is open in "only list block mode"`)
}
const data = await this.#nbdClient.readBlock(index, this.getBlockSize())
return { index, data }
return readNbdBlock(this.#nbdClient, index, this.getBlockSize())
}
/** @returns {Promise<void>} */

View File

@@ -1,6 +1,290 @@
import MultiNbdClient from '@vates/nbd-client/multi.mjs'
import NbdTcpClient from '@vates/nbd-client/NbdTcpClient.mjs'
import { BlockBufferPool } from '@xen-orchestra/disk-transform'
import { createLogger } from '@xen-orchestra/log'
import { NbdDiskWriter } from './NbdDiskWriter.mjs'
import { XapiProgressHandler } from './XapiProgress.mjs'
const { debug, info, warn } = createLogger('xo:xapi:disks:nbd')
const NBD_BLOCK_SIZE = 2 * 1024 * 1024
// shared by all the NBD exports of this process: reusing the block buffers instead of allocating 2MB per
// block keeps V8 from running a major GC dozens of times per second.
const NBD_BLOCK_POOL = new BlockBufferPool({ blockSize: NBD_BLOCK_SIZE, maxFree: 32 })
/**
* Reads a block through NBD directly into a pooled buffer
*
* @param {MultiNbdClient} nbdClient
* @param {number} index
* @param {number} blockSize
* @returns {Promise<import('@xen-orchestra/disk-transform').DiskBlock>}
*/
export async function readNbdBlock(nbdClient, index, blockSize) {
if (blockSize !== NBD_BLOCK_SIZE) {
return { index, data: await nbdClient.readBlock(index, blockSize) }
}
const { data, release } = NBD_BLOCK_POOL.acquire()
let read
try {
read = await nbdClient.readBlock(index, blockSize, data)
} catch (error) {
// the failed connection does not parse anything anymore, nobody will write in this buffer
release()
throw error
}
return { index, data: read, release }
}
/**
* The addresses of the backup network of the pool, undefined if none is set
*
* @param {any} xapi
* @returns {Promise<string[]|undefined>}
*/
async function getBackupNetworkAddresses(xapi) {
const poolBackupNetwork = xapi.pool.other_config['xo:backupNetwork']
if (!poolBackupNetwork) {
return undefined
}
const networkRef = await xapi.call('network.get_by_uuid', poolBackupNetwork)
const pifs = await xapi.getField('network', networkRef, 'PIFs')
// @todo implement ipv6
return Promise.all(pifs.map(pifRef => xapi.getField('PIF', pifRef, 'IP')))
}
/**
* Whether xapi-nbd serves in TLS: same rule as xapi-nbd itself, TLS unless the pool only has networks with
* the insecure_nbd purpose (it refuses connections when both purposes are set)
*
* @param {any} xapi
* @returns {Promise<boolean>}
*/
async function isNbdTlsRequired(xapi) {
const networks = Object.values(await xapi.call('network.get_all_records'))
const purposes = networks.flatMap(network => network.purpose)
return purposes.includes('nbd') || !purposes.includes('insecure_nbd')
}
// the xo-nbd XAPI plugin serves the VDIs through its own NBD data server, faster than xapi-nbd
const NBD_PLUGIN = 'xo-nbd'
// unreachable plugin server: fall back to xapi-nbd quickly instead of waiting for the default 1 minute
const PLUGIN_CONNECT_TIMEOUT = 10e3
// a pool without the plugin is not asked again before this delay
const PLUGIN_ABSENCE_TTL = 10 * 60e3
const pluginAbsentUntil = new WeakMap()
/**
* @param {any} xapi
* @param {string} hostRef
* @param {string} fn
* @param {Record<string, string>} args
*/
async function callNbdPlugin(xapi, hostRef, fn, args) {
return JSON.parse(await xapi.call('host.call_plugin', hostRef, NBD_PLUGIN, fn, args))
}
/**
* A MultiNbdClient whose disconnection also closes the export of the plugin (which unplugs its VBD)
*/
class PluginNbdClient extends MultiNbdClient {
#closeExport
/**
* @param {object[]} nbdInfos
* @param {object} options
* @param {() => Promise<void>} closeExport - only called once
*/
constructor(nbdInfos, options, closeExport) {
super(nbdInfos, options)
this.#closeExport = closeExport
}
async disconnect() {
try {
await super.disconnect()
} finally {
await this.#closeExport()
}
}
}
/**
* The hosts which can export the VDI through the xo-nbd plugin
*
* @param {any} xapi
* @param {string} vdiUuid
* @param {(address: string) => boolean} keepAddress
* @returns {Promise<Array<{host: string}>|undefined>} undefined when the plugin is not usable on this pool
*/
async function getPluginCandidates(xapi, vdiUuid, keepAddress) {
if (xapi.pool.other_config['xo:nbdPlugin'] === 'false' || (pluginAbsentUntil.get(xapi) ?? 0) > Date.now()) {
return undefined
}
let candidates
try {
// any host can list the candidates: the hosts where the SR of the VDI is attached
candidates = await callNbdPlugin(xapi, xapi.pool.master, 'get_nbd_infos', { vdi_uuid: vdiUuid })
} catch (error) {
if (error.code === 'XENAPI_MISSING_PLUGIN') {
pluginAbsentUntil.set(xapi, Date.now() + PLUGIN_ABSENCE_TTL)
debug('the xo-nbd plugin is not installed')
return undefined
}
throw error
}
// in a random order to spread the exports on the hosts of a shared SR
return candidates.filter(({ addresses }) => addresses.some(keepAddress)).sort(() => Math.random() - 0.5)
}
/**
* Exports the VDI through the xo-nbd plugin and connects to it
*
* @param {any} xapi
* @param {string} vdiRef
* @param {number} nbdConcurrency
* @param {string[]|undefined} backupAddresses - when set, only these addresses are used
* @returns {Promise<MultiNbdClient|undefined>} undefined when the plugin is not usable: the caller falls back to xapi-nbd
*/
async function connectThroughPlugin(xapi, vdiRef, nbdConcurrency, backupAddresses) {
const vdiUuid = await xapi.getField('VDI', vdiRef, 'uuid')
const keepAddress = address => backupAddresses === undefined || backupAddresses.includes(address)
const usable = await getPluginCandidates(xapi, vdiUuid, keepAddress)
if (usable === undefined) {
return undefined
}
for (const candidate of usable) {
const hostRef = await xapi.call('host.get_by_uuid', candidate.host)
let exported
try {
exported = await callNbdPlugin(xapi, hostRef, 'open', { vdi_uuid: vdiUuid })
} catch (error) {
warn('xo-nbd plugin: open failed, trying the next candidate', { vdiUuid, host: candidate.host, error })
continue
}
// closed once, whoever asks first (a failed connection here, or the disconnection of the client)
let closed
const closeExport = () => {
if (closed === undefined) {
closed = callNbdPlugin(xapi, hostRef, 'close', { token: exported.exportname }).catch(error =>
warn('xo-nbd plugin: close failed, the export will expire', { vdiUuid, host: candidate.host, error })
)
}
return closed
}
const nbdInfos = exported.addresses.filter(keepAddress).map(address => ({
address,
port: exported.port,
exportname: exported.exportname,
// the client upgrades to TLS only when given a certificate
cert: exported.tls ? exported.cert : undefined,
}))
const client = new PluginNbdClient(
nbdInfos,
{ nbdConcurrency, connectTimeout: PLUGIN_CONNECT_TIMEOUT },
closeExport
)
try {
await client.connect()
// the export must be this VDI: a different size means another disk, never read it as this one
const virtualSize = BigInt(await xapi.getField('VDI', vdiRef, 'virtual_size'))
if (BigInt(client.exportSize) !== virtualSize) {
throw new Error(`export size ${client.exportSize} differs from the VDI virtual size ${virtualSize}`)
}
info('xo-nbd plugin: connected', { vdiUuid, host: candidate.host, tls: exported.tls })
return client
} catch (error) {
warn('xo-nbd plugin: connection failed, trying the next candidate', { vdiUuid, host: candidate.host, error })
await client.disconnect().catch(() => {})
await closeExport()
}
}
return undefined
}
/**
* Exports the VDI in write mode through the xo-nbd plugin, and connects a single NBD client to it
*
* xapi-nbd only exports read-only: without the plugin, the caller imports the data through XAPI
*
* @param {any} xapi
* @param {string} vdiRef - must not be a snapshot nor be attached (dom0 included)
* @returns {Promise<NbdDiskWriter|undefined>} undefined when the VDI can't be exported in write mode
*/
export async function openNbdDiskWriter(xapi, vdiRef) {
const backupAddresses = await getBackupNetworkAddresses(xapi)
const keepAddress = address => backupAddresses === undefined || backupAddresses.includes(address)
const vdiUuid = await xapi.getField('VDI', vdiRef, 'uuid')
const usable = await getPluginCandidates(xapi, vdiUuid, keepAddress)
if (usable === undefined) {
return undefined
}
for (const candidate of usable) {
const hostRef = await xapi.call('host.get_by_uuid', candidate.host)
let exported
try {
exported = await callNbdPlugin(xapi, hostRef, 'open', { vdi_uuid: vdiUuid, mode: 'w' })
} catch (error) {
warn('xo-nbd plugin: open in write mode failed, trying the next candidate', {
vdiUuid,
host: candidate.host,
error,
})
continue
}
// the written data are only guaranteed once it succeeded
const closeExport = () => callNbdPlugin(xapi, hostRef, 'close', { token: exported.exportname })
if (exported.writable) {
const virtualSize = BigInt(await xapi.getField('VDI', vdiRef, 'virtual_size'))
// a single connection: the first address which answers
for (const address of exported.addresses.filter(keepAddress)) {
const client = new NbdTcpClient(
{
address,
port: exported.port,
exportname: exported.exportname,
// the client upgrades to TLS only when given a certificate
cert: exported.tls ? exported.cert : undefined,
},
{ connectTimeout: PLUGIN_CONNECT_TIMEOUT }
)
try {
await client.connect()
if (client.readOnly) {
throw new Error('the export is read-only')
}
// the export must be this VDI: a different size means another disk, never write it
if (BigInt(client.exportSize) !== virtualSize) {
throw new Error(`export size ${client.exportSize} differs from the VDI virtual size ${virtualSize}`)
}
info('xo-nbd plugin: connected in write mode', { vdiUuid, host: candidate.host, tls: exported.tls })
// like the reads (see XapiDiskSource), a XAPI task shows the progress of the import
const label = await xapi.getField('VDI', vdiRef, 'name_label')
return new NbdDiskWriter(client, closeExport, {
progressHandler: new XapiProgressHandler(xapi, `Importing content of VDI ${label} through NBD`),
})
} catch (error) {
warn('xo-nbd plugin: write connection failed', { vdiUuid, host: candidate.host, address, error })
await client.disconnect().catch(() => {})
}
}
} else {
warn('xo-nbd plugin: the export is not writable', { vdiUuid, host: candidate.host })
}
await closeExport().catch(error =>
warn('xo-nbd plugin: close failed, the export will expire', { vdiUuid, host: candidate.host, error })
)
}
return undefined
}
/**
* Connects to the VDI through the xo-nbd plugin if it is installed, otherwise (or if it fails) through xapi-nbd
*
* @param {any} xapi
* @param {string} vdiRef
@@ -8,15 +292,27 @@ import MultiNbdClient from '@vates/nbd-client/multi.mjs'
* @returns {Promise<MultiNbdClient|undefined>}
*/
export async function connectNbdClientIfPossible(xapi, vdiRef, nbdConcurrency) {
let nbdInfos = await xapi.call('VDI.get_nbd_info', vdiRef)
// filter nbd to only use backup network ( if set )
const poolBackupNetwork = xapi._pool.other_config['xo:backupNetwork']
if (poolBackupNetwork) {
const networkRef = await xapi.call('network.get_by_uuid', poolBackupNetwork)
const pifs = await xapi.getField('network', networkRef, 'PIFs')
// @todo implement ipv6
const addresses = await Promise.all(pifs.map(pifRef => xapi.getField('PIF', pifRef, 'IP')))
nbdInfos = nbdInfos.filter(({ address }) => addresses.includes(address))
const backupAddresses = await getBackupNetworkAddresses(xapi)
try {
const client = await connectThroughPlugin(xapi, vdiRef, nbdConcurrency, backupAddresses)
if (client !== undefined) {
return client
}
} catch (error) {
warn('xo-nbd plugin unusable, falling back to xapi-nbd', { error })
}
let nbdInfos = await xapi.call('VDI.get_nbd_info', vdiRef)
if (backupAddresses !== undefined) {
nbdInfos = nbdInfos.filter(({ address }) => backupAddresses.includes(address))
}
// VDI.get_nbd_info always gives the certificate, even when xapi-nbd serves in clear (insecure_nbd), and
// the client upgrades to TLS whenever it has one: decide like xapi-nbd, from the network purposes, rather
// than downgrading when the server refuses STARTTLS (which anyone on the path could force)
if (!(await isNbdTlsRequired(xapi))) {
nbdInfos = nbdInfos.map(({ cert: _cert, ...nbdInfo }) => nbdInfo)
}
if (nbdInfos.length === 0) {

View File

@@ -11,6 +11,7 @@ const { warn } = createLogger('xo:xapi')
export { default as isDefaultTemplate } from './isDefaultTemplate.mjs'
export { XapiDiskSource } from './disks/Xapi.mjs'
export { openNbdDiskWriter } from './disks/utils.mjs'
// VDI formats. (Raw is not available for delta vdi.)
export const VDI_FORMAT_RAW = 'raw'

View File

@@ -11,6 +11,17 @@
> Users must be able to say: "Nice enhancement, I'm eager to test it"
- [Backup] Read the disks through the `xo-nbd` XAPI plugin when it is installed on the hosts, faster than `xapi-nbd` which stays the fallback
- [Backup] Faster NBD reads using much less CPU: the blocks are received without intermediate copy and their memory is reused
- [Backup] Less CPU used to write the blocks on a remote, especially an encrypted one: the data are written and encrypted without being concatenated first
- [Backup] New `zstd` compression for the remotes in block mode, faster to back up and about 1.5 times faster to restore than `brotli`
- [Backup] Faster compression and decompression of the backups on a remote in block mode, existing backups included
- [Backup/Restore] Faster reading of the files of a remote, like the full backups (10 MiB chunks instead of 64 KiB)
- [Restore/Replication] Write the disks through the `xo-nbd` XAPI plugin when it is installed, several blocks at a time, instead of a XAPI import
- [Mirror] The incremental mirrors copy the blocks as stored, without decompressing nor recompressing them, when the source and the destinations use the same compression
- [Mirror] The full mirrors between unencrypted remotes reuse the checksum of the source instead of computing it again
- [Backup/Restore] Less CPU used on the local, NFS and SMB remotes: the stack traces of the file system errors are no longer completed by default (`syncStackTraces` in `[remoteOptions]` to enable them)
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"
@@ -31,4 +42,12 @@
<!--packages-start-->
- @vates/nbd-client minor
- @xen-orchestra/backup-archive minor
- @xen-orchestra/backups minor
- @xen-orchestra/disk-transform minor
- @xen-orchestra/fs minor
- @xen-orchestra/xapi minor
- vhd-lib minor
<!--packages-end-->

377
journal.md Normal file
View File

@@ -0,0 +1,377 @@
# Journal : performances du chemin NBD vers un dépôt en mode block
Branche : `perf/nbd-zero-copy` (non commitée)
Périmètre : backup incrémental (`IncrementalXapi`) via NBD vers un dépôt en mode block (VhdDirectory),
en clair (sans compression ni chiffrement au repos, hors périmètre).
## Diagnostic
Profil du client XO pendant une lecture NBD à environ 1,2 Go/s (Node 24, VM de 4 vCPU) :
- **37 GC majeurs par seconde** (238 mark-compact pour 8 Go lus), alors que le tas JS ne fait que 17 Mo.
Ils sont déclenchés par la mémoire externe : chaque `Buffer` de 2 Mo, et chaque morceau de 64 Ko lu sur
le socket, est un ArrayBuffer compté par V8.
44 % des échantillons `perf` tombent dans les threads de GC de V8.
- **Deux copies de 2 Mo par bloc** :
1. `readChunkStrict` concatène les morceaux reçus du socket dans un nouveau buffer (`fromList`) ;
2. `DiskConsumerVhdDirectory` concatène le bitmap VHD de 512 octets et les données (`Buffer.concat`).
- **Le thread principal de Node est à 100 %.** C'est lui qui limite un processus de backup, et non le réseau
ou le stockage.
## Changements
### 1. `@vates/nbd-client` : lecture des réponses sans concaténation
- `AbstractNbdClient.mjs`
- Nouvelle classe interne `ReplyReceiver`, **une par connexion**. Elle parse les réponses au fil des
morceaux reçus (événements `data`, ou `setReceiver` pour les transports qui le proposent) et copie la
charge utile **une seule fois**, directement dans le buffer final de la requête.
Elle remplace `#readBlockResponse` et `readChunkStrict(size)` pour la phase de transmission. Le
handshake n'a pas changé.
- Comme le receiver est propre à chaque connexion, des données tardives d'une ancienne connexion ne peuvent
pas atteindre les requêtes de la suivante après une reconnexion.
- Une réponse invalide (magic, code d'erreur, identifiant inconnu), une erreur ou une fermeture du
transport rejettent toutes les requêtes en attente, comme avant. Le receiver arrête alors de parser,
pour ne jamais écrire dans un buffer qui a été rendu.
- Le timeout est maintenant un **timeout d'inactivité** : il échoue si aucun octet n'arrive pendant
`messageTimeout` alors que des requêtes attendent. Avant, il portait sur chaque lecture. L'erreur reste
une `TimeoutError` de promise-toolbox, donc la relance et la reconnexion fonctionnent comme avant.
- `readBlock(index, size, target?)` : nouveau paramètre optionnel `target`, le buffer où écrire les
données. La valeur renvoyée est alors une vue sur `target` (plus courte pour le dernier bloc d'un export
non aligné).
- `NbdTcpClient.mjs` : **sans TLS**, le socket utilise `onread`, et le noyau écrit dans un buffer de 1 Mo
réutilisé. Il n'y a plus aucune allocation par morceau reçu. Pendant le handshake, les données passent par
une `PassThrough`, puis `setReceiver` les donne directement au parser.
Pièges rencontrés :
- `onread` doit être passé au **constructeur** de `Socket`, pas à `connect()` : il y est ignoré sans
erreur.
- `tls.connect` ignore aussi `onread` sans erreur. Avec TLS, on garde donc les événements `data`. La copie
unique dans `target` s'applique quand même.
- `multi.mjs` : `readBlock(index, size, target)` transmet `target`, y compris lors d'une relance sur un
autre client.
- Tests :
- unitaires : lecture dans `target`, réponses découpées en morceaux arbitraires (1, 7, 16, 17,
taille + 3 octets) et dans le désordre, timeout quand le serveur cesse de répondre ;
- faux serveur : nouvelles options `chunkSize` et `stopAnsweringAfter` ;
- intégration : variante sans TLS (chemin `onread`) qui lit dans des buffers `target` et redémarre
nbdkit en cours de lecture.
### 2. `@xen-orchestra/disk-transform` : pool de buffers et libération optionnelle
- `BlockBufferPool.mts` (nouveau) : pool de buffers de `blockSize` octets.
- Le pool ne bloque et n'échoue jamais : s'il est vide, il alloue un nouveau buffer.
- Il garde au plus `maxFree` buffers libres ; les autres sont laissés au GC.
- Une double libération est ignorée, pour ne jamais confier la même mémoire à deux utilisateurs.
- `Disk.mts` : `DiskBlock` gagne un champ optionnel, `release()`. Il rend la mémoire au pool une fois le
bloc consommé. **Ne jamais l'appeler est toujours sûr** : la mémoire est alors ramassée par le GC.
- `Throttled.mts` : conserve `release`. Avant, chaque bloc était reconstruit en
`{ index, data, length }`.
- `SynchronizedDisk.mts` : le même bloc est remis à tous les forks.
- Un compteur, égal au nombre de forks, fait que la mémoire ne retourne au pool qu'une fois que **tous**
les forks ont libéré le bloc.
- Un double `release()` venant du même fork n'est compté qu'une fois.
- Un fork qui s'arrête en cours de route laisse simplement les blocs suivants au GC.
- `DiskLargerBlock.mts` : libère le bloc source dès qu'il a été copié dans le bloc agrandi.
- Tests : pool (réutilisation, double libération, `maxFree`), conservation de `release`
par `ThrottledDisk`, libération partagée par `SynchronizedDisk` (une seule libération après les deux
forks, et aucune tant qu'un fork détient encore le bloc).
### 3. Sources NBD de XAPI et writer VHD block : écriture sans copie
- `@xen-orchestra/xapi/disks/utils.mjs` : un pool de buffers de 2 Mo partagé par le processus, et la
fonction `readNbdBlock()`. Elle lit le bloc directement dans un buffer du pool et renvoie
`{ index, data, release }`.
- `XapiStreamNbd.mjs` et `XapiVhdCbt.mjs` : `readBlock()` passe par `readNbdBlock()`.
- `@xen-orchestra/fs` : `outputFile()` accepte un tableau de buffers, le fichier est leur concaténation.
- Le handler local (et donc NFS et SMB) l'écrit avec un seul `writev`, sans concaténer.
- Les autres (S3, Azure) reçoivent un `Buffer.concat` : ils sont distants, plus lents, et on privilégie
la fiabilité.
- Avec chiffrement, chaque morceau passe par `cipher.update()`, sans copie supplémentaire.
- `vhd-lib` : nouvelle méthode `writeBlockData(blockId, data)`. Elle ne reçoit que les données, le bitmap
(complet) est spécifique au format et ajouté par le VHD lui-même.
- `VhdDirectory` : `_writeChunk()` avec `[bitmap, data]`, donc `outputFile()` avec un tableau. Avec
compression, les morceaux sont concaténés avant de compresser.
- `VhdFile` : bitmap et données écrits séparément à leur place dans le fichier, sans concaténation.
- `VhdAbstract` (les autres) : concaténation puis `writeEntireBlock()`.
- `writeEntireBlock()` ne change pas : le merge de `VhdAbstract.mergeBlock()` recopie le bitmap du bloc
enfant, qui n'est pas forcément complet.
- `vhd-lib/disk-consumer/DiskConsumerVhdDirectory.mjs` : `writeBlockData()`, puis `release()` une fois le
fichier de bloc écrit. Le dernier bloc d'un disque non aligné est complété par des zéros, comme le
faisait le `Buffer.concat` avec une longueur fixe.
- `backup-archive/RemoteVhdDisk.writeBlock()` (utilisé aussi par le merge, qui ne garde déjà que les
données) : `writeBlockData()` au lieu de construire le bitmap et de concaténer.
- Tests : blocs écrits et libérés une fois chacun (la mémoire est brouillée au `release()` pour détecter
une lecture tardive), compression, dernier bloc plus court, `writeBlockData()` sur `VhdDirectory` (avec
et sans compression) et sur `VhdFile`.
Une première version mettait le bitmap en préfixe dans les buffers du pool, pour écrire bitmap et données
d'un seul tenant. Elle a été retirée : mesurée sur la vraie stack, `writev` est aussi rapide (tmpfs,
2 Go : 6080 Mo/s contre 5735 avec le préfixe, et 2799 avec `Buffer.concat`) pour beaucoup moins de
code.
### Résultat des tests
| Suite | Résultat |
| ------------------------------------------------------------------------------------ | ----------- |
| `@vates/nbd-client`, unitaires | 27 sur 27 |
| `@vates/nbd-client`, intégration TCP (nbdkit, TLS et clair, redémarrages du serveur) | 33 sur 33 |
| `@vates/nbd-client`, intégration stdio | 41 sur 41 |
| `@xen-orchestra/disk-transform` | 43 sur 43 |
| `@xen-orchestra/xapi` | 7 sur 7 |
| `vhd-lib`, `disk-consumer` | 10 sur 10 |
| `@xen-orchestra/backups` | 103 sur 103 |
## Mesures
Environnement de test :
- **Hôte :** XCP-ng 8.3, i5-12600H, SR ext sur NVMe WD SN850X.
- **XO :** VM de 4 vCPU sur le même hôte, avec une route 10G vers le dom0.
- **Serveur NBD :** nbdkit 1.8 dans le dom0, sur le device tapdisk du VDI (VHD), une connexion, 16 requêtes
en vol.
- **Chaîne :** chaîne XO complète (`ReadAhead` → `ThrottledDisk` → `SynchronizedDisk` → writer
VhdDirectory), avec un dépôt nul (`REMOTE=null`) qui garde tout le travail CPU de XO mais n'écrit pas
les fichiers de blocs. On mesure ainsi la lecture sans le bruit du disque de la VM XO, lui-même sur le
même NVMe.
Deux scénarios :
- **en cache :** relecture des 512 premiers Mo, qui restent dans le cache du dom0. On mesure alors le
client XO seul ;
- **réel :** 8 Go lus sur le NVMe.
| Scénario | Code d'origine | Nouveau client NBD seul | Nouveau client + pool + écriture sans copie |
| ---------------- | ---------------------------------------------- | ------------------------------- | ------------------------------------------- |
| Clair, en cache | 1 296 à 1 302 Mo/s, 190 % CPU, ~317 GC majeurs | 1 550 à 1 617 Mo/s, 155 à 171 % | **2 820 à 2 832 Mo/s, 78 à 85 %, 2 GC** |
| Clair, réel 8 Go | 1 128 à 1 244 Mo/s, 185 % | 1 212 à 1 231 Mo/s, 160 % | **1 352 à 1 359 Mo/s, 41 à 44 %, 2 GC** |
| TLS, en cache | 895 à 963 Mo/s, 165 %, ~350 GC | 1 017 à 1 028 Mo/s | **1 091 à 1 199 Mo/s**, ~210 GC |
| TLS, réel 8 Go | 847 à 933 Mo/s, 170 % | 892 à 917 Mo/s | **1 026 à 1 041 Mo/s** |
## Gains attendus en production
- **NBD en clair** (`insecure_nbd`, réseau de backup dédié) :
- environ **×2,2 de débit pour un processus de backup** quand le serveur suit ;
- surtout **environ 4 fois moins de CPU par Go transféré**.
En lecture réelle, on n'est plus limité par XO mais par le serveur : environ 1,35 Go/s ici, ce qui
correspond à la limite de tapdisk (1,5 Go/s par VDI). Le CPU libéré profite aux VMs et aux disques
sauvegardés en parallèle, et aux jobs simultanés.
- **NBD en TLS :** **+15 à +20 % de débit**. Le déchiffrement TLS de Node, sur le thread principal, et ses
allocations par enregistrement restent la limite.
- **Aujourd'hui, avec `xapi-nbd` :** le gain sera masqué tant que le serveur plafonne (395 Mo/s par
connexion, environ 690 Mo/s par hôte, voir plus bas). Ce qui reste, c'est **le CPU économisé côté XO**.
- **Mémoire :** au plus 32 buffers libres retenus par processus (64 Mo). Les buffers en vol sont, eux,
ceux qui étaient déjà alloués avant ce changement.
- **Configurations sans gain sur les blocs eux-mêmes** (comportement inchangé, aucune régression) :
- les consommateurs qui ne libèrent pas les blocs : réplication (`IncrementalXapiWriter`), dépôts VHD
en flux (non block), qcow2 ;
- avec `SynchronizedDisk`, le cas 1 backup block + 1 réplication : la réplication ne libère pas, donc le
compteur n'atteint jamais 0. Avec 2 backups block, le gain est entier.
Dans tous ces cas, on garde le gain du client NBD : une copie unique, sans concaténation.
## Pistes suivantes (non implémentées)
1. **Libération dans les writers en flux** (réplication, VHD en flux, qcow2) : appeler `release()` une
fois les octets du bloc écrits dans le stream. Ça étendrait le gain à 1 backup + 1 réplication.
2. **TLS hors du thread principal** : une connexion NBD par disque dans un `worker_thread`, avec des
buffers du pool sur `SharedArrayBuffer` transférés sans copie. Ça permettrait de dépasser environ
1,1 Go/s en TLS par processus.
3. **`SynchronizedDisk` fait avancer les forks en lockstep.** Le `ReadAhead` en amont masque la latence de
la source, mais pas les écarts entre writers : un dépôt lent, ou un pic de latence d'une réplication,
freine les autres bloc par bloc. Une file bornée par fork (quelques blocs) absorberait ces écarts. À
mesurer avec 2 dépôts de latences différentes.
4. **Côté serveur** (mesuré sur britney, voir le diagnostic de la session) :
- `xapi-nbd` plafonne à 395 Mo/s par connexion et environ 690 Mo/s par hôte : un seul thread Lwt, et un
`write()` par enregistrement TLS de 16 Ko ;
- il accepte au plus 16 connexions par hôte, et sa file `listen` ne fait que 5 places, ce qui provoque
des blocages de 60 s en cas de rafale de connexions ;
- nbdkit 1.8 fait 963 Mo/s en TLS et 1 272 Mo/s en clair sur une seule connexion, et traite en
parallèle les requêtes en vol ;
- kTLS fonctionne dans le dom0 (OpenSSL 3.0.9 et noyau 4.19, en TLS 1.2 avec AES-128-GCM uniquement),
et monte à 1,15 Go/s avec `sendfile`.
5. **Côté XO :**
- un budget de connexions NBD par hôte, plutôt que `nbdConcurrency` par disque, puisque disques et VMs
sont traités en parallèle ;
- un `ReadAhead` dimensionné sur le nombre de requêtes en vol, et non sur un pourcentage de blocs : les
petits deltas CBT n'ont aujourd'hui qu'une seule requête en vol.
## Reproduire les mesures
Le banc de test est dans `@xen-orchestra/backups/_bench/` (non suivi par git) :
- `bench.mjs` : chaîne XO complète. Variables d'environnement :
- `MODE=read|full|write` ;
- `NBD_HOST`, `NBD_PORT`, `TLS`, `NBD_CONC`, `READAHEAD` ;
- `POOL=1` : lecture par `readNbdBlock` ;
- `REMOTE=null` : dépôt nul ;
- `SRC=mem` : source en mémoire (avec `MODE=write`) ;
- `CYCLE_MB` : relecture d'une même plage ;
- `COMP`, `ENC`.
- `nbdfast.mjs` et `nbdfast2.mjs` : prototypes de client minimal (`onread` et pool).
- `/data/bench/matrix.sh` : la matrice ci-dessus.
---
# Chaînes de disques, et chemins VHD et qcow2 côté hyperviseur
Banc de test sur britney (XCP-ng 8.3, xapi 26.1.16, blktap 3.55.5, SR ext sur NVMe) :
- **VDIs de test :** de 16 Go, avec 8 Go de données aléatoires, en VHD et en qcow2.
- **Chaînes :** à chaque niveau, un snapshot suivi de la réécriture de 10 % des blocs de 2 Mo. Mesures aux
profondeurs 0, 1, 3 et 10.
- **Lecture côté XO :** chaîne complète avec le pool, vers le dépôt nul, sur 8 Go.
Scripts : `/root/xo-bench/chain.sh` sur l'hôte, `/data/bench/chainmeasure.sh` et `/data/bench/tapnbd.sh`
côté XO.
## Mesures (Mo/s)
| Chemin | VHD d0 | VHD d1 | VHD d3 | VHD d10 | qcow2 d0 | qcow2 d1 | qcow2 d3 | qcow2 d10 |
| -------------------------------------------------------------------- | --------- | ------ | ------ | --------- | --------- | --------- | --------- | ----------------------------------- |
| Device dom0, `dd` 2 Mo O_DIRECT, 1 lecture | 1 316 | 918 | 853 | 813 | 980 | 962 | 946 | 913 |
| Device dom0, 4 lectures | 1 507 | 883 | 827 | 801 | 1 113 | 1 050 | 1 024 | 996 |
| CPU de tapdisk | 88-99 % | 73 % | 71 % | 67 % | 127-148 % | 219-235 % | 220-230 % | 214-226 % |
| `xapi-nbd` TLS, 1 connexion | 585 | 465 | 445 | 414 | 537 | 409 | 402 | 396 |
| `xapi-nbd` TLS, 4 connexions | 959 | 579 | 576 | 558 | 802 | 667 | 660 | 636 |
| nbdkit en clair sur le device, 1 connexion | 1 321 | 654 | 643 | 614 | 1 046 | 846 | 852 | 816 |
| `export_raw_vdi` (flux complet) | 573 | 472 | 467 | 442 | 666 | 632 | 613 | 604 |
| **Serveur NBD de tapdisk**, 1 connexion, en clair, via `socat -b 1M` | **3 159** | | | **1 230** | **2 878** | | | **1 651** (1 809 avec 4 connexions) |
Le délai jusqu'au premier octet de `export_raw_vdi` est inférieur à 15 ms dans tous les cas : 8 Go et
10 niveaux, c'est trop petit pour que `qemu-img map` pèse. Il faut le remesurer sur de gros disques très
alloués.
## Différences entre VHD et qcow2 côté hyperviseur
1. **Driver tapdisk.**
- VHD : `drivers/block-vhd.c`, natif tapdisk. Chaque niveau de la chaîne est une image tapdisk séparée.
Chaque lecture consulte la BAT et le bitmap de secteurs du bloc dans la couche courante, et passe à
la couche suivante si le bloc n'y est pas (`td_forward_request`). Le cache ne garde que 32 bitmaps
par image (`VHD_CACHE_SIZE`), et un bitmap absent du cache doit être lu sur disque, sous verrou,
avant les données. L'I/O se fait par libaio en O_DIRECT, dans une boucle d'événements à un seul
thread.
- qcow2 : `drivers/block-qcow2.c` (branche `v3.55.5-qcow2`, livré dans `libqcow2.so`). Il **embarque
la couche bloc de QEMU** (`blk_aio_preadv`, coroutines, contexte AIO, 3 threads), avec `cache=none`,
`aio=native` et `l2-cache-size=256M`. C'est le code qcow2 de QEMU qui résout la chaîne de backing.
- Conséquence : le VHD perd 30 % dès le premier niveau puis environ 38 % à 10 niveaux, et lire en
parallèle ne sert plus à rien, avec moins d'un cœur. Le qcow2 ne perd que 2 à 7 % à 10 niveaux, mais
tapdisk consomme 2,2 cœurs. Sur disque plat, le VHD est plus rapide (1 316 contre 980 Mo/s).
2. **Chemin du device dom0**, commun aux deux formats, utilisé par `xapi-nbd` et par les exports XAPI. La
lecture passe par la file blktap : 32 requêtes × 11 segments de 4 Ko, soit environ 1,4 Mo en vol. Même
sur disque plat, on plafonne vers 1,3 à 1,5 Go/s. Avec une chaîne VHD, chaque requête est plus lente à
cause des renvois de couche en couche, et comme la file est pleine, le débit baisse d'autant.
3. **Export stream (`export_raw_vdi`).**
- VHD : `vhd-tool`, en OCaml.
- qcow2 : `qemu-img map --output=json` et `qemu-img info` sur toute la chaîne (et sur la base pour un
delta), puis `qcow2-to-stdout.py` en Python, qui lit le device et produit le flux.
- Mesuré : le qcow2 est un peu plus rapide (604 à 666 contre 442 à 573 Mo/s).
4. **Serveur NBD intégré à tapdisk (`drivers/tapdisk-nbdserver.c`).**
- Chaque tapdisk l'expose sur `/var/run/blktap-control/nbd<pid>.<minor>`. Le socket
`nbdserver-new<pid>.<minor>` sert, lui, à recevoir un descripteur client par fd-passing.
- Il ne passe ni par la file blktap, ni par le device, ni par `xapi-nbd`. Il crée une requête interne
pour toute la lecture demandée (jusqu'à 64 Mo), répond de façon asynchrone et gère les réponses
structurées ainsi que **`NBD_CMD_BLOCK_STATUS`** (`base:allocation`). Il ne gère pas TLS.
- Résultat : **5,4 fois `xapi-nbd`** sur disque plat (3,2 Go/s en VHD, 2,9 Go/s en qcow2) et 3 à 4,5
fois à 10 niveaux.
## Bug trouvé dans le serveur NBD de tapdisk
Symptôme :
- un tapdisk part dans une boucle infinie `scheduler_wait_for_events: select failed: Bad file descriptor`
(plus de 2 300 lignes de log, une CPU occupée) ;
- son socket de contrôle ne répond plus, donc `tap-ctl list` bloque, ainsi que **toutes les opérations VBD
du dom0** (un `VBD.unplug` est resté plus de 20 minutes en attente).
Il a fallu tuer ce tapdisk. Ses descripteurs ouverts et ses logs sont conservés dans
`/root/xo-bench/tapdisk-spin-1618556/` sur britney.
Déclencheur observé : plusieurs connexions se sont ouvertes et refermées en rafale, puis un client a fermé
sa connexion avant d'envoyer ses flags (`Zero return from recv`, puis `Could not receive client flags`).
Causes dans `tapdisk-nbdserver.c` (branche `3.55.5-8.3`) :
- **`server->handshake_fd` est un champ unique pour tout le serveur.**
`tapdisk_nbdserver_new_protocol_handshake()` l'écrase à chaque nouveau client, et
`tapdisk_nbdserver_handshake_cb()` lit puis **ferme** `server->handshake_fd`, et non le descripteur de son
propre client. Si deux handshakes se chevauchent, l'erreur de l'un ferme le descripteur de l'autre, dont
l'événement reste enregistré dans `select()`, d'où l'EBADF en boucle.
- **Chemin d'erreur de `receive_newstyle_options()`** : le descripteur est fermé, puis la fonction continue
et active quand même le client (`tapdisk_nbdserver_enable_client`) sur ce descripteur fermé. Il manque
un `goto out`, et la libération du client.
## Ce que ça implique
1. **Le levier principal côté hyperviseur est de remplacer le chemin VBD et device de `xapi-nbd` par le
serveur NBD de tapdisk.**
- Pour `insecure_nbd`, `xapi-nbd` n'aurait plus qu'à authentifier, puis à transmettre la connexion au
tapdisk du VDI par fd-passing via `nbdserver-new`. Il ne serait plus du tout sur le chemin des
données.
- Pour TLS, il terminerait TLS et relaierait vers le socket Unix, avec de gros buffers et du kTLS si
possible.
- On gagnerait aussi **`BLOCK_STATUS`** : XO n'aurait plus besoin de l'export stream pour obtenir la
liste des blocs d'un backup complet, et pourrait sauter les zones non allouées de toute la chaîne.
- **Prérequis : corriger le bug de handshake ci-dessus.**
2. **Chaînes VHD** : agrandir `VHD_CACHE_SIZE` (32 bitmaps par image), et préférer qcow2, ou coalescer,
pour les chaînes longues.
3. **Côté XO, avec un serveur NBD de tapdisk exposé** : une seule connexion par disque suffit (le serveur
traite les requêtes en vol), et on peut demander des blocs plus gros que 2 Mo (jusqu'à 64 Mo).
---
# Intégration du plugin `xo-nbd` dans XO
Le plugin et le démon sont décrits dans `/data/xapi-nbd-rs/JOURNAL.md`. Côté XO, une seule fonction change :
`connectNbdClientIfPossible()`, dans `@xen-orchestra/xapi/disks/utils.mjs`. Elle est utilisée par
`XapiStreamNbdSource` (backups complets) et `XapiVhdCbtSource` (deltas CBT).
## Logique
1. **Désactivation :** si le pool porte `other_config['xo:nbdPlugin'] = 'false'`, le plugin n'est pas
essayé.
2. **Détection :** `host.call_plugin(master, 'xo-nbd', 'get_nbd_infos', { vdi_uuid })`. Une erreur
`XENAPI_MISSING_PLUGIN` est mémorisée pour ce pool pendant 10 minutes, et on passe directement à
`xapi-nbd` sans redemander.
3. **Choix du candidat :** on garde les candidats qui ont au moins une adresse sur le réseau de backup
(`xo:backupNetwork`) s'il est défini, avec le même filtre que pour `xapi-nbd`, désormais en commun. Ils
sont ensuite essayés dans un ordre aléatoire, pour répartir les exports d'une SR partagée.
4. **Pour chaque candidat :**
- `open` sur son hôte ;
- `MultiNbdClient` sur ses adresses, avec le jeton comme nom d'export, le certificat seulement si
`tls` est vrai (sinon le chemin `onread`, sans allocation), et un délai de connexion de 10 s au lieu
de 60 ;
- si `open` ou la connexion échoue, l'export est refermé (`close`) et on passe au candidat suivant.
5. **Repli :** si aucun candidat n'a fonctionné, ou si le plugin a levé une erreur inattendue, on utilise
le chemin actuel, `VDI.get_nbd_info` et `xapi-nbd`.
6. **Fin de vie :** `PluginNbdClient` hérite de `MultiNbdClient`, et son `disconnect()` appelle `close` une
seule fois, ce qui débranche le VBD. C'est le `close()` des sources qui déclenche ce `disconnect()`. Si
XO plante, l'export expire dans le démon, puis le VBD est débranché par le timer de nettoyage ou le
prochain `open`.
## Tests sur britney
Lecture complète d'un VDI de 8 Go par `XapiDiskSource` en NBD, avec un `Xapi` réel. Script :
`@xen-orchestra/backups/_bench/xapidisk.mjs`.
| Scénario | Chemin utilisé | Débit | Exports ou VBD restants |
| ---------------------------------------------- | ---------------------------------------------------------- | ---------------- | ------------------------------------------ |
| A. Plugin installé | plugin (1 export pendant le transfert), TLS | 969 à 1 053 Mo/s | aucun |
| B. Plugin absent (renommé) | `XENAPI_MISSING_PLUGIN`, puis `xapi-nbd` | 611 Mo/s | aucun |
| C1. Port 10810 bloqué vers `.5` | plugin, via la seconde adresse du candidat (`.6`) | 1 135 Mo/s | aucun |
| C2. Port 10810 bloqué vers toutes les adresses | plugin `open`, connexion refusée, `close`, puis `xapi-nbd` | 628 Mo/s | aucun : export retiré dans la même seconde |
Tests de non-régression : `@xen-orchestra/xapi` 7 sur 7, `@xen-orchestra/backups` 103 sur 103. ESLint et
Prettier passent.
## Limites et suites
- **Pas de test unitaire de la logique de repli.** `MultiNbdClient` est importé directement : il faudrait
injecter la dépendance, ou utiliser `mock.module`, que le script de test de `@xen-orchestra/xapi`
n'active pas.
- **Un paquet perdu au lieu d'un refus** coûte jusqu'à 10 s par adresse avant le repli. La connexion
refusée a été testée, la perte de paquets non.
- **La connexion se fait encore au moment de `init()`.** Avec un backup de plusieurs disques, chaque disque
appelle `open` et `close` séparément : environ 0,7 s chacun, dont l'essentiel est le `VBD.plug`.

View File

@@ -98,6 +98,17 @@ exports.VhdAbstract = class VhdAbstract {
throw new Error(`reading ${onlyBitmap ? 'bitmap of block' : 'block'} ${blockId} is not implemented`)
}
/**
* writes the data of a block, with a bitmap marking all its sectors as present
*
* @param {number} blockId
* @param {Buffer} data - header.blockSize bytes
*/
async writeBlockData(blockId, data) {
assert.strictEqual(data.length, this.header.blockSize, `block ${blockId} must be ${this.header.blockSize} bytes`)
await this.writeEntireBlock({ id: blockId, buffer: Buffer.concat([Buffer.alloc(this.bitmapSize, 255), data]) })
}
/**
* coalesce the block with id blockId from the child vhd into
* this vhd

View File

@@ -80,6 +80,52 @@ describe('VhdDirectory', async () => {
})
})
for (const compression of [undefined, 'gzip', 'zstd', 'zeros']) {
it(`writes the data of a block with a full bitmap (compression: ${compression})`, async () => {
await createRandomFile(`${tempDir}/randomfile`, 8)
await convertFromRawToVhd(`${tempDir}/randomfile`, `${tempDir}/source.vhd`)
await Disposable.use(async function* () {
const source = yield openVhd(handler, 'source.vhd')
const vhd = yield VhdDirectory.create(handler, 'data.vhd', { compression })
vhd.header = source.header
vhd.footer = source.footer
const data = Buffer.alloc(vhd.header.blockSize, 0x42)
await vhd.writeBlockData(3, data)
assert.equal(vhd.containsBlock(3), true)
const block = await vhd.readBlock(3)
assert.ok(block.bitmap.every(byte => byte === 0xff))
assert.ok(block.data.equals(data))
await assert.rejects(vhd.writeBlockData(4, data.subarray(1)))
})
})
}
it('copies block files as stored between VHD directories with the same compression', async () => {
await createRandomFile(`${tempDir}/randomfile`, 8)
await convertFromRawToVhd(`${tempDir}/randomfile`, `${tempDir}/source.vhd`)
await Disposable.use(async function* () {
const source = yield openVhd(handler, 'source.vhd')
const gzip = yield VhdDirectory.create(handler, 'gzip.vhd', { compression: 'gzip' })
const copy = yield VhdDirectory.create(handler, 'copy.vhd', { compression: 'gzip' })
const plain = yield VhdDirectory.create(handler, 'plain.vhd')
for (const vhd of [gzip, copy, plain]) {
vhd.header = source.header
vhd.footer = source.footer
}
const data = Buffer.alloc(source.header.blockSize, 0x42)
await gzip.writeBlockData(1, data)
const raw = await gzip.readRawBlock(1)
assert.ok(raw.length < gzip.fullBlockSize, 'still compressed')
await copy.writeRawBlock(1, raw, 'gzip')
assert.ok((await copy.readBlock(1)).data.equals(data))
// never written in a VHD directory with another compression, nor with the wrong size
await assert.rejects(plain.writeRawBlock(1, raw, 'gzip'))
await assert.rejects(plain.writeRawBlock(1, raw, plain.compressionType), /must be/)
})
})
it('compresses blocks and metadata works', async () => {
const initalSize = 4
const rawFileName = `${tempDir}/randomfile`

View File

@@ -9,6 +9,7 @@ const assert = require('assert')
const { synchronized } = require('decorator-synchronized')
const promisify = require('promise-toolbox/promisify')
const zlib = require('zlib')
const { removeZeroPages, restoreZeroPages } = require('./_zeroFilter')
const { debug } = createLogger('vhd-lib:VhdDirectory')
@@ -18,26 +19,54 @@ const NULL_COMPRESSOR = {
baseOptions: {},
}
// the output buffers of zlib, instead of chunks of 16 KiB (the default) each costing a round trip to the thread pool:
// - a compressed block is about 1 MiB: larger buffers would be allocated for nothing, keeping the GC busy
// - a decompressed block (2 MiB + 512) fits in a single buffer
const COMPRESS_CHUNK_SIZE = 1024 * 1024
const DECOMPRESS_CHUNK_SIZE = 2 * 1024 * 1024 + 1024
function withZlibOptions(fn, chunkSize, options) {
const promisified = promisify(fn)
return buffer => promisified(buffer, { chunkSize, ...options })
}
const COMPRESSORS = {
gzip: {
compress: (
gzip => buffer =>
gzip(buffer, { level: zlib.constants.Z_BEST_SPEED })
)(promisify(zlib.gzip)),
decompress: promisify(zlib.gunzip),
compress: withZlibOptions(zlib.gzip, COMPRESS_CHUNK_SIZE, { level: zlib.constants.Z_BEST_SPEED }),
decompress: withZlibOptions(zlib.gunzip, DECOMPRESS_CHUNK_SIZE),
},
brotli: {
compress: (
brotliCompress => buffer =>
brotliCompress(buffer, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MIN_QUALITY,
},
})
)(promisify(zlib.brotliCompress)),
decompress: promisify(zlib.brotliDecompress),
compress: withZlibOptions(zlib.brotliCompress, COMPRESS_CHUNK_SIZE, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MIN_QUALITY,
},
}),
decompress: withZlibOptions(zlib.brotliDecompress, DECOMPRESS_CHUNK_SIZE),
},
none: NULL_COMPRESSOR,
// no compression, only the pages full of zeroes are removed: much cheaper, it works on the parts of a block
// without concatenating them, and its result (views on the data) is written without copy
zeros: {
compress: removeZeroPages,
decompress: restoreZeroPages,
acceptsParts: true,
},
}
// negative levels are the fastest ones: they mostly remove the runs of zeroes, and decompress about 5 times faster
// than brotli
const ZSTD_LEVEL = -3
// zstd is only available since Node 22.15
if (zlib.zstdCompress !== undefined) {
COMPRESSORS.zstd = {
compress: withZlibOptions(zlib.zstdCompress, COMPRESS_CHUNK_SIZE, {
params: {
[zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL,
},
}),
decompress: withZlibOptions(zlib.zstdDecompress, DECOMPRESS_CHUNK_SIZE),
}
}
// inject identifiers
@@ -45,6 +74,33 @@ for (const id of Object.keys(COMPRESSORS)) {
COMPRESSORS[id].id = id
}
// buffers concatenating the parts of a full block before compressing it, reused instead of allocated for each block
// (each one is external memory which keeps the GC busy at high throughput)
const concatBuffers = []
const MAX_FREE_CONCAT_BUFFERS = 32
/**
* @param {{compress(buffer: Buffer): Promise<Buffer>}} compressor
* @param {Buffer[]} parts
*/
async function compressParts(compressor, parts) {
const length = parts.reduce((sum, part) => sum + part.length, 0)
const reused = concatBuffers.length !== 0 && concatBuffers[concatBuffers.length - 1].length === length
const buffer = reused ? concatBuffers.pop() : Buffer.allocUnsafeSlow(length)
let offset = 0
for (const part of parts) {
offset += part.copy(buffer, offset)
}
try {
return await compressor.compress(buffer)
} finally {
// the compressed data are a new buffer: this one can be reused
if (concatBuffers.length < MAX_FREE_CONCAT_BUFFERS) {
concatBuffers.push(buffer)
}
}
}
function getCompressor(compressorType) {
if (compressorType === undefined) {
return NULL_COMPRESSOR
@@ -159,6 +215,7 @@ exports.VhdDirectory = class VhdDirectory extends VhdAbstract {
}
}
// buffer can be an array of buffers, the chunk is then their concatenation
async _writeChunk(partName, buffer) {
assert.notStrictEqual(
this._opts?.flags,
@@ -166,10 +223,18 @@ exports.VhdDirectory = class VhdDirectory extends VhdAbstract {
`Can't write a chunk ${partName} in ${this._path} with read permission`
)
if (Array.isArray(buffer) && this.#compressor !== NULL_COMPRESSOR && !this.#compressor.acceptsParts) {
// the handler can write an array without concatenating it, a compressor can't
return this.#outputChunk(partName, await compressParts(this.#compressor, buffer))
}
return this.#outputChunk(partName, await this.#compressor.compress(buffer))
}
// writes a chunk as stored (already compressed)
#outputChunk(partName, data) {
// in case of VhdDirectory, we want to create the file if it does not exists
const flags = this._opts?.flags === 'r+' ? 'w' : this._opts?.flags
const compressed = await this.#compressor.compress(buffer)
return this._handler.outputFile(this.#getChunkPath(partName), compressed, { flags })
return this._handler.outputFile(this.#getChunkPath(partName), data, { flags })
}
// put block in subdirectories to limit impact when doing directory listing
@@ -313,6 +378,34 @@ exports.VhdDirectory = class VhdDirectory extends VhdAbstract {
setBitmap(this.#blockTable, block.id)
}
// the block file as stored: decrypted by the handler but still compressed with compressionType
async readRawBlock(blockId) {
return this._handler.readFile(this.getFullBlockPath(blockId))
}
// writes a block file read by readRawBlock() from a VHD directory, which must use the same compression: it is
// neither decompressed nor recompressed
async writeRawBlock(blockId, buffer, compressionType) {
assert.strictEqual(
compressionType,
this.compressionType,
`can't write a block compressed with ${compressionType} in a VHD directory using ${this.compressionType}`
)
if (this.#compressor === NULL_COMPRESSOR) {
assert.strictEqual(buffer.length, this.fullBlockSize, `block ${blockId} must be ${this.fullBlockSize} bytes`)
}
assert.notStrictEqual(this._opts?.flags, 'r', `Can't write block ${blockId} in ${this._path} with read permission`)
await this.#outputChunk(this.#getBlockPath(blockId), buffer)
setBitmap(this.#blockTable, blockId)
}
// bitmap and data are not concatenated: the handler can write them with a single writev
async writeBlockData(blockId, data) {
assert.strictEqual(data.length, this.header.blockSize, `block ${blockId} must be ${this.header.blockSize} bytes`)
await this._writeChunk(this.#getBlockPath(blockId), [Buffer.alloc(this.bitmapSize, 255), data])
setBitmap(this.#blockTable, blockId)
}
async _readParentLocatorData(id) {
return (await this._readChunk('parentLocatorEntry' + id)).buffer
}

View File

@@ -139,6 +139,29 @@ describe('VhdFile', async () => {
assert.equal((await fs.readFile(recoveredFileName)).equals(randomData), true)
})
it('writes the data of a block with a full bitmap', async () => {
const emptyFileName = `${tempDir}/empty.vhd`
await execa('qemu-img', ['create', '-fvpc', emptyFileName, '8M'])
const vhd = new VhdFile(handler, 'empty.vhd')
await vhd.readHeaderAndFooter()
await vhd.readBlockAllocationTable()
const data = randomBytes(vhd.header.blockSize)
await vhd.writeBlockData(1, data)
// written twice: the second write reuses the allocated block
await vhd.writeBlockData(2, data)
await vhd.writeBlockData(1, data)
await vhd.writeFooter()
const reopened = new VhdFile(handler, 'empty.vhd')
await reopened.readHeaderAndFooter()
await reopened.readBlockAllocationTable()
for (const id of [1, 2]) {
const block = await reopened.readBlock(id)
assert.ok(block.bitmap.every(byte => byte === 0xff))
assert.ok(block.data.equals(data))
}
assert.equal(reopened.containsBlock(0), false)
})
it('writes Data in 2 non-overlapping operations', async () => {
const mbOfRandom = 3
const rawFileName = `${tempDir}/randomfile`

View File

@@ -346,6 +346,18 @@ exports.VhdFile = class VhdFile extends VhdAbstract {
await this._write(block.buffer, sectorsToBytes(blockAddr))
}
// bitmap and data are written separately, at their place in the block: no need to concatenate them
async writeBlockData(blockId, data) {
assert.strictEqual(data.length, this.header.blockSize, `block ${blockId} must be ${this.header.blockSize} bytes`)
let blockAddr = this._getBatEntry(blockId)
if (blockAddr === BLOCK_UNUSED) {
blockAddr = await this._createBlock(blockId)
}
await this._writeBlockBitmap(blockAddr, Buffer.alloc(this.bitmapSize, 255))
await this._write(data, sectorsToBytes(blockAddr + this.sectorsOfBitmap))
}
async _writeBlockSectors(block, beginSectorId, endSectorId, parentBitmap) {
let blockAddr = this._getBatEntry(block.id)

View File

@@ -0,0 +1,63 @@
'use strict'
const { describe, it } = require('node:test')
const { strict: assert } = require('assert')
const { randomBytes } = require('crypto')
const { removeZeroPages, restoreZeroPages } = require('./_zeroFilter')
const PAGE = 4096
const roundTrip = buffer => restoreZeroPages(Buffer.concat(removeZeroPages(buffer)))
describe('zero filter', () => {
it('restores chunks of any size', () => {
// footer, header, odd sizes, a block with its bitmap
for (const length of [0, 1, 512, 1024, PAGE - 1, PAGE, PAGE + 1, 2 * 1024 * 1024 + 512]) {
for (const make of [n => Buffer.alloc(n), n => randomBytes(n)]) {
const buffer = make(length)
assert.ok(roundTrip(buffer).equals(buffer), `${length} bytes`)
}
}
})
it('only stores the pages which are not full of zeroes', () => {
const buffer = Buffer.alloc(10 * PAGE)
randomBytes(PAGE).copy(buffer, 2 * PAGE)
randomBytes(PAGE).copy(buffer, 3 * PAGE)
buffer[7 * PAGE + 100] = 1
const parts = removeZeroPages(buffer)
// header + one part per run of stored pages, which are views on the original memory
assert.equal(parts.length, 3)
assert.equal(parts[1].buffer, buffer.buffer)
const filtered = Buffer.concat(parts)
assert.equal(filtered.length, 4 + 2 + 3 * PAGE)
assert.ok(restoreZeroPages(filtered).equals(buffer))
})
it('filters the parts of a chunk without concatenating them, pages spanning several parts included', () => {
const bitmap = Buffer.alloc(512, 0xff)
const data = Buffer.alloc(4 * PAGE)
randomBytes(100).copy(data, 2 * PAGE + 50)
const parts = removeZeroPages([bitmap, data])
for (const part of parts.slice(1)) {
assert.ok(part.buffer === bitmap.buffer || part.buffer === data.buffer, 'a view on the data, not a copy')
}
assert.ok(restoreZeroPages(Buffer.concat(parts)).equals(Buffer.concat([bitmap, data])))
// same result as on the concatenation
assert.ok(Buffer.concat(parts).equals(Buffer.concat(removeZeroPages(Buffer.concat([bitmap, data])))))
})
it('stores a shorter last page', () => {
const buffer = Buffer.alloc(2 * PAGE + 10)
buffer[2 * PAGE + 5] = 42
const filtered = Buffer.concat(removeZeroPages(buffer))
assert.equal(filtered.length, 4 + 1 + 10)
assert.ok(restoreZeroPages(filtered).equals(buffer))
})
it('detects a truncated or inconsistent chunk', () => {
const filtered = Buffer.concat(removeZeroPages(randomBytes(3 * PAGE)))
assert.throws(() => restoreZeroPages(filtered.subarray(0, filtered.length - 1)), /does not match/)
assert.throws(() => restoreZeroPages(filtered.subarray(0, 2)), /truncated/)
})
})

View File

@@ -0,0 +1,132 @@
'use strict'
const assert = require('assert')
// Chunk filter which only removes the pages full of zeroes, much cheaper than a compression
//
// format: original length (uint32 BE) + bitmap of the stored pages (bit i = page i) + the stored pages
const PAGE_SIZE = 4096
const ZERO_PAGE = Buffer.alloc(PAGE_SIZE)
const LENGTH_SIZE = 4
// the empty parts of a disk are usually large: a region of zeroes is skipped with a single comparison
const REGION_SIZE = 64 * 1024
const PAGES_PER_REGION = REGION_SIZE / PAGE_SIZE
const ZERO_REGION = Buffer.alloc(REGION_SIZE)
// cheap check before a comparison: data rarely have their start, middle and end at zero
const mayBeZero = (part, start, end) => part[start] === 0 && part[(start + end) >>> 1] === 0 && part[end - 1] === 0
/**
* @param {Buffer|Buffer[]} data - the chunk, or its parts (the chunk is their concatenation)
* @returns {Buffer[]} the filtered chunk, in parts: the stored pages are views on the data, never copied
*/
exports.removeZeroPages = function removeZeroPages(data) {
const parts = Array.isArray(data) ? data : [data]
const length = parts.reduce((sum, part) => sum + part.length, 0)
assert.ok(length <= 0xffffffff, `chunk of ${length} bytes is too large`)
const nbPages = Math.ceil(length / PAGE_SIZE)
const header = Buffer.alloc(LENGTH_SIZE + Math.ceil(nbPages / 8))
header.writeUInt32BE(length, 0)
const result = [header]
// the current run of stored bytes, in the current part
let run
const store = (part, start, end) => {
if (run !== undefined && run.part === part && run.end === start) {
run.end = end
} else {
if (run !== undefined) result.push(run.part.subarray(run.start, run.end))
run = { part, start, end }
}
}
let partIndex = 0
let offset = 0 // in parts[partIndex]
for (let i = 0; i < nbPages; i++) {
if (i % PAGES_PER_REGION === 0 && (i + PAGES_PER_REGION) * PAGE_SIZE <= length) {
const part = parts[partIndex]
const end = offset + REGION_SIZE
if (
end <= part.length &&
mayBeZero(part, offset, end) &&
part.compare(ZERO_REGION, 0, REGION_SIZE, offset, end) === 0
) {
offset = end
if (offset === part.length) {
partIndex++
offset = 0
}
i += PAGES_PER_REGION - 1
continue
}
}
// the page may span several parts
const slices = []
let remaining = Math.min(PAGE_SIZE, length - i * PAGE_SIZE)
while (remaining > 0) {
const part = parts[partIndex]
const size = Math.min(remaining, part.length - offset)
if (size > 0) slices.push([part, offset, offset + size])
offset += size
remaining -= size
if (offset === part.length) {
partIndex++
offset = 0
}
}
if (
slices.every(
([part, start, end]) => mayBeZero(part, start, end) && part.compare(ZERO_PAGE, 0, end - start, start, end) === 0
)
) {
continue
}
header[LENGTH_SIZE + (i >> 3)] |= 1 << (i & 7)
for (const [part, start, end] of slices) store(part, start, end)
}
if (run !== undefined) result.push(run.part.subarray(run.start, run.end))
return result
}
/**
* @param {Buffer} filtered
* @returns {Buffer} the original chunk
*/
exports.restoreZeroPages = function restoreZeroPages(filtered) {
assert.ok(filtered.length >= LENGTH_SIZE, 'truncated chunk')
const length = filtered.readUInt32BE(0)
const nbPages = Math.ceil(length / PAGE_SIZE)
const dataStart = LENGTH_SIZE + Math.ceil(nbPages / 8)
assert.ok(filtered.length >= dataStart, 'truncated chunk')
let nbStored = 0
for (let i = 0; i < nbPages; i++) {
if (filtered[LENGTH_SIZE + (i >> 3)] & (1 << (i & 7))) {
nbStored++
}
}
// the last page may be shorter
const lastPageSize = length - (nbPages - 1) * PAGE_SIZE
const lastStored = nbPages > 0 && filtered[LENGTH_SIZE + ((nbPages - 1) >> 3)] & (1 << ((nbPages - 1) & 7))
const storedSize = nbStored * PAGE_SIZE - (lastStored ? PAGE_SIZE - lastPageSize : 0)
assert.strictEqual(filtered.length - dataStart, storedSize, 'the chunk does not match its bitmap')
// no page has been removed: the data are the original chunk
if (nbStored === nbPages) {
return filtered.subarray(dataStart)
}
const chunk = Buffer.alloc(length)
let offset = dataStart
for (let i = 0; i < nbPages; i++) {
if (filtered[LENGTH_SIZE + (i >> 3)] & (1 << (i & 7))) {
const size = Math.min(PAGE_SIZE, length - i * PAGE_SIZE)
filtered.copy(chunk, i * PAGE_SIZE, offset, offset + size)
offset += size
}
}
return chunk
}

View File

@@ -8,6 +8,18 @@ import { DEFAULT_BLOCK_SIZE, DISK_TYPES, FOOTER_SIZE, HEADER_SIZE, SECTOR_SIZE }
import { createFooter, createHeader } from '../_createFooterHeader.js'
export const FULL_BLOCK_BITMAP = Buffer.alloc(SECTOR_SIZE, 255)
/**
* the data of a block file of a VHD directory (see DiskBlock.vhdBlockCompression) is not the data of the block:
* only a VHD directory using the same compression can write it
*
* @param {import('@xen-orchestra/disk-transform').DiskBlock} block
*/
export function assertNotVhdBlockFile({ index, vhdBlockCompression }) {
if (vhdBlockCompression !== undefined) {
throw new Error(`block ${index} is a block file of a VHD directory compressed with ${vhdBlockCompression}`)
}
}
/**
* @abstract
*/
@@ -82,8 +94,9 @@ export class BaseVhd {
*/
async *vhdblockGenerator() {
const generator = this.#source.diskBlocks()
for await (const { data } of generator) {
yield Buffer.concat([FULL_BLOCK_BITMAP, data])
for await (const block of generator) {
assertNotVhdBlockFile(block)
yield Buffer.concat([FULL_BLOCK_BITMAP, block.data])
}
}
}

View File

@@ -60,7 +60,127 @@ class MockDisk extends RandomAccessDisk {
async close() {}
}
// blocks whose memory goes back to their producer once released, like the NBD sources do
class ReleasingMockDisk extends MockDisk {
released = 0
#fillByte
constructor(nbBlocks, blockIndexes, fillByte) {
super(nbBlocks, blockIndexes, fillByte)
this.#fillByte = fillByte
}
async readBlock(index) {
const data = Buffer.alloc(DEFAULT_BLOCK_SIZE, this.#fillByte)
return {
index,
data,
release: () => {
// once released, the producer may reuse the memory: scramble it to catch a late use
data.fill(0)
this.released++
},
}
}
}
async function writeAndCheck(disk, fillByte, compression) {
const tempDir = await pFromCallback(cb => tmp.dir(cb))
try {
await Disposable.use(async function* () {
const handler = yield getSyncedHandler({ url: 'file://' + tempDir })
const aliasPath = 'disk.alias.vhd'
await writeToVhdDirectory({
disk,
target: { handler, path: aliasPath, compression, concurrency: 4, validator: async () => {} },
})
const vhd = yield openVhd(handler, aliasPath)
await vhd.readBlockAllocationTable()
for (const index of disk.getBlockIndexes()) {
const { bitmap, data } = await vhd.readBlock(index)
assert.ok(
bitmap.every(byte => byte === 0xff),
`block ${index} bitmap should be full`
)
assert.ok(
data.every(byte => byte === fillByte),
`block ${index} should contain the source disk data`
)
}
})
} finally {
await rimraf(tempDir)
}
}
describe('DiskConsumerVhdDirectory', () => {
it('writes the blocks and releases them once written', async () => {
const disk = new ReleasingMockDisk(4, [0, 1, 3], 0x5a)
await writeAndCheck(disk, 0x5a)
assert.equal(disk.released, 3, 'each block released once')
})
it('pads the last block when it is shorter', async () => {
const disk = new MockDisk(2, [0, 1], 0x21)
const shortLength = 1024 * 1024
disk.getVirtualSize = () => DEFAULT_BLOCK_SIZE + shortLength
disk.readBlock = async index => ({
index,
data: Buffer.alloc(index === 1 ? shortLength : DEFAULT_BLOCK_SIZE, 0x21),
})
const tempDir = await pFromCallback(cb => tmp.dir(cb))
try {
await Disposable.use(async function* () {
const handler = yield getSyncedHandler({ url: 'file://' + tempDir })
await writeToVhdDirectory({
disk,
target: { handler, path: 'disk.alias.vhd', concurrency: 1, validator: async () => {} },
})
const vhd = yield openVhd(handler, 'disk.alias.vhd')
await vhd.readBlockAllocationTable()
const { data } = await vhd.readBlock(1)
assert.ok(data.subarray(0, shortLength).every(byte => byte === 0x21))
assert.ok(data.subarray(shortLength).every(byte => byte === 0))
})
} finally {
await rimraf(tempDir)
}
})
it('writes block files as stored, only with the same compression', async () => {
const tempDir = await pFromCallback(cb => tmp.dir(cb))
try {
await Disposable.use(async function* () {
const handler = yield getSyncedHandler({ url: 'file://' + tempDir })
// a block file without compression: full bitmap + data
const blockFile = Buffer.concat([Buffer.alloc(512, 0xff), Buffer.alloc(DEFAULT_BLOCK_SIZE, 0x24)])
const disk = new MockDisk(2, [0, 1], 0)
disk.readBlock = async index => ({ index, data: blockFile, vhdBlockCompression: 'none' })
await writeToVhdDirectory({
disk,
target: { handler, path: 'disk.alias.vhd', concurrency: 1, validator: async () => {} },
})
const vhd = yield openVhd(handler, 'disk.alias.vhd')
await vhd.readBlockAllocationTable()
assert.ok((await vhd.readBlock(1)).data.every(byte => byte === 0x24))
disk.readBlock = async index => ({ index, data: blockFile, vhdBlockCompression: 'gzip' })
await assert.rejects(
writeToVhdDirectory({
disk,
target: { handler, path: 'other.alias.vhd', concurrency: 1, validator: async () => {} },
}),
/compressed with gzip/
)
})
} finally {
await rimraf(tempDir)
}
})
it('writes compressed blocks', async () => {
await writeAndCheck(new ReleasingMockDisk(2, [0, 1], 0x33), 0x33, 'gzip')
})
it('writes a valid VHD directory that can be read back', async () => {
const tempDir = await pFromCallback(cb => tmp.dir(cb))
try {

View File

@@ -5,7 +5,7 @@
* @typedef {import('@xen-orchestra/disk-transform').Disk} Disk
*/
import { BaseVhd, FULL_BLOCK_BITMAP } from './BaseVhd.mjs'
import { BaseVhd } from './BaseVhd.mjs'
import { basename, dirname } from 'node:path'
import { asyncEach } from '@vates/async-each'
import { VhdDirectory, VhdAbstract } from 'vhd-lib'
@@ -74,11 +74,16 @@ export class DiskConsumerVhdDirectory extends BaseVhd {
* @type {import('@xen-orchestra/disk-transform').DiskBlock | null}
*/
let truncatedBlock = null
const EXPECTED_FULL_BUFFER_SIZE = DEFAULT_BLOCK_SIZE + FULL_BLOCK_BITMAP.length
await asyncEach(
generator,
async ({ index, data }) => {
async ({ index, data, release, vhdBlockCompression }) => {
signal?.throwIfAborted()
if (vhdBlockCompression !== undefined) {
// a block file of a VHD directory: written as stored, never decompressed nor recompressed
await vhd.writeRawBlock(index, data, vhdBlockCompression)
release?.()
return
}
if (truncatedBlock !== null) {
throw new Error(
`Expecting a ${DEFAULT_BLOCK_SIZE} bytes block, got a ${truncatedBlock.data.length}, for index ${truncatedBlock.index}`
@@ -87,10 +92,13 @@ export class DiskConsumerVhdDirectory extends BaseVhd {
if (data.length < DEFAULT_BLOCK_SIZE) {
truncatedBlock = { data, index }
}
await vhd.writeEntireBlock({
id: index,
buffer: Buffer.concat([FULL_BLOCK_BITMAP, data], EXPECTED_FULL_BUFFER_SIZE),
})
// the last block of a disk may be shorter: pad it with zeros
await vhd.writeBlockData(
index,
data.length < DEFAULT_BLOCK_SIZE ? Buffer.concat([data], DEFAULT_BLOCK_SIZE) : data
)
// the block file is written, its memory can be reused
release?.()
},
{ concurrency }
)

View File

@@ -115,6 +115,13 @@ describe('DiskConsumerVhdStream', () => {
}
})
it('should refuse the block files of a VHD directory', async () => {
const disk = new MockDisk(2, [0, 1])
disk.readBlock = async index => ({ index, data: Buffer.alloc(1024), vhdBlockCompression: 'gzip' })
const stream = await toVhdStream(disk)
await assert.rejects(stream.toArray(), /block file of a VHD directory/)
})
it('should abort stream when signal is aborted before start', async () => {
const disk = new MockDisk(
8,

View File

@@ -1,5 +1,5 @@
import { Readable } from 'stream'
import { BaseVhd, FULL_BLOCK_BITMAP } from './BaseVhd.mjs'
import { BaseVhd, FULL_BLOCK_BITMAP, assertNotVhdBlockFile } from './BaseVhd.mjs'
import { DEFAULT_BLOCK_SIZE } from '../_constants.js'
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
import { fuFooter, fuHeader, checksumStruct } from 'vhd-lib/_structs.js'
@@ -67,8 +67,10 @@ export class DiskConsumerVhdStream extends BaseVhd {
yield header
yield bat
let truncatedBlock = null
for await (const { data, index } of blockGenerator) {
for await (const block of blockGenerator) {
signal?.throwIfAborted()
assertNotVhdBlockFile(block)
const { data, index } = block
// only the last block can be truncated
// but the stream expect a full block
if (truncatedBlock !== null) {