mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
perf(storages): an ISO uploads to an SSH compute without waiting for each write
Each 32 MiB chunk went out as 1024 SFTP writes, each waiting for its answer: 19 MiB/s on a gigabit LAN, ~1.9 s per chunk with the SSH session. The writes now go out without waiting and their answers are read as they come and at the end: ~0.53 s per chunk, the same file (checked by sha256). paramiko's own pipelined writes (set_pipelined) drop the errors in their answers, so a full disk would leave a short ISO behind a successful upload. Each write is registered to its file instead, the way paramiko does for prefetched reads: every answer, in any order, reaches the file and an error is raised as soon as one is read.
This commit is contained in:
parent
24231813d7
commit
a87fbf739f
4 changed files with 136 additions and 5 deletions
|
|
@ -536,6 +536,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat
|
|||
- A disk can be grown while its VM runs or is paused, not only when it is shut off.
|
||||
- A host's CPU graph shows the average since its previous point (normally 5 s) instead of a 1 s sample taken while a worker waits.
|
||||
- A VM's stats are polled every 5 s like the host graph, the first point as soon as the tab opens; each point is the average since the previous one, disk rates in MB/s (they were MiB/s labelled Mb/s); a counter the host does not report shows a gap instead of 0.
|
||||
- An ISO uploads to an SSH compute about 3.5 times faster (SFTP writes no longer wait for each 32 KiB); a failed write still fails the upload.
|
||||
- A running VM without a guest agent shows the IPv4 address the host learned by ARP (the lookup always failed before).
|
||||
- After a live migration the VM's NVRAM file is deleted on the source, when it is in libvirt's `/var/lib/libvirt/qemu/nvram` and the destination does not share that directory (a VM created there later with the same name would start with those UEFI variables).
|
||||
- gstfsd finds the guest's operating system the way libguestfs inspects it, so a root on LVM works, and it always answers (an error when it finds no or several operating systems). Update `/usr/local/bin/gstfsd` on every compute (see [Adding a Compute Node](#adding-a-compute-node), step 3).
|
||||
|
|
|
|||
94
storages/test_sftp_pipelined.py
Normal file
94
storages/test_sftp_pipelined.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""ISO chunks go to an SSH compute as pipelined SFTP writes. paramiko's own
|
||||
pipelined writes drop their errors, so the upload registers each write to
|
||||
the file: every answer, in whatever order it comes, reaches the file and an
|
||||
error is raised. Checked with paramiko's real client and file, on a fake
|
||||
transport."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import paramiko
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from paramiko.message import Message
|
||||
from paramiko.sftp import CMD_STATUS, CMD_WRITE, SFTP_FAILURE, SFTP_OK
|
||||
|
||||
from storages.upload import _UploadTarget
|
||||
from vrtManager.connection import CONN_SSH
|
||||
|
||||
|
||||
class Client(paramiko.SFTPClient):
|
||||
"""paramiko's SFTPClient without a channel. The server answers once it is
|
||||
asked for an answer, all requests so far in the given order, and fails
|
||||
the request numbered fail_at."""
|
||||
|
||||
def __init__(self, order=None, fail_at=None, answer_early=False):
|
||||
self._lock = threading.Lock()
|
||||
self._expecting = {}
|
||||
self.request_number = 1
|
||||
self.logger = logging.getLogger("test_sftp_pipelined")
|
||||
self.ultra_debug = False
|
||||
self.order, self.fail_at = order, fail_at
|
||||
self.written = {}
|
||||
self.answers = []
|
||||
# answer_early: an answer is ready as soon as its request is sent
|
||||
self.sock = MagicMock(recv_ready=lambda: answer_early and bool(self.answers))
|
||||
|
||||
def _send_packet(self, kind, packet):
|
||||
message = Message(packet.asbytes())
|
||||
number = message.get_int()
|
||||
if kind == CMD_WRITE:
|
||||
message.get_string() # handle
|
||||
offset = message.get_int64()
|
||||
self.written[offset] = message.get_string()
|
||||
self.answers.append(number) # the close too
|
||||
|
||||
def _read_packet(self):
|
||||
if self.order is not None:
|
||||
self.answers = [self.answers[i] for i in self.order(len(self.answers))]
|
||||
self.order = None
|
||||
number = self.answers.pop(0)
|
||||
answer = Message()
|
||||
answer.add_int(number)
|
||||
answer.add_int(SFTP_FAILURE if number == self.fail_at else SFTP_OK)
|
||||
answer.add_string("Failure" if number == self.fail_at else "")
|
||||
answer.add_string("")
|
||||
return CMD_STATUS, answer.asbytes()
|
||||
|
||||
|
||||
SIZE = 10 * paramiko.SFTPFile.MAX_REQUEST_SIZE
|
||||
|
||||
|
||||
def upload(client):
|
||||
target = _UploadTarget(MagicMock(conn=CONN_SSH, host="192.0.2.1"), "/pool", "x.iso", "id")
|
||||
target.sftp = MagicMock(open=lambda path, mode: paramiko.SFTPFile(client, b"handle", "w"))
|
||||
data = bytes(range(256)) * (SIZE // 256)
|
||||
return target.write_chunk(SimpleUploadedFile("x.iso", data), 0, new=True), data
|
||||
|
||||
|
||||
class PipelinedWriteTestCase(unittest.TestCase):
|
||||
def test_all_writes_are_sent_then_answered_in_any_order(self):
|
||||
for name, order in (("in order", None), ("reversed", lambda n: reversed(range(n)))):
|
||||
with self.subTest(name):
|
||||
client = Client(order=order)
|
||||
written, data = upload(client)
|
||||
self.assertEqual(written, SIZE)
|
||||
self.assertEqual(b"".join(client.written[k] for k in sorted(client.written)), data)
|
||||
self.assertEqual(client._expecting, {}) # every answer was taken
|
||||
|
||||
def test_answers_read_while_sending(self):
|
||||
client = Client(answer_early=True)
|
||||
written, data = upload(client)
|
||||
self.assertEqual(b"".join(client.written[k] for k in sorted(client.written)), data)
|
||||
self.assertEqual(client._expecting, {})
|
||||
|
||||
def test_an_error_of_any_write_is_raised(self):
|
||||
for name, client in (
|
||||
("in order", lambda: Client(fail_at=4)),
|
||||
("reversed", lambda: Client(order=lambda n: reversed(range(n)), fail_at=4)),
|
||||
("read while sending", lambda: Client(fail_at=4, answer_early=True)),
|
||||
):
|
||||
with self.subTest(name):
|
||||
with self.assertRaisesRegex(IOError, "Failure"):
|
||||
upload(client())
|
||||
|
|
@ -523,6 +523,25 @@ class StorageUploadSecurityTests(TestCase):
|
|||
def read(self):
|
||||
return bytes(self.buf)
|
||||
|
||||
# the SFTP client calls of a pipelined write: the file is its own client
|
||||
MAX_REQUEST_SIZE = 32768
|
||||
handle = b"handle"
|
||||
sock = MagicMock(recv_ready=lambda: False)
|
||||
|
||||
@property
|
||||
def sftp(self):
|
||||
return self
|
||||
|
||||
def _async_request(self, fileobj, kind, handle, offset, data):
|
||||
self.seek(offset)
|
||||
self.write(data)
|
||||
|
||||
def _finish_responses(self, fileobj):
|
||||
pass
|
||||
|
||||
def _check_exception(self):
|
||||
pass
|
||||
|
||||
def seek(self, pos):
|
||||
self.pos = pos
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,26 @@ def _sftp_replace_file(sftp, src, dst, ssh=None):
|
|||
raise OSError(_("Remote server cannot safely replace upload metadata"))
|
||||
|
||||
|
||||
def _write_pipelined(stream, chunk, offset):
|
||||
"""Write chunk to an SFTP file at offset without waiting for the answer to
|
||||
each 32 KiB write, raising an error as soon as one is read. Each write is
|
||||
registered to the file, as paramiko does for prefetched reads, so its
|
||||
answer reaches the file in whatever order it comes. (paramiko's own
|
||||
pipelined writes drop their errors.)"""
|
||||
sftp = stream.sftp
|
||||
for data in chunk.chunks():
|
||||
for start in range(0, len(data), stream.MAX_REQUEST_SIZE):
|
||||
piece = data[start:start + stream.MAX_REQUEST_SIZE]
|
||||
sftp._async_request(stream, paramiko.sftp.CMD_WRITE, stream.handle, paramiko.sftp.int64(offset), piece)
|
||||
offset += len(piece)
|
||||
# answers are taken as they come, so the server never waits for us to read
|
||||
while sftp.sock.recv_ready():
|
||||
sftp._read_response()
|
||||
stream._check_exception()
|
||||
sftp._finish_responses(stream)
|
||||
return offset
|
||||
|
||||
|
||||
def _cleanup_stale_local_uploads(base_dir, max_age_seconds=86400):
|
||||
try:
|
||||
now = time.time()
|
||||
|
|
@ -404,11 +424,8 @@ class _UploadTarget:
|
|||
if not new:
|
||||
stream.seek(offset)
|
||||
stream.truncate(offset)
|
||||
for data in chunk.chunks():
|
||||
stream.write(data)
|
||||
offset += len(data)
|
||||
if callable(getattr(stream, "flush", None)):
|
||||
stream.flush()
|
||||
# ~5x faster on a LAN than waiting for each 32 KiB write
|
||||
offset = _write_pipelined(stream, chunk, offset)
|
||||
return offset
|
||||
flags = (os.O_WRONLY | os.O_CREAT | os.O_EXCL) if new else os.O_RDWR
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue