"""Bridges Celery's sync task functions into the app's async service layer. In production, a Celery worker process has no asyncio event loop running when a task executes, so a plain `asyncio.run(...)` is enough. But with `CELERY_TASK_ALWAYS_EAGER=true` (tests, and `.delay()` called from inside an async FastAPI route handler), the task body runs synchronously *inside* the caller's already-running event loop, and `asyncio.run()` refuses to nest. `run_async_task` handles both: it uses `asyncio.run()` directly when no loop is running, and falls back to a dedicated thread with its own loop when one is. """ from __future__ import annotations import asyncio import contextvars from collections.abc import Coroutine from concurrent.futures import ThreadPoolExecutor def run_async_task[T](coro: Coroutine[object, object, T]) -> T: try: asyncio.get_running_loop() except RuntimeError: return asyncio.run(coro) # ThreadPoolExecutor does not copy contextvars into the worker thread by # default, which would silently drop the structlog correlation id # (request_id/run_id/task_id - see core/logging.py) bound by the caller. # Capturing the current context explicitly and running the executor call # through it keeps those log fields intact even on this fallback path. ctx = contextvars.copy_context() with ThreadPoolExecutor(max_workers=1) as executor: return executor.submit(ctx.run, asyncio.run, coro).result()