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 6669e7eed8d Fix AwaitMessageTrigger crash on tombstone messages when 
apply_function is unset (#69665)
6669e7eed8d is described below

commit 6669e7eed8d39c5ab9a549570314202ef7725797
Author: Tim <[email protected]>
AuthorDate: Fri Jul 31 22:50:28 2026 -0700

    Fix AwaitMessageTrigger crash on tombstone messages when apply_function is 
unset (#69665)
    
    Signed-off-by: Timur Rakhmatullin 
<[email protected]>
    Co-authored-by: Timur Rakhmatullin 
<[email protected]>
---
 .../apache/kafka/triggers/await_message.py         | 13 ++--
 .../apache/kafka/triggers/test_await_message.py    | 72 ++++++++++++++++++++++
 2 files changed, 80 insertions(+), 5 deletions(-)

diff --git 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/triggers/await_message.py
 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/triggers/await_message.py
index 3dbc5e467d4..645965cf7ad 100644
--- 
a/providers/apache/kafka/src/airflow/providers/apache/kafka/triggers/await_message.py
+++ 
b/providers/apache/kafka/src/airflow/providers/apache/kafka/triggers/await_message.py
@@ -132,11 +132,14 @@ class AwaitMessageTrigger(BaseEventTrigger):
             elif message.error():
                 raise AirflowException(f"Error: {message.error()}")
             else:
-                event = (
-                    await async_message_process(message)
-                    if async_message_process
-                    else message.value().decode("utf-8")
-                )
+                if async_message_process:
+                    event = await async_message_process(message)
+                else:
+                    value = message.value()
+                    # A tombstone (null value, e.g. from a log-compacted 
topic) carries no
+                    # payload to emit, so treat it like a non-matching message 
and keep polling
+                    # instead of raising AttributeError on ``None.decode()``.
+                    event = value.decode("utf-8") if value is not None else 
None
                 if event:
                     if self.commit_offset:
                         await async_commit(message=message, asynchronous=False)
diff --git 
a/providers/apache/kafka/tests/unit/apache/kafka/triggers/test_await_message.py 
b/providers/apache/kafka/tests/unit/apache/kafka/triggers/test_await_message.py
index 374fdb0f2f9..ed950bea9ab 100644
--- 
a/providers/apache/kafka/tests/unit/apache/kafka/triggers/test_await_message.py
+++ 
b/providers/apache/kafka/tests/unit/apache/kafka/triggers/test_await_message.py
@@ -50,6 +50,22 @@ class MockedMessage:
     def error(*args, **kwargs):
         return False
 
+    def value(*args, **kwargs):
+        return b"test_message"
+
+
+class MockedTombstoneMessage:
+    """A message with a null payload, e.g. a deletion marker from a 
log-compacted topic."""
+
+    def __init__(*args, **kwargs):
+        pass
+
+    def error(*args, **kwargs):
+        return False
+
+    def value(*args, **kwargs):
+        return None
+
 
 class MockedConsumer:
     def __init__(*args, **kwargs) -> None:
@@ -162,6 +178,62 @@ class TestTrigger:
         assert task.done() is False
         asyncio.get_event_loop().stop()
 
+    @pytest.mark.asyncio
+    async def test_trigger_run_tombstone_message_keeps_polling(self, mocker):
+        """
+        A tombstone (null-value) message must not crash the trigger when no 
apply_function is set.
+
+        Regression test: the trigger previously raised
+        ``AttributeError: 'NoneType' object has no attribute 'decode'`` on 
``message.value()``
+        returning ``None``. A tombstone carries no payload to emit, so the 
trigger should
+        commit the offset (when ``commit_offset`` is enabled) and keep polling.
+        """
+        message = MockedTombstoneMessage()
+        consumer = MockedConsumer()
+        mocker.patch.object(consumer, "poll", return_value=message)
+        commit_mock = mocker.patch.object(consumer, "commit")
+        mocker.patch.object(KafkaConsumerHook, "get_consumer", 
return_value=consumer)
+
+        trigger = AwaitMessageTrigger(
+            kafka_config_id="kafka_d",
+            apply_function=None,
+            topics=["noop"],
+            poll_timeout=0.0001,
+            poll_interval=5,
+        )
+
+        task = asyncio.create_task(trigger.run().__anext__())
+        await asyncio.sleep(1.0)
+        try:
+            # The task must neither raise nor yield an event: the tombstone is 
skipped
+            # and the trigger sleeps through poll_interval waiting for the 
next message.
+            assert task.done() is False
+            # The tombstone offset is still committed, like any other 
non-matching message.
+            assert commit_mock.mock_calls == [call(message=message, 
asynchronous=False)]
+        finally:
+            task.cancel()
+
+    @pytest.mark.asyncio
+    async def 
test_trigger_run_without_apply_function_yields_message_value(self, mocker):
+        """A regular message with no apply_function yields the decoded message 
value."""
+        consumer = MockedConsumer()
+        mocker.patch.object(consumer, "poll", return_value=MockedMessage())
+        mocker.patch.object(consumer, "commit")
+        mocker.patch.object(KafkaConsumerHook, "get_consumer", 
return_value=consumer)
+
+        trigger = AwaitMessageTrigger(
+            kafka_config_id="kafka_d",
+            apply_function=None,
+            topics=["noop"],
+            poll_timeout=0.0001,
+            poll_interval=5,
+        )
+
+        task = asyncio.create_task(trigger.run().__anext__())
+        await asyncio.sleep(1.0)
+        assert task.done() is True
+        assert task.result().payload == "test_message"
+
     @pytest.mark.asyncio
     async def test_cleanup_closes_consumer(self, mocker):
         consumer = MockedConsumer()

Reply via email to