Files
ksv741 fe295aa73a
Conventional commits / commits (pull_request) Successful in 10s
Specs security / security (pull_request) Successful in 8s
Specs verify / verify (pull_request) Successful in 9s
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 11s
spec: archive harden-hourly-sync-lifecycle change (WealthLedger/backend#56)
2026-10-12 22:52:44 +03:00

221 lines
15 KiB
Markdown
Raw Permalink 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.
# 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`. Тем же bound покрыты
и ожидания walk до появления durable-строк шагов (review-замечание,
follow-up WealthLedger/backend#78): `membership.request`/`member.done`,
`access.local` и dispatch `portfolio`/`operations`/`events.refresh` гоняются
с `WORK_WAIT_BOUND_MS` через race; истечение трактуется как проблемная
секция (как failed dispatch), для `access.local` — как закрытие
`INTERRUPTED`. Вызов не отменяется; поздняя durable-строка fenced по
`attemptId`, как любая поздняя публикация. Управляемые часы в
тестах — 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 не даёт выгоды в
одно-процессной топологии, а стоимость нового компонента выше фикса.