1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

feat(instances): a running migration can be cancelled; its time limit goes

This commit is contained in:
catborise 2026-10-15 20:42:50 +03:00
parent 31cbe60aa4
commit c2d3285e0f
7 changed files with 69 additions and 127 deletions

View file

@ -87,8 +87,6 @@ def get_workers():
workers = get_workers()
# worker_class = 'egg:gunicorn#eventlet'
worker_connections = 1000
# Must exceed instances.utils.MIGRATION_TIME_LIMIT (540 s) plus time to answer;
# a lower value would kill a worker in the middle of a live migration.
timeout = 600
keepalive = 2

View file

@ -2,6 +2,7 @@
import math
import re
import time
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_noop
@ -19,7 +20,7 @@ from computes.models import Compute
from instances import utils
from instances.models import Instance
from operations import claims
from operations.jobs import Kind, Outcome, Plan, register
from operations.jobs import Cancelled, Kind, Outcome, Plan, register
from operations.models import Claim, Operation
@ -610,6 +611,8 @@ def _domain(compute_pk, uuid):
class MigrateInstance(Kind):
name = "instance.migrate"
label = gettext_noop("Migrate VM")
cancellable = True # libvirt aborts a migration; the VM keeps running on the source
progress_bytes = True # libvirt job data processed/total
def normalize(self, raw):
return {"instance": int(raw["instance"]), "target": int(raw["target"]),
@ -643,11 +646,32 @@ class MigrateInstance(Kind):
instance = _instance(p["instance"])
ctx.record_intent()
ctx.set_phase(gettext_noop("Migrating the VM"))
# migrate_instance logs the outcome
utils.migrate_instance(Compute.objects.get(pk=p["target"]), instance, op.user,
**{k: p[k] for k in MIGRATE_OPTIONS})
try:
# migrate_instance logs the outcome
utils.migrate_instance(Compute.objects.get(pk=p["target"]), instance, op.user,
**{k: p[k] for k in MIGRATE_OPTIONS})
except libvirtError:
op.refresh_from_db(fields=["cancel_requested"])
if op.cancel_requested:
raise Cancelled from None
raise
return {}
def progress(self, op):
info = _proxy(_instance(op.params["instance"])).instance.jobInfo()
return (info[4], info[3]) if info[3] else None
def cancel(self, op):
dom = _proxy(_instance(op.params["instance"])).instance
# the worker sends a cancel once: one that comes before libvirt began
# the migration waits for it, or for the job to end without it
while not _libvirt_busy(dom):
if not Operation.objects.filter(pk=op.pk, state__in=Operation.ACTIVE).exists():
return
time.sleep(1)
# an error (the migration ended meanwhile) goes to the worker log
dom.abortJob()
def reconcile(self, op):
source = _domain(op.intent["compute"], op.intent["uuid"])
dest = _domain(op.params["target"], op.intent["uuid"])

View file

@ -11,7 +11,8 @@ from appsettings.settings import get_settings
from computes.models import Compute
from instances.jobs import MigrateInstance
from instances.models import Instance
from operations.jobs import Context, get_kind
from libvirt import libvirtError
from operations.jobs import Cancelled, Context, get_kind
from operations.models import Claim, Operation, acquire
UUID = "31313131-4242-5353-6464-757575757575"
@ -74,3 +75,37 @@ class MigrateJobTestCase(TransactionTestCase):
self.assertEqual(MigrateInstance().reconcile(op).state, Operation.SUCCEEDED)
self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.dest)
def test_a_cancelled_migration_ends_cancelled_and_the_vm_stays(self):
op = self.queue(live_migrate="true")
ctx = self.start(op)
self.dest_conn.moveto.side_effect = libvirtError("operation aborted: migration out: canceled by client")
Operation.objects.filter(pk=op.pk).update(cancel_requested=True)
with self.assertRaises(Cancelled):
get_kind(op.kind).run(op, ctx)
self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.source)
def test_cancel_and_progress_ask_the_source_vm(self):
op = self.queue(live_migrate="true")
with patch("instances.jobs.wvmInstance") as proxy:
proxy.return_value.instance.jobInfo.return_value = [2, 0, 0, 1000, 250] + [0] * 7
self.assertEqual(get_kind(op.kind).progress(op), (250, 1000))
get_kind(op.kind).cancel(op)
proxy.return_value.instance.abortJob.assert_called_once_with()
def test_a_cancel_before_libvirt_starts_the_migration_waits_for_it(self):
op = self.queue(live_migrate="true")
self.start(op)
with patch("instances.jobs.wvmInstance") as proxy, patch("instances.jobs.time.sleep") as sleep:
dom = proxy.return_value.instance
dom.isActive.return_value = True
dom.jobInfo.side_effect = [[0] * 12, [2] + [0] * 11] # no job yet, then the migration
get_kind(op.kind).cancel(op)
sleep.assert_called_once()
dom.abortJob.assert_called_once_with()
Operation.objects.filter(pk=op.pk).update(state=Operation.SUCCEEDED) # ended before libvirt began
with patch("instances.jobs.wvmInstance") as proxy:
proxy.return_value.instance.isActive.return_value = False
get_kind(op.kind).cancel(op)
proxy.return_value.instance.abortJob.assert_not_called()

View file

@ -1,20 +1,17 @@
"""What migrate_instance does around the migration itself: it records a
finished migration even when autostart fails."""
import re
from pathlib import Path
from unittest.mock import MagicMock, patch
from accounts.models import UserInstance
from computes.models import Compute
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import DatabaseError
from django.test import TestCase
from logs.models import Logs
from instances.models import Instance
from instances.utils import MIGRATION_TIME_LIMIT, migrate_instance
from instances.utils import migrate_instance
from vrtManager import util
@ -71,10 +68,3 @@ class MigrationBookkeepingTests(TestCase):
found.refresh_from_db()
self.assertTrue(found.is_template)
self.assertEqual(UserInstance.objects.get(user=owner).instance_id, found.id)
def test_a_migration_is_cancelled_before_the_web_server_ends_the_request(self):
self.migrate()
self.assertEqual(self.moveto.call_args.kwargs["timeout"], MIGRATION_TIME_LIMIT)
gunicorn = (Path(settings.BASE_DIR) / "gunicorn.conf.py").read_text()
request_timeout = int(re.search(r"^timeout = (\d+)", gunicorn, re.M).group(1))
self.assertLess(MIGRATION_TIME_LIMIT, request_timeout - 30) # time to answer and log

View file

@ -148,11 +148,6 @@ def get_network_tuple(network_source_str):
return network_source_pack[0], "net"
# A migration that has not finished by then is cancelled: below the 600 s
# after which gunicorn (gunicorn.conf.py) ends the request.
MIGRATION_TIME_LIMIT = 540
def migrate_instance(
new_compute,
instance,
@ -186,7 +181,6 @@ def migrate_instance(
autoconverge,
compress,
uri=new_compute.migration_uri,
timeout=MIGRATION_TIME_LIMIT,
)
except Exception as error:
msg = _("Instance migration to %(target)s failed: %(error)s") % {

View file

@ -5,7 +5,6 @@ import logging
import os.path
import re
import string
import threading
import time
logger = logging.getLogger(__name__)
@ -77,7 +76,7 @@ def _rate(before, after, seconds, scale=1):
class wvmInstances(wvmConnect):
def moveto(self, conn, name, live, unsafe, offline, autoconverge=False, compress=False, uri=None, timeout=None):
def moveto(self, conn, name, live, unsafe, offline, autoconverge=False, compress=False, uri=None):
"""Migrates the VM of conn (a wvmInstance on the source) here.
The mode follows the VM's state, read once: a running VM migrates
@ -86,9 +85,8 @@ class wvmInstances(wvmConnect):
fit the state is refused. unsafe, autoconverge (live only) and
compress apply to a running VM. uri: the native migration URI
(tcp://address) on the destination; None lets libvirt use the
destination's own hostname. timeout: seconds after which a migration
that has not finished is cancelled (None: no limit). Returns the mode
and options used, for the log."""
destination's own hostname. Returns the mode and options used, for
the log."""
state = conn.get_status()
if state == 5:
if live:
@ -150,7 +148,7 @@ class wvmInstances(wvmConnect):
# dom.migrate.
self.move_definition(dom)
else:
self.migrate_within(dom, flags, uri, timeout)
dom.migrate(self.wvm, flags, None, uri, 0)
return ", ".join(mode)
@staticmethod
@ -172,40 +170,6 @@ class wvmInstances(wvmConnect):
"host. Start the VM and migrate it live."
)
def migrate_within(self, dom, flags, uri, timeout):
"""dom.migrate, cancelled when it has not finished after timeout
seconds: a VM that changes its memory faster than the network carries
it never finishes, and the request would be ended by the web server
with no word. Cancelled, the VM stays on the source."""
cancelled = threading.Event()
def cancel():
with contextlib.suppress(libvirtError): # finished meanwhile
dom.abortJob()
cancelled.set()
timer = threading.Timer(timeout, cancel) if timeout else None
if timer:
timer.daemon = True
timer.start()
try:
dom.migrate(self.wvm, flags, None, uri, 0)
except libvirtError as error:
if timer:
timer.cancel()
timer.join() # a cancel under way ends before the locks go
if cancelled.is_set():
raise util.OperationError(
f"The migration did not finish within {timeout} s and was cancelled ({error}); the VM stays on "
"the source host. A VM that changes its memory faster than the network carries it needs auto "
"converge."
) from error
raise
finally:
if timer:
timer.cancel()
timer.join()
def missing_disks(self, definitions):
"""(target, source) of the disks in the VM definitions (XML) that are
not volumes of this host's active storage pools: the VM's own files,

View file

@ -1,9 +1,7 @@
"""Migration: the source definition never stays behind; a shut-off VM's
definition is moved by the app, not by libvirt's offline migration."""
import threading
import unittest
from unittest.mock import patch
import libvirt
from django.conf import settings
@ -427,64 +425,3 @@ class DestinationDisksTestCase(unittest.TestCase):
xml = with_disks("<disk type='volume' device='disk'><source pool='p' volume='v'/><target dev='vdc'/></disk>")
with self.assertRaisesRegex(util.OperationError, "vdc \\(p/v\\)"):
self.migrate(host, xml)
class SlowDomain(FakeDomain):
"""A migration that does not finish until its job is aborted."""
def __init__(self, **kw):
super().__init__(**kw)
self.aborted = threading.Event()
def migrate(self, dconn, flags, dname, uri, bandwidth):
if not self.aborted.wait(5):
raise AssertionError("the migration was not aborted")
raise libvirt.libvirtError("operation aborted: job 'migration out' canceled by client")
def abortJob(self):
self.aborted.set()
class MigrationTimeLimitTestCase(unittest.TestCase):
"""A migration that does not converge is cancelled after the time limit,
before the web server ends the request; the VM stays on the source."""
def test_a_migration_over_the_limit_is_cancelled(self):
source = FakeSource(1)
source.instance = SlowDomain()
with self.assertRaisesRegex(util.OperationError, "did not finish within 0.05 s and was cancelled.*auto converge"):
destination().moveto(source, "vm", live=True, unsafe=False, offline=False, timeout=0.05)
self.assertTrue(source.instance.aborted.is_set())
def test_a_migration_within_the_limit_stops_its_timer(self):
timers = []
class Timer(threading.Timer):
def __init__(self, *args):
super().__init__(*args)
self.joined = False
timers.append(self)
def join(self, timeout=None):
self.joined = True
super().join(timeout)
source = FakeSource(1)
with patch("vrtManager.instance.threading.Timer", Timer):
destination().moveto(source, "vm", live=True, unsafe=False, offline=False, timeout=5)
self.assertEqual(len(source.instance.calls), 1)
self.assertTrue(timers[0].finished.is_set() and timers[0].joined)
self.assertFalse(timers[0].is_alive())
def test_a_failure_the_cancel_did_not_cause_keeps_its_error(self):
source = FakeSource(1)
source.instance = SlowDomain()
def refused():
source.instance.aborted.set() # migrate() gives up, but the abort itself failed
raise libvirt.libvirtError("cannot abort migration in confirm phase")
source.instance.abortJob = refused
with self.assertRaisesRegex(libvirt.libvirtError, "operation aborted") as caught:
destination().moveto(source, "vm", live=True, unsafe=False, offline=False, timeout=0.05)
self.assertNotIsInstance(caught.exception, util.OperationError)