327 lines
8.9 KiB
Python
327 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
|
|
from aiogram import Bot, Dispatcher
|
|
|
|
from src.bootstrap.market_data_storage import (
|
|
MarketDataStorageLifecycleProtocol,
|
|
)
|
|
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
|
|
)
|
|
market_data_storage_lifecycle: (
|
|
MarketDataStorageLifecycleProtocol | None
|
|
) = None
|
|
|
|
|
|
async def run_application(
|
|
application: ApplicationComposition,
|
|
) -> None:
|
|
"""
|
|
Выполнять Telegram polling и опциональный Trade Stream как одно целое.
|
|
|
|
Ошибка включённого Trade Stream считается фатальной для всего
|
|
приложения. Остановка Telegram, Trade Stream и bot session
|
|
выполняется одним владельцем и не оставляет фоновых root tasks.
|
|
"""
|
|
polling_task: asyncio.Task[None] | None = None
|
|
runtime_task: asyncio.Task[None] | None = None
|
|
primary_error: BaseException | None = None
|
|
|
|
try:
|
|
storage_lifecycle = (
|
|
application.market_data_storage_lifecycle
|
|
)
|
|
|
|
if storage_lifecycle is not None:
|
|
await _run_blocking_lifecycle_operation(
|
|
storage_lifecycle.start,
|
|
task_name="market-data-storage-startup",
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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] | 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 polling_task is not None and 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()
|
|
|
|
if polling_task is not None:
|
|
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,
|
|
)
|
|
|
|
storage_lifecycle = application.market_data_storage_lifecycle
|
|
|
|
if storage_lifecycle is not None:
|
|
try:
|
|
await _run_blocking_lifecycle_operation(
|
|
storage_lifecycle.stop,
|
|
task_name="market-data-storage-shutdown",
|
|
)
|
|
except BaseException as error:
|
|
cleanup_error = _merge_cleanup_error(
|
|
cleanup_error,
|
|
error,
|
|
)
|
|
|
|
try:
|
|
await application.bot.session.close()
|
|
except BaseException as error:
|
|
cleanup_error = _merge_cleanup_error(
|
|
cleanup_error,
|
|
error,
|
|
)
|
|
|
|
return cleanup_error
|
|
|
|
|
|
async def _run_blocking_lifecycle_operation(
|
|
operation: Callable[[], None],
|
|
*,
|
|
task_name: str,
|
|
) -> None:
|
|
operation_task = asyncio.create_task(
|
|
asyncio.to_thread(operation),
|
|
name=task_name,
|
|
)
|
|
|
|
try:
|
|
await asyncio.shield(operation_task)
|
|
except asyncio.CancelledError as cancellation:
|
|
try:
|
|
await _wait_for_blocking_operation(operation_task)
|
|
except BaseException as operation_error:
|
|
cancellation.add_note(
|
|
"Application blocking lifecycle operation also failed: "
|
|
f"{type(operation_error).__name__}."
|
|
)
|
|
|
|
raise
|
|
|
|
|
|
async def _wait_for_blocking_operation(
|
|
operation_task: asyncio.Task[None],
|
|
) -> None:
|
|
while not operation_task.done():
|
|
try:
|
|
await asyncio.shield(operation_task)
|
|
except asyncio.CancelledError:
|
|
continue
|
|
except BaseException:
|
|
break
|
|
|
|
operation_task.result()
|
|
|
|
|
|
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
|