|
19 | 19 |
|
20 | 20 | import asyncio |
21 | 21 | import json |
22 | | -import threading |
23 | 22 | import time |
24 | | -import weakref |
25 | | -from contextlib import AsyncExitStack |
26 | | -from functools import cached_property |
27 | 23 | from typing import TYPE_CHECKING, Any, cast |
28 | 24 |
|
29 | | -import attrs |
30 | 25 | import svcs |
31 | 26 | from cadwyn import ( |
32 | 27 | Cadwyn, |
|
44 | 39 | get_sig_validation_args, |
45 | 40 | get_signing_args, |
46 | 41 | ) |
| 42 | +from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI |
47 | 43 |
|
48 | 44 | if TYPE_CHECKING: |
49 | | - import httpx |
| 45 | + from airflow.models.dagbag import DBDagBag |
50 | 46 |
|
51 | 47 | import structlog |
52 | 48 | from structlog.contextvars import bind_contextvars |
53 | 49 |
|
54 | 50 | logger = structlog.get_logger(logger_name=__name__) |
55 | 51 |
|
56 | 52 | __all__ = [ |
| 53 | + "InProcessExecutionAPI", |
57 | 54 | "create_task_execution_api_app", |
58 | 55 | "lifespan", |
59 | 56 | "CorrelationIdMiddleware", |
@@ -285,8 +282,18 @@ def _inject_trace_context_dep(routes, mode: str) -> None: |
285 | 282 | route.dependencies.append(dep) |
286 | 283 |
|
287 | 284 |
|
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 |
290 | 297 | from airflow.api_fastapi.common.exceptions import init_error_handlers |
291 | 298 | from airflow.api_fastapi.execution_api.routes import execution_api_router |
292 | 299 | from airflow.api_fastapi.execution_api.versions import bundle |
@@ -326,6 +333,7 @@ def handle_exceptions(request: Request, exc: Exception): |
326 | 333 | content["correlation-id"] = correlation_id |
327 | 334 | return JSONResponse(status_code=500, content=content) |
328 | 335 |
|
| 336 | + app.state.dag_bag = dag_bag if dag_bag is not None else create_dag_bag() |
329 | 337 | return app |
330 | 338 |
|
331 | 339 |
|
@@ -353,111 +361,3 @@ def get_extra_schemas() -> dict[str, dict]: |
353 | 361 | "x-enum-varnames": [DagAttributeTypes.OP.name, DagAttributeTypes.TASK_GROUP.name], |
354 | 362 | }, |
355 | 363 | } |
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) |
0 commit comments