mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
25 Commits
8025af3cc8
...
feat_nbd_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79865673da | ||
|
|
7432823adb | ||
|
|
eb284cda81 | ||
|
|
bb3d0017c1 | ||
|
|
e55cb82ec5 | ||
|
|
7334b170b8 | ||
|
|
9fbc16edaa | ||
|
|
d0e6097086 | ||
|
|
c984e29108 | ||
|
|
03b44eef37 | ||
|
|
b833581a9c | ||
|
|
cc74b2383d | ||
|
|
90d838450b | ||
|
|
0bd578aee9 | ||
|
|
35b2c5ca8b | ||
|
|
0b14824883 | ||
|
|
44a137bb0c | ||
|
|
93f434a9a1 | ||
|
|
6ac515be8a | ||
|
|
f180f9730d | ||
|
|
5123eb443d | ||
|
|
f010299af8 | ||
|
|
c5556f5f68 | ||
|
|
ca2522e04b | ||
|
|
66bea770b1 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
205
@xen-orchestra/backups/_bench/bench.mjs
Normal file
205
@xen-orchestra/backups/_bench/bench.mjs
Normal 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)
|
||||
34
@xen-orchestra/backups/_bench/discardtest.mjs
Normal file
34
@xen-orchestra/backups/_bench/discardtest.mjs
Normal 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()
|
||||
}
|
||||
26
@xen-orchestra/backups/_bench/nbdcheck.mjs
Normal file
26
@xen-orchestra/backups/_bench/nbdcheck.mjs
Normal 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) }))
|
||||
99
@xen-orchestra/backups/_bench/nbdfast.mjs
Normal file
99
@xen-orchestra/backups/_bench/nbdfast.mjs
Normal 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)
|
||||
120
@xen-orchestra/backups/_bench/nbdfast2.mjs
Normal file
120
@xen-orchestra/backups/_bench/nbdfast2.mjs
Normal 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)
|
||||
67
@xen-orchestra/backups/_bench/nbdlimit.mjs
Normal file
67
@xen-orchestra/backups/_bench/nbdlimit.mjs
Normal 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)
|
||||
36
@xen-orchestra/backups/_bench/tlsonread.mjs
Normal file
36
@xen-orchestra/backups/_bench/tlsonread.mjs
Normal 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)
|
||||
57
@xen-orchestra/backups/_bench/xapidisk.mjs
Normal file
57
@xen-orchestra/backups/_bench/xapidisk.mjs
Normal 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)
|
||||
137
@xen-orchestra/backups/_bench/xoworker.mjs
Normal file
137
@xen-orchestra/backups/_bench/xoworker.mjs
Normal 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)
|
||||
}
|
||||
110
@xen-orchestra/backups/_bench/xowrite.mjs
Normal file
110
@xen-orchestra/backups/_bench/xowrite.mjs
Normal 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)
|
||||
40
@xen-orchestra/backups/_discardedDisk.mjs
Normal file
40
@xen-orchestra/backups/_discardedDisk.mjs
Normal 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?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
64
@xen-orchestra/disk-transform/src/BlockBufferPool.mts
Normal file
64
@xen-orchestra/disk-transform/src/BlockBufferPool.mts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
125
@xen-orchestra/disk-transform/src/BlockBufferPool.test.mts
Normal file
125
@xen-orchestra/disk-transform/src/BlockBufferPool.test.mts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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', ''))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
87
@xen-orchestra/qa-test/_restorescale.tmp.mjs
Normal file
87
@xen-orchestra/qa-test/_restorescale.tmp.mjs
Normal 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()
|
||||
}
|
||||
100
@xen-orchestra/qa-test/_scale.tmp.mjs
Normal file
100
@xen-orchestra/qa-test/_scale.tmp.mjs
Normal 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()
|
||||
@@ -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 {
|
||||
|
||||
140
@xen-orchestra/xapi/disks/NbdDiskWriter.mjs
Normal file
140
@xen-orchestra/xapi/disks/NbdDiskWriter.mjs
Normal 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 }))
|
||||
}
|
||||
}
|
||||
195
@xen-orchestra/xapi/disks/NbdDiskWriter.test.mjs
Normal file
195
@xen-orchestra/xapi/disks/NbdDiskWriter.test.mjs
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>} */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
377
journal.md
Normal 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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
63
packages/vhd-lib/Vhd/_zeroFilter.integ.js
Normal file
63
packages/vhd-lib/Vhd/_zeroFilter.integ.js
Normal 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/)
|
||||
})
|
||||
})
|
||||
132
packages/vhd-lib/Vhd/_zeroFilter.js
Normal file
132
packages/vhd-lib/Vhd/_zeroFilter.js
Normal 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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user