Просмотр исходного кода

Merge pull request #349 from CloudVE/gcp-metadata-conflict-retry

Retry GCP metadata writes on an operation-level fingerprint conflict
Nuwan Goonasekera 5 дней назад
Родитель
Сommit
c8374046b2

+ 25 - 0
CHANGELOG.rst

@@ -1,3 +1,28 @@
+4.4.2 - unreleased
+------------------
+
+## Fixes
+* **GCP common-metadata writes now survive concurrent writers.** Labels for
+  networks, routers, firewalls and key pairs live in the project-wide common
+  instance metadata, which every write re-uploads under an optimistic
+  fingerprint. Two things went wrong under parallel use. The retry for a
+  stale fingerprint only recognised the conflict as an HTTP error, but a
+  concurrent writer produces it differently: the upload is accepted and the
+  resulting *operation* completes with ``CONDITION_NOT_MET``, which
+  ``wait_for_operation`` raised as a plain ``Exception`` the retry ignored.
+  And - measured in the live suite - GCP accepts several writes against the
+  same fingerprint while an earlier one is still pending, and a later one can
+  complete as ``DONE`` with its change absent from the document: 11 of 56
+  writes in one run. ``wait_for_operation`` now raises ``GCPOperationError``
+  (a ``ProviderInternalException`` carrying the operation's error payload and
+  ``codes``); a metadata write is only finished once its change reads back,
+  and is redone on fresh metadata otherwise (``MetadataWriteNotApplied`` once
+  the retries are exhausted); a write that changes nothing is not sent at
+  all; ``add_metadata_item`` checks for an existing key itself and raises
+  ``DuplicateResourceException``, so a retry never appends a second copy;
+  and ``remove_metadata_item`` returns ``False`` when there was nothing to
+  remove, as its callers always assumed.
+
 4.4.1 - August 21, 2026 (sha 093ef669598d9f324be28d400a851396739cf1d8)
 4.4.1 - August 21, 2026 (sha 093ef669598d9f324be28d400a851396739cf1d8)
 ----------------------------------------------------------------------
 ----------------------------------------------------------------------
 
 

+ 102 - 29
cloudbridge/providers/gcp/helpers.py

@@ -13,12 +13,45 @@ from googleapiclient.errors import HttpError
 
 
 import tenacity
 import tenacity
 
 
+from cloudbridge.interfaces.exceptions import DuplicateResourceException
 from cloudbridge.interfaces.exceptions import ProviderInternalException
 from cloudbridge.interfaces.exceptions import ProviderInternalException
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .provider import GCPCloudProvider
     from .provider import GCPCloudProvider
 
 
 
 
+class GCPOperationError(ProviderInternalException):
+    """A GCP operation completed with an error.
+
+    Raised by ``wait_for_operation`` with the operation's ``error`` payload,
+    so callers can act on the error codes rather than parse the message.
+    """
+
+    def __init__(self, error: Any) -> None:
+        super().__init__(error)
+        self.error = error
+
+    @property
+    def codes(self) -> list[str]:
+        return [e.get('code', '') for e in self.error.get('errors', [])]
+
+
+class MetadataWriteNotApplied(ProviderInternalException):
+    """A common-metadata write completed as DONE but is absent when read back.
+
+    Observed under concurrent writers: GCP accepts several writes against
+    the same fingerprint while an earlier one is still pending, and a later
+    one can complete without its change surviving. The write is retried on
+    fresh metadata; this is raised only once the retries are exhausted.
+    """
+
+    def __init__(self, keys: list[str]) -> None:
+        super().__init__(
+            'Project metadata write reported done, but these keys do not '
+            'hold the written value: {}'.format(', '.join(keys)))
+        self.keys = keys
+
+
 def gcp_projects(provider: "GCPCloudProvider") -> Any:
 def gcp_projects(provider: "GCPCloudProvider") -> Any:
     return provider.gcp_compute.projects()
     return provider.gcp_compute.projects()
 
 
@@ -43,8 +76,18 @@ def get_common_metadata(provider: "GCPCloudProvider") -> Any:
     return metadata["commonInstanceMetadata"]
     return metadata["commonInstanceMetadata"]
 
 
 
 
-def __if_fingerprint_differs(e: BaseException) -> bool:
-    # return True if the CloudError exception is due to subnet being in use
+def __metadata_write_lost_to_a_concurrent_writer(e: BaseException) -> bool:
+    """Whether ``e`` means a metadata write must be redone on fresh metadata.
+
+    A concurrent writer shows up in three shapes: an HTTP 412 on the request
+    itself, a submitted operation that completes with ``CONDITION_NOT_MET``,
+    or - when GCP accepted both writes against the same fingerprint - an
+    operation that completes as DONE without the change surviving.
+    """
+    if isinstance(e, GCPOperationError):
+        return 'CONDITION_NOT_MET' in e.codes
+    if isinstance(e, MetadataWriteNotApplied):
+        return True
     if isinstance(e, HttpError):
     if isinstance(e, HttpError):
         expected_message = 'Supplied fingerprint does not match current ' \
         expected_message = 'Supplied fingerprint does not match current ' \
                            'metadata fingerprint.'
                            'metadata fingerprint.'
@@ -53,8 +96,14 @@ def __if_fingerprint_differs(e: BaseException) -> bool:
     return False
     return False
 
 
 
 
+def _metadata_values(metadata: Any) -> dict[str, Any]:
+    return {item['key']: item.get('value')
+            for item in metadata.get('items', [])}
+
+
 @tenacity.retry(stop=tenacity.stop_after_attempt(10),
 @tenacity.retry(stop=tenacity.stop_after_attempt(10),
-                retry=tenacity.retry_if_exception(__if_fingerprint_differs),
+                retry=tenacity.retry_if_exception(
+                    __metadata_write_lost_to_a_concurrent_writer),
                 wait=tenacity.wait_exponential(max=10),
                 wait=tenacity.wait_exponential(max=10),
                 reraise=True)
                 reraise=True)
 def gcp_metadata_save_op(provider: "GCPCloudProvider",
 def gcp_metadata_save_op(provider: "GCPCloudProvider",
@@ -66,16 +115,36 @@ def gcp_metadata_save_op(provider: "GCPCloudProvider",
     retrieves the metadata, invokes the provided callback with that
     retrieves the metadata, invokes the provided callback with that
     metadata, and saves the metadata using the original fingerprint
     metadata, and saves the metadata using the original fingerprint
     immediately afterwards, ensuring that update conflicts can be detected.
     immediately afterwards, ensuring that update conflicts can be detected.
+
+    The fingerprint alone is not enough: a write that GCP reports as done
+    can still be missing from the document when another write was accepted
+    against the same fingerprint. So the write is only finished once the
+    callback's changes can be read back, and is redone on fresh metadata
+    otherwise. A callback that changes nothing sends no write at all - every
+    write re-uploads the whole document and moves the fingerprint, which
+    would only add contention for other writers.
     """
     """
     def _save_common_metadata(provider: "GCPCloudProvider") -> None:
     def _save_common_metadata(provider: "GCPCloudProvider") -> None:
         # get the latest metadata (so we get the latest fingerprint)
         # get the latest metadata (so we get the latest fingerprint)
         metadata = get_common_metadata(provider)
         metadata = get_common_metadata(provider)
+        before = _metadata_values(metadata)
         # allow callback to do processing on it
         # allow callback to do processing on it
         callback(metadata)
         callback(metadata)
+        wanted = _metadata_values(metadata)
+        changes = {key: wanted.get(key) for key in before.keys() | wanted.keys()
+                   if before.get(key) != wanted.get(key)}
+        if not changes:
+            return
         # save the metadata
         # save the metadata
         operation = gcp_projects(provider).setCommonInstanceMetadata(
         operation = gcp_projects(provider).setCommonInstanceMetadata(
             project=provider.project_name, body=metadata).execute()
             project=provider.project_name, body=metadata).execute()
         provider.wait_for_operation(operation)
         provider.wait_for_operation(operation)
+        # ...and make sure it stuck
+        current = _metadata_values(get_common_metadata(provider))
+        lost = sorted(key for key, value in changes.items()
+                      if current.get(key) != value)
+        if lost:
+            raise MetadataWriteNotApplied(lost)
 
 
     # Retry a few times if the fingerprints conflict
     # Retry a few times if the fingerprints conflict
     _save_common_metadata(provider)
     _save_common_metadata(provider)
@@ -98,15 +167,24 @@ def modify_or_add_metadata_item(provider: "GCPCloudProvider", key: str,
     gcp_metadata_save_op(provider, _update_metadata_key)
     gcp_metadata_save_op(provider, _update_metadata_key)
 
 
 
 
-# This function will raise an HttpError with message containing
-# "Metadata has duplicate key" if it's not unique, unlike the previous
-# method which either adds or updates the value corresponding to that key
 def add_metadata_item(provider: "GCPCloudProvider", key: str,
 def add_metadata_item(provider: "GCPCloudProvider", key: str,
                       value: str) -> None:
                       value: str) -> None:
+    """Add ``key``, raising ``DuplicateResourceException`` if it is taken.
+
+    Unlike ``modify_or_add_metadata_item`` this never overwrites. The
+    check is done on the freshly fetched metadata so that a retry after a
+    lost write does not append a second copy of a key that did land: a key
+    already holding this very value is this write, and counts as done.
+    """
     def _add_metadata_key(metadata: Any) -> None:
     def _add_metadata_key(metadata: Any) -> None:
-        entry = {'key': key, 'value': value}
         entries = metadata.get('items', [])
         entries = metadata.get('items', [])
-        entries.append(entry)
+        existing = [item for item in entries if item['key'] == key]
+        if existing:
+            if existing[-1].get('value') == value:
+                return
+            raise DuplicateResourceException(
+                'Metadata key {0} already exists'.format(key))
+        entries.append({'key': key, 'value': value})
         # Reassign explicitly in case the original get returned [] although
         # Reassign explicitly in case the original get returned [] although
         # if not it will be already updated
         # if not it will be already updated
         metadata['items'] = entries
         metadata['items'] = entries
@@ -136,30 +214,25 @@ def get_metadata_item_value(provider: "GCPCloudProvider", key: str) -> Any:
 
 
 
 
 def remove_metadata_item(provider: "GCPCloudProvider", key: str) -> bool:
 def remove_metadata_item(provider: "GCPCloudProvider", key: str) -> bool:
-    def _remove_metadata_by_key(metadata: Any) -> bool | None:
-        items = metadata.get('items', [])
-        # No metadata to delete
-        if not items:
-            return False
-        else:
-            entries = [item for item in metadata.get('items', [])
-                       if item['key'] != key]
-
-            # Make sure only one entry is deleted
-            if len(entries) < len(items) - 1:
-                raise ProviderInternalException("Multiple metadata entries "
-                                                "found for the same key {}"
-                                                .format(key))
-            # If none is deleted indicate so by returning False
-            elif len(entries) == len(items):
-                return False
+    """Remove ``key``; ``False`` if there was nothing to remove."""
+    removed = False
 
 
-            else:
-                metadata['items'] = entries
-                return None
+    def _remove_metadata_by_key(metadata: Any) -> None:
+        nonlocal removed
+        items = metadata.get('items', [])
+        entries = [item for item in items if item['key'] != key]
+        # Make sure only one entry is deleted
+        if len(entries) < len(items) - 1:
+            raise ProviderInternalException("Multiple metadata entries "
+                                            "found for the same key {}"
+                                            .format(key))
+        # A retry after a lost write finds the key still present and
+        # removes it again; the first attempt decides whether it was there.
+        removed = removed or len(entries) < len(items)
+        metadata['items'] = entries
 
 
     gcp_metadata_save_op(provider, _remove_metadata_by_key)
     gcp_metadata_save_op(provider, _remove_metadata_by_key)
-    return True
+    return removed
 
 
 
 
 def __if_label_fingerprint_differs(e: BaseException) -> bool:
 def __if_label_fingerprint_differs(e: BaseException) -> bool:

+ 2 - 1
cloudbridge/providers/gcp/provider.py

@@ -30,6 +30,7 @@ from cloudbridge.interfaces.services import NetworkingService
 from cloudbridge.interfaces.services import SecurityService
 from cloudbridge.interfaces.services import SecurityService
 from cloudbridge.interfaces.services import StorageService
 from cloudbridge.interfaces.services import StorageService
 
 
+from .helpers import GCPOperationError
 from .services import GCPComputeService
 from .services import GCPComputeService
 from .services import GCPDnsService
 from .services import GCPDnsService
 from .services import GCPNetworkingService
 from .services import GCPNetworkingService
@@ -401,7 +402,7 @@ class GCPCloudProvider(BaseCloudProvider):
             result = operations.get(**args).execute()
             result = operations.get(**args).execute()
             if result['status'] == 'DONE':
             if result['status'] == 'DONE':
                 if 'error' in result:
                 if 'error' in result:
-                    raise Exception(result['error'])
+                    raise GCPOperationError(result['error'])
                 return result
                 return result
 
 
             time.sleep(0.5)
             time.sleep(0.5)

+ 5 - 0
cloudbridge/providers/gcp/services.py

@@ -188,7 +188,12 @@ class GCPKeyPairService(BaseKeyPairService):
                                       GCPKeyPair.KP_TAG_PREFIX + name,
                                       GCPKeyPair.KP_TAG_PREFIX + name,
                                       metadata_value)
                                       metadata_value)
             return GCPKeyPair(provider, kp_info, private_key)
             return GCPKeyPair(provider, kp_info, private_key)
+        except DuplicateResourceException:
+            raise DuplicateResourceException(
+                'A KeyPair with name {0} already exists'.format(name))
         except googleapiclient.errors.HttpError as err:
         except googleapiclient.errors.HttpError as err:
+            # GCP's own duplicate-key rejection, should a concurrent create
+            # slip past the check on the fetched metadata.
             if err.resp.get('content-type', '').startswith('application/json'):
             if err.resp.get('content-type', '').startswith('application/json'):
                 message = (json.loads(err.content).get('error', {})
                 message = (json.loads(err.content).get('error', {})
                            .get('errors', [{}])[0].get('message'))
                            .get('errors', [{}])[0].get('message'))

+ 208 - 0
tests/test_gcp_metadata_save.py

@@ -0,0 +1,208 @@
+"""Making GCP common-metadata writes stick.
+
+GCP keeps labels and key pairs in the project-wide common instance metadata,
+which every write re-uploads under an optimistic fingerprint. Under
+concurrent writers two things happen that a write has to survive: the
+operation fails with ``CONDITION_NOT_MET`` (a different path from the
+HTTP-level 412), and - observed in the live suite - the operation reports
+``DONE`` yet the change is absent from the document afterwards. Both must
+lead to the write being re-applied on freshly fetched metadata. No SDK is
+involved: the compute client is a fake with a server-side document, and the
+provider's real ``wait_for_operation`` polls it.
+"""
+
+import unittest
+from unittest import mock
+
+import tenacity
+
+from cloudbridge.interfaces.exceptions import DuplicateResourceException
+from cloudbridge.providers.gcp.helpers import GCPOperationError
+from cloudbridge.providers.gcp.helpers import MetadataWriteNotApplied
+from cloudbridge.providers.gcp.helpers import add_metadata_item
+from cloudbridge.providers.gcp.helpers import gcp_metadata_save_op
+from cloudbridge.providers.gcp.helpers import modify_or_add_metadata_item
+from cloudbridge.providers.gcp.helpers import remove_metadata_item
+from cloudbridge.providers.gcp.provider import GCPCloudProvider
+
+FINGERPRINT_CONFLICT = {
+    'errors': [{'code': 'CONDITION_NOT_MET',
+                'message': 'Supplied fingerprint does not match current '
+                           'metadata fingerprint.'}]}
+OTHER_FAILURE = {
+    'errors': [{'code': 'RESOURCE_NOT_FOUND',
+                'message': "The resource 'projects/p' was not found"}]}
+
+# Scripted outcomes for successive set operations.
+APPLY = 'apply'        # the write lands and the fingerprint moves on
+LOST = 'lost'          # reported DONE, but nothing changed
+CONFLICT = 'conflict'  # CONDITION_NOT_MET, nothing changed
+
+
+class _Call:
+    def __init__(self, result):
+        self._result = result
+
+    def execute(self):
+        return self._result
+
+
+class _FakeCompute:
+    """A compute client holding one project metadata document.
+
+    ``projects().get()`` returns a copy of the document; each
+    ``setCommonInstanceMetadata()`` yields an operation whose fate is the
+    next entry in ``outcomes``, resolved when ``wait_for_operation`` polls
+    ``globalOperations().get()``.
+    """
+
+    def __init__(self, outcomes, items=None):
+        self.outcomes = list(outcomes)
+        self.document = {'fingerprint': 'fp-0',
+                         'items': list(items or [])}
+        self.version = 0
+        self.fetches = 0
+        self.saved_bodies = []
+        self._pending = {}
+
+    def projects(self):
+        return self
+
+    def globalOperations(self):
+        return self
+
+    def get(self, project, operation=None):
+        if operation is not None:
+            return self._resolve(operation)
+        self.fetches += 1
+        return _Call({'commonInstanceMetadata': {
+            'fingerprint': self.document['fingerprint'],
+            'items': [dict(i) for i in self.document['items']]}})
+
+    def setCommonInstanceMetadata(self, project, body):
+        self.saved_bodies.append(body)
+        name = 'op-%d' % len(self.saved_bodies)
+        self._pending[name] = body
+        return _Call({'name': name})
+
+    def _resolve(self, name):
+        body = self._pending.pop(name)
+        outcome = self.outcomes.pop(0)
+        result = {'status': 'DONE'}
+        if outcome == APPLY:
+            self.version += 1
+            self.document = {'fingerprint': 'fp-%d' % self.version,
+                             'items': [dict(i) for i in body['items']]}
+        elif outcome == CONFLICT:
+            result['error'] = FINGERPRINT_CONFLICT
+        elif outcome == LOST:
+            pass
+        else:
+            result['error'] = outcome
+        return _Call(result)
+
+    def keys(self):
+        return {i['key']: i['value'] for i in self.document['items']}
+
+
+class _FakeProvider:
+    project_name = 'p'
+    wait_for_operation = GCPCloudProvider.wait_for_operation
+
+    def __init__(self, outcomes, items=None):
+        self.gcp_compute = _FakeCompute(outcomes, items)
+
+
+class GCPMetadataSaveTestCase(unittest.TestCase):
+
+    def setUp(self):
+        # The production wait between attempts is exponential backoff; these
+        # tests are about whether a retry happens, not how long it waits.
+        patcher = mock.patch.object(gcp_metadata_save_op.retry, 'wait',
+                                    tenacity.wait_none())
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    def test_fingerprint_conflict_is_retried_with_fresh_metadata(self):
+        provider = _FakeProvider([CONFLICT, APPLY])
+        callback = mock.Mock(side_effect=lambda md: md['items'].append(
+            {'key': 'k', 'value': 'v'}))
+
+        gcp_metadata_save_op(provider, callback)
+
+        # Two attempts, each on metadata fetched anew so the retry carries
+        # the fingerprint the conflict invalidated.
+        self.assertEqual(callback.call_count, 2)
+        self.assertEqual(
+            [b['fingerprint'] for b in provider.gcp_compute.saved_bodies],
+            ['fp-0', 'fp-0'])
+        self.assertEqual(provider.gcp_compute.keys(), {'k': 'v'})
+
+    def test_write_reported_done_but_absent_is_reapplied(self):
+        # The live suite showed operations completing as DONE with the
+        # change missing from the document. A write is only finished once
+        # it can be read back.
+        provider = _FakeProvider([LOST, APPLY])
+
+        modify_or_add_metadata_item(provider, 'k', 'v')
+
+        self.assertEqual(len(provider.gcp_compute.saved_bodies), 2)
+        self.assertEqual(provider.gcp_compute.keys(), {'k': 'v'})
+
+    def test_write_that_never_lands_is_reported_after_retries(self):
+        provider = _FakeProvider([LOST] * 10)
+
+        with self.assertRaises(MetadataWriteNotApplied) as raised:
+            modify_or_add_metadata_item(provider, 'k', 'v')
+
+        self.assertIn('k', str(raised.exception))
+        self.assertEqual(len(provider.gcp_compute.saved_bodies), 10)
+
+    def test_other_operation_failures_are_raised_as_typed_errors(self):
+        provider = _FakeProvider([OTHER_FAILURE])
+        callback = mock.Mock(side_effect=lambda md: md['items'].append(
+            {'key': 'k', 'value': 'v'}))
+
+        with self.assertRaises(GCPOperationError) as raised:
+            gcp_metadata_save_op(provider, callback)
+
+        self.assertEqual(callback.call_count, 1)
+        self.assertEqual(raised.exception.codes, ['RESOURCE_NOT_FOUND'])
+        self.assertIn("was not found", str(raised.exception))
+
+    def test_a_write_that_changes_nothing_is_not_sent(self):
+        # Every set re-uploads the whole document and moves the fingerprint,
+        # so a no-op (removing an absent key, re-setting the current value)
+        # would only add contention for other writers.
+        provider = _FakeProvider([], items=[{'key': 'k', 'value': 'v'}])
+
+        self.assertFalse(remove_metadata_item(provider, 'absent'))
+        modify_or_add_metadata_item(provider, 'k', 'v')
+
+        self.assertEqual(provider.gcp_compute.saved_bodies, [])
+
+    def test_remove_is_reapplied_until_the_key_is_gone(self):
+        provider = _FakeProvider([LOST, APPLY],
+                                 items=[{'key': 'k', 'value': 'v'}])
+
+        self.assertTrue(remove_metadata_item(provider, 'k'))
+
+        self.assertEqual(len(provider.gcp_compute.saved_bodies), 2)
+        self.assertEqual(provider.gcp_compute.keys(), {})
+
+    def test_add_is_satisfied_by_its_own_value_already_present(self):
+        # A retry must not append a second copy if the earlier attempt did
+        # land after all.
+        provider = _FakeProvider([], items=[{'key': 'k', 'value': 'v'}])
+
+        add_metadata_item(provider, 'k', 'v')
+
+        self.assertEqual(provider.gcp_compute.saved_bodies, [])
+
+    def test_add_rejects_a_key_someone_else_holds(self):
+        provider = _FakeProvider([], items=[{'key': 'k', 'value': 'theirs'}])
+
+        with self.assertRaises(DuplicateResourceException):
+            add_metadata_item(provider, 'k', 'mine')
+
+        self.assertEqual(provider.gcp_compute.saved_bodies, [])

+ 5 - 3
tox.ini

@@ -16,14 +16,16 @@ commands = # see pyproject.toml for coverage options; setup.cfg for flake8
            # inferring it from gaps between result lines in the CI log, which
            # inferring it from gaps between result lines in the CI log, which
            # cannot separate a slow test from a worker waiting for work.
            # cannot separate a slow test from a worker waiting for work.
            coverage run --source=cloudbridge -m pytest -v --durations=25 {posargs:-n 5 tests/}
            coverage run --source=cloudbridge -m pytest -v --durations=25 {posargs:-n 5 tests/}
-           # Emit any CB_TEST_TRACE output (see tests/conftest.py). A no-op
-           # when tracing is off, which is the default.
-           python -c "import glob,sys;[sys.stdout.write(open(f).read()) for f in sorted(glob.glob('cb-trace-*.log'))]"
            # Combine parallel-mode data files and emit Cobertura XML for upload
            # Combine parallel-mode data files and emit Cobertura XML for upload
            # by coverallsapp/github-action in CI. Locally this produces
            # by coverallsapp/github-action in CI. Locally this produces
            # coverage.xml in the project root, which IDEs can also consume.
            # coverage.xml in the project root, which IDEs can also consume.
            coverage combine
            coverage combine
            coverage xml
            coverage xml
+# Emit any CB_TEST_TRACE output (see tests/conftest.py). A no-op when tracing
+# is off, which is the default. This lives in commands_post rather than after
+# pytest in commands because tox stops at the first failing command, and a
+# failing run is the one whose trace is worth reading.
+commands_post = python -c "import glob,sys;[sys.stdout.write(open(f).read()) for f in sorted(glob.glob('cb-trace-*.log'))]"
 setenv =
 setenv =
     # Fix for moto import issue: https://github.com/travis-ci/travis-ci/issues/7940
     # Fix for moto import issue: https://github.com/travis-ci/travis-ci/issues/7940
     BOTO_CONFIG=/dev/null
     BOTO_CONFIG=/dev/null