c3a1a8b4a1
Capture current PCP architecture notes, service-map prototypes, TGU operations UI/map work, local configuration updates, database helper scripts, and request/sample JSON artifacts.
1886 lines
64 KiB
Markdown
1886 lines
64 KiB
Markdown
# План доработки `pcp-tgu-service` под горизонтальное масштабирование
|
||
|
||
## 0. Назначение документа
|
||
|
||
Этот документ — рабочий план для агента, который должен за один проход на усилии **high** перепроектировать `pcp-tgu-service` из single-instance сервиса с in-memory состоянием в horizontally scalable persisted scheduler выдачи планов КА.
|
||
|
||
План намеренно строгий: каждый этап имеет вход, выход, шаги, ограничения и gate перехода к следующему этапу. Агент должен двигаться по этапам последовательно и не перескакивать через gates.
|
||
|
||
После выполнения этого плана будет выполнен отдельный повторный review. Цель текущего прогона — не идеальная финальная архитектура, а устранение главных blocker'ов горизонтального масштабирования:
|
||
|
||
- production state не должен жить в памяти pod'а;
|
||
- несколько pod'ов должны безопасно выполнять polling, пересчёт, выдачу и обработку решений;
|
||
- план и попытка выдачи должны быть разными сущностями;
|
||
- BPMN должен означать одну попытку выдачи, а не долгоживущий процесс ожидания принятия;
|
||
- ручной функционал должен быть вынесен в test-only controller и не участвовать в production-flow.
|
||
|
||
---
|
||
|
||
## 1. Scope
|
||
|
||
### 1.1. Можно менять
|
||
|
||
- `services/pcp-tgu-service/**`;
|
||
- `config-repo/pcp-tgu-service.yaml`, если нужно добавить новые свойства;
|
||
- Flyway migrations внутри `services/pcp-tgu-service/src/main/resources/db/migration`;
|
||
- BPMN-файл `services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn`;
|
||
- тесты `services/pcp-tgu-service/src/test/**`.
|
||
|
||
### 1.2. Нельзя менять
|
||
|
||
- Helm charts;
|
||
- GitLab CI/CD;
|
||
- другие сервисы;
|
||
- общие библиотеки, если без этого можно обойтись;
|
||
- контракты других сервисов без явной необходимости;
|
||
- инфраструктурные зависимости: Redis, новые брокеры, отдельные БД, leader-election framework.
|
||
|
||
### 1.3. Не делать в этом прогоне
|
||
|
||
- не выделять отдельный сервис расчёта;
|
||
- не распределять КА между разными сервисами вручную;
|
||
- не делать Redis locks;
|
||
- не делать сложный reconciliation для `START_AMBIGUOUS`;
|
||
- не делать automatic timeout/reissue, если решение по плану вообще не пришло;
|
||
- не переписывать алгоритм `PlanCalculationService` без необходимости;
|
||
- не оптимизировать преждевременно JSONB/нормализацию окон видимости, если raw payload можно надёжно сохранить как `TEXT`.
|
||
|
||
---
|
||
|
||
## 2. Целевая production-логика
|
||
|
||
`pcp-tgu-service` должен стать сервисом, который:
|
||
|
||
1. получает список разрешённых аппаратов из НСИ;
|
||
2. для активных аппаратов получает окна видимости у баллистики;
|
||
3. полностью сохраняет raw payload окон видимости;
|
||
4. строит persisted расписание планов согласно настройкам;
|
||
5. за `planning.notification-before-start-minutes` до старта плана запускает BPMN-процесс выдачи;
|
||
6. считает один BPMN process instance одной попыткой выдачи плана;
|
||
7. хранит план и попытки выдачи как разные сущности;
|
||
8. при тех же `spacecraftId + startTime + endTime + kppId` создаёт тот же persisted plan, но новую attempt;
|
||
9. получает `ACCEPTED` / `REJECTED` через отдельный Kafka topic;
|
||
10. при `ACCEPTED` фиксирует план как базовый и пересчитывает хвост расписания;
|
||
11. при `REJECTED` создаёт возможность новой попытки того же плана, если время ещё позволяет;
|
||
12. если время ушло, переводит текущий план в `EXPIRED` и продвигает следующий доступный план;
|
||
13. если решения нет, не делает автоматическую перевыдачу.
|
||
|
||
---
|
||
|
||
## 3. Архитектурные решения, которые нельзя менять без явной причины
|
||
|
||
### 3.1. Kafka разделяется на два входа
|
||
|
||
Должно быть два разных Kafka topic и два разных consumer/use case.
|
||
|
||
#### Topic 1: изменение окон видимости
|
||
|
||
Смысл: сигнал, что для КА нужно перечитать окна у баллистики.
|
||
|
||
Consumer не должен:
|
||
|
||
- хранить окна в памяти;
|
||
- получать окна у баллистики прямо в listener transaction;
|
||
- пересчитывать планы;
|
||
- стартовать BPMN.
|
||
|
||
Consumer должен только поставить или обновить `visibility_refresh_job`.
|
||
|
||
#### Topic 2: решение по попытке выдачи плана
|
||
|
||
Смысл: результат конкретной попытки выдачи плана.
|
||
|
||
Payload должен содержать:
|
||
|
||
- `eventId`;
|
||
- `spacecraftId`;
|
||
- `planId`;
|
||
- `attemptId`;
|
||
- `decision = ACCEPTED | REJECTED`;
|
||
- `decisionTime`;
|
||
- `reason`.
|
||
|
||
`attemptId` обязателен. Без него нельзя безопасно отличить решение старой попытки от решения новой попытки того же плана.
|
||
|
||
### 3.2. План и попытка выдачи — разные сущности
|
||
|
||
Один business-plan:
|
||
|
||
```text
|
||
spacecraftId + startTime + endTime + kppId
|
||
```
|
||
|
||
должен иметь один persisted `planId`.
|
||
|
||
Повторная выдача того же business-plan должна создавать новый `attemptId`, а не новый `planId`.
|
||
|
||
### 3.3. BPMN = одна попытка выдачи
|
||
|
||
Целевой BPMN:
|
||
|
||
```text
|
||
start
|
||
-> calculateSatelliteSurveyMissions
|
||
-> calculateSatelliteDropMissions
|
||
-> sendSatellitePlan
|
||
-> end
|
||
```
|
||
|
||
BPMN больше не должен ждать accepted/rejected message. Результат попытки приходит через Kafka topic решения по плану.
|
||
|
||
### 3.4. DB — source of truth
|
||
|
||
Production-flow не должен опираться на:
|
||
|
||
- `PlanRepository` как in-memory хранилище планов;
|
||
- `SpacecraftPointsRepository` как in-memory хранилище окон видимости.
|
||
|
||
Локальная память допустима только для временных вычислений внутри одного метода или как read-only cache, от которого не зависит корректность.
|
||
|
||
### 3.5. Горизонтальное масштабирование через DB claim
|
||
|
||
Все повторяющиеся workers/schedulers могут запускаться на каждом pod. Корректность должна обеспечиваться через:
|
||
|
||
- unique constraints;
|
||
- transactional updates;
|
||
- `FOR UPDATE SKIP LOCKED` или lease-модель;
|
||
- idempotency по `eventId`;
|
||
- active attempt на уровне `spacecraft_planning_state`.
|
||
|
||
---
|
||
|
||
## 4. Текущие code anchors
|
||
|
||
Текущие классы, на которые нужно ориентироваться:
|
||
|
||
```text
|
||
services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service
|
||
```
|
||
|
||
Ключевые текущие файлы:
|
||
|
||
```text
|
||
config/PlanningProperties.kt
|
||
config/TopicsProperties.kt
|
||
controller/ExternalPointsController.kt
|
||
controller/PlanConfirmationController.kt
|
||
controller/PlanController.kt
|
||
controller/PlatformController.kt
|
||
controller/SatelliteMissionController.kt
|
||
domain/Plan.kt
|
||
domain/SpacecraftPoints.kt
|
||
dto/CreateSatelliteMissionVariables.kt
|
||
dto/ExternalRvaDto.kt
|
||
dto/SpacecraftPointsMessage.kt
|
||
entity/SpacecraftPlanningStateEntity.kt
|
||
entity/TrackedPlanEntity.kt
|
||
entity/TrackedPlanStatus.kt
|
||
integration/api/ExternalPointsClient.kt
|
||
integration/api/PlatformsClassifierClient.kt
|
||
integration/kafka/SpacecraftPointsKafkaConsumer.kt
|
||
repository/PlanRepository.kt
|
||
repository/SpacecraftPlanningStateJpaRepository.kt
|
||
repository/SpacecraftPointsRepository.kt
|
||
repository/TrackedPlanJpaRepository.kt
|
||
service/ExternalPointsUpdateScheduler.kt
|
||
service/PlanCalculationService.kt
|
||
service/PlanConfirmationService.kt
|
||
service/PlanProcessScheduler.kt
|
||
service/PlanQueryService.kt
|
||
service/PlanStatusWorker.kt
|
||
service/PlatformService.kt
|
||
service/SatelliteMissionProcessService.kt
|
||
service/SpacecraftPlanningStateService.kt
|
||
service/SpacecraftPointsService.kt
|
||
service/TrackedPlanService.kt
|
||
```
|
||
|
||
Текущие migrations:
|
||
|
||
```text
|
||
V1__create_tracked_plan.sql
|
||
V2__extend_tracked_plan.sql
|
||
V3__create_spacecraft_planning_state.sql
|
||
```
|
||
|
||
Новая migration должна быть следующей по номеру, например:
|
||
|
||
```text
|
||
V4__redesign_tgu_planning_state.sql
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Целевая структура кода
|
||
|
||
Агент может выбрать небольшие вариации имён, но структура должна остаться близкой к этой.
|
||
|
||
```text
|
||
config/
|
||
PlanningProperties.kt
|
||
TopicsProperties.kt
|
||
|
||
controller/
|
||
PlanController.kt
|
||
PlatformController.kt
|
||
TestTguController.kt
|
||
|
||
.domain/
|
||
CalculatedPlan.kt
|
||
InsertionPoint.kt
|
||
ObservationWindow.kt
|
||
PlanDecision.kt
|
||
SpacecraftPoints.kt
|
||
|
||
.dto/
|
||
CreateSatelliteMissionVariables.kt
|
||
PlanDecisionMessage.kt
|
||
PlanResponse.kt
|
||
VisibilityWindowsChangedMessage.kt
|
||
|
||
.entity/
|
||
JobStatus.kt
|
||
PlanIssueAttemptEntity.kt
|
||
PlanIssueAttemptStatus.kt
|
||
PlannedPlanEntity.kt
|
||
PlannedPlanStatus.kt
|
||
ProcessedPlanDecisionEventEntity.kt
|
||
SpacecraftPlanningStateEntity.kt
|
||
SpacecraftSnapshotEntity.kt
|
||
SpacecraftVisibilitySnapshotEntity.kt
|
||
VisibilityRefreshJobEntity.kt
|
||
PlanRecalculationJobEntity.kt
|
||
|
||
.integration/api/
|
||
ExternalPointsClient.kt
|
||
PlatformsClassifierClient.kt
|
||
VisibilityPayloadParser.kt
|
||
|
||
.integration/kafka/
|
||
VisibilityWindowsChangedConsumer.kt
|
||
PlanDecisionConsumer.kt
|
||
|
||
.repository/
|
||
PlannedPlanRepository.kt
|
||
PlanIssueAttemptRepository.kt
|
||
ProcessedPlanDecisionEventRepository.kt
|
||
SpacecraftPlanningStateJpaRepository.kt
|
||
SpacecraftSnapshotRepository.kt
|
||
SpacecraftVisibilitySnapshotRepository.kt
|
||
VisibilityRefreshJobRepository.kt
|
||
PlanRecalculationJobRepository.kt
|
||
|
||
.service/
|
||
BpmnPlanIssueProcessStarter.kt
|
||
HandlePlanDecisionUseCase.kt
|
||
IssueDuePlanWorker.kt
|
||
PlanCalculationService.kt
|
||
PlanQueryService.kt
|
||
PlanRecalculationWorker.kt
|
||
RebuildPlanScheduleUseCase.kt
|
||
RefreshVisibilityWindowsUseCase.kt
|
||
SpacecraftVisibilitySnapshotService.kt
|
||
SyncSpacecraftFromNsiUseCase.kt
|
||
NsiSyncScheduler.kt
|
||
VisibilityRefreshWorker.kt
|
||
```
|
||
|
||
Если существующие классы проще переиспользовать с переименованием — можно. Но production-flow не должен сохранять старую semantic responsibility.
|
||
|
||
---
|
||
|
||
## 6. Этапы и gates
|
||
|
||
## Этап 1. Подготовить доменную модель
|
||
|
||
### Цель этапа
|
||
|
||
Отделить расчётный план от persisted plan и попытки выдачи.
|
||
|
||
### Вход этапа
|
||
|
||
- `Plan.kt` с random `UUID`;
|
||
- `TrackedPlanEntity`;
|
||
- `TrackedPlanStatus`;
|
||
- `PlanCalculationService`, который возвращает `List<Plan>`.
|
||
|
||
### Шаг 1.1. Добавить `CalculatedPlan`
|
||
|
||
**Реализация:**
|
||
|
||
Создать domain model:
|
||
|
||
```kotlin
|
||
data class CalculatedPlan(
|
||
val spacecraftId: String,
|
||
val startTime: LocalDateTime,
|
||
val endTime: LocalDateTime,
|
||
val kppId: String
|
||
)
|
||
```
|
||
|
||
**Вход шага:** текущий `Plan`.
|
||
|
||
**Выход шага:** новая модель без `planId`.
|
||
|
||
**Существующие классы:** `Plan`.
|
||
|
||
**Новая модель:** `CalculatedPlan`.
|
||
|
||
**Слой:** domain.
|
||
|
||
**Строгое правило:** не использовать `UUID.randomUUID()` в расчётном алгоритме.
|
||
|
||
### Шаг 1.2. Добавить enum'ы lifecycle
|
||
|
||
Добавить:
|
||
|
||
```text
|
||
PlannedPlanStatus:
|
||
PLANNED
|
||
ISSUING
|
||
WAITING_DECISION
|
||
ACCEPTED
|
||
REJECTED
|
||
SUPERSEDED
|
||
EXPIRED
|
||
START_AMBIGUOUS
|
||
|
||
PlanIssueAttemptStatus:
|
||
STARTING
|
||
STARTED
|
||
ACCEPTED
|
||
REJECTED
|
||
FAILED
|
||
AMBIGUOUS
|
||
|
||
PlanDecision:
|
||
ACCEPTED
|
||
REJECTED
|
||
|
||
JobStatus:
|
||
PENDING
|
||
RUNNING
|
||
DONE
|
||
FAILED
|
||
```
|
||
|
||
**Вход шага:** `TrackedPlanStatus`.
|
||
|
||
**Выход шага:** новые enum classes.
|
||
|
||
**Существующие классы:** `TrackedPlanStatus`.
|
||
|
||
**Новая модель:** да.
|
||
|
||
**Слой:** domain/entity.
|
||
|
||
**Строгое правило:** новые production-flow классы должны использовать новые статусы, а не `TrackedPlanStatus`.
|
||
|
||
### Шаг 1.3. Перевести `PlanCalculationService` на `CalculatedPlan`
|
||
|
||
**Реализация:**
|
||
|
||
- `calculatePlans(...)` возвращает `List<CalculatedPlan>`;
|
||
- `calculatePlansAfter(...)` принимает previous plan как business fields. Можно временно принимать `CalculatedPlan` или отдельный lightweight type;
|
||
- `calculateNearestPlanFrom(...)`, если сохраняется для test-only functionality, возвращает `CalculatedPlan?`;
|
||
- private `StartCandidate.toPlan(...)` переименовать в `toCalculatedPlan(...)`.
|
||
|
||
**Вход шага:** `SpacecraftPoints`.
|
||
|
||
**Выход шага:** `List<CalculatedPlan>`.
|
||
|
||
**Существующие классы:** `PlanCalculationService`, `Plan`.
|
||
|
||
**Новая модель:** `CalculatedPlan`.
|
||
|
||
**Слой:** algorithm/domain.
|
||
|
||
**Строгое правило:** алгоритм выбора окон не менять, если изменение не требуется для компиляции.
|
||
|
||
### Gate 1
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- `CalculatedPlan` добавлен;
|
||
- enum'ы добавлены;
|
||
- `PlanCalculationService` больше не создаёт random `planId`;
|
||
- тесты алгоритма обновлены или временно помечены к обновлению в этапе тестов;
|
||
- production-flow ещё может не компилироваться полностью, но направление изменений должно быть очевидным и локальным.
|
||
|
||
---
|
||
|
||
## Этап 2. Добавить persistent schema
|
||
|
||
### Цель этапа
|
||
|
||
Создать новую persisted модель без destructive migration старых таблиц.
|
||
|
||
### Вход этапа
|
||
|
||
- существующие таблицы `tracked_plan`, `spacecraft_planning_state`;
|
||
- отсутствие persisted окон видимости, планов, attempts и jobs.
|
||
|
||
### Шаг 2.1. Добавить Flyway migration
|
||
|
||
Создать:
|
||
|
||
```text
|
||
services/pcp-tgu-service/src/main/resources/db/migration/V4__redesign_tgu_planning_state.sql
|
||
```
|
||
|
||
Migration должна:
|
||
|
||
1. не удалять старые таблицы;
|
||
2. создать новые таблицы;
|
||
3. расширить `spacecraft_planning_state`.
|
||
|
||
Минимальная схема:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS spacecraft_snapshot (
|
||
spacecraft_id VARCHAR(255) PRIMARY KEY,
|
||
norad_id BIGINT NOT NULL,
|
||
nsi_id VARCHAR(255),
|
||
business_key VARCHAR(255),
|
||
name VARCHAR(1024),
|
||
status VARCHAR(255),
|
||
mission VARCHAR(1024),
|
||
valid_from TIMESTAMP,
|
||
valid_to TIMESTAMP,
|
||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
created_at TIMESTAMP NOT NULL,
|
||
updated_at TIMESTAMP NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS spacecraft_visibility_snapshot (
|
||
spacecraft_id VARCHAR(255) PRIMARY KEY,
|
||
norad_id BIGINT NOT NULL,
|
||
payload_json TEXT NOT NULL,
|
||
payload_hash VARCHAR(128) NOT NULL,
|
||
fetched_at TIMESTAMP NOT NULL,
|
||
visibility_from TIMESTAMP,
|
||
visibility_to TIMESTAMP,
|
||
windows_count INTEGER,
|
||
created_at TIMESTAMP NOT NULL,
|
||
updated_at TIMESTAMP NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS planned_plan (
|
||
plan_id UUID PRIMARY KEY,
|
||
spacecraft_id VARCHAR(255) NOT NULL,
|
||
start_time TIMESTAMP NOT NULL,
|
||
end_time TIMESTAMP NOT NULL,
|
||
kpp_id VARCHAR(255) NOT NULL,
|
||
status VARCHAR(64) NOT NULL,
|
||
issue_not_before TIMESTAMP,
|
||
base_plan_id UUID,
|
||
chain_version BIGINT NOT NULL DEFAULT 0,
|
||
accepted_at TIMESTAMP,
|
||
superseded_by_plan_id UUID,
|
||
created_at TIMESTAMP NOT NULL,
|
||
updated_at TIMESTAMP NOT NULL,
|
||
CONSTRAINT uq_planned_plan_business_key UNIQUE (spacecraft_id, start_time, end_time, kpp_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_planned_plan_due
|
||
ON planned_plan (status, issue_not_before, start_time);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_planned_plan_spacecraft_start
|
||
ON planned_plan (spacecraft_id, start_time);
|
||
|
||
CREATE TABLE IF NOT EXISTS plan_issue_attempt (
|
||
attempt_id UUID PRIMARY KEY,
|
||
plan_id UUID NOT NULL REFERENCES planned_plan(plan_id),
|
||
attempt_no INTEGER NOT NULL,
|
||
status VARCHAR(64) NOT NULL,
|
||
process_instance_key BIGINT,
|
||
decision_event_id UUID,
|
||
decision_reason TEXT,
|
||
started_at TIMESTAMP,
|
||
finished_at TIMESTAMP,
|
||
created_at TIMESTAMP NOT NULL,
|
||
updated_at TIMESTAMP NOT NULL,
|
||
CONSTRAINT uq_plan_issue_attempt_no UNIQUE (plan_id, attempt_no)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_plan_issue_attempt_plan
|
||
ON plan_issue_attempt (plan_id, attempt_no);
|
||
|
||
CREATE TABLE IF NOT EXISTS visibility_refresh_job (
|
||
spacecraft_id VARCHAR(255) PRIMARY KEY,
|
||
norad_id BIGINT,
|
||
status VARCHAR(64) NOT NULL,
|
||
reason VARCHAR(255),
|
||
locked_by VARCHAR(255),
|
||
locked_until TIMESTAMP,
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
requested_at TIMESTAMP NOT NULL,
|
||
updated_at TIMESTAMP NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS plan_recalculation_job (
|
||
spacecraft_id VARCHAR(255) PRIMARY KEY,
|
||
status VARCHAR(64) NOT NULL,
|
||
reason VARCHAR(255),
|
||
locked_by VARCHAR(255),
|
||
locked_until TIMESTAMP,
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
requested_at TIMESTAMP NOT NULL,
|
||
updated_at TIMESTAMP NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS processed_plan_decision_event (
|
||
event_id UUID PRIMARY KEY,
|
||
received_at TIMESTAMP NOT NULL
|
||
);
|
||
|
||
ALTER TABLE spacecraft_planning_state
|
||
ADD COLUMN IF NOT EXISTS active_plan_id UUID;
|
||
|
||
ALTER TABLE spacecraft_planning_state
|
||
ADD COLUMN IF NOT EXISTS active_attempt_id UUID;
|
||
|
||
ALTER TABLE spacecraft_planning_state
|
||
ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 0;
|
||
```
|
||
|
||
**Вход шага:** migrations V1-V3.
|
||
|
||
**Выход шага:** V4 migration.
|
||
|
||
**Существующие классы:** none.
|
||
|
||
**Новая модель:** new persisted schema.
|
||
|
||
**Слой:** persistence.
|
||
|
||
**Строгое правило:** не удалять и не переименовывать старые таблицы в этом прогоне.
|
||
|
||
### Шаг 2.2. Добавить entities
|
||
|
||
Создать JPA entities:
|
||
|
||
- `SpacecraftSnapshotEntity`;
|
||
- `SpacecraftVisibilitySnapshotEntity`;
|
||
- `PlannedPlanEntity`;
|
||
- `PlanIssueAttemptEntity`;
|
||
- `VisibilityRefreshJobEntity`;
|
||
- `PlanRecalculationJobEntity`;
|
||
- `ProcessedPlanDecisionEventEntity`.
|
||
|
||
Обновить `SpacecraftPlanningStateEntity`:
|
||
|
||
- `activePlanId: UUID?`;
|
||
- `activeAttemptId: UUID?`;
|
||
- `chainVersion: Long`.
|
||
|
||
**Вход шага:** migration.
|
||
|
||
**Выход шага:** JPA entities.
|
||
|
||
**Существующие классы:** `SpacecraftPlanningStateEntity`.
|
||
|
||
**Новая модель:** listed entities.
|
||
|
||
**Слой:** persistence.
|
||
|
||
**Строгое правило:** entity classes и enum classes должны иметь комментарии, потому что это повышает читаемость модели.
|
||
|
||
### Шаг 2.3. Добавить repositories
|
||
|
||
Создать repositories:
|
||
|
||
- `SpacecraftSnapshotRepository`;
|
||
- `SpacecraftVisibilitySnapshotRepository`;
|
||
- `PlannedPlanRepository`;
|
||
- `PlanIssueAttemptRepository`;
|
||
- `VisibilityRefreshJobRepository`;
|
||
- `PlanRecalculationJobRepository`;
|
||
- `ProcessedPlanDecisionEventRepository`.
|
||
|
||
Добавить native methods для claim/lock.
|
||
|
||
Для jobs использовать паттерн:
|
||
|
||
```sql
|
||
SELECT *
|
||
FROM visibility_refresh_job
|
||
WHERE status = 'PENDING'
|
||
AND (locked_until IS NULL OR locked_until < now())
|
||
ORDER BY requested_at
|
||
LIMIT :limit
|
||
FOR UPDATE SKIP LOCKED
|
||
```
|
||
|
||
Для due plans использовать паттерн:
|
||
|
||
```sql
|
||
SELECT *
|
||
FROM planned_plan
|
||
WHERE status = 'PLANNED'
|
||
AND (issue_not_before IS NULL OR issue_not_before <= :now)
|
||
AND start_time <= :issueBefore
|
||
AND start_time > :now
|
||
ORDER BY start_time
|
||
LIMIT :limit
|
||
FOR UPDATE SKIP LOCKED
|
||
```
|
||
|
||
Для state lock добавить method в `SpacecraftPlanningStateJpaRepository`:
|
||
|
||
```sql
|
||
SELECT *
|
||
FROM spacecraft_planning_state
|
||
WHERE spacecraft_id = :spacecraftId
|
||
FOR UPDATE
|
||
```
|
||
|
||
Если state отсутствует, use case должен создать его перед lock-sensitive operations либо использовать create-and-lock pattern.
|
||
|
||
**Вход шага:** entities.
|
||
|
||
**Выход шага:** repositories.
|
||
|
||
**Существующие классы:** `SpacecraftPlanningStateJpaRepository`.
|
||
|
||
**Новая модель:** repository methods.
|
||
|
||
**Слой:** persistence.
|
||
|
||
**Строгое правило:** workers не должны делать select без claim, если результат запускает side effect или меняет state.
|
||
|
||
### Gate 2
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- migration добавлена;
|
||
- entities соответствуют migration;
|
||
- repositories добавлены;
|
||
- есть claim methods для jobs и due plans;
|
||
- `SpacecraftPlanningStateEntity` расширена;
|
||
- проект может не компилироваться из-за ещё не переписанных services, но persistence-модель должна быть цельной.
|
||
|
||
---
|
||
|
||
## Этап 3. Переписать получение и хранение окон видимости
|
||
|
||
### Цель этапа
|
||
|
||
Сохранять полный raw payload баллистики и строить domain model из raw payload только для расчёта.
|
||
|
||
### Вход этапа
|
||
|
||
- `ExternalPointsClient.fetchSpacecraftPoints(...)` возвращает `SpacecraftPoints`;
|
||
- `SpacecraftPointsRepository` хранит points in-memory;
|
||
- `SpacecraftPointsService.saveAndRecalculate(...)` сразу пересчитывает планы.
|
||
|
||
### Шаг 3.1. Изменить `ExternalPointsClient`
|
||
|
||
Добавить метод:
|
||
|
||
```kotlin
|
||
fun fetchRawVisibilityPayload(
|
||
satelliteId: Long,
|
||
timeStart: LocalDateTime,
|
||
timeStop: LocalDateTime
|
||
): String
|
||
```
|
||
|
||
Требования:
|
||
|
||
- использовать тот же endpoint `/api/satellites/{satelliteId}/rva`;
|
||
- сохранить полный body как строку;
|
||
- не терять неизвестные поля;
|
||
- существующий DTO parsing перенести из client в parser.
|
||
|
||
**Вход шага:** текущий `ExternalPointsClient`.
|
||
|
||
**Выход шага:** client умеет получать raw payload.
|
||
|
||
**Существующие классы:** `ExternalPointsClient`, `ExternalRvaDto`.
|
||
|
||
**Новая модель:** none.
|
||
|
||
**Слой:** adapter.
|
||
|
||
**Строгое правило:** external client не должен сохранять payload в БД и не должен запускать пересчёт.
|
||
|
||
### Шаг 3.2. Добавить `VisibilityPayloadParser`
|
||
|
||
Создать parser:
|
||
|
||
```kotlin
|
||
@Component
|
||
class VisibilityPayloadParser(
|
||
private val objectMapper: ObjectMapper
|
||
) {
|
||
fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints
|
||
}
|
||
```
|
||
|
||
Логика парсинга должна повторить текущий `mapToDomain(...)`:
|
||
|
||
- сгруппировать RVA records по `stationId`;
|
||
- создать `InsertionPoint`;
|
||
- создать `ObservationWindow`;
|
||
- сортировать окна по `startTime`;
|
||
- сортировать insertion points по `pointId`.
|
||
|
||
**Вход шага:** raw JSON.
|
||
|
||
**Выход шага:** `SpacecraftPoints`.
|
||
|
||
**Существующие классы:** `ExternalRvaItemDto`, `SpacecraftPoints`, `InsertionPoint`, `ObservationWindow`.
|
||
|
||
**Новая модель:** `VisibilityPayloadParser`.
|
||
|
||
**Слой:** adapter/domain boundary.
|
||
|
||
**Строгое правило:** parser не должен обращаться в БД.
|
||
|
||
### Шаг 3.3. Добавить `SpacecraftVisibilitySnapshotService`
|
||
|
||
Создать service:
|
||
|
||
```text
|
||
saveSnapshot(spacecraftId, noradId, payloadJson, fetchedAt)
|
||
```
|
||
|
||
Логика:
|
||
|
||
1. вычислить `payloadHash`, например SHA-256;
|
||
2. найти текущий snapshot по `spacecraftId`;
|
||
3. если snapshot отсутствует — сохранить и поставить recalculation job;
|
||
4. если hash изменился — обновить snapshot и поставить recalculation job;
|
||
5. если hash не изменился — обновить `fetchedAt`/`updatedAt`, но не ставить recalculation job.
|
||
|
||
Optional derived metadata:
|
||
|
||
- `visibilityFrom`;
|
||
- `visibilityTo`;
|
||
- `windowsCount`.
|
||
|
||
Эти поля можно заполнить через parser. Если это усложняет реализацию, можно оставить null/0, но raw payload обязателен.
|
||
|
||
**Вход шага:** raw payload.
|
||
|
||
**Выход шага:** persisted visibility snapshot и optional recalculation job.
|
||
|
||
**Существующие классы:** `SpacecraftPointsService`.
|
||
|
||
**Новая модель:** `SpacecraftVisibilitySnapshotEntity`.
|
||
|
||
**Слой:** orchestration/persistence.
|
||
|
||
**Строгое правило:** production-flow больше не должен писать в `SpacecraftPointsRepository`.
|
||
|
||
### Gate 3
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- raw payload можно получить у баллистики;
|
||
- raw payload можно сохранить полностью;
|
||
- parser строит `SpacecraftPoints` из raw payload;
|
||
- hash unchanged не создаёт лишний recalculation job;
|
||
- `SpacecraftPointsRepository` больше не нужен для нового production-flow.
|
||
|
||
---
|
||
|
||
## Этап 4. NSI sync и visibility refresh jobs
|
||
|
||
### Цель этапа
|
||
|
||
Сделать получение аппаратов и окон видимости job-based и безопасным для нескольких pod'ов.
|
||
|
||
### Вход этапа
|
||
|
||
- `PlatformService.loadAllowedPlatforms()`;
|
||
- `ExternalPointsUpdateScheduler`, который при старте/по расписанию сразу ходит в баллистику и пересчитывает планы.
|
||
|
||
### Шаг 4.1. Добавить `SyncSpacecraftFromNsiUseCase`
|
||
|
||
Логика:
|
||
|
||
1. вызвать `PlatformService.loadAllowedPlatforms()`;
|
||
2. сохранить/обновить `spacecraft_snapshot`;
|
||
3. active=true для найденных разрешённых платформ;
|
||
4. active=false для ранее известных, но больше не разрешённых/не найденных;
|
||
5. для active spacecraft поставить/обновить `visibility_refresh_job`.
|
||
|
||
`spacecraftId` на этом этапе использовать совместимо с текущей логикой: `noradId.toString()`.
|
||
|
||
**Вход шага:** список платформ из НСИ.
|
||
|
||
**Выход шага:** persisted spacecraft snapshot и refresh jobs.
|
||
|
||
**Существующие классы:** `PlatformService`, `PlatformCatalogItem`.
|
||
|
||
**Новая модель:** `SpacecraftSnapshotEntity`, `VisibilityRefreshJobEntity`.
|
||
|
||
**Слой:** orchestration/persistence.
|
||
|
||
**Строгое правило:** use case не должен ходить в баллистику и не должен считать планы.
|
||
|
||
### Шаг 4.2. Добавить `NsiSyncScheduler`
|
||
|
||
Scheduler вызывает `SyncSpacecraftFromNsiUseCase` по расписанию.
|
||
|
||
Можно переиспользовать существующий `planning.external-update-fixed-delay-ms`, чтобы не плодить лишние настройки.
|
||
|
||
Если `planning.external-update-enabled=false`, scheduler не должен выполнять sync.
|
||
|
||
**Вход шага:** настройки planning.
|
||
|
||
**Выход шага:** scheduled sync.
|
||
|
||
**Существующие классы:** `ExternalPointsUpdateScheduler`.
|
||
|
||
**Новая модель:** `NsiSyncScheduler`.
|
||
|
||
**Слой:** orchestration.
|
||
|
||
**Строгое правило:** если scheduler работает на нескольких pod, это допустимо: jobs должны coalesce'иться по `spacecraft_id`.
|
||
|
||
### Шаг 4.3. Добавить `VisibilityRefreshWorker`
|
||
|
||
Worker должен:
|
||
|
||
1. claim'ить pending refresh jobs через `FOR UPDATE SKIP LOCKED` или lease;
|
||
2. для каждого job определить `noradId`:
|
||
- из job, если есть;
|
||
- иначе из `spacecraft_snapshot`;
|
||
3. рассчитать интервал:
|
||
- `timeStart = now.truncatedTo(MINUTES)`;
|
||
- `timeStop = timeStart + externalPointsLookaheadDays`;
|
||
4. вызвать `ExternalPointsClient.fetchRawVisibilityPayload(...)`;
|
||
5. сохранить snapshot через `SpacecraftVisibilitySnapshotService`;
|
||
6. отметить job done или вернуть в pending/failed по понятной retry-логике.
|
||
|
||
Минимальная retry-логика:
|
||
|
||
- увеличить `attempts`;
|
||
- при временной ошибке вернуть в `PENDING` с `locked_until = null` или оставить `FAILED` после разумного порога;
|
||
- не блокировать остальные КА.
|
||
|
||
**Вход шага:** `visibility_refresh_job`.
|
||
|
||
**Выход шага:** `spacecraft_visibility_snapshot`, optionally `plan_recalculation_job`.
|
||
|
||
**Существующие классы:** `ExternalPointsUpdateScheduler`, `ExternalPointsClient`.
|
||
|
||
**Новая модель:** `VisibilityRefreshWorker`.
|
||
|
||
**Слой:** orchestration/adapter.
|
||
|
||
**Строгое правило:** worker может запускаться на каждом pod; один job должен обрабатываться одним pod.
|
||
|
||
### Gate 4
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- список аппаратов сохраняется в БД;
|
||
- refresh jobs coalesce'ятся по `spacecraft_id`;
|
||
- worker получает raw payload и сохраняет snapshot;
|
||
- получение окон больше не запускает немедленный in-memory recalculation.
|
||
|
||
---
|
||
|
||
## Этап 5. Kafka visibility event
|
||
|
||
### Цель этапа
|
||
|
||
Оставить Kafka для события изменения окон видимости как сигнал к refresh, а не как источник in-memory state.
|
||
|
||
### Вход этапа
|
||
|
||
- `SpacecraftPointsKafkaConsumer` слушает `spacecraftPointsUpdate` и вызывает `SpacecraftPointsService.saveFromMessage(...)`.
|
||
|
||
### Шаг 5.1. Обновить `TopicsProperties`
|
||
|
||
Заменить:
|
||
|
||
```kotlin
|
||
val spacecraftPointsUpdate: String
|
||
```
|
||
|
||
на:
|
||
|
||
```kotlin
|
||
val visibilityWindowsChanged: String
|
||
val satellitePlanDecision: String
|
||
```
|
||
|
||
**Вход шага:** текущий `TopicsProperties`.
|
||
|
||
**Выход шага:** новые topic properties.
|
||
|
||
**Существующие классы:** `TopicsProperties`.
|
||
|
||
**Новая модель:** none.
|
||
|
||
**Слой:** config.
|
||
|
||
### Шаг 5.2. Добавить `VisibilityWindowsChangedMessage`
|
||
|
||
DTO:
|
||
|
||
```kotlin
|
||
data class VisibilityWindowsChangedMessage(
|
||
val eventId: UUID?,
|
||
val spacecraftId: String,
|
||
val noradId: Long?,
|
||
val changedAt: LocalDateTime?
|
||
)
|
||
```
|
||
|
||
**Вход шага:** old `SpacecraftPointsMessage`.
|
||
|
||
**Выход шага:** new lightweight event.
|
||
|
||
**Существующие классы:** `SpacecraftPointsMessage`.
|
||
|
||
**Новая модель:** `VisibilityWindowsChangedMessage`.
|
||
|
||
**Слой:** dto.
|
||
|
||
### Шаг 5.3. Заменить consumer
|
||
|
||
Создать `VisibilityWindowsChangedConsumer`:
|
||
|
||
```text
|
||
@KafkaListener(topics = topics.visibilityWindowsChanged, groupId = ...)
|
||
-> enqueue visibility_refresh_job(spacecraftId, noradId, reason='KAFKA_VISIBILITY_CHANGED')
|
||
```
|
||
|
||
Удалить или вывести из production-flow `SpacecraftPointsKafkaConsumer`.
|
||
|
||
**Вход шага:** Kafka event.
|
||
|
||
**Выход шага:** refresh job.
|
||
|
||
**Существующие классы:** `SpacecraftPointsKafkaConsumer`, `SpacecraftPointsService`.
|
||
|
||
**Новая модель:** `VisibilityWindowsChangedConsumer`.
|
||
|
||
**Слой:** adapter/orchestration.
|
||
|
||
**Строгое правило:** consumer не должен парсить окна, получать окна у баллистики, считать планы или стартовать BPMN.
|
||
|
||
### Gate 5
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- old `spacecraftPointsUpdate` больше не используется в production consumer;
|
||
- visibility event only enqueues refresh job;
|
||
- topic config содержит два новых topic name.
|
||
|
||
---
|
||
|
||
## Этап 6. Persistent расчёт расписания
|
||
|
||
### Цель этапа
|
||
|
||
Строить расписание планов из persisted visibility snapshot и сохранять его в БД.
|
||
|
||
### Вход этапа
|
||
|
||
- latest `spacecraft_visibility_snapshot`;
|
||
- `PlanCalculationService` уже возвращает `CalculatedPlan`;
|
||
- `spacecraft_planning_state.current_base_plan_id`.
|
||
|
||
### Шаг 6.1. Добавить enqueue для recalculation job
|
||
|
||
В `PlanRecalculationJobRepository`/service добавить метод:
|
||
|
||
```text
|
||
enqueue(spacecraftId, reason)
|
||
```
|
||
|
||
Требования:
|
||
|
||
- coalesce по `spacecraft_id`;
|
||
- если job уже `PENDING` или `RUNNING`, обновить `requested_at`/`reason`, но не создавать дубль;
|
||
- если job `DONE`/`FAILED`, вернуть в `PENDING`.
|
||
|
||
**Вход шага:** spacecraftId.
|
||
|
||
**Выход шага:** pending recalculation job.
|
||
|
||
**Существующие классы:** none.
|
||
|
||
**Новая модель:** `PlanRecalculationJobEntity`.
|
||
|
||
**Слой:** persistence/orchestration.
|
||
|
||
### Шаг 6.2. Добавить `RebuildPlanScheduleUseCase`
|
||
|
||
Логика:
|
||
|
||
1. получить latest visibility snapshot;
|
||
2. распарсить `payloadJson` через `VisibilityPayloadParser`;
|
||
3. прочитать `spacecraft_planning_state`;
|
||
4. если `currentBasePlanId` есть:
|
||
- загрузить persisted accepted/base plan;
|
||
- рассчитать хвост после base plan;
|
||
5. если base plan отсутствует:
|
||
- рассчитать полную цепочку;
|
||
6. upsert each `CalculatedPlan` into `planned_plan` by business key;
|
||
7. для новых планов выставить:
|
||
- `status = PLANNED`;
|
||
- `issueNotBefore = null` или `startTime - notificationBeforeStartMinutes`;
|
||
- `basePlanId = currentBasePlanId`;
|
||
- `chainVersion = state.chainVersion + 1`;
|
||
8. obsolete future `PLANNED` plans, отсутствующие в новом расчёте, пометить `SUPERSEDED`;
|
||
9. не трогать планы в статусах:
|
||
- `ISSUING`;
|
||
- `WAITING_DECISION`;
|
||
- `ACCEPTED`;
|
||
- `START_AMBIGUOUS`.
|
||
|
||
**Вход шага:** spacecraftId.
|
||
|
||
**Выход шага:** persisted plan schedule.
|
||
|
||
**Существующие классы:** `SpacecraftPointsService`, `PlanCalculationService`, `SpacecraftPlanningStateService`.
|
||
|
||
**Новая модель:** `PlannedPlanEntity`, `PlanRecalculationJobEntity`.
|
||
|
||
**Слой:** orchestration/domain/persistence.
|
||
|
||
**Строгое правило:** upsert должен сохранять стабильный `planId` для одного business key.
|
||
|
||
### Шаг 6.3. Добавить `PlanRecalculationWorker`
|
||
|
||
Worker:
|
||
|
||
1. claim'ит recalculation jobs;
|
||
2. вызывает `RebuildPlanScheduleUseCase`;
|
||
3. завершает job;
|
||
4. при ошибке увеличивает attempts и не блокирует остальные jobs.
|
||
|
||
**Вход шага:** `plan_recalculation_job`.
|
||
|
||
**Выход шага:** rebuilt persisted schedule.
|
||
|
||
**Существующие классы:** `SpacecraftPointsService`.
|
||
|
||
**Новая модель:** `PlanRecalculationWorker`.
|
||
|
||
**Слой:** orchestration.
|
||
|
||
### Gate 6
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- расписание планов сохраняется в `planned_plan`;
|
||
- repeated rebuild не создаёт дубли для того же business key;
|
||
- old in-memory `PlanRepository` не участвует в production rebuild;
|
||
- obsolete future plans получают `SUPERSEDED`, но active/accepted планы не ломаются.
|
||
|
||
---
|
||
|
||
## Этап 7. Выдача планов через BPMN
|
||
|
||
### Цель этапа
|
||
|
||
Выдавать due plans из БД через attempts и безопасный claim.
|
||
|
||
### Вход этапа
|
||
|
||
- persisted `planned_plan`;
|
||
- `PlanProcessScheduler`, который сканирует in-memory `PlanRepository`;
|
||
- `SatelliteMissionProcessService`, который смешивает manual start, расчёт и Camunda call.
|
||
|
||
### Шаг 7.1. Добавить `BpmnPlanIssueProcessStarter`
|
||
|
||
Новый adapter service:
|
||
|
||
```text
|
||
startProcess(plan, attempt): Long processInstanceKey
|
||
```
|
||
|
||
Он должен:
|
||
|
||
- получить `PlannedPlanEntity` и `PlanIssueAttemptEntity`;
|
||
- заполнить `CreateSatelliteMissionVariables`;
|
||
- вызвать Zeebe/Camunda create instance;
|
||
- вернуть `processInstanceKey`.
|
||
|
||
BPMN variables:
|
||
|
||
```text
|
||
spacecraftId
|
||
satellitePlanId
|
||
satellitePlanAttemptId
|
||
planStartTime
|
||
planEndTime
|
||
kppId
|
||
```
|
||
|
||
**Вход шага:** plan + attempt.
|
||
|
||
**Выход шага:** processInstanceKey.
|
||
|
||
**Существующие классы:** `SatelliteMissionProcessService`, `CreateSatelliteMissionVariables`.
|
||
|
||
**Новая модель:** `BpmnPlanIssueProcessStarter`.
|
||
|
||
**Слой:** external adapter.
|
||
|
||
**Строгое правило:** starter не должен искать nearest plan, читать visibility snapshot, создавать plan, принимать решения о retry.
|
||
|
||
### Шаг 7.2. Добавить `IssueDuePlanWorker`
|
||
|
||
Worker должен:
|
||
|
||
1. найти due `PLANNED` plans через repository claim;
|
||
2. для каждого plan в отдельной transaction:
|
||
- lock/create `spacecraft_planning_state`;
|
||
- если `activeAttemptId != null`, пропустить plan;
|
||
- определить next `attemptNo` для `planId`;
|
||
- создать `PlanIssueAttemptEntity(status=STARTING)`;
|
||
- поставить `plan.status = ISSUING`;
|
||
- заполнить `state.activePlanId` и `state.activeAttemptId`;
|
||
- commit;
|
||
3. после commit вызвать `BpmnPlanIssueProcessStarter.startProcess(...)` вне DB transaction;
|
||
4. после успешного call в новой transaction:
|
||
- сохранить `processInstanceKey`;
|
||
- `attempt.status = STARTED`;
|
||
- `attempt.startedAt = now`;
|
||
- `plan.status = WAITING_DECISION`;
|
||
5. если Camunda call бросил exception:
|
||
- `attempt.status = AMBIGUOUS`;
|
||
- `plan.status = START_AMBIGUOUS`;
|
||
- active ids оставить или перевести в состояние, явно блокирующее автоматический повтор;
|
||
- не делать автоматический retry.
|
||
|
||
Due condition:
|
||
|
||
```text
|
||
status = PLANNED
|
||
AND (issueNotBefore IS NULL OR issueNotBefore <= now)
|
||
AND startTime <= now + notificationBeforeStartMinutes
|
||
AND startTime > now
|
||
```
|
||
|
||
**Вход шага:** due persisted plan.
|
||
|
||
**Выход шага:** attempt + BPMN process.
|
||
|
||
**Существующие классы:** `PlanProcessScheduler`, `SatelliteMissionProcessService`.
|
||
|
||
**Новая модель:** `IssueDuePlanWorker`, `PlanIssueAttemptEntity`.
|
||
|
||
**Слой:** orchestration.
|
||
|
||
**Строгое правило:** DB transaction не должна держаться во время Zeebe/Camunda call.
|
||
|
||
### Шаг 7.3. Убрать production usage старого scheduler/process service
|
||
|
||
- `PlanProcessScheduler` не должен быть production scheduler;
|
||
- `SatelliteMissionProcessService` не должен быть production use case для ручного запуска;
|
||
- если классы оставлены временно, они должны использоваться только из test-only controller или быть удалены.
|
||
|
||
**Вход шага:** old flow.
|
||
|
||
**Выход шага:** issue flow only through `IssueDuePlanWorker`.
|
||
|
||
**Существующие классы:** `PlanProcessScheduler`, `SatelliteMissionProcessService`.
|
||
|
||
**Новая модель:** none.
|
||
|
||
**Слой:** cleanup.
|
||
|
||
### Gate 7
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- due plans читаются из БД;
|
||
- по одному КА одновременно не создаётся несколько active attempts;
|
||
- повторная выдача same plan создаёт new attemptNo/attemptId;
|
||
- Camunda start выполняется вне DB transaction;
|
||
- ambiguous start не ретраится автоматически;
|
||
- `PlanProcessScheduler` не участвует в production-flow.
|
||
|
||
---
|
||
|
||
## Этап 8. BPMN вариант A
|
||
|
||
### Цель этапа
|
||
|
||
Сделать BPMN одной попыткой выдачи, без ожидания принятия.
|
||
|
||
### Вход этапа
|
||
|
||
Текущий `createSatelliteMission.bpmn` содержит:
|
||
|
||
- расчёт survey missions;
|
||
- расчёт drop missions;
|
||
- отправку satellite plan;
|
||
- ожидание `Message_SatellitePlanAccepted`;
|
||
- `updatePlanStatus`;
|
||
- `updateSlotsStatus`.
|
||
|
||
### Шаг 8.1. Упростить BPMN
|
||
|
||
Целевой flow:
|
||
|
||
```text
|
||
start
|
||
-> calculateSatelliteSurveyMissions
|
||
-> calculateSatelliteDropMissions
|
||
-> sendSatellitePlan
|
||
-> end
|
||
```
|
||
|
||
Удалить из production BPMN:
|
||
|
||
- message catch event ожидания accepted;
|
||
- `updatePlanStatus` service task;
|
||
- `updateSlotsStatus`, если он был строго после accepted.
|
||
|
||
Если `updateSlotsStatus` критичен, не пытаться встроить его обратно без понимания контракта. Оставить как follow-up в финальном отчёте.
|
||
|
||
**Вход шага:** BPMN file.
|
||
|
||
**Выход шага:** short-lived BPMN per attempt.
|
||
|
||
**Существующие файлы:** `createSatelliteMission.bpmn`.
|
||
|
||
**Слой:** BPMN/orchestration.
|
||
|
||
### Шаг 8.2. Обновить process variables
|
||
|
||
`CreateSatelliteMissionVariables` должен включать:
|
||
|
||
```text
|
||
spacecraftId
|
||
satellitePlanId
|
||
satellitePlanAttemptId
|
||
planStartTime
|
||
planEndTime
|
||
kppId
|
||
```
|
||
|
||
**Вход шага:** old variables.
|
||
|
||
**Выход шага:** attempt-aware variables.
|
||
|
||
**Существующие классы:** `CreateSatelliteMissionVariables`.
|
||
|
||
**Слой:** dto.
|
||
|
||
### Gate 8
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- BPMN больше не ждёт accepted/rejected;
|
||
- `PlanStatusWorker` больше не требуется production BPMN;
|
||
- process variables содержат `satellitePlanAttemptId`.
|
||
|
||
---
|
||
|
||
## Этап 9. Kafka decision flow
|
||
|
||
### Цель этапа
|
||
|
||
Обрабатывать принятие/непринятие попытки выдачи через отдельный Kafka topic.
|
||
|
||
### Вход этапа
|
||
|
||
- separate topic `satellitePlanDecision`;
|
||
- persisted plans/attempts;
|
||
- `spacecraft_planning_state` с active ids.
|
||
|
||
### Шаг 9.1. Добавить `PlanDecisionMessage`
|
||
|
||
DTO:
|
||
|
||
```kotlin
|
||
data class PlanDecisionMessage(
|
||
val eventId: UUID,
|
||
val spacecraftId: String,
|
||
val planId: UUID,
|
||
val attemptId: UUID,
|
||
val decision: PlanDecision,
|
||
val decisionTime: LocalDateTime,
|
||
val reason: String?
|
||
)
|
||
```
|
||
|
||
**Вход шага:** Kafka payload.
|
||
|
||
**Выход шага:** typed DTO.
|
||
|
||
**Существующие классы:** none or old confirmation DTO.
|
||
|
||
**Новая модель:** `PlanDecisionMessage`.
|
||
|
||
**Слой:** dto.
|
||
|
||
### Шаг 9.2. Добавить `PlanDecisionConsumer`
|
||
|
||
Consumer:
|
||
|
||
```text
|
||
@KafkaListener(topics = topics.satellitePlanDecision, groupId = ...)
|
||
-> HandlePlanDecisionUseCase.handle(message)
|
||
```
|
||
|
||
**Вход шага:** Kafka event.
|
||
|
||
**Выход шага:** delegated use case.
|
||
|
||
**Существующие классы:** `PlanStatusWorker`, `PlanConfirmationService`.
|
||
|
||
**Новая модель:** `PlanDecisionConsumer`.
|
||
|
||
**Слой:** adapter.
|
||
|
||
### Шаг 9.3. Добавить `HandlePlanDecisionUseCase`
|
||
|
||
Общая transaction логика:
|
||
|
||
1. insert `processed_plan_decision_event(eventId)`;
|
||
2. если duplicate key — вернуть success/idempotent no-op;
|
||
3. lock attempt by `attemptId`;
|
||
4. проверить, что attempt belongs to `planId`;
|
||
5. lock plan;
|
||
6. lock/create planning state;
|
||
7. применить decision.
|
||
|
||
#### ACCEPTED
|
||
|
||
```text
|
||
attempt.status = ACCEPTED
|
||
attempt.finishedAt = decisionTime
|
||
attempt.decisionEventId = eventId
|
||
plan.status = ACCEPTED
|
||
plan.acceptedAt = decisionTime
|
||
state.currentBasePlanId = planId
|
||
state.activePlanId = null
|
||
state.activeAttemptId = null
|
||
state.chainVersion = state.chainVersion + 1
|
||
enqueue recalculation_job(spacecraftId, reason='PLAN_ACCEPTED')
|
||
```
|
||
|
||
#### REJECTED before cutoff
|
||
|
||
Condition:
|
||
|
||
```text
|
||
now < plan.startTime - planning.minReissueLeadMinutes
|
||
```
|
||
|
||
Action:
|
||
|
||
```text
|
||
attempt.status = REJECTED
|
||
attempt.finishedAt = decisionTime
|
||
attempt.decisionEventId = eventId
|
||
attempt.decisionReason = reason
|
||
plan.status = PLANNED
|
||
plan.issueNotBefore = now
|
||
state.activePlanId = null
|
||
state.activeAttemptId = null
|
||
```
|
||
|
||
`IssueDuePlanWorker` позже создаст новую attempt for same plan.
|
||
|
||
#### REJECTED after cutoff
|
||
|
||
Condition:
|
||
|
||
```text
|
||
now >= plan.startTime - planning.minReissueLeadMinutes
|
||
```
|
||
|
||
Action:
|
||
|
||
```text
|
||
attempt.status = REJECTED
|
||
attempt.finishedAt = decisionTime
|
||
attempt.decisionEventId = eventId
|
||
attempt.decisionReason = reason
|
||
plan.status = EXPIRED
|
||
state.activePlanId = null
|
||
state.activeAttemptId = null
|
||
```
|
||
|
||
Then:
|
||
|
||
1. найти следующий `PLANNED` plan for same spacecraft with `startTime > now`, order by `startTime`;
|
||
2. если найден: `nextPlan.issueNotBefore = now`;
|
||
3. если не найден: enqueue recalculation job.
|
||
|
||
**Вход шага:** `PlanDecisionMessage`.
|
||
|
||
**Выход шага:** updated plan/attempt/planning state.
|
||
|
||
**Существующие классы:** `PlanConfirmationService`, `TrackedPlanService`, `SpacecraftPlanningStateService`.
|
||
|
||
**Новая модель:** `ProcessedPlanDecisionEventEntity`.
|
||
|
||
**Слой:** orchestration/domain.
|
||
|
||
**Строгое правило:** не запускать новую attempt прямо из decision use case. Use case только меняет state; запуск делает `IssueDuePlanWorker`.
|
||
|
||
### Gate 9
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- duplicate `eventId` idempotently ignored;
|
||
- accepted updates base plan;
|
||
- rejected before cutoff reopens same plan;
|
||
- rejected after cutoff expires current plan and promotes next;
|
||
- no decision means no automatic retry.
|
||
|
||
---
|
||
|
||
## Этап 10. Controllers
|
||
|
||
### Цель этапа
|
||
|
||
Оставить production API только read-only, ручные операции перенести в явно test-only controller.
|
||
|
||
### Вход этапа
|
||
|
||
- `SatelliteMissionController` содержит ручной запуск;
|
||
- `PlanConfirmationController` содержит ручное подтверждение;
|
||
- `ExternalPointsController` может использоваться как manual/debug.
|
||
|
||
### Шаг 10.1. Обновить production `PlanController` / `PlanQueryService`
|
||
|
||
Production endpoints:
|
||
|
||
```text
|
||
GET /api/plans
|
||
GET /api/plans/{spacecraftId}
|
||
GET /api/platforms
|
||
```
|
||
|
||
`PlanQueryService` должен читать:
|
||
|
||
- `planned_plan`;
|
||
- optionally latest attempt per plan;
|
||
- not `PlanRepository`.
|
||
|
||
Response можно оставить совместимым с `PlanResponse`, добавив минимум полей, если нужно:
|
||
|
||
- `planId`;
|
||
- `spacecraftId`;
|
||
- `startTime`;
|
||
- `endTime`;
|
||
- `kppId`;
|
||
- `status`.
|
||
|
||
**Вход шага:** persisted plans.
|
||
|
||
**Выход шага:** read-only query API.
|
||
|
||
**Существующие классы:** `PlanController`, `PlanQueryService`, `PlanResponse`.
|
||
|
||
**Новая модель:** maybe response fields updated.
|
||
|
||
**Слой:** controller/query.
|
||
|
||
### Шаг 10.2. Добавить `TestTguController`
|
||
|
||
Создать controller:
|
||
|
||
```kotlin
|
||
@ConditionalOnProperty(
|
||
prefix = "tgu.test-controller",
|
||
name = ["enabled"],
|
||
havingValue = "true"
|
||
)
|
||
@RestController
|
||
@RequestMapping("/test/tgu")
|
||
class TestTguController(...)
|
||
```
|
||
|
||
Default property must be false.
|
||
|
||
Перенести туда manual/debug операции:
|
||
|
||
```text
|
||
POST /test/tgu/spacecraft/{spacecraftId}/refresh-visibility
|
||
POST /test/tgu/spacecraft/{spacecraftId}/rebuild-plans
|
||
POST /test/tgu/plans/{planId}/issue
|
||
POST /test/tgu/plan-decisions
|
||
GET /test/tgu/external-points/{noradId}
|
||
```
|
||
|
||
Эти endpoints должны вызывать новые use cases, а не старый in-memory flow.
|
||
|
||
**Вход шага:** old manual controllers.
|
||
|
||
**Выход шага:** guarded test-only controller.
|
||
|
||
**Существующие классы:** `SatelliteMissionController`, `PlanConfirmationController`, `ExternalPointsController`.
|
||
|
||
**Новая модель:** `TestTguController`.
|
||
|
||
**Слой:** controller.
|
||
|
||
**Строгое правило:** production-flow не должен зависеть от test controller.
|
||
|
||
### Шаг 10.3. Убрать/отключить старые manual production endpoints
|
||
|
||
- `SatelliteMissionController` удалить или сделать inactive;
|
||
- `PlanConfirmationController.POST` удалить из production-flow или перенести в test controller;
|
||
- `ExternalPointsController` перенести в test controller, если он debug-only.
|
||
|
||
**Вход шага:** old controllers.
|
||
|
||
**Выход шага:** no production manual write endpoints.
|
||
|
||
**Слой:** cleanup/controller.
|
||
|
||
### Gate 10
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- production write/manual endpoints removed or guarded;
|
||
- test controller disabled by default;
|
||
- plan query reads DB only.
|
||
|
||
---
|
||
|
||
## Этап 11. Config
|
||
|
||
### Цель этапа
|
||
|
||
Добавить свойства для новых topic'ов, reissue cutoff, worker lock и test controller.
|
||
|
||
### Шаг 11.1. Обновить `PlanningProperties`
|
||
|
||
Добавить:
|
||
|
||
```kotlin
|
||
val minReissueLeadMinutes: Long
|
||
val workerLockTtlSeconds: Long
|
||
```
|
||
|
||
Сохранить существующие свойства, если их используют новые workers:
|
||
|
||
- `notificationBeforeStartMinutes`;
|
||
- `targetPlanDurationMinutes`;
|
||
- `targetPlanDurationDeltaMinutes`;
|
||
- `externalUpdateEnabled`;
|
||
- `externalPointsLookaheadDays`;
|
||
- `externalUpdateFixedDelayMs`;
|
||
- `notificationCheckFixedDelayMs`.
|
||
|
||
`inMemoryHistoryDays` можно оставить временно, но production query не должен от него зависеть.
|
||
|
||
### Шаг 11.2. Обновить config files
|
||
|
||
В `services/pcp-tgu-service/src/main/resources/application.yml` и при необходимости `config-repo/pcp-tgu-service.yaml` добавить:
|
||
|
||
```yaml
|
||
topics:
|
||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
|
||
|
||
planning:
|
||
min-reissue-lead-minutes: 5
|
||
worker-lock-ttl-seconds: 300
|
||
|
||
tgu:
|
||
test-controller:
|
||
enabled: false
|
||
```
|
||
|
||
Если в проекте используется kebab-case binding, имена должны соответствовать Kotlin properties.
|
||
|
||
### Gate 11
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- приложение стартует с новыми properties;
|
||
- старый `spacecraft-points-update` topic не нужен production-flow;
|
||
- test controller disabled by default.
|
||
|
||
---
|
||
|
||
## Этап 12. Cleanup старого production-flow
|
||
|
||
### Цель этапа
|
||
|
||
Удалить или изолировать старые single-instance элементы.
|
||
|
||
### Шаг 12.1. Убрать production usage in-memory repositories
|
||
|
||
`PlanRepository` и `SpacecraftPointsRepository` не должны участвовать в production-flow.
|
||
|
||
Допустимые варианты:
|
||
|
||
1. удалить классы;
|
||
2. оставить только для старых тестов, но production services не inject'ят их;
|
||
3. перенести в test package, если нужно.
|
||
|
||
Строго проверить, что новые production classes не зависят от них.
|
||
|
||
### Шаг 12.2. Убрать production usage `PlanStatusWorker` / `PlanConfirmationService`
|
||
|
||
Если BPMN больше не ждёт accepted, `PlanStatusWorker` не должен быть активным production worker.
|
||
|
||
Допустимые варианты:
|
||
|
||
1. удалить;
|
||
2. выключить;
|
||
3. оставить только для test/legacy, если без этого быстрее пройти build.
|
||
|
||
Но production accepted/rejected должен идти через `PlanDecisionConsumer`.
|
||
|
||
### Шаг 12.3. Убрать direct recalculation из старого `SpacecraftPointsService`
|
||
|
||
Если `SpacecraftPointsService` остаётся, он не должен быть центральным production use case.
|
||
|
||
Новая цепочка:
|
||
|
||
```text
|
||
visibility changed -> refresh job -> raw snapshot -> recalculation job -> rebuild schedule
|
||
```
|
||
|
||
### Gate 12
|
||
|
||
К следующему этапу можно переходить только если:
|
||
|
||
- old in-memory repositories are not source of truth;
|
||
- old plan status BPMN worker not used in production;
|
||
- old direct `saveAndRecalculate` path not used by production Kafka/scheduler.
|
||
|
||
---
|
||
|
||
## Этап 13. Тесты
|
||
|
||
### Цель этапа
|
||
|
||
Покрыть новую state model и защитить ключевые scale-out инварианты.
|
||
|
||
### Шаг 13.1. Обновить unit tests алгоритма
|
||
|
||
Покрыть:
|
||
|
||
- `PlanCalculationService` возвращает `CalculatedPlan`;
|
||
- `CalculatedPlan` не содержит random `planId`;
|
||
- алгоритм выбора окон не изменился относительно старых expectations.
|
||
|
||
### Шаг 13.2. Покрыть visibility flow
|
||
|
||
Тесты:
|
||
|
||
- visibility changed event only enqueues refresh job;
|
||
- refresh worker stores full raw payload;
|
||
- same payload hash does not enqueue recalculation;
|
||
- changed payload hash enqueues recalculation.
|
||
|
||
### Шаг 13.3. Покрыть rebuild schedule
|
||
|
||
Тесты:
|
||
|
||
- upsert by business key keeps stable `planId`;
|
||
- repeated rebuild does not duplicate plans;
|
||
- obsolete future `PLANNED` plans become `SUPERSEDED`;
|
||
- `WAITING_DECISION`, `ISSUING`, `ACCEPTED`, `START_AMBIGUOUS` are not superseded by normal rebuild.
|
||
|
||
### Шаг 13.4. Покрыть issue worker
|
||
|
||
Тесты:
|
||
|
||
- due plan creates attempt;
|
||
- second call with active attempt does not create another active attempt for same spacecraft;
|
||
- repeated issue of same plan creates new `attemptId` and increments `attemptNo`;
|
||
- Camunda exception sets `AMBIGUOUS`/`START_AMBIGUOUS` and no auto retry happens.
|
||
|
||
### Шаг 13.5. Покрыть decision flow
|
||
|
||
Тесты:
|
||
|
||
- duplicate `eventId` ignored;
|
||
- `ACCEPTED` sets attempt accepted, plan accepted, current base plan, clears active ids, enqueues recalculation;
|
||
- `REJECTED` before cutoff sets same plan `PLANNED` and `issueNotBefore = now`;
|
||
- `REJECTED` after cutoff sets current plan `EXPIRED` and promotes next `PLANNED` plan;
|
||
- `REJECTED` after cutoff with no next plan enqueues recalculation;
|
||
- decision with mismatched `planId`/`attemptId` fails safely and does not corrupt state.
|
||
|
||
### Шаг 13.6. Покрыть query/test controller basics
|
||
|
||
Тесты:
|
||
|
||
- `PlanQueryService` reads `planned_plan`, not `PlanRepository`;
|
||
- test controller is disabled by default when property absent/false;
|
||
- test controller is enabled only when `tgu.test-controller.enabled=true`.
|
||
|
||
### Gate 13
|
||
|
||
К финальной проверке можно переходить только если:
|
||
|
||
- старые тесты либо обновлены, либо удалены как неактуальные;
|
||
- новые тесты покрывают acceptance criteria;
|
||
- нет тестов, которые закрепляют старый in-memory production-flow.
|
||
|
||
---
|
||
|
||
## Этап 14. Проверки и финальный отчёт агента
|
||
|
||
### Проверки
|
||
|
||
Агент обязан выполнить из корня репозитория:
|
||
|
||
```bash
|
||
./gradlew :services:pcp-tgu-service:test
|
||
./gradlew :services:pcp-tgu-service:build
|
||
```
|
||
|
||
Если это проходит и время позволяет:
|
||
|
||
```bash
|
||
./gradlew build
|
||
```
|
||
|
||
Если какая-то проверка не проходит по причине, не связанной с `pcp-tgu-service`, агент должен явно указать это в финальном отчёте и привести error summary.
|
||
|
||
### Финальный отчёт агента должен содержать
|
||
|
||
1. Что изменено по этапам.
|
||
2. Список ключевых новых классов.
|
||
3. Список удалённых/изолированных старых production-flow классов.
|
||
4. Новые таблицы и migration name.
|
||
5. Как теперь работает flow:
|
||
- NSI sync;
|
||
- visibility refresh;
|
||
- schedule rebuild;
|
||
- issue due plan;
|
||
- accepted/rejected decision.
|
||
6. Какие проверки запущены и их результат.
|
||
7. Что осталось follow-up.
|
||
|
||
---
|
||
|
||
## 7. Acceptance criteria
|
||
|
||
Задача считается выполненной для первого прогона, если выполнены все критерии ниже.
|
||
|
||
### State model
|
||
|
||
1. `pcp-tgu-service` больше не хранит production state планов в in-memory `PlanRepository`.
|
||
2. `pcp-tgu-service` больше не хранит production state окон видимости в in-memory `SpacecraftPointsRepository`.
|
||
3. Окна видимости сохраняются полностью как raw payload.
|
||
4. Один business-plan имеет стабильный `planId` через unique business key.
|
||
5. Повторная выдача того же business-plan создаёт новый `attemptId`, а не новый `planId`.
|
||
|
||
### Kafka
|
||
|
||
6. Kafka topic изменения окон только ставит refresh job.
|
||
7. Kafka topic решения плана обрабатывает `ACCEPTED` / `REJECTED` по `attemptId`.
|
||
8. Decision events дедуплицируются по `eventId`.
|
||
|
||
### Planning
|
||
|
||
9. Список аппаратов берётся из НСИ и сохраняется в `spacecraft_snapshot`.
|
||
10. Для active spacecraft создаются refresh jobs.
|
||
11. Расписание планов строится из persisted visibility snapshot.
|
||
12. Расписание планов сохраняется в `planned_plan`.
|
||
13. Rebuild не дублирует планы с тем же business key.
|
||
|
||
### Issuing
|
||
|
||
14. BPMN process = одна попытка выдачи плана.
|
||
15. BPMN больше не ждёт accepted/rejected.
|
||
16. Due plans выдаются через DB claim.
|
||
17. По одному КА не создаётся несколько active attempts одновременно.
|
||
18. Camunda/Zeebe call выполняется вне DB transaction.
|
||
19. Ambiguous start не ретраится автоматически.
|
||
|
||
### Decisions
|
||
|
||
20. `ACCEPTED` обновляет attempt, plan, current base plan и ставит recalculation job.
|
||
21. `REJECTED` before cutoff открывает тот же plan для новой попытки.
|
||
22. `REJECTED` after cutoff переводит текущий plan в `EXPIRED` и продвигает следующий plan.
|
||
23. Если решения нет, автоматической перевыдачи нет.
|
||
|
||
### API/config/tests
|
||
|
||
24. Production write/manual endpoints убраны или guarded.
|
||
25. Ручной функционал находится в `TestTguController` под `/test/tgu`.
|
||
26. `TestTguController` disabled by default.
|
||
27. Helm и CI/CD не изменены.
|
||
28. `./gradlew :services:pcp-tgu-service:test` проходит.
|
||
29. `./gradlew :services:pcp-tgu-service:build` проходит.
|
||
|
||
---
|
||
|
||
## 8. Follow-up после первого прогона
|
||
|
||
Не реализовывать в этом плане, но указать в финальном отчёте, если актуально:
|
||
|
||
1. Reconciliation для `START_AMBIGUOUS` через поиск процесса в Camunda/Zeebe по business key/attempt id.
|
||
2. Kafka retry/DLQ/error-handler hardening.
|
||
3. Observability: metrics, logs, alerts для stuck `WAITING_DECISION`, `START_AMBIGUOUS`, job failures.
|
||
4. JSONB вместо `TEXT payload_json`, если потребуется query inside payload.
|
||
5. Нормализация окон видимости в отдельные таблицы, если появятся SQL-запросы по окнам.
|
||
6. Timeout policy для отсутствующего decision event.
|
||
7. Outbox/event publication, если сервис позже начнёт сам публиковать доменные события.
|
||
8. Удаление legacy tables `tracked_plan`, если миграция данных больше не нужна.
|
||
9. Отдельные integration tests с PostgreSQL/Testcontainers для claim semantics.
|
||
|
||
---
|
||
|
||
## 9. Стиль реализации
|
||
|
||
Агент должен соблюдать стиль:
|
||
|
||
- простое явное Kotlin/Spring решение;
|
||
- не вводить лишние интерфейсы, фабрики, стратегии и adapters без необходимости;
|
||
- не делать архитектуру “на будущее”;
|
||
- comments оставлять у data classes, enum classes, JPA entities и у неочевидных бизнес-правил;
|
||
- не удалять комментарии у data classes/entities/enums только потому, что они выглядят очевидными;
|
||
- транзакционные границы делать явными;
|
||
- external side effects не выполнять внутри долгой DB transaction;
|
||
- если нужно выбрать между идеальным redesign и компилируемым минимально достаточным refactor — выбрать минимально достаточный refactor, сохранив acceptance criteria.
|
||
|
||
---
|
||
|
||
## 10. Короткая карта нового runtime flow
|
||
|
||
```text
|
||
NSI scheduler
|
||
-> SyncSpacecraftFromNsiUseCase
|
||
-> spacecraft_snapshot
|
||
-> visibility_refresh_job
|
||
|
||
Kafka visibility-windows-changed
|
||
-> VisibilityWindowsChangedConsumer
|
||
-> visibility_refresh_job
|
||
|
||
VisibilityRefreshWorker
|
||
-> claim visibility_refresh_job
|
||
-> ExternalPointsClient.fetchRawVisibilityPayload
|
||
-> spacecraft_visibility_snapshot
|
||
-> if payload changed: plan_recalculation_job
|
||
|
||
PlanRecalculationWorker
|
||
-> claim plan_recalculation_job
|
||
-> VisibilityPayloadParser
|
||
-> PlanCalculationService
|
||
-> upsert planned_plan
|
||
-> supersede obsolete future plans
|
||
|
||
IssueDuePlanWorker
|
||
-> claim due planned_plan
|
||
-> create plan_issue_attempt
|
||
-> BpmnPlanIssueProcessStarter
|
||
-> plan WAITING_DECISION
|
||
|
||
Kafka satellite-plan-decision
|
||
-> PlanDecisionConsumer
|
||
-> HandlePlanDecisionUseCase
|
||
-> ACCEPTED: base plan + recalculation
|
||
-> REJECTED before cutoff: same plan PLANNED again
|
||
-> REJECTED after cutoff: current EXPIRED + promote next
|
||
```
|