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

mobuchowski 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 7abf0487f44 fix: Prevent stats from breaking executor initializer 
(#66351)
7abf0487f44 is described below

commit 7abf0487f44807faef1226773f6986058c15a9ee
Author: Kacper Muda <[email protected]>
AuthorDate: Mon May 4 16:27:34 2026 +0200

    fix: Prevent stats from breaking executor initializer (#66351)
---
 .../providers/openlineage/plugins/listener.py      |  32 +++++--
 .../unit/openlineage/plugins/test_listener.py      | 104 +++++++++++++++++++++
 2 files changed, 126 insertions(+), 10 deletions(-)

diff --git 
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py 
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
index b33c0fed57e..6ab1944c393 100644
--- 
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
+++ 
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
@@ -29,12 +29,13 @@ from openlineage.client.serde import Serde
 
 from airflow import settings
 from airflow.models import DagRun, TaskInstance
-from airflow.providers.common.compat.sdk import Stats, hookimpl, timeout, 
timezone
+from airflow.providers.common.compat.sdk import Stats, conf as airflow_conf, 
hookimpl, timeout, timezone
 from airflow.providers.openlineage import conf
 from airflow.providers.openlineage.extractors import ExtractorManager, 
OperatorLineage
 from airflow.providers.openlineage.plugins.adapter import OpenLineageAdapter, 
RunState
 from airflow.providers.openlineage.utils.utils import (
     AIRFLOW_V_3_0_PLUS,
+    AIRFLOW_V_3_2_PLUS,
     get_airflow_dag_run_facet,
     get_airflow_debug_facet,
     get_airflow_job_facet,
@@ -81,21 +82,32 @@ def _executor_initializer():
     parent's ``Stats.initialize(...)`` call from scheduler startup does not 
propagate across the
     spawn boundary.
     """
+    log = logging.getLogger(__name__)
     # This initializer is used only on the scheduler
     # We can configure_orm regardless of the Airflow version, as DB access is 
always allowed from scheduler.
     settings.configure_orm()
+
+    if not AIRFLOW_V_3_2_PLUS:  # Initialize stats only for AF 3.2+
+        return
+
+    # Stats-related errors must not block initialization of executor (this 
will block DAG event emission).
     try:
         from airflow.observability.metrics import stats_utils
 
-        Stats.initialize(
-            factory=stats_utils.get_stats_factory(),
-            export_legacy_names=conf.getboolean("metrics", "legacy_names_on", 
fallback=True),
-        )
-    except ImportError:
-        # ``stats_utils`` lives under ``airflow.observability.metrics`` in 
current Airflow; if the
-        # import path changes or is unavailable, fall through silently — gauge 
calls will simply
-        # land on ``NoStatsLogger`` as before, which is no worse than current 
behavior.
-        pass
+        try:
+            Stats.initialize(
+                factory=stats_utils.get_stats_factory(),
+                export_legacy_names=airflow_conf.getboolean("metrics", 
"legacy_names_on", fallback=True),
+            )
+        except TypeError:
+            # args were changed in #63932, for compat we try to fall back to 
old way if first one fails
+            Stats.initialize(factory=stats_utils.get_stats_factory(Stats))
+
+    except Exception as err:
+        # Intentional catch-all: stats-related errors must not block DAG event 
emission.
+        # If any errors are raised it will fall back to NoStatsLogger, which 
is no worse than before.
+        log.warning("OpenLineage failed to initialize Stats in executor 
initializer: `%s`", err)
+        log.debug("Exception details:", exc_info=True)
 
 
 def _emit_manual_state_change_event(adapter_method, stats_key, **kwargs):
diff --git 
a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py 
b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
index 1183ce91f60..25bee70ce75 100644
--- a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
+++ b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
@@ -121,6 +121,110 @@ class MockExecutor:
         print("Shutting down")
 
 
+class TestExecutorInitializer:
+    """Tests for _executor_initializer function."""
+
+    @patch("airflow.settings.configure_orm")
+    def test_executor_initializer_calls_configure_orm(self, 
mock_configure_orm):
+        """Test that _executor_initializer always calls 
settings.configure_orm().
+
+        This test runs on all Airflow versions to ensure ORM initialization 
always happens.
+        """
+        from airflow.providers.openlineage.plugins.listener import 
_executor_initializer
+
+        _executor_initializer()
+
+        mock_configure_orm.assert_called_once()
+
+    @pytest.mark.skipif(AIRFLOW_V_3_2_PLUS, reason="Testing older Airflow 
behavior")
+    @patch("airflow.settings.configure_orm")
+    @patch("airflow.providers.openlineage.plugins.listener.Stats")
+    def test_executor_initializer_skips_stats_on_older_af(self, mock_stats, 
mock_configure_orm):
+        """Test that _executor_initializer skips Stats initialization on 
Airflow < 3.2."""
+        from airflow.providers.openlineage.plugins.listener import 
_executor_initializer
+
+        _executor_initializer()
+
+        # configure_orm should be called
+        mock_configure_orm.assert_called_once()
+        # But Stats.initialize should NOT be called (early return)
+        mock_stats.initialize.assert_not_called()
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="Airflow 3.2+ tests")
+    @patch("airflow.settings.configure_orm")
+    @patch("airflow.providers.openlineage.plugins.listener.Stats")
+    def test_executor_initializer_initializes_stats_on_af32(self, mock_stats, 
mock_configure_orm):
+        """Test that _executor_initializer initializes Stats on Airflow 
3.2+."""
+        from airflow.providers.openlineage.plugins.listener import 
_executor_initializer
+
+        with 
patch("airflow.observability.metrics.stats_utils.get_stats_factory") as 
mock_get_factory:
+            mock_get_factory.return_value = MagicMock()
+
+            _executor_initializer()
+
+            mock_configure_orm.assert_called_once()
+            mock_stats.initialize.assert_called_once()
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="Airflow 3.2+ tests")
+    @patch("airflow.settings.configure_orm")
+    @patch("airflow.providers.openlineage.plugins.listener.Stats")
+    def test_executor_initializer_stats_fallback_on_type_error(self, 
mock_stats, mock_configure_orm):
+        """Test that _executor_initializer falls back to old Stats.initialize 
signature on TypeError."""
+        from airflow.providers.openlineage.plugins.listener import 
_executor_initializer
+
+        with 
patch("airflow.observability.metrics.stats_utils.get_stats_factory") as 
mock_get_factory:
+            mock_get_factory.return_value = MagicMock()
+            # First call to Stats.initialize raises TypeError (old signature 
not supported)
+            # Second call succeeds (using the fallback)
+            mock_stats.initialize.side_effect = [TypeError("unexpected keyword 
argument"), None]
+
+            _executor_initializer()
+
+            # Both calls to Stats.initialize should happen
+            assert mock_stats.initialize.call_count == 2
+            # get_stats_factory should be called twice: once for new 
signature, once for fallback
+            assert mock_get_factory.call_count == 2
+            # Second call should pass Stats as argument
+            mock_get_factory.assert_called_with(mock_stats)
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="Airflow 3.2+ tests")
+    @patch("airflow.settings.configure_orm")
+    @patch("airflow.providers.openlineage.plugins.listener.Stats")
+    def test_executor_initializer_exception_does_not_break(self, mock_stats, 
mock_configure_orm):
+        """Test that _executor_initializer doesn't break on exception and logs 
warning."""
+        from airflow.providers.openlineage.plugins.listener import 
_executor_initializer
+
+        with 
patch("airflow.observability.metrics.stats_utils.get_stats_factory") as 
mock_get_factory:
+            # Make stats_utils.get_stats_factory raise an exception
+            mock_get_factory.side_effect = RuntimeError("stats initialization 
failed")
+
+            # Should not raise, exception should be caught and logged
+            _executor_initializer()
+
+            # configure_orm should still have been called
+            mock_configure_orm.assert_called_once()
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="Airflow 3.2+ tests")
+    @patch("airflow.settings.configure_orm")
+    def test_executor_initializer_with_real_stats(self, mock_configure_orm):
+        """Integration test that calls real stats code to catch API changes.
+
+        This test ensures that the stats initialization code remains compatible
+        with the actual airflow.observability.metrics.stats_utils 
implementation.
+        If the stats API changes in the future, this test will fail and alert
+        developers to update the initializer accordingly.
+
+        Note: configure_orm is mocked to avoid side effects; only stats code 
is real.
+        """
+        from airflow.providers.openlineage.plugins.listener import 
_executor_initializer
+
+        # Call with real stats code (not mocked), but ORM initialization mocked
+        # Should not raise and configure_orm should be called
+        _executor_initializer()
+
+        mock_configure_orm.assert_called_once()
+
+
 @pytest.mark.skipif(AIRFLOW_V_3_0_PLUS, reason="Airflow 2 tests")
 class TestOpenLineageListenerAirflow2:
     @patch("airflow.models.TaskInstance.xcom_push")

Reply via email to