@@ -85,6 +85,23 @@ def regular_call(self, callable, callable_name, use_fork):
8585 callable ()
8686
8787
88+ def direct_submit_call (self , callable , * args , ** kwargs ):
89+ """Synchronous stand-in for ``OpenLineageListener.submit_callable``.
90+
91+ Bypasses the ``ProcessPoolExecutor`` so tests can assert against mocked
92+ adapter methods without hitting pickling of ``unittest.mock.Mock``.
93+ When the submitted callable is ``_emit_manual_state_change_event``, skip
94+ its ``Stats.gauge`` side effect (which would try to ``Serde.to_json`` a
95+ ``MagicMock`` return value) and invoke the adapter method directly.
96+ """
97+ from airflow .providers .openlineage .plugins .listener import _emit_manual_state_change_event
98+
99+ if callable is _emit_manual_state_change_event :
100+ adapter_method , _stats_key , * _ = args
101+ return adapter_method (** kwargs )
102+ return callable (* args , ** kwargs )
103+
104+
88105class MockExecutor :
89106 def __init__ (self , * args , ** kwargs ):
90107 self .submitted = False
@@ -1463,7 +1480,8 @@ def test_adapter_fail_task_is_called_with_dag_description_when_task_doc_is_empty
14631480 @mock .patch ("airflow.providers.openlineage.plugins.listener.get_airflow_run_facet" )
14641481 @mock .patch ("airflow.providers.openlineage.plugins.listener.get_task_parent_run_facet" )
14651482 @mock .patch (
1466- "airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute" , new = regular_call
1483+ "airflow.providers.openlineage.plugins.listener.OpenLineageListener.submit_callable" ,
1484+ new = direct_submit_call ,
14671485 )
14681486 def test_adapter_fail_task_is_called_with_proper_arguments_for_db_task_instance_model (
14691487 self ,
@@ -1482,6 +1500,7 @@ def test_adapter_fail_task_is_called_with_proper_arguments_for_db_task_instance_
14821500 time_machine .move_to (timezone .datetime (2023 , 1 , 3 , 13 , 1 , 1 ), tick = False )
14831501
14841502 listener , task_instance = self ._create_listener_and_task_instance (runtime_ti = False )
1503+ listener ._executor = MagicMock () # satisfy `if not self.executor` guard
14851504 mock_get_airflow_run_facet .return_value = {"airflow" : 3 }
14861505 mock_get_task_parent_run_facet .return_value = {"parent" : 4 }
14871506 mock_debug_facet .return_value = {"debug" : "packages" }
@@ -1649,7 +1668,8 @@ def test_adapter_complete_task_is_called_with_dag_description_when_task_doc_is_e
16491668 @mock .patch ("airflow.providers.openlineage.plugins.listener.get_airflow_debug_facet" )
16501669 @mock .patch ("airflow.providers.openlineage.plugins.listener.get_task_parent_run_facet" )
16511670 @mock .patch (
1652- "airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute" , new = regular_call
1671+ "airflow.providers.openlineage.plugins.listener.OpenLineageListener.submit_callable" ,
1672+ new = direct_submit_call ,
16531673 )
16541674 def test_adapter_complete_task_is_called_with_proper_arguments_for_db_task_instance_model (
16551675 self , mock_get_task_parent_run_facet , mock_debug_facet , mock_debug_mode , mock_emit , time_machine
@@ -1662,6 +1682,7 @@ def test_adapter_complete_task_is_called_with_proper_arguments_for_db_task_insta
16621682 time_machine .move_to (timezone .datetime (2023 , 1 , 3 , 13 , 1 , 1 ), tick = False )
16631683
16641684 listener , task_instance = self ._create_listener_and_task_instance (runtime_ti = False )
1685+ listener ._executor = MagicMock () # satisfy `if not self.executor` guard
16651686 delattr (task_instance , "task" ) # Test api server path, where task is not available
16661687 mock_get_task_parent_run_facet .return_value = {"parent" : 4 }
16671688 mock_debug_facet .return_value = {"debug" : "packages" }
@@ -1856,7 +1877,8 @@ def test_listener_on_task_instance_skipped_do_not_call_adapter_when_disabled_ope
18561877 @mock .patch ("airflow.providers.openlineage.plugins.listener.get_airflow_debug_facet" )
18571878 @mock .patch ("airflow.providers.openlineage.plugins.listener.get_task_parent_run_facet" )
18581879 @mock .patch (
1859- "airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute" , new = regular_call
1880+ "airflow.providers.openlineage.plugins.listener.OpenLineageListener.submit_callable" ,
1881+ new = direct_submit_call ,
18601882 )
18611883 def test_adapter_complete_task_is_called_with_proper_arguments_for_db_task_instance_model_on_skip (
18621884 self , mock_get_task_parent_run_facet , mock_debug_facet , mock_debug_mode , mock_emit , time_machine
@@ -1869,6 +1891,7 @@ def test_adapter_complete_task_is_called_with_proper_arguments_for_db_task_insta
18691891 time_machine .move_to (timezone .datetime (2023 , 1 , 3 , 13 , 1 , 1 ), tick = False )
18701892
18711893 listener , task_instance = self ._create_listener_and_task_instance (runtime_ti = False )
1894+ listener ._executor = MagicMock () # satisfy `if not self.executor` guard
18721895 delattr (task_instance , "task" ) # Test api server path, where task is not available
18731896 mock_get_task_parent_run_facet .return_value = {"parent" : 4 }
18741897 mock_debug_facet .return_value = {"debug" : "packages" }
@@ -1980,6 +2003,55 @@ def set_result(*args, **kwargs):
19802003 listener .log .warning .assert_called_once ()
19812004
19822005
2006+ class TestOpenLineageListenerForkExecute :
2007+ """Regression tests for `OpenLineageListener._fork_execute`.
2008+
2009+ On processes where the ORM is configured (scheduler; workers on AF2), the
2010+ forked child must rebuild the engine so it does not share pooled Postgres
2011+ connections with the parent -- otherwise an inherited SSL socket gets
2012+ written by both processes and the parent's next query dies with
2013+ ``SSL error: decryption failed or bad record mac``.
2014+
2015+ On AF3+ workers the Task SDK sets SQL_ALCHEMY_CONN to
2016+ ``airflow-db-not-allowed:///``; ``configure_orm`` would raise there, so
2017+ the child must skip the rebuild when ``settings.engine`` is ``None``.
2018+ """
2019+
2020+ @staticmethod
2021+ def _run_child (engine_value ):
2022+ listener = OpenLineageListener ()
2023+ called = MagicMock ()
2024+ with (
2025+ patch ("airflow.providers.openlineage.plugins.listener.os.fork" , return_value = 0 ),
2026+ patch ("airflow.providers.openlineage.plugins.listener.os._exit" ) as mock_exit ,
2027+ patch ("airflow.providers.openlineage.plugins.listener.configure_orm" ) as mock_configure_orm ,
2028+ patch ("airflow.providers.openlineage.plugins.listener.setproctitle" ),
2029+ patch (
2030+ "airflow.providers.openlineage.plugins.listener.getproctitle" ,
2031+ return_value = "test" ,
2032+ ),
2033+ patch ("airflow.providers.openlineage.plugins.listener.settings" ) as mock_settings ,
2034+ ):
2035+ mock_settings .engine = engine_value
2036+ listener ._fork_execute (called , "on_failure" )
2037+ return mock_configure_orm , called , mock_exit
2038+
2039+ def test_child_rebuilds_orm_when_engine_is_configured (self ):
2040+ mock_configure_orm , called , mock_exit = self ._run_child (engine_value = MagicMock ())
2041+ mock_configure_orm .assert_called_once_with (disable_connection_pool = True )
2042+ called .assert_called_once ()
2043+ mock_exit .assert_called_once_with (0 )
2044+
2045+ def test_child_skips_orm_rebuild_when_engine_is_none (self ):
2046+ # On AF3+ workers the metadata DB is intentionally unreachable and
2047+ # configure_orm would raise on the sentinel URL. The callable must
2048+ # still run and the child must still exit cleanly.
2049+ mock_configure_orm , called , mock_exit = self ._run_child (engine_value = None )
2050+ mock_configure_orm .assert_not_called ()
2051+ called .assert_called_once ()
2052+ mock_exit .assert_called_once_with (0 )
2053+
2054+
19832055@pytest .mark .skipif (AIRFLOW_V_3_0_PLUS , reason = "Airflow 2 tests" )
19842056class TestOpenLineageSelectiveEnableAirflow2 :
19852057 def setup_method (self ):
0 commit comments