Build 060.25: implement Production Runtime Integration

This commit is contained in:
2026-07-31 00:29:36 +03:00
parent c142145361
commit 60bec1eaf9
50 changed files with 14044 additions and 83 deletions

View File

@@ -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,
)

View 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

View 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,
)

View File

@@ -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(),
)
)

View File

@@ -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

View File

@@ -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())

View File

@@ -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."
)

View File

@@ -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 "
"не должен быть пустым."
)

View File

@@ -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

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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

View 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()

View File

@@ -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()
)

View File

@@ -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.
"""
...

View File

@@ -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"
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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))

View File

@@ -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 сообщения.
"""
...

View 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

View File

@@ -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"
)

View File

@@ -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,