Files
dzentra_bot/docs/migrations/build_060_25_architecture.md

1638 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Build 060.25 — Production Runtime Integration Architecture
**Статус:** Completed
**Build:** 060.25
**Подсистема:** Market Data Acquisition / Trade Stream Runtime
**Дата начала:** 2026-07-30
**Дата завершения:** 2026-07-31
**Версия документа:** 1.0
---
## 1. Назначение
Документ фиксирует архитектуру Production Runtime Integration для
Trade Stream.
Build 060.24 создал внутренний граф Runtime Recovery, но намеренно:
- не создавал реальное WebSocket-соединение;
- не запускал receive loop;
- не управлял asyncio-задачами;
- не связывал reconnect с Trade Recovery;
- не подключал Runtime к bootstrap приложения.
Build 060.25 превратил этот граф в управляемый production
lifecycle без нарушения границ Transport, Runtime, Acquisition,
Consistency и Recovery.
Документ является живой архитектурной спецификацией.
Он разделяет:
- принятые и реализованные решения 060.25.0060.25.8;
- end-to-end, reconnect и stress validation Build 060.26;
- persistent storage и startup recovery Build 060.27060.28.
Итоговый migration report зафиксирован в `build_060_25.md`.
---
## 2. Статус подэтапов
| Подэтап | Название | Статус |
|---|---|---|
| 060.25.0 | Static Contract Cleanup | Accepted |
| 060.25.1 | Dzengi WebSocket Transport | Accepted |
| 060.25.2 | WebSocket Session and Subscription Manager | Accepted |
| 060.25.3 | Async Runtime Event Publisher | Accepted |
| 060.25.4 | Trade Stream Production Runtime | Accepted |
| 060.25.5 | Reconnect and Recovery Integration | Accepted |
| 060.25.6 | Heartbeat and Scheduler Integration | Accepted |
| 060.25.7 | Settings, Bootstrap and Graceful Shutdown | Accepted |
| 060.25.8 | Targeted Runtime Verification | Accepted |
Статус `Accepted` означает:
- реализация прошла целевые тесты;
- архитектурные findings устранены;
- пользователь принял результат подэтапа;
- файлы ещё не зафиксированы отдельным Build commit.
---
## 3. Исходная архитектура
Build 060.24 предоставил:
```text
TradeStreamRuntimeComposition
├── TradeStreamStateStore
├── TradeStreamConsistencyController
├── TradeRecoveryController
├── TradeRecoveryWindowPlanner
├── RuntimeRecoveryCoordinator
├── AcquisitionRuntimeService
├── TradeStreamAcquisitionService
├── ReconnectCoordinator
├── HeartbeatMonitor
├── RuntimeSupervisor
└── RuntimeScheduler
```
Composition создаёт единые экземпляры stateful-компонентов, но не
запускает их.
Внешними зависимостями оставались:
```text
WebSocketTransportProtocol
WebSocketSessionProtocol
WebSocketSubscriptionManagerProtocol
AcquisitionRuntimeEventPublisherProtocol
TradeStreamMessageAdapterProtocol
DzengiTradesDocumentSource
```
До Build 060.25 production-реализации первых четырёх WebSocket Runtime
контрактов отсутствовали.
---
## 4. Целевая модель
```text
APPLICATION BOOTSTRAP
Trade Stream Production Runtime
┌───────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
WebSocket lifecycle Runtime supervision Recovery gate
│ │ │
▼ ▼ ▼
Dzengi Transport Heartbeat / Scheduler Runtime Recovery
│ │ │
▼ ▼ ▼
WebSocket Session Runtime Supervisor REST Recovery
│ │ │
▼ ▼ │
Subscription Manager ───► Reconnect Coordinator │
│ │
▼ │
receive loop │
│ │
▼ ▼
TradeStreamAcquisitionService ─► TradeStreamConsistencyController
TradeStreamStateStore
```
Production Runtime является владельцем выполнения, но не владельцем
бизнес-состояния сделок.
---
## 5. Архитектурные границы
### 5.1. Dzengi WebSocket Transport
Transport отвечает только за:
- нормализацию WebSocket URL;
- открытие и закрытие соединения;
- отправку `str | bytes`;
- получение `str | bytes`;
- transport timeouts и ping/pong параметры библиотеки;
- преобразование инфраструктурных ошибок в WebSocket transport errors.
Transport не:
- декодирует JSON;
- различает ACK и market events;
- строит subscription payload;
- изменяет Trade Stream state;
- выполняет reconnect policy;
- выполняет Recovery.
### 5.2. WebSocket Session
Session отвечает за:
- идемпотентные `start()` и `stop()`;
- сериализацию lifecycle через `asyncio.Lock`;
- согласование локального состояния с Transport;
- повторный `start()` после фактического закрытия Transport.
Session не должна превращать повторный `start()` в неявный force
reconnect. Принудительная замена соединения является явной операцией
ReconnectCoordinator.
### 5.3. Subscription Manager
Subscription Manager отвечает за:
- desired subscription registry;
- active state текущего соединения;
- идемпотентную подписку;
- последовательное восстановление подписок;
- очистку runtime-состояния;
- явную политику unsupported unsubscribe.
Manager работает с:
```text
TransportTextMessage
TransportBinaryMessage
```
Он не анализирует содержимое payload и не знает о Trade, Quote, Candle
или конкретной JSON destination.
### 5.4. Production Runtime
Production Runtime должен отвечать за:
- запуск и остановку полного графа;
- владение asyncio-задачами;
- единственный receive loop;
- JSON decoding;
- отделение control/ACK сообщений от market events;
- передачу market documents в Acquisition Service;
- координацию reconnect и recovery;
- graceful cancellation и shutdown.
Production Runtime не должен дублировать:
- Trade consistency;
- subscription registry;
- heartbeat calculations;
- recovery window planning;
- WebSocket transport logic.
---
## 6. Build 060.25.0 — Static Contract Cleanup
### 6.1. Исходная проблема
Тестовый helper `create_coordinator()` принимал:
```text
RecordingWindowPlanner | None
```
Негативный тест передавал:
```text
BrokenPlanner(TradeRecoveryWindowPlanner)
```
Runtime-поведение было корректным, но Pylance обнаруживал несовместимый
тип аргумента.
### 6.2. Принятое решение
Тестовая иерархия согласована с реальной:
```text
TradeRecoveryWindowPlanner
RecordingWindowPlanner
BrokenPlanner
```
Удалён локальный `type: ignore[arg-type]`.
Production-контракт RuntimeRecoveryCoordinator не изменён.
---
## 7. Build 060.25.1 — Dzengi WebSocket Transport
### 7.1. Реализация
Добавлен:
```text
DzengiWebSocketTransport
```
Transport структурно соответствует:
```text
WebSocketTransportProtocol
```
Прямое наследование от Protocol не используется, чтобы сохранить
рабочий `__slots__`.
### 7.2. URL policy
Поддерживаемые исходные схемы:
```text
http → ws
https → wss
ws → ws
wss → wss
```
URL нормализуется до endpoint:
```text
/connect
```
Примеры:
```text
https://api-adapter.dzengi.com
wss://api-adapter.dzengi.com/connect
```
```text
http://localhost:8080/
ws://localhost:8080/connect
```
Пустые, относительные, неподдерживаемые и fragment URL отклоняются до
сетевого вызова.
### 7.3. Connection options
Через constructor внедряются:
```text
headers
open_timeout
ping_interval
ping_timeout
close_timeout
connector
```
Инъекция connector позволяет тестировать Transport без реальной сети.
Transport использует WebSocket subprotocol:
```text
json
```
### 7.4. Lifecycle
```text
DISCONNECTED
│ connect()
OPEN
│ disconnect()
DISCONNECTED
```
Повторные `connect()` и `disconnect()` идемпотентны.
Lifecycle сериализован отдельным `asyncio.Lock`.
Transport считает соединение активным только если:
```text
connection is not None
and connection.state is State.OPEN
```
### 7.5. Error policy
Введены:
```text
WebSocketTransportError
WebSocketTransportNotConnectedError
```
Connect, disconnect, send и receive errors оборачиваются с сохранением
исходной ошибки в `__cause__`.
Попытка send или receive без открытого соединения завершается
`WebSocketTransportNotConnectedError`.
`asyncio.CancelledError` не поглощается обработчиками `Exception`.
---
## 8. Build 060.25.2 — WebSocket Session
### 8.1. Реализация
Добавлен:
```text
WebSocketSession
```
Session структурно соответствует:
```text
WebSocketSessionProtocol
```
### 8.2. Идемпотентность
```text
start()
├── Session и Transport connected → no-op
└── disconnected → transport.connect()
```
```text
stop()
├── Session и Transport disconnected → no-op
└── active → transport.disconnect()
```
Параллельные lifecycle-вызовы сериализуются Session lock.
После ошибки `start()` Session остаётся disconnected.
После ошибки `stop()` локальный Session state гарантированно
сбрасывается в `finally`.
### 8.3. Remote disconnect
Session не доверяет только локальному `_started`.
Состояние:
```text
is_connected =
_started
and transport.is_connected
```
Если Transport обнаружил закрытие соединения, следующий `start()`
действительно открывает новое соединение.
---
## 9. Build 060.25.2 — Subscription State
### 9.1. Два уровня состояния
Subscription Manager разделяет:
```text
desired subscriptions
```
и:
```text
active subscription keys
```
Desired registry описывает требуемое состояние Runtime и переживает
ошибку текущей сетевой попытки.
Active keys относятся только к текущему WebSocket connection.
### 9.2. Subscribe
```text
validate key and message
store desired subscription
transport.send()
├── success → mark active
└── failure → keep pending desired subscription
```
Повторный вызов для active key является no-op.
Повторный вызов для pending key выполняет новую отправку и может
обновить сохранённое transport message.
### 9.3. Restore
После нового соединения:
```text
clear active keys
for each desired subscription in registration order:
transport.send()
mark key active
```
Ошибка восстановления не удаляет desired registry.
### 9.4. Unsubscribe
Публичный Dzengi endpoint не поддерживает надёжный
`trades.unsubscribe`.
Поэтому production-default:
```text
supports_unsubscribe = False
```
Попытка unsubscribe завершается:
```text
WebSocketUnsubscribeNotSupportedError
```
и не изменяет registry.
Generic supported branch сохраняется для будущих провайдеров.
### 9.5. Clear
`clear_subscriptions()` очищает desired и active state без отправки
сетевого сообщения.
Это локальная lifecycle-операция, а не имитация exchange unsubscribe.
---
## 10. Корректирующее решение Reconnect
### 10.1. Обнаруженный риск
Первоначальная реализация Build 060.24 выполняла:
```text
ConnectCommand
restore_subscriptions()
```
Новая идемпотентная Session показала скрытую проблему.
При heartbeat timeout WebSocket мог формально оставаться `OPEN`.
В таком состоянии `Session.start()` являлся no-op, и подписки повторно
отправлялись в старое соединение.
### 10.2. Принятое решение
Одна reconnect-попытка теперь выполняет:
```text
ReconnectStartedEvent
DisconnectCommand
ConnectCommand
restore_subscriptions()
ReconnectCompletedEvent
```
Это гарантирует замену старого connection даже при формальном
`State.OPEN`.
Registry подписок не очищается при disconnect, поэтому desired state
доступен для восстановления.
### 10.3. Failure policy
Ошибка disconnect, connect или restore:
```text
state = FAILED
ReconnectFailedEvent
raise original exception
```
Retry loop и backoff по-прежнему находятся вне ReconnectCoordinator.
---
## 11. Concurrency invariants
### 11.1. Локальные locks
```text
DzengiWebSocketTransport._lifecycle_lock
```
защищает connect/disconnect.
```text
WebSocketSession._lifecycle_lock
```
защищает Session start/stop.
```text
WebSocketSubscriptionManager._lock
```
защищает subscribe, unsubscribe, restore и clear.
### 11.2. Lock ordering
Допустимый порядок:
```text
Session lock
Transport lifecycle lock
```
```text
Subscription Manager lock
Transport send
```
Transport не вызывает Session или Subscription Manager обратно.
Обратного lock ordering нет.
### 11.3. Cross-component coordination
Локальные locks не заменяют общий lifecycle gate.
Production Runtime обязан исключить гонку:
```text
live message processing
X
reconnect / recovery state mutation
```
Этот gate относится к 060.25.4060.25.5.
---
## 12. Build 060.25.3 — Async Runtime Event Publisher
**Статус:** Accepted
Реализованы:
```text
AcquisitionRuntimeEventConsumerProtocol
AcquisitionRuntimeEventPublisher
AcquisitionRuntimeEventLoggingConsumer
```
`AcquisitionRuntimeEventPublisher` является production-реализацией:
```text
AcquisitionRuntimeEventPublisherProtocol
```
### 12.1. Delivery model
Publisher:
- принимает immutable-набор Consumer через constructor injection;
- последовательно await-ит Consumer в порядке регистрации;
- сериализует конкурентные `publish()` одним `asyncio.Lock`;
- возвращается только после завершения fan-out;
- не создаёт `asyncio.Task`;
- не хранит event queue;
- не имеет собственного `start()` или `stop()`.
Рекурсивная публикация через тот же Publisher отклоняется до входа
в lock. Для определения прямой и child-task reentrancy используется
`ContextVar`.
### 12.2. Consumer failure policy
Обычная ошибка одного Consumer:
- не прерывает доставку остальным Consumer;
- не прерывает Heartbeat или Reconnect lifecycle;
- фиксируется только безопасными метаданными:
`event type`, `consumer type`, `error type`;
- не добавляет exception message, traceback или `repr(event)` в лог.
Ошибка logging handler также не выходит из Publisher.
`CancelledError` и другие `BaseException` не перехватываются.
Publisher освобождает lock и reentrancy context в `finally`, поэтому
может использоваться после отменённого вызова.
### 12.3. Logging Consumer
`AcquisitionRuntimeEventLoggingConsumer` использует стандартный
Python logging.
Lifecycle и failure events получают соответствующие уровни
`INFO`, `WARNING` и `ERROR`.
Для `MessageReceivedEvent` и `MessageSentEvent` на уровне `DEBUG`
фиксируются только:
- тип transport message;
- размер payload.
Содержимое payload не журналируется.
### 12.4. Legacy Runtime Events
Существующий `src/runtime_events/publisher.py` не изменён.
Новый Publisher не зависит от:
- `NotificationService`;
- Telegram;
- `JournalService`;
- legacy `RuntimeEvent`;
- market models.
Преобразование Acquisition Runtime Event в legacy Runtime Event
в настоящий Build не добавлено.
---
## 13. Build 060.25.4 — Trade Stream Production Runtime
**Статус:** Accepted
Реализован один высокоуровневый владелец lifecycle:
TradeStreamProductionRuntime
### 13.1. Задачи
Production Runtime владеет:
- startup sequence;
- receive task;
- lifecycle gate;
- cancellation;
- graceful shutdown;
- terminal error propagation.
Startup, Scheduler и receive выполняются как явно сохранённые owned
tasks.
Внешний bootstrap остаётся владельцем корневой coroutine `run()`.
Production Runtime синхронно закрепляет Scheduler за собой до первого
await сетевого startup. Поэтому другой lifecycle owner не может
запустить тот же Scheduler во время подключения или подписки.
### 13.2. Receive loop
Существует ровно один consumer:
```text
transport.receive()
decode JSON
├── ACK / control → runtime control handling
├── market document → TradeStreamAcquisitionService
└── invalid document → explicit error policy
```
ACK и control messages нельзя передавать в Unified Market Adapter как
неизвестный market event.
Production Runtime создаёт correlation ID Trade subscription и
передаёт его через Acquisition Service. Provider-specific handler:
- принимает только совпадающий correlation ID;
- подтверждает успешный ACK;
- идемпотентно принимает повторный успешный ACK;
- делает отрицательный ACK terminal error;
- отклоняет неизвестный correlation ID и malformed control message.
### 13.3. Task ownership
Production Runtime создаёт, хранит, отменяет и await-ит собственные
startup, Scheduler и receive tasks.
`stop()` может отменить незавершившиеся:
- Session startup;
- публикацию `ConnectedEvent`;
- отправку Trade subscription.
Ни Transport, ни Session, ни Heartbeat не создают скрытые background
tasks на уровне приложения.
---
## 14. Build 060.25.5 — Reconnect and Recovery
**Статус:** Accepted
### 14.1. Основная последовательность
```text
connection failure or confirmed liveness timeout
acquire single reconnect/recovery gate
pause live message processing
disconnect old connection
connect new connection
restore desired subscriptions
capture recovery_end_time
run RuntimeRecoveryCoordinator
resume buffered live messages
```
### 14.2. Recovery execution
`RuntimeRecoveryCoordinator.recover()` является синхронным и использует
блокирующий REST.
В asyncio Runtime он должен выполняться:
```text
await asyncio.to_thread(...)
```
либо через отдельный async adapter с эквивалентной семантикой.
### 14.3. Shared state
Live и Recovery используют один:
```text
TradeStreamStateStore
TradeStreamConsistencyController
```
Поэтому live processing должен быть приостановлен на время Recovery.
Одновременное изменение state из event loop и recovery thread
запрещено.
### 14.4. Boundary policy
После восстановления подписки новые WebSocket frames могут накапливаться
в transport buffer.
Recovery обрабатывается первым, затем buffered live frames.
Повтор на общей временной границе устраняется существующим Consistency
Layer.
### 14.5. Single-flight и поколения connection
Добавлен:
```text
RuntimeReconnectRecoveryCoordinator
```
Он объединяет базовый `ReconnectCoordinator` и
`RuntimeRecoveryCoordinator` в одну single-flight операцию.
Одновременные запросы reconnect присоединяются к уже выполняющейся
операции. Ошибка старого connection generation не запускает повторный
reconnect после того, как её поколение уже было обработано.
### 14.6. Cancellation и ошибки Recovery
Recovery запускается через `asyncio.to_thread()` при закрытом live gate.
Если владеющая asyncio-задача отменена, Runtime продолжает ждать
фактического завершения Recovery worker. Повторные запросы отмены не
могут преждевременно открыть gate и оставить worker изменяющим
Consistency state в фоне.
После ошибки reconnect или Recovery gate переходит в failed state.
Накопленные live messages не передаются в Consistency Layer. Failed
state очищается только при начале нового полного Production Runtime
lifecycle.
### 14.7. Composition identity
Composition создаёт один экземпляр live gate и передаёт один
`RuntimeReconnectRecoveryCoordinator` в `RuntimeSupervisor`.
Production Runtime проверяет:
- identity общего live gate;
- равенство нормализованного набора symbols для Live и Recovery.
Несогласованный dependency graph отклоняется до запуска WebSocket
lifecycle.
---
## 15. Build 060.25.6 — Heartbeat and Scheduler
**Статус:** Accepted
### 15.1. Liveness
Отсутствие сделок не означает потерю соединения.
Для тихого symbol допустима длительная пауза между market events.
Поэтому reconnect нельзя запускать только по условию:
```text
no Trade messages
```
Liveness подтверждается двумя transport-level источниками:
- успешным WebSocket Ping/Pong;
- успешно полученным WebSocket message независимо от его market/control
назначения.
### 15.2. Реализованная граница
Реализован отдельный контракт:
```text
RuntimeLivenessProbeProtocol
WebSocket ping/pong implementation
├── success → supervisor.notify_activity()
└── failure → reconnect path
```
`DzengiWebSocketTransport.probe()`:
- возвращает `True` только после соответствующего Pong;
- возвращает `False`, если connection отсутствует, закрыт или Pong не
получен вовремя;
- не перехватывает cancellation;
- распространяет неожиданную ошибку открытого connection как
`WebSocketTransportError`.
Ожидание Pong всегда имеет конечную границу `probe_timeout`.
Если отдельное значение не передано, оно берётся из конечного
`ping_timeout`. При отключённом `ping_timeout` применяется безопасное
значение 20 секунд. Неположительное и бесконечное значение запрещено.
### 15.3. Scheduler
Scheduler продолжает владеть только временем вызовов.
Каждая итерация:
1. выполняет transport probe;
2. при успешном Pong уведомляет Supervisor об активности;
3. проверяет Heartbeat timeout;
4. передаёт подтверждённый timeout Supervisor.
Он не должен:
- отправлять WebSocket payload самостоятельно;
- изменять subscription registry;
- выполнять Trade Recovery;
- владеть receive loop.
### 15.4. Reconnect generation
Supervisor связывает каждый Heartbeat monitoring period с generation
текущего connection.
Heartbeat timeout передаётся в
`reconnect_after_transport_failure(observed_generation=...)`.
Поэтому timeout и receive error одного поколения:
- присоединяются к одной активной reconnect/recovery operation;
- используют результат уже завершённой operation;
- не запускают повторный reconnect после восстановления того же
connection generation.
### 15.5. Task ownership и shutdown
Production Runtime выполняет `Scheduler.claim(owner)` до первого
сетевого await и освобождает claim только после завершения owned task.
Это исключает запуск Scheduler вторым владельцем во время startup.
Порядок остановки:
```text
Scheduler stop / cancel / await
Supervisor stop
receive task cancel / await
Session stop
```
Scheduler-led reconnect/recovery достигает безопасной границы до
остановки Supervisor. Receive-led operation безопасно завершается при
последующем cancel/await receive task и до остановки Session.
---
## 16. Build 060.25.7 — Settings and Bootstrap
**Статус:** Accepted
### 16.1. Settings
Добавлена отдельная неизменяемая группа:
```text
TradeStreamSettings
├── enabled
├── websocket_url
├── symbols
├── open_timeout_seconds
├── probe_timeout_seconds
├── close_timeout_seconds
├── heartbeat_timeout_seconds
├── scheduler_interval_seconds
└── recovery_window_ms
```
Production Trade Stream должен иметь отдельный feature flag с безопасным
значением по умолчанию.
Общий `EXCHANGE_ENABLED` не должен неявно запускать новый Runtime без
явной настройки.
Используются переменные:
```text
TRADE_STREAM_ENABLED=false
TRADE_STREAM_WS_URL
TRADE_STREAM_SYMBOLS
TRADE_STREAM_OPEN_TIMEOUT_SECONDS=10
TRADE_STREAM_PROBE_TIMEOUT_SECONDS=20
TRADE_STREAM_CLOSE_TIMEOUT_SECONDS=10
TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS=30
TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS=5
TRADE_STREAM_RECOVERY_WINDOW_MS=3599999
```
При выключенном flag зависимые Trade Stream значения не проверяются и
не создают Runtime. При включённом flag обязательны отдельные
`TRADE_STREAM_WS_URL`, `TRADE_STREAM_SYMBOLS` и `EXCHANGE_BASE_URL` для
REST Recovery.
Fallback с `EXCHANGE_WS_URL` и `DEFAULT_SYMBOL` запрещён.
`EXCHANGE_BASE_URL` и `EXCHANGE_TIMEOUT_SEC` используются только
явным REST-клиентом Recovery.
Все интервалы должны быть положительными и конечными, recovery window —
положительным целым числом. Символы очищаются от пробелов,
дедуплицируются и сортируются.
### 16.2. Bootstrap
Добавлен отдельный production composition root:
```text
build_trade_stream_production_runtime(settings)
├── feature flag disabled → None
└── enabled
├── DzengiWebSocketTransport
├── WebSocketSession
├── WebSocketSubscriptionManager
├── AcquisitionRuntimeEventPublisher
├── DzengiTradesDocumentSource
├── TradeStreamRuntimeComposition
└── TradeStreamProductionRuntime
```
Factory использует один снимок `Settings`. Создание графа:
- не открывает WebSocket;
- не выполняет REST;
- не создаёт asyncio-задачи;
- сохраняет identity общих stateful-зависимостей.
Встроенный keepalive библиотеки `websockets` отключён:
```text
ping_interval=None
ping_timeout=None
```
Единственным владельцем активного Ping/Pong является Runtime Scheduler
через конечный `probe_timeout`.
Корневой `ApplicationComposition` содержит Telegram Bot, Dispatcher и
опциональный Trade Stream Runtime. `run_application()` создаёт и
контролирует обе root tasks.
Ошибка или неожиданное нормальное завершение включённого Trade Stream
фатальны для всего процесса. Ошибка Telegram polling симметрично
останавливает Trade Stream. Исходная ошибка сохраняет приоритет над
ошибками cleanup.
`main.py` не должен содержать детали WebSocket protocol или recovery
алгоритма.
### 16.3. Shutdown
Внешний порядок приложения:
```text
stop accepting Telegram work
TradeStreamProductionRuntime.stop()
await both root tasks
close bot session exactly once
```
Внутренний порядок Trade Stream Runtime:
```text
Scheduler stop / cancel / await
Supervisor stop
receive task cancel / await
Session stop
clear subscriptions
```
Application Runner передаёт Aiogram параметр
`close_bot_session=False`, поэтому сессию закрывает ровно один корневой
владелец.
Cancellation не прерывает начатый cleanup: cleanup выполняется в
отдельной owned task и ожидается через shield. Все созданные root tasks
отменяются либо завершаются и обязательно await-ятся.
---
## 17. Build 060.25.8 — Verification
**Статус:** Accepted
060.25.8 не добавляет production-поведение. Этап подтверждает
детерминированными in-process тестами, что принятые компоненты работают
как один production graph.
Добавлен управляемый test harness:
```text
Production Trade Stream Factory
├── real DzengiWebSocketTransport
├── real WebSocketSession
├── real WebSocketSubscriptionManager
├── real Runtime Composition
└── real TradeStreamProductionRuntime
├── controlled fake WebSocket connector
├── controlled fake REST client
└── fake Telegram boundary
```
Harness не использует реальную сеть, реальные credentials или
недетерминированные внешние задержки.
Подтверждены следующие vertical-slice сценарии:
1. выключенный feature flag не создаёт Runtime graph;
2. production factory выполняет subscribe → ACK → Trade → checkpoint;
3. ошибка WebSocket connect фатальна и не оставляет задач;
4. ошибка первой subscription send откатывает partial startup;
5. reconnect открывает новое connection и восстанавливает subscription
до запуска REST Recovery;
6. recovered Trade обрабатывается раньше buffered Live Trade;
7. ошибка Recovery оставляет gate в failed state и отклоняет buffered
Trade;
8. одновременные ошибки Telegram и Trade Stream обрабатываются
детерминированно;
9. повторная cancellation не прерывает начатый cleanup;
10. Bot session закрывается один раз, owned tasks всегда await-ятся.
Production-код в 060.25.8 не изменён.
В Build 060.26 остаются:
- live exchange integration;
- длительные reconnect scenarios;
- recovery scenarios с реальными задержками;
- stress testing;
- network fault injection;
- финальная Runtime documentation verification.
Unit tests по умолчанию не должны обращаться к внешней сети.
---
## 18. Реализованные файлы
### 18.1. Production code
Добавлены:
```text
app/src/market_data/acquisition/adapters/dzengi/
websocket_control_message_handler.py
websocket_inbound_message_classifier.py
websocket_transport.py
app/src/market_data/acquisition/runtime/
acquisition_runtime_event_logging_consumer.py
acquisition_runtime_event_publisher.py
live_processing_gate.py
runtime_liveness_probe.py
runtime_reconnect_recovery_coordinator.py
trade_stream_production_runtime.py
websocket_inbound_message.py
websocket_session.py
websocket_subscription_manager.py
app/src/bootstrap/
application.py
trade_stream_runtime.py
```
Изменены:
```text
app/.env.example
app/src/bootstrap/app_factory.py
app/src/core/config.py
app/src/integrations/exchange/rest_client.py
app/src/main.py
app/src/market_data/acquisition/exceptions.py
app/src/market_data/acquisition/runtime/reconnect.py
app/src/market_data/acquisition/runtime/scheduler.py
app/src/market_data/acquisition/runtime/supervisor.py
app/src/market_data/acquisition/trade_stream_runtime_composition.py
```
### 18.2. Tests
Добавлены:
```text
app/tests/unit/market_data/acquisition/adapters/dzengi/
test_websocket_control_message_handler.py
test_websocket_inbound_message_classifier.py
test_websocket_transport.py
app/tests/unit/market_data/acquisition/runtime/
test_acquisition_runtime_event_logging_consumer.py
test_acquisition_runtime_event_publisher.py
test_live_processing_gate.py
test_runtime_reconnect_recovery_coordinator.py
test_trade_stream_production_runtime.py
test_websocket_session.py
test_websocket_subscription_manager.py
test_websocket_runtime_reconnect_integration.py
app/tests/unit/bootstrap/
test_app_factory.py
test_application.py
test_trade_stream_production_integration.py
test_trade_stream_runtime.py
app/tests/unit/core/
test_config.py
app/tests/unit/test_main.py
```
Изменены:
```text
app/tests/unit/market_data/acquisition/runtime/
test_reconnect_coordinator.py
test_runtime_recovery_coordinator.py
test_runtime_scheduler.py
test_runtime_supervisor.py
app/tests/unit/market_data/acquisition/
test_trade_stream_runtime_composition.py
```
Состав файлов соответствует итоговому состоянию Build 060.25.
---
## 19. Test evidence
После реализации 060.25.8 выполнены:
```text
Settings / Bootstrap tests: 30 passed
New vertical / lifecycle scenarios: 8 passed
Expanded Production Runtime target: 445 passed
Full project regression: 1869 passed
```
Расширенный целевой набор включает:
- Trade Stream settings;
- Application Composition, factory и `main.py`;
- production factory vertical-slice integration;
- весь `market_data/acquisition/runtime`;
- Dzengi WebSocket Transport, classifier и control handler;
- Trade Stream Runtime Composition.
Отдельно проверены:
```text
OPEN connection
│ reconnect()
old connection closed
new connection opened
subscriptions restored
```
и:
```text
initial subscribe send failure
desired subscription remains pending
│ reconnect()
subscription sent through new connection
```
Для Async Runtime Event Publisher отдельно подтверждены:
- последовательная доставка;
- сериализация конкурентных публикаций;
- отсутствие fire-and-forget;
- consumer error isolation;
- отсутствие payload в error log;
- устойчивость к ошибке logging handler;
- распространение cancellation;
- повторное использование после cancellation;
- защита от прямой и child-task reentrancy;
- сохранение Heartbeat и Reconnect lifecycle при ошибке Consumer.
После read-only review 060.25.8 целевой набор и полная регрессия
повторены 2026-07-31. Новых findings не обнаружено.
---
## 20. Принятые архитектурные решения
### ADR-060.25-001 — Transport остаётся raw boundary
**Статус:** Accepted
DzengiWebSocketTransport работает только с `str | bytes` и не содержит
JSON или market routing.
### ADR-060.25-002 — Session start является идемпотентным
**Статус:** Accepted
Повторный start не заменяет исправное открытое соединение.
### ADR-060.25-003 — Reconnect всегда выполняет disconnect
**Статус:** Accepted
Force reconnect выражен последовательностью Disconnect → Connect, а не
скрытым поведением Session.start().
### ADR-060.25-004 — Subscription registry хранит desired state
**Статус:** Accepted
Сетевой send failure не удаляет намерение подписаться. Desired и active
state разделены.
### ADR-060.25-005 — Unsupported unsubscribe является явной ошибкой
**Статус:** Accepted
Dzengi unsubscribe не имитируется локальным успешным результатом.
### ADR-060.25-006 — Production Runtime владеет задачами
**Статус:** Accepted
Внешний bootstrap владеет корневой task `run()`. Production Runtime
до сетевого startup закрепляет Scheduler за собой, а затем создаёт,
хранит, отменяет и await-ит собственные startup, Scheduler и receive
tasks. Claim освобождается после завершения Scheduler task.
### ADR-060.25-007 — Recovery выполняется при закрытом live gate
**Статус:** Accepted
REST Recovery и live message processing не изменяют общий Consistency
state параллельно. Отмена ожидает фактического завершения Recovery
worker, а ошибка Recovery блокирует buffered live processing до нового
Runtime lifecycle.
### ADR-060.25-008 — Heartbeat основан на transport liveness
**Статус:** Accepted
Отсутствие Trade events не является достаточным признаком разрыва.
Liveness подтверждается конечным Ping/Pong probe либо успешно полученным
WebSocket message. Timeout и receive error используют одно connection
generation и одну single-flight reconnect/recovery operation.
### ADR-060.25-009 — Runtime Events доставляются inline
**Статус:** Accepted
Publisher последовательно await-ит фиксированный набор Consumer и не
создаёт скрытую очередь или background task.
### ADR-060.25-010 — Consumer errors не управляют Runtime lifecycle
**Статус:** Accepted
Обычная ошибка Consumer изолируется, безопасно диагностируется и не
прерывает доставку остальным Consumer. Cancellation распространяется.
### ADR-060.25-011 — Trade Stream включается только отдельным flag
**Статус:** Accepted
`TRADE_STREAM_ENABLED` по умолчанию выключен. `EXCHANGE_ENABLED` не
запускает новый Runtime, а legacy WebSocket URL и default symbol не
используются как fallback.
### ADR-060.25-012 — Ошибка включённого Runtime фатальна
**Статус:** Accepted
Telegram polling и Trade Stream принадлежат одному Application Runner.
Terminal error любой включённой root task останавливает вторую root
task, после чего обе задачи ожидаются и сессия Bot закрывается ровно
один раз.
### ADR-060.25-013 — Scheduler является единственным keepalive owner
**Статус:** Accepted
Автоматический keepalive `websockets` отключён. Ping/Pong выполняется
только Scheduler через явный transport liveness probe с конечным
timeout.
---
## 21. Инварианты Build
К завершению 060.25 должны выполняться:
```text
exactly one production receive loop
```
```text
exactly one scheduler task
```
```text
at most one reconnect/recovery sequence
```
```text
reconnect =
disconnect
→ connect
→ restore subscriptions
```
```text
recovery and live state mutation are not concurrent
```
```text
desired subscriptions survive transient send failure
```
```text
quiet market does not cause false reconnect
```
```text
startup and shutdown are deterministic and awaitable
```
```text
Composition construction has no network side effects
```
```text
disabled Trade Stream creates no Runtime graph
```
```text
application owns and awaits both root tasks
```
```text
bot session is closed exactly once
```
```text
runtime event delivery is ordered and awaitable
```
```text
consumer failure does not interrupt runtime lifecycle
```
---
## 22. Не входит в Build
Build 060.25 не реализует:
- persistent market data storage;
- persistent checkpoint;
- startup recovery после перезапуска процесса;
- historical query API;
- replay API;
- analytics API;
- общий retry framework приложения;
- production stress certification.
Эти задачи относятся к Build 060.26060.29.
---
## 23. Критерии завершения
Build 060.25 получил статус `Completed`, поскольку:
- все этапы 060.25.0060.25.8 приняты;
- concrete Runtime dependencies собраны через composition root;
- Trade Stream запускается и останавливается из bootstrap;
- receive loop передаёт Trade messages в общий Consistency Layer;
- ACK/control messages не разрушают market pipeline;
- reconnect действительно заменяет connection;
- desired subscriptions восстанавливаются;
- Recovery выполняется до возобновления live processing;
- heartbeat не зависит только от частоты сделок;
- cancellation не оставляет фоновые задачи;
- целевые тесты и полная регрессия проходят;
- `git diff --check` не содержит новых ошибок;
- создан итоговый `build_060_25.md`;
- настоящий документ обновлён до финального фактического состояния.
---
## 24. Текущий итог
На момент версии 1.0:
```text
060.25.0 Accepted
060.25.1 Accepted
060.25.2 Accepted
060.25.3 Accepted
060.25.4 Accepted
060.25.5 Accepted
060.25.6 Accepted
060.25.7 Accepted
060.25.8 Accepted
```
Реализован production-ready фундамент WebSocket lifecycle:
- concrete Dzengi Transport;
- идемпотентная Session;
- desired/active Subscription Manager;
- принудительная замена connection во время reconnect;
- интеграционные unit-сценарии двух критических recovery cases;
- последовательный Async Runtime Event Publisher;
- безопасный Logging Consumer;
- изоляция Consumer errors от Heartbeat и Reconnect lifecycle;
- единый Trade Stream Production Runtime;
- отменяемые и awaitable startup/receive tasks;
- явная маршрутизация и проверка Trade subscription ACK;
- single-flight reconnect → subscription restore → Recovery;
- общий live/recovery gate с блокировкой buffered сообщений после
Recovery failure;
- безопасное ожидание Recovery worker при повторной cancellation;
- проверка единого набора Live и Recovery symbols;
- конечный WebSocket Ping/Pong liveness probe;
- generation-aware объединение heartbeat и receive reconnect;
- единственная owned Scheduler task;
- детерминированная остановка Scheduler → Supervisor → receive loop;
- отдельный безопасный Trade Stream feature flag;
- production composition root без сетевых side effects;
- один Application Runner для Telegram и Trade Stream;
- фатальность terminal error включённого Trade Stream;
- явные WebSocket URL и symbols без legacy fallback;
- отключённый встроенный WebSocket keepalive;
- детерминированное ожидание root tasks и однократное закрытие Bot
session.
Runtime подключён к bootstrap приложения и прошёл финальную
детерминированную in-process verification. Отдельный read-only review
060.25.8 не выявил findings; целевой набор из 445 тестов и полная
регрессия из 1869 тестов прошли. Build 060.25 завершён.