diff --git a/nova/tests/fixtures/libvirt.py b/nova/tests/fixtures/libvirt.py index 492fe0ba43..de0b88b87c 100644 --- a/nova/tests/fixtures/libvirt.py +++ b/nova/tests/fixtures/libvirt.py @@ -1221,6 +1221,10 @@ class Domain(object): if emulator_pin is not None: definition['emulator_pin'] = emulator_pin.get('cpuset') + iothread_pin = tree.find('./cputune/iothreadpin') + if iothread_pin is not None: + definition['iothread_pin'] = iothread_pin.get('cpuset') + memnodes = {} for node in tree.findall('./numatune/memnode'): @@ -1671,12 +1675,17 @@ class Domain(object): cputune = '' for vcpu, cpuset in self._def['cpu_pins'].items(): cputune += '' % (int(vcpu), cpuset) - emulatorpin = None + emulatorpin = '' if 'emulator_pin' in self._def: emulatorpin = ('' % self._def['emulator_pin']) - if cputune or emulatorpin: - cputune = '%s%s' % (emulatorpin, cputune) + iothreadpin = '' + if 'iothread_pin' in self._def: + iothreadpin = ('' % + self._def['iothread_pin']) + if cputune or emulatorpin or iothreadpin: + cputune = '%s%s%s' % ( + emulatorpin, iothreadpin, cputune) numatune = '' for cellid, nodeset in self._def['memnodes'].items(): diff --git a/nova/tests/functional/regressions/test_bug_2139351.py b/nova/tests/functional/regressions/test_bug_2139351.py new file mode 100644 index 0000000000..b5584353b2 --- /dev/null +++ b/nova/tests/functional/regressions/test_bug_2139351.py @@ -0,0 +1,184 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Regression test for bug 2139351. + +https://bugs.launchpad.net/nova/+bug/2139351 + +Commit 76d64b9cb4241b73e62b3775f13d8eddcc0cb778 added iothread support, +creating one iothread per VM and pinning iothreads to the same cpuset as +emulator threads. During live migration, _update_numa_xml() in +nova/virt/libvirt/migration.py updates vcpupin and emulatorpin elements +but NOT iothreadpin. When cpu_shared_set differs between source and +destination hosts, the iothread remains incorrectly pinned to the source +host's CPU set. +""" + +from lxml import etree + +from nova.tests.fixtures import libvirt as fakelibvirt +from nova.tests.functional import integrated_helpers +from nova.tests.functional.libvirt import base + + +class TestLiveMigrationIOThreadPinning( + base.LibvirtMigrationMixin, + base.ServersTestBase, + integrated_helpers.InstanceHelperMixin +): + """Regression test for iothreadpin during live migration. + + This tests that iothreadpin is correctly updated when live migrating + between hosts with different cpu_shared_set configurations. + """ + + microversion = 'latest' + ADMIN_API = True + ADDITIONAL_FILTERS = ['NUMATopologyFilter'] + + def setUp(self): + super().setUp() + self.src_hostname = self.start_compute( + hostname='src', + host_info=fakelibvirt.HostInfo( + cpu_nodes=1, cpu_sockets=1, cpu_cores=4, cpu_threads=1)) + self.dest_hostname = self.start_compute( + hostname='dest', + host_info=fakelibvirt.HostInfo( + cpu_nodes=1, cpu_sockets=1, cpu_cores=4, cpu_threads=1)) + self.src = self.computes['src'] + self.dest = self.computes['dest'] + + def get_host(self, server_id): + server = self.api.get_server(server_id) + return server['OS-EXT-SRV-ATTR:host'] + + def _get_xml_element(self, xml, xpath): + """Get element from XML using xpath.""" + xml_doc = etree.fromstring(xml.encode('utf-8')) + element = xml_doc.find(xpath) + return element + + def test_live_migrate_iothread_pinning_numa(self): + """Test iothread pinning updated for NUMA VMs with dedicated CPUs. + + BUG: emulatorpin updates correctly but iothreadpin does not. + """ + # Configure both hosts: shared=0,1 dedicated=2,3 + self.flags( + cpu_shared_set='0,1', cpu_dedicated_set='2,3', group='compute') + self.restart_compute_service('src') + self.restart_compute_service('dest') + + # Create VM with dedicated CPUs and shared emulator threads + extra_spec = { + 'hw:cpu_policy': 'dedicated', + 'hw:emulator_threads_policy': 'share' + } + flavor = self._create_flavor(vcpu=1, extra_spec=extra_spec) + self.server = self._create_server( + flavor_id=flavor, host='src', networks='none') + + # Get source XML and verify pinning matches cpu_shared_set + conn = self.src.driver._host.get_connection() + dom = conn.lookupByUUIDString(self.server['id']) + src_xml = dom.XMLDesc(0) + + src_emulatorpin = self._get_xml_element( + src_xml, './cputune/emulatorpin') + src_iothreadpin = self._get_xml_element( + src_xml, './cputune/iothreadpin') + self.assertIsNotNone(src_emulatorpin) + self.assertIsNotNone(src_iothreadpin) + self.assertEqual('0-1', src_emulatorpin.get('cpuset')) + self.assertEqual('0-1', src_iothreadpin.get('cpuset')) + + # Configure dest: shared=2,3 dedicated=0,1 (swapped) + self.flags( + cpu_shared_set='2,3', cpu_dedicated_set='0,1', group='compute') + self.restart_compute_service('dest') + + # Live migrate + self._live_migrate(self.server, 'completed') + self.assertEqual('dest', self.get_host(self.server['id'])) + + # Get dest XML + conn = self.dest.driver._host.get_connection() + dom = conn.lookupByUUIDString(self.server['id']) + dest_xml = dom.XMLDesc(0) + + # Verify emulatorpin updated (this works) + dest_emulatorpin = self._get_xml_element( + dest_xml, './cputune/emulatorpin') + self.assertIsNotNone(dest_emulatorpin) + self.assertEqual('2-3', dest_emulatorpin.get('cpuset')) + + # Verify iothreadpin updated + dest_iothreadpin = self._get_xml_element( + dest_xml, './cputune/iothreadpin') + self.assertIsNotNone(dest_iothreadpin) + # FIXME: this is bug 2139351 + self.assertEqual('0-1', dest_iothreadpin.get('cpuset')) + self.assertNotEqual( + dest_emulatorpin.get('cpuset'), dest_iothreadpin.get('cpuset')) + # self.assertEqual( + # '2-3', dest_iothreadpin.get('cpuset'), + # f"iothreadpin was not updated during live migration. " + # f"Expected '2-3' but got '{dest_iothreadpin.get('cpuset')}'") + + # # Both should match + # self.assertEqual( + # dest_emulatorpin.get('cpuset'), dest_iothreadpin.get('cpuset')) + + def test_live_migrate_unpinned_vcpu_cpuset_updated(self): + """Test vcpu cpuset updated for unpinned VMs with cpu_shared_set. + + This is a sanity check that the existing migration code correctly + updates the vcpu cpuset for unpinned VMs. Unpinned VMs don't have + cputune/emulatorpin or cputune/iothreadpin elements. + """ + # Configure both hosts with cpu_shared_set + self.flags(cpu_shared_set='0,1', group='compute') + self.restart_compute_service('src') + self.restart_compute_service('dest') + + # Create unpinned VM (default flavor, no dedicated CPU policy) + self.server = self._create_server(host='src', networks='none') + + # Verify source XML has vcpu cpuset matching cpu_shared_set + conn = self.src.driver._host.get_connection() + dom = conn.lookupByUUIDString(self.server['id']) + src_xml = dom.XMLDesc(0) + self.assertIn('1', src_xml) + + # Verify no cputune elements for unpinned VMs + src_emulatorpin = self._get_xml_element( + src_xml, './cputune/emulatorpin') + src_iothreadpin = self._get_xml_element( + src_xml, './cputune/iothreadpin') + self.assertIsNone(src_emulatorpin) + self.assertIsNone(src_iothreadpin) + + # Configure dest with different cpu_shared_set + self.flags(cpu_shared_set='2,3', group='compute') + self.restart_compute_service('dest') + + # Live migrate + self._live_migrate(self.server, 'completed') + self.assertEqual('dest', self.get_host(self.server['id'])) + + # Verify dest XML has vcpu cpuset updated + conn = self.dest.driver._host.get_connection() + dom = conn.lookupByUUIDString(self.server['id']) + dest_xml = dom.XMLDesc(0) + self.assertNotIn('1', dest_xml) + self.assertIn('1', dest_xml)