This is an automated email from the ASF dual-hosted git repository.
ephraimbuddy 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 dc38f84ff46 Consolidate in-process Execution API hosting (#73327)
dc38f84ff46 is described below
commit dc38f84ff46d55b6e22961520636b1b7c9711994
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Fri Oct 9 13:29:45 2026 +0100
Consolidate in-process Execution API hosting (#73327)
* 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.
* Reduce startup overhead in lazy import tests
---
airflow-core/src/airflow/api_fastapi/app.py | 3 +-
.../src/airflow/api_fastapi/execution_api/app.py | 141 +++-----------------
.../api_fastapi/execution_api/in_process.py | 148 +++++++++++++++++++++
airflow-core/src/airflow/dag_processing/manager.py | 14 +-
.../src/airflow/dag_processing/processor.py | 8 --
.../src/airflow/jobs/triggerer_job_runner.py | 11 +-
.../unit/api_fastapi/execution_api/test_app.py | 45 +++++++
.../api_fastapi/execution_api/test_in_process.py | 60 +++++++++
airflow-core/tests/unit/api_fastapi/test_app.py | 2 +-
.../tests/unit/dag_processing/test_processor.py | 2 +-
scripts/ci/prek/generate_execution_api_schema.py | 2 +
11 files changed, 278 insertions(+), 158 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/app.py
b/airflow-core/src/airflow/api_fastapi/app.py
index 65121ab53d0..974fb2cee75 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -142,8 +142,7 @@ def create_app(apps: str = "all") -> FastAPI:
dag_bag = create_dag_bag()
if "all" in apps_list or "execution" in apps_list:
- task_exec_api_app = create_task_execution_api_app()
- task_exec_api_app.state.dag_bag = dag_bag
+ task_exec_api_app = create_task_execution_api_app(dag_bag=dag_bag)
app.mount("/execution", task_exec_api_app)
if "all" in apps_list or "core" in apps_list:
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 29b6793c6da..8370b0ae982 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/app.py
@@ -19,14 +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
-import attrs
import svcs
from cadwyn import (
Cadwyn,
@@ -38,16 +33,16 @@ from fastapi.routing import APIRoute
from opentelemetry import context as otel_context, propagate as otel_propagate
from starlette.middleware.base import BaseHTTPMiddleware
-from airflow import settings
from airflow.api_fastapi.auth.tokens import (
JWTGenerator,
JWTValidator,
get_sig_validation_args,
get_signing_args,
)
+from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI
if TYPE_CHECKING:
- import httpx
+ from airflow.models.dagbag import DBDagBag
import structlog
from structlog.contextvars import bind_contextvars
@@ -55,6 +50,7 @@ from structlog.contextvars import bind_contextvars
logger = structlog.get_logger(logger_name=__name__)
__all__ = [
+ "InProcessExecutionAPI",
"create_task_execution_api_app",
"lifespan",
"CorrelationIdMiddleware",
@@ -286,8 +282,18 @@ def _inject_trace_context_dep(routes, mode: str) -> None:
route.dependencies.append(dep)
-def create_task_execution_api_app(lifespan: svcs.fastapi.lifespan = lifespan)
-> FastAPI:
- """Create FastAPI app for task execution API."""
+def create_task_execution_api_app(
+ lifespan: svcs.fastapi.lifespan = lifespan,
+ *,
+ dag_bag: DBDagBag | None = None,
+) -> FastAPI:
+ """
+ Create the Execution API app.
+
+ :param lifespan: Lifespan manager that registers the app's services.
+ :param dag_bag: Dag bag to use; a new one is created when omitted.
+ """
+ from airflow.api_fastapi.common.dagbag import create_dag_bag
from airflow.api_fastapi.common.exceptions import init_error_handlers
from airflow.api_fastapi.execution_api.routes import execution_api_router
from airflow.api_fastapi.execution_api.versions import bundle
@@ -327,6 +333,7 @@ def create_task_execution_api_app(lifespan:
svcs.fastapi.lifespan = lifespan) ->
content["correlation-id"] = correlation_id
return JSONResponse(status_code=500, content=content)
+ app.state.dag_bag = dag_bag if dag_bag is not None else create_dag_bag()
return app
@@ -354,119 +361,3 @@ def get_extra_schemas() -> dict[str, dict]:
"x-enum-varnames": [DagAttributeTypes.OP.name,
DagAttributeTypes.TASK_GROUP.name],
},
}
-
-
-# Note: _shutdown_loop is used as a finalizer for the WSGI transport returned
by
-# ``InProcessExecutionAPI.transport``. As such, its arguments must not
directly or indirectly reference that
-# transport, as this would prevent the transport 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)
-
-
[email protected]()
-class InProcessExecutionAPI:
- """
- A helper class to make it possible to run the ExecutionAPI "in-process".
-
- The sync version of this makes use of a2wsgi which runs the async loop in
a separate thread. This is
- needed so that we can use the sync httpx client
- """
-
- _app: FastAPI | None = None
-
- @cached_property
- def app(self):
- if not self._app:
- from airflow.api_fastapi.common.dagbag import create_dag_bag
- from airflow.api_fastapi.execution_api.datamodels.token import
TIClaims, TIToken
- from airflow.api_fastapi.execution_api.routes.connections import
has_connection_access
- from airflow.api_fastapi.execution_api.routes.variables import
has_variable_access
- from airflow.api_fastapi.execution_api.routes.xcoms import
has_xcom_access
- from airflow.api_fastapi.execution_api.security import
_IN_PROCESS_NON_TI_CALLER, _jwt_bearer
-
- # Give this app its own lifespan + services registry so that
stubbing services
- # (e.g. JWTValidator) doesn't affect the module-level
``lifespan.registry``.
- registry = svcs.Registry()
- private_lifespan = attrs.evolve(lifespan, registry=registry)
- self._app =
create_task_execution_api_app(lifespan=private_lifespan)
-
- # In-process callers don't need a real JWTValidator: auth is
bypassed below via
- # ``dependency_overrides``.
- registry.register_value(JWTValidator, None)
-
- # Set up dag_bag in app state for dependency injection
- self._app.state.dag_bag = create_dag_bag()
-
- async def always_allow(request: Request):
- from uuid import UUID
-
- ti_id = UUID(
- request.path_params.get("task_instance_id")
- or request.headers.get("X-Airflow-In-Process-Attempt-Id")
- or "00000000-0000-0000-0000-000000000000"
- )
- # Watchers and other trusted in-process callers may have no
task identity.
- request.scope[_IN_PROCESS_NON_TI_CALLER] = (
- "task_instance_id" not in request.path_params
- and "X-Airflow-In-Process-Attempt-Id" not in
request.headers
- )
- claims = TIClaims(scope="execution")
- return TIToken(id=ti_id, claims=claims)
-
- self._app.dependency_overrides[_jwt_bearer] = always_allow
- self._app.dependency_overrides[has_connection_access] =
always_allow
- self._app.dependency_overrides[has_variable_access] = always_allow
- self._app.dependency_overrides[has_xcom_access] = always_allow
-
- return self._app
-
- @cached_property
- def transport(self) -> httpx.WSGITransport:
- import httpx
- from a2wsgi import ASGIMiddleware
-
- # 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):
- cm.push_async_callback(settings.dispose_async_engine)
- await cm.enter_async_context(app.router.lifespan_context(app))
-
- 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()
-
- transport = httpx.WSGITransport(app=middleware) # type:
ignore[arg-type]
-
- # Stop the loop + thread and unwind the lifespan when the *transport*
is garbage collected, not
- # this InProcessExecutionAPI instance. Callers commonly build a Client
from ``.transport`` and drop
- # the factory object (e.g.
``Client(transport=InProcessExecutionAPI().transport)``); finalizing on
- # ``self`` would stop the loop while the transport is still in use, so
every later request would
- # hang on the now-dead loop.
- weakref.finalize(transport, _shutdown_loop, loop, thread, cm)
-
- return transport
-
- @cached_property
- def atransport(self) -> httpx.ASGITransport:
- import httpx
-
- return httpx.ASGITransport(app=self.app)
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
b/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
new file mode 100644
index 00000000000..63f0ce78e71
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
@@ -0,0 +1,148 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""In-process Execution API hosting, authentication overrides, and transport
lifecycle."""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+import weakref
+from contextlib import AsyncExitStack
+from functools import cached_property
+from typing import TYPE_CHECKING
+
+import attrs
+import structlog
+from starlette.requests import Request
+
+if TYPE_CHECKING:
+ import httpx
+ from fastapi import FastAPI
+
+logger = structlog.get_logger(logger_name=__name__)
+
+
+# Finalizer arguments must not reference the transport, or it cannot be
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)
+
+
+def _configure_in_process_auth(app: FastAPI) -> None:
+ from airflow.api_fastapi.execution_api.datamodels.token import TIClaims,
TIToken
+ from airflow.api_fastapi.execution_api.routes.connections import
has_connection_access
+ from airflow.api_fastapi.execution_api.routes.variables import
has_variable_access
+ from airflow.api_fastapi.execution_api.routes.xcoms import has_xcom_access
+ from airflow.api_fastapi.execution_api.security import
_IN_PROCESS_NON_TI_CALLER, _jwt_bearer
+
+ async def always_allow(request: Request):
+ from uuid import UUID
+
+ ti_id = UUID(
+ request.path_params.get("task_instance_id")
+ or request.headers.get("X-Airflow-In-Process-Attempt-Id")
+ or "00000000-0000-0000-0000-000000000000"
+ )
+ # Watchers and other trusted in-process callers may have no task
identity.
+ request.scope[_IN_PROCESS_NON_TI_CALLER] = (
+ "task_instance_id" not in request.path_params
+ and "X-Airflow-In-Process-Attempt-Id" not in request.headers
+ )
+ claims = TIClaims(scope="execution")
+ return TIToken(id=ti_id, claims=claims)
+
+ app.dependency_overrides[_jwt_bearer] = always_allow
+ app.dependency_overrides[has_connection_access] = always_allow
+ app.dependency_overrides[has_variable_access] = always_allow
+ app.dependency_overrides[has_xcom_access] = always_allow
+
+
[email protected]()
+class InProcessExecutionAPI:
+ """
+ A helper class to make it possible to run the ExecutionAPI "in-process".
+
+ The sync version of this makes use of a2wsgi which runs the async loop in
a separate thread. This is
+ needed so that we can use the sync httpx client
+ """
+
+ _app: FastAPI | None = None
+
+ @cached_property
+ def app(self):
+ import svcs
+
+ from airflow.api_fastapi.auth.tokens import JWTValidator
+ from airflow.api_fastapi.execution_api.app import
create_task_execution_api_app, lifespan
+
+ if not self._app:
+ # Keep the stub private: a shared None validator would prevent
lifespan() from
+ # registering the real API server's validator.
+ registry = svcs.Registry()
+ self._app =
create_task_execution_api_app(lifespan=attrs.evolve(lifespan,
registry=registry))
+ # Auth dependency overrides bypass validation, so the stub skips
validator setup in lifespan().
+ registry.register_value(JWTValidator, None)
+ _configure_in_process_auth(self._app)
+
+ return self._app
+
+ @cached_property
+ def transport(self) -> httpx.WSGITransport:
+ import httpx
+ from a2wsgi import ASGIMiddleware
+
+ from airflow import settings
+
+ # Own the event loop and thread so the transport controls 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):
+ cm.push_async_callback(settings.dispose_async_engine)
+ await cm.enter_async_context(app.router.lifespan_context(app))
+
+ cm = AsyncExitStack()
+
+ # Wait for startup so callers see a ready app and the finalizer can
close an entered context.
+ asyncio.run_coroutine_threadsafe(start_lifespan(cm, self.app),
loop).result()
+
+ transport = httpx.WSGITransport(app=middleware) # type:
ignore[arg-type]
+
+ # Callers can retain the transport after dropping this instance;
finalizing on self would
+ # stop the loop while requests still need it.
+ weakref.finalize(transport, _shutdown_loop, loop, thread, cm)
+
+ return transport
+
+ @cached_property
+ def atransport(self) -> httpx.ASGITransport:
+ import httpx
+
+ return httpx.ASGITransport(app=self.app)
diff --git a/airflow-core/src/airflow/dag_processing/manager.py
b/airflow-core/src/airflow/dag_processing/manager.py
index 4d525a40feb..343d72c5e00 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -47,6 +47,7 @@ from uuid6 import uuid7
from airflow._shared.observability.metrics import stats
from airflow._shared.observability.metrics.stats import
normalize_name_for_stats
from airflow._shared.timezones import timezone
+from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI
from airflow.callbacks.callback_requests import DagCallbackRequest
from airflow.configuration import conf
from airflow.dag_processing.bundles.base import (
@@ -97,22 +98,11 @@ if TYPE_CHECKING:
from sqlalchemy.orm import Session
from sqlalchemy.sql import Select
- from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
from airflow.callbacks.callback_requests import CallbackRequest
from airflow.dag_processing.bundles.base import BaseDagBundle
from airflow.sdk.api.client import Client
-def _make_execution_api() -> InProcessExecutionAPI:
- # This is a seriously weighty import, pulling in svcs, cadwyn, fastapi,
aiohttp, etc.
- #
- # Defer it so that an import of this module for types (e.g. DagFileStat,
DagFileInto) doesn't need to pay
- # that cost.
- from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
-
- return InProcessExecutionAPI()
-
-
class BundleState(NamedTuple):
"""Persisted refresh state for a DAG bundle."""
@@ -304,7 +294,7 @@ class DagFileProcessorManager(LoggingMixin):
)
"""Resolved once per process so file discovery and the deactivation scan
use the same value."""
- _api_server: InProcessExecutionAPI = attrs.field(init=False,
factory=_make_execution_api)
+ _api_server: InProcessExecutionAPI = attrs.field(init=False,
factory=InProcessExecutionAPI)
"""API server to interact with Metadata DB"""
def register_exit_signals(self):
diff --git a/airflow-core/src/airflow/dag_processing/processor.py
b/airflow-core/src/airflow/dag_processing/processor.py
index fb3dd55f438..c3feb3f4959 100644
--- a/airflow-core/src/airflow/dag_processing/processor.py
+++ b/airflow-core/src/airflow/dag_processing/processor.py
@@ -87,7 +87,6 @@ if TYPE_CHECKING:
from structlog.typing import FilteringBoundLogger
- from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
from airflow.sdk.api.client import Client
from airflow.sdk.bases.operator import BaseOperator
from airflow.sdk.definitions.context import Context
@@ -568,13 +567,6 @@ def _execute_email_callbacks(dagbag: DagBag, request:
EmailRequest, log: Filteri
)
-def in_process_api_server() -> InProcessExecutionAPI:
- from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
-
- api = InProcessExecutionAPI()
- return api
-
-
@attrs.define(kw_only=True)
class BaseDagFileProcessorProcess(WatchedSubprocess, LoggingMixin):
"""
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index ede83fe6c39..dbef9d2951a 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -49,6 +49,7 @@ from structlog.contextvars import bind_contextvars as
bind_log_contextvars
from airflow._shared.module_loading import import_string
from airflow._shared.observability.metrics import stats
from airflow._shared.timezones import timezone
+from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI
from airflow.configuration import conf
from airflow.exceptions import TaskNotFound
from airflow.executors import workloads
@@ -140,7 +141,6 @@ if TYPE_CHECKING:
from sqlalchemy.orm import Session
from structlog.typing import FilteringBoundLogger, WrappedLogger
- from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
from airflow.jobs.job import Job
from airflow.sdk.api.client import Client
from airflow.sdk.definitions.context import Context
@@ -469,13 +469,6 @@ class TriggerLoggingFactory:
upload_to_remote(self.bound_logger, self.ti)
-def in_process_api_server() -> InProcessExecutionAPI:
- from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
-
- api = InProcessExecutionAPI()
- return api
-
-
@attrs.define(kw_only=True)
class TriggerRunnerSupervisor(WatchedSubprocess):
"""
@@ -584,7 +577,7 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
"""
from airflow.sdk.api.client import Client
- client = Client(base_url=None, token="", dry_run=True,
transport=in_process_api_server().transport)
+ client = Client(base_url=None, token="", dry_run=True,
transport=InProcessExecutionAPI().transport)
# Mypy is wrong -- the setter accepts a string on the property setter!
`URLType = URL | str`
client.base_url = "http://in-process.invalid./"
return client
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 1cbb610a418..a57607f3b91 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
@@ -34,9 +34,11 @@ from sqlalchemy import event, select, text
from sqlalchemy.exc import SQLAlchemyError
from airflow import settings
+from airflow.api_fastapi.app import create_app
from airflow.api_fastapi.execution_api.app import (
InProcessExecutionAPI,
_extract_w3c_trace_context,
+ _jwt_generator,
create_task_execution_api_app,
)
from airflow.api_fastapi.execution_api.datamodels.taskinstance import
TaskInstance
@@ -51,6 +53,49 @@ from tests_common.test_utils.config import conf_vars
pytestmark = pytest.mark.db_test
[email protected]("supply_dag_bag", [False, True])
[email protected]("airflow.api_fastapi.common.dagbag.create_dag_bag", autospec=True)
+def test_create_app_initializes_dag_bag(mock_create_dag_bag, supply_dag_bag):
+ dag_bag = mock.sentinel.dag_bag if supply_dag_bag else None
+ app = create_task_execution_api_app(dag_bag=dag_bag)
+
+ if supply_dag_bag:
+ assert app.state.dag_bag is dag_bag
+ mock_create_dag_bag.assert_not_called()
+ else:
+ assert app.state.dag_bag is mock_create_dag_bag.return_value
+ mock_create_dag_bag.assert_called_once_with()
+
+
[email protected]("in_process_first", [False, True])
+@conf_vars({("api_auth", "jwt_secret"): "execution-api-hosting-test-secret"})
+def test_server_and_in_process_apps_isolate_auth(in_process_first,
monkeypatch):
+ monkeypatch.setenv("AIRFLOW_VAR_EXECUTION_API_HOSTING", "shared-value")
+ if in_process_first:
+ api = InProcessExecutionAPI()
+ assert api.app is not None
+ server_app = create_app(apps="execution")
+ else:
+ server_app = create_app(apps="execution")
+ api = InProcessExecutionAPI()
+
+ with (
+ TestClient(server_app) as server,
+ httpx.Client(transport=api.transport,
base_url="http://in-process.invalid") as local,
+ ):
+ response = local.get("/variables/execution_api_hosting")
+ assert response.status_code == 200
+ assert response.json() == {"key": "execution_api_hosting", "value":
"shared-value"}
+ assert
server.get("/execution/variables/execution_api_hosting").status_code == 401
+ token = _jwt_generator().generate({"sub":
"00000000-0000-0000-0000-000000000000"})
+ authenticated_response = server.get(
+ "/execution/variables/execution_api_hosting",
headers={"Authorization": f"Bearer {token}"}
+ )
+ # Missing credentials bypass JWTValidator; only an authenticated
request detects a leaked stub.
+ assert authenticated_response.status_code == 200
+ assert authenticated_response.json() == response.json()
+
+
def test_custom_openapi_includes_extra_schemas(client):
"""Test to ensure that extra schemas are correctly included in the OpenAPI
schema."""
response = client.get("/execution/openapi.json?version=2025-04-28")
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/test_in_process.py
b/airflow-core/tests/unit/api_fastapi/execution_api/test_in_process.py
new file mode 100644
index 00000000000..3f093721620
--- /dev/null
+++ b/airflow-core/tests/unit/api_fastapi/execution_api/test_in_process.py
@@ -0,0 +1,60 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from textwrap import dedent
+
+
+def test_construction_defers_execution_api_imports():
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ dedent("""
+ import sys
+
+ modules = {'svcs', 'cadwyn', 'fastapi', 'aiohttp',
'airflow.api_fastapi.execution_api.app'}
+
+ from airflow.api_fastapi.execution_api.in_process import
InProcessExecutionAPI
+
+ api = InProcessExecutionAPI()
+ loaded = modules.intersection(sys.modules)
+ assert not loaded, f'{loaded} imported eagerly by
InProcessExecutionAPI'
+
+ from airflow.dag_processing.manager import
DagFileProcessorManager
+
+ manager = DagFileProcessorManager(max_runs=1)
+ loaded = modules.intersection(sys.modules)
+ assert not loaded, f'{loaded} imported eagerly by
DagFileProcessorManager'
+ """),
+ ],
+ env={**os.environ, "_AIRFLOW__AS_LIBRARY": "1"},
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_app_reexports_in_process_api():
+ from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI as
legacy_api
+ from airflow.api_fastapi.execution_api.in_process import
InProcessExecutionAPI
+
+ assert legacy_api is InProcessExecutionAPI
diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py
b/airflow-core/tests/unit/api_fastapi/test_app.py
index de74dbc8d7c..dcbbc0a593a 100644
--- a/airflow-core/tests/unit/api_fastapi/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/test_app.py
@@ -174,7 +174,7 @@ def test_all_apps(mock_create_task_exec_api,
mock_init_plugins, mock_init_views)
mock_init_plugins.assert_called_once_with(test_app)
# Assert that execution-related functions were also called
- mock_create_task_exec_api.assert_called_once_with()
+
mock_create_task_exec_api.assert_called_once_with(dag_bag=test_app.state.dag_bag)
@pytest.mark.parametrize("apps", ["all", "core", "execution"])
diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py
b/airflow-core/tests/unit/dag_processing/test_processor.py
index 76a5ea85cde..f5abf5f8b52 100644
--- a/airflow-core/tests/unit/dag_processing/test_processor.py
+++ b/airflow-core/tests/unit/dag_processing/test_processor.py
@@ -38,12 +38,12 @@ from sqlalchemy import select
from structlog.typing import FilteringBoundLogger
from airflow._shared.timezones import timezone
-from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
from airflow.api_fastapi.execution_api.datamodels.taskinstance import (
DagRun as DRDataModel,
TaskInstance as TIDataModel,
TIRunContext,
)
+from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI
from airflow.callbacks.callback_requests import (
CallbackRequest,
DagCallbackRequest,
diff --git a/scripts/ci/prek/generate_execution_api_schema.py
b/scripts/ci/prek/generate_execution_api_schema.py
index 2efe713ea22..7b34e0be484 100644
--- a/scripts/ci/prek/generate_execution_api_schema.py
+++ b/scripts/ci/prek/generate_execution_api_schema.py
@@ -29,6 +29,8 @@ sys.path.insert(0, str(Path("airflow-core/src").resolve()))
import httpx
+# Keep this import compatible with the target branch: the version check runs
this
+# generator against both checkouts.
from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
app = InProcessExecutionAPI()