Skip to content

Commit 271dfde

Browse files
committed
fix(market-data): drain late router completions
Retain provider subscribe and unsubscribe completions across cancelled owner tasks, expose two-phase Router processing, and keep physical cleanup retryable until shutdown completes. Document the lifecycle in the English and Russian guides and cover late source-thread completions with Router and subscriber-base regressions.
1 parent 130f226 commit 271dfde

6 files changed

Lines changed: 574 additions & 137 deletions

File tree

guides/api-and-header-contracts.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,13 @@ Contract rules:
253253
provider handle and callback binding for cleanup. Check
254254
`failed_unsubscribe_count()` and call `retry_failed_unsubscribes()` from the
255255
owner loop; new routes through that provider are rejected until cleanup
256-
succeeds. `shutdown()` makes a final best-effort attempt.
256+
succeeds.
257+
- Router shutdown is two-phase. `shutdown()` stops new routes and user delivery,
258+
retains tombstones for pending provider operations, and performs one immediate
259+
`process()` pass. Keep the provider and owner loop alive and call Router
260+
`process()` until `is_shutdown_complete()` becomes true. Late successful
261+
subscribes are physically unsubscribed without user callbacks or replay;
262+
failed cleanup remains retryable and prevents shutdown completion.
257263
- `register_provider()` adds a non-owning provider reference under a stable,
258264
application-assigned `MarketDataProviderId` and optional exact string aliases.
259265
Registration is a selection catalog only and does not bind live callbacks.
@@ -273,7 +279,9 @@ Contract rules:
273279
delivery instead of invoking subscriber code in the foreign source thread.
274280
- `BaseTradingPlatform::post_task()` is the standard adapter for using the
275281
platform TaskManager as that owner loop. Shutdown may cancel accepted tasks,
276-
so Router and subscriber cleanup must be drained before stopping the platform.
282+
so provider operation results are retained in Router state rather than owned
283+
only by posted tasks. Router and subscriber cleanup must be drained before
284+
stopping the platform.
277285

278286
`MarketDataSubscriberBase` is optional convenience sugar over Router. A bot can
279287
derive from it, call protected `subscribe_ticks()`/`subscribe_bars()` from its

guides/market-data-router.md

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ if (router.failed_unsubscribe_count() != 0) {
172172

173173
The original unsubscribe callback receives the failure once; internal retries
174174
do not resurrect the consumed public handle. Apply application-level backoff
175-
before retrying persistent failures. `shutdown()` makes one final best-effort
176-
cleanup attempt before releasing Router state.
175+
before retrying persistent failures. `shutdown()` retries cleanup entries that
176+
had already failed once, but a repeated failure keeps Router in its draining
177+
state until the application retries or applies a future explicit abandon policy.
177178

178179
The optional subscription callback reports desired-state acceptance by the
179180
provider. It is not a transport-readiness callback:
@@ -340,19 +341,25 @@ transport is ready. Those stages are observed separately:
340341
341342
If the subscriber expires before a posted subscribe command runs, the command is
342343
cancelled and its callbacks are not invoked. Once a configured dispatcher starts
343-
rejecting work during shutdown, new provider completions and deliveries are
344-
dropped rather than being invoked inline on the source thread. Tick, bar, and
345-
status deliveries need no physical cleanup and are discarded immediately.
346-
347-
A successful subscribe completion reserves its concrete provider handle in
348-
Router state before the completion task is posted. The owner task must atomically
349-
claim that reservation before promoting the route from pending to active. If the
350-
task is rejected, the reservation remains with the pending route and Router
351-
quarantines that provider from new routes. If the task is accepted but later
352-
cancelled during owner shutdown, the reservation likewise remains available to
353-
`router.shutdown()`. In both cases shutdown unsubscribes the retained handle from
354-
the owner loop; neither the subscription callback nor subscriber callbacks run
355-
on the source thread.
344+
rejecting work during shutdown, new tick, bar, and status deliveries are dropped
345+
rather than being invoked inline on the source thread. Those deliveries need no
346+
physical cleanup and are discarded immediately.
347+
348+
Subscribe and unsubscribe completions are recorded in Router state before an
349+
owner task is posted. A successful subscribe result reserves its concrete
350+
provider handle; the owner task atomically claims that reservation before
351+
promoting the route from pending to active. Rejected or later-cancelled owner
352+
tasks therefore do not own the only copy of a physical handle or completion
353+
result.
354+
355+
`process()` advances Router-owned deferred lifecycle work on the owner thread.
356+
During normal operation posted completion tasks apply their transitions
357+
directly, so Router does not require a separate pump. After `shutdown()` starts
358+
draining, late provider completions remain in Router state and `process()` turns
359+
every late successful subscribe into a physical unsubscribe without invoking
360+
user callbacks or replay. It does not poll providers or transports; in manual
361+
platform mode call `platform.process()` first so the provider can produce its
362+
completion.
356363
357364
All Router callbacks are serialized by the owner loop, but fields read directly
358365
from a different bot thread still require the bot's own mutex, atomics, or
@@ -368,18 +375,27 @@ provider and owner dispatcher
368375
outlives subscribers and posted cleanup work
369376
```
370377

371-
Use this shutdown order:
378+
`shutdown()` is an idempotent request to stop, not an unconditional assertion
379+
that every asynchronous provider operation finished before it returned. It
380+
performs one `process()` pass itself, so synchronous cleanup still completes in
381+
the call. Use this shutdown order:
372382

373383
1. Stop producing new commands from bot threads.
374384
2. Request unsubscribe or destroy subscribers while the dispatcher still accepts
375385
work.
376-
3. Keep processing the owner loop until unsubscribe commands and any nested
377-
provider completion callbacks are drained. Inspect
378-
`failed_unsubscribe_count()` and retry according to the application policy
379-
while the owner loop and providers are still available.
380-
4. Call `router.shutdown()` from the owner loop. It is idempotent, releases
381-
provider callback slots, and requests unsubscribe for remaining active routes.
382-
5. Stop the platform/dispatcher and then destroy providers.
386+
3. Call `router.shutdown()` from the owner loop. New routes and user delivery
387+
stop immediately; pending provider operations remain as cleanup tombstones.
388+
4. Keep the provider and owner loop running until
389+
`router.is_shutdown_complete()` is true. In manual mode each host tick calls
390+
`platform.process()` and then `router.process()`.
391+
5. Inspect `failed_unsubscribe_count()` and retry with application-level backoff
392+
while the owner loop and providers remain available. Failed cleanup prevents
393+
`is_shutdown_complete()` from becoming true.
394+
6. Stop the platform/dispatcher and then destroy providers.
395+
396+
Applications that compose several process/shutdown modules should put this
397+
drain loop in their lifecycle supervisor rather than special-case Router in
398+
business code.
383399

384400
Do not defer subscriber destruction until after the dispatcher is closed.
385401
`MarketDataSubscriberBase` normally posts remaining handles as one cleanup task;

guides/market-data-router.ru.md

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ if (router.failed_unsubscribe_count() != 0) {
172172

173173
Исходный unsubscribe callback получает ошибку один раз; внутренний retry не
174174
восстанавливает уже использованный публичный handle. Для постоянных ошибок
175-
применяй backoff на уровне приложения. `shutdown()` делает последнюю
176-
best-effort попытку cleanup перед освобождением состояния Router.
175+
применяй backoff на уровне приложения. `shutdown()` повторяет cleanup entries,
176+
которые уже один раз завершились ошибкой, но повторная ошибка оставляет Router в
177+
состоянии draining до retry приложения или будущей явной abandon policy.
177178

178179
Необязательный subscription callback сообщает о принятии desired state
179180
провайдером. Это не callback готовности транспорта:
@@ -341,19 +342,24 @@ tick batches, bar batches и status updates в этот loop. Боты испо
341342
342343
Если subscriber уничтожен до выполнения posted subscribe command, команда
343344
отменяется, а её callbacks не вызываются. Когда настроенный dispatcher начинает
344-
отклонять работу во время shutdown, новые provider completions и deliveries
345-
отбрасываются, а не исполняются inline в source thread. Tick, bar и status
346-
deliveries не требуют физического cleanup и сразу удаляются.
347-
348-
Успешный subscribe completion резервирует concrete provider handle в состоянии
349-
Router до постановки completion task в очередь. Owner task должен атомарно
350-
забрать эту reservation перед переводом маршрута из pending в active. Если задача
351-
отклонена, reservation остаётся у pending маршрута, а Router помещает provider в
352-
карантин для новых routes. Если задача принята, но позднее отменена во время
353-
shutdown owner loop, reservation также остаётся доступной для
354-
`router.shutdown()`. В обоих случаях shutdown отписывает сохранённый handle из
355-
owner loop; ни subscription callback, ни callbacks subscriber не выполняются в
356-
source thread.
345+
отклонять работу во время shutdown, новые tick, bar и status deliveries
346+
отбрасываются, а не исполняются inline в source thread. Эти deliveries не
347+
требуют физического cleanup и сразу удаляются.
348+
349+
Subscribe и unsubscribe completions записываются в состояние Router до
350+
постановки owner task. Успешный subscribe result резервирует concrete provider
351+
handle; owner task атомарно забирает reservation перед переводом route из pending
352+
в active. Поэтому отклонённая или позднее отменённая owner task не владеет
353+
единственной копией physical handle или completion result.
354+
355+
`process()` продвигает принадлежащую Router deferred lifecycle work в owner
356+
thread. При обычной работе posted completion tasks применяют transitions
357+
напрямую, поэтому Router не требует отдельной прокачки. После начала draining
358+
через `shutdown()` поздние provider completions остаются в состоянии Router, а
359+
`process()` превращает каждый поздний успешный subscribe в physical unsubscribe
360+
без user callbacks и replay. Метод не опрашивает providers или transports; в
361+
ручном режиме платформы сначала вызывай `platform.process()`, чтобы provider
362+
смог сформировать completion.
357363
358364
Все callbacks Router сериализованы owner loop, но поля, которые напрямую читает
359365
другой поток бота, всё равно требуют собственного mutex, atomics или очереди
@@ -369,18 +375,26 @@ provider и owner dispatcher
369375
который живёт дольше subscribers и posted cleanup work
370376
```
371377

372-
Используй такой порядок остановки:
378+
`shutdown()` — идемпотентный запрос остановки, а не безусловное утверждение, что
379+
все асинхронные provider operations завершились до возврата метода. Он сам
380+
выполняет один проход `process()`, поэтому синхронный cleanup по-прежнему
381+
завершается внутри вызова. Используй такой порядок остановки:
373382

374383
1. Прекрати создавать новые команды в потоках ботов.
375384
2. Запроси unsubscribe или уничтожь subscribers, пока dispatcher принимает
376385
работу.
377-
3. Продолжай обработку owner loop, пока не завершатся unsubscribe commands и
378-
вложенные provider completion callbacks. Проверь
379-
`failed_unsubscribe_count()` и выполни retry по политике приложения, пока
380-
owner loop и providers ещё доступны.
381-
4. Вызови `router.shutdown()` из owner loop. Метод идемпотентен, освобождает
382-
callback slots провайдера и запрашивает unsubscribe оставшихся маршрутов.
383-
5. Останови platform/dispatcher, затем уничтожай providers.
386+
3. Вызови `router.shutdown()` из owner loop. Новые routes и user delivery
387+
прекращаются сразу; pending provider operations остаются cleanup tombstones.
388+
4. Оставь provider и owner loop работающими, пока
389+
`router.is_shutdown_complete()` не вернёт true. В ручном режиме каждый host
390+
tick вызывает `platform.process()`, а затем `router.process()`.
391+
5. Проверяй `failed_unsubscribe_count()` и выполняй retry с backoff приложения,
392+
пока owner loop и providers доступны. Failed cleanup не позволяет
393+
`is_shutdown_complete()` стать true.
394+
6. Останови platform/dispatcher, затем уничтожай providers.
395+
396+
Если приложение объединяет несколько process/shutdown modules, этот drain loop
397+
должен находиться в lifecycle supervisor, а не в Router-specific business code.
384398

385399
Не откладывай уничтожение subscriber до момента, когда dispatcher уже закрыт.
386400
Обычно `MarketDataSubscriberBase` отправляет оставшиеся handles одной cleanup

0 commit comments

Comments
 (0)