Build 060.24: implement Runtime Recovery Architecture

This commit is contained in:
2026-07-30 00:17:12 +03:00
parent ee8765b716
commit c142145361
21 changed files with 7475 additions and 5 deletions

View File

@@ -5,11 +5,11 @@ from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
TradeConsistencyError,
TradeOrderingError,
)
from src.market_data.acquisition.models.trade import Trade
DEFAULT_DEDUPLICATION_WINDOW_SIZE = 10_000
@@ -25,6 +25,7 @@ class TradeStreamState:
deduplication_window_size: int = DEFAULT_DEDUPLICATION_WINDOW_SIZE
last_trade_id: int | None = None
last_trade: Trade | None = None
_trade_window: deque[int] = field(init=False, repr=False)
_trades: dict[int, Trade] = field(init=False, repr=False)
@@ -88,6 +89,7 @@ class TradeStreamState:
self._append(trade)
self.last_trade_id = trade_id
self.last_trade = trade
return trade
@@ -100,4 +102,4 @@ class TradeStreamState:
self._trades.pop(oldest_trade_id, None)
self._trade_window.append(trade.trade_id)
self._trades[trade.trade_id] = trade
self._trades[trade.trade_id] = trade

View File

@@ -0,0 +1,51 @@
# app/src/market_data/acquisition/recovery/trade_recovery_window.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TradeRecoveryWindow:
"""
Неизменяемое описание одного временного диапазона
восстановления Trade Stream.
Модель не выполняет Recovery и не содержит ограничений
конкретного REST API. Допустимый максимальный размер окна
контролируется TradeRecoveryWindowPlanner.
"""
symbol: str
start_time: int
end_time: int
def __post_init__(self) -> None:
if not isinstance(self.symbol, str):
raise TypeError("symbol must be a string")
if not self.symbol.strip():
raise ValueError("symbol must not be empty")
if not isinstance(self.start_time, int) or isinstance(
self.start_time,
bool,
):
raise TypeError("start_time must be an integer")
if not isinstance(self.end_time, int) or isinstance(
self.end_time,
bool,
):
raise TypeError("end_time must be an integer")
if self.start_time < 0:
raise ValueError("start_time must not be negative")
if self.end_time < 0:
raise ValueError("end_time must not be negative")
if self.start_time > self.end_time:
raise ValueError(
"start_time must not be greater than end_time"
)

View File

@@ -0,0 +1,147 @@
# app/src/market_data/acquisition/recovery/trade_recovery_window_planner.py
from __future__ import annotations
from src.market_data.acquisition.recovery.trade_recovery_window import (
TradeRecoveryWindow,
)
MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS = 3_599_999
DEFAULT_TRADE_RECOVERY_WINDOW_MS = (
MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS
)
class TradeRecoveryWindowPlanner:
"""
Stateless-планировщик временных окон восстановления Trade Stream.
Planner разбивает один непрерывный временной диапазон на
последовательность окон, каждое из которых совместимо с ограничением
TradeRecoveryRequest:
end_time - start_time < 3_600_000 ms
Planner не выполняет REST-запросы и не создаёт TradeRecoveryRequest.
"""
__slots__ = ("_max_window_ms",)
def __init__(
self,
*,
max_window_ms: int = DEFAULT_TRADE_RECOVERY_WINDOW_MS,
) -> None:
if not isinstance(max_window_ms, int) or isinstance(
max_window_ms,
bool,
):
raise TypeError("max_window_ms must be an integer")
if max_window_ms <= 0:
raise ValueError("max_window_ms must be positive")
if max_window_ms > MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS:
raise ValueError(
"max_window_ms must not exceed "
f"{MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS}"
)
self._max_window_ms = max_window_ms
@property
def max_window_ms(self) -> int:
"""
Максимальная длительность одного создаваемого окна.
"""
return self._max_window_ms
def build_windows(
self,
*,
symbol: str,
start_time: int,
end_time: int,
) -> tuple[TradeRecoveryWindow, ...]:
"""
Построить последовательность Recovery Window.
Если start_time равен end_time, восстановление не требуется
и возвращается пустой tuple.
Соседние окна имеют общую границу:
previous.end_time == next.start_time
Возможный повтор Trade на границе должен быть устранён
существующим Consistency Layer.
"""
self._validate_range(
symbol=symbol,
start_time=start_time,
end_time=end_time,
)
if start_time == end_time:
return ()
windows: list[TradeRecoveryWindow] = []
cursor = start_time
while cursor < end_time:
window_end = min(
cursor + self._max_window_ms,
end_time,
)
windows.append(
TradeRecoveryWindow(
symbol=symbol,
start_time=cursor,
end_time=window_end,
)
)
cursor = window_end
return tuple(windows)
@staticmethod
def _validate_range(
*,
symbol: str,
start_time: int,
end_time: int,
) -> None:
"""
Проверить исходный диапазон до начала разбиения.
"""
if not isinstance(symbol, str):
raise TypeError("symbol must be a string")
if not symbol.strip():
raise ValueError("symbol must not be empty")
if not isinstance(start_time, int) or isinstance(
start_time,
bool,
):
raise TypeError("start_time must be an integer")
if not isinstance(end_time, int) or isinstance(
end_time,
bool,
):
raise TypeError("end_time must be an integer")
if start_time < 0:
raise ValueError("start_time must not be negative")
if end_time < 0:
raise ValueError("end_time must not be negative")
if start_time > end_time:
raise ValueError(
"start_time must not be greater than end_time"
)

View File

@@ -0,0 +1,192 @@
# app/src/market_data/acquisition/runtime/heartbeat.py
from __future__ import annotations
import time
from enum import Enum
from typing import Callable, Protocol, runtime_checkable
from src.market_data.acquisition.runtime.runtime_events import (
HeartbeatTimeoutEvent,
)
from src.market_data.acquisition.runtime.websocket_protocol import (
AcquisitionRuntimeEventPublisherProtocol,
)
class HeartbeatState(str, Enum):
"""
Текущее состояние Heartbeat Monitor.
"""
IDLE = "idle"
MONITORING = "monitoring"
TIMED_OUT = "timed_out"
@runtime_checkable
class HeartbeatMonitorProtocol(Protocol):
"""
Контракт пассивного контроля активности Acquisition Runtime.
"""
@property
def state(self) -> HeartbeatState:
"""Вернуть текущее состояние Heartbeat Monitor."""
...
@property
def timeout_seconds(self) -> float:
"""Вернуть настроенный интервал timeout."""
...
@property
def last_activity_at(self) -> float | None:
"""Вернуть monotonic-время последней активности."""
...
def start(self) -> None:
"""Начать мониторинг активности."""
...
def stop(self) -> None:
"""Остановить мониторинг и очистить текущее состояние."""
...
def record_activity(self) -> None:
"""Зафиксировать текущий момент как последнюю активность."""
...
async def check_timeout(self) -> bool:
"""
Проверить наступление timeout.
Вернуть True только при первом обнаружении timeout.
"""
...
class HeartbeatMonitor:
"""
Пассивный монитор активности Acquisition Runtime.
Monitor:
- хранит monotonic-время последней активности;
- детерминированно проверяет превышение timeout;
- однократно публикует HeartbeatTimeoutEvent;
- не запускает собственные фоновые задачи;
- не выполняет reconnect;
- не управляет WebSocket lifecycle.
Периодический вызов check_timeout() выполняет Runtime Scheduler.
"""
__slots__ = (
"_event_publisher",
"_timeout_seconds",
"_clock",
"_state",
"_last_activity_at",
)
def __init__(
self,
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
*,
timeout_seconds: float,
clock: Callable[[], float] = time.monotonic,
) -> None:
if not isinstance(timeout_seconds, (int, float)) or isinstance(
timeout_seconds,
bool,
):
raise TypeError(
"timeout_seconds must be an integer or float"
)
if timeout_seconds <= 0:
raise ValueError(
"timeout_seconds must be positive"
)
if not callable(clock):
raise TypeError("clock must be callable")
self._event_publisher = event_publisher
self._timeout_seconds = float(timeout_seconds)
self._clock = clock
self._state = HeartbeatState.IDLE
self._last_activity_at: float | None = None
@property
def state(self) -> HeartbeatState:
"""Вернуть текущее состояние Heartbeat Monitor."""
return self._state
@property
def timeout_seconds(self) -> float:
"""Вернуть настроенный интервал timeout."""
return self._timeout_seconds
@property
def last_activity_at(self) -> float | None:
"""Вернуть monotonic-время последней активности."""
return self._last_activity_at
def start(self) -> None:
"""
Начать мониторинг с текущего monotonic-времени.
"""
self._last_activity_at = self._clock()
self._state = HeartbeatState.MONITORING
def stop(self) -> None:
"""
Остановить мониторинг и удалить runtime-состояние Heartbeat.
"""
self._last_activity_at = None
self._state = HeartbeatState.IDLE
def record_activity(self) -> None:
"""
Зафиксировать текущий момент как последнюю активность.
Получение новой активности также переводит Monitor из состояния
TIMED_OUT обратно в MONITORING.
"""
self._last_activity_at = self._clock()
self._state = HeartbeatState.MONITORING
async def check_timeout(self) -> bool:
"""
Проверить, истёк ли настроенный интервал активности.
HeartbeatTimeoutEvent публикуется только один раз для каждого
периода отсутствия активности.
После новой активности Monitor снова может зафиксировать timeout.
"""
if self._state is not HeartbeatState.MONITORING:
return False
last_activity_at = self._last_activity_at
if last_activity_at is None:
return False
elapsed_seconds = self._clock() - last_activity_at
if elapsed_seconds < self._timeout_seconds:
return False
self._state = HeartbeatState.TIMED_OUT
await self._event_publisher.publish(
HeartbeatTimeoutEvent(
timeout_seconds=self._timeout_seconds,
)
)
return True

View File

@@ -1 +1,186 @@
# app/src/market_data/acquisition/runtime/reconnect.py
# app/src/market_data/acquisition/runtime/reconnect.py
from __future__ import annotations
from enum import Enum
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.runtime.runtime_commands import (
ConnectCommand,
)
from src.market_data.acquisition.runtime.runtime_events import (
ReconnectCompletedEvent,
ReconnectFailedEvent,
ReconnectStartedEvent,
)
from src.market_data.acquisition.runtime.websocket_protocol import (
AcquisitionRuntimeCommandDispatcherProtocol,
AcquisitionRuntimeEventPublisherProtocol,
WebSocketSubscriptionManagerProtocol,
)
class ReconnectState(str, Enum):
"""
Текущее состояние ReconnectCoordinator.
Состояния описывают только lifecycle одной инфраструктурной
операции reconnect.
Состояния Heartbeat, Supervisor и Trade Recovery принадлежат
соответствующим специализированным компонентам.
"""
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
RESTORING_SUBSCRIPTIONS = "restoring_subscriptions"
CONNECTED = "connected"
FAILED = "failed"
@runtime_checkable
class ReconnectCoordinatorProtocol(Protocol):
"""
Контракт координатора повторного подключения Acquisition Runtime.
"""
@property
def state(self) -> ReconnectState:
"""
Вернуть текущее состояние reconnect lifecycle.
"""
...
@property
def attempt(self) -> int:
"""
Вернуть номер последней начатой попытки reconnect.
"""
...
async def reconnect(self) -> None:
"""
Выполнить одну попытку повторного подключения.
"""
...
class ReconnectCoordinator:
"""
Координатор одной попытки повторного подключения.
Coordinator:
- увеличивает номер попытки;
- публикует ReconnectStartedEvent;
- передаёт ConnectCommand в Runtime Dispatcher;
- восстанавливает зарегистрированные подписки;
- публикует ReconnectCompletedEvent;
- при ошибке публикует ReconnectFailedEvent;
- сохраняет текущее состояние lifecycle.
Coordinator не выполняет:
- retry loop;
- backoff;
- scheduling;
- heartbeat monitoring;
- Trade Recovery;
- обработку рыночных сообщений.
"""
__slots__ = (
"_command_dispatcher",
"_subscription_manager",
"_event_publisher",
"_state",
"_attempt",
)
def __init__(
self,
command_dispatcher: AcquisitionRuntimeCommandDispatcherProtocol,
subscription_manager: WebSocketSubscriptionManagerProtocol,
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
) -> None:
self._command_dispatcher = command_dispatcher
self._subscription_manager = subscription_manager
self._event_publisher = event_publisher
self._state = ReconnectState.DISCONNECTED
self._attempt = 0
@property
def state(self) -> ReconnectState:
"""
Вернуть текущее состояние reconnect lifecycle.
"""
return self._state
@property
def attempt(self) -> int:
"""
Вернуть номер последней начатой попытки reconnect.
"""
return self._attempt
async def reconnect(self) -> None:
"""
Выполнить одну попытку повторного подключения.
Последовательность:
ReconnectStartedEvent
ConnectCommand
restore_subscriptions()
ReconnectCompletedEvent
Raises:
Exception:
Любая ошибка Runtime Dispatcher или Subscription Manager
распространяется вызывающему компоненту без обёртки.
"""
self._attempt += 1
current_attempt = self._attempt
self._state = ReconnectState.CONNECTING
await self._event_publisher.publish(
ReconnectStartedEvent(
attempt=current_attempt,
)
)
try:
await self._command_dispatcher.dispatch(
ConnectCommand()
)
self._state = (
ReconnectState.RESTORING_SUBSCRIPTIONS
)
await self._subscription_manager.restore_subscriptions()
except Exception as error:
self._state = ReconnectState.FAILED
await self._event_publisher.publish(
ReconnectFailedEvent(
attempt=current_attempt,
reason=str(error),
)
)
raise
self._state = ReconnectState.CONNECTED
await self._event_publisher.publish(
ReconnectCompletedEvent(
attempt=current_attempt,
)
)

View File

@@ -0,0 +1,295 @@
# app/src/market_data/acquisition/runtime/
# runtime_recovery_coordinator.py
from __future__ import annotations
from datetime import datetime, timezone
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
TradeStreamStateNotFoundError,
)
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
TradeStreamStateStoreProtocol,
)
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.recovery.trade_recovery_protocol import (
TradeRecoveryProtocol,
)
from src.market_data.acquisition.recovery.trade_recovery_request import (
TradeRecoveryRequest,
)
from src.market_data.acquisition.recovery.trade_recovery_result import (
TradeRecoveryResult,
)
from src.market_data.acquisition.recovery.trade_recovery_window_planner import (
TradeRecoveryWindowPlanner,
)
class RuntimeRecoveryCoordinator:
"""
Runtime-координатор восстановления Trade Stream.
Coordinator связывает существующие компоненты:
- TradeStreamStateStoreProtocol;
- checkpoint в TradeStreamState.last_trade;
- TradeRecoveryWindowPlanner;
- TradeRecoveryProtocol.
Последовательность восстановления:
state_store.get(symbol)
TradeStreamState.last_trade
executed_at → Unix milliseconds
TradeRecoveryWindowPlanner.build_windows()
TradeRecoveryRequest для каждого окна
TradeRecoveryProtocol.recover()
объединённый TradeRecoveryResult
Coordinator не выполняет:
- WebSocket connect или disconnect;
- reconnect;
- восстановление подписок;
- heartbeat monitoring;
- scheduling;
- retry или backoff;
- публикацию Runtime Events;
- создание нового TradeStreamState;
- обработку и проверку согласованности Trade.
Отсутствие состояния или последней принятой сделки является
штатной ситуацией: восстановление пропускается и возвращается
пустой TradeRecoveryResult.
Все остальные ошибки State Store, Window Planner и Trade Recovery
распространяются вызывающему коду без обёртки.
"""
__slots__ = (
"_state_store",
"_window_planner",
"_recovery_controller",
)
def __init__(
self,
*,
state_store: TradeStreamStateStoreProtocol,
window_planner: TradeRecoveryWindowPlanner,
recovery_controller: TradeRecoveryProtocol,
) -> None:
"""
Создать Runtime Recovery Coordinator.
Args:
state_store:
Общее хранилище состояния Trade Stream Consistency.
Должен передаваться тот же экземпляр хранилища,
который используется основным WebSocket-потоком.
window_planner:
Планировщик допустимых временных окон восстановления.
recovery_controller:
Существующий stateless-контроллер Trade Recovery.
"""
self._state_store = state_store
self._window_planner = window_planner
self._recovery_controller = recovery_controller
def recover(
self,
*,
symbol: str,
recovery_end_time: int,
) -> TradeRecoveryResult:
"""
Восстановить пропущенные сделки одного инструмента.
Начальная граница определяется по времени последней сделки,
принятой общим Trade Stream Consistency Layer.
Конечная граница передаётся вызывающим Runtime-компонентом.
Coordinator не получает системное время самостоятельно.
Возможный повтор последней принятой сделки на левой границе
безопасно устраняется существующим Consistency Layer.
Args:
symbol:
Символ торгового инструмента.
recovery_end_time:
Конечная граница восстановления в миллисекундах
Unix time.
Returns:
Объединённый TradeRecoveryResult для полного диапазона.
Если состояние или checkpoint отсутствуют, возвращается
пустой результат с границами, равными recovery_end_time.
Raises:
TypeError:
Если symbol или recovery_end_time имеют неверный тип.
ValueError:
Если symbol пустой, recovery_end_time отрицательный,
checkpoint не содержит timezone-aware datetime либо
конечная граница расположена раньше checkpoint.
Exception:
Ошибки Window Planner и Trade Recovery Controller
распространяются без изменения.
"""
self._validate_input(
symbol=symbol,
recovery_end_time=recovery_end_time,
)
try:
state = self._state_store.get(symbol)
except TradeStreamStateNotFoundError:
return self._build_empty_result(
symbol=symbol,
boundary_time=recovery_end_time,
)
checkpoint_trade = state.last_trade
if checkpoint_trade is None:
return self._build_empty_result(
symbol=symbol,
boundary_time=recovery_end_time,
)
recovery_start_time = self._datetime_to_unix_ms(
checkpoint_trade.executed_at,
)
windows = self._window_planner.build_windows(
symbol=symbol,
start_time=recovery_start_time,
end_time=recovery_end_time,
)
recovered_trades: list[Trade] = []
for window in windows:
window_result = self._recovery_controller.recover(
TradeRecoveryRequest(
symbol=window.symbol,
start_time=window.start_time,
end_time=window.end_time,
)
)
recovered_trades.extend(
window_result.recovered_trades,
)
return TradeRecoveryResult(
symbol=symbol,
requested_start_time=recovery_start_time,
requested_end_time=recovery_end_time,
recovered_trades=tuple(recovered_trades),
)
@staticmethod
def _validate_input(
*,
symbol: str,
recovery_end_time: int,
) -> None:
"""
Проверить входные параметры до обращения к State Store.
"""
if not isinstance(symbol, str):
raise TypeError(
"symbol должен иметь тип str."
)
if not symbol.strip():
raise ValueError(
"symbol не должен быть пустым."
)
if isinstance(recovery_end_time, bool) or not isinstance(
recovery_end_time,
int,
):
raise TypeError(
"recovery_end_time должен иметь тип int."
)
if recovery_end_time < 0:
raise ValueError(
"recovery_end_time не должен быть отрицательным."
)
@staticmethod
def _datetime_to_unix_ms(
value: datetime,
) -> int:
"""
Преобразовать timezone-aware datetime в Unix milliseconds.
Расчёт выполняется относительно UTC epoch без использования
float, чтобы не вносить погрешность преобразования timestamp.
"""
if not isinstance(value, datetime):
raise TypeError(
"checkpoint executed_at должен иметь тип datetime."
)
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(
"checkpoint executed_at должен содержать timezone."
)
utc_value = value.astimezone(
timezone.utc,
)
epoch = datetime(
1970,
1,
1,
tzinfo=timezone.utc,
)
delta = utc_value - epoch
return (
delta.days * 86_400_000
+ delta.seconds * 1_000
+ delta.microseconds // 1_000
)
@staticmethod
def _build_empty_result(
*,
symbol: str,
boundary_time: int,
) -> TradeRecoveryResult:
"""
Создать пустой результат при отсутствии Recovery Checkpoint.
Равные границы явно обозначают отсутствие диапазона,
который можно восстановить на основе существующего состояния.
"""
return TradeRecoveryResult(
symbol=symbol,
requested_start_time=boundary_time,
requested_end_time=boundary_time,
recovered_trades=(),
)

View File

@@ -0,0 +1,72 @@
# app/src/market_data/acquisition/runtime/runtime_recovery_protocol.py
"""
Публичный контракт Runtime Recovery Coordinator.
Runtime Recovery Coordinator связывает Runtime Layer с существующим
Trade Recovery Layer, но не выполняет reconnect, WebSocket-операции,
heartbeat, scheduling или восстановление подписок.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.recovery.trade_recovery_result import (
TradeRecoveryResult,
)
@runtime_checkable
class RuntimeRecoveryProtocol(Protocol):
"""
Контракт Runtime-координатора восстановления Trade Stream.
Реализация должна:
- получить существующий checkpoint торгового инструмента;
- определить полный временной диапазон восстановления;
- разбить диапазон на допустимые Recovery Window;
- последовательно выполнить Trade Recovery для каждого окна;
- вернуть объединённый TradeRecoveryResult.
Реализация не должна:
- создавать новое состояние Trade Stream;
- выполнять WebSocket connect или disconnect;
- восстанавливать WebSocket-подписки;
- управлять Heartbeat Monitor;
- управлять Runtime Supervisor;
- запускать Scheduler;
- выполнять retry или backoff;
- скрывать ошибки нижележащих компонентов.
"""
def recover(
self,
*,
symbol: str,
recovery_end_time: int,
) -> TradeRecoveryResult:
"""
Восстановить пропущенные сделки одного торгового инструмента.
Args:
symbol:
Символ торгового инструмента.
recovery_end_time:
Правая граница восстанавливаемого диапазона
в миллисекундах Unix time.
Значение определяется вызывающим Runtime-компонентом.
Coordinator самостоятельно текущее время не вычисляет.
Returns:
Объединённый результат восстановления всего диапазона.
Если существующее состояние или checkpoint отсутствуют,
реализация возвращает пустой TradeRecoveryResult и не создаёт
новое состояние Trade Stream.
"""
...

View File

@@ -0,0 +1,199 @@
# app/src/market_data/acquisition/runtime/scheduler.py
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.runtime.heartbeat import (
HeartbeatMonitorProtocol,
)
from src.market_data.acquisition.runtime.supervisor import (
RuntimeSupervisorProtocol,
)
RuntimeSleep = Callable[[float], Awaitable[None]]
@runtime_checkable
class RuntimeSchedulerProtocol(Protocol):
"""
Контракт периодического планировщика Acquisition Runtime.
"""
@property
def running(self) -> bool:
"""
Вернуть признак активного scheduler loop.
"""
...
@property
def interval_seconds(self) -> float:
"""
Вернуть интервал между периодическими проверками.
"""
...
async def start(self) -> None:
"""
Запустить scheduler loop.
Метод выполняется до вызова stop() либо возникновения ошибки.
"""
...
def stop(self) -> None:
"""
Запросить остановку scheduler loop.
"""
...
async def run_once(self) -> bool:
"""
Выполнить одну проверку Heartbeat.
Вернуть True, если был обнаружен timeout.
"""
...
class RuntimeScheduler:
"""
Периодический планировщик Acquisition Runtime.
Scheduler отвечает только за время выполнения проверок:
HeartbeatMonitor.check_timeout()
├── False
│ └── ожидание следующего интервала
└── True
└── RuntimeSupervisor
.handle_heartbeat_timeout()
Scheduler не выполняет:
- reconnect;
- Heartbeat calculations;
- управление WebSocket;
- Trade Recovery;
- backoff;
- retry policy;
- публикацию Runtime Events.
Ошибки Heartbeat Monitor, Runtime Supervisor и sleep-функции
распространяются вызывающему коду без обёртки.
"""
__slots__ = (
"_heartbeat_monitor",
"_runtime_supervisor",
"_interval_seconds",
"_sleep",
"_running",
)
def __init__(
self,
heartbeat_monitor: HeartbeatMonitorProtocol,
runtime_supervisor: RuntimeSupervisorProtocol,
*,
interval_seconds: float,
sleep: RuntimeSleep = asyncio.sleep,
) -> None:
if not isinstance(interval_seconds, (int, float)) or isinstance(
interval_seconds,
bool,
):
raise TypeError(
"interval_seconds must be an integer or float"
)
if interval_seconds <= 0:
raise ValueError(
"interval_seconds must be positive"
)
if not callable(sleep):
raise TypeError("sleep must be callable")
self._heartbeat_monitor = heartbeat_monitor
self._runtime_supervisor = runtime_supervisor
self._interval_seconds = float(interval_seconds)
self._sleep = sleep
self._running = False
@property
def running(self) -> bool:
"""
Вернуть признак активного scheduler loop.
"""
return self._running
@property
def interval_seconds(self) -> float:
"""
Вернуть интервал между проверками.
"""
return self._interval_seconds
async def start(self) -> None:
"""
Запустить периодический scheduler loop.
Если Scheduler уже запущен, повторный вызов немедленно
завершается и не создаёт второй параллельный цикл.
Scheduler всегда сбрасывает running в False:
- после stop();
- после ошибки;
- после отмены внешней asyncio-задачи.
"""
if self._running:
return
self._running = True
try:
while self._running:
await self.run_once()
if not self._running:
break
await self._sleep(
self._interval_seconds,
)
finally:
self._running = False
def stop(self) -> None:
"""
Запросить завершение scheduler loop.
Метод идемпотентен. Он не отменяет внешнюю asyncio-задачу,
а останавливает цикл на ближайшей управляемой границе.
"""
self._running = False
async def run_once(self) -> bool:
"""
Выполнить одну проверку Heartbeat.
Если Heartbeat Monitor подтверждает timeout, Scheduler
передаёт его Runtime Supervisor.
Возвращаемое значение отражает только результат проверки
Heartbeat Monitor.
"""
timed_out = await self._heartbeat_monitor.check_timeout()
if timed_out:
await self._runtime_supervisor.handle_heartbeat_timeout()
return timed_out

View File

@@ -1 +1,203 @@
# app/src/market_data/acquisition/runtime/supervisor.py
# app/src/market_data/acquisition/runtime/supervisor.py
from __future__ import annotations
from enum import Enum
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.runtime.heartbeat import (
HeartbeatMonitorProtocol,
)
from src.market_data.acquisition.runtime.reconnect import (
ReconnectCoordinatorProtocol,
)
class RuntimeSupervisorState(str, Enum):
"""
Текущее состояние Runtime Supervisor.
"""
STOPPED = "stopped"
RUNNING = "running"
RECONNECTING = "reconnecting"
FAILED = "failed"
@runtime_checkable
class RuntimeSupervisorProtocol(Protocol):
"""
Контракт координатора lifecycle Acquisition Runtime.
"""
@property
def state(self) -> RuntimeSupervisorState:
"""
Вернуть текущее состояние Runtime Supervisor.
"""
...
def start(self) -> None:
"""
Запустить supervision и Heartbeat Monitor.
"""
...
def stop(self) -> None:
"""
Остановить supervision и Heartbeat Monitor.
"""
...
def notify_activity(self) -> None:
"""
Передать Heartbeat Monitor информацию о Runtime activity.
"""
...
async def handle_heartbeat_timeout(self) -> bool:
"""
Обработать подтверждённый Heartbeat timeout.
Вернуть True, если была выполнена попытка reconnect.
"""
...
class RuntimeSupervisor:
"""
Координатор lifecycle Acquisition Runtime.
Supervisor объединяет:
- HeartbeatMonitor;
- ReconnectCoordinator;
- общее состояние Runtime Session.
Supervisor:
- запускает и останавливает Heartbeat Monitor;
- принимает уведомления о Runtime activity;
- запускает одну попытку reconnect после Heartbeat timeout;
- после успешного reconnect начинает новый период Heartbeat;
- фиксирует состояния STOPPED, RUNNING, RECONNECTING и FAILED;
- предотвращает параллельный reconnect.
Supervisor не выполняет:
- периодические проверки Heartbeat;
- retry loop;
- backoff;
- scheduling;
- Trade Recovery;
- управление WebSocket Transport;
- публикацию Runtime Events.
Периодический вызов Heartbeat и передача timeout в Supervisor
будут ответственностью Runtime Scheduler.
"""
__slots__ = (
"_heartbeat_monitor",
"_reconnect_coordinator",
"_state",
)
def __init__(
self,
heartbeat_monitor: HeartbeatMonitorProtocol,
reconnect_coordinator: ReconnectCoordinatorProtocol,
) -> None:
self._heartbeat_monitor = heartbeat_monitor
self._reconnect_coordinator = reconnect_coordinator
self._state = RuntimeSupervisorState.STOPPED
@property
def state(self) -> RuntimeSupervisorState:
"""
Вернуть текущее состояние Runtime Supervisor.
"""
return self._state
def start(self) -> None:
"""
Запустить supervision.
Повторный вызов start() начинает новый Heartbeat monitoring
period и сохраняет Supervisor в состоянии RUNNING.
"""
self._heartbeat_monitor.start()
self._state = RuntimeSupervisorState.RUNNING
def stop(self) -> None:
"""
Остановить supervision.
Повторный вызов stop() безопасен и сохраняет состояние STOPPED.
"""
self._heartbeat_monitor.stop()
self._state = RuntimeSupervisorState.STOPPED
def notify_activity(self) -> None:
"""
Передать информацию о Runtime activity в Heartbeat Monitor.
Активность учитывается только во время RUNNING lifecycle.
События, полученные после stop() либо во время reconnect,
не должны неявно запускать Heartbeat Monitor.
"""
if self._state is not RuntimeSupervisorState.RUNNING:
return
self._heartbeat_monitor.record_activity()
async def handle_heartbeat_timeout(self) -> bool:
"""
Обработать подтверждённый Heartbeat timeout.
Последовательность:
RUNNING
stop Heartbeat
RECONNECTING
ReconnectCoordinator.reconnect()
├── success:
│ start Heartbeat
│ RUNNING
└── failure:
FAILED
exception propagates
Если Supervisor не находится в состоянии RUNNING,
новая попытка reconnect не запускается.
Returns:
bool:
True, если reconnect был выполнен успешно.
False, если Supervisor не находился в RUNNING.
Raises:
Exception:
Исходная ошибка ReconnectCoordinator распространяется
вызывающему компоненту без обёртки.
"""
if self._state is not RuntimeSupervisorState.RUNNING:
return False
self._heartbeat_monitor.stop()
self._state = RuntimeSupervisorState.RECONNECTING
try:
await self._reconnect_coordinator.reconnect()
except Exception:
self._state = RuntimeSupervisorState.FAILED
raise
self._heartbeat_monitor.start()
self._state = RuntimeSupervisorState.RUNNING
return True

View File

@@ -0,0 +1,184 @@
# app/src/market_data/acquisition/
# trade_stream_runtime_composition.py
from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from dataclasses import dataclass
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiTradesDocumentSource,
)
from src.market_data.acquisition.consistency.trade_stream_consistency_controller import (
TradeStreamConsistencyController,
)
from src.market_data.acquisition.consistency.trade_stream_state_store import (
TradeStreamStateStore,
)
from src.market_data.acquisition.recovery.trade_recovery_controller import (
TradeRecoveryController,
)
from src.market_data.acquisition.recovery.trade_recovery_window_planner import (
DEFAULT_TRADE_RECOVERY_WINDOW_MS,
TradeRecoveryWindowPlanner,
)
from src.market_data.acquisition.runtime.acquisition_runtime_service import (
AcquisitionRuntimeService,
)
from src.market_data.acquisition.runtime.heartbeat import (
HeartbeatMonitor,
)
from src.market_data.acquisition.runtime.reconnect import (
ReconnectCoordinator,
)
from src.market_data.acquisition.runtime.runtime_recovery_coordinator import (
RuntimeRecoveryCoordinator,
)
from src.market_data.acquisition.runtime.scheduler import (
RuntimeScheduler,
RuntimeSleep,
)
from src.market_data.acquisition.runtime.supervisor import (
RuntimeSupervisor,
)
from src.market_data.acquisition.runtime.websocket_protocol import (
AcquisitionRuntimeEventPublisherProtocol,
WebSocketSessionProtocol,
WebSocketSubscriptionManagerProtocol,
WebSocketTransportProtocol,
)
from src.market_data.acquisition.trade_stream_acquisition_service import (
TradeStreamAcquisitionService,
)
from src.market_data.acquisition.trade_stream_message_adapter_protocol import (
TradeStreamMessageAdapterProtocol,
)
@dataclass(frozen=True, slots=True)
class TradeStreamRuntimeComposition:
"""
Неизменяемый результат композиции Trade Stream Runtime.
Объект предоставляет явно построенный граф зависимостей и не является
Registry либо Service Locator. Все компоненты создаются factory-функцией
ровно один раз и повторно используют общие stateful-зависимости.
Создание Composition не запускает WebSocket, Heartbeat, Supervisor
или Scheduler и не выполняет Recovery.
"""
state_store: TradeStreamStateStore
consistency_controller: TradeStreamConsistencyController
recovery_controller: TradeRecoveryController
recovery_window_planner: TradeRecoveryWindowPlanner
runtime_recovery_coordinator: RuntimeRecoveryCoordinator
acquisition_runtime_service: AcquisitionRuntimeService
trade_stream_acquisition_service: TradeStreamAcquisitionService
reconnect_coordinator: ReconnectCoordinator
heartbeat_monitor: HeartbeatMonitor
runtime_supervisor: RuntimeSupervisor
runtime_scheduler: RuntimeScheduler
def build_trade_stream_runtime_composition(
*,
session: WebSocketSessionProtocol,
transport: WebSocketTransportProtocol,
subscription_manager: WebSocketSubscriptionManagerProtocol,
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
message_adapter: TradeStreamMessageAdapterProtocol,
recovery_document_source: DzengiTradesDocumentSource,
heartbeat_timeout_seconds: float,
scheduler_interval_seconds: float,
max_recovery_window_ms: int = DEFAULT_TRADE_RECOVERY_WINDOW_MS,
heartbeat_clock: Callable[[], float] = time.monotonic,
scheduler_sleep: RuntimeSleep = asyncio.sleep,
) -> TradeStreamRuntimeComposition:
"""
Построить изолированный граф зависимостей Trade Stream Runtime.
Factory создаёт единые экземпляры TradeStreamStateStore и
TradeStreamConsistencyController. Благодаря этому Live Stream
и Recovery используют один checkpoint и одинаковые правила
согласованности сделок.
Внешние WebSocket-зависимости передаются через Protocol-контракты.
Factory не создаёт production transport, не читает Settings,
не запускает lifecycle и не создаёт фоновые asyncio-задачи.
"""
state_store = TradeStreamStateStore()
consistency_controller = TradeStreamConsistencyController(
state_store,
)
recovery_controller = TradeRecoveryController(
document_source=recovery_document_source,
consistency_controller=consistency_controller,
)
recovery_window_planner = TradeRecoveryWindowPlanner(
max_window_ms=max_recovery_window_ms,
)
runtime_recovery_coordinator = RuntimeRecoveryCoordinator(
state_store=state_store,
window_planner=recovery_window_planner,
recovery_controller=recovery_controller,
)
acquisition_runtime_service = AcquisitionRuntimeService(
session=session,
transport=transport,
subscription_manager=subscription_manager,
event_publisher=event_publisher,
)
trade_stream_acquisition_service = TradeStreamAcquisitionService(
runtime_service=acquisition_runtime_service,
adapter=message_adapter,
consistency_controller=consistency_controller,
)
reconnect_coordinator = ReconnectCoordinator(
command_dispatcher=acquisition_runtime_service,
subscription_manager=subscription_manager,
event_publisher=event_publisher,
)
heartbeat_monitor = HeartbeatMonitor(
event_publisher=event_publisher,
timeout_seconds=heartbeat_timeout_seconds,
clock=heartbeat_clock,
)
runtime_supervisor = RuntimeSupervisor(
heartbeat_monitor=heartbeat_monitor,
reconnect_coordinator=reconnect_coordinator,
)
runtime_scheduler = RuntimeScheduler(
heartbeat_monitor=heartbeat_monitor,
runtime_supervisor=runtime_supervisor,
interval_seconds=scheduler_interval_seconds,
sleep=scheduler_sleep,
)
return TradeStreamRuntimeComposition(
state_store=state_store,
consistency_controller=consistency_controller,
recovery_controller=recovery_controller,
recovery_window_planner=recovery_window_planner,
runtime_recovery_coordinator=runtime_recovery_coordinator,
acquisition_runtime_service=acquisition_runtime_service,
trade_stream_acquisition_service=trade_stream_acquisition_service,
reconnect_coordinator=reconnect_coordinator,
heartbeat_monitor=heartbeat_monitor,
runtime_supervisor=runtime_supervisor,
runtime_scheduler=runtime_scheduler,
)