15 KiB
Engineering Design
Context and Constraints
- Один backend-процесс; durable fence — reservation попытки в SQLite; таймер
процесса только предлагает работу (существующий инвариант, комментарии в
tbank-hourly.scheduler.ts). - Итог попытки пишется CAS-обновлением только из состояния
running: устаревшая публикация после появления преемника уже отклоняется существующим fence (settleHourlywherestate: '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
-
Итог выводится из durable-состояния шагов (корневой фикс).
settleHourlyв одной транзакции читает строки шагов попытки и вычисляет итог:succeeded— только когда каждый обязательный шаг успешен и закрытие инициировано нормальным завершением walk;partial— успешны некоторые;failed— ни один. Вызывающие больше не передают результат и список ошибок; walk передаёт максимумcause: 'INTERRUPTED'(закрытие до нормального завершения), который капит итог доpartial. Исключение — обязательные секции, чей dispatch не создал durable-строку шага: walk передаёт их как дополнительные problem-записи (наблюдаемый факт диспатча, не результат), иначе попытка могла бы получить ложный успех. Альтернатива «прокидывать настоящий failures-map в catch» отклонена: не закрывает lost-settle и краш-пути — каждый error-путь требовал бы своего guard. Отдельный janitor-сервис отклонён: issue требует чинить существующих владельцев, не вводить параллельный scheduler. -
Id шагов персистятся на попытке в момент dispatch.
progresshourly-попытки переиспользует существующую формуchildren: string[]+attempts: { [id]: attemptId }(паттернScreenProgress/progress()). Walk записывает id каждой строки шага сразу после dispatch, до ожидания — иначе recovery после краша не знает множество шагов попытки, а шаг, упавший в dispatch, исчезает из множества. -
Ограниченное ожидание на каждый шаг.
settledWorksполучает per-id deadline: константаWORK_WAIT_BOUND_MS = 15 * 60 * 1000; истёкший id возвращается как проблемный шаг с причинойDEADLINE. Управляемые часы в тестах — jest fake timers, инъекция clock в production-код не вводится. RPC не отменяется; поздний результат шага сохраняется его собственной строкой — учтён ли он в итоге, решает fence и момент фиксации. -
In-memory активные обходы + recovery на существующем tick.
TbankHourlySchedulerдержитactive: Set<string>(attemptId): добавление в началеwalk, удаление вfinally.launch()передreserveHourlyвызываетrecoverAbandoned(userId): если строка hourlyrunning,processRunIdсвой и attemptId не вactive— попытка потеряла settle и закрывается черезsettleHourlyсcause: 'INTERRUPTED'(итог из детей, максимумpartial). Это же закрывает застрявшие legacy-строкиrunning. Новых таймеров и санитаров нет — восстановление едет на существующем минутном tick. -
Restart-ветка
reserveHourlyзакрывает попытку итогом. Существующая ветка «running + чужойprocessRunId» вместо голой записиstate: 'cancelled'выполняет тот же derived settle сcause: 'INTERRUPTED'внутри транзакции reservation; далее прежнийdue-гейт: новый обход — не ранее следующего часового срока от начала попытки.cancelledдля hourly-строк перестаёт писаться; существующие маппингиcancelledвhourly()/hourlyAttempt()остаются толерантным чтением и не удаляются. -
Причина прерывания — в существующем поле
errorCode.INTERRUPTED— уровень попытки (закрытие до нормального завершения);DEADLINE— уровень шага (истечение ожидания), попадает вerrorCodeпопытки как первая ошибка секции по существующему precedence. Коды ошибок шагов остаются вprogress.sections[].errorCode. Поле — открытое строковое: форма ответа и enumTbankErrorCodeне расширяются (маркеры жизненного цикла — не ошибки T-Bank). -
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живут в существующем JSONprogressстроки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 не даёт выгоды в
одно-процессной топологии, а стоимость нового компонента выше фикса.