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 6cea6df94cd OpenLineage: reuse per-process adapter in dag-state-change 
pool workers (#69283)
6cea6df94cd is described below

commit 6cea6df94cd24e04df22ca815e22c7d2be0d5397
Author: Gang Zhang <[email protected]>
AuthorDate: Tue Jul 7 02:52:03 2026 -0700

    OpenLineage: reuse per-process adapter in dag-state-change pool workers 
(#69283)
    
    * OpenLineage: reuse per-process adapter in dag-state-change pool workers
    
    Submitting bound adapter methods to the listener's ProcessPoolExecutor
    pickles the adapter with every event, so each pool worker unpickled a
    fresh OpenLineageAdapter per DAG-run state change and built a new
    OpenLineageClient (and transport set) on every emit, with close() never
    called. Transports that start background worker threads leak one thread
    per event this way: the datadog transport always starts an async HTTP
    worker thread in its constructor, and each idle thread busy-polls at
    ~100Hz (~0.45% CPU each, measured on openlineage-python 1.47.1). On a
    scheduler emitting dozens of DAG-run events per hour this steadily
    consumes CPU and memory until the scheduler is restarted.
    
    Route pool submissions through module-level _run_adapter_method, which
    resolves the adapter method by name on a per-process adapter singleton,
    so each pool worker keeps exactly one client for its lifetime. Also
    resolve _emit_manual_state_change_event's adapter method the same way.
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    * Add troubleshooting entry for scheduler CPU growth from per-event clients
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    * Use @cache for per-process adapter instead of module global
    
    Review feedback: match the get_openlineage_listener() idiom.
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 providers/openlineage/docs/troubleshooting.rst     | 16 +++++++
 .../providers/openlineage/plugins/listener.py      | 50 +++++++++++++++++-----
 .../unit/openlineage/plugins/test_listener.py      | 48 ++++++++++++++++++---
 3 files changed, 98 insertions(+), 16 deletions(-)

diff --git a/providers/openlineage/docs/troubleshooting.rst 
b/providers/openlineage/docs/troubleshooting.rst
index f679e6ff015..9c358a6273e 100644
--- a/providers/openlineage/docs/troubleshooting.rst
+++ b/providers/openlineage/docs/troubleshooting.rst
@@ -43,6 +43,22 @@ as well as the `task_success_overtime 
<https://airflow.apache.org/docs/apache-ai
 configuration in Airflow config.
 
 
+Scheduler CPU or memory growing steadily while OpenLineage is enabled
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+In provider versions that submit bound adapter methods to the DAG-run event 
process pool, each pool worker
+built a new OpenLineage client — including a new transport set — for every 
DAG-run state change, and the
+clients were never closed. With transports that start background worker 
threads (e.g. the ``datadog``
+transport), each DAG-run event leaked one thread inside the scheduler, so 
scheduler CPU and memory climbed
+steadily over hours and recovered only on a scheduler restart.
+
+**Possible Solution**
+
+Upgrade to a provider version that reuses a single per-process adapter in the 
pool workers. If you cannot
+upgrade yet, prefer a transport that does not start background worker threads 
(e.g. plain ``http``) for the
+affected destination, or restart the scheduler to reclaim the leaked threads 
as a stopgap.
+
+
 Missing lineage from EmptyOperators
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git 
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py 
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
index ec24c37128f..4fa8c657126 100644
--- 
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
+++ 
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
@@ -113,15 +113,42 @@ def _executor_initializer():
         log.debug("Exception details:", exc_info=True)
 
 
-def _emit_manual_state_change_event(adapter_method, stats_key, **kwargs):
+@cache
+def _get_process_adapter() -> OpenLineageAdapter:
+    """
+    Return the per-process ``OpenLineageAdapter`` used inside pool worker 
processes.
+
+    Each ``ProcessPoolExecutor`` worker keeps exactly one adapter — and 
therefore one
+    ``OpenLineageClient`` with one set of transports — for its whole lifetime.
+    """
+    return OpenLineageAdapter()
+
+
+def _run_adapter_method(method_name: str, /, *args, **kwargs):
+    """
+    Run the named ``OpenLineageAdapter`` method on the per-process adapter.
+
+    Module-level so it is picklable across the ProcessPoolExecutor boundary. 
Bound adapter
+    methods must not be submitted to the pool directly: pickling them 
serializes the whole
+    adapter, so the worker unpickles a fresh adapter per event and builds a new
+    ``OpenLineageClient`` (with new transports) on every emit. Transports that 
start
+    background worker threads (e.g. the ``datadog`` transport, which always 
starts an async
+    HTTP worker thread) are never closed, so this leaks one thread per event 
and steadily
+    consumes scheduler CPU and memory until restart.
+    """
+    return getattr(_get_process_adapter(), method_name)(*args, **kwargs)
+
+
+def _emit_manual_state_change_event(adapter_method_name: str, stats_key: str, 
**kwargs):
     """
-    Emit an OL event via the given adapter method and record its serialized 
size.
+    Emit an OL event via the named adapter method and record its serialized 
size.
 
     Module-level so it is picklable across the ProcessPoolExecutor boundary 
used by
     `_on_task_instance_manual_state_change` for scheduler-side "task state 
changed
-    externally" emissions.
+    externally" emissions. The method is resolved on the per-process adapter 
so the
+    pool worker reuses one client across events (see ``_run_adapter_method``).
     """
-    event = adapter_method(**kwargs)
+    event = getattr(_get_process_adapter(), adapter_method_name)(**kwargs)
     Stats.gauge(stats_key, len(Serde.to_json(event).encode("utf-8")))
     return event
 
@@ -782,10 +809,10 @@ class OpenLineageListener:
                 return
 
             if ti_state == TaskInstanceState.FAILED:
-                adapter_method = self.adapter.fail_task
+                adapter_method_name = "fail_task"
                 event_type = RunState.FAIL.value.lower()
             elif ti_state in (TaskInstanceState.SUCCESS, 
TaskInstanceState.SKIPPED):
-                adapter_method = self.adapter.complete_task
+                adapter_method_name = "complete_task"
                 event_type = RunState.COMPLETE.value.lower()
             else:
                 raise ValueError(f"Unsupported ti_state: `{ti_state}`.")
@@ -867,7 +894,7 @@ class OpenLineageListener:
             operator_name = (ti.operator or "unknown").lower()
             self.submit_callable(
                 _emit_manual_state_change_event,
-                adapter_method,
+                adapter_method_name,
                 f"ol.event.size.{event_type}.{operator_name}",
                 **adapter_kwargs,
             )
@@ -979,7 +1006,8 @@ class OpenLineageListener:
             doc, doc_type = get_dag_documentation(dag_run.dag)
 
             self.submit_callable(
-                self.adapter.dag_started,
+                _run_adapter_method,
+                "dag_started",
                 dag_id=dag_run.dag_id,
                 run_id=dag_run.run_id,
                 logical_date=date,
@@ -1031,7 +1059,8 @@ class OpenLineageListener:
             doc, doc_type = get_dag_documentation(dag_run.dag)
 
             self.submit_callable(
-                self.adapter.dag_success,
+                _run_adapter_method,
+                "dag_success",
                 dag_id=dag_run.dag_id,
                 run_id=dag_run.run_id,
                 end_date=dag_run.end_date,
@@ -1082,7 +1111,8 @@ class OpenLineageListener:
             doc, doc_type = get_dag_documentation(dag_run.dag)
 
             self.submit_callable(
-                self.adapter.dag_failed,
+                _run_adapter_method,
+                "dag_failed",
                 dag_id=dag_run.dag_id,
                 run_id=dag_run.run_id,
                 end_date=dag_run.end_date,
diff --git 
a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py 
b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
index f3132746cd6..f7f3b398d4b 100644
--- a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
+++ b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
@@ -92,15 +92,23 @@ def direct_submit_call(self, callable, *args, **kwargs):
 
     Bypasses the ``ProcessPoolExecutor`` so tests can assert against mocked
     adapter methods without hitting pickling of ``unittest.mock.Mock``.
-    When the submitted callable is ``_emit_manual_state_change_event``, skip
-    its ``Stats.gauge`` side effect (which would try to ``Serde.to_json`` a
-    ``MagicMock`` return value) and invoke the adapter method directly.
+    The module-level pool wrappers pass adapter method *names* and resolve them
+    on the per-process adapter; here we resolve them on this listener's adapter
+    instead, so assertions against mocked adapter methods keep working. For
+    ``_emit_manual_state_change_event`` this also skips its ``Stats.gauge``
+    side effect (which would try to ``Serde.to_json`` a ``MagicMock`` return).
     """
-    from airflow.providers.openlineage.plugins.listener import 
_emit_manual_state_change_event
+    from airflow.providers.openlineage.plugins.listener import (
+        _emit_manual_state_change_event,
+        _run_adapter_method,
+    )
 
     if callable is _emit_manual_state_change_event:
-        adapter_method, _stats_key, *_ = args
-        return adapter_method(**kwargs)
+        adapter_method_name, _stats_key, *_ = args
+        return getattr(self.adapter, adapter_method_name)(**kwargs)
+    if callable is _run_adapter_method:
+        adapter_method_name, *rest = args
+        return getattr(self.adapter, adapter_method_name)(*rest, **kwargs)
     return callable(*args, **kwargs)
 
 
@@ -123,6 +131,34 @@ class MockExecutor:
         print("Shutting down")
 
 
+def _probe_process_adapter():
+    """Return (pid, adapter id) from inside a pool worker; module-level so it 
is picklable."""
+    import os
+
+    from airflow.providers.openlineage.plugins.listener import 
_get_process_adapter
+
+    return os.getpid(), id(_get_process_adapter())
+
+
+class TestProcessAdapterReuse:
+    def test_process_adapter_reused_across_pool_submissions(self):
+        """
+        A pool worker must reuse one adapter (hence one client/transport set) 
across events.
+
+        Regression test: submitting bound adapter methods pickled a fresh 
adapter per event,
+        making the worker build a new OpenLineageClient (and transport worker 
threads that are
+        never closed) for every DAG-run state change, leaking threads in the 
scheduler.
+        """
+        from concurrent.futures import ProcessPoolExecutor
+
+        with ProcessPoolExecutor(max_workers=1) as pool:
+            pid_first, adapter_id_first = 
pool.submit(_probe_process_adapter).result(timeout=60)
+            pid_second, adapter_id_second = 
pool.submit(_probe_process_adapter).result(timeout=60)
+
+        assert pid_first == pid_second
+        assert adapter_id_first == adapter_id_second
+
+
 class TestExecutorInitializer:
     """Tests for _executor_initializer function."""
 

Reply via email to