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

ashb 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 a52be75711a Do not leak threads from InProcessExecutionAPI (#68840)
a52be75711a is described below

commit a52be75711a439cdb8c0627c20ce51df2214e796
Author: Nick Stenning <[email protected]>
AuthorDate: Mon Jun 22 17:39:04 2026 +0200

    Do not leak threads from InProcessExecutionAPI (#68840)
    
    Through `a2wsgi.ASGIMiddleware`, the `InProcessExecutionAPI` was leaking
    a daemon thread per instance, used by `a2wsgi` to run an asyncio event
    loop.
    
    This commit moves management of that background thread into
    `InProcessExecutionAPI`, and adds a finalizer so that the thread is
    stopped when the execution API instance is garbage collected.
---
 .../src/airflow/api_fastapi/execution_api/app.py   | 40 ++++++++++++++++++----
 .../unit/api_fastapi/execution_api/test_app.py     | 26 ++++++++++++++
 task-sdk/dev/generate_task_sdk_models.py           |  2 ++
 3 files changed, 62 insertions(+), 6 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/app.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/app.py
index 88c7d23fff2..12bbb0921a2 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/app.py
@@ -19,7 +19,9 @@ from __future__ import annotations
 
 import asyncio
 import json
+import threading
 import time
+import weakref
 from contextlib import AsyncExitStack
 from functools import cached_property
 from typing import TYPE_CHECKING, Any, cast
@@ -348,6 +350,23 @@ def get_extra_schemas() -> dict[str, dict]:
     }
 
 
+# Note: _shutdown_loop is used as a finalizer for 
:class:`InProcessExecutionAPI`. As such, its arguments must
+# not directly or indirectly reference the instance itself, as this will 
prevent the instance from being
+# garbage collected.
+def _shutdown_loop(
+    loop: asyncio.AbstractEventLoop,
+    thread: threading.Thread,
+    cm: AsyncExitStack,
+) -> None:
+    """Close the FastAPI lifespan and stop the background event loop + 
thread."""
+    try:
+        asyncio.run_coroutine_threadsafe(cm.aclose(), loop).result(timeout=5)
+    except Exception:
+        logger.exception("Error while closing in-process execution API 
lifespan")
+    loop.call_soon_threadsafe(loop.stop)
+    thread.join(timeout=5)
+
+
 @attrs.define()
 class InProcessExecutionAPI:
     """
@@ -358,7 +377,6 @@ class InProcessExecutionAPI:
     """
 
     _app: FastAPI | None = None
-    _cm: AsyncExitStack | None = None
 
     @cached_property
     def app(self):
@@ -393,20 +411,30 @@ class InProcessExecutionAPI:
 
     @cached_property
     def transport(self) -> httpx.WSGITransport:
-        import asyncio
-
         import httpx
         from a2wsgi import ASGIMiddleware
 
-        middleware = ASGIMiddleware(self.app)
+        # We choose to own the event loop + executor thread here so that we 
can have explicit control over
+        # their lifecycle.
+        loop = asyncio.new_event_loop()
+        thread = threading.Thread(target=loop.run_forever, 
name="InProcessExecutionAPI-loop", daemon=True)
+        thread.start()
+
+        middleware = ASGIMiddleware(self.app, loop=loop)
 
         # https://github.com/abersheeran/a2wsgi/discussions/64
         async def start_lifespan(cm: AsyncExitStack, app: FastAPI):
             await cm.enter_async_context(app.router.lifespan_context(app))
 
-        self._cm = AsyncExitStack()
+        cm = AsyncExitStack()
+
+        # Wait for lifespan startup to complete so callers see a ready app and 
so the finalizer can
+        # safely aclose() a context whose __aenter__ has actually run.
+        asyncio.run_coroutine_threadsafe(start_lifespan(cm, self.app), 
loop).result()
+
+        # Stop the loop + thread and unwind the lifespan when this instance is 
garbage collected.
+        weakref.finalize(self, _shutdown_loop, loop, thread, cm)
 
-        asyncio.run_coroutine_threadsafe(start_lifespan(self._cm, self.app), 
middleware.loop)
         return httpx.WSGITransport(app=middleware)  # type: ignore[arg-type]
 
     @cached_property
diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py 
b/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
index a5f371e565f..75e25bf0eba 100644
--- a/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
@@ -17,6 +17,8 @@
 from __future__ import annotations
 
 import asyncio
+import gc
+import threading
 from unittest import mock
 from uuid import UUID
 
@@ -28,6 +30,7 @@ from fastapi.testclient import TestClient
 from opentelemetry import context as otel_context, propagate as otel_propagate
 
 from airflow.api_fastapi.execution_api.app import (
+    InProcessExecutionAPI,
     _extract_w3c_trace_context,
     create_task_execution_api_app,
 )
@@ -134,6 +137,29 @@ def 
test_routes_with_task_instance_id_param_enforce_ti_self(client):
     )
 
 
+def test_in_process_execution_api_teardown():
+    """Accessing .transport spins up a daemon thread; dropping the instance 
must stop it via finalize.
+
+    Regression coverage for the a2wsgi background-thread leak.
+    """
+    before = {t for t in threading.enumerate() if t.name == 
"InProcessExecutionAPI-loop"}
+
+    api = InProcessExecutionAPI()
+    _ = api.transport  # trigger loop + thread creation
+
+    new_threads = {t for t in threading.enumerate() if t.name == 
"InProcessExecutionAPI-loop"} - before
+    assert len(new_threads) == 1
+    thread = new_threads.pop()
+    assert thread.is_alive()
+
+    # Drop the only strong reference; the weakref.finalize registered in 
.transport must stop
+    # the loop and join the daemon thread once the instance is collected.
+    del api
+    gc.collect()
+    thread.join(timeout=5)
+    assert not thread.is_alive()
+
+
 class TestCorrelationIdMiddleware:
     def test_correlation_id_echoed_in_response_headers(self, client):
         """Test that correlation-id from request is echoed back in response 
headers."""
diff --git a/task-sdk/dev/generate_task_sdk_models.py 
b/task-sdk/dev/generate_task_sdk_models.py
index bb19fffc449..74a1cb7f756 100644
--- a/task-sdk/dev/generate_task_sdk_models.py
+++ b/task-sdk/dev/generate_task_sdk_models.py
@@ -33,6 +33,8 @@ from datamodel_code_generator import (
 from openapi_spec_validator import validate_spec
 
 os.environ["_AIRFLOW__AS_LIBRARY"] = "1"
+# Set a placeholder secret to allow the in-process FastAPI app to run its 
lifecycle hooks.
+os.environ.setdefault("AIRFLOW__API_AUTH__JWT_SECRET", 
"task-sdk-model-generation")
 
 AIRFLOW_ROOT_PATH = Path(__file__).parents[2].resolve()
 AIRFLOW_TASK_SDK_ROOT_PATH = AIRFLOW_ROOT_PATH / "task-sdk"

Reply via email to