Skip to content

Commit 1daab90

Browse files
committed
Consolidate in-process Execution API hosting
Dag parsing, trigger execution, local Dag tests and schema generation need consistent Execution API setup. Keeping in-process authentication and transport lifecycle together prevents setup drift while preserving isolation from network-served apps. Manager construction should not load the API server dependency stack. Existing Task SDK consumers and schema generation against older checkouts also need the established app.py import path to remain valid.
1 parent d878bef commit 1daab90

11 files changed

Lines changed: 262 additions & 149 deletions

File tree

‎airflow-core/src/airflow/api_fastapi/app.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,7 @@ def create_app(apps: str = "all") -> FastAPI:
140140
dag_bag = create_dag_bag()
141141

142142
if "all" in apps_list or "execution" in apps_list:
143-
task_exec_api_app = create_task_execution_api_app()
144-
task_exec_api_app.state.dag_bag = dag_bag
143+
task_exec_api_app = create_task_execution_api_app(dag_bag=dag_bag)
145144
app.mount("/execution", task_exec_api_app)
146145

147146
if "all" in apps_list or "core" in apps_list:

‎airflow-core/src/airflow/api_fastapi/execution_api/app.py‎

Lines changed: 16 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,9 @@
1919

2020
import asyncio
2121
import json
22-
import threading
2322
import time
24-
import weakref
25-
from contextlib import AsyncExitStack
26-
from functools import cached_property
2723
from typing import TYPE_CHECKING, Any, cast
2824

29-
import attrs
3025
import svcs
3126
from cadwyn import (
3227
Cadwyn,
@@ -44,16 +39,18 @@
4439
get_sig_validation_args,
4540
get_signing_args,
4641
)
42+
from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI
4743

4844
if TYPE_CHECKING:
49-
import httpx
45+
from airflow.models.dagbag import DBDagBag
5046

5147
import structlog
5248
from structlog.contextvars import bind_contextvars
5349

5450
logger = structlog.get_logger(logger_name=__name__)
5551

5652
__all__ = [
53+
"InProcessExecutionAPI",
5754
"create_task_execution_api_app",
5855
"lifespan",
5956
"CorrelationIdMiddleware",
@@ -285,8 +282,18 @@ def _inject_trace_context_dep(routes, mode: str) -> None:
285282
route.dependencies.append(dep)
286283

287284

288-
def create_task_execution_api_app(lifespan: svcs.fastapi.lifespan = lifespan) -> FastAPI:
289-
"""Create FastAPI app for task execution API."""
285+
def create_task_execution_api_app(
286+
lifespan: svcs.fastapi.lifespan = lifespan,
287+
*,
288+
dag_bag: DBDagBag | None = None,
289+
) -> FastAPI:
290+
"""
291+
Create the Execution API app.
292+
293+
:param lifespan: Lifespan manager that registers the app's services.
294+
:param dag_bag: Dag bag to use; a new one is created when omitted.
295+
"""
296+
from airflow.api_fastapi.common.dagbag import create_dag_bag
290297
from airflow.api_fastapi.common.exceptions import init_error_handlers
291298
from airflow.api_fastapi.execution_api.routes import execution_api_router
292299
from airflow.api_fastapi.execution_api.versions import bundle
@@ -326,6 +333,7 @@ def handle_exceptions(request: Request, exc: Exception):
326333
content["correlation-id"] = correlation_id
327334
return JSONResponse(status_code=500, content=content)
328335

336+
app.state.dag_bag = dag_bag if dag_bag is not None else create_dag_bag()
329337
return app
330338

331339

@@ -353,111 +361,3 @@ def get_extra_schemas() -> dict[str, dict]:
353361
"x-enum-varnames": [DagAttributeTypes.OP.name, DagAttributeTypes.TASK_GROUP.name],
354362
},
355363
}
356-
357-
358-
# Note: _shutdown_loop is used as a finalizer for the WSGI transport returned by
359-
# ``InProcessExecutionAPI.transport``. As such, its arguments must not directly or indirectly reference that
360-
# transport, as this would prevent the transport from being garbage collected.
361-
def _shutdown_loop(
362-
loop: asyncio.AbstractEventLoop,
363-
thread: threading.Thread,
364-
cm: AsyncExitStack,
365-
) -> None:
366-
"""Close the FastAPI lifespan and stop the background event loop + thread."""
367-
try:
368-
asyncio.run_coroutine_threadsafe(cm.aclose(), loop).result(timeout=5)
369-
except Exception:
370-
logger.exception("Error while closing in-process execution API lifespan")
371-
loop.call_soon_threadsafe(loop.stop)
372-
thread.join(timeout=5)
373-
374-
375-
@attrs.define()
376-
class InProcessExecutionAPI:
377-
"""
378-
A helper class to make it possible to run the ExecutionAPI "in-process".
379-
380-
The sync version of this makes use of a2wsgi which runs the async loop in a separate thread. This is
381-
needed so that we can use the sync httpx client
382-
"""
383-
384-
_app: FastAPI | None = None
385-
386-
@cached_property
387-
def app(self):
388-
if not self._app:
389-
from airflow.api_fastapi.common.dagbag import create_dag_bag
390-
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims, TIToken
391-
from airflow.api_fastapi.execution_api.routes.connections import has_connection_access
392-
from airflow.api_fastapi.execution_api.routes.variables import has_variable_access
393-
from airflow.api_fastapi.execution_api.routes.xcoms import has_xcom_access
394-
from airflow.api_fastapi.execution_api.security import _jwt_bearer
395-
396-
# Give this app its own lifespan + services registry so that stubbing services
397-
# (e.g. JWTValidator) doesn't affect the module-level ``lifespan.registry``.
398-
registry = svcs.Registry()
399-
private_lifespan = attrs.evolve(lifespan, registry=registry)
400-
self._app = create_task_execution_api_app(lifespan=private_lifespan)
401-
402-
# In-process callers don't need a real JWTValidator: auth is bypassed below via
403-
# ``dependency_overrides``.
404-
registry.register_value(JWTValidator, None)
405-
406-
# Set up dag_bag in app state for dependency injection
407-
self._app.state.dag_bag = create_dag_bag()
408-
409-
async def always_allow(request: Request):
410-
from uuid import UUID
411-
412-
ti_id = UUID(
413-
request.path_params.get("task_instance_id", "00000000-0000-0000-0000-000000000000")
414-
)
415-
claims = TIClaims(scope="execution")
416-
return TIToken(id=ti_id, claims=claims)
417-
418-
self._app.dependency_overrides[_jwt_bearer] = always_allow
419-
self._app.dependency_overrides[has_connection_access] = always_allow
420-
self._app.dependency_overrides[has_variable_access] = always_allow
421-
self._app.dependency_overrides[has_xcom_access] = always_allow
422-
423-
return self._app
424-
425-
@cached_property
426-
def transport(self) -> httpx.WSGITransport:
427-
import httpx
428-
from a2wsgi import ASGIMiddleware
429-
430-
# We choose to own the event loop + executor thread here so that we can have explicit control over
431-
# their lifecycle.
432-
loop = asyncio.new_event_loop()
433-
thread = threading.Thread(target=loop.run_forever, name="InProcessExecutionAPI-loop", daemon=True)
434-
thread.start()
435-
436-
middleware = ASGIMiddleware(self.app, loop=loop)
437-
438-
# https://github.com/abersheeran/a2wsgi/discussions/64
439-
async def start_lifespan(cm: AsyncExitStack, app: FastAPI):
440-
await cm.enter_async_context(app.router.lifespan_context(app))
441-
442-
cm = AsyncExitStack()
443-
444-
# Wait for lifespan startup to complete so callers see a ready app and so the finalizer can
445-
# safely aclose() a context whose __aenter__ has actually run.
446-
asyncio.run_coroutine_threadsafe(start_lifespan(cm, self.app), loop).result()
447-
448-
transport = httpx.WSGITransport(app=middleware) # type: ignore[arg-type]
449-
450-
# Stop the loop + thread and unwind the lifespan when the *transport* is garbage collected, not
451-
# this InProcessExecutionAPI instance. Callers commonly build a Client from ``.transport`` and drop
452-
# the factory object (e.g. ``Client(transport=InProcessExecutionAPI().transport)``); finalizing on
453-
# ``self`` would stop the loop while the transport is still in use, so every later request would
454-
# hang on the now-dead loop.
455-
weakref.finalize(transport, _shutdown_loop, loop, thread, cm)
456-
457-
return transport
458-
459-
@cached_property
460-
def atransport(self) -> httpx.ASGITransport:
461-
import httpx
462-
463-
return httpx.ASGITransport(app=self.app)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""In-process Execution API hosting, authentication overrides, and transport lifecycle."""
18+
19+
from __future__ import annotations
20+
21+
import asyncio
22+
import threading
23+
import weakref
24+
from contextlib import AsyncExitStack
25+
from functools import cached_property
26+
from typing import TYPE_CHECKING
27+
28+
import attrs
29+
import structlog
30+
from starlette.requests import Request
31+
32+
if TYPE_CHECKING:
33+
import httpx
34+
from fastapi import FastAPI
35+
36+
logger = structlog.get_logger(logger_name=__name__)
37+
38+
39+
# Finalizer arguments must not reference the transport, or it cannot be garbage collected.
40+
def _shutdown_loop(
41+
loop: asyncio.AbstractEventLoop,
42+
thread: threading.Thread,
43+
cm: AsyncExitStack,
44+
) -> None:
45+
"""Close the FastAPI lifespan and stop the background event loop + thread."""
46+
try:
47+
asyncio.run_coroutine_threadsafe(cm.aclose(), loop).result(timeout=5)
48+
except Exception:
49+
logger.exception("Error while closing in-process execution API lifespan")
50+
loop.call_soon_threadsafe(loop.stop)
51+
thread.join(timeout=5)
52+
53+
54+
def _configure_in_process_auth(app: FastAPI) -> None:
55+
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims, TIToken
56+
from airflow.api_fastapi.execution_api.routes.connections import has_connection_access
57+
from airflow.api_fastapi.execution_api.routes.variables import has_variable_access
58+
from airflow.api_fastapi.execution_api.routes.xcoms import has_xcom_access
59+
from airflow.api_fastapi.execution_api.security import _jwt_bearer
60+
61+
async def always_allow(request: Request):
62+
from uuid import UUID
63+
64+
ti_id = UUID(request.path_params.get("task_instance_id", "00000000-0000-0000-0000-000000000000"))
65+
claims = TIClaims(scope="execution")
66+
return TIToken(id=ti_id, claims=claims)
67+
68+
app.dependency_overrides[_jwt_bearer] = always_allow
69+
app.dependency_overrides[has_connection_access] = always_allow
70+
app.dependency_overrides[has_variable_access] = always_allow
71+
app.dependency_overrides[has_xcom_access] = always_allow
72+
73+
74+
@attrs.define()
75+
class InProcessExecutionAPI:
76+
"""
77+
A helper class to make it possible to run the ExecutionAPI "in-process".
78+
79+
The sync version of this makes use of a2wsgi which runs the async loop in a separate thread. This is
80+
needed so that we can use the sync httpx client
81+
"""
82+
83+
_app: FastAPI | None = None
84+
85+
@cached_property
86+
def app(self):
87+
import svcs
88+
89+
from airflow.api_fastapi.auth.tokens import JWTValidator
90+
from airflow.api_fastapi.execution_api.app import create_task_execution_api_app, lifespan
91+
92+
if not self._app:
93+
# Keep the stub private: a shared None validator would prevent lifespan() from
94+
# registering the real API server's validator.
95+
registry = svcs.Registry()
96+
self._app = create_task_execution_api_app(lifespan=attrs.evolve(lifespan, registry=registry))
97+
# Auth dependency overrides bypass validation, so the stub skips validator setup in lifespan().
98+
registry.register_value(JWTValidator, None)
99+
_configure_in_process_auth(self._app)
100+
101+
return self._app
102+
103+
@cached_property
104+
def transport(self) -> httpx.WSGITransport:
105+
import httpx
106+
from a2wsgi import ASGIMiddleware
107+
108+
# Own the event loop and thread so the transport controls their lifecycle.
109+
loop = asyncio.new_event_loop()
110+
thread = threading.Thread(target=loop.run_forever, name="InProcessExecutionAPI-loop", daemon=True)
111+
thread.start()
112+
113+
middleware = ASGIMiddleware(self.app, loop=loop)
114+
115+
# https://github.com/abersheeran/a2wsgi/discussions/64
116+
async def start_lifespan(cm: AsyncExitStack, app: FastAPI):
117+
await cm.enter_async_context(app.router.lifespan_context(app))
118+
119+
cm = AsyncExitStack()
120+
121+
# Wait for startup so callers see a ready app and the finalizer can close an entered context.
122+
asyncio.run_coroutine_threadsafe(start_lifespan(cm, self.app), loop).result()
123+
124+
transport = httpx.WSGITransport(app=middleware) # type: ignore[arg-type]
125+
126+
# Callers can retain the transport after dropping this instance; finalizing on self would
127+
# stop the loop while requests still need it.
128+
weakref.finalize(transport, _shutdown_loop, loop, thread, cm)
129+
130+
return transport
131+
132+
@cached_property
133+
def atransport(self) -> httpx.ASGITransport:
134+
import httpx
135+
136+
return httpx.ASGITransport(app=self.app)

‎airflow-core/src/airflow/dag_processing/manager.py‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from airflow._shared.observability.metrics import stats
4949
from airflow._shared.observability.metrics.stats import normalize_name_for_stats
5050
from airflow._shared.timezones import timezone
51+
from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI
5152
from airflow.configuration import conf
5253
from airflow.dag_processing.bundles.base import (
5354
BundleUsageTrackingManager,
@@ -90,22 +91,11 @@
9091
from sqlalchemy.orm import Session
9192
from sqlalchemy.sql import Select
9293

93-
from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
9494
from airflow.callbacks.callback_requests import CallbackRequest
9595
from airflow.dag_processing.bundles.base import BaseDagBundle
9696
from airflow.sdk.api.client import Client
9797

9898

99-
def _make_execution_api() -> InProcessExecutionAPI:
100-
# This is a seriously weighty import, pulling in svcs, cadwyn, fastapi, aiohttp, etc.
101-
#
102-
# Defer it so that an import of this module for types (e.g. DagFileStat, DagFileInto) doesn't need to pay
103-
# that cost.
104-
from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
105-
106-
return InProcessExecutionAPI()
107-
108-
10999
class DagParsingStat(NamedTuple):
110100
"""Information on processing progress."""
111101

@@ -303,7 +293,7 @@ class DagFileProcessorManager(LoggingMixin):
303293
)
304294
"""Resolved once per process so file discovery and the deactivation scan use the same value."""
305295

306-
_api_server: InProcessExecutionAPI = attrs.field(init=False, factory=_make_execution_api)
296+
_api_server: InProcessExecutionAPI = attrs.field(init=False, factory=InProcessExecutionAPI)
307297
"""API server to interact with Metadata DB"""
308298

309299
def register_exit_signals(self):

‎airflow-core/src/airflow/dag_processing/processor.py‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@
101101

102102
from structlog.typing import FilteringBoundLogger
103103

104-
from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
105104
from airflow.sdk.api.client import Client
106105
from airflow.sdk.bases.operator import BaseOperator
107106
from airflow.sdk.definitions.context import Context
@@ -567,13 +566,6 @@ def _execute_email_callbacks(dagbag: DagBag, request: EmailRequest, log: Filteri
567566
)
568567

569568

570-
def in_process_api_server() -> InProcessExecutionAPI:
571-
from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
572-
573-
api = InProcessExecutionAPI()
574-
return api
575-
576-
577569
@attrs.define(kw_only=True)
578570
class DagFileProcessorProcess(WatchedSubprocess, LoggingMixin):
579571
"""

0 commit comments

Comments
 (0)