Files
dc-observatio/docs/tasks/TASK_tgu-plan-on-demand-redesign.md
T
Дмитрий Соловьев c76a823dbc Редизайн pcp-tgu-service: хранить только выданное, будущее считать на лету
Раньше вся будущая цепочка планов материализовалась в planned_plan как
PLANNED-строки, а отдельный воркер пересчёта (RebuildPlanScheduleUseCase +
PlanRecalculationJob) держал её в актуальном состоянии и гасил выпавшие планы
в SUPERSEDED/EXPIRED. Это плодило вложенные планы, гонки пересчёта и мусор в БД.

Новая модель: в БД живёт только выданное (история навсегда), будущее — проекция,
вычисляемая по запросу из ЗРВ. Перекрывать (supersede) и протухать (expire)
больше нечего.

Бэкенд:
- Удалены TrackedPlan (entity/source/status + repo) и PlanRecalculationJob
  (entity/repo/service/worker) — материализация и воркер пересчёта больше не нужны.
- Удалён RebuildPlanScheduleUseCase; будущее строит новый PlanProjectionService
  поверх PlanCalculationService.
- PlanQueryService отдаёт проекцию (выданное из БД + будущее на лету).
- Новый флоу реакции на смену параметров станции: StationParamsChangedConsumer +
  HandleStationParamsChangedUseCase (срочный перевыпуск через planning_state).
- Новые консьюмеры обратной связи исполнения: LayinFailedConsumer,
  PlanNotAcceptedConsumer (+ DTO PlanExecutionFeedbackMessage,
  StationParamsChangedMessage).
- PlannedPlanStatus: убраны мёртвые SUPERSEDED/EXPIRED; статусы выровнены под
  жизненный цикл выдачи/закладки.

Миграции (ddl-auto: validate):
- V7  drop tracked_plan
- V8  drop plan_recalculation_job
- V9  planning_state: поля срочного перевыпуска
- V10 снять unique-ограничение бизнес-ключа planned_plan
- V11 удалить легаси PLANNED-строки (материализованное будущее)
- V12 удалить легаси SUPERSEDED/EXPIRED и столбец superseded_by_plan_id

Тесты: удалены JobRerunFlowTest и RebuildPlanScheduleUseCaseTest; добавлены
PlanProjectionServiceTest, PlanQueryServiceTest, IssueDuePlanWorkerOnDemandTest,
HandleStationParamsChangedUseCaseTest, VisibilityPayloadParserTest. Скорректированы
PlanIssueAndDecisionFlowTest, PlanCalculationServiceTest, VisibilityFlowTest и др.

V11/V12 необратимы — перед миграцией на проде снять дамп planned_plan.
2026-06-02 14:32:51 +03:00

32 KiB
Raw Blame History

Задача: pcp-tgu-service — «храним только выданное» + закладочный флоу

ТЗ для кодинг-агента и для приёмки. Раздел 0 — промпт запуска. Разделы 1–9 — что и как делаем. Раздел 10 — критерии приёмки (по ним проверяем результат). Стиль работы, границы слоёв и формат финального ответа — по корневому AGENTS.md.


0. Промпт для запуска агента (copy-paste)

Ты работаешь в монорепозитории observatio-terrae (pcp), модуль services/pcp-tgu-service (Kotlin/Spring Boot).

Задача: перестроить хранение и выдачу планов ТГУ — перестать материализовать
будущую цепочку планов в БД (хранить только выданные планы и их историю — вечно),
считать будущее на лету как проекцию, и добавить закладочный флоу с реакцией
на два Kafka-события «не заложен» и «не принят».

Перед началом обязательно прочитай:
1. /AGENTS.md — правила работы, стиль, формат финального ответа.
2. /docs/tasks/TASK_tgu-plan-on-demand-redesign.md — это ТЗ.
3. Текущий код: services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/
   (service/, entity/, repository/, controller/, integration/kafka/, config/).

Делай строго по фазам раздела 5. Всё — в одной ветке/одном PR.
Ядро расчёта цепочки (PlanCalculationService) НЕ меняем — оно уже монотонно-корректно.
После работы прогоняй ./gradlew :services:pcp-tgu-service:test из корня монорепо.
В конце дай отчёт в формате из AGENTS.md и пройдись по разделу 10 (критерии приёмки).

1. Контекст и цель

pcp-tgu-service (порт :7011) — роллинг-планировщик сеансов управления по каждому операционному КА. Поток: список КА из НСИ → окна видимости (ЗРВ) из баллистики → расчёт цепочки планов (~target 1440±240 мин, привязка 1:1 к ЗРВ, строгая монотонность) → выдача дозревшего плана через короткий BPMN → решение из Kafka. UI (через агрегатор :7008) рисует таймлайн планов.

Проблема сейчас. Таблица planned_plan материализует всю будущую цепочку как PLANNED-строки и переписывает её на каждое изменение хэша ЗРВ. Это копит «лишнее»: пачки SUPERSEDED/EXPIRED на каждый пересчёт, рост без границ, отдельная машинерия plan_recalculation_job, и целый класс багов рассинхрона цепочки со снапшотом («схлопывание в SUPERSEDED», «зомби-PLANNED»). При этом будущая цепочка — детерминированная проекция от (снапшот ЗРВ + базовый план + конфиг).

Цель.

  1. Хранить в planned_plan только планы, перешедшие в выдачу, и их историю — вечно (ретеншн не делаем).
  2. Будущее (не выданное) — не хранить вовсе, считать на лету как проекцию (в т.ч. для UI).
  3. Добавить закладочный флоу: после ACCEPTED план оптимистично считается заложенным; реагируем только на явный негатив — два раздельных Kafka-события «не заложен» и «не принят», оба пока ведут себя одинаково (срочно выдать план на ближайшую ЗРВ), но разнесены для будущего расхождения.
  4. Реагировать на изменение параметров станций (Kafka-событие): станция могла выйти из строя — нужно заново запросить ЗРВ у баллистики по затронутым КА и пересобрать цепочку. В on-demand-модели это сводится к форсу visibility_refresh_job → новый снапшот → проекция пересчитается сама.
  5. Снести мёртвый код (tracked_plan) и ставшую ненужной машинерию пересчёта (plan_recalculation_job).

Масштаб: 100150 КА, горизонт планирования до 10 суток, деплой 23 реплики (горизонтальное масштабирование уже обеспечено координацией через БД: FOR UPDATE SKIP LOCKED + pessimistic-lock на КА).


2. Границы задачи (scope)

В scope:

  • Перестроение хранения/выдачи планов в pcp-tgu-service (backend, Kotlin).
  • Новый сервис проекции; переписанный воркер выдачи; read-эндпоинт, отдающий persisted ∪ проекцию.
  • Новые статусы плана и закладочный флоу с двумя новыми Kafka-консьюмерами.
  • Удаление мёртвого кода и снятие plan_recalculation_job.
  • Миграции БД, изменения конфига (application.yml + config-repo/pcp-tgu-service.yaml).
  • Юнит-тесты на новую логику.

Вне scope (без отдельного запроса):

  • Фронтенд pcp-tgu-ops-ui и агрегатор pcp-ui-service — контракт /api/plans сохраняем неизменным, фронт не трогаем.
  • Изменение ядра расчёта PlanCalculationService (кроме переиспользования; алгоритм не меняем).
  • BPMN-схемы (issueSatellitePlan, updateAcceptedPlanSlots) — не меняем.
  • Источник/продюсер новых Kafka-событий «не заложен»/«не принят» — мы только консьюмеры; контракт ниже фиксируем.
  • Ретеншн/архивация — НЕ делаем (выданное храним вечно).

3. Текущая архитектура (точки изменения)

  • PlannedPlanEntity / planned_plan — хранит всю цепочку, upsert по бизнес-ключу (spacecraft, start, end, kpp).
  • RebuildPlanScheduleUseCase — пересобирает цепочку: calculatePlans/After → upsert → supersedeObsoleteFuturePlansexpireMissedPlans.
  • PlanCalculationServiceядро, не трогаем.
  • SpacecraftVisibilitySnapshotService — на изменение хэша ставит plan_recalculation_job.
  • PlanRecalculationWorker / PlanRecalculationJobService / plan_recalculation_jobснимаем целиком.
  • IssueDuePlanWorker — сейчас сканирует PLANNED (findDueForUpdate). Переписываем на on-demand.
  • HandlePlanDecisionUseCase — обработка ACCEPTED/REJECTED; promoteNextPlanOrEnqueueRebuild завязан на хранимые PLANNED → переписываем.
  • PlanQueryService / PlanController — read-путь, дополняем проекцией.
  • Мёртвый код: TrackedPlanEntity, TrackedPlanStatus, TrackedPlanSource, TrackedPlanJpaRepository — на репозиторий нет ссылок.

4. Целевая архитектура

  • planned_plan пополняется строкой только в момент выдачи (статус ISSUING). Дальше строка живёт по жизненному циклу выдачи и хранится вечно.
  • Будущее не хранится. PlanProjectionService.project(spacecraftId) считает цепочку на лету из последнего снапшота ЗРВ + текущей базы + конфига, переиспользуя PlanCalculationService.
  • Выдача ([IssueDuePlanWorker]) идёт по активным КА, считает голову проекции; если голова «дозрела» (issue_not_before ≤ now) или взведён urgent_reissue_from — материализует ровно эту голову в ISSUING и выдаёт. Никаких PLANNED-строк в БД.
  • Read ([PlanQueryService]) отдаёт persisted (вся история) ∪ проекция будущего (виртуальный PLANNED, в БД не пишется), дедуп по бизнес-ключу. Контракт /api/plans неизменен.
  • Закладочный флоу: ACCEPTEDAWAITING_LAYIN (оптимистично = база, цепочка идёт дальше). Два новых события гасят план терминально (LAYIN_FAILED / NOT_ACCEPTED) и взводят срочную перевыдачу на ближайшую ЗРВ с лидом now + reissue-prep-minutes.

5. Фазы работ

Фаза 0. Чистка мёртвого кода

  • Удалить TrackedPlanEntity, TrackedPlanStatus, TrackedPlanSource, TrackedPlanJpaRepository.
  • Миграция V7__drop_tracked_plan.sql: DROP TABLE IF EXISTS tracked_plan;
  • Убедиться, что компиляция/тесты не ссылались на эти классы.

Фаза 1. On-demand проекция, конец материализации цепочки

  • Новый PlanProjectionService с project(spacecraftId): List<CalculatedPlan> и projectHead(spacecraftId): CalculatedPlan?: читает spacecraft_visibility_snapshot, текущую базу (spacecraft_planning_state.currentBasePlanId), считает цепочку через PlanCalculationService (calculatePlans/calculatePlansAfter). Опционально — in-memory кэш по payload_hash (если делаешь — инвалидируй при смене снапшота/базы; при 150 КА можно и без кэша).
  • Переписать IssueDuePlanWorker на on-demand: идём по активным КА (spacecraft_snapshot.active = true / spacecraft_planning_state), для каждого через PlanProjectionService.projectHead берём ближайший план; если у КА нет активной попытки (activeAttemptId == null) и (issue_not_before(head) ≤ now или взведён urgent_reissue_from) — материализуем голову в planned_plan со статусом ISSUING и стартуем выдачу. Сохраняем claim-семантику с lock на КА.
  • RebuildPlanScheduleUseCase: убрать материализацию (upsertPlannedByBusinessKey, supersedeObsoleteFuturePlans, expireMissedPlans). Логику расчёта забирает PlanProjectionService; сам use case либо удаляется, либо превращается в тонкую обёртку над проекцией (если на него ссылается TestTguController — переориентировать/упростить).
  • Снять машинерию пересчёта: удалить PlanRecalculationWorker, PlanRecalculationJobService, PlanRecalculationJobEntity, PlanRecalculationJobRepository; убрать enqueue(...) из SpacecraftVisibilitySnapshotService (hash-проверку оставить — она просто перестаёт что-либо ставить). Миграция V8__drop_plan_recalculation_job.sql: DROP TABLE IF EXISTS plan_recalculation_job;
  • PlannedPlanRepository: удалить ставшие мёртвыми upsertPlannedByBusinessKey, findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime, findAllBySpacecraftIdAndStatusAndStartTimeLessThanEqualOrderByStartTime, findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime. Оставить/добавить: lockByPlanId, выборка истории по КА и по всем для read, защита от дублей выдачи.

Фаза 2. Read-проекция для UI

  • PlanQueryService.getAllPlans/getPlansBySpacecraftId: вернуть persisted projection. Persisted — все строки planned_plan (история вечна; historyDays оставить как фильтр отображения левого края, БД не режем). Projection — PlanProjectionService.project(...) по активным КА, виртуальный статус PLANNED, дедуп: проекционный план не добавляем, если его бизнес-ключ уже есть среди persisted.
  • PlanResponse: научить представлять виртуальные планы (детерминированный planId из бизнес-ключа, либо nullable — на усмотрение, но контракт JSON для фронта не ломать; status = PLANNED). Добавить фабрику from(CalculatedPlan, status).

Фаза 3. Статусы и закладочный флоу

  • PlannedPlanStatus: добавить AWAITING_LAYIN, LAID_IN, LAYIN_FAILED, NOT_ACCEPTED. PLANNED оставить только как виртуальный для проекции (в БД не писать).
  • HandlePlanDecisionUseCase (топик satellite-plan-decision):
    • ACCEPTED → план ACCEPTED затем AWAITING_LAYIN, currentBasePlanId = plan, проекция продолжается; acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) (как есть).
    • REJECTED → план терминально REJECTED → общий reissueOnNearestWindow(...).
    • Удалить promoteNextPlanOrEnqueueRebuild (он завязан на хранимые PLANNED).
  • Новые события (раздельные консьюмеры, общее поведение):
    • TopicsProperties: добавить layinFailed и planNotAccepted.
    • DTO PlanExecutionFeedbackMessage(eventId, spacecraftId, planId, occurredAt, reason) — пока общий на оба события.
    • Консьюмеры LayinFailedConsumer, PlanNotAcceptedConsumer (groupId ${spring.kafka.consumer.group-id}).
    • В use case — два метода handleLayinFailed(msg) / handleNotAccepted(msg), оба идемпотентны по eventId через processed_plan_decision_event, оба зовут общий reissueOnNearestWindow(...); статус помечаемого плана соответственно LAYIN_FAILED / NOT_ACCEPTED.
  • reissueOnNearestWindow(spacecraftId, failedPlanId, now) (общий для REJECTED-после-cutoff / LAYIN_FAILED / NOT_ACCEPTED):
    • под lock на КА: помечает план терминальным статусом (история — навечно), снимает его с роли базы (currentBasePlanId ← предыдущая валидная база или null), чистит activePlanId/activeAttemptId;
    • ставит spacecraft_planning_state.urgent_reissue_from = now + reissue-prep-minutes.
  • IssueDuePlanWorker urgent-path: если у КА urgent_reissue_from != null — голову проекции берём как ближайшую ЗРВ со startTime ≥ urgent_reissue_from, материализуем и выдаём немедленно (не ждём issue_not_before), после успешного старта urgent_reissue_from = null.
  • Миграция V9__planning_state_urgent_reissue.sql: ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;

Фаза 3b. Событие «изменились параметры станций»

  • TopicsProperties + конфиги: topics.station-params-changed: pcp.tgu.station-params-changed.v1.
  • DTO StationParamsChangedMessage(eventId, stationId?, occurredAt, reason?).
  • Консьюмер StationParamsChangedConsumer (groupId ${spring.kafka.consumer.group-id}).
  • Use case handleStationParamsChanged(msg): идемпотентен по eventId (общий ledger processed_plan_decision_event); ставит visibility_refresh_job (VisibilityRefreshJobService.enqueue, reason STATION_PARAMS_CHANGED) по всем активным КА (spacecraft_snapshot.active = true). Дальше [VisibilityRefreshWorker] перечитывает ЗРВ у баллистики → новый снапшот → on-demand проекция пересобирает цепочку автоматически.
  • stationId в событии пока не используется для сужения (карты «станция → КА» в tgu нет; баллистика считает ЗРВ по-КА). Поле оставляем на будущее для точечного refresh; сейчас поведение — blanket refresh всех активных КА (при 150 КА дёшево).
  • Уже выданные/принятые планы (ISSUING/AWAITING_LAYIN) ретроспективно не меняются — пересобирается только будущая часть цепочки (после базы). Если станция текущего плана отказала по сути — это придёт отдельным негативным событием (layin-failed/not-accepted).

Фаза 4. Конфиг

  • PlanningProperties + application.yml + config-repo/pcp-tgu-service.yaml:
    • external-points-lookahead-days: 3 → 10;
    • новый reissue-prep-minutes: 30;
    • min-reissue-lead-minutes — оставить (REJECTED-до-cutoff).
  • TopicsProperties + конфиги: topics.layin-failed: pcp.tgu.layin-failed.v1, topics.plan-not-accepted: pcp.tgu.plan-not-accepted.v1, topics.station-params-changed: pcp.tgu.station-params-changed.v1 (с возможностью переопределить).

Фаза 5. Тесты

  • Сохранить PlanCalculationServiceTest (ядро не тронуто).
  • Новые: PlanProjectionServiceTest, IssueDuePlanWorker (on-demand голова + urgent-path), HandlePlanDecisionUseCase (ACCEPTED→AWAITING_LAYIN; REJECTED→reissue), консьюмеры layin-failed/not-accepted (идемпотентность, раздельность, общий reissue), консьюмер station-params-changed (идемпотентность + постановка refresh по всем активным КА), read-проекция (persisted projection + дедуп).
  • Удалить тесты снятой машинерии (recalc worker, supersede/expire); поправить RebuildPlanScheduleUseCaseTest.

6. Модель данных и миграции

Миграция Действие
V7__drop_tracked_plan.sql DROP TABLE IF EXISTS tracked_plan;
V8__drop_plan_recalculation_job.sql DROP TABLE IF EXISTS plan_recalculation_job;
V9__planning_state_urgent_reissue.sql ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;

planned_plan — схему не меняем (новые значения статуса — это VARCHAR, миграция не нужна). Индексы оставляем. ddl-auto: validate — значит сущности должны точно соответствовать таблицам после миграций.


7. Конфиг (итоговые значения)

planning:
  notification-before-start-minutes: 263
  target-plan-duration-minutes: 1440
  target-plan-duration-delta-minutes: 240
  external-update-enabled: true
  external-points-lookahead-days: 10      # было 3
  in-memory-history-days: 14
  external-update-fixed-delay-ms: 600000
  notification-check-fixed-delay-ms: 30000
  min-reissue-lead-minutes: 5
  reissue-prep-minutes: 30                 # новый
  worker-lock-ttl-seconds: 300

topics:
  visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
  satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
  layin-failed: pcp.tgu.layin-failed.v1                    # новый
  plan-not-accepted: pcp.tgu.plan-not-accepted.v1          # новый
  station-params-changed: pcp.tgu.station-params-changed.v1 # новый

8. Контракты Kafka (входящие, мы — консьюмер)

Существующий satellite-plan-decision (PlanDecisionMessage) — без изменений.

Новые топики, общий DTO PlanExecutionFeedbackMessage:

{
  "eventId": "uuid",       // dedup-ключ (идемпотентность)
  "spacecraftId": "string",
  "planId": "uuid",        // план, по которому пришёл негатив (текущая база)
  "occurredAt": "2026-06-02T10:00:00",
  "reason": "string|null"
}
  • pcp.tgu.layin-failed.v1 — «не заложен».
  • pcp.tgu.plan-not-accepted.v1 — «не принят».
  • Поведение пока идентично; обработчики раздельные.

Новый топик pcp.tgu.station-params-changed.v1, DTO StationParamsChangedMessage:

{
  "eventId": "uuid",        // dedup-ключ (идемпотентность)
  "stationId": "string|null", // какая станция изменилась; пока не используется для сужения
  "occurredAt": "2026-06-02T10:00:00",
  "reason": "string|null"
}
  • При получении — поставить visibility_refresh_job по всем активным КА (перезапрос ЗРВ у баллистики), цепочка пересоберётся через on-demand проекцию.

9. Допущения и риски

  • Источник «ближайшей ЗРВ» для срочной перевыдачи — тот же последний снапшот ЗРВ; если в нём нет окна со startTime ≥ urgent_reissue_from, голова пуста — КА ждёт следующего refresh окон (это корректное поведение, не ошибка).
  • Идемпотентность новых событий — обязательна (at-least-once Kafka), через общий processed_plan_decision_event.
  • ddl-auto: validate: после правок прогнать миграции на чистой БД и убедиться, что Hibernate-валидация проходит.
  • Совместимость UI: JSON-форма /api/plans не меняется; виртуальные планы отдаются с тем же набором полей и status: "PLANNED".
  • Сборка ресурсов: при правке application.yml помнить про ./gradlew :services:pcp-tgu-service:processResources (IntelliJ-рестарт не всегда пересобирает ресурсы).

10. Критерии приёмки (по ним проверяем)

10.1. Хранение «только выданное»

  • При штатной работе (КА с окнами ЗРВ, ни одного дозревшего плана) в planned_plan нет ни одной строки для этого КА. Будущее видно только через /api/plans как PLANNED.
  • Строка в planned_plan появляется ровно в момент выдачи и сразу в статусе ISSUING (не PLANNED).
  • Статус PLANNED никогда не пишется в БД (проверяемо: SELECT count(*) FROM planned_plan WHERE status='PLANNED' = 0 в любой момент).
  • Выданные планы и их история не удаляются (ретеншн-джоба нет; нет DELETE/SUPERSEDED-массовой чистки будущего).
  • В коде нет материализации цепочки: upsertPlannedByBusinessKey, supersedeObsoleteFuturePlans, expireMissedPlans удалены.

10.2. Проекция будущего

  • GET /api/plans и GET /api/plans/{spacecraftId} возвращают persisted-планы плюс вычисленную проекцию будущего; JSON-контракт прежний, фронт работает без изменений.
  • Проекция совпадает по содержанию с тем, что раньше материализовалось (та же монотонная цепочка из PlanCalculationService): для одних и тех же окон ЗРВ набор (start,end,kpp) будущих планов идентичен прежнему поведению.
  • Нет дублей: если бизнес-ключ есть в persisted, проекция его не добавляет повторно.
  • Левый край истории по-прежнему регулируется historyDays.

10.3. Снятие машинерии и мёртвого кода

  • Таблиц tracked_plan и plan_recalculation_job нет (миграции DROP применены); связанные сущности/репозитории/воркеры/сервисы удалены.
  • SpacecraftVisibilitySnapshotService больше не ставит recalc-job; сохранение снапшота и hash-проверка работают.
  • Проект компилируется без ссылок на удалённые классы; неиспользуемые методы репозитория убраны.

10.4. Закладочный флоу (оптимизм + реакция на негатив)

  • ACCEPTED → план переходит в AWAITING_LAYIN, становится базой; цепочка продолжается; updateAcceptedPlanSlots-job ставится. Никакого ожидания подтверждения закладки в виде доп. строк.
  • Отсутствие любых событий после ACCEPTED = «заложен» (план остаётся базой, ничего не делаем).
  • Событие pcp.tgu.layin-failed.v1 по базовому плану → план в LAYIN_FAILED, взводится urgent_reissue_from = now + reissue-prep-minutes, и ближайшая ЗРВ ≥ этого времени выдаётся немедленно (не дожидаясь issue_not_before).
  • Событие pcp.tgu.plan-not-accepted.v1 → то же поведение, но статус NOT_ACCEPTED; обработчик отдельный (разные методы/консьюмеры), общее ядро reissueOnNearestWindow.
  • Оба события идемпотентны: повтор того же eventId — no-op (повторной перевыдачи нет).
  • reissue-prep-minutes берётся из конфига (по умолчанию 30); изменение значения меняет лид срочной выдачи.
  • Если в снапшоте нет ЗРВ ≥ urgent_reissue_from — выдачи нет, флаг сохраняется, повторная попытка произойдёт после обновления окон (без ошибок/исключений в логе).

10.4b. Реакция на изменение параметров станций

  • Событие pcp.tgu.station-params-changed.v1 ставит visibility_refresh_job по всем активным КА (reason STATION_PARAMS_CHANGED).
  • После перезапроса ЗРВ снапшоты обновляются, и /api/plans отдаёт цепочку, пересобранную по новым окнам (без перезапуска сервиса и без ручного rebuild).
  • Событие идемпотентно по eventId (повтор — без повторной постановки refresh).
  • Уже выданные/принятые планы ретроспективно не меняются; пересобирается только будущее.

10.5. Масштабирование / корректность под несколькими подами

  • Claim во всех воркерах — FOR UPDATE SKIP LOCKED; выдача плана и обработка решения — под lock на КА (один план не выдаётся дважды при 2–3 подах).
  • У одного КА одновременно не более одной активной попытки выдачи (activeAttemptId).
  • Сервис стартует и работает с external-points-lookahead-days: 10.

10.6. Конфиг и миграции

  • application.yml и config-repo/pcp-tgu-service.yaml содержат итоговые значения раздела 7 (lookahead 10, reissue-prep-minutes: 30, два новых топика).
  • ddl-auto: validate проходит на чистой БД после применения всех миграций (V7–V9).

10.7. Тесты и сборка

  • ./gradlew :services:pcp-tgu-service:test — зелёный.
  • Есть тесты на: проекцию, on-demand выдачу, urgent-path, ACCEPTED→AWAITING_LAYIN, оба новых консьюмера (идемпотентность + раздельность + общий reissue), read-объединение с дедупом.
  • Старые тесты снятой машинерии удалены, оставшиеся — зелёные.

11. Команды проверки

# из корня монорепо
./gradlew :services:pcp-tgu-service:test
./gradlew :services:pcp-tgu-service:processResources   # после правок application.yml

# ручная проверка флоу (нужен tgu.test-controller.enabled=true локально):
#  - запустить сервис на :7011, дождаться окон ЗРВ
#  - GET http://localhost:7011/api/plans/{spacecraftId}  -> в ответе PLANNED-проекция, в БД строк нет
#  - сэмулировать ACCEPTED -> в БД ACCEPTED/AWAITING_LAYIN, проекция продолжается
#  - отправить layin-failed/not-accepted -> терминальный статус + срочная выдача ближайшей ЗРВ