Build 060.25: implement Production Runtime Integration
This commit is contained in:
@@ -5,7 +5,11 @@ from __future__ import annotations
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
|
||||
from src.bootstrap.application import ApplicationComposition
|
||||
from src.bootstrap.logging import setup_logging
|
||||
from src.bootstrap.trade_stream_runtime import (
|
||||
build_trade_stream_production_runtime,
|
||||
)
|
||||
from src.core.config import load_settings
|
||||
from src.notifications.targets import NotificationTargetRegistry
|
||||
from src.storage.schema import init_schema
|
||||
@@ -13,7 +17,7 @@ from src.telegram.routers import setup_routers
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
def create_app() -> tuple[Bot, Dispatcher]:
|
||||
def create_app() -> ApplicationComposition:
|
||||
settings = load_settings()
|
||||
|
||||
setup_logging(settings.log_level)
|
||||
@@ -37,18 +41,9 @@ def create_app() -> tuple[Bot, Dispatcher]:
|
||||
pass
|
||||
raise
|
||||
|
||||
try:
|
||||
journal.log_info(
|
||||
"app_started",
|
||||
"Приложение запущено",
|
||||
{
|
||||
"env": settings.app_env,
|
||||
"exchange_name": settings.exchange_name,
|
||||
"default_symbol": settings.default_symbol,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
trade_stream_runtime = (
|
||||
build_trade_stream_production_runtime(settings)
|
||||
)
|
||||
|
||||
bot = Bot(
|
||||
token=settings.bot_token,
|
||||
@@ -61,4 +56,24 @@ def create_app() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
setup_routers(dispatcher)
|
||||
|
||||
return bot, dispatcher
|
||||
try:
|
||||
journal.log_info(
|
||||
"app_started",
|
||||
"Приложение запущено",
|
||||
{
|
||||
"env": settings.app_env,
|
||||
"exchange_name": settings.exchange_name,
|
||||
"default_symbol": settings.default_symbol,
|
||||
"trade_stream_enabled": (
|
||||
settings.trade_stream.enabled
|
||||
),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ApplicationComposition(
|
||||
bot=bot,
|
||||
dispatcher=dispatcher,
|
||||
trade_stream_runtime=trade_stream_runtime,
|
||||
)
|
||||
|
||||
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
|
||||
125
app/src/bootstrap/trade_stream_runtime.py
Normal file
125
app/src/bootstrap/trade_stream_runtime.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.config import Settings
|
||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||
DzengiTradesDocumentSource,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||
DzengiUnifiedWebSocketAdapter,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.websocket_control_message_handler import (
|
||||
DzengiWebSocketControlMessageHandler,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.websocket_inbound_message_classifier import (
|
||||
DzengiWebSocketInboundMessageClassifier,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.websocket_transport import (
|
||||
DzengiWebSocketTransport,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.acquisition_runtime_event_logging_consumer import (
|
||||
AcquisitionRuntimeEventLoggingConsumer,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.acquisition_runtime_event_publisher import (
|
||||
AcquisitionRuntimeEventPublisher,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
||||
TradeStreamProductionRuntime,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_session import (
|
||||
WebSocketSession,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_subscription_manager import (
|
||||
WebSocketSubscriptionManager,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_runtime_composition import (
|
||||
build_trade_stream_runtime_composition,
|
||||
)
|
||||
|
||||
|
||||
def build_trade_stream_production_runtime(
|
||||
settings: Settings,
|
||||
) -> TradeStreamProductionRuntime | None:
|
||||
"""
|
||||
Собрать Production Trade Stream Runtime без запуска lifecycle.
|
||||
|
||||
Выключенный feature flag возвращает ``None`` до создания
|
||||
WebSocket/REST-зависимостей. Все stateful-компоненты включённого
|
||||
Runtime создаются один раз и затем передаются по identity.
|
||||
"""
|
||||
trade_stream = settings.trade_stream
|
||||
|
||||
if not trade_stream.enabled:
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Origin": settings.exchange_base_url.rstrip("/"),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if settings.exchange_api_key:
|
||||
headers["X-MBX-APIKEY"] = settings.exchange_api_key
|
||||
|
||||
transport = DzengiWebSocketTransport(
|
||||
url=trade_stream.websocket_url,
|
||||
headers=headers,
|
||||
open_timeout=trade_stream.open_timeout_seconds,
|
||||
ping_interval=None,
|
||||
ping_timeout=None,
|
||||
probe_timeout=trade_stream.probe_timeout_seconds,
|
||||
close_timeout=trade_stream.close_timeout_seconds,
|
||||
)
|
||||
session = WebSocketSession(transport)
|
||||
subscription_manager = WebSocketSubscriptionManager(
|
||||
transport,
|
||||
supports_unsubscribe=False,
|
||||
)
|
||||
event_publisher = AcquisitionRuntimeEventPublisher(
|
||||
(
|
||||
AcquisitionRuntimeEventLoggingConsumer(),
|
||||
)
|
||||
)
|
||||
recovery_document_source = DzengiTradesDocumentSource(
|
||||
ExchangeRestClient(settings=settings),
|
||||
)
|
||||
message_adapter = DzengiUnifiedWebSocketAdapter()
|
||||
|
||||
composition = build_trade_stream_runtime_composition(
|
||||
session=session,
|
||||
transport=transport,
|
||||
subscription_manager=subscription_manager,
|
||||
event_publisher=event_publisher,
|
||||
message_adapter=message_adapter,
|
||||
recovery_document_source=recovery_document_source,
|
||||
symbols=trade_stream.symbols,
|
||||
heartbeat_timeout_seconds=(
|
||||
trade_stream.heartbeat_timeout_seconds
|
||||
),
|
||||
scheduler_interval_seconds=(
|
||||
trade_stream.scheduler_interval_seconds
|
||||
),
|
||||
max_recovery_window_ms=trade_stream.recovery_window_ms,
|
||||
)
|
||||
|
||||
return TradeStreamProductionRuntime(
|
||||
session=session,
|
||||
transport=transport,
|
||||
subscription_manager=subscription_manager,
|
||||
event_publisher=event_publisher,
|
||||
trade_stream_service=(
|
||||
composition.trade_stream_acquisition_service
|
||||
),
|
||||
message_classifier=(
|
||||
DzengiWebSocketInboundMessageClassifier()
|
||||
),
|
||||
control_message_handler=(
|
||||
DzengiWebSocketControlMessageHandler()
|
||||
),
|
||||
live_processing_gate=composition.live_processing_gate,
|
||||
reconnect_recovery_coordinator=(
|
||||
composition.runtime_reconnect_recovery_coordinator
|
||||
),
|
||||
runtime_supervisor=composition.runtime_supervisor,
|
||||
runtime_scheduler=composition.runtime_scheduler,
|
||||
symbols=trade_stream.symbols,
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -19,6 +20,21 @@ ENV_FILE = BASE_DIR / ".env"
|
||||
load_dotenv(ENV_FILE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradeStreamSettings:
|
||||
"""Настройки Production Trade Stream Runtime."""
|
||||
|
||||
enabled: bool
|
||||
websocket_url: str
|
||||
symbols: tuple[str, ...]
|
||||
open_timeout_seconds: float
|
||||
probe_timeout_seconds: float
|
||||
close_timeout_seconds: float
|
||||
heartbeat_timeout_seconds: float
|
||||
scheduler_interval_seconds: float
|
||||
recovery_window_ms: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Settings:
|
||||
# Telegram
|
||||
@@ -40,6 +56,7 @@ class Settings:
|
||||
exchange_timeout_sec: int
|
||||
exchange_testnet: bool
|
||||
default_symbol: str
|
||||
trade_stream: TradeStreamSettings
|
||||
|
||||
# Database
|
||||
db_host: str
|
||||
@@ -75,6 +92,206 @@ def _parse_int(raw_value: str, default: int) -> int:
|
||||
return int(value)
|
||||
|
||||
|
||||
def _parse_strict_bool(
|
||||
raw_value: str,
|
||||
*,
|
||||
name: str,
|
||||
default: bool,
|
||||
) -> bool:
|
||||
value = (raw_value or "").strip().lower()
|
||||
|
||||
if not value:
|
||||
return default
|
||||
|
||||
if value in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
|
||||
if value in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
|
||||
raise ValueError(
|
||||
f"{name} must be a boolean value"
|
||||
)
|
||||
|
||||
|
||||
def _parse_positive_float(
|
||||
raw_value: str,
|
||||
*,
|
||||
name: str,
|
||||
default: float,
|
||||
) -> float:
|
||||
value = (raw_value or "").strip()
|
||||
|
||||
try:
|
||||
result = (
|
||||
float(value)
|
||||
if value
|
||||
else float(default)
|
||||
)
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"{name} must be a number"
|
||||
) from error
|
||||
|
||||
if not math.isfinite(result) or result <= 0:
|
||||
raise ValueError(
|
||||
f"{name} must be positive and finite"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_positive_int(
|
||||
raw_value: str,
|
||||
*,
|
||||
name: str,
|
||||
default: int,
|
||||
) -> int:
|
||||
value = (raw_value or "").strip()
|
||||
|
||||
try:
|
||||
result = (
|
||||
int(value)
|
||||
if value
|
||||
else int(default)
|
||||
)
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"{name} must be an integer"
|
||||
) from error
|
||||
|
||||
if result <= 0:
|
||||
raise ValueError(
|
||||
f"{name} must be positive"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_trade_stream_symbols(
|
||||
raw_value: str,
|
||||
) -> tuple[str, ...]:
|
||||
value = (raw_value or "").strip()
|
||||
|
||||
if not value:
|
||||
raise RuntimeError(
|
||||
"TRADE_STREAM_SYMBOLS is required when "
|
||||
"TRADE_STREAM_ENABLED is true"
|
||||
)
|
||||
|
||||
raw_symbols = value.split(",")
|
||||
|
||||
if any(not symbol.strip() for symbol in raw_symbols):
|
||||
raise ValueError(
|
||||
"TRADE_STREAM_SYMBOLS must not contain empty symbols"
|
||||
)
|
||||
|
||||
return tuple(
|
||||
sorted(
|
||||
{
|
||||
symbol.strip()
|
||||
for symbol in raw_symbols
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _load_trade_stream_settings(
|
||||
*,
|
||||
exchange_base_url: str,
|
||||
) -> TradeStreamSettings:
|
||||
enabled = _parse_strict_bool(
|
||||
os.getenv("TRADE_STREAM_ENABLED", "false"),
|
||||
name="TRADE_STREAM_ENABLED",
|
||||
default=False,
|
||||
)
|
||||
|
||||
if not enabled:
|
||||
return TradeStreamSettings(
|
||||
enabled=False,
|
||||
websocket_url="",
|
||||
symbols=(),
|
||||
open_timeout_seconds=10.0,
|
||||
probe_timeout_seconds=20.0,
|
||||
close_timeout_seconds=10.0,
|
||||
heartbeat_timeout_seconds=30.0,
|
||||
scheduler_interval_seconds=5.0,
|
||||
recovery_window_ms=3_599_999,
|
||||
)
|
||||
|
||||
websocket_url = os.getenv(
|
||||
"TRADE_STREAM_WS_URL",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if not websocket_url:
|
||||
raise RuntimeError(
|
||||
"TRADE_STREAM_WS_URL is required when "
|
||||
"TRADE_STREAM_ENABLED is true"
|
||||
)
|
||||
|
||||
if not exchange_base_url:
|
||||
raise RuntimeError(
|
||||
"EXCHANGE_BASE_URL is required for Trade Stream Recovery"
|
||||
)
|
||||
|
||||
return TradeStreamSettings(
|
||||
enabled=True,
|
||||
websocket_url=websocket_url,
|
||||
symbols=_parse_trade_stream_symbols(
|
||||
os.getenv("TRADE_STREAM_SYMBOLS", ""),
|
||||
),
|
||||
open_timeout_seconds=_parse_positive_float(
|
||||
os.getenv(
|
||||
"TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
|
||||
"10",
|
||||
),
|
||||
name="TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
|
||||
default=10.0,
|
||||
),
|
||||
probe_timeout_seconds=_parse_positive_float(
|
||||
os.getenv(
|
||||
"TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
|
||||
"20",
|
||||
),
|
||||
name="TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
|
||||
default=20.0,
|
||||
),
|
||||
close_timeout_seconds=_parse_positive_float(
|
||||
os.getenv(
|
||||
"TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
|
||||
"10",
|
||||
),
|
||||
name="TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
|
||||
default=10.0,
|
||||
),
|
||||
heartbeat_timeout_seconds=_parse_positive_float(
|
||||
os.getenv(
|
||||
"TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
|
||||
"30",
|
||||
),
|
||||
name="TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
|
||||
default=30.0,
|
||||
),
|
||||
scheduler_interval_seconds=_parse_positive_float(
|
||||
os.getenv(
|
||||
"TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
|
||||
"5",
|
||||
),
|
||||
name="TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
|
||||
default=5.0,
|
||||
),
|
||||
recovery_window_ms=_parse_positive_int(
|
||||
os.getenv(
|
||||
"TRADE_STREAM_RECOVERY_WINDOW_MS",
|
||||
"3599999",
|
||||
),
|
||||
name="TRADE_STREAM_RECOVERY_WINDOW_MS",
|
||||
default=3_599_999,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# load all settings
|
||||
def load_settings() -> Settings:
|
||||
bot_token = os.getenv("BOT_TOKEN", "").strip()
|
||||
@@ -82,6 +299,11 @@ def load_settings() -> Settings:
|
||||
if not bot_token:
|
||||
raise RuntimeError("BOT_TOKEN is not set in app/.env")
|
||||
|
||||
exchange_base_url = os.getenv(
|
||||
"EXCHANGE_BASE_URL",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
return Settings(
|
||||
# Telegram
|
||||
bot_token=bot_token,
|
||||
@@ -99,7 +321,7 @@ def load_settings() -> Settings:
|
||||
# Exchange
|
||||
exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")),
|
||||
exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
|
||||
exchange_base_url=os.getenv("EXCHANGE_BASE_URL", "").strip(),
|
||||
exchange_base_url=exchange_base_url,
|
||||
exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(),
|
||||
exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(),
|
||||
exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(),
|
||||
@@ -107,6 +329,9 @@ def load_settings() -> Settings:
|
||||
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
||||
default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip()
|
||||
or "ETH/USD_LEVERAGE",
|
||||
trade_stream=_load_trade_stream_settings(
|
||||
exchange_base_url=exchange_base_url,
|
||||
),
|
||||
|
||||
# Database
|
||||
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
|
||||
@@ -114,4 +339,4 @@ def load_settings() -> Settings:
|
||||
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",
|
||||
db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot",
|
||||
db_password=os.getenv("DB_PASSWORD", "").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.core.config import Settings, load_settings
|
||||
from src.integrations.exchange.exceptions import (
|
||||
ExchangeConnectionError,
|
||||
ExchangeResponseError,
|
||||
@@ -15,8 +15,15 @@ from src.integrations.exchange.exceptions import (
|
||||
|
||||
|
||||
class ExchangeRestClient:
|
||||
def __init__(self) -> None:
|
||||
self.settings = load_settings()
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings | None = None,
|
||||
) -> None:
|
||||
self.settings = (
|
||||
settings
|
||||
if settings is not None
|
||||
else load_settings()
|
||||
)
|
||||
if not self.settings.exchange_base_url:
|
||||
raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.")
|
||||
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
||||
@@ -133,4 +140,4 @@ class ExchangeRestClient:
|
||||
if not isinstance(payload, dict):
|
||||
raise ExchangeResponseError("Exchange response is not a JSON object.")
|
||||
|
||||
return payload
|
||||
return payload
|
||||
|
||||
@@ -2,26 +2,14 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from src.bootstrap.application import run_application
|
||||
from src.bootstrap.app_factory import create_app
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# создаём bot + dispatcher
|
||||
bot, dispatcher = create_app()
|
||||
|
||||
# WebSocket stream временно отключён.
|
||||
# Причина: Dzengi Swagger содержит wss:/api/v1/* endpoints,
|
||||
# но runtime probe не нашёл endpoint с WebSocket Upgrade 101.
|
||||
#
|
||||
# Когда Dzengi подтвердит рабочий WS endpoint,
|
||||
# можно будет вернуть запуск:
|
||||
#
|
||||
# from src.integrations.exchange.market_stream import start_market_stream
|
||||
# market_stream_task = asyncio.create_task(start_market_stream())
|
||||
|
||||
# запускаем Telegram polling
|
||||
await dispatcher.start_polling(bot)
|
||||
application = create_app()
|
||||
await run_application(application)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
WebSocketControlMessageError,
|
||||
WebSocketMessageRoutingError,
|
||||
)
|
||||
|
||||
|
||||
_TRADE_SUBSCRIPTION_DESTINATION = "trades.subscribe"
|
||||
_SUCCESS_STATUS = "OK"
|
||||
|
||||
|
||||
class DzengiWebSocketControlMessageHandler:
|
||||
"""
|
||||
Provider-specific проверка Dzengi control/ACK сообщений.
|
||||
|
||||
Handler не управляет lifecycle и не хранит pending requests.
|
||||
Ожидаемый correlation ID передаётся владельцем Runtime.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def handle(
|
||||
self,
|
||||
document: object,
|
||||
*,
|
||||
expected_correlation_id: str,
|
||||
) -> None:
|
||||
"""Проверить ACK текущей Trade Stream подписки."""
|
||||
if not isinstance(document, dict):
|
||||
raise WebSocketMessageRoutingError(
|
||||
"Control message Dzengi WebSocket должен быть объектом."
|
||||
)
|
||||
|
||||
correlation_id = document.get("correlationId")
|
||||
|
||||
if correlation_id != expected_correlation_id:
|
||||
raise WebSocketMessageRoutingError(
|
||||
"Получен control message с неизвестным correlationId."
|
||||
)
|
||||
|
||||
status = document.get("status")
|
||||
|
||||
if not isinstance(status, str) or not status.strip():
|
||||
raise WebSocketMessageRoutingError(
|
||||
"Control message Dzengi WebSocket должен содержать "
|
||||
"непустой строковый status."
|
||||
)
|
||||
|
||||
if status != _SUCCESS_STATUS:
|
||||
raise WebSocketControlMessageError(
|
||||
"Dzengi отклонил Trade Stream subscription ACK."
|
||||
)
|
||||
|
||||
if (
|
||||
document.get("destination")
|
||||
!= _TRADE_SUBSCRIPTION_DESTINATION
|
||||
):
|
||||
raise WebSocketMessageRoutingError(
|
||||
"Получен успешный control message с неизвестным "
|
||||
"destination."
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
WebSocketMessageRoutingError,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_inbound_message import (
|
||||
WebSocketInboundMessageKind,
|
||||
)
|
||||
|
||||
|
||||
_DZENGI_MARKET_DESTINATIONS = frozenset(
|
||||
{
|
||||
"internal.trade",
|
||||
"ohlc.event",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DzengiWebSocketInboundMessageClassifier:
|
||||
"""
|
||||
Классификатор декодированных входящих сообщений Dzengi.
|
||||
|
||||
Market markers имеют приоритет над correlationId, чтобы market
|
||||
document с дополнительными transport metadata не был ошибочно
|
||||
принят за control response.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def classify(
|
||||
self,
|
||||
document: object,
|
||||
) -> WebSocketInboundMessageKind:
|
||||
"""Разделить market documents и ответы на Runtime-команды."""
|
||||
if not isinstance(document, dict):
|
||||
raise WebSocketMessageRoutingError(
|
||||
"Сообщение Dzengi WebSocket должно быть объектом."
|
||||
)
|
||||
|
||||
destination = document.get("destination")
|
||||
|
||||
if (
|
||||
(
|
||||
isinstance(destination, str)
|
||||
and destination in _DZENGI_MARKET_DESTINATIONS
|
||||
)
|
||||
or "Payload" in document
|
||||
):
|
||||
return WebSocketInboundMessageKind.MARKET
|
||||
|
||||
if "correlationId" in document:
|
||||
self._validate_correlation_id(
|
||||
document["correlationId"],
|
||||
)
|
||||
return WebSocketInboundMessageKind.CONTROL
|
||||
|
||||
raise WebSocketMessageRoutingError(
|
||||
"Не удалось определить категорию сообщения "
|
||||
"Dzengi WebSocket."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_correlation_id(
|
||||
correlation_id: object,
|
||||
) -> None:
|
||||
if (
|
||||
isinstance(correlation_id, bool)
|
||||
or not isinstance(correlation_id, (str, int))
|
||||
):
|
||||
raise WebSocketMessageRoutingError(
|
||||
"correlationId сообщения Dzengi WebSocket должен "
|
||||
"быть строкой или целым числом."
|
||||
)
|
||||
|
||||
if (
|
||||
isinstance(correlation_id, str)
|
||||
and not correlation_id.strip()
|
||||
):
|
||||
raise WebSocketMessageRoutingError(
|
||||
"correlationId сообщения Dzengi WebSocket "
|
||||
"не должен быть пустым."
|
||||
)
|
||||
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Protocol, cast
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from websockets.asyncio.client import connect as websocket_connect
|
||||
from websockets.protocol import State
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
WebSocketTransportError,
|
||||
WebSocketTransportNotConnectedError,
|
||||
)
|
||||
|
||||
|
||||
class _WebSocketConnectionProtocol(Protocol):
|
||||
"""
|
||||
Минимальный контракт соединения.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""Вернуть состояние WebSocket-соединения."""
|
||||
...
|
||||
|
||||
async def close(
|
||||
self,
|
||||
code: int = 1000,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Закрыть WebSocket-соединение."""
|
||||
...
|
||||
|
||||
async def send(
|
||||
self,
|
||||
message: str | bytes,
|
||||
) -> None:
|
||||
"""Отправить одно сообщение."""
|
||||
...
|
||||
|
||||
async def recv(self) -> str | bytes:
|
||||
"""Получить одно сообщение."""
|
||||
...
|
||||
|
||||
async def ping(self) -> Awaitable[float]:
|
||||
"""Отправить Ping и вернуть ожидание соответствующего Pong."""
|
||||
...
|
||||
|
||||
|
||||
WebSocketConnector = Callable[
|
||||
...,
|
||||
Awaitable[_WebSocketConnectionProtocol],
|
||||
]
|
||||
|
||||
|
||||
def build_dzengi_websocket_url(
|
||||
raw_url: str,
|
||||
) -> str:
|
||||
"""
|
||||
Нормализовать Dzengi HTTP/WebSocket URL до endpoint ``/connect``.
|
||||
"""
|
||||
if not isinstance(raw_url, str):
|
||||
raise TypeError("raw_url must be a string")
|
||||
|
||||
normalized_url = raw_url.strip()
|
||||
|
||||
if not normalized_url:
|
||||
raise ValueError("raw_url must not be empty")
|
||||
|
||||
parsed = urlsplit(normalized_url)
|
||||
|
||||
scheme = {
|
||||
"http": "ws",
|
||||
"https": "wss",
|
||||
"ws": "ws",
|
||||
"wss": "wss",
|
||||
}.get(parsed.scheme.lower())
|
||||
|
||||
if scheme is None or not parsed.netloc:
|
||||
raise ValueError(
|
||||
"raw_url must be an absolute HTTP or WebSocket URL"
|
||||
)
|
||||
|
||||
if parsed.fragment:
|
||||
raise ValueError("raw_url must not contain a fragment")
|
||||
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
if not path.endswith("/connect"):
|
||||
path = f"{path}/connect"
|
||||
|
||||
return urlunsplit(
|
||||
(
|
||||
scheme,
|
||||
parsed.netloc,
|
||||
path,
|
||||
parsed.query,
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class DzengiWebSocketTransport:
|
||||
"""
|
||||
Низкоуровневый WebSocket-транспорт Dzengi.
|
||||
|
||||
Transport управляет только соединением и непрозрачными
|
||||
``str``/``bytes`` сообщениями. JSON parsing, subscription routing,
|
||||
recovery и lifecycle находятся за пределами этого компонента.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_url",
|
||||
"_headers",
|
||||
"_open_timeout",
|
||||
"_ping_interval",
|
||||
"_ping_timeout",
|
||||
"_probe_timeout",
|
||||
"_close_timeout",
|
||||
"_connector",
|
||||
"_connection",
|
||||
"_lifecycle_lock",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
open_timeout: float | None = 10.0,
|
||||
ping_interval: float | None = 20.0,
|
||||
ping_timeout: float | None = 20.0,
|
||||
probe_timeout: float | None = None,
|
||||
close_timeout: float | None = 10.0,
|
||||
connector: WebSocketConnector | None = None,
|
||||
) -> None:
|
||||
self._url = build_dzengi_websocket_url(url)
|
||||
self._headers = self._validate_headers(headers)
|
||||
self._open_timeout = self._validate_timeout(
|
||||
"open_timeout",
|
||||
open_timeout,
|
||||
)
|
||||
self._ping_interval = self._validate_timeout(
|
||||
"ping_interval",
|
||||
ping_interval,
|
||||
)
|
||||
self._ping_timeout = self._validate_timeout(
|
||||
"ping_timeout",
|
||||
ping_timeout,
|
||||
)
|
||||
self._probe_timeout = self._validate_probe_timeout(
|
||||
(
|
||||
probe_timeout
|
||||
if probe_timeout is not None
|
||||
else (
|
||||
ping_timeout
|
||||
if ping_timeout is not None
|
||||
else 20.0
|
||||
)
|
||||
),
|
||||
)
|
||||
self._close_timeout = self._validate_timeout(
|
||||
"close_timeout",
|
||||
close_timeout,
|
||||
)
|
||||
self._connector = (
|
||||
connector
|
||||
if connector is not None
|
||||
else cast(
|
||||
WebSocketConnector,
|
||||
websocket_connect,
|
||||
)
|
||||
)
|
||||
self._connection: _WebSocketConnectionProtocol | None = None
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Вернуть нормализованный WebSocket URL."""
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Показывает, открыто ли соединение."""
|
||||
connection = self._connection
|
||||
|
||||
return (
|
||||
connection is not None
|
||||
and connection.state is State.OPEN
|
||||
)
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""
|
||||
Идемпотентно открыть WebSocket-соединение Dzengi.
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
if self.is_connected:
|
||||
return
|
||||
|
||||
self._connection = None
|
||||
|
||||
try:
|
||||
connection = await self._connector(
|
||||
self._url,
|
||||
additional_headers=(
|
||||
self._headers
|
||||
if self._headers
|
||||
else None
|
||||
),
|
||||
subprotocols=(
|
||||
Subprotocol("json"),
|
||||
),
|
||||
open_timeout=self._open_timeout,
|
||||
ping_interval=self._ping_interval,
|
||||
ping_timeout=self._ping_timeout,
|
||||
close_timeout=self._close_timeout,
|
||||
)
|
||||
except Exception as error:
|
||||
raise WebSocketTransportError(
|
||||
"Не удалось открыть Dzengi WebSocket connection: "
|
||||
f"{error}"
|
||||
) from error
|
||||
|
||||
self._connection = connection
|
||||
|
||||
if self.is_connected:
|
||||
return
|
||||
|
||||
self._connection = None
|
||||
|
||||
try:
|
||||
await connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise WebSocketTransportError(
|
||||
"Dzengi WebSocket connection не перешло "
|
||||
"в состояние OPEN."
|
||||
)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""
|
||||
Идемпотентно закрыть WebSocket-соединение.
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
connection = self._connection
|
||||
self._connection = None
|
||||
|
||||
if connection is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await connection.close()
|
||||
except Exception as error:
|
||||
raise WebSocketTransportError(
|
||||
"Не удалось закрыть Dzengi WebSocket connection: "
|
||||
f"{error}"
|
||||
) from error
|
||||
|
||||
async def send(
|
||||
self,
|
||||
message: str | bytes,
|
||||
) -> None:
|
||||
"""Отправить непрозрачное транспортное сообщение."""
|
||||
if not isinstance(message, (str, bytes)):
|
||||
raise TypeError("message must be str or bytes")
|
||||
|
||||
connection = self._require_connection()
|
||||
|
||||
try:
|
||||
await connection.send(message)
|
||||
except Exception as error:
|
||||
self._discard_closed_connection(connection)
|
||||
|
||||
raise WebSocketTransportError(
|
||||
"Не удалось отправить Dzengi WebSocket message: "
|
||||
f"{error}"
|
||||
) from error
|
||||
|
||||
async def receive(self) -> str | bytes:
|
||||
"""Получить непрозрачное транспортное сообщение."""
|
||||
connection = self._require_connection()
|
||||
|
||||
try:
|
||||
return await connection.recv()
|
||||
except Exception as error:
|
||||
self._discard_closed_connection(connection)
|
||||
|
||||
raise WebSocketTransportError(
|
||||
"Не удалось получить Dzengi WebSocket message: "
|
||||
f"{error}"
|
||||
) from error
|
||||
|
||||
async def probe(self) -> bool:
|
||||
"""
|
||||
Подтвердить liveness текущего connection через Ping/Pong.
|
||||
|
||||
Закрытое соединение и отсутствие Pong являются штатным
|
||||
отрицательным результатом. Cancellation не перехватывается.
|
||||
"""
|
||||
connection = self._connection
|
||||
|
||||
if (
|
||||
connection is None
|
||||
or connection.state is not State.OPEN
|
||||
):
|
||||
self._connection = None
|
||||
return False
|
||||
|
||||
try:
|
||||
pong_waiter = await connection.ping()
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(
|
||||
pong_waiter,
|
||||
),
|
||||
timeout=self._probe_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self._discard_closed_connection(connection)
|
||||
|
||||
if connection.state is not State.OPEN:
|
||||
return False
|
||||
|
||||
raise WebSocketTransportError(
|
||||
"Не удалось проверить Dzengi WebSocket liveness: "
|
||||
f"{error}"
|
||||
) from error
|
||||
|
||||
return (
|
||||
self._connection is connection
|
||||
and connection.state is State.OPEN
|
||||
)
|
||||
|
||||
def _require_connection(
|
||||
self,
|
||||
) -> _WebSocketConnectionProtocol:
|
||||
"""
|
||||
Вернуть открытое соединение.
|
||||
"""
|
||||
connection = self._connection
|
||||
|
||||
if (
|
||||
connection is None
|
||||
or connection.state is not State.OPEN
|
||||
):
|
||||
self._connection = None
|
||||
|
||||
raise WebSocketTransportNotConnectedError(
|
||||
"Dzengi WebSocket transport is not connected."
|
||||
)
|
||||
|
||||
return connection
|
||||
|
||||
def _discard_closed_connection(
|
||||
self,
|
||||
connection: _WebSocketConnectionProtocol,
|
||||
) -> None:
|
||||
"""
|
||||
Удалить ссылку на закрытое соединение.
|
||||
"""
|
||||
if (
|
||||
self._connection is connection
|
||||
and connection.state is not State.OPEN
|
||||
):
|
||||
self._connection = None
|
||||
|
||||
@staticmethod
|
||||
def _validate_headers(
|
||||
headers: Mapping[str, str] | None,
|
||||
) -> dict[str, str]:
|
||||
if headers is None:
|
||||
return {}
|
||||
|
||||
if not isinstance(headers, Mapping):
|
||||
raise TypeError("headers must be a mapping")
|
||||
|
||||
normalized_headers: dict[str, str] = {}
|
||||
|
||||
for name, value in headers.items():
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError(
|
||||
"header names must be non-empty strings"
|
||||
)
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise TypeError("header values must be strings")
|
||||
|
||||
normalized_headers[name] = value
|
||||
|
||||
return normalized_headers
|
||||
|
||||
@staticmethod
|
||||
def _validate_timeout(
|
||||
name: str,
|
||||
value: float | None,
|
||||
) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
):
|
||||
raise TypeError(
|
||||
f"{name} must be an integer, float, or None"
|
||||
)
|
||||
|
||||
if value <= 0:
|
||||
raise ValueError(
|
||||
f"{name} must be positive"
|
||||
)
|
||||
|
||||
return float(value)
|
||||
|
||||
@staticmethod
|
||||
def _validate_probe_timeout(
|
||||
value: float,
|
||||
) -> float:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
):
|
||||
raise TypeError(
|
||||
"probe_timeout must be an integer or float"
|
||||
)
|
||||
|
||||
normalized_value = float(value)
|
||||
|
||||
if (
|
||||
not math.isfinite(normalized_value)
|
||||
or normalized_value <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
"probe_timeout must be positive and finite"
|
||||
)
|
||||
|
||||
return normalized_value
|
||||
@@ -153,4 +153,31 @@ class TradeFeedRegistryError(MarketDataAcquisitionError):
|
||||
# Ошибка определения типа входящего WebSocket-сообщения
|
||||
# и выбора специализированного адаптера.
|
||||
class WebSocketMessageRoutingError(MarketDataAcquisitionError):
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка декодирования сырого WebSocket-сообщения.
|
||||
class WebSocketMessageDecodeError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка обработки control/ACK сообщения WebSocket-провайдера.
|
||||
class WebSocketControlMessageError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка низкоуровневой WebSocket-инфраструктуры Acquisition Runtime.
|
||||
class WebSocketTransportError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Попытка использовать WebSocket-транспорт без открытого соединения.
|
||||
class WebSocketTransportNotConnectedError(WebSocketTransportError):
|
||||
pass
|
||||
|
||||
|
||||
# WebSocket-провайдер не поддерживает транспортную операцию unsubscribe.
|
||||
class WebSocketUnsubscribeNotSupportedError(
|
||||
MarketDataAcquisitionError,
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from src.market_data.acquisition.runtime.runtime_events import (
|
||||
ConnectedEvent,
|
||||
ConnectFailedEvent,
|
||||
DisconnectedEvent,
|
||||
HeartbeatTimeoutEvent,
|
||||
MessageReceivedEvent,
|
||||
MessageSentEvent,
|
||||
ReconnectCompletedEvent,
|
||||
ReconnectFailedEvent,
|
||||
ReconnectStartedEvent,
|
||||
TransportMessage,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeEvent,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AcquisitionRuntimeEventLoggingConsumer:
|
||||
"""
|
||||
Безопасно записывает Acquisition Runtime Events в standard logging.
|
||||
|
||||
Содержимое transport payload намеренно не журналируется. Для
|
||||
MessageReceivedEvent и MessageSentEvent фиксируются только тип
|
||||
сообщения и размер payload.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_logger",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_logger: logging.Logger = logger,
|
||||
) -> None:
|
||||
if not isinstance(event_logger, logging.Logger):
|
||||
raise TypeError(
|
||||
"event_logger must be a logging.Logger"
|
||||
)
|
||||
|
||||
self._logger = event_logger
|
||||
|
||||
async def consume(
|
||||
self,
|
||||
event: AcquisitionRuntimeEvent,
|
||||
) -> None:
|
||||
"""Записать одно Acquisition Runtime Event."""
|
||||
if isinstance(event, ConnectedEvent):
|
||||
self._logger.info(
|
||||
"Acquisition WebSocket connected."
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, DisconnectedEvent):
|
||||
self._logger.info(
|
||||
"Acquisition WebSocket disconnected."
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, ConnectFailedEvent):
|
||||
self._logger.error(
|
||||
"Acquisition WebSocket connection failed: %s",
|
||||
event.reason,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, MessageReceivedEvent):
|
||||
self._log_transport_message(
|
||||
direction="received",
|
||||
message=event.message,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, MessageSentEvent):
|
||||
self._log_transport_message(
|
||||
direction="sent",
|
||||
message=event.message,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, ReconnectStartedEvent):
|
||||
self._logger.info(
|
||||
"Acquisition WebSocket reconnect started: attempt=%d",
|
||||
event.attempt,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, ReconnectCompletedEvent):
|
||||
self._logger.info(
|
||||
"Acquisition WebSocket reconnect completed: attempt=%d",
|
||||
event.attempt,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, ReconnectFailedEvent):
|
||||
self._logger.error(
|
||||
"Acquisition WebSocket reconnect failed: "
|
||||
"attempt=%d reason=%s",
|
||||
event.attempt,
|
||||
event.reason,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, HeartbeatTimeoutEvent):
|
||||
self._logger.warning(
|
||||
"Acquisition WebSocket heartbeat timeout: "
|
||||
"timeout_seconds=%s",
|
||||
event.timeout_seconds,
|
||||
)
|
||||
return
|
||||
|
||||
raise TypeError(
|
||||
"event must be an AcquisitionRuntimeEvent"
|
||||
)
|
||||
|
||||
def _log_transport_message(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
message: TransportMessage,
|
||||
) -> None:
|
||||
self._logger.debug(
|
||||
"Acquisition WebSocket message %s: "
|
||||
"message_type=%s payload_size=%d",
|
||||
direction,
|
||||
type(message).__name__,
|
||||
len(message.payload),
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from contextvars import ContextVar
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeEvent,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_active_publisher_ids: ContextVar[tuple[int, ...]] = ContextVar(
|
||||
"active_acquisition_runtime_event_publisher_ids",
|
||||
default=(),
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AcquisitionRuntimeEventConsumerProtocol(Protocol):
|
||||
"""
|
||||
Контракт одного потребителя Acquisition Runtime Events.
|
||||
|
||||
Consumer обрабатывает уже произошедший инфраструктурный факт
|
||||
и не управляет lifecycle Publisher.
|
||||
"""
|
||||
|
||||
async def consume(
|
||||
self,
|
||||
event: AcquisitionRuntimeEvent,
|
||||
) -> None:
|
||||
"""Обработать одно Acquisition Runtime Event."""
|
||||
...
|
||||
|
||||
|
||||
class AcquisitionRuntimeEventPublisher:
|
||||
"""
|
||||
Последовательный in-process Publisher Acquisition Runtime Events.
|
||||
|
||||
Один вызов publish() полностью доставляет событие всем Consumer
|
||||
в порядке их регистрации. Параллельные вызовы сериализуются одним
|
||||
asyncio.Lock.
|
||||
|
||||
Publisher не создаёт фоновых задач, не хранит очередь и не управляет
|
||||
lifecycle Consumer.
|
||||
|
||||
Обычная ошибка одного Consumer журналируется и не препятствует
|
||||
доставке остальным Consumer. Сигналы отмены и другие BaseException
|
||||
не перехватываются.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_consumers",
|
||||
"_publish_lock",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
consumers: Iterable[
|
||||
AcquisitionRuntimeEventConsumerProtocol
|
||||
] = (),
|
||||
) -> None:
|
||||
registered_consumers = tuple(consumers)
|
||||
|
||||
for consumer in registered_consumers:
|
||||
if not isinstance(
|
||||
consumer,
|
||||
AcquisitionRuntimeEventConsumerProtocol,
|
||||
):
|
||||
raise TypeError(
|
||||
"consumer must satisfy "
|
||||
"AcquisitionRuntimeEventConsumerProtocol"
|
||||
)
|
||||
|
||||
self._consumers = registered_consumers
|
||||
self._publish_lock = asyncio.Lock()
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
event: AcquisitionRuntimeEvent,
|
||||
) -> None:
|
||||
"""
|
||||
Последовательно доставить событие всем Consumer.
|
||||
|
||||
Рекурсивная публикация через тот же Publisher запрещена.
|
||||
Такое ограничение предотвращает deadlock на publish lock.
|
||||
"""
|
||||
if not isinstance(event, AcquisitionRuntimeEvent):
|
||||
raise TypeError(
|
||||
"event must be an AcquisitionRuntimeEvent"
|
||||
)
|
||||
|
||||
publisher_id = id(self)
|
||||
active_publisher_ids = _active_publisher_ids.get()
|
||||
|
||||
if publisher_id in active_publisher_ids:
|
||||
raise RuntimeError(
|
||||
"Recursive publication through the same "
|
||||
"AcquisitionRuntimeEventPublisher is not supported."
|
||||
)
|
||||
|
||||
active_token = _active_publisher_ids.set(
|
||||
(*active_publisher_ids, publisher_id)
|
||||
)
|
||||
|
||||
try:
|
||||
async with self._publish_lock:
|
||||
for consumer in self._consumers:
|
||||
try:
|
||||
await consumer.consume(event)
|
||||
except Exception as error:
|
||||
self._report_consumer_error(
|
||||
event=event,
|
||||
consumer=consumer,
|
||||
error=error,
|
||||
)
|
||||
finally:
|
||||
_active_publisher_ids.reset(active_token)
|
||||
|
||||
@staticmethod
|
||||
def _report_consumer_error(
|
||||
*,
|
||||
event: AcquisitionRuntimeEvent,
|
||||
consumer: AcquisitionRuntimeEventConsumerProtocol,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
"""
|
||||
Безопасно зафиксировать ошибку Consumer.
|
||||
|
||||
Текст исключения и traceback намеренно не журналируются:
|
||||
они могут содержать repr(event) с transport payload.
|
||||
|
||||
Ошибка logging handler не должна прерывать Runtime lifecycle
|
||||
или доставку события остальным Consumer.
|
||||
"""
|
||||
try:
|
||||
logger.error(
|
||||
"Acquisition Runtime Event consumer failed: "
|
||||
"event=%s consumer=%s error_type=%s",
|
||||
type(event).__name__,
|
||||
type(consumer).__name__,
|
||||
type(error).__name__,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
118
app/src/market_data/acquisition/runtime/live_processing_gate.py
Normal file
118
app/src/market_data/acquisition/runtime/live_processing_gate.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import TracebackType
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeLiveProcessingGateProtocol(Protocol):
|
||||
"""
|
||||
Контракт общей границы Live processing и Runtime Recovery.
|
||||
"""
|
||||
|
||||
@property
|
||||
def locked(self) -> bool:
|
||||
"""Показывает, закрыт ли gate текущей операцией."""
|
||||
...
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
"""Показывает, запрещено ли продолжать Live processing."""
|
||||
...
|
||||
|
||||
def fail(
|
||||
self,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
"""Запретить Live processing после terminal Runtime error."""
|
||||
...
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Разрешить Live processing для нового Runtime lifecycle."""
|
||||
...
|
||||
|
||||
async def __aenter__(
|
||||
self,
|
||||
) -> RuntimeLiveProcessingGateProtocol:
|
||||
"""Закрыть gate и дождаться эксклюзивного доступа."""
|
||||
...
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
"""Открыть gate после завершения защищённой операции."""
|
||||
...
|
||||
|
||||
|
||||
class RuntimeLiveProcessingGate:
|
||||
"""
|
||||
Единый asyncio gate для Live processing и Recovery.
|
||||
|
||||
Gate не содержит market state и не создаёт фоновые задачи.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_lock",
|
||||
"_failure",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._failure: Exception | None = None
|
||||
|
||||
@property
|
||||
def locked(self) -> bool:
|
||||
"""Показывает, закрыт ли gate текущей операцией."""
|
||||
return self._lock.locked()
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
"""Показывает, запрещено ли продолжать Live processing."""
|
||||
return self._failure is not None
|
||||
|
||||
def fail(
|
||||
self,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
"""Запретить Live processing после terminal Runtime error."""
|
||||
if not isinstance(error, Exception):
|
||||
raise TypeError("error must be an Exception")
|
||||
|
||||
self._failure = error
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Разрешить Live processing для нового Runtime lifecycle."""
|
||||
if self._lock.locked():
|
||||
raise RuntimeError(
|
||||
"Live processing gate cannot be reset while locked."
|
||||
)
|
||||
|
||||
self._failure = None
|
||||
|
||||
async def __aenter__(
|
||||
self,
|
||||
) -> RuntimeLiveProcessingGate:
|
||||
"""Закрыть gate и дождаться эксклюзивного доступа."""
|
||||
await self._lock.acquire()
|
||||
|
||||
failure = self._failure
|
||||
|
||||
if failure is not None:
|
||||
self._lock.release()
|
||||
raise failure
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
"""Открыть gate после завершения защищённой операции."""
|
||||
self._lock.release()
|
||||
@@ -7,6 +7,7 @@ from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.runtime.runtime_commands import (
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_events import (
|
||||
ReconnectCompletedEvent,
|
||||
@@ -73,7 +74,7 @@ class ReconnectCoordinator:
|
||||
|
||||
- увеличивает номер попытки;
|
||||
- публикует ReconnectStartedEvent;
|
||||
- передаёт ConnectCommand в Runtime Dispatcher;
|
||||
- передаёт DisconnectCommand и ConnectCommand в Runtime Dispatcher;
|
||||
- восстанавливает зарегистрированные подписки;
|
||||
- публикует ReconnectCompletedEvent;
|
||||
- при ошибке публикует ReconnectFailedEvent;
|
||||
@@ -132,6 +133,8 @@ class ReconnectCoordinator:
|
||||
|
||||
ReconnectStartedEvent
|
||||
↓
|
||||
DisconnectCommand
|
||||
↓
|
||||
ConnectCommand
|
||||
↓
|
||||
restore_subscriptions()
|
||||
@@ -155,6 +158,10 @@ class ReconnectCoordinator:
|
||||
)
|
||||
|
||||
try:
|
||||
await self._command_dispatcher.dispatch(
|
||||
DisconnectCommand()
|
||||
)
|
||||
|
||||
await self._command_dispatcher.dispatch(
|
||||
ConnectCommand()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeLivenessProbeProtocol(Protocol):
|
||||
"""
|
||||
Контракт активной проверки transport liveness.
|
||||
|
||||
Успешная проверка подтверждается transport-level ответом, а не
|
||||
наличием market events.
|
||||
"""
|
||||
|
||||
async def probe(self) -> bool:
|
||||
"""
|
||||
Проверить активность текущего transport connection.
|
||||
|
||||
Вернуть True только после подтверждённого transport response.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||
TradeRecoveryResult,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.live_processing_gate import (
|
||||
RuntimeLiveProcessingGateProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.reconnect import (
|
||||
ReconnectCoordinatorProtocol,
|
||||
ReconnectState,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_recovery_protocol import (
|
||||
RuntimeRecoveryProtocol,
|
||||
)
|
||||
|
||||
|
||||
RuntimeUnixTimeMillisecondsClock = Callable[[], int]
|
||||
|
||||
|
||||
def system_unix_time_ms() -> int:
|
||||
"""Вернуть текущее Unix time без промежуточного float."""
|
||||
return time.time_ns() // 1_000_000
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeReconnectRecoveryProtocol(
|
||||
ReconnectCoordinatorProtocol,
|
||||
Protocol,
|
||||
):
|
||||
"""
|
||||
Контракт единой reconnect → restore → recovery операции.
|
||||
"""
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
"""Вернуть поколение последней начатой операции."""
|
||||
...
|
||||
|
||||
@property
|
||||
def live_processing_gate(
|
||||
self,
|
||||
) -> RuntimeLiveProcessingGateProtocol:
|
||||
"""Вернуть общий gate Live processing и Recovery."""
|
||||
...
|
||||
|
||||
@property
|
||||
def symbols(self) -> tuple[str, ...]:
|
||||
"""Вернуть нормализованные Recovery symbols."""
|
||||
...
|
||||
|
||||
async def reconnect_after_transport_failure(
|
||||
self,
|
||||
*,
|
||||
observed_generation: int,
|
||||
) -> None:
|
||||
"""
|
||||
Восстановить connection, если ошибка относится к текущему
|
||||
поколению.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class _ReconnectRecoveryOperation:
|
||||
__slots__ = (
|
||||
"source_generation",
|
||||
"completion",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source_generation: int,
|
||||
completion: asyncio.Future[None],
|
||||
) -> None:
|
||||
self.source_generation = source_generation
|
||||
self.completion = completion
|
||||
|
||||
|
||||
class RuntimeReconnectRecoveryCoordinator:
|
||||
"""
|
||||
Single-flight координатор reconnect → restore → recovery.
|
||||
|
||||
Существующий ReconnectCoordinator сохраняет ответственность только
|
||||
за Disconnect → Connect → restore subscriptions. Этот компонент
|
||||
закрывает общий Live processing gate и после reconnect выполняет
|
||||
синхронный Runtime Recovery вне event loop.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_reconnect_coordinator",
|
||||
"_recovery_coordinator",
|
||||
"_live_processing_gate",
|
||||
"_symbols",
|
||||
"_clock",
|
||||
"_coordination_lock",
|
||||
"_generation",
|
||||
"_active_operation",
|
||||
"_last_operation",
|
||||
"_recovery_task",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
reconnect_coordinator: ReconnectCoordinatorProtocol,
|
||||
recovery_coordinator: RuntimeRecoveryProtocol,
|
||||
live_processing_gate: RuntimeLiveProcessingGateProtocol,
|
||||
symbols: tuple[str, ...],
|
||||
clock: RuntimeUnixTimeMillisecondsClock = system_unix_time_ms,
|
||||
) -> None:
|
||||
if not callable(clock):
|
||||
raise TypeError("clock must be callable")
|
||||
|
||||
self._reconnect_coordinator = reconnect_coordinator
|
||||
self._recovery_coordinator = recovery_coordinator
|
||||
self._live_processing_gate = live_processing_gate
|
||||
self._symbols = self._normalize_symbols(symbols)
|
||||
self._clock = clock
|
||||
|
||||
self._coordination_lock = asyncio.Lock()
|
||||
self._generation = 0
|
||||
self._active_operation: _ReconnectRecoveryOperation | None = None
|
||||
self._last_operation: _ReconnectRecoveryOperation | None = None
|
||||
self._recovery_task: (
|
||||
asyncio.Task[tuple[TradeRecoveryResult, ...]] | None
|
||||
) = None
|
||||
|
||||
@property
|
||||
def state(self) -> ReconnectState:
|
||||
"""Вернуть состояние базовой reconnect-операции."""
|
||||
return self._reconnect_coordinator.state
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
"""Вернуть номер последней базовой reconnect-попытки."""
|
||||
return self._reconnect_coordinator.attempt
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
"""Вернуть поколение последней начатой операции."""
|
||||
return self._generation
|
||||
|
||||
@property
|
||||
def live_processing_gate(
|
||||
self,
|
||||
) -> RuntimeLiveProcessingGateProtocol:
|
||||
"""Вернуть общий gate Live processing и Recovery."""
|
||||
return self._live_processing_gate
|
||||
|
||||
@property
|
||||
def symbols(self) -> tuple[str, ...]:
|
||||
"""Вернуть нормализованные Recovery symbols."""
|
||||
return self._symbols
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
"""
|
||||
Выполнить либо присоединиться к текущей single-flight операции.
|
||||
"""
|
||||
await self._run_or_join(
|
||||
observed_generation=None,
|
||||
)
|
||||
|
||||
async def reconnect_after_transport_failure(
|
||||
self,
|
||||
*,
|
||||
observed_generation: int,
|
||||
) -> None:
|
||||
"""
|
||||
Обработать transport error конкретного connection generation.
|
||||
|
||||
Ошибка старого connection присоединяется к уже выполняемой
|
||||
операции либо использует её завершённый результат и не запускает
|
||||
второй reconnect.
|
||||
"""
|
||||
self._validate_generation(observed_generation)
|
||||
|
||||
await self._run_or_join(
|
||||
observed_generation=observed_generation,
|
||||
)
|
||||
|
||||
async def _run_or_join(
|
||||
self,
|
||||
*,
|
||||
observed_generation: int | None,
|
||||
) -> None:
|
||||
operation, is_leader = await self._select_operation(
|
||||
observed_generation=observed_generation,
|
||||
)
|
||||
|
||||
if operation is None:
|
||||
return
|
||||
|
||||
if not is_leader:
|
||||
await asyncio.shield(
|
||||
operation.completion,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await self._execute_operation()
|
||||
except asyncio.CancelledError:
|
||||
operation.completion.cancel()
|
||||
raise
|
||||
except BaseException as error:
|
||||
operation.completion.set_exception(error)
|
||||
operation.completion.exception()
|
||||
raise
|
||||
else:
|
||||
operation.completion.set_result(None)
|
||||
finally:
|
||||
if self._active_operation is operation:
|
||||
self._last_operation = operation
|
||||
self._active_operation = None
|
||||
|
||||
async def _select_operation(
|
||||
self,
|
||||
*,
|
||||
observed_generation: int | None,
|
||||
) -> tuple[_ReconnectRecoveryOperation | None, bool]:
|
||||
async with self._coordination_lock:
|
||||
active_operation = self._active_operation
|
||||
|
||||
if active_operation is not None:
|
||||
return (
|
||||
active_operation,
|
||||
False,
|
||||
)
|
||||
|
||||
if (
|
||||
observed_generation is not None
|
||||
and observed_generation != self._generation
|
||||
):
|
||||
last_operation = self._last_operation
|
||||
|
||||
if (
|
||||
last_operation is not None
|
||||
and last_operation.source_generation
|
||||
== observed_generation
|
||||
):
|
||||
return (
|
||||
last_operation,
|
||||
False,
|
||||
)
|
||||
|
||||
return (
|
||||
None,
|
||||
False,
|
||||
)
|
||||
|
||||
source_generation = self._generation
|
||||
self._generation += 1
|
||||
|
||||
operation = _ReconnectRecoveryOperation(
|
||||
source_generation=source_generation,
|
||||
completion=asyncio.get_running_loop().create_future(),
|
||||
)
|
||||
self._active_operation = operation
|
||||
|
||||
return (
|
||||
operation,
|
||||
True,
|
||||
)
|
||||
|
||||
async def _execute_operation(self) -> None:
|
||||
async with self._live_processing_gate:
|
||||
try:
|
||||
await self._reconnect_coordinator.reconnect()
|
||||
|
||||
recovery_end_time = self._clock()
|
||||
self._validate_recovery_end_time(
|
||||
recovery_end_time,
|
||||
)
|
||||
|
||||
recovery_task = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
self._recover_symbols,
|
||||
recovery_end_time,
|
||||
),
|
||||
name="trade-stream-runtime-recovery",
|
||||
)
|
||||
self._recovery_task = recovery_task
|
||||
|
||||
try:
|
||||
await asyncio.shield(
|
||||
recovery_task,
|
||||
)
|
||||
except asyncio.CancelledError as cancellation:
|
||||
try:
|
||||
await self._wait_for_recovery_completion(
|
||||
recovery_task,
|
||||
)
|
||||
except BaseException as recovery_error:
|
||||
cancellation.add_note(
|
||||
"Trade Stream Runtime Recovery also failed: "
|
||||
f"{type(recovery_error).__name__}."
|
||||
)
|
||||
|
||||
raise
|
||||
finally:
|
||||
if self._recovery_task is recovery_task:
|
||||
self._recovery_task = None
|
||||
except Exception as error:
|
||||
self._live_processing_gate.fail(error)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def _wait_for_recovery_completion(
|
||||
recovery_task: asyncio.Task[
|
||||
tuple[TradeRecoveryResult, ...]
|
||||
],
|
||||
) -> None:
|
||||
while not recovery_task.done():
|
||||
try:
|
||||
await asyncio.shield(
|
||||
recovery_task,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except BaseException:
|
||||
break
|
||||
|
||||
recovery_task.result()
|
||||
|
||||
def _recover_symbols(
|
||||
self,
|
||||
recovery_end_time: int,
|
||||
) -> tuple[TradeRecoveryResult, ...]:
|
||||
return tuple(
|
||||
self._recovery_coordinator.recover(
|
||||
symbol=symbol,
|
||||
recovery_end_time=recovery_end_time,
|
||||
)
|
||||
for symbol in self._symbols
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_symbols(
|
||||
symbols: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
if not isinstance(symbols, tuple):
|
||||
raise TypeError("symbols must be a tuple")
|
||||
|
||||
normalized_symbols: set[str] = set()
|
||||
|
||||
for symbol in symbols:
|
||||
if not isinstance(symbol, str):
|
||||
raise TypeError(
|
||||
"symbols must contain only strings"
|
||||
)
|
||||
|
||||
normalized_symbol = symbol.strip()
|
||||
|
||||
if normalized_symbol:
|
||||
normalized_symbols.add(normalized_symbol)
|
||||
|
||||
if not normalized_symbols:
|
||||
raise ValueError(
|
||||
"symbols must contain at least one non-empty symbol"
|
||||
)
|
||||
|
||||
return tuple(sorted(normalized_symbols))
|
||||
|
||||
@staticmethod
|
||||
def _validate_generation(
|
||||
generation: int,
|
||||
) -> None:
|
||||
if isinstance(generation, bool) or not isinstance(
|
||||
generation,
|
||||
int,
|
||||
):
|
||||
raise TypeError(
|
||||
"observed_generation must be an integer"
|
||||
)
|
||||
|
||||
if generation < 0:
|
||||
raise ValueError(
|
||||
"observed_generation must not be negative"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_recovery_end_time(
|
||||
recovery_end_time: int,
|
||||
) -> None:
|
||||
if isinstance(recovery_end_time, bool) or not isinstance(
|
||||
recovery_end_time,
|
||||
int,
|
||||
):
|
||||
raise TypeError(
|
||||
"clock must return an integer"
|
||||
)
|
||||
|
||||
if recovery_end_time < 0:
|
||||
raise ValueError(
|
||||
"clock must not return a negative value"
|
||||
)
|
||||
@@ -9,6 +9,9 @@ from typing import Protocol, runtime_checkable
|
||||
from src.market_data.acquisition.runtime.heartbeat import (
|
||||
HeartbeatMonitorProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_liveness_probe import (
|
||||
RuntimeLivenessProbeProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.supervisor import (
|
||||
RuntimeSupervisorProtocol,
|
||||
)
|
||||
@@ -37,7 +40,29 @@ class RuntimeSchedulerProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def start(self) -> None:
|
||||
@property
|
||||
def liveness_probe(self) -> RuntimeLivenessProbeProtocol:
|
||||
"""Вернуть используемый transport liveness probe."""
|
||||
...
|
||||
|
||||
@property
|
||||
def runtime_supervisor(self) -> RuntimeSupervisorProtocol:
|
||||
"""Вернуть Supervisor, получающий timeout."""
|
||||
...
|
||||
|
||||
def claim(self, owner: object) -> None:
|
||||
"""Закрепить Scheduler за единственным lifecycle owner."""
|
||||
...
|
||||
|
||||
def release(self, owner: object) -> None:
|
||||
"""Освободить Scheduler после завершения owned task."""
|
||||
...
|
||||
|
||||
async def start(
|
||||
self,
|
||||
*,
|
||||
owner: object | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Запустить scheduler loop.
|
||||
|
||||
@@ -66,6 +91,12 @@ class RuntimeScheduler:
|
||||
|
||||
Scheduler отвечает только за время выполнения проверок:
|
||||
|
||||
RuntimeLivenessProbe.probe()
|
||||
│
|
||||
├── True
|
||||
│ └── RuntimeSupervisor.notify_activity()
|
||||
│
|
||||
▼
|
||||
HeartbeatMonitor.check_timeout()
|
||||
│
|
||||
├── False
|
||||
@@ -85,20 +116,23 @@ class RuntimeScheduler:
|
||||
- retry policy;
|
||||
- публикацию Runtime Events.
|
||||
|
||||
Ошибки Heartbeat Monitor, Runtime Supervisor и sleep-функции
|
||||
распространяются вызывающему коду без обёртки.
|
||||
Ошибки liveness probe, Heartbeat Monitor, Runtime Supervisor и
|
||||
sleep-функции распространяются вызывающему коду без обёртки.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_liveness_probe",
|
||||
"_heartbeat_monitor",
|
||||
"_runtime_supervisor",
|
||||
"_interval_seconds",
|
||||
"_sleep",
|
||||
"_running",
|
||||
"_owner",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
liveness_probe: RuntimeLivenessProbeProtocol,
|
||||
heartbeat_monitor: HeartbeatMonitorProtocol,
|
||||
runtime_supervisor: RuntimeSupervisorProtocol,
|
||||
*,
|
||||
@@ -121,11 +155,13 @@ class RuntimeScheduler:
|
||||
if not callable(sleep):
|
||||
raise TypeError("sleep must be callable")
|
||||
|
||||
self._liveness_probe = liveness_probe
|
||||
self._heartbeat_monitor = heartbeat_monitor
|
||||
self._runtime_supervisor = runtime_supervisor
|
||||
self._interval_seconds = float(interval_seconds)
|
||||
self._sleep = sleep
|
||||
self._running = False
|
||||
self._owner: object | None = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
@@ -141,7 +177,57 @@ class RuntimeScheduler:
|
||||
"""
|
||||
return self._interval_seconds
|
||||
|
||||
async def start(self) -> None:
|
||||
@property
|
||||
def liveness_probe(self) -> RuntimeLivenessProbeProtocol:
|
||||
"""Вернуть используемый transport liveness probe."""
|
||||
return self._liveness_probe
|
||||
|
||||
@property
|
||||
def runtime_supervisor(self) -> RuntimeSupervisorProtocol:
|
||||
"""Вернуть Supervisor, получающий timeout."""
|
||||
return self._runtime_supervisor
|
||||
|
||||
def claim(self, owner: object) -> None:
|
||||
"""
|
||||
Атомарно закрепить Scheduler за lifecycle owner.
|
||||
|
||||
После claim только этот owner может запустить loop. Claim не
|
||||
создаёт asyncio-задачу и безопасен до длительного startup.
|
||||
"""
|
||||
if owner is None:
|
||||
raise TypeError("owner must not be None")
|
||||
|
||||
if self._owner is owner:
|
||||
return
|
||||
|
||||
if self._owner is not None or self._running:
|
||||
raise RuntimeError(
|
||||
"Runtime Scheduler is already owned or active."
|
||||
)
|
||||
|
||||
self._owner = owner
|
||||
|
||||
def release(self, owner: object) -> None:
|
||||
"""
|
||||
Освободить Scheduler после остановки owned loop.
|
||||
"""
|
||||
if self._owner is not owner:
|
||||
raise RuntimeError(
|
||||
"Runtime Scheduler ownership does not match."
|
||||
)
|
||||
|
||||
if self._running:
|
||||
raise RuntimeError(
|
||||
"Cannot release a running Runtime Scheduler."
|
||||
)
|
||||
|
||||
self._owner = None
|
||||
|
||||
async def start(
|
||||
self,
|
||||
*,
|
||||
owner: object | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Запустить периодический scheduler loop.
|
||||
|
||||
@@ -154,6 +240,16 @@ class RuntimeScheduler:
|
||||
- после ошибки;
|
||||
- после отмены внешней asyncio-задачи.
|
||||
"""
|
||||
if self._owner is not None and self._owner is not owner:
|
||||
raise RuntimeError(
|
||||
"Runtime Scheduler is owned by another lifecycle."
|
||||
)
|
||||
|
||||
if self._owner is None and owner is not None:
|
||||
raise RuntimeError(
|
||||
"Runtime Scheduler owner must claim it before start."
|
||||
)
|
||||
|
||||
if self._running:
|
||||
return
|
||||
|
||||
@@ -183,14 +279,25 @@ class RuntimeScheduler:
|
||||
|
||||
async def run_once(self) -> bool:
|
||||
"""
|
||||
Выполнить одну проверку Heartbeat.
|
||||
Выполнить одну liveness- и Heartbeat-проверку.
|
||||
|
||||
Если Heartbeat Monitor подтверждает timeout, Scheduler
|
||||
передаёт его Runtime Supervisor.
|
||||
Успешный transport probe обновляет время активности через
|
||||
Supervisor. Если Heartbeat Monitor подтверждает timeout,
|
||||
Scheduler передаёт его Runtime Supervisor.
|
||||
|
||||
Возвращаемое значение отражает только результат проверки
|
||||
Heartbeat Monitor.
|
||||
"""
|
||||
connection_is_live = await self._liveness_probe.probe()
|
||||
|
||||
if not isinstance(connection_is_live, bool):
|
||||
raise TypeError(
|
||||
"liveness probe must return a boolean"
|
||||
)
|
||||
|
||||
if connection_is_live:
|
||||
self._runtime_supervisor.notify_activity()
|
||||
|
||||
timed_out = await self._heartbeat_monitor.check_timeout()
|
||||
|
||||
if timed_out:
|
||||
|
||||
@@ -24,6 +24,28 @@ class RuntimeSupervisorState(str, Enum):
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RuntimeSupervisorReconnectProtocol(
|
||||
ReconnectCoordinatorProtocol,
|
||||
Protocol,
|
||||
):
|
||||
"""
|
||||
Generation-aware reconnect-контракт Runtime Supervisor.
|
||||
"""
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
"""Вернуть текущее connection generation."""
|
||||
...
|
||||
|
||||
async def reconnect_after_transport_failure(
|
||||
self,
|
||||
*,
|
||||
observed_generation: int,
|
||||
) -> None:
|
||||
"""Восстановить только наблюдавшееся поколение connection."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeSupervisorProtocol(Protocol):
|
||||
"""
|
||||
@@ -100,16 +122,18 @@ class RuntimeSupervisor:
|
||||
__slots__ = (
|
||||
"_heartbeat_monitor",
|
||||
"_reconnect_coordinator",
|
||||
"_observed_generation",
|
||||
"_state",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
heartbeat_monitor: HeartbeatMonitorProtocol,
|
||||
reconnect_coordinator: ReconnectCoordinatorProtocol,
|
||||
reconnect_coordinator: RuntimeSupervisorReconnectProtocol,
|
||||
) -> None:
|
||||
self._heartbeat_monitor = heartbeat_monitor
|
||||
self._reconnect_coordinator = reconnect_coordinator
|
||||
self._observed_generation = reconnect_coordinator.generation
|
||||
self._state = RuntimeSupervisorState.STOPPED
|
||||
|
||||
@property
|
||||
@@ -127,6 +151,9 @@ class RuntimeSupervisor:
|
||||
period и сохраняет Supervisor в состоянии RUNNING.
|
||||
"""
|
||||
self._heartbeat_monitor.start()
|
||||
self._observed_generation = (
|
||||
self._reconnect_coordinator.generation
|
||||
)
|
||||
self._state = RuntimeSupervisorState.RUNNING
|
||||
|
||||
def stop(self) -> None:
|
||||
@@ -150,6 +177,9 @@ class RuntimeSupervisor:
|
||||
return
|
||||
|
||||
self._heartbeat_monitor.record_activity()
|
||||
self._observed_generation = (
|
||||
self._reconnect_coordinator.generation
|
||||
)
|
||||
|
||||
async def handle_heartbeat_timeout(self) -> bool:
|
||||
"""
|
||||
@@ -163,7 +193,7 @@ class RuntimeSupervisor:
|
||||
↓
|
||||
RECONNECTING
|
||||
↓
|
||||
ReconnectCoordinator.reconnect()
|
||||
reconnect текущего connection generation
|
||||
├── success:
|
||||
│ start Heartbeat
|
||||
│ RUNNING
|
||||
@@ -190,14 +220,23 @@ class RuntimeSupervisor:
|
||||
|
||||
self._heartbeat_monitor.stop()
|
||||
self._state = RuntimeSupervisorState.RECONNECTING
|
||||
observed_generation = self._observed_generation
|
||||
|
||||
try:
|
||||
await self._reconnect_coordinator.reconnect()
|
||||
await (
|
||||
self._reconnect_coordinator
|
||||
.reconnect_after_transport_failure(
|
||||
observed_generation=observed_generation,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self._state = RuntimeSupervisorState.FAILED
|
||||
raise
|
||||
|
||||
self._heartbeat_monitor.start()
|
||||
self._observed_generation = (
|
||||
self._reconnect_coordinator.generation
|
||||
)
|
||||
self._state = RuntimeSupervisorState.RUNNING
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
from uuid import uuid4
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
WebSocketMessageDecodeError,
|
||||
WebSocketTransportError,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.live_processing_gate import (
|
||||
RuntimeLiveProcessingGateProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_reconnect_recovery_coordinator import (
|
||||
RuntimeReconnectRecoveryProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.scheduler import (
|
||||
RuntimeSchedulerProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.supervisor import (
|
||||
RuntimeSupervisorProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_events import (
|
||||
ConnectedEvent,
|
||||
ConnectFailedEvent,
|
||||
DisconnectedEvent,
|
||||
MessageReceivedEvent,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.transport_messages import (
|
||||
TransportBinaryMessage,
|
||||
TransportTextMessage,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_inbound_message import (
|
||||
WebSocketControlMessageHandlerProtocol,
|
||||
WebSocketInboundMessageClassifierProtocol,
|
||||
WebSocketInboundMessageKind,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeEventPublisherProtocol,
|
||||
WebSocketSessionProtocol,
|
||||
WebSocketSubscriptionManagerProtocol,
|
||||
WebSocketTransportProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_acquisition_protocol import (
|
||||
TradeStreamAcquisitionServiceProtocol,
|
||||
)
|
||||
|
||||
|
||||
class TradeStreamProductionRuntimeState(str, Enum):
|
||||
"""
|
||||
Состояние высокоуровневого lifecycle Trade Stream Runtime.
|
||||
"""
|
||||
|
||||
STOPPED = "stopped"
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
STOPPING = "stopping"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TradeStreamProductionRuntimeProtocol(Protocol):
|
||||
"""
|
||||
Контракт единственного владельца Trade Stream runtime execution.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> TradeStreamProductionRuntimeState:
|
||||
"""Вернуть текущее lifecycle-состояние."""
|
||||
...
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Показывает, выполняется ли receive lifecycle."""
|
||||
...
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Запустить Runtime и выполнять его до stop или ошибки."""
|
||||
...
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Идемпотентно остановить Runtime и дождаться cleanup."""
|
||||
...
|
||||
|
||||
|
||||
class TradeStreamProductionRuntime:
|
||||
"""
|
||||
Высокоуровневый владелец lifecycle Trade Stream.
|
||||
|
||||
Runtime:
|
||||
|
||||
- запускает WebSocket Session;
|
||||
- регистрирует Trade subscription;
|
||||
- создаёт ровно одну receive task;
|
||||
- декодирует JSON и отделяет control messages от market documents;
|
||||
- передаёт market documents в TradeStreamAcquisitionService;
|
||||
- после transport failure выполняет reconnect и Runtime Recovery;
|
||||
- запускает Runtime Supervisor и одну Scheduler task;
|
||||
- отменяет и await-ит созданные им startup, Scheduler и receive tasks;
|
||||
- выполняет полный cleanup при stop, cancellation и ошибке.
|
||||
|
||||
Scheduler останавливается до Supervisor и receive loop. Благодаря
|
||||
этому shutdown не может запустить новую heartbeat recovery-операцию.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_session",
|
||||
"_transport",
|
||||
"_subscription_manager",
|
||||
"_event_publisher",
|
||||
"_trade_stream_service",
|
||||
"_message_classifier",
|
||||
"_control_message_handler",
|
||||
"_live_processing_gate",
|
||||
"_reconnect_recovery_coordinator",
|
||||
"_runtime_supervisor",
|
||||
"_runtime_scheduler",
|
||||
"_symbols",
|
||||
"_state",
|
||||
"_startup_task",
|
||||
"_receive_task",
|
||||
"_scheduler_task",
|
||||
"_scheduler_claimed",
|
||||
"_supervisor_started",
|
||||
"_session_started",
|
||||
"_subscription_correlation_id",
|
||||
"_lifecycle_lock",
|
||||
"_stop_requested",
|
||||
"_stopped",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session: WebSocketSessionProtocol,
|
||||
transport: WebSocketTransportProtocol,
|
||||
subscription_manager: WebSocketSubscriptionManagerProtocol,
|
||||
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||
trade_stream_service: TradeStreamAcquisitionServiceProtocol,
|
||||
message_classifier: WebSocketInboundMessageClassifierProtocol,
|
||||
control_message_handler: WebSocketControlMessageHandlerProtocol,
|
||||
live_processing_gate: RuntimeLiveProcessingGateProtocol,
|
||||
reconnect_recovery_coordinator: RuntimeReconnectRecoveryProtocol,
|
||||
runtime_supervisor: RuntimeSupervisorProtocol,
|
||||
runtime_scheduler: RuntimeSchedulerProtocol,
|
||||
symbols: tuple[str, ...],
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._transport = transport
|
||||
self._subscription_manager = subscription_manager
|
||||
self._event_publisher = event_publisher
|
||||
self._trade_stream_service = trade_stream_service
|
||||
self._message_classifier = message_classifier
|
||||
self._control_message_handler = control_message_handler
|
||||
|
||||
normalized_symbols = self._normalize_symbols(symbols)
|
||||
|
||||
if (
|
||||
reconnect_recovery_coordinator.live_processing_gate
|
||||
is not live_processing_gate
|
||||
):
|
||||
raise ValueError(
|
||||
"Production Runtime and reconnect/recovery coordinator "
|
||||
"must share one live processing gate."
|
||||
)
|
||||
|
||||
if reconnect_recovery_coordinator.symbols != normalized_symbols:
|
||||
raise ValueError(
|
||||
"Production Runtime and reconnect/recovery coordinator "
|
||||
"must use the same symbols."
|
||||
)
|
||||
|
||||
if runtime_scheduler.runtime_supervisor is not runtime_supervisor:
|
||||
raise ValueError(
|
||||
"Production Runtime and Scheduler must share one "
|
||||
"Runtime Supervisor."
|
||||
)
|
||||
|
||||
if runtime_scheduler.liveness_probe is not transport:
|
||||
raise ValueError(
|
||||
"Production Runtime and Scheduler must share one "
|
||||
"WebSocket Transport."
|
||||
)
|
||||
|
||||
self._live_processing_gate = live_processing_gate
|
||||
self._reconnect_recovery_coordinator = (
|
||||
reconnect_recovery_coordinator
|
||||
)
|
||||
self._runtime_supervisor = runtime_supervisor
|
||||
self._runtime_scheduler = runtime_scheduler
|
||||
self._symbols = normalized_symbols
|
||||
|
||||
self._state = TradeStreamProductionRuntimeState.STOPPED
|
||||
self._startup_task: asyncio.Task[None] | None = None
|
||||
self._receive_task: asyncio.Task[None] | None = None
|
||||
self._scheduler_task: asyncio.Task[None] | None = None
|
||||
self._scheduler_claimed = False
|
||||
self._supervisor_started = False
|
||||
self._session_started = False
|
||||
self._subscription_correlation_id: str | None = None
|
||||
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
self._stop_requested = asyncio.Event()
|
||||
self._stopped = asyncio.Event()
|
||||
self._stopped.set()
|
||||
|
||||
@property
|
||||
def state(self) -> TradeStreamProductionRuntimeState:
|
||||
"""Вернуть текущее lifecycle-состояние."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Показывает, выполняется ли receive lifecycle."""
|
||||
return self._state is TradeStreamProductionRuntimeState.RUNNING
|
||||
|
||||
async def run(self) -> None:
|
||||
"""
|
||||
Запустить Runtime и выполнять его до stop или terminal error.
|
||||
|
||||
Метод не создаёт скрытую корневую задачу. Внешний bootstrap
|
||||
владеет coroutine task ``run()``, а Runtime владеет созданными
|
||||
внутри startup, Scheduler и receive tasks.
|
||||
"""
|
||||
await self._begin_run()
|
||||
|
||||
primary_error: BaseException | None = None
|
||||
|
||||
try:
|
||||
await self._run_lifecycle()
|
||||
except BaseException as error:
|
||||
primary_error = error
|
||||
|
||||
cleanup_error = await self._shutdown(
|
||||
primary_error=primary_error,
|
||||
)
|
||||
|
||||
failed = (
|
||||
isinstance(primary_error, Exception)
|
||||
or isinstance(cleanup_error, Exception)
|
||||
)
|
||||
self._finish_run(failed=failed)
|
||||
|
||||
if primary_error is not None:
|
||||
if cleanup_error is not None:
|
||||
primary_error.add_note(
|
||||
"Trade Stream Runtime 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 stop(self) -> None:
|
||||
"""
|
||||
Запросить остановку и дождаться полного cleanup.
|
||||
|
||||
stop() не отменяет внешнюю task ``run()``. Он останавливает
|
||||
только startup, Scheduler и receive tasks, принадлежащие
|
||||
Production Runtime.
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
if self._state in {
|
||||
TradeStreamProductionRuntimeState.STOPPED,
|
||||
TradeStreamProductionRuntimeState.FAILED,
|
||||
}:
|
||||
return
|
||||
|
||||
if (
|
||||
self._state
|
||||
is not TradeStreamProductionRuntimeState.STOPPING
|
||||
):
|
||||
self._state = TradeStreamProductionRuntimeState.STOPPING
|
||||
self._stop_requested.set()
|
||||
|
||||
startup_task = self._startup_task
|
||||
scheduler_task = self._scheduler_task
|
||||
|
||||
if (
|
||||
startup_task is not None
|
||||
and not startup_task.done()
|
||||
):
|
||||
startup_task.cancel()
|
||||
|
||||
if (
|
||||
scheduler_task is not None
|
||||
and not scheduler_task.done()
|
||||
):
|
||||
self._runtime_scheduler.stop()
|
||||
scheduler_task.cancel()
|
||||
|
||||
await self._stopped.wait()
|
||||
|
||||
async def _begin_run(self) -> None:
|
||||
async with self._lifecycle_lock:
|
||||
if self._state not in {
|
||||
TradeStreamProductionRuntimeState.STOPPED,
|
||||
TradeStreamProductionRuntimeState.FAILED,
|
||||
}:
|
||||
raise RuntimeError(
|
||||
"Trade Stream Production Runtime is already active."
|
||||
)
|
||||
|
||||
self._runtime_scheduler.claim(self)
|
||||
self._scheduler_claimed = True
|
||||
|
||||
try:
|
||||
self._live_processing_gate.reset()
|
||||
except BaseException:
|
||||
self._runtime_scheduler.release(self)
|
||||
self._scheduler_claimed = False
|
||||
raise
|
||||
|
||||
self._state = TradeStreamProductionRuntimeState.STARTING
|
||||
self._startup_task = None
|
||||
self._receive_task = None
|
||||
self._scheduler_task = None
|
||||
self._supervisor_started = False
|
||||
self._session_started = False
|
||||
self._subscription_correlation_id = None
|
||||
self._stop_requested.clear()
|
||||
self._stopped.clear()
|
||||
|
||||
async def _run_lifecycle(self) -> None:
|
||||
startup_task = asyncio.create_task(
|
||||
self._start_sequence(),
|
||||
name="trade-stream-startup",
|
||||
)
|
||||
|
||||
async with self._lifecycle_lock:
|
||||
self._startup_task = startup_task
|
||||
|
||||
if self._stop_requested.is_set():
|
||||
startup_task.cancel()
|
||||
|
||||
try:
|
||||
await startup_task
|
||||
except asyncio.CancelledError:
|
||||
if not self._stop_requested.is_set():
|
||||
raise
|
||||
|
||||
return
|
||||
|
||||
async with self._lifecycle_lock:
|
||||
if self._stop_requested.is_set():
|
||||
return
|
||||
|
||||
self._supervisor_started = True
|
||||
self._runtime_supervisor.start()
|
||||
|
||||
receive_task = asyncio.create_task(
|
||||
self._receive_loop(),
|
||||
name="trade-stream-receive",
|
||||
)
|
||||
scheduler_task = asyncio.create_task(
|
||||
self._runtime_scheduler.start(
|
||||
owner=self,
|
||||
),
|
||||
name="trade-stream-scheduler",
|
||||
)
|
||||
self._receive_task = receive_task
|
||||
self._scheduler_task = scheduler_task
|
||||
self._state = TradeStreamProductionRuntimeState.RUNNING
|
||||
|
||||
try:
|
||||
await self._wait_for_runtime_task(
|
||||
receive_task=receive_task,
|
||||
scheduler_task=scheduler_task,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
if not self._stop_requested.is_set():
|
||||
raise
|
||||
|
||||
async def _start_sequence(self) -> None:
|
||||
try:
|
||||
await self._session.start()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
await self._event_publisher.publish(
|
||||
ConnectFailedEvent(
|
||||
reason=str(error),
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
self._session_started = True
|
||||
|
||||
await self._event_publisher.publish(
|
||||
ConnectedEvent(),
|
||||
)
|
||||
|
||||
if self._stop_requested.is_set():
|
||||
return
|
||||
|
||||
correlation_id = str(uuid4())
|
||||
self._subscription_correlation_id = correlation_id
|
||||
|
||||
await self._trade_stream_service.subscribe(
|
||||
self._symbols,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
while not self._stop_requested.is_set():
|
||||
observed_generation = (
|
||||
self._reconnect_recovery_coordinator.generation
|
||||
)
|
||||
|
||||
try:
|
||||
raw_message = await self._transport.receive()
|
||||
except WebSocketTransportError:
|
||||
if self._stop_requested.is_set():
|
||||
return
|
||||
|
||||
await (
|
||||
self._reconnect_recovery_coordinator
|
||||
.reconnect_after_transport_failure(
|
||||
observed_generation=observed_generation,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if self._stop_requested.is_set():
|
||||
return
|
||||
|
||||
self._runtime_supervisor.notify_activity()
|
||||
|
||||
transport_message = self._wrap_transport_message(
|
||||
raw_message,
|
||||
)
|
||||
|
||||
await self._event_publisher.publish(
|
||||
MessageReceivedEvent(
|
||||
message=transport_message,
|
||||
)
|
||||
)
|
||||
|
||||
document = self._decode_message(
|
||||
raw_message,
|
||||
)
|
||||
|
||||
async with self._live_processing_gate:
|
||||
if self._stop_requested.is_set():
|
||||
return
|
||||
|
||||
message_kind = self._message_classifier.classify(
|
||||
document,
|
||||
)
|
||||
|
||||
if (
|
||||
message_kind
|
||||
is WebSocketInboundMessageKind.CONTROL
|
||||
):
|
||||
correlation_id = (
|
||||
self._subscription_correlation_id
|
||||
)
|
||||
|
||||
if correlation_id is None:
|
||||
raise RuntimeError(
|
||||
"Control message received without an "
|
||||
"active subscription correlation ID."
|
||||
)
|
||||
|
||||
self._control_message_handler.handle(
|
||||
document,
|
||||
expected_correlation_id=correlation_id,
|
||||
)
|
||||
continue
|
||||
|
||||
if (
|
||||
message_kind
|
||||
is not WebSocketInboundMessageKind.MARKET
|
||||
):
|
||||
raise TypeError(
|
||||
"message classifier must return "
|
||||
"WebSocketInboundMessageKind"
|
||||
)
|
||||
|
||||
self._trade_stream_service.handle_message(
|
||||
document,
|
||||
)
|
||||
|
||||
async def _shutdown(
|
||||
self,
|
||||
*,
|
||||
primary_error: BaseException | None,
|
||||
) -> BaseException | None:
|
||||
cleanup_error: BaseException | None = None
|
||||
startup_task = self._startup_task
|
||||
scheduler_task = self._scheduler_task
|
||||
receive_task = self._receive_task
|
||||
|
||||
self._stop_requested.set()
|
||||
|
||||
if (
|
||||
startup_task is not None
|
||||
and not startup_task.done()
|
||||
):
|
||||
startup_task.cancel()
|
||||
try:
|
||||
await startup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except BaseException as error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
if scheduler_task is not None:
|
||||
if not scheduler_task.done():
|
||||
try:
|
||||
self._runtime_scheduler.stop()
|
||||
except BaseException as error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
scheduler_task.cancel()
|
||||
|
||||
try:
|
||||
await scheduler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except BaseException as error:
|
||||
if error is not primary_error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
if self._scheduler_claimed:
|
||||
try:
|
||||
self._runtime_scheduler.release(self)
|
||||
except BaseException as error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
else:
|
||||
self._scheduler_claimed = False
|
||||
|
||||
if self._supervisor_started:
|
||||
try:
|
||||
self._runtime_supervisor.stop()
|
||||
except BaseException as error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
finally:
|
||||
self._supervisor_started = False
|
||||
|
||||
if receive_task is not None:
|
||||
if not receive_task.done():
|
||||
receive_task.cancel()
|
||||
|
||||
try:
|
||||
await receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except BaseException as error:
|
||||
if error is not primary_error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
session_stopped = False
|
||||
|
||||
try:
|
||||
await self._session.stop()
|
||||
session_stopped = True
|
||||
except BaseException as error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._subscription_manager.clear_subscriptions()
|
||||
except BaseException as error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
if self._session_started and session_stopped:
|
||||
try:
|
||||
await self._event_publisher.publish(
|
||||
DisconnectedEvent(),
|
||||
)
|
||||
except BaseException as error:
|
||||
cleanup_error = self._merge_cleanup_error(
|
||||
cleanup_error,
|
||||
error,
|
||||
)
|
||||
|
||||
self._startup_task = None
|
||||
self._receive_task = None
|
||||
self._scheduler_task = None
|
||||
self._session_started = False
|
||||
self._subscription_correlation_id = None
|
||||
|
||||
return cleanup_error
|
||||
|
||||
async def _wait_for_runtime_task(
|
||||
self,
|
||||
*,
|
||||
receive_task: asyncio.Task[None],
|
||||
scheduler_task: asyncio.Task[None],
|
||||
) -> None:
|
||||
done_tasks, _ = await asyncio.wait(
|
||||
(
|
||||
receive_task,
|
||||
scheduler_task,
|
||||
),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
task_errors: list[BaseException] = []
|
||||
cancelled_task_names: list[str] = []
|
||||
|
||||
for task in (
|
||||
receive_task,
|
||||
scheduler_task,
|
||||
):
|
||||
if task not in done_tasks:
|
||||
continue
|
||||
|
||||
if task.cancelled():
|
||||
cancelled_task_names.append(
|
||||
task.get_name(),
|
||||
)
|
||||
continue
|
||||
|
||||
error = task.exception()
|
||||
|
||||
if error is not None:
|
||||
task_errors.append(error)
|
||||
|
||||
if task_errors:
|
||||
primary_error = task_errors[0]
|
||||
|
||||
for additional_error in task_errors[1:]:
|
||||
primary_error.add_note(
|
||||
"Additional Trade Stream Runtime task failure: "
|
||||
f"{type(additional_error).__name__}."
|
||||
)
|
||||
|
||||
raise primary_error.with_traceback(
|
||||
primary_error.__traceback__,
|
||||
)
|
||||
|
||||
if self._stop_requested.is_set():
|
||||
return
|
||||
|
||||
if cancelled_task_names:
|
||||
raise RuntimeError(
|
||||
"Trade Stream Runtime task was cancelled unexpectedly: "
|
||||
f"{', '.join(cancelled_task_names)}."
|
||||
)
|
||||
|
||||
completed_task_names = ", ".join(
|
||||
task.get_name()
|
||||
for task in done_tasks
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Trade Stream Runtime task terminated unexpectedly: "
|
||||
f"{completed_task_names}."
|
||||
)
|
||||
|
||||
def _finish_run(
|
||||
self,
|
||||
*,
|
||||
failed: bool,
|
||||
) -> None:
|
||||
self._state = (
|
||||
TradeStreamProductionRuntimeState.FAILED
|
||||
if failed
|
||||
else TradeStreamProductionRuntimeState.STOPPED
|
||||
)
|
||||
self._stopped.set()
|
||||
|
||||
@staticmethod
|
||||
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 Trade Stream Runtime cleanup failure: "
|
||||
f"{type(new_error).__name__}."
|
||||
)
|
||||
return current_error
|
||||
|
||||
@staticmethod
|
||||
def _wrap_transport_message(
|
||||
raw_message: str | bytes,
|
||||
) -> TransportTextMessage | TransportBinaryMessage:
|
||||
if isinstance(raw_message, str):
|
||||
return TransportTextMessage(
|
||||
payload=raw_message,
|
||||
)
|
||||
|
||||
if isinstance(raw_message, bytes):
|
||||
return TransportBinaryMessage(
|
||||
payload=raw_message,
|
||||
)
|
||||
|
||||
raise TypeError(
|
||||
"WebSocket transport receive() must return str or bytes"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode_message(
|
||||
raw_message: str | bytes,
|
||||
) -> object:
|
||||
try:
|
||||
return json.loads(raw_message)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
||||
raise WebSocketMessageDecodeError(
|
||||
"Не удалось декодировать JSON из WebSocket message."
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def _normalize_symbols(
|
||||
symbols: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
if not isinstance(symbols, tuple):
|
||||
raise TypeError("symbols must be a tuple")
|
||||
|
||||
normalized_symbols: set[str] = set()
|
||||
|
||||
for symbol in symbols:
|
||||
if not isinstance(symbol, str):
|
||||
raise TypeError(
|
||||
"symbols must contain only strings"
|
||||
)
|
||||
|
||||
normalized_symbol = symbol.strip()
|
||||
|
||||
if normalized_symbol:
|
||||
normalized_symbols.add(normalized_symbol)
|
||||
|
||||
if not normalized_symbols:
|
||||
raise ValueError(
|
||||
"symbols must contain at least one non-empty symbol"
|
||||
)
|
||||
|
||||
return tuple(sorted(normalized_symbols))
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
class WebSocketInboundMessageKind(str, Enum):
|
||||
"""
|
||||
Категория декодированного входящего WebSocket-сообщения.
|
||||
"""
|
||||
|
||||
MARKET = "market"
|
||||
CONTROL = "control"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WebSocketInboundMessageClassifierProtocol(Protocol):
|
||||
"""
|
||||
Контракт классификации декодированного WebSocket-документа.
|
||||
|
||||
Конкретная реализация содержит provider-specific знания о форме
|
||||
market и control messages. Production Runtime использует только
|
||||
итоговую категорию и не интерпретирует exchange-specific поля.
|
||||
"""
|
||||
|
||||
def classify(
|
||||
self,
|
||||
document: object,
|
||||
) -> WebSocketInboundMessageKind:
|
||||
"""Классифицировать один декодированный документ."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WebSocketControlMessageHandlerProtocol(Protocol):
|
||||
"""
|
||||
Контракт provider-specific проверки входящего control/ACK.
|
||||
|
||||
Production Runtime передаёт ожидаемый correlation ID, но не
|
||||
интерпретирует provider-specific поля ответа самостоятельно.
|
||||
"""
|
||||
|
||||
def handle(
|
||||
self,
|
||||
document: object,
|
||||
*,
|
||||
expected_correlation_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Проверить принадлежность и успешность control/ACK сообщения.
|
||||
"""
|
||||
...
|
||||
81
app/src/market_data/acquisition/runtime/websocket_session.py
Normal file
81
app/src/market_data/acquisition/runtime/websocket_session.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Protocol
|
||||
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
WebSocketTransportProtocol,
|
||||
)
|
||||
|
||||
|
||||
class _SessionTransportProtocol(
|
||||
WebSocketTransportProtocol,
|
||||
Protocol,
|
||||
):
|
||||
"""
|
||||
Transport-контракт для Session lifecycle.
|
||||
"""
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Показывает состояние Transport."""
|
||||
...
|
||||
|
||||
|
||||
class WebSocketSession:
|
||||
"""
|
||||
Идемпотентный lifecycle WebSocket-сессии.
|
||||
|
||||
Session сериализует start/stop, делегирует сетевые операции
|
||||
Transport и не содержит parsing, subscriptions или recovery.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_transport",
|
||||
"_started",
|
||||
"_lifecycle_lock",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: _SessionTransportProtocol,
|
||||
) -> None:
|
||||
self._transport = transport
|
||||
self._started = False
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Показывает состояние Session."""
|
||||
return (
|
||||
self._started
|
||||
and self._transport.is_connected
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
Открыть Session без повторного соединения.
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
if self.is_connected:
|
||||
return
|
||||
|
||||
self._started = False
|
||||
await self._transport.connect()
|
||||
self._started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""
|
||||
Закрыть Session и сбросить локальное состояние.
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
if (
|
||||
not self._started
|
||||
and not self._transport.is_connected
|
||||
):
|
||||
return
|
||||
|
||||
try:
|
||||
await self._transport.disconnect()
|
||||
finally:
|
||||
self._started = False
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
WebSocketUnsubscribeNotSupportedError,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.transport_messages import (
|
||||
TransportBinaryMessage,
|
||||
TransportTextMessage,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionSubscriptionMessage,
|
||||
WebSocketTransportProtocol,
|
||||
)
|
||||
|
||||
|
||||
class WebSocketSubscriptionManager:
|
||||
"""
|
||||
Реестр WebSocket-подписок и механизм их восстановления.
|
||||
|
||||
Manager хранит только универсальные transport messages.
|
||||
Он не разбирает payload и не содержит exchange-specific знаний.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_transport",
|
||||
"_supports_unsubscribe",
|
||||
"_subscriptions",
|
||||
"_active_subscription_keys",
|
||||
"_lock",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: WebSocketTransportProtocol,
|
||||
*,
|
||||
supports_unsubscribe: bool = False,
|
||||
) -> None:
|
||||
if not isinstance(supports_unsubscribe, bool):
|
||||
raise TypeError(
|
||||
"supports_unsubscribe must be a boolean"
|
||||
)
|
||||
|
||||
self._transport = transport
|
||||
self._supports_unsubscribe = supports_unsubscribe
|
||||
self._subscriptions: dict[
|
||||
str,
|
||||
AcquisitionSubscriptionMessage,
|
||||
] = {}
|
||||
self._active_subscription_keys: set[str] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def subscription_keys(self) -> tuple[str, ...]:
|
||||
"""Вернуть ключи в порядке регистрации."""
|
||||
return tuple(self._subscriptions)
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
subscription_key: str,
|
||||
message: AcquisitionSubscriptionMessage,
|
||||
) -> None:
|
||||
"""
|
||||
Идемпотентно отправить и зарегистрировать подписку.
|
||||
|
||||
Desired subscription сохраняется до сетевой отправки.
|
||||
При ошибке она остаётся pending для последующего retry/reconnect.
|
||||
"""
|
||||
self._validate_subscription_key(subscription_key)
|
||||
self._validate_message(message)
|
||||
|
||||
async with self._lock:
|
||||
if subscription_key in self._active_subscription_keys:
|
||||
return
|
||||
|
||||
self._subscriptions[subscription_key] = message
|
||||
|
||||
await self._send(message)
|
||||
self._active_subscription_keys.add(
|
||||
subscription_key,
|
||||
)
|
||||
|
||||
async def unsubscribe(
|
||||
self,
|
||||
subscription_key: str,
|
||||
message: AcquisitionSubscriptionMessage,
|
||||
) -> None:
|
||||
"""
|
||||
Отправить unsubscribe только при явной поддержке провайдером.
|
||||
|
||||
Dzengi ``trades.unsubscribe`` не поддерживает. Поэтому
|
||||
``supports_unsubscribe`` по умолчанию равно False.
|
||||
"""
|
||||
self._validate_subscription_key(subscription_key)
|
||||
self._validate_message(message)
|
||||
|
||||
if not self._supports_unsubscribe:
|
||||
raise WebSocketUnsubscribeNotSupportedError(
|
||||
"WebSocket provider does not support unsubscribe."
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
if subscription_key not in self._subscriptions:
|
||||
return
|
||||
|
||||
if subscription_key in self._active_subscription_keys:
|
||||
await self._send(message)
|
||||
|
||||
del self._subscriptions[subscription_key]
|
||||
self._active_subscription_keys.discard(
|
||||
subscription_key,
|
||||
)
|
||||
|
||||
async def restore_subscriptions(self) -> None:
|
||||
"""
|
||||
Последовательно восстановить подписки.
|
||||
"""
|
||||
async with self._lock:
|
||||
self._active_subscription_keys.clear()
|
||||
|
||||
for subscription_key, message in (
|
||||
self._subscriptions.items()
|
||||
):
|
||||
await self._send(message)
|
||||
self._active_subscription_keys.add(
|
||||
subscription_key,
|
||||
)
|
||||
|
||||
async def clear_subscriptions(self) -> None:
|
||||
"""
|
||||
Очистить реестр без отправки сообщений.
|
||||
"""
|
||||
async with self._lock:
|
||||
self._subscriptions.clear()
|
||||
self._active_subscription_keys.clear()
|
||||
|
||||
async def _send(
|
||||
self,
|
||||
message: AcquisitionSubscriptionMessage,
|
||||
) -> None:
|
||||
if isinstance(message, TransportTextMessage):
|
||||
await self._transport.send(message.payload)
|
||||
return
|
||||
|
||||
await self._transport.send(message.payload)
|
||||
|
||||
@staticmethod
|
||||
def _validate_subscription_key(
|
||||
subscription_key: str,
|
||||
) -> None:
|
||||
if not isinstance(subscription_key, str):
|
||||
raise TypeError(
|
||||
"subscription_key must be a string"
|
||||
)
|
||||
|
||||
if not subscription_key.strip():
|
||||
raise ValueError(
|
||||
"subscription_key must not be empty"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_message(
|
||||
message: AcquisitionSubscriptionMessage,
|
||||
) -> None:
|
||||
if not isinstance(
|
||||
message,
|
||||
(
|
||||
TransportTextMessage,
|
||||
TransportBinaryMessage,
|
||||
),
|
||||
):
|
||||
raise TypeError(
|
||||
"message must be TransportTextMessage "
|
||||
"or TransportBinaryMessage"
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||
DzengiTradesDocumentSource,
|
||||
@@ -30,9 +31,20 @@ from src.market_data.acquisition.runtime.acquisition_runtime_service import (
|
||||
from src.market_data.acquisition.runtime.heartbeat import (
|
||||
HeartbeatMonitor,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.live_processing_gate import (
|
||||
RuntimeLiveProcessingGate,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.reconnect import (
|
||||
ReconnectCoordinator,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_reconnect_recovery_coordinator import (
|
||||
RuntimeReconnectRecoveryCoordinator,
|
||||
RuntimeUnixTimeMillisecondsClock,
|
||||
system_unix_time_ms,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_liveness_probe import (
|
||||
RuntimeLivenessProbeProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_recovery_coordinator import (
|
||||
RuntimeRecoveryCoordinator,
|
||||
)
|
||||
@@ -57,6 +69,14 @@ from src.market_data.acquisition.trade_stream_message_adapter_protocol import (
|
||||
)
|
||||
|
||||
|
||||
class _TradeStreamRuntimeTransportProtocol(
|
||||
WebSocketTransportProtocol,
|
||||
RuntimeLivenessProbeProtocol,
|
||||
Protocol,
|
||||
):
|
||||
"""Общий Transport и liveness-контракт Composition."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradeStreamRuntimeComposition:
|
||||
"""
|
||||
@@ -80,6 +100,11 @@ class TradeStreamRuntimeComposition:
|
||||
trade_stream_acquisition_service: TradeStreamAcquisitionService
|
||||
|
||||
reconnect_coordinator: ReconnectCoordinator
|
||||
live_processing_gate: RuntimeLiveProcessingGate
|
||||
liveness_probe: RuntimeLivenessProbeProtocol
|
||||
runtime_reconnect_recovery_coordinator: (
|
||||
RuntimeReconnectRecoveryCoordinator
|
||||
)
|
||||
heartbeat_monitor: HeartbeatMonitor
|
||||
runtime_supervisor: RuntimeSupervisor
|
||||
runtime_scheduler: RuntimeScheduler
|
||||
@@ -88,15 +113,19 @@ class TradeStreamRuntimeComposition:
|
||||
def build_trade_stream_runtime_composition(
|
||||
*,
|
||||
session: WebSocketSessionProtocol,
|
||||
transport: WebSocketTransportProtocol,
|
||||
transport: _TradeStreamRuntimeTransportProtocol,
|
||||
subscription_manager: WebSocketSubscriptionManagerProtocol,
|
||||
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||
message_adapter: TradeStreamMessageAdapterProtocol,
|
||||
recovery_document_source: DzengiTradesDocumentSource,
|
||||
symbols: tuple[str, ...],
|
||||
heartbeat_timeout_seconds: float,
|
||||
scheduler_interval_seconds: float,
|
||||
max_recovery_window_ms: int = DEFAULT_TRADE_RECOVERY_WINDOW_MS,
|
||||
heartbeat_clock: Callable[[], float] = time.monotonic,
|
||||
recovery_end_time_clock: RuntimeUnixTimeMillisecondsClock = (
|
||||
system_unix_time_ms
|
||||
),
|
||||
scheduler_sleep: RuntimeSleep = asyncio.sleep,
|
||||
) -> TradeStreamRuntimeComposition:
|
||||
"""
|
||||
@@ -151,6 +180,18 @@ def build_trade_stream_runtime_composition(
|
||||
event_publisher=event_publisher,
|
||||
)
|
||||
|
||||
live_processing_gate = RuntimeLiveProcessingGate()
|
||||
|
||||
runtime_reconnect_recovery_coordinator = (
|
||||
RuntimeReconnectRecoveryCoordinator(
|
||||
reconnect_coordinator=reconnect_coordinator,
|
||||
recovery_coordinator=runtime_recovery_coordinator,
|
||||
live_processing_gate=live_processing_gate,
|
||||
symbols=symbols,
|
||||
clock=recovery_end_time_clock,
|
||||
)
|
||||
)
|
||||
|
||||
heartbeat_monitor = HeartbeatMonitor(
|
||||
event_publisher=event_publisher,
|
||||
timeout_seconds=heartbeat_timeout_seconds,
|
||||
@@ -159,10 +200,13 @@ def build_trade_stream_runtime_composition(
|
||||
|
||||
runtime_supervisor = RuntimeSupervisor(
|
||||
heartbeat_monitor=heartbeat_monitor,
|
||||
reconnect_coordinator=reconnect_coordinator,
|
||||
reconnect_coordinator=(
|
||||
runtime_reconnect_recovery_coordinator
|
||||
),
|
||||
)
|
||||
|
||||
runtime_scheduler = RuntimeScheduler(
|
||||
liveness_probe=transport,
|
||||
heartbeat_monitor=heartbeat_monitor,
|
||||
runtime_supervisor=runtime_supervisor,
|
||||
interval_seconds=scheduler_interval_seconds,
|
||||
@@ -178,6 +222,11 @@ def build_trade_stream_runtime_composition(
|
||||
acquisition_runtime_service=acquisition_runtime_service,
|
||||
trade_stream_acquisition_service=trade_stream_acquisition_service,
|
||||
reconnect_coordinator=reconnect_coordinator,
|
||||
live_processing_gate=live_processing_gate,
|
||||
liveness_probe=transport,
|
||||
runtime_reconnect_recovery_coordinator=(
|
||||
runtime_reconnect_recovery_coordinator
|
||||
),
|
||||
heartbeat_monitor=heartbeat_monitor,
|
||||
runtime_supervisor=runtime_supervisor,
|
||||
runtime_scheduler=runtime_scheduler,
|
||||
|
||||
Reference in New Issue
Block a user