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

kaxil 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 78200a943bd Fix OpenLineage emitting duplicate START events for 
rescheduled sensors (#73144)
78200a943bd is described below

commit 78200a943bd6d317e88e11b6a99e31919e5c5fa2
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Sep 15 10:07:38 2026 +0100

    Fix OpenLineage emitting duplicate START events for rescheduled sensors 
(#73144)
---
 .../providers/openlineage/plugins/listener.py      | 17 ++++--
 .../unit/openlineage/plugins/test_listener.py      | 65 +++++++++++++++++++++-
 2 files changed, 76 insertions(+), 6 deletions(-)

diff --git 
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py 
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
index 6772f8c2bd3..074c4f0a1eb 100644
--- 
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
+++ 
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
@@ -181,6 +181,18 @@ class OpenLineageListener:
             if TYPE_CHECKING:
                 assert task
             start_date = task_instance.start_date
+
+            # Only sensors in reschedule mode re-run without a new try_number, 
so only they can
+            # repeat a START for a run id that already emitted one. Other 
operators can also have
+            # TaskReschedule rows (a missing-Dag startup failure writes them 
before any listener
+            # hook fires), and those attempts have emitted nothing yet.
+            if (
+                getattr(task, "reschedule", False)
+                and 
task_instance.get_template_context().get("task_reschedule_count", 0) > 0
+            ):
+                self.log.debug("Skipping this instance of rescheduled task - 
START event was emitted already")
+                return
+
             self._on_task_instance_running(task_instance, dag, dagrun, task, 
start_date)
     else:
 
@@ -234,11 +246,6 @@ class OpenLineageListener:
 
         @print_warning(self.log)
         def on_running():
-            context = task_instance.get_template_context()
-            if hasattr(context, "task_reschedule_count") and 
context["task_reschedule_count"] > 0:
-                self.log.debug("Skipping this instance of rescheduled task - 
START event was emitted already")
-                return
-
             date = dagrun.logical_date
             if AIRFLOW_V_3_0_PLUS and date is None:
                 date = dagrun.run_after
diff --git 
a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py 
b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
index 0094a46192d..d9314027a8f 100644
--- a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
+++ b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
@@ -37,7 +37,12 @@ from openlineage.client.transport.console import 
ConsoleConfig
 from uuid6 import uuid7
 
 from airflow.models import DAG, DagRun, TaskInstance
-from airflow.providers.common.compat.sdk import AirflowTaskTimeout, 
BaseOperator, timezone
+from airflow.providers.common.compat.sdk import (
+    AirflowTaskTimeout,
+    BaseOperator,
+    BaseSensorOperator,
+    timezone,
+)
 from airflow.providers.openlineage.extractors.base import OperatorLineage
 from airflow.providers.openlineage.plugins.adapter import OpenLineageAdapter
 from airflow.providers.openlineage.plugins.listener import OpenLineageListener
@@ -2309,6 +2314,64 @@ class TestOpenLineageListenerAirflow3:
             map_index=-1,
         )
 
+    @pytest.mark.parametrize(
+        ("sensor_mode", "task_reschedule_count", "should_emit_start"),
+        [
+            pytest.param("reschedule", 0, True, 
id="sensor_first_poke_emits_start"),
+            pytest.param("reschedule", 3, False, 
id="sensor_later_poke_skips_start"),
+            pytest.param("poke", 3, True, 
id="poke_sensor_ignores_reschedule_rows"),
+            pytest.param(None, 3, True, 
id="non_sensor_with_reschedule_rows_still_emits"),
+        ],
+    )
+    @mock.patch("airflow.providers.openlineage.conf.debug_mode", 
return_value=True)
+    
@mock.patch("airflow.providers.openlineage.plugins.listener.get_airflow_debug_facet")
+    
@mock.patch("airflow.providers.openlineage.plugins.listener.get_task_parent_run_facet")
+    
@mock.patch("airflow.providers.openlineage.plugins.listener.get_airflow_run_facet")
+    
@mock.patch("airflow.providers.openlineage.plugins.listener.get_airflow_mapped_task_facet")
+    
@mock.patch("airflow.providers.openlineage.plugins.listener.get_user_provided_run_facets")
+    @mock.patch(
+        
"airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute", 
new=regular_call
+    )
+    def 
test_on_task_instance_running_skips_start_event_only_for_rescheduled_sensors(
+        self,
+        mock_get_user_provided_run_facets,
+        mock_get_airflow_mapped_task_facet,
+        mock_get_airflow_run_facet,
+        mock_get_task_parent_run_facet,
+        mock_debug_facet,
+        mock_debug_mode,
+        sensor_mode,
+        task_reschedule_count,
+        should_emit_start,
+    ):
+        """Only a reschedule-mode sensor may have its repeat START suppressed.
+
+        Such a sensor re-runs to poke again without incrementing 
``try_number``, so the run id is
+        unchanged and a second START would show consumers the same run 
entering RUNNING twice. Any
+        operator can accumulate ``TaskReschedule`` rows though, because a 
missing-Dag startup failure
+        writes them before any listener hook fires, and those attempts have 
emitted nothing yet.
+        """
+        listener, task_instance = self._create_listener_and_task_instance()
+        mock_get_airflow_mapped_task_facet.return_value = {}
+        mock_get_user_provided_run_facets.return_value = {}
+        mock_get_airflow_run_facet.return_value = {}
+        mock_get_task_parent_run_facet.return_value = {}
+        mock_debug_facet.return_value = {}
+        if sensor_mode is not None:
+            task_instance.task = BaseSensorOperator(  # type: 
ignore[assignment]
+                task_id="sensor_task", dag=task_instance.task.dag, 
mode=sensor_mode
+            )
+        task_instance._ti_context_from_server.task_reschedule_count = 
task_reschedule_count
+
+        listener.on_task_instance_running(None, task_instance)
+
+        if should_emit_start:
+            listener.adapter.start_task.assert_called_once()
+        else:
+            # Stopped at the guard rather than failing somewhere later in the 
emission path.
+            listener.adapter.build_dag_run_id.assert_not_called()
+            listener.adapter.start_task.assert_not_called()
+
     @mock.patch(
         
"airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute", 
new=regular_call
     )

Reply via email to