OpenLineage: reuse per-process adapter in dag-state-change pool workers - #69283
Conversation
|
Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide
|
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 <noreply@anthropic.com>
…ents Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
142f6b4 to
818c066
Compare
kacpermuda
left a comment
There was a problem hiding this comment.
Looks good, one comment.
Review feedback: match the get_openlineage_listener() idiom. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Hey @mobuchowski , can I get the PR merged to join the recent release? |
|
@gang-zh sorry, I wanted the tests to pass, took a long time and forgot to merge |
|
Awesome work, congrats on your first merged pull request! You are invited to check our Issue Tracker for additional contributions. |
|
Thank you! @mobuchowski this merged a few hours before 2.19.0rc1 was cut and didn't make the snapshot — any chance openlineage could be respun as 2.19.0rc2 in the current wave? It fixes a production scheduler CPU leak and unblocks re-enabling Datadog Jobs Monitoring for us. |
Summary
The OpenLineage listener submits bound adapter methods to its DAG-run event
ProcessPoolExecutor(self.adapter.dag_started/dag_success/dag_failed, andself.adapter.fail_task/complete_taskvia_emit_manual_state_change_event). Pickling a bound method serializes the adapter itself, so the long-lived pool worker unpickles a freshOpenLineageAdapterfor every event and, on emit, builds a newOpenLineageClient— with a new transport set — per DAG-run state change (get_or_create_openlineage_clientcaches onself._client, an instance attribute).close()is never called on those clients.For transports that start background worker threads this leaks one thread per event inside the scheduler. The
datadogtransport (openlineage-python >= 1.37.0) unconditionally starts anAsyncHttpTransportworker thread in its constructor; each idle thread busy-polls at ~100 Hz and costs ~0.45% CPU (measured on openlineage-python 1.47.1 — see repro below; 400 accumulated clients = 401 threads = ~167% CPU doing nothing). Observed in production (Airflow 2.11, Astronomer, KubernetesExecutor, ~50 hourly DAGs, composite transport with adatadogleg): scheduler CPU climbs steadily from the moment the datadog leg is enabled until pinned at its limit within ~6 hours, heartbeat dips, and only a scheduler restart recovers it. Even the thread-lesshttptransport pays a per-event session/connection-pool rebuild.Fix
Route pool submissions through a module-level
_run_adapter_method(method_name, ...)that resolves the adapter method by name on a per-process adapter singleton (_get_process_adapter), so each pool worker keeps exactly one client — and one transport set — for its whole lifetime._emit_manual_state_change_eventnow takes the adapter method name and resolves it the same way.Changes
listener.py: add_get_process_adapter()/_run_adapter_method(); DAG-run hooks submit method names instead of bound methods;_emit_manual_state_change_eventresolves the named method on the per-process adaptertest_listener.py: regression test asserting two submissions executed in the same pool worker observe the same adapter instance;direct_submit_calltest stand-in resolves method names on the listener's adapter so existing mocked-adapter assertions keep workingdocs/troubleshooting.rst: known-limitations entry for the steadily-growing scheduler CPU/memory symptom on affected versionsDesign notes
_get_process_adapter: pool workers execute submitted tasks serially, and the scheduler parent only submits (it never runs_run_adapter_methoditself).Test Plan
test_process_adapter_reused_across_pool_submissionspassestest_listener.pysuite onmain: 38 passed, 0 failed (38 Airflow-2 variants skip locally and run in the CI compatibility matrix)ruff checkandruff format --checkclean on changed filesThread-leak repro (openlineage-python 1.47.1) — one DatadogTransport instantiation per simulated event
A complementary report about the
datadogtransport itself (unconditional thread start, never closed, busy-poll idle loop) is being filed with OpenLineage separately; this provider-side fix removes the per-event client multiplication for all transport types.closes: #69284
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code following the guidelines
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.