This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new e7737ff9bb1 Fix Kafka consumer not being closed on error in 
ConsumeFromTopicOperator (#69641)
e7737ff9bb1 is described below

commit e7737ff9bb1229f637bef132a5829e77505814e6
Author: Tim <[email protected]>
AuthorDate: Sat Aug 1 10:58:34 2026 -0700

    Fix Kafka consumer not being closed on error in ConsumeFromTopicOperator 
(#69641)
    
    Signed-off-by: Timur Rakhmatullin 
<[email protected]>
    Co-authored-by: Timur Rakhmatullin 
<[email protected]>
---
 .../providers/apache/kafka/operators/consume.py    | 112 +++++++++++----------
 .../unit/apache/kafka/operators/test_consume.py    |  42 ++++++++
 2 files changed, 100 insertions(+), 54 deletions(-)

diff --git 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/operators/consume.py
 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/operators/consume.py
index 3456534cce0..05cc6e59b2b 100644
--- 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/operators/consume.py
+++ 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/operators/consume.py
@@ -126,68 +126,72 @@ class ConsumeFromTopicOperator(BaseOperator):
         self._validate_commit_cadence_before_execute()
         consumer = self.hook.get_consumer()
 
-        if isinstance(self.apply_function, str):
-            self.apply_function = import_string(self.apply_function)
+        try:
+            if isinstance(self.apply_function, str):
+                self.apply_function = import_string(self.apply_function)
 
-        if isinstance(self.apply_function_batch, str):
-            self.apply_function_batch = 
import_string(self.apply_function_batch)
+            if isinstance(self.apply_function_batch, str):
+                self.apply_function_batch = 
import_string(self.apply_function_batch)
 
-        if self.apply_function is not None and not 
callable(self.apply_function):
-            raise TypeError(f"apply_function is not a callable, got 
{type(self.apply_function)} instead.")
-
-        if self.apply_function:
-            apply_callable = partial(
-                self.apply_function,
-                *self.apply_function_args,
-                **self.apply_function_kwargs,
-            )
-
-        if self.apply_function_batch is not None and not 
callable(self.apply_function_batch):
-            raise TypeError(
-                f"apply_function_batch is not a callable, got 
{type(self.apply_function_batch)} instead."
-            )
-
-        if self.apply_function_batch:
-            apply_callable = partial(
-                self.apply_function_batch,
-                *self.apply_function_args,
-                **self.apply_function_kwargs,
-            )
-
-        messages_left = self.max_messages or True
-
-        while self.read_to_end or (
-            messages_left > 0
-        ):  # bool(True > 0) == True in the case where self.max_messages isn't 
set by the user
-            if not isinstance(messages_left, bool):
-                batch_size = self.max_batch_size if messages_left > 
self.max_batch_size else messages_left
-            else:
-                batch_size = self.max_batch_size
-
-            msgs = consumer.consume(num_messages=batch_size, 
timeout=self.poll_timeout)
-            if not self.read_to_end:
-                messages_left -= len(msgs)
-
-            if not msgs:  # No messages + messages_left is being used.
-                self.log.info("Reached end of log. Exiting.")
-                break
+            if self.apply_function is not None and not 
callable(self.apply_function):
+                raise TypeError(f"apply_function is not a callable, got 
{type(self.apply_function)} instead.")
 
             if self.apply_function:
-                for m in msgs:
-                    apply_callable(m)
+                apply_callable = partial(
+                    self.apply_function,
+                    *self.apply_function_args,
+                    **self.apply_function_kwargs,
+                )
 
-            if self.apply_function_batch:
-                apply_callable(msgs)
+            if self.apply_function_batch is not None and not 
callable(self.apply_function_batch):
+                raise TypeError(
+                    f"apply_function_batch is not a callable, got 
{type(self.apply_function_batch)} instead."
+                )
 
-            if self.commit_cadence == "end_of_batch":
+            if self.apply_function_batch:
+                apply_callable = partial(
+                    self.apply_function_batch,
+                    *self.apply_function_args,
+                    **self.apply_function_kwargs,
+                )
+
+            messages_left = self.max_messages or True
+
+            while self.read_to_end or (
+                messages_left > 0
+            ):  # bool(True > 0) == True in the case where self.max_messages 
isn't set by the user
+                if not isinstance(messages_left, bool):
+                    batch_size = self.max_batch_size if messages_left > 
self.max_batch_size else messages_left
+                else:
+                    batch_size = self.max_batch_size
+
+                msgs = consumer.consume(num_messages=batch_size, 
timeout=self.poll_timeout)
+                if not self.read_to_end:
+                    messages_left -= len(msgs)
+
+                if not msgs:  # No messages + messages_left is being used.
+                    self.log.info("Reached end of log. Exiting.")
+                    break
+
+                if self.apply_function:
+                    for m in msgs:
+                        apply_callable(m)
+
+                if self.apply_function_batch:
+                    apply_callable(msgs)
+
+                if self.commit_cadence == "end_of_batch":
+                    self.log.info("committing offset at %s", 
self.commit_cadence)
+                    consumer.commit()
+
+            if self.commit_cadence != "never":
                 self.log.info("committing offset at %s", self.commit_cadence)
                 consumer.commit()
-
-        if self.commit_cadence != "never":
-            self.log.info("committing offset at %s", self.commit_cadence)
-            consumer.commit()
-
-        consumer.close()
+        finally:
+            try:
+                consumer.close()
+            except Exception:
+                self.log.warning("Failed to close Kafka consumer", 
exc_info=True)
 
         return
 
diff --git 
a/providers/apache/kafka/tests/unit/apache/kafka/operators/test_consume.py 
b/providers/apache/kafka/tests/unit/apache/kafka/operators/test_consume.py
index e883825b2ff..c7cf1b089d8 100644
--- a/providers/apache/kafka/tests/unit/apache/kafka/operators/test_consume.py
+++ b/providers/apache/kafka/tests/unit/apache/kafka/operators/test_consume.py
@@ -41,6 +41,11 @@ def _no_op(*args, **kwargs) -> Any:
     return args, kwargs
 
 
+def _raise_on_message(*args, **kwargs) -> Any:
+    """A function that always raises, to simulate a failing apply_function."""
+    raise ValueError("boom")
+
+
 def create_mock_kafka_consumer(
     message_count: int = 1001, message_content: Any = "test_message", 
track_consumed_messages: bool = False
 ) -> tuple[mock.MagicMock, mock.MagicMock, list[int] | None]:
@@ -279,3 +284,40 @@ class TestConsumeFromTopic:
 
             # Verify consumer was closed
             mock_consumer.close.assert_called_once()
+
+    def test_execute_closes_consumer_when_apply_function_raises(self):
+        """The consumer must be closed even if message processing raises."""
+        mock_consumer, mock_get_consumer, _ = 
create_mock_kafka_consumer(message_count=5)
+
+        with mock_get_consumer:
+            operator = ConsumeFromTopicOperator(
+                kafka_config_id="kafka_d",
+                topics=["test"],
+                task_id="test",
+                poll_timeout=0.0001,
+                apply_function=_raise_on_message,
+            )
+
+            with pytest.raises(ValueError, match="boom"):
+                operator.execute(context={})
+
+            mock_consumer.close.assert_called_once()
+
+    def test_execute_does_not_mask_error_when_close_raises(self):
+        """A failing close() must not replace the original processing error."""
+        mock_consumer, mock_get_consumer, _ = 
create_mock_kafka_consumer(message_count=5)
+        mock_consumer.close.side_effect = Exception("close failed")
+
+        with mock_get_consumer:
+            operator = ConsumeFromTopicOperator(
+                kafka_config_id="kafka_d",
+                topics=["test"],
+                task_id="test",
+                poll_timeout=0.0001,
+                apply_function=_raise_on_message,
+            )
+
+            with pytest.raises(ValueError, match="boom"):
+                operator.execute(context={})
+
+            mock_consumer.close.assert_called_once()

Reply via email to