# 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` (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 не даёт выгоды в одно-процессной топологии, а стоимость нового компонента выше фикса.