Skip to content

Commit 69dd48d

Browse files
Zuulopenstack-gerrit
authored andcommitted
Merge "Fix retry of instance_update_and_get_original" into stable/rocky
2 parents 2f0f01f + 4540cd6 commit 69dd48d

File tree

2 files changed

+57
-22
lines changed

2 files changed

+57
-22
lines changed

nova/db/sqlalchemy/api.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2747,6 +2747,11 @@ def _instance_update(context, instance_uuid, values, expected, original=None):
27472747
if not uuidutils.is_uuid_like(instance_uuid):
27482748
raise exception.InvalidUUID(instance_uuid)
27492749

2750+
# NOTE(mdbooth): We pop values from this dict below, so we copy it here to
2751+
# ensure there are no side effects for the caller or if we retry the
2752+
# function due to a db conflict.
2753+
updates = copy.copy(values)
2754+
27502755
if expected is None:
27512756
expected = {}
27522757
else:
@@ -2758,32 +2763,32 @@ def _instance_update(context, instance_uuid, values, expected, original=None):
27582763
# updates
27592764
for field in ('task_state', 'vm_state'):
27602765
expected_field = 'expected_%s' % field
2761-
if expected_field in values:
2762-
value = values.pop(expected_field, None)
2766+
if expected_field in updates:
2767+
value = updates.pop(expected_field, None)
27632768
# Coerce all single values to singleton lists
27642769
if value is None:
27652770
expected[field] = [None]
27662771
else:
27672772
expected[field] = sqlalchemyutils.to_list(value)
27682773

27692774
# Values which need to be updated separately
2770-
metadata = values.pop('metadata', None)
2771-
system_metadata = values.pop('system_metadata', None)
2775+
metadata = updates.pop('metadata', None)
2776+
system_metadata = updates.pop('system_metadata', None)
27722777

2773-
_handle_objects_related_type_conversions(values)
2778+
_handle_objects_related_type_conversions(updates)
27742779

27752780
# Hostname is potentially unique, but this is enforced in code rather
27762781
# than the DB. The query below races, but the number of users of
27772782
# osapi_compute_unique_server_name_scope is small, and a robust fix
27782783
# will be complex. This is intentionally left as is for the moment.
2779-
if 'hostname' in values:
2780-
_validate_unique_server_name(context, values['hostname'])
2784+
if 'hostname' in updates:
2785+
_validate_unique_server_name(context, updates['hostname'])
27812786

27822787
compare = models.Instance(uuid=instance_uuid, **expected)
27832788
try:
27842789
instance_ref = model_query(context, models.Instance,
27852790
project_only=True).\
2786-
update_on_match(compare, 'uuid', values)
2791+
update_on_match(compare, 'uuid', updates)
27872792
except update_match.NoRowsMatched:
27882793
# Update failed. Try to find why and raise a specific error.
27892794

nova/tests/unit/db/test_db_api.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from sqlalchemy import Integer
4545
from sqlalchemy import MetaData
4646
from sqlalchemy.orm import query
47+
from sqlalchemy.orm import session as sqla_session
4748
from sqlalchemy import sql
4849
from sqlalchemy import Table
4950

@@ -2558,21 +2559,50 @@ def test(context):
25582559
test(self.ctxt)
25592560

25602561
def test_instance_update_and_get_original_conflict_race(self):
2561-
# Ensure that we retry if update_on_match fails for no discernable
2562-
# reason
2563-
instance = self.create_instance_with_args()
2564-
2565-
orig_update_on_match = update_match.update_on_match
2562+
# Ensure that we correctly process expected_task_state when retrying
2563+
# due to an unknown conflict
2564+
2565+
# This requires modelling the MySQL read view, which means that if we
2566+
# have read something in the current transaction and we read it again,
2567+
# we will read the same data every time even if another committed
2568+
# transaction has since altered that data. In this test we have an
2569+
# instance whose task state was originally None, but has been set to
2570+
# SHELVING by another, concurrent transaction. Therefore the first time
2571+
# we read the data we will read None, but when we restart the
2572+
# transaction we will read the correct data.
25662573

2567-
# Reproduce the conditions of a race between fetching and updating the
2568-
# instance by making update_on_match fail for no discernable reason the
2569-
# first time it is called, but work normally the second time.
2570-
with mock.patch.object(update_match, 'update_on_match',
2571-
side_effect=[update_match.NoRowsMatched,
2572-
orig_update_on_match]):
2573-
db.instance_update_and_get_original(
2574-
self.ctxt, instance['uuid'], {'metadata': {'mk1': 'mv3'}})
2575-
self.assertEqual(update_match.update_on_match.call_count, 2)
2574+
instance = self.create_instance_with_args(
2575+
task_state=task_states.SHELVING)
2576+
2577+
instance_out_of_date = copy.copy(instance)
2578+
instance_out_of_date['task_state'] = None
2579+
2580+
# NOTE(mdbooth): SQLA magic which makes this dirty object look
2581+
# like a freshly loaded one.
2582+
sqla_session.make_transient(instance_out_of_date)
2583+
sqla_session.make_transient_to_detached(instance_out_of_date)
2584+
2585+
# update_on_match will fail first time because the actual task state
2586+
# (SHELVING) doesn't match the expected task state (None). However,
2587+
# we ensure that the first time we fetch the instance object we get
2588+
# out-of-date data. This forces us to retry the operation to find out
2589+
# what really went wrong.
2590+
with mock.patch.object(sqlalchemy_api, '_instance_get_by_uuid',
2591+
side_effect=[instance_out_of_date, instance]), \
2592+
mock.patch.object(sqlalchemy_api, '_instance_update',
2593+
side_effect=sqlalchemy_api._instance_update):
2594+
self.assertRaises(exception.UnexpectedTaskStateError,
2595+
db.instance_update_and_get_original,
2596+
self.ctxt, instance['uuid'],
2597+
{'expected_task_state': [None]})
2598+
sqlalchemy_api._instance_update.assert_has_calls([
2599+
mock.call(self.ctxt, instance['uuid'],
2600+
{'expected_task_state': [None]}, None,
2601+
original=instance_out_of_date),
2602+
mock.call(self.ctxt, instance['uuid'],
2603+
{'expected_task_state': [None]}, None,
2604+
original=instance),
2605+
])
25762606

25772607
def test_instance_update_and_get_original_conflict_race_fallthrough(self):
25782608
# Ensure that is update_match continuously fails for no discernable

0 commit comments

Comments
 (0)