Repository: fedora-infra/bodhi

Issue:
Fedora Messaging may deliver messages multiple times
In Fedora Messaging, messages may get delivered multiple times (in case of deconnection for exemple). The consumers need to handle that.

Ideally the tasks would be idempotent, if not the consumer can check if the task to do has already been done (if the expected results have been produced), or any other method.


Discussion:

Resolving patch:
diff --git a/bodhi/server/consumers/composer.py b/bodhi/server/consumers/composer.py
--- a/bodhi/server/consumers/composer.py
+++ b/bodhi/server/consumers/composer.py
@@ -180,6 +180,9 @@ def __call__(self, message: fedora_messaging.api.Message):
         If there are any security updates in the push, then those repositories
         will be executed before all others.
 
+        Duplicate messages: if a requested compose is already pending or has
+        already started, it will be skipped.
+
         Args:
             message: The message we are processing. This is how we know what compose jobs to run.
         """
@@ -244,6 +247,10 @@ def _get_composes(self, msg: dict):
             else:
                 raise ValueError('Unable to process message: {}'.format(msg))
 
+            # Filter out composes that are pending or have started, for example in
+            # case of duplicate messages.
+            composes = [c for c in composes if c.state == ComposeState.requested]
+
             for c in composes:
                 # Acknowledge that we've received the command to run these composes.
                 c.state = ComposeState.pending
diff --git a/bodhi/server/consumers/signed.py b/bodhi/server/consumers/signed.py
--- a/bodhi/server/consumers/signed.py
+++ b/bodhi/server/consumers/signed.py
@@ -77,6 +77,8 @@ def __call__(self, message: fedora_messaging.api.Message):
 
         The message can contain additional keys.
 
+        Duplicate messages: this method is idempotent.
+
         Args:
             message: The incoming message in the format described above.
         """
@@ -100,6 +102,10 @@ def __call__(self, message: fedora_messaging.api.Message):
                 log.info("Tag is not pending_testing tag, skipping")
                 return
 
+            if build.signed:
+                log.info("Build was already marked as signed (maybe a duplicate message)")
+                return
+
             # This build was moved into the pending_testing tag for the applicable release, which
             # is done by RoboSignatory to indicate that the build has been correctly signed and
             # written out. Mark it as such.
diff --git a/bodhi/server/consumers/updates.py b/bodhi/server/consumers/updates.py
--- a/bodhi/server/consumers/updates.py
+++ b/bodhi/server/consumers/updates.py
@@ -76,6 +76,10 @@ def __call__(self, message: fedora_messaging.api.Message):
         """
         Process the given message, updating relevant bugs and test cases.
 
+        Duplicate messages: if the server delivers the message multiple times,
+        the bugs and test cases are simply re-fetched and updated, so nothing
+        bad happens.
+
         Args:
             message: A message about a new or edited update.
         """


Test patch:
diff --git a/bodhi/tests/server/consumers/test_composer.py b/bodhi/tests/server/consumers/test_composer.py
--- a/bodhi/tests/server/consumers/test_composer.py
+++ b/bodhi/tests/server/consumers/test_composer.py
@@ -360,6 +360,14 @@ def test_push_invalid_compose(self):
 
         self.assertEqual(str(exc.exception), 'No row was found for one()')
 
+    def test__get_composes_duplicate(self):
+        """Test _get_composes() when a duplicate message is received."""
+        msg = self._make_msg().body
+        composes = self.handler._get_composes(msg)
+        self.assertEqual(len(composes), 1)
+        composes = self.handler._get_composes(msg)
+        self.assertEqual(len(composes), 0)
+
     @mock.patch(**mock_taskotron_results)
     @mock.patch('bodhi.server.consumers.composer.PungiComposerThread._wait_for_pungi')
     @mock.patch('bodhi.server.consumers.composer.PungiComposerThread._sanity_check_repo')
diff --git a/bodhi/tests/server/consumers/test_signed.py b/bodhi/tests/server/consumers/test_signed.py
--- a/bodhi/tests/server/consumers/test_signed.py
+++ b/bodhi/tests/server/consumers/test_signed.py
@@ -57,6 +57,7 @@ def setUp(self):
     def test_consume(self, mock_build_model):
         """Assert that messages marking the build as signed updates the database"""
         build = mock_build_model.get.return_value
+        build.signed = False
         build.release.pending_testing_tag = self.sample_message.body["msg"]["tag"]
 
         self.handler(self.sample_message)
@@ -68,6 +69,7 @@ def test_consume_not_pending_testing_tag(self, mock_build_model):
         Assert that messages whose tag don't match the pending testing tag don't update the DB
         """
         build = mock_build_model.get.return_value
+        build.signed = False
         build.release.pending_testing_tag = "some tag that isn't pending testing"
 
         self.handler(self.sample_message)
@@ -79,6 +81,7 @@ def test_consume_no_release(self, mock_build_model):
         Assert that messages about builds that haven't been assigned a release don't update the DB
         """
         build = mock_build_model.get.return_value
+        build.signed = False
         build.release = None
 
         self.handler(self.sample_message)
@@ -92,3 +95,16 @@ def test_consume_no_build(self, mock_build_model, mock_log):
 
         self.handler(self.sample_message)
         mock_log.info.assert_called_with('Build was not submitted, skipping')
+
+    @mock.patch('bodhi.server.consumers.signed.log')
+    @mock.patch('bodhi.server.consumers.signed.Build')
+    def test_consume_duplicate(self, mock_build_model, mock_log):
+        """Assert that the handler is idempotent."""
+        build = mock_build_model.get.return_value
+        build.release.pending_testing_tag = self.sample_message.body["msg"]["tag"]
+        build.signed = True
+
+        self.handler(self.sample_message)
+        mock_log.info.assert_called_with(
+            "Build was already marked as signed (maybe a duplicate message)")
+        self.assertEqual(mock_log.info.call_count, 2)