214 lines
15 KiB
Markdown
214 lines
15 KiB
Markdown
# Engineering Design
|
||
|
||
## Context and Constraints
|
||
|
||
- Один backend-процесс; durable fence — reservation попытки в SQLite; таймер
|
||
процесса только предлагает работу (существующий инвариант, комментарии в
|
||
`tbank-hourly.scheduler.ts`).
|
||
- Итог попытки пишется CAS-обновлением только из состояния `running`:
|
||
устаревшая публикация после появления преемника уже отклоняется существующим
|
||
fence (`settleHourly` where `state: 'running'`; `read()` при несовпадении
|
||
`progress.attempts` помечает шаг `cancelled`).
|
||
- Сегодня итог вычисляется по in-memory списку ошибок walk (пуст при
|
||
исключении — ложный `succeeded`), ошибка `settleHourly` подавляется
|
||
`.catch(() => undefined)`, `settledWorks` поллит без deadline, а id строк
|
||
шагов не персистятся на hourly-попытке (`progress: { sections: [] }`).
|
||
- Финализаторы enrichment/operations могут очистить память без durable finish —
|
||
шаг остаётся `running`.
|
||
- Топология не меняется: без lease и мультреплик; CAS-ограничения
|
||
`attempt`/`revision`/`source` не ослабляются.
|
||
|
||
## Technical Goals / Non-Goals
|
||
|
||
**Goals**
|
||
|
||
- Итог попытки выводится из durable-состояния шагов; ложный `succeeded`
|
||
невозможен ни на одном error-пути.
|
||
- Каждая попытка достигает терминального итога в ограниченное время
|
||
(ограничение ожидания на каждый шаг).
|
||
- Попытка с утраченным выполнением закрывается диагностируемо (`INTERRUPTED`)
|
||
и не блокирует следующий обход дольше обычного часового срока.
|
||
- Поздняя публикация шага текущей попытки сохраняется до фиксации итога;
|
||
после появления преемника отклоняется существующим fence.
|
||
|
||
**Non-Goals**
|
||
|
||
- Lease, очередь, вторая реплика, параллельный scheduler.
|
||
- Отмена уже начатых обращений к T-Bank.
|
||
- Новый публичный статус попытки и изменения HTTP contract.
|
||
- Graceful drain — только если тест контролируемой остановки докажет
|
||
необходимость.
|
||
|
||
## Implementation Impact Map
|
||
|
||
### main
|
||
|
||
`N/A` — workspace/dev/release orchestration не меняется.
|
||
|
||
### frontend
|
||
|
||
`N/A` — публичный словарь состояний и форма `meta` не меняются.
|
||
|
||
### backend
|
||
|
||
`affected` — координатор `tbank-hourly.scheduler.ts` (walk/settle/
|
||
settledWorks/launch), `tbank-sync.repository.ts` (`reserveHourly`/
|
||
`settleHourly`), персист id шагов на попытке, durable finish в финализаторах
|
||
`catalog-enrichment.service.ts` и `brokerage-operations-sync.service.ts`,
|
||
изолированные SQLite-тесты reservation/coordinator.
|
||
|
||
### qa
|
||
|
||
`N/A` — QA repository не существует; системная верификация остаётся ручной по
|
||
QA Analysis.
|
||
|
||
## Decisions
|
||
|
||
### backend
|
||
|
||
1. **Итог выводится из durable-состояния шагов (корневой фикс).**
|
||
`settleHourly` в одной транзакции читает строки шагов попытки и вычисляет
|
||
итог: `succeeded` — только когда каждый обязательный шаг успешен и закрытие
|
||
инициировано нормальным завершением walk; `partial` — успешны некоторые;
|
||
`failed` — ни один. Вызывающие больше не передают результат и список
|
||
ошибок; walk передаёт максимум `cause: 'INTERRUPTED'` (закрытие до
|
||
нормального завершения), который капит итог до `partial`. Исключение —
|
||
обязательные секции, чей dispatch не создал durable-строку шага: walk
|
||
передаёт их как дополнительные problem-записи (наблюдаемый факт
|
||
диспатча, не результат), иначе попытка могла бы получить ложный успех.
|
||
Альтернатива
|
||
«прокидывать настоящий failures-map в catch» отклонена: не закрывает
|
||
lost-settle и краш-пути — каждый error-путь требовал бы своего guard.
|
||
Отдельный janitor-сервис отклонён: issue требует чинить существующих
|
||
владельцев, не вводить параллельный scheduler.
|
||
|
||
2. **Id шагов персистятся на попытке в момент dispatch.** `progress`
|
||
hourly-попытки переиспользует существующую форму `children: string[]` +
|
||
`attempts: { [id]: attemptId }` (паттерн `ScreenProgress`/`progress()`).
|
||
Walk записывает id каждой строки шага сразу после dispatch, до ожидания —
|
||
иначе recovery после краша не знает множество шагов попытки, а шаг, упавший
|
||
в dispatch, исчезает из множества.
|
||
|
||
3. **Ограниченное ожидание на каждый шаг.** `settledWorks` получает per-id
|
||
deadline: константа `WORK_WAIT_BOUND_MS = 15 * 60 * 1000`; истёкший id
|
||
возвращается как проблемный шаг с причиной `DEADLINE`. Управляемые часы в
|
||
тестах — jest fake timers, инъекция clock в production-код не вводится.
|
||
RPC не отменяется; поздний результат шага сохраняется его собственной
|
||
строкой — учтён ли он в итоге, решает fence и момент фиксации.
|
||
|
||
4. **In-memory активные обходы + recovery на существующем tick.**
|
||
`TbankHourlyScheduler` держит `active: Set<string>` (attemptId): добавление
|
||
в начале `walk`, удаление в `finally`. `launch()` перед `reserveHourly`
|
||
вызывает `recoverAbandoned(userId)`: если строка hourly `running`,
|
||
`processRunId` свой и attemptId не в `active` — попытка потеряла settle и
|
||
закрывается через `settleHourly` с `cause: 'INTERRUPTED'` (итог из детей,
|
||
максимум `partial`). Это же закрывает застрявшие legacy-строки `running`.
|
||
Новых таймеров и санитаров нет — восстановление едет на существующем
|
||
минутном tick.
|
||
|
||
5. **Restart-ветка `reserveHourly` закрывает попытку итогом.** Существующая
|
||
ветка «running + чужой `processRunId`» вместо голой записи
|
||
`state: 'cancelled'` выполняет тот же derived settle с
|
||
`cause: 'INTERRUPTED'` внутри транзакции reservation; далее прежний
|
||
`due`-гейт: новый обход — не ранее следующего часового срока от начала
|
||
попытки. `cancelled` для hourly-строк перестаёт писаться; существующие
|
||
маппинги `cancelled` в `hourly()`/`hourlyAttempt()` остаются толерантным
|
||
чтением и не удаляются.
|
||
|
||
6. **Причина прерывания — в существующем поле `errorCode`.** `INTERRUPTED` —
|
||
уровень попытки (закрытие до нормального завершения); `DEADLINE` — уровень
|
||
шага (истечение ожидания), попадает в `errorCode` попытки как первая ошибка
|
||
секции по существующему precedence. Коды ошибок шагов остаются в
|
||
`progress.sections[].errorCode`. Поле — открытое строковое: форма ответа и
|
||
enum `TbankErrorCode` не расширяются (маркеры жизненного цикла — не ошибки
|
||
T-Bank).
|
||
|
||
7. **Durable finish в дочерних финализаторах.** Внешний catch финализаторов
|
||
(enrichment, operations drain/step) обязан перед возвратом или пробросом
|
||
записать терминальный неуспешный результат своей строки шага существующим
|
||
путём записи ошибки, а не только очистить память. Иначе шаг остаётся
|
||
`running`, и попытка не проходит terminal barrier.
|
||
|
||
## Cross-cutting Implementation
|
||
|
||
- Подавление ошибки `settleHourly` (`.catch(() => undefined)`) допустимо только
|
||
как пара к решению 4: `walk` удаляет attemptId из `active` в `finally`, и
|
||
следующий tick закрывает строку recovery. Ошибка фиксируется существующим
|
||
структурным логом.
|
||
- Новых фоновых таймеров, очередей и таблиц нет; concurrency не меняется
|
||
(один walk на подключение).
|
||
|
||
## Data / Storage Changes
|
||
|
||
- Миграции нет: `children`/`attempts` живут в существующем JSON `progress`
|
||
строки `tbankSyncWork`; поле `errorCode` уже в схеме
|
||
(`20260924170000_persist_tbank_data/migration.sql:177`).
|
||
- Legacy-застрявшие `running` строки закрываются первым recovery-проходом
|
||
(итог из детей + `INTERRUPTED`); чтение уже закрытых строк не меняется.
|
||
|
||
## Contract Implementation
|
||
|
||
- HTTP contract не меняется: форма `meta` и словарь состояний
|
||
(`running`/`succeeded`/`partial`/`failed`) прежние; `cancelled` остаётся в
|
||
enum DTO как толерантное чтение.
|
||
- До закрытия change: backend-generated OpenAPI проверяется на семантическую
|
||
совместимость с текущим контрактом — изменения формы не ожидаются; новые
|
||
значения существуют только внутри открытого строкового поля диагностики и
|
||
в hourly-read наружу не попадают.
|
||
|
||
## Developer Verification Strategy
|
||
|
||
- Изолированные SQLite-тесты reservation/coordinator (не stub
|
||
`upcoming-events`): ветки `first`, `not-due`, `running`, `restart`,
|
||
`partial`; итог проверяется после terminal barrier.
|
||
- Инъекция ошибок отдельно в дочерние шаги и в родительский settle;
|
||
восстановление после ошибки сохранения; защита от overlap и от поздней
|
||
публикации; controlled stop/restart — нет бесконечного drain, после
|
||
остановки новые reservations не создаются.
|
||
- Управляемые часы — jest fake timers.
|
||
- Сохранить существующие наборы auth/money/publication; полный
|
||
`backend verify` на финальном diff.
|
||
- Graceful drain не добавляется, пока тест остановки не покажет
|
||
необходимость.
|
||
|
||
## UI/UX Package
|
||
|
||
`N/A` — UI не меняется; новый mockup не требуется.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- Derivation зависит от полноты `children`: пропуск записи id при dispatch
|
||
делает шаг невидимым для итога. Митигируется решением 2 (запись до await) и
|
||
тестом инъекции ошибки в dispatch.
|
||
- Per-step deadline может закрыть попытку как `partial` при медленном, но
|
||
живом шаге; поздний результат сохраняется строкой шага, данные не теряются.
|
||
- In-memory `active` не переживает рестарт — этот случай закрывает
|
||
restart-ветка reservation (решение 5).
|
||
- Константа 15 минут без конфигурации: потолок опыта одного процесса
|
||
(`ponytail:` вынести в конфиг, если медленные шаги станут регулярными).
|
||
|
||
## Migration / Rollout / Rollback
|
||
|
||
- Rollout: один PR в backend; миграций и флагов нет; следующий tick после
|
||
деплоя закрывает застрявшие строки.
|
||
- Rollback contract: `N/A` — нет миграции, security или public contract
|
||
изменения; откат — revert кода, уже закрытые recovery строки остаются в
|
||
корректном терминальном виде.
|
||
|
||
## Solution Assessment
|
||
|
||
Обязателен (concurrency/recovery). Варианты:
|
||
|
||
- (a) Root-cause: settle выводит итог из durable-детей; recovery на
|
||
существующем tick/reservation; один guard в общей точке.
|
||
- (b) Минимальный патч: настоящий failures-map в catch плюс guard в каждом
|
||
вызывающем.
|
||
- (c) Отдельный janitor/очередь с lease.
|
||
|
||
Выбор — (a): закрывает все error-пути одной точкой (exception, lost settle,
|
||
краш, deadline), переиспользует существующие fence и `progress`-паттерн, без
|
||
новых компонентов. (b) оставляет открытыми lost-settle и краш — каждый новый
|
||
error-путь снова требует своего guard, что и есть текущий баг. (c) отклонён:
|
||
issue явно запрещает параллельный scheduler, lease не даёт выгоды в
|
||
одно-процессной топологии, а стоимость нового компонента выше фикса.
|