Files
specs/openspec/changes/harden-hourly-sync-lifecycle/design.md
T
ksv741 14174a7bb3
Conventional commits / commits (pull_request) Successful in 12s
Specs security / security (pull_request) Successful in 7s
Specs verify / verify (pull_request) Successful in 10s
spec: add harden-hourly-sync-lifecycle change (WealthLedger/backend#56)
2026-10-12 20:28:58 +03:00

15 KiB
Raw Blame History

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