Build 060.25: implement Production Runtime Integration
This commit is contained in:
254
app/src/bootstrap/application.py
Normal file
254
app/src/bootstrap/application.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
|
||||
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
||||
TradeStreamProductionRuntimeProtocol,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationComposition:
|
||||
"""Корневые компоненты одного запущенного экземпляра приложения."""
|
||||
|
||||
bot: Bot
|
||||
dispatcher: Dispatcher
|
||||
trade_stream_runtime: (
|
||||
TradeStreamProductionRuntimeProtocol | None
|
||||
)
|
||||
|
||||
|
||||
async def run_application(
|
||||
application: ApplicationComposition,
|
||||
) -> None:
|
||||
"""
|
||||
Выполнять Telegram polling и опциональный Trade Stream как одно целое.
|
||||
|
||||
Ошибка включённого Trade Stream считается фатальной для всего
|
||||
приложения. Остановка Telegram, Trade Stream и bot session
|
||||
выполняется одним владельцем и не оставляет фоновых root tasks.
|
||||
"""
|
||||
polling_task = asyncio.create_task(
|
||||
application.dispatcher.start_polling(
|
||||
application.bot,
|
||||
close_bot_session=False,
|
||||
),
|
||||
name="telegram-polling",
|
||||
)
|
||||
runtime_task = (
|
||||
asyncio.create_task(
|
||||
application.trade_stream_runtime.run(),
|
||||
name="trade-stream-runtime",
|
||||
)
|
||||
if application.trade_stream_runtime is not None
|
||||
else None
|
||||
)
|
||||
|
||||
primary_error: BaseException | None = None
|
||||
|
||||
try:
|
||||
await _wait_for_root_tasks(
|
||||
polling_task=polling_task,
|
||||
runtime_task=runtime_task,
|
||||
)
|
||||
except BaseException as error:
|
||||
primary_error = error
|
||||
|
||||
cleanup_task = asyncio.create_task(
|
||||
_shutdown_application(
|
||||
application=application,
|
||||
polling_task=polling_task,
|
||||
runtime_task=runtime_task,
|
||||
primary_error=primary_error,
|
||||
),
|
||||
name="application-shutdown",
|
||||
)
|
||||
cleanup_error = await _await_cleanup(
|
||||
cleanup_task,
|
||||
primary_error=primary_error,
|
||||
)
|
||||
|
||||
if primary_error is not None:
|
||||
if cleanup_error is not None:
|
||||
primary_error.add_note(
|
||||
"Application cleanup also failed: "
|
||||
f"{type(cleanup_error).__name__}."
|
||||
)
|
||||
|
||||
raise primary_error.with_traceback(
|
||||
primary_error.__traceback__,
|
||||
)
|
||||
|
||||
if cleanup_error is not None:
|
||||
raise cleanup_error.with_traceback(
|
||||
cleanup_error.__traceback__,
|
||||
)
|
||||
|
||||
|
||||
async def _wait_for_root_tasks(
|
||||
*,
|
||||
polling_task: asyncio.Task[None],
|
||||
runtime_task: asyncio.Task[None] | None,
|
||||
) -> None:
|
||||
if runtime_task is None:
|
||||
await polling_task
|
||||
return
|
||||
|
||||
done_tasks, _ = await asyncio.wait(
|
||||
(
|
||||
polling_task,
|
||||
runtime_task,
|
||||
),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if runtime_task in done_tasks:
|
||||
if runtime_task.cancelled():
|
||||
raise RuntimeError(
|
||||
"Trade Stream Runtime was cancelled unexpectedly."
|
||||
)
|
||||
|
||||
runtime_error = runtime_task.exception()
|
||||
|
||||
if runtime_error is not None:
|
||||
raise runtime_error.with_traceback(
|
||||
runtime_error.__traceback__,
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
"Trade Stream Runtime terminated unexpectedly."
|
||||
)
|
||||
|
||||
await polling_task
|
||||
|
||||
|
||||
async def _shutdown_application(
|
||||
*,
|
||||
application: ApplicationComposition,
|
||||
polling_task: asyncio.Task[None],
|
||||
runtime_task: asyncio.Task[None] | None,
|
||||
primary_error: BaseException | None,
|
||||
) -> BaseException | None:
|
||||
cleanup_error: BaseException | None = None
|
||||
polling_cancelled = False
|
||||
runtime_cancelled = False
|
||||
|
||||
if not polling_task.done():
|
||||
polling_cancelled = True
|
||||
polling_task.cancel()
|
||||
|
||||
runtime = application.trade_stream_runtime
|
||||
|
||||
if runtime is not None:
|
||||
try:
|
||||
await runtime.stop()
|
||||
except BaseException as error:
|
||||
cleanup_error = _merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
if runtime_task is not None and not runtime_task.done():
|
||||
runtime_cancelled = True
|
||||
runtime_task.cancel()
|
||||
|
||||
cleanup_error = await _observe_root_task(
|
||||
polling_task,
|
||||
expected_cancellation=polling_cancelled,
|
||||
primary_error=primary_error,
|
||||
cleanup_error=cleanup_error,
|
||||
)
|
||||
|
||||
if runtime_task is not None:
|
||||
cleanup_error = await _observe_root_task(
|
||||
runtime_task,
|
||||
expected_cancellation=runtime_cancelled,
|
||||
primary_error=primary_error,
|
||||
cleanup_error=cleanup_error,
|
||||
)
|
||||
|
||||
try:
|
||||
await application.bot.session.close()
|
||||
except BaseException as error:
|
||||
cleanup_error = _merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
return cleanup_error
|
||||
|
||||
|
||||
async def _observe_root_task(
|
||||
task: asyncio.Task[None],
|
||||
*,
|
||||
expected_cancellation: bool,
|
||||
primary_error: BaseException | None,
|
||||
cleanup_error: BaseException | None,
|
||||
) -> BaseException | None:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError as error:
|
||||
if (
|
||||
not expected_cancellation
|
||||
and error is not primary_error
|
||||
and not isinstance(primary_error, asyncio.CancelledError)
|
||||
):
|
||||
return _merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
except BaseException as error:
|
||||
if error is not primary_error:
|
||||
return _merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
return cleanup_error
|
||||
|
||||
|
||||
async def _await_cleanup(
|
||||
cleanup_task: asyncio.Task[BaseException | None],
|
||||
*,
|
||||
primary_error: BaseException | None,
|
||||
) -> BaseException | None:
|
||||
interrupted_error: asyncio.CancelledError | None = None
|
||||
|
||||
while not cleanup_task.done():
|
||||
try:
|
||||
await asyncio.shield(cleanup_task)
|
||||
except asyncio.CancelledError as error:
|
||||
if primary_error is None:
|
||||
primary_error = error
|
||||
interrupted_error = error
|
||||
continue
|
||||
|
||||
cleanup_error = cleanup_task.result()
|
||||
|
||||
if interrupted_error is not None:
|
||||
if cleanup_error is not None:
|
||||
interrupted_error.add_note(
|
||||
"Application cleanup also failed: "
|
||||
f"{type(cleanup_error).__name__}."
|
||||
)
|
||||
|
||||
raise interrupted_error
|
||||
|
||||
return cleanup_error
|
||||
|
||||
|
||||
def _merge_cleanup_error(
|
||||
current_error: BaseException | None,
|
||||
new_error: BaseException,
|
||||
) -> BaseException:
|
||||
if current_error is None:
|
||||
return new_error
|
||||
|
||||
current_error.add_note(
|
||||
"Additional application cleanup failure: "
|
||||
f"{type(new_error).__name__}."
|
||||
)
|
||||
return current_error
|
||||
Reference in New Issue
Block a user