# Build 020 — Перенос кэша инструментов в Storage Layer **Engineering Migration Record** --- ## Контроль документа | Свойство | Значение | |---|---| | Документ | Build 020 — Перенос кэша инструментов в Storage Layer | | Тип документа | Engineering Migration Record | | Статус | **Complete** | | Проект | Dzentra | | Подсистема | Instrument Reference Data | | Build | 020 | | Язык | Русский | | Целевой файл | `docs/migrations/instrument_reference_data/build_020.md` | --- ## 1. Цель Build 020 Цель Build 020 — удалить каноническое хранение справочных данных инструментов из legacy-кэша `ExchangeService._exchange_symbols_cache` и перенести его в специализированный Storage Layer. До выполнения Build 020 `ExchangeService` одновременно отвечал за: - получение справочных данных инструментов; - запуск acquisition pipeline; - преобразование канонических моделей `Instrument` в legacy-модели `ExchangeSymbol`; - хранение результата в собственном class-level cache. Это создавало архитектурную зависимость канонических справочных данных от legacy exchange layer. После Build 020 канонические данные инструментов хранятся в специализированном: ```text InMemoryInstrumentStore ``` в виде: ```text tuple[Instrument, ...] ``` Legacy-модели `ExchangeSymbol` больше не являются каноническим представлением справочных данных. --- ## 2. Архитектурное состояние до Build 020 До миграции `ExchangeService` содержал: ```python _exchange_symbols_cache: list[ExchangeSymbol] | None = None ``` Метод: ```python get_exchange_symbols() ``` работал по следующей схеме: ```text get_exchange_symbols() ↓ _exchange_symbols_cache ↓ cache miss _load_exchange_symbols_via_acquisition() ↓ Instrument Acquisition Pipeline ↓ tuple[Instrument, ...] ↓ map_instruments_to_exchange_symbols() ↓ list[ExchangeSymbol] ↓ _exchange_symbols_cache ``` Таким образом, результат новой Instrument Reference Data pipeline сразу преобразовывался в legacy-модель, и именно legacy-представление сохранялось как основной кэш. --- ## 3. Архитектурная проблема Старый подход имел несколько принципиальных недостатков. ### 3.1. Legacy-модель использовалась как каноническое хранилище Каноническая модель: ```python Instrument ``` содержит полные справочные данные инструмента. Legacy-модель: ```python ExchangeSymbol ``` является сокращённой compatibility-проекцией и содержит только часть этих данных. Хранение только `ExchangeSymbol` означало потерю полноценного канонического представления после завершения acquisition pipeline. ### 3.2. Exchange Layer владел состоянием справочных данных Поле: ```python ExchangeService._exchange_symbols_cache ``` делало `ExchangeService` владельцем справочных данных инструментов. Это противоречило целевой архитектуре: ```text Acquisition Layer ↓ Canonical Instrument Models ↓ Storage Layer ↓ Consumers / Compatibility Projections ``` ### 3.3. Канонические данные и legacy-проекция были объединены Результат acquisition pipeline немедленно преобразовывался: ```text Instrument ↓ ExchangeSymbol ``` и сохранялся только после преобразования. Это не позволяло независимо использовать полный `Instrument` в будущих подсистемах Dzentra. --- ## 4. Реализованное архитектурное решение В Build 020 введено разделение между: 1. каноническим хранилищем инструментов; 2. legacy compatibility projection cache. Теперь `ExchangeService` использует: ```python _instrument_store: InstrumentStoreProtocol = InMemoryInstrumentStore() ``` для хранения канонических моделей: ```python tuple[Instrument, ...] ``` и отдельный: ```python _exchange_symbols_projection_cache: list[ExchangeSymbol] | None = None ``` для временного кэширования legacy-проекции. Итоговая схема: ```text DzengiInstrumentDocumentSource ↓ InstrumentFeed ↓ DzengiInstrumentDocumentHandler ↓ InstrumentAcquisitionService ↓ tuple[Instrument, ...] ↓ InMemoryInstrumentStore ↓ Canonical Instrument Reference Data ↓ map_instruments_to_exchange_symbols() ↓ list[ExchangeSymbol] ↓ Legacy Compatibility Projection Cache ``` --- ## 5. Каноническое хранилище Канонические справочные данные теперь находятся в: ```python ExchangeService._instrument_store ``` Тип зависимости: ```python InstrumentStoreProtocol ``` Текущая реализация: ```python InMemoryInstrumentStore ``` Store хранит: ```python tuple[Instrument, ...] ``` Ключ источника: ```text dzengi ``` Таким образом, канонические справочные данные больше не зависят от legacy-модели `ExchangeSymbol`. --- ## 6. Новый production flow Метод: ```python get_exchange_symbols() ``` сохраняет прежний внешний контракт: ```python list[ExchangeSymbol] ``` Это необходимо для сохранения работоспособности существующего бота во время поэтапной миграции. Внутренний flow теперь выглядит следующим образом: ```text get_exchange_symbols() ↓ Проверка exchange_enabled ↓ Проверка _exchange_symbols_projection_cache ↓ cache miss Чтение InstrumentStore ↓ store miss _load_instruments_via_acquisition() ↓ tuple[Instrument, ...] ↓ Сохранение в InstrumentStore ↓ map_instruments_to_exchange_symbols() ↓ list[ExchangeSymbol] ↓ Сохранение в _exchange_symbols_projection_cache ↓ Возврат legacy-результата ``` --- ## 7. Разделение канонического кэша и compatibility projection cache После Build 020 существуют два разных уровня состояния. ### 7.1. Каноническое состояние ```python _instrument_store ``` Хранит: ```python tuple[Instrument, ...] ``` Назначение: - хранение полной справочной модели инструмента; - повторное использование канонических данных; - основа для будущих market intelligence consumers; - независимость от legacy exchange models. ### 7.2. Compatibility projection cache ```python _exchange_symbols_projection_cache ``` Хранит: ```python list[ExchangeSymbol] | None ``` Назначение: - сохранить старый контракт `get_exchange_symbols()`; - не выполнять повторный compatibility mapping при каждом вызове; - обеспечить безопасную постепенную миграцию legacy-кода. `_exchange_symbols_projection_cache` не является источником истины. Источником истины является: ```text InstrumentStore ``` --- ## 8. Изменение acquisition loader Старый метод: ```python _load_exchange_symbols_via_acquisition() ``` удалён. Он одновременно: - запускал acquisition pipeline; - получал `Instrument`; - выполнял compatibility mapping; - возвращал `ExchangeSymbol`. Вместо него используется: ```python _load_instruments_via_acquisition() ``` Новый метод возвращает: ```python tuple[Instrument, ...] ``` и не выполняет: ```python map_instruments_to_exchange_symbols() ``` Это обеспечивает чистую архитектурную границу: ```text Acquisition Pipeline ↓ Canonical Instrument Models ``` Compatibility mapping выполняется отдельно только там, где действительно требуется legacy-контракт. --- ## 9. Сохранение обратной совместимости Build 020 не меняет публичный контракт: ```python ExchangeService.get_exchange_symbols() ``` Он по-прежнему возвращает: ```python list[ExchangeSymbol] ``` Благодаря этому продолжают работать существующие consumers, включая: ```text validate_symbol() currency_ui.py legacy exchange UI существующие unit tests ``` Миграция выполнена без обязательного одновременного переписывания всех legacy consumers. --- ## 10. Поведение при отключённой бирже При: ```python exchange_enabled = False ``` метод: ```python get_exchange_symbols() ``` возвращает: ```python [] ``` При этом он не должен: - читать `InstrumentStore`; - использовать существующий projection cache; - запускать acquisition pipeline; - обращаться к реальной бирже. Это сохраняет прежнее поведение mock/disabled режима. --- ## 11. Поведение при cache hit Если существует: ```python _exchange_symbols_projection_cache ``` метод возвращает существующий объект legacy-проекции без: - чтения acquisition source; - повторной обработки документа; - повторного compatibility mapping. Если projection cache отсутствует, но канонические данные уже существуют в: ```python InstrumentStore ``` то acquisition pipeline не запускается повторно. Вместо этого выполняется: ```text InstrumentStore ↓ tuple[Instrument, ...] ↓ Compatibility Mapper ↓ list[ExchangeSymbol] ``` Пустой канонический набор: ```python () ``` также считается валидным cache hit и не должен ошибочно интерпретироваться как отсутствие данных. --- ## 12. Поведение при cache miss Если одновременно отсутствуют: ```text _exchange_symbols_projection_cache InstrumentStore entry for "dzengi" ``` выполняется полный production flow: ```text Instrument Source ↓ Instrument Handler ↓ Instrument Acquisition Service ↓ tuple[Instrument, ...] ↓ InstrumentStore.set(...) ↓ Compatibility Mapper ↓ list[ExchangeSymbol] ``` После успешной загрузки: - канонический `tuple[Instrument, ...]` сохраняется в `InstrumentStore`; - legacy-проекция создаётся отдельно; - legacy-проекция сохраняется в `_exchange_symbols_projection_cache`. --- ## 13. Поведение при ошибках Если acquisition pipeline завершается ошибкой: - ошибка логируется как exchange request error; - endpoint сохраняется как: ```text exchangeInfo ``` - исходная ошибка становится причиной `ExchangeError`; - канонический store не должен получать частичные или некорректные данные; - projection cache не должен заполняться. Таким образом, ошибка acquisition не создаёт ложное успешное состояние. Если ошибка возникает на этапе compatibility mapping: - канонические данные уже могут находиться в `InstrumentStore`; - projection cache не должен заполняться некорректным результатом; - следующий вызов может повторно построить legacy-проекцию из сохранённых канонических данных без повторного запуска acquisition pipeline. --- ## 14. Инварианты Build 020 После завершения Build 020 действуют следующие обязательные инварианты. 1. `ExchangeService._exchange_symbols_cache` отсутствует в production-коде. 2. Канонические справочные данные хранятся как: ```python tuple[Instrument, ...] ``` 3. Владельцем канонического состояния является: ```text InstrumentStore ``` 4. Текущей реализацией store является: ```python InMemoryInstrumentStore ``` 5. `ExchangeService` зависит от абстракции: ```python InstrumentStoreProtocol ``` 6. Старый loader: ```python _load_exchange_symbols_via_acquisition() ``` отсутствует. 7. Новый loader: ```python _load_instruments_via_acquisition() ``` возвращает только: ```python tuple[Instrument, ...] ``` 8. Новый loader не вызывает: ```python map_instruments_to_exchange_symbols() ``` 9. Compatibility mapping выполняется после получения канонических моделей из store или acquisition pipeline. 10. `_exchange_symbols_projection_cache` не является каноническим источником данных. 11. Публичный контракт: ```python get_exchange_symbols() -> list[ExchangeSymbol] ``` сохранён для обратной совместимости. 12. `validate_symbol()` продолжает работать через `get_exchange_symbols()`. 13. При отключённой бирже store, projection cache и acquisition pipeline не используются. 14. Ошибка acquisition не заполняет канонический store. 15. Ошибка acquisition не заполняет projection cache. 16. Пустой `tuple[Instrument, ...]` является валидным сохранённым значением и должен отличаться от отсутствия записи в store. --- ## 15. Изменённые production-файлы Основные изменения Build 020 выполнены в: ```text app/src/integrations/exchange/service.py ``` Используются ранее подготовленные компоненты Storage Layer: ```text app/src/storage/instrument_store.py app/src/storage/exceptions.py ``` Используется compatibility mapper: ```text app/src/market_data/acquisition/compatibility.py ``` --- ## 16. Тестовое покрытие Основные проверки миграции находятся в: ```text app/tests/unit/integrations/exchange/test_service_exchange_symbols.py ``` Дополнительно проверена совместимость: ```text app/tests/unit/integrations/exchange/test_service_validate_symbol.py ``` И отдельно сохраняется тестовое покрытие самого store: ```text app/tests/unit/storage/test_instrument_store.py ``` Проверены следующие сценарии: - отключённая биржа возвращает пустой список; - отключённая биржа не читает существующий `InstrumentStore`; - cache hit legacy-проекции не запускает acquisition pipeline; - store hit не запускает acquisition pipeline; - store miss запускает acquisition pipeline; - загруженные `Instrument` сохраняются в store; - пустой tuple корректно обрабатывается как cache hit; - compatibility mapping получает именно канонические `Instrument`; - legacy-проекция сохраняет прежний тип `list[ExchangeSymbol]`; - сохраняется порядок инструментов; - acquisition error оборачивается в `ExchangeError`; - acquisition error логируется с endpoint `exchangeInfo`; - acquisition error не заполняет store; - acquisition error не заполняет projection cache; - acquisition loader использует реальную processing pipeline; - acquisition loader использует registry key `dzengi`; - acquisition loader возвращает `tuple[Instrument, ...]`; - acquisition loader не вызывает compatibility mapper; - `validate_symbol()` сохраняет прежнее поведение. --- ## 17. Результаты проверок Целевая проверка `get_exchange_symbols()`: ```text 23 passed in 0.12s ``` Целевая проверка `validate_symbol()`: ```text 16 passed in 0.07s ``` Проверка синтаксической компиляции: ```text py_compile — успешно ``` Полный regression suite: ```text 426 passed in 0.23s ``` Финальная архитектурная проверка подтвердила: ```text старый _exchange_symbols_cache отсутствует в production-коде; старый _load_exchange_symbols_via_acquisition отсутствует; канонический store подключён через InstrumentStoreProtocol; текущая реализация store — InMemoryInstrumentStore; legacy projection cache отделён от канонического store; compatibility mapper не вызывается внутри acquisition loader; новый acquisition loader возвращает канонические Instrument. ``` --- ## 18. Архитектурный результат До Build 020: ```text Exchange API ↓ Acquisition Pipeline ↓ Instrument ↓ Compatibility Mapper ↓ ExchangeSymbol ↓ ExchangeService class-level cache ``` После Build 020: ```text Exchange API ↓ Acquisition Pipeline ↓ Instrument ↓ InstrumentStore ↓ Canonical Instrument Reference Data ↓ Compatibility Mapper ↓ ExchangeSymbol ↓ Temporary Legacy Projection Cache ``` Ключевое изменение: ```text ExchangeSymbol больше не является канонически сохраняемой моделью Instrument Reference Data. ``` Каноническим представлением теперь является: ```python Instrument ``` а владельцем его состояния является: ```text Storage Layer ``` --- ## 19. Ограничения текущего этапа Build 020 намеренно не выполняет полную миграцию всех consumers на каноническую модель `Instrument`. На текущем этапе сохраняются: ```python get_exchange_symbols() -> list[ExchangeSymbol] ``` и: ```python _exchange_symbols_projection_cache ``` Они являются compatibility-механизмами переходного периода. Также Build 020 не переносит в новый `InstrumentStore`: - market price cache; - execution price cache; - balance snapshots; - journal storage; - другие runtime caches. Build 020 касается исключительно хранения канонических Instrument Reference Data. --- ## 20. Критерии завершения Build 020 считается завершённым, если одновременно выполняются следующие условия: - [x] созданный ранее `InstrumentStore` используется production-кодом; - [x] канонические данные хранятся как `tuple[Instrument, ...]`; - [x] старый `_exchange_symbols_cache` удалён; - [x] старый `_load_exchange_symbols_via_acquisition()` удалён; - [x] новый `_load_instruments_via_acquisition()` возвращает канонические модели; - [x] acquisition loader не выполняет compatibility mapping; - [x] legacy API `get_exchange_symbols()` сохранён; - [x] `validate_symbol()` сохраняет прежнее поведение; - [x] ошибки acquisition не создают ложное состояние кэша; - [x] целевые тесты проходят; - [x] `py_compile` проходит; - [x] полный regression suite проходит; - [x] финальная архитектурная grep-проверка выполнена. --- ## 21. Итоговый статус ```text BUILD 020 — COMPLETE ``` Build 020 завершает фактический перенос канонического кэша Instrument Reference Data из legacy `ExchangeService._exchange_symbols_cache` в специализированный Storage Layer. Существующий бот продолжает работать через сохранённый compatibility-контракт: ```python get_exchange_symbols() -> list[ExchangeSymbol] ``` При этом новая архитектура уже располагает полноценным каноническим хранилищем: ```text Instrument Acquisition Pipeline ↓ tuple[Instrument, ...] ↓ InstrumentStore ``` Это создаёт основу для дальнейшего поэтапного перевода consumers с legacy `ExchangeSymbol` на каноническую модель `Instrument` без нарушения работоспособности существующего бота.