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

fix(jobs): a cancel reaches libvirt for a job that recovery watches

The worker sends cancels only to jobs its own threads run. After a
worker restart, a migration or live clone that libvirt still ran was
watched by recovery; a cancel then moved the job to cancelling, the page
said so, but nothing told libvirt, and the job ran to its end. Recovery
now sends the cancel once while libvirt still runs the job, and a
migration it aborted ends cancelled rather than failed.
This commit is contained in:
catborise 2026-11-06 09:02:12 +03:00
parent 65345bd980
commit 1d93b5c0bf
4 changed files with 28 additions and 0 deletions

View file

@ -1076,6 +1076,8 @@ class MigrateInstance(Kind):
return Outcome(Operation.SUCCEEDED, result=_moved(op, dest))
if source is not None and dest is None:
_shut_off_again(op, source)
if op.cancel_requested:
return Outcome(Operation.CANCELLED)
return Outcome(Operation.FAILED, error=say(_("The VM stayed on the source host")))
return Outcome(Operation.UNCERTAIN, error=say(_(
"The VM is on both computes or on neither; check them, then resolve this job.")))

View file

@ -70,6 +70,10 @@ class MigrateJobTestCase(TransactionTestCase):
dom.isActive.return_value = False
with patch("instances.jobs._domain", side_effect=[dom, None]): # source, destination
self.assertEqual(MigrateInstance().reconcile(op).state, Operation.FAILED)
op.cancel_requested = True # recovery sent the cancel: libvirt aborted it
with patch("instances.jobs._domain", side_effect=[dom, None]):
self.assertEqual(MigrateInstance().reconcile(op).state, Operation.CANCELLED)
op.cancel_requested = False
dom.isActive.return_value = True
dom.jobInfo.return_value = [2] + [0] * 11 # VIR_DOMAIN_JOB_UNBOUNDED
with patch("instances.jobs._domain", side_effect=[dom, dom]):

View file

@ -184,6 +184,22 @@ class RecoveryTestCase(TransactionTestCase):
self.run_at(1100, Outcome(Operation.RUNNING))
self.assertNotIn(op.pk, self.scheduler.failing_since)
def test_a_cancel_of_a_job_libvirt_still_runs_goes_to_libvirt_once(self):
op = self.orphan({})
Operation.objects.filter(pk=op.pk).update(state=Operation.CANCELLING, cancel_requested=True)
with patch.object(fakes.ScriptedKind, "cancel") as cancel:
self.run_at(1000, Outcome(Operation.RUNNING))
self.scheduler.next_try[op.pk] = 0
self.run_at(1015, Outcome(Operation.RUNNING))
cancel.assert_called_once()
self.assertEqual(cancel.call_args.args[0].pk, op.pk)
def test_a_job_without_a_cancel_is_watched_without_one(self):
self.orphan({})
with patch.object(fakes.ScriptedKind, "cancel") as cancel:
self.run_at(1000, Outcome(Operation.RUNNING))
cancel.assert_not_called()
def test_a_job_that_never_recorded_its_intent_fails(self):
op = self.orphan({}, effects=False)
self.run_due()

View file

@ -391,6 +391,7 @@ class Scheduler:
self.errors = {}
self.stalled_since = {}
self.failing_since = {}
self.cancel_sent = set()
self.inflight = set()
def schedule_orphans(self):
@ -415,6 +416,7 @@ class Scheduler:
def _done(self, op_id):
for table in (self.next_try, self.attempts, self.errors, self.stalled_since, self.failing_since):
table.pop(op_id, None)
self.cancel_sent.discard(op_id)
def _take_over(self, op):
"""True if op is now this worker's. Another host's job ends uncertain
@ -479,6 +481,10 @@ class Scheduler:
return
self.failing_since.pop(op_id, None)
if outcome.state == Operation.RUNNING:
# libvirt still runs it: a cancel asked meanwhile goes to libvirt once, as for a job thread's
if op.cancel_requested and op_id not in self.cancel_sent:
self.cancel_sent.add(op_id)
_guarded(jobs.get_kind(op.kind).cancel, op)
now = time.monotonic()
if not outcome.stalled:
self.stalled_since.pop(op_id, None)