diff --git a/.github/workflows/ubuntu-smoke.yml b/.github/workflows/ubuntu-smoke.yml index 2636ed7..da0c279 100644 --- a/.github/workflows/ubuntu-smoke.yml +++ b/.github/workflows/ubuntu-smoke.yml @@ -32,6 +32,14 @@ jobs: - name: Configure run: cmake -S . -B build-linux -DOPTIONX_BUILD_DEPS=ON -DOPTIONX_BUILD_TESTS=ON -DOPTIONX_BUILD_EXAMPLES=ON + - name: Build lifecycle stack test and example + run: cmake --build build-linux --target lifecycle_stack_test lifecycle_stack_example -j + + - name: Run lifecycle stack test and example + run: | + ./build-linux/lifecycle_stack_test --gtest_brief=1 + ./build-linux/lifecycle_stack_example + - name: Build trade_record_db_test run: cmake --build build-linux --target trade_record_db_test -j diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index 12134a5..850ddba 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -32,6 +32,18 @@ jobs: -DOPTIONX_BUILD_EXAMPLES=ON -DOPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS=ON + - name: Build lifecycle stack test and example + run: > + cmake --build build-windows --config Debug --target + lifecycle_stack_test lifecycle_stack_example + + - name: Run lifecycle stack test and example + shell: pwsh + run: | + $env:PATH = "$PWD\build-windows\bin;$PWD\build-windows\Debug;$env:PATH" + .\build-windows\Debug\lifecycle_stack_test.exe --gtest_brief=1 + .\build-windows\Debug\lifecycle_stack_example.exe + - name: Build MetaTrader path discovery test run: cmake --build build-windows --config Debug --target metatrader_paths_test diff --git a/AGENTS.md b/AGENTS.md index d3af5b7..23aa833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,10 @@ контракт Router, provider registry, replay, ownership и bot-thread dispatch. - [Market data router guide RU](guides/market-data-router.ru.md) - русский перевод, обновляемый вместе с каноническим руководством. +- [Lifecycle stack guide](guides/lifecycle-stack.md) - канонический контракт + общего process/shutdown API и staged reverse-order shutdown. +- [Lifecycle stack guide RU](guides/lifecycle-stack.ru.md) - русский перевод, + обновляемый вместе с каноническим руководством. - [Codebase orientation](guides/codebase-orientation.md) - карта проекта, DDD-слои, зависимости, расширение и безопасные точки входа. - [Build and test](guides/build-and-test.md) - CMake options, зависимости, @@ -91,3 +95,7 @@ `guides/market-data-router.md`. Любое смысловое изменение синхронизируй с `guides/market-data-router.ru.md` в том же PR; русский перевод не является источником обратных изменений английского контракта. +- Для общего lifecycle stack каноническая версия - английская + `guides/lifecycle-stack.md`. Любое смысловое изменение синхронизируй с + `guides/lifecycle-stack.ru.md` в том же PR; русский перевод не является + источником обратных изменений английского контракта. diff --git a/CMakeLists.txt b/CMakeLists.txt index c033914..019ceb2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -455,6 +455,41 @@ if(OPTIONX_BUILD_EXAMPLES) ) endif() + add_executable(lifecycle_stack_example examples/lifecycle_stack_example.cpp) + + target_include_directories(lifecycle_stack_example PRIVATE + ${EXAMPLE_INCLUDE_DIRS} + ${EXAMPLE_DEPS_INCLUDE_DIRS} + ) + + target_link_directories(lifecycle_stack_example PRIVATE ${EXAMPLE_LIBRARY_DIRS}) + target_compile_definitions( + lifecycle_stack_example PRIVATE + ${EXAMPLE_DEFINES} + LOGIT_BASE_PATH="${LOGIT_BASE_PATH_FWD}" + ) + target_link_libraries(lifecycle_stack_example PRIVATE ${EXAMPLE_LIBS} optionx_cpp) + + if(OPTIONX_BUILD_DEPS) + add_dependencies(lifecycle_stack_example mdbx-static AES) + endif() + + foreach(dll ${EXAMPLE_DLL_FILES}) + add_custom_command(TARGET lifecycle_stack_example POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${dll}" "$" + ) + endforeach() + + if(WIN32) + add_custom_command(TARGET lifecycle_stack_example POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DOPTIONX_RUNTIME_DLL_DIR="${EXAMPLE_BUILD_LIBS_DIR}/bin" + -DOPTIONX_RUNTIME_TARGET_DIR="$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/copy_runtime_dlls.cmake" + ) + endif() + add_executable(intrade_market_data_example examples/intrade_market_data_example.cpp) target_include_directories(intrade_market_data_example PRIVATE @@ -1032,6 +1067,7 @@ if(OPTIONX_BUILD_TESTS) set(OPTIONX_LIGHTWEIGHT_TESTS bridge_host_test + lifecycle_stack_test metatrader_paths_test telegram_dto_test telegram_signal_parser_test diff --git a/README.md b/README.md index 1974791..5de1339 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,5 @@ OptionX is a C++ library for working with APIs of various brokers and trading pl - Market-data routing, provider selection, replay, and bot threads: [English](guides/market-data-router.md) | [Русский](guides/market-data-router.ru.md) +- Optional common module processing and staged shutdown: + [English](guides/lifecycle-stack.md) | [Русский](guides/lifecycle-stack.ru.md) diff --git a/examples/lifecycle_stack_example.cpp b/examples/lifecycle_stack_example.cpp new file mode 100644 index 0000000..cec1552 --- /dev/null +++ b/examples/lifecycle_stack_example.cpp @@ -0,0 +1,135 @@ +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace lifecycle = optionx::lifecycle; +namespace market_data = optionx::market_data; + +class OwnerLoop final : public lifecycle::ILifecycleModule { +public: + bool post(std::function task) { + if (!task) return false; + std::lock_guard lock(m_mutex); + if (m_shutdown_requested) return false; + m_tasks.push_back(std::move(task)); + return true; + } + + void process() override { + std::vector> tasks; + { + std::lock_guard lock(m_mutex); + tasks.swap(m_tasks); + } + for (auto& task : tasks) task(); + + std::lock_guard lock(m_mutex); + if (m_shutdown_requested && m_tasks.empty()) m_stopped = true; + } + + void shutdown() noexcept override { + std::lock_guard lock(m_mutex); + m_shutdown_requested = true; + if (m_tasks.empty()) m_stopped = true; + } + + [[nodiscard]] bool is_stopped() const noexcept override { + std::lock_guard lock(m_mutex); + return m_stopped; + } + +private: + mutable std::mutex m_mutex; + std::vector> m_tasks; + bool m_shutdown_requested = false; + bool m_stopped = false; +}; + +class DeferredProvider final : public market_data::BaseMarketDataProvider { +public: + explicit DeferredProvider(OwnerLoop& owner_loop) + : m_owner_loop(owner_loop) {} + + bool subscribe_ticks( + market_data::TickSubscriptionRequest request, + subscription_callback_t callback) override { + auto subscription = market_data::MarketDataSubscriptionHandle::from_tick_request( + provider_id(), + m_next_subscription_id++, + request); + if (callback) { + callback(market_data::MarketDataSubscriptionResult::subscribed( + std::move(subscription))); + } + return true; + } + + bool unsubscribe( + market_data::MarketDataSubscriptionHandle subscription, + subscription_callback_t callback) override { + return m_owner_loop.post( + [subscription = std::move(subscription), + callback = std::move(callback)]() mutable { + if (callback) { + callback(market_data::MarketDataSubscriptionResult::unsubscribed( + std::move(subscription))); + } + }); + } + +private: + OwnerLoop& m_owner_loop; + market_data::SubscriptionId m_next_subscription_id = 1; +}; + +class QuoteSink final : public market_data::IMarketDataSubscriber {}; + +} // namespace + +int main() { + OwnerLoop owner_loop; + DeferredProvider provider(owner_loop); + market_data::MarketDataRouter router( + [&owner_loop](market_data::MarketDataRouter::owner_task_t task) { + return owner_loop.post(std::move(task)); + }); + auto subscriber = std::make_shared(); + + lifecycle::LifecycleStack application; + // Registration order declares dependencies: executor first, Router next. + if (!application.add_module(owner_loop) || + !application.add_module(router)) { + std::cerr << "Lifecycle registration failed\n"; + return 1; + } + + auto route = router.subscribe_ticks( + provider, + subscriber, + market_data::TickSubscriptionRequest("EURUSD")); + application.process(); + if (!route.active()) { + std::cerr << "Subscription did not become active\n"; + return 1; + } + + application.shutdown(); + while (!application.is_stopped()) { + application.process(); + } + if (route.valid()) { + std::cerr << "Routed subscription cleanup did not finish\n"; + return 1; + } + + std::cout << "Lifecycle stopped after routed subscription cleanup\n"; + return 0; +} diff --git a/guides/api-and-header-contracts.md b/guides/api-and-header-contracts.md index dd4ceae..553c045 100644 --- a/guides/api-and-header-contracts.md +++ b/guides/api-and-header-contracts.md @@ -108,6 +108,28 @@ etc.) являются implementation detail конкретной платфор метод сначала должен появиться на facade/base contract, а затем делегироваться в manager. +## Common Lifecycle Contract + +`optionx_cpp/lifecycle.hpp` exposes the optional +`lifecycle::ILifecycleModule` and `lifecycle::LifecycleStack` API. + +- A module implements `process()`, idempotent `shutdown() noexcept`, and the + terminal predicate `is_stopped()`. +- The stack stores non-owning references. Registered modules must outlive the + stack and its complete shutdown. +- Registration order is dependency order. Processing runs forward; shutdown is + staged in reverse, one module at a time. +- Dependencies remain processable until the current dependent module stops. +- Stack calls are owner-loop confined. The stack does not create threads. +- Initialization and `run()` remain explicit because existing module startup + contracts differ. +- `MarketDataRouter` and `BaseTradingPlatform` implement the common interface. + Direct lifecycle calls remain supported. + +See [lifecycle-stack.md](lifecycle-stack.md) for the complete contract and +[lifecycle-stack.ru.md](lifecycle-stack.ru.md) for the synchronized Russian +version. + ## Account Info Subscriber Contract `components::AccountInfoHub` is an optional fan-out adapter for the single diff --git a/guides/build-and-test.md b/guides/build-and-test.md index cf89559..3943972 100644 --- a/guides/build-and-test.md +++ b/guides/build-and-test.md @@ -207,6 +207,7 @@ targets. - `examples/event_mediator_test.cpp` - `examples/intrade_bar_api_example.cpp` +- `examples/lifecycle_stack_example.cpp` - `examples/market_data_router_example.cpp` - `examples/market_data_subscriber_base_example.cpp` - `examples/task_manager_example.cpp` diff --git a/guides/codebase-orientation.md b/guides/codebase-orientation.md index 5a17380..869bce3 100644 --- a/guides/codebase-orientation.md +++ b/guides/codebase-orientation.md @@ -24,8 +24,8 @@ ## Public Include Points Главная include-точка - `include/optionx_cpp/optionx.hpp`. Она включает -`utils.hpp`, `data.hpp`, `storages.hpp`, `components.hpp`, `platforms.hpp`, -`bridges.hpp`. +`utils.hpp`, `lifecycle.hpp`, `data.hpp`, `storages.hpp`, `components.hpp`, +`platforms.hpp`, `bridges.hpp`. Aggregate headers в корне `include/optionx_cpp` - часть публичной поверхности. Если добавляешь новый публичный DTO/component/platform, проверь соответствующий diff --git a/guides/implementation-notes.md b/guides/implementation-notes.md index e719c80..3fa89e5 100644 --- a/guides/implementation-notes.md +++ b/guides/implementation-notes.md @@ -50,6 +50,19 @@ Lifecycle: - Registered components хранятся как raw pointers; concrete platform должна владеть ими как fields и гарантировать lifetime. +### Optional Application Lifecycle Stack + +`lifecycle::LifecycleStack` composes top-level modules without replacing their +direct APIs. Register dependencies first. Normal processing runs forward; +shutdown is staged in reverse and does not stop a lower-level executor until +the current dependent module reports `is_stopped()`. + +The stack is non-owning and owner-loop confined. It intentionally does not call +`initialize()` or `run()` because existing platform, component, Router, and bot +startup contracts are not equivalent. Keep startup explicit and use the stack +only when common processing and shutdown are useful. The canonical contract is +in [lifecycle-stack.md](lifecycle-stack.md). + ### IntradeBar Delayed Retry Lifecycle Note Do not report a use-after-free risk for the current IntradeBar settings-switch diff --git a/guides/lifecycle-stack.md b/guides/lifecycle-stack.md new file mode 100644 index 0000000..5868c87 --- /dev/null +++ b/guides/lifecycle-stack.md @@ -0,0 +1,183 @@ +# Lifecycle Stack Guide + +This is the canonical guide for the optional common lifecycle API. Keep +[`lifecycle-stack.ru.md`](lifecycle-stack.ru.md) synchronized when this contract +changes. + +## Purpose + +`LifecycleStack` lets an application drive several modules through one +`process()` call and one staged `shutdown()` request. It is useful when a +platform owner loop, `MarketDataRouter`, bots, node systems, and similar modules +start and stop together. + +The stack is optional. Every module keeps its direct lifecycle API and may be +managed without `LifecycleStack`. + +Public include: + +```cpp +#include +``` + +## Module Contract + +Modules implement `optionx::lifecycle::ILifecycleModule`: + +```cpp +class ILifecycleModule { +public: + virtual void process() = 0; + virtual void shutdown() noexcept = 0; + virtual bool is_stopped() const noexcept = 0; +}; +``` + +The contract is deliberately small: + +- `process()` advances normal work or an in-progress graceful shutdown; +- `shutdown()` is an idempotent request to stop accepting new work; +- `is_stopped()` becomes true only after module-owned work and cleanup finish. + +`shutdown()` does not have to complete asynchronous cleanup before returning. +The owner loop continues calling `process()` until the terminal state is +reached. + +`MarketDataRouter` implements this interface. Its common `is_stopped()` state is +the same as `is_shutdown_complete()`. `BaseTradingPlatform` also implements the +interface and reports its existing terminal lifecycle state. + +## Registration And Ownership + +Register dependencies first and dependents last: + +```text +platform / owner executor + -> provider-facing Router + -> bots or node systems +``` + +```cpp +lifecycle::LifecycleStack application; +application.add_module(platform); +application.add_module(router); +application.add_module(bot); +``` + +The stack stores non-owning pointers. Every registered module must outlive the +stack and its complete shutdown. Duplicate registration, self-registration, and +registration after shutdown starts are rejected. + +Registration order is a dependency declaration, not merely presentation order: + +- normal `process()` runs forward; +- `shutdown()` runs in reverse; +- only one dependent shutdown stage is active at a time; +- lower-level modules keep processing until the current dependent reports + `is_stopped()`. + +Synchronous stages can collapse into one `shutdown()` call. An asynchronous +stage pauses the reverse walk until later `process()` calls complete it. + +## Startup + +`LifecycleStack` does not call `initialize()` or `run()`. Existing objects have +different startup contracts: a platform has `run(bool)`, a component has +`initialize()`, and a bot may require application-specific configuration. Keep +that work explicit: + +```cpp +platform.configure_auth(...); +platform.run(false); +bot.start(); + +lifecycle::LifecycleStack application; +application.add_module(platform); +application.add_module(router); +application.add_module(bot); +``` + +This avoids inventing a lowest-common-denominator startup API. A separate +initialization capability can be added later if multiple real modules share the +same semantics. + +## Owner Loop + +Call `LifecycleStack::process()` and `shutdown()` from the same owner loop. The +stack itself does not create a thread and does not add synchronization around +module methods. + +For a manually driven platform, the host loop becomes: + +```cpp +while (running) { + application.process(); +} + +application.shutdown(); +while (!application.is_stopped()) { + application.process(); +} +``` + +With registration order `platform -> router -> bot`, each tick first lets the +platform execute queued provider callbacks, then lets Router consume retained +lifecycle completions, then processes the bot while it is still active. + +Do not also drive a platform manually when it already owns a worker thread. +For that mode, schedule the common supervisor on the actual owner loop or keep +using the modules' direct lifecycle APIs. + +## Staged Shutdown + +Given this registration order: + +```text +platform -> router -> bot +``` + +the shutdown sequence is: + +```text +bot.shutdown() +while bot is not stopped: + platform.process() + router.process() + bot.process() + +router.shutdown() +while router is not stopped: + platform.process() + router.process() + +platform.shutdown() +``` + +Application code sees only `application.shutdown()` and +`application.process()`. The stack keeps the platform/executor alive while +Router waits for late provider completions and physical unsubscribe results. + +`LifecycleStack` is also an `ILifecycleModule`, so stacks may be nested when a +larger application has independently composed subsystems. + +Nested lifecycle stacks must form an acyclic dependency graph. `add_module()` +rejects self-registration and duplicate references, but it does not detect an +indirect cycle such as `stack_a -> stack_b -> stack_a`; the application must +avoid such registrations. + +## Failures And Limits + +The stack does not invent retry, timeout, or abandon policies. For example, a +failed Router unsubscribe keeps Router and therefore the whole stack in a +non-terminal state. The application may inspect +`failed_unsubscribe_count()` and call `retry_failed_unsubscribes()` with its own +backoff while the provider remains alive. + +`process()` exceptions propagate to the caller. Module shutdown is `noexcept` +by contract. The stack does not own modules, destroy them, or call shutdown from +its destructor. + +The runnable integration example is +[`examples/lifecycle_stack_example.cpp`](../examples/lifecycle_stack_example.cpp). +It combines an owner-loop executor, a deferred market-data provider, and +`MarketDataRouter`, then drains them through the common stack. diff --git a/guides/lifecycle-stack.ru.md b/guides/lifecycle-stack.ru.md new file mode 100644 index 0000000..675968d --- /dev/null +++ b/guides/lifecycle-stack.ru.md @@ -0,0 +1,182 @@ +# Руководство По Lifecycle Stack + +Это русский перевод канонического руководства по необязательному общему +lifecycle API. При изменении контракта синхронизируй его с +[`lifecycle-stack.md`](lifecycle-stack.md). Русская версия не является +источником обратных смысловых правок английского документа. + +## Назначение + +`LifecycleStack` позволяет приложению управлять несколькими модулями через один +вызов `process()` и один staged-запрос `shutdown()`. Это полезно, когда platform +owner loop, `MarketDataRouter`, боты, системы нод и похожие модули запускаются и +останавливаются вместе. + +Stack необязателен. Каждый модуль сохраняет прямой lifecycle API и может +управляться без `LifecycleStack`. + +Публичный include: + +```cpp +#include +``` + +## Контракт Модуля + +Модули реализуют `optionx::lifecycle::ILifecycleModule`: + +```cpp +class ILifecycleModule { +public: + virtual void process() = 0; + virtual void shutdown() noexcept = 0; + virtual bool is_stopped() const noexcept = 0; +}; +``` + +Контракт намеренно мал: + +- `process()` продвигает обычную работу или уже начатый graceful shutdown; +- `shutdown()` является идемпотентным запросом прекратить приём новой работы; +- `is_stopped()` становится true только после завершения принадлежащей модулю + работы и cleanup. + +`shutdown()` не обязан завершать асинхронный cleanup до возврата. Owner loop +продолжает вызывать `process()` до достижения terminal state. + +`MarketDataRouter` реализует этот интерфейс. Его общий `is_stopped()` совпадает +с `is_shutdown_complete()`. `BaseTradingPlatform` также реализует интерфейс и +возвращает своё существующее terminal lifecycle state. + +## Регистрация И Владение + +Сначала регистрируй зависимости, затем зависящие от них модули: + +```text +platform / owner executor + -> provider-facing Router + -> bots или node systems +``` + +```cpp +lifecycle::LifecycleStack application; +application.add_module(platform); +application.add_module(router); +application.add_module(bot); +``` + +Stack хранит non-owning указатели. Каждый зарегистрированный модуль должен жить +дольше stack и полного завершения его shutdown. Повторная регистрация, +self-registration и регистрация после начала shutdown отклоняются. + +Порядок регистрации описывает зависимости, а не только порядок отображения: + +- обычный `process()` идёт вперёд; +- `shutdown()` идёт в обратном порядке; +- одновременно активна только одна стадия shutdown зависимого модуля; +- нижележащие модули продолжают обрабатываться, пока текущий зависимый модуль не + вернёт true из `is_stopped()`. + +Синхронные стадии могут завершиться за один вызов `shutdown()`. Асинхронная +стадия приостанавливает обратный проход до следующих вызовов `process()`. + +## Запуск + +`LifecycleStack` не вызывает `initialize()` или `run()`. У существующих объектов +разные контракты запуска: у платформы есть `run(bool)`, у component есть +`initialize()`, а боту может требоваться прикладная конфигурация. Оставляй эти +действия явными: + +```cpp +platform.configure_auth(...); +platform.run(false); +bot.start(); + +lifecycle::LifecycleStack application; +application.add_module(platform); +application.add_module(router); +application.add_module(bot); +``` + +Так не появляется искусственный startup API по наименьшему общему знаменателю. +Отдельную initialization capability можно добавить позже, если несколько +реальных модулей получат одинаковую семантику. + +## Owner Loop + +Вызывай `LifecycleStack::process()` и `shutdown()` из одного owner loop. Stack не +создаёт поток и не добавляет синхронизацию вокруг методов модулей. + +Для платформы в ручном режиме host loop выглядит так: + +```cpp +while (running) { + application.process(); +} + +application.shutdown(); +while (!application.is_stopped()) { + application.process(); +} +``` + +При порядке регистрации `platform -> router -> bot` каждый tick сначала +позволяет платформе выполнить queued provider callbacks, затем Router забирает +сохранённые lifecycle completions, после чего обрабатывается ещё активный бот. + +Не управляй платформой вручную, если она уже использует собственный worker +thread. В таком режиме запускай общий supervisor в настоящем owner loop или +продолжай использовать прямые lifecycle API модулей. + +## Staged Shutdown + +Для такого порядка регистрации: + +```text +platform -> router -> bot +``` + +остановка выполняется так: + +```text +bot.shutdown() +пока bot не stopped: + platform.process() + router.process() + bot.process() + +router.shutdown() +пока router не stopped: + platform.process() + router.process() + +platform.shutdown() +``` + +Прикладной код видит только `application.shutdown()` и +`application.process()`. Stack сохраняет platform/executor живым, пока Router +ждёт поздние provider completions и результаты физического unsubscribe. + +`LifecycleStack` сам является `ILifecycleModule`, поэтому stacks можно вкладывать, +если большое приложение состоит из независимо собранных подсистем. + +Вложенные lifecycle stacks должны образовывать ацикличный граф зависимостей. +`add_module()` отклоняет саморегистрацию и повторную регистрацию одной ссылки, +но не обнаруживает косвенный цикл вроде `stack_a -> stack_b -> stack_a`; +приложение должно не допускать такие регистрации. + +## Ошибки И Ограничения + +Stack не придумывает retry, timeout или abandon policy. Например, ошибка Router +unsubscribe оставляет Router и поэтому весь stack в non-terminal состоянии. +Приложение может проверить `failed_unsubscribe_count()` и вызвать +`retry_failed_unsubscribes()` со своим backoff, пока provider ещё жив. + +Исключения из `process()` передаются caller. Shutdown модуля имеет контракт +`noexcept`. Stack не владеет модулями, не уничтожает их и не вызывает shutdown +из своего destructor. + +Рабочий интеграционный пример находится в +[`examples/lifecycle_stack_example.cpp`](../examples/lifecycle_stack_example.cpp). +Он объединяет owner-loop executor, deferred market-data provider и +`MarketDataRouter`, а затем дренирует их через общий stack. diff --git a/guides/market-data-router.md b/guides/market-data-router.md index 834fa96..596d787 100644 --- a/guides/market-data-router.md +++ b/guides/market-data-router.md @@ -397,6 +397,10 @@ Applications that compose several process/shutdown modules should put this drain loop in their lifecycle supervisor rather than special-case Router in business code. +The optional [`LifecycleStack`](lifecycle-stack.md) provides that supervisor. +Register the platform/executor before Router; it processes them forward and +keeps the executor alive while stopping Router first in reverse order. + Do not defer subscriber destruction until after the dispatcher is closed. `MarketDataSubscriberBase` normally posts remaining handles as one cleanup task; if posting is no longer possible, its destructor falls back to synchronous diff --git a/guides/market-data-router.ru.md b/guides/market-data-router.ru.md index 2266699..73e1ef5 100644 --- a/guides/market-data-router.ru.md +++ b/guides/market-data-router.ru.md @@ -396,6 +396,10 @@ provider и owner dispatcher Если приложение объединяет несколько process/shutdown modules, этот drain loop должен находиться в lifecycle supervisor, а не в Router-specific business code. +Необязательный [`LifecycleStack`](lifecycle-stack.ru.md) предоставляет такой +supervisor. Зарегистрируй platform/executor перед Router: process пойдёт вперёд, +а при обратном shutdown executor останется жив до полной остановки Router. + Не откладывай уничтожение subscriber до момента, когда dispatcher уже закрыт. Обычно `MarketDataSubscriberBase` отправляет оставшиеся handles одной cleanup задачей; если posting уже невозможен, destructor переходит к синхронному diff --git a/guides/platform-api-guide.md b/guides/platform-api-guide.md index 7c56cba..deba70f 100644 --- a/guides/platform-api-guide.md +++ b/guides/platform-api-guide.md @@ -119,6 +119,19 @@ Subscription rules: websocket feeds may still run for trade lifecycle needs even when there are no public subscribers. +## `lifecycle::LifecycleStack` + +Файлы: `include/optionx_cpp/lifecycle/ILifecycleModule.hpp` и +`include/optionx_cpp/lifecycle/LifecycleStack.hpp`. + +Полное руководство: [English](lifecycle-stack.md) | +[Русский](lifecycle-stack.ru.md). + +Это необязательный application-level supervisor для модулей с `process()`, +`shutdown()` и terminal predicate `is_stopped()`. Зависимости регистрируются +первыми, обычный process идёт вперёд, а shutdown выполняется по одной стадии в +обратном порядке. Startup остаётся явным. + ## `market_data::MarketDataRouter` Файл: `include/optionx_cpp/market_data/MarketDataRouter.hpp`. diff --git a/guides/project-overview.md b/guides/project-overview.md index 965b5a3..aaa66da 100644 --- a/guides/project-overview.md +++ b/guides/project-overview.md @@ -24,10 +24,16 @@ broker API и bridges. Основной сценарий: пользовател ## Public Include Surface +`optionx_cpp/lifecycle.hpp` exposes the optional `ILifecycleModule` and +`LifecycleStack` API for applications that compose platform, Router, bot, and +node lifecycles. See [English](lifecycle-stack.md) or +[Русский](lifecycle-stack.ru.md). + | Include | Что открывает | Когда использовать | |---|---|---| | `optionx_cpp/optionx.hpp` | Все основные subsystems | В приложениях и examples, когда нужен полный API | | `optionx_cpp/utils.hpp` | Pub-sub, tasks, crypto, strings, time, ids | Для инфраструктурного кода и новых components | +| `optionx_cpp/lifecycle.hpp` | `ILifecycleModule`, `LifecycleStack` | Для необязательной общей обработки и staged shutdown модулей | | `optionx_cpp/data.hpp` | DTO, events, enums, account/symbol/tick/bar/trading data | Для API boundary и сообщений | | `optionx_cpp/components.hpp` | `BaseComponent`, HTTP и trade execution base classes | Для нового manager/component | | `optionx_cpp/market_data.hpp` | Market-data provider role, subscription DTOs and statuses | Для live tick/bar subscriptions и history API contracts | diff --git a/include/optionx_cpp/lifecycle.hpp b/include/optionx_cpp/lifecycle.hpp new file mode 100644 index 0000000..3c1b6c0 --- /dev/null +++ b/include/optionx_cpp/lifecycle.hpp @@ -0,0 +1,17 @@ +#pragma once +#ifndef OPTIONX_HEADER_LIFECYCLE_HPP_INCLUDED +#define OPTIONX_HEADER_LIFECYCLE_HPP_INCLUDED + +/// \file lifecycle.hpp +/// \brief Includes the optional common lifecycle module API. +/// \note Headers under the lifecycle directory are intended to be included +/// through this aggregate header. + +#include +#include +#include + +#include "lifecycle/ILifecycleModule.hpp" +#include "lifecycle/LifecycleStack.hpp" + +#endif // OPTIONX_HEADER_LIFECYCLE_HPP_INCLUDED diff --git a/include/optionx_cpp/lifecycle/ILifecycleModule.hpp b/include/optionx_cpp/lifecycle/ILifecycleModule.hpp new file mode 100644 index 0000000..ad9cbd4 --- /dev/null +++ b/include/optionx_cpp/lifecycle/ILifecycleModule.hpp @@ -0,0 +1,30 @@ +#pragma once +#ifndef OPTIONX_HEADER_LIFECYCLE_ILIFECYCLE_MODULE_HPP_INCLUDED +#define OPTIONX_HEADER_LIFECYCLE_ILIFECYCLE_MODULE_HPP_INCLUDED + +/// \file ILifecycleModule.hpp +/// \brief Declares the common process and graceful-shutdown module contract. + +namespace optionx::lifecycle { + + /// \class ILifecycleModule + /// \brief Common interface for modules driven by an application owner loop. + /// \details shutdown() requests an idempotent stop. A module may need later + /// process() calls before is_stopped() becomes true. + class ILifecycleModule { + public: + virtual ~ILifecycleModule() noexcept = default; + + /// \brief Advances normal work or an in-progress graceful shutdown. + virtual void process() = 0; + + /// \brief Requests an idempotent graceful shutdown. + virtual void shutdown() noexcept = 0; + + /// \brief Returns true after all module-owned work and cleanup finished. + [[nodiscard]] virtual bool is_stopped() const noexcept = 0; + }; + +} // namespace optionx::lifecycle + +#endif // OPTIONX_HEADER_LIFECYCLE_ILIFECYCLE_MODULE_HPP_INCLUDED diff --git a/include/optionx_cpp/lifecycle/LifecycleStack.hpp b/include/optionx_cpp/lifecycle/LifecycleStack.hpp new file mode 100644 index 0000000..873093f --- /dev/null +++ b/include/optionx_cpp/lifecycle/LifecycleStack.hpp @@ -0,0 +1,114 @@ +#pragma once +#ifndef OPTIONX_HEADER_LIFECYCLE_LIFECYCLE_STACK_HPP_INCLUDED +#define OPTIONX_HEADER_LIFECYCLE_LIFECYCLE_STACK_HPP_INCLUDED + +/// \file LifecycleStack.hpp +/// \brief Defines optional ordered processing and staged shutdown for modules. + +namespace optionx::lifecycle { + + /// \class LifecycleStack + /// \brief Drives non-owning lifecycle modules in dependency order. + /// \details Register lower-level dependencies first and dependents last. + /// process() runs forward. shutdown() stops one module at a time in + /// reverse order, while lower-level dependencies keep processing. + /// All methods must be called from the same owner loop. + class LifecycleStack final : public ILifecycleModule { + public: + LifecycleStack() = default; + + LifecycleStack(const LifecycleStack&) = delete; + LifecycleStack& operator=(const LifecycleStack&) = delete; + LifecycleStack(LifecycleStack&&) = delete; + LifecycleStack& operator=(LifecycleStack&&) = delete; + + /// \brief Registers a non-owning module reference. + /// \param module Module that must outlive this stack and its shutdown. + /// \return False for duplicate/self registration or after shutdown starts. + bool add_module(ILifecycleModule& module) { + if (m_shutdown_requested || &module == this) return false; + if (std::find(m_modules.begin(), m_modules.end(), &module) != + m_modules.end()) { + return false; + } + m_modules.push_back(&module); + return true; + } + + /// \brief Returns the number of registered modules. + [[nodiscard]] std::size_t size() const noexcept { + return m_modules.size(); + } + + /// \brief Returns true when no modules are registered. + [[nodiscard]] bool empty() const noexcept { + return m_modules.empty(); + } + + /// \brief Returns true after staged shutdown has been requested. + [[nodiscard]] bool is_shutdown_requested() const noexcept { + return m_shutdown_requested; + } + + /// \brief Processes modules forward and advances staged shutdown. + void process() override { + if (m_stopped) return; + + const auto process_count = m_shutdown_requested + ? m_shutdown_cursor + : m_modules.size(); + for (std::size_t index = 0; index < process_count; ++index) { + auto* module = m_modules[index]; + if (module && !module->is_stopped()) { + module->process(); + } + } + + if (m_shutdown_requested) advance_shutdown(); + } + + /// \brief Starts staged reverse-order shutdown. + void shutdown() noexcept override { + if (m_stopped || m_shutdown_requested) return; + m_shutdown_requested = true; + m_shutdown_cursor = m_modules.size(); + advance_shutdown(); + } + + /// \brief Returns true after every registered module stopped. + [[nodiscard]] bool is_stopped() const noexcept override { + return m_stopped; + } + + private: + void advance_shutdown() noexcept { + while (m_shutdown_cursor != 0) { + auto* module = m_modules[m_shutdown_cursor - 1]; + if (!module || module->is_stopped()) { + --m_shutdown_cursor; + m_current_shutdown_requested = false; + continue; + } + + if (!m_current_shutdown_requested) { + m_current_shutdown_requested = true; + module->shutdown(); + } + if (!module->is_stopped()) return; + + --m_shutdown_cursor; + m_current_shutdown_requested = false; + } + m_stopped = true; + } + + std::vector m_modules; + std::size_t m_shutdown_cursor = 0; + bool m_shutdown_requested = false; + bool m_current_shutdown_requested = false; + bool m_stopped = false; + }; + +} // namespace optionx::lifecycle + +#endif // OPTIONX_HEADER_LIFECYCLE_LIFECYCLE_STACK_HPP_INCLUDED diff --git a/include/optionx_cpp/market_data.hpp b/include/optionx_cpp/market_data.hpp index 90ab96f..8c935bf 100644 --- a/include/optionx_cpp/market_data.hpp +++ b/include/optionx_cpp/market_data.hpp @@ -20,7 +20,11 @@ #include #include -#include "data.hpp" +#include "lifecycle.hpp" +#include "utils/fixed_point.hpp" +#include "data/market.hpp" +#include "data/bars.hpp" +#include "data/ticks.hpp" #include "market_data/enums.hpp" #include "market_data/MarketDataSubscription.hpp" #include "market_data/MarketDataBatch.hpp" diff --git a/include/optionx_cpp/market_data/MarketDataRouter.hpp b/include/optionx_cpp/market_data/MarketDataRouter.hpp index 85bac1d..ee61ca3 100644 --- a/include/optionx_cpp/market_data/MarketDataRouter.hpp +++ b/include/optionx_cpp/market_data/MarketDataRouter.hpp @@ -227,7 +227,7 @@ namespace optionx::market_data { /// or fails physical unsubscription, the router retains that provider /// handle, keeps the callback binding, and rejects new routes through the /// affected provider until retry_failed_unsubscribes() succeeds. - class MarketDataRouter { + class MarketDataRouter : public lifecycle::ILifecycleModule { public: using SubscriptionHandle = MarketDataRouterSubscription; ///< Move-only route owner. using subscription_callback_t = BaseMarketDataProvider::subscription_callback_t; ///< Operation callback. @@ -254,8 +254,9 @@ namespace optionx::market_data { /// \brief Move assignment is disabled because handles refer to one router state. MarketDataRouter& operator=(MarketDataRouter&&) = delete; - /// \brief Stops routed subscriptions and releases provider callbacks. - ~MarketDataRouter(); + /// \brief Requests shutdown for any routes still owned by this instance. + /// \details Drain asynchronous provider operations before destruction. + ~MarketDataRouter() override; /// \brief Adds a non-owning provider registration with stable aliases. /// \details Registration does not bind provider callbacks. Aliases are @@ -407,16 +408,21 @@ namespace optionx::market_data { /// \details Processes provider completions retained during shutdown and /// starts or completes physical subscription cleanup. This method /// does not poll providers or transport data. - void process(); + void process() override; /// \brief Returns true after shutdown drained every provider operation. [[nodiscard]] bool is_shutdown_complete() const noexcept; + /// \brief Returns the common lifecycle terminal state. + [[nodiscard]] bool is_stopped() const noexcept override { + return is_shutdown_complete(); + } + /// \brief Starts an idempotent graceful shutdown. /// \details New routes and user delivery stop immediately. Pending provider /// operations remain owned until later process() calls finish their /// physical cleanup on the owner loop. - void shutdown() noexcept; + void shutdown() noexcept override; private: std::shared_ptr m_state; diff --git a/include/optionx_cpp/optionx.hpp b/include/optionx_cpp/optionx.hpp index d6db941..6eef746 100644 --- a/include/optionx_cpp/optionx.hpp +++ b/include/optionx_cpp/optionx.hpp @@ -6,6 +6,7 @@ /// \brief Includes core headers for the OptionX library. #include "utils.hpp" +#include "lifecycle.hpp" #include "data.hpp" #include "market_data.hpp" #include "storages.hpp" diff --git a/include/optionx_cpp/platforms.hpp b/include/optionx_cpp/platforms.hpp index acad157..64c98b7 100644 --- a/include/optionx_cpp/platforms.hpp +++ b/include/optionx_cpp/platforms.hpp @@ -18,6 +18,7 @@ #include "config.hpp" #include "utils.hpp" +#include "lifecycle.hpp" #include "data.hpp" #include "market_data.hpp" #include "storages.hpp" diff --git a/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp b/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp index a6833ab..98b6b94 100644 --- a/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp +++ b/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp @@ -9,7 +9,10 @@ namespace optionx::platforms { /// \class BaseTradingPlatform /// \brief Base endpoint facade for trading platforms, account data, lifecycle, and connection state. - class BaseTradingPlatform : public BaseEndpoint, public BaseTradingApi { + class BaseTradingPlatform + : public BaseEndpoint, + public BaseTradingApi, + public lifecycle::ILifecycleModule { public: BaseTradingPlatform(std::shared_ptr account_info) : m_account_info(std::move(account_info)), @@ -241,6 +244,11 @@ namespace optionx::platforms { m_stopped.store(true, std::memory_order_release); }; + /// \brief Returns true after the platform lifecycle has stopped. + [[nodiscard]] bool is_stopped() const noexcept override { + return m_stopped.load(std::memory_order_acquire); + } + /// \brief Returns a reference to the event bus. utils::EventBus& event_bus() { return m_event_bus; } diff --git a/tests/lifecycle_event_safety_test.cpp b/tests/lifecycle_event_safety_test.cpp index d0943eb..472e49f 100644 --- a/tests/lifecycle_event_safety_test.cpp +++ b/tests/lifecycle_event_safety_test.cpp @@ -62,6 +62,16 @@ class TestPlatform final : public optionx::platforms::BaseTradingPlatform { } // namespace +TEST(BaseTradingPlatformLifecycle, ImplementsCommonLifecycleModule) { + TestPlatform platform; + optionx::lifecycle::ILifecycleModule& module = platform; + + EXPECT_FALSE(module.is_stopped()); + module.process(); + module.shutdown(); + EXPECT_TRUE(module.is_stopped()); +} + TEST(BaseTradingPlatformLifecycle, RepeatedRunDoesNotDuplicateLifecycleTasks) { TestPlatform platform; diff --git a/tests/lifecycle_stack_test.cpp b/tests/lifecycle_stack_test.cpp new file mode 100644 index 0000000..a424f20 --- /dev/null +++ b/tests/lifecycle_stack_test.cpp @@ -0,0 +1,132 @@ +#include + +#include + +#include +#include +#include + +namespace { + +using optionx::lifecycle::ILifecycleModule; +using optionx::lifecycle::LifecycleStack; + +class RecordingModule final : public ILifecycleModule { +public: + RecordingModule( + std::string name, + std::size_t shutdown_process_count, + std::vector& events) + : m_name(std::move(name)), + m_shutdown_process_count(shutdown_process_count), + m_events(events) {} + + void process() override { + m_events.push_back("process:" + m_name); + if (!m_shutdown_requested || m_shutdown_process_count == 0) return; + --m_shutdown_process_count; + if (m_shutdown_process_count == 0) m_stopped = true; + } + + void shutdown() noexcept override { + m_events.push_back("shutdown:" + m_name); + m_shutdown_requested = true; + if (m_shutdown_process_count == 0) m_stopped = true; + } + + [[nodiscard]] bool is_stopped() const noexcept override { + return m_stopped; + } + +private: + std::string m_name; + std::size_t m_shutdown_process_count = 0; + std::vector& m_events; + bool m_shutdown_requested = false; + bool m_stopped = false; +}; + +TEST(LifecycleStack, ProcessesForwardAndShutsDownDependentsOneAtATime) { + std::vector events; + RecordingModule platform("platform", 0, events); + RecordingModule router("router", 1, events); + RecordingModule bot("bot", 1, events); + LifecycleStack stack; + + ASSERT_TRUE(stack.add_module(platform)); + ASSERT_TRUE(stack.add_module(router)); + ASSERT_TRUE(stack.add_module(bot)); + EXPECT_FALSE(stack.add_module(router)); + EXPECT_FALSE(stack.add_module(stack)); + + stack.process(); + EXPECT_EQ(events, (std::vector{ + "process:platform", + "process:router", + "process:bot"})); + events.clear(); + + stack.shutdown(); + EXPECT_TRUE(stack.is_shutdown_requested()); + EXPECT_FALSE(stack.is_stopped()); + EXPECT_EQ(events, (std::vector{"shutdown:bot"})); + events.clear(); + + stack.process(); + EXPECT_FALSE(stack.is_stopped()); + EXPECT_EQ(events, (std::vector{ + "process:platform", + "process:router", + "process:bot", + "shutdown:router"})); + events.clear(); + + stack.process(); + EXPECT_TRUE(stack.is_stopped()); + EXPECT_EQ(events, (std::vector{ + "process:platform", + "process:router", + "shutdown:platform"})); + + events.clear(); + stack.process(); + stack.shutdown(); + EXPECT_TRUE(events.empty()); + EXPECT_FALSE(stack.add_module(bot)); +} + +TEST(LifecycleStack, StopsSynchronousModulesInReverseOrder) { + std::vector events; + RecordingModule first("first", 0, events); + RecordingModule second("second", 0, events); + LifecycleStack stack; + + ASSERT_TRUE(stack.add_module(first)); + ASSERT_TRUE(stack.add_module(second)); + stack.shutdown(); + + EXPECT_TRUE(stack.is_stopped()); + EXPECT_EQ(events, (std::vector{ + "shutdown:second", + "shutdown:first"})); +} + +TEST(LifecycleStack, EmptyStackStopsOnRequest) { + LifecycleStack stack; + + EXPECT_TRUE(stack.empty()); + EXPECT_EQ(stack.size(), 0u); + EXPECT_FALSE(stack.is_stopped()); + + stack.shutdown(); + + EXPECT_TRUE(stack.is_shutdown_requested()); + EXPECT_TRUE(stack.is_stopped()); +} + +} // namespace + +int main(int argc, char** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/market_data_router_test.cpp b/tests/market_data_router_test.cpp index 36498c6..86bab1a 100644 --- a/tests/market_data_router_test.cpp +++ b/tests/market_data_router_test.cpp @@ -274,6 +274,16 @@ MarketDataStatusUpdate ready_status( } // namespace +TEST(MarketDataRouter, ImplementsCommonLifecycleModule) { + MarketDataRouter router; + optionx::lifecycle::ILifecycleModule& module = router; + + EXPECT_FALSE(module.is_stopped()); + module.process(); + module.shutdown(); + EXPECT_TRUE(module.is_stopped()); +} + TEST(MarketDataRouter, UsesStrongRoutedSubscriptionIds) { const RoutedSubscriptionId empty; EXPECT_FALSE(empty.valid());