Capture current PCP architecture notes, service-map prototypes, TGU operations UI/map work, local configuration updates, database helper scripts, and request/sample JSON artifacts.
64 KiB
План доработки 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 должен стать сервисом, который:
- получает список разрешённых аппаратов из НСИ;
- для активных аппаратов получает окна видимости у баллистики;
- полностью сохраняет raw payload окон видимости;
- строит persisted расписание планов согласно настройкам;
- за
planning.notification-before-start-minutesдо старта плана запускает BPMN-процесс выдачи; - считает один BPMN process instance одной попыткой выдачи плана;
- хранит план и попытки выдачи как разные сущности;
- при тех же
spacecraftId + startTime + endTime + kppIdсоздаёт тот же persisted plan, но новую attempt; - получает
ACCEPTED/REJECTEDчерез отдельный Kafka topic; - при
ACCEPTEDфиксирует план как базовый и пересчитывает хвост расписания; - при
REJECTEDсоздаёт возможность новой попытки того же плана, если время ещё позволяет; - если время ушло, переводит текущий план в
EXPIREDи продвигает следующий доступный план; - если решения нет, не делает автоматическую перевыдачу.
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:
spacecraftId + startTime + endTime + kppId
должен иметь один persisted planId.
Повторная выдача того же business-plan должна создавать новый attemptId, а не новый planId.
3.3. BPMN = одна попытка выдачи
Целевой BPMN:
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
Текущие классы, на которые нужно ориентироваться:
services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service
Ключевые текущие файлы:
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:
V1__create_tracked_plan.sql
V2__extend_tracked_plan.sql
V3__create_spacecraft_planning_state.sql
Новая migration должна быть следующей по номеру, например:
V4__redesign_tgu_planning_state.sql
5. Целевая структура кода
Агент может выбрать небольшие вариации имён, но структура должна остаться близкой к этой.
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с randomUUID;TrackedPlanEntity;TrackedPlanStatus;PlanCalculationService, который возвращаетList<Plan>.
Шаг 1.1. Добавить CalculatedPlan
Реализация:
Создать domain model:
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
Добавить:
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больше не создаёт randomplanId;- тесты алгоритма обновлены или временно помечены к обновлению в этапе тестов;
- production-flow ещё может не компилироваться полностью, но направление изменений должно быть очевидным и локальным.
Этап 2. Добавить persistent schema
Цель этапа
Создать новую persisted модель без destructive migration старых таблиц.
Вход этапа
- существующие таблицы
tracked_plan,spacecraft_planning_state; - отсутствие persisted окон видимости, планов, attempts и jobs.
Шаг 2.1. Добавить Flyway migration
Создать:
services/pcp-tgu-service/src/main/resources/db/migration/V4__redesign_tgu_planning_state.sql
Migration должна:
- не удалять старые таблицы;
- создать новые таблицы;
- расширить
spacecraft_planning_state.
Минимальная схема:
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 использовать паттерн:
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 использовать паттерн:
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:
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
Добавить метод:
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:
@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:
saveSnapshot(spacecraftId, noradId, payloadJson, fetchedAt)
Логика:
- вычислить
payloadHash, например SHA-256; - найти текущий snapshot по
spacecraftId; - если snapshot отсутствует — сохранить и поставить recalculation job;
- если hash изменился — обновить snapshot и поставить recalculation job;
- если 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
Логика:
- вызвать
PlatformService.loadAllowedPlatforms(); - сохранить/обновить
spacecraft_snapshot; - active=true для найденных разрешённых платформ;
- active=false для ранее известных, но больше не разрешённых/не найденных;
- для 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 должен:
- claim'ить pending refresh jobs через
FOR UPDATE SKIP LOCKEDили lease; - для каждого job определить
noradId:- из job, если есть;
- иначе из
spacecraft_snapshot;
- рассчитать интервал:
timeStart = now.truncatedTo(MINUTES);timeStop = timeStart + externalPointsLookaheadDays;
- вызвать
ExternalPointsClient.fetchRawVisibilityPayload(...); - сохранить snapshot через
SpacecraftVisibilitySnapshotService; - отметить 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
Заменить:
val spacecraftPointsUpdate: String
на:
val visibilityWindowsChanged: String
val satellitePlanDecision: String
Вход шага: текущий TopicsProperties.
Выход шага: новые topic properties.
Существующие классы: TopicsProperties.
Новая модель: none.
Слой: config.
Шаг 5.2. Добавить VisibilityWindowsChangedMessage
DTO:
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:
@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 добавить метод:
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
Логика:
- получить latest visibility snapshot;
- распарсить
payloadJsonчерезVisibilityPayloadParser; - прочитать
spacecraft_planning_state; - если
currentBasePlanIdесть:- загрузить persisted accepted/base plan;
- рассчитать хвост после base plan;
- если base plan отсутствует:
- рассчитать полную цепочку;
- upsert each
CalculatedPlanintoplanned_planby business key; - для новых планов выставить:
status = PLANNED;issueNotBefore = nullилиstartTime - notificationBeforeStartMinutes;basePlanId = currentBasePlanId;chainVersion = state.chainVersion + 1;
- obsolete future
PLANNEDplans, отсутствующие в новом расчёте, пометитьSUPERSEDED; - не трогать планы в статусах:
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:
- claim'ит recalculation jobs;
- вызывает
RebuildPlanScheduleUseCase; - завершает job;
- при ошибке увеличивает 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-memoryPlanRepository;SatelliteMissionProcessService, который смешивает manual start, расчёт и Camunda call.
Шаг 7.1. Добавить BpmnPlanIssueProcessStarter
Новый adapter service:
startProcess(plan, attempt): Long processInstanceKey
Он должен:
- получить
PlannedPlanEntityиPlanIssueAttemptEntity; - заполнить
CreateSatelliteMissionVariables; - вызвать Zeebe/Camunda create instance;
- вернуть
processInstanceKey.
BPMN variables:
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 должен:
- найти due
PLANNEDplans через repository claim; - для каждого plan в отдельной transaction:
- lock/create
spacecraft_planning_state; - если
activeAttemptId != null, пропустить plan; - определить next
attemptNoдляplanId; - создать
PlanIssueAttemptEntity(status=STARTING); - поставить
plan.status = ISSUING; - заполнить
state.activePlanIdиstate.activeAttemptId; - commit;
- lock/create
- после commit вызвать
BpmnPlanIssueProcessStarter.startProcess(...)вне DB transaction; - после успешного call в новой transaction:
- сохранить
processInstanceKey; attempt.status = STARTED;attempt.startedAt = now;plan.status = WAITING_DECISION;
- сохранить
- если Camunda call бросил exception:
attempt.status = AMBIGUOUS;plan.status = START_AMBIGUOUS;- active ids оставить или перевести в состояние, явно блокирующее автоматический повтор;
- не делать автоматический retry.
Due condition:
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:
start
-> calculateSatelliteSurveyMissions
-> calculateSatelliteDropMissions
-> sendSatellitePlan
-> end
Удалить из production BPMN:
- message catch event ожидания accepted;
updatePlanStatusservice task;updateSlotsStatus, если он был строго после accepted.
Если updateSlotsStatus критичен, не пытаться встроить его обратно без понимания контракта. Оставить как follow-up в финальном отчёте.
Вход шага: BPMN file.
Выход шага: short-lived BPMN per attempt.
Существующие файлы: createSatelliteMission.bpmn.
Слой: BPMN/orchestration.
Шаг 8.2. Обновить process variables
CreateSatelliteMissionVariables должен включать:
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:
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:
@KafkaListener(topics = topics.satellitePlanDecision, groupId = ...)
-> HandlePlanDecisionUseCase.handle(message)
Вход шага: Kafka event.
Выход шага: delegated use case.
Существующие классы: PlanStatusWorker, PlanConfirmationService.
Новая модель: PlanDecisionConsumer.
Слой: adapter.
Шаг 9.3. Добавить HandlePlanDecisionUseCase
Общая transaction логика:
- insert
processed_plan_decision_event(eventId); - если duplicate key — вернуть success/idempotent no-op;
- lock attempt by
attemptId; - проверить, что attempt belongs to
planId; - lock plan;
- lock/create planning state;
- применить decision.
ACCEPTED
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:
now < plan.startTime - planning.minReissueLeadMinutes
Action:
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:
now >= plan.startTime - planning.minReissueLeadMinutes
Action:
attempt.status = REJECTED
attempt.finishedAt = decisionTime
attempt.decisionEventId = eventId
attempt.decisionReason = reason
plan.status = EXPIRED
state.activePlanId = null
state.activeAttemptId = null
Then:
- найти следующий
PLANNEDplan for same spacecraft withstartTime > now, order bystartTime; - если найден:
nextPlan.issueNotBefore = now; - если не найден: 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
eventIdidempotently 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:
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:
@ConditionalOnProperty(
prefix = "tgu.test-controller",
name = ["enabled"],
havingValue = "true"
)
@RestController
@RequestMapping("/test/tgu")
class TestTguController(...)
Default property must be false.
Перенести туда manual/debug операции:
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
Добавить:
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 добавить:
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-updatetopic не нужен production-flow; - test controller disabled by default.
Этап 12. Cleanup старого production-flow
Цель этапа
Удалить или изолировать старые single-instance элементы.
Шаг 12.1. Убрать production usage in-memory repositories
PlanRepository и SpacecraftPointsRepository не должны участвовать в production-flow.
Допустимые варианты:
- удалить классы;
- оставить только для старых тестов, но production services не inject'ят их;
- перенести в test package, если нужно.
Строго проверить, что новые production classes не зависят от них.
Шаг 12.2. Убрать production usage PlanStatusWorker / PlanConfirmationService
Если BPMN больше не ждёт accepted, PlanStatusWorker не должен быть активным production worker.
Допустимые варианты:
- удалить;
- выключить;
- оставить только для test/legacy, если без этого быстрее пройти build.
Но production accepted/rejected должен идти через PlanDecisionConsumer.
Шаг 12.3. Убрать direct recalculation из старого SpacecraftPointsService
Если SpacecraftPointsService остаётся, он не должен быть центральным production use case.
Новая цепочка:
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
saveAndRecalculatepath not used by production Kafka/scheduler.
Этап 13. Тесты
Цель этапа
Покрыть новую state model и защитить ключевые scale-out инварианты.
Шаг 13.1. Обновить unit tests алгоритма
Покрыть:
PlanCalculationServiceвозвращаетCalculatedPlan;CalculatedPlanне содержит randomplanId;- алгоритм выбора окон не изменился относительно старых 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
PLANNEDplans becomeSUPERSEDED; WAITING_DECISION,ISSUING,ACCEPTED,START_AMBIGUOUSare 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
attemptIdand incrementsattemptNo; - Camunda exception sets
AMBIGUOUS/START_AMBIGUOUSand no auto retry happens.
Шаг 13.5. Покрыть decision flow
Тесты:
- duplicate
eventIdignored; ACCEPTEDsets attempt accepted, plan accepted, current base plan, clears active ids, enqueues recalculation;REJECTEDbefore cutoff sets same planPLANNEDandissueNotBefore = now;REJECTEDafter cutoff sets current planEXPIREDand promotes nextPLANNEDplan;REJECTEDafter cutoff with no next plan enqueues recalculation;- decision with mismatched
planId/attemptIdfails safely and does not corrupt state.
Шаг 13.6. Покрыть query/test controller basics
Тесты:
PlanQueryServicereadsplanned_plan, notPlanRepository;- 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. Проверки и финальный отчёт агента
Проверки
Агент обязан выполнить из корня репозитория:
./gradlew :services:pcp-tgu-service:test
./gradlew :services:pcp-tgu-service:build
Если это проходит и время позволяет:
./gradlew build
Если какая-то проверка не проходит по причине, не связанной с pcp-tgu-service, агент должен явно указать это в финальном отчёте и привести error summary.
Финальный отчёт агента должен содержать
- Что изменено по этапам.
- Список ключевых новых классов.
- Список удалённых/изолированных старых production-flow классов.
- Новые таблицы и migration name.
- Как теперь работает flow:
- NSI sync;
- visibility refresh;
- schedule rebuild;
- issue due plan;
- accepted/rejected decision.
- Какие проверки запущены и их результат.
- Что осталось follow-up.
7. Acceptance criteria
Задача считается выполненной для первого прогона, если выполнены все критерии ниже.
State model
pcp-tgu-serviceбольше не хранит production state планов в in-memoryPlanRepository.pcp-tgu-serviceбольше не хранит production state окон видимости в in-memorySpacecraftPointsRepository.- Окна видимости сохраняются полностью как raw payload.
- Один business-plan имеет стабильный
planIdчерез unique business key. - Повторная выдача того же business-plan создаёт новый
attemptId, а не новыйplanId.
Kafka
- Kafka topic изменения окон только ставит refresh job.
- Kafka topic решения плана обрабатывает
ACCEPTED/REJECTEDпоattemptId. - Decision events дедуплицируются по
eventId.
Planning
- Список аппаратов берётся из НСИ и сохраняется в
spacecraft_snapshot. - Для active spacecraft создаются refresh jobs.
- Расписание планов строится из persisted visibility snapshot.
- Расписание планов сохраняется в
planned_plan. - Rebuild не дублирует планы с тем же business key.
Issuing
- BPMN process = одна попытка выдачи плана.
- BPMN больше не ждёт accepted/rejected.
- Due plans выдаются через DB claim.
- По одному КА не создаётся несколько active attempts одновременно.
- Camunda/Zeebe call выполняется вне DB transaction.
- Ambiguous start не ретраится автоматически.
Decisions
ACCEPTEDобновляет attempt, plan, current base plan и ставит recalculation job.REJECTEDbefore cutoff открывает тот же plan для новой попытки.REJECTEDafter cutoff переводит текущий plan вEXPIREDи продвигает следующий plan.- Если решения нет, автоматической перевыдачи нет.
API/config/tests
- Production write/manual endpoints убраны или guarded.
- Ручной функционал находится в
TestTguControllerпод/test/tgu. TestTguControllerdisabled by default.- Helm и CI/CD не изменены.
./gradlew :services:pcp-tgu-service:testпроходит../gradlew :services:pcp-tgu-service:buildпроходит.
8. Follow-up после первого прогона
Не реализовывать в этом плане, но указать в финальном отчёте, если актуально:
- Reconciliation для
START_AMBIGUOUSчерез поиск процесса в Camunda/Zeebe по business key/attempt id. - Kafka retry/DLQ/error-handler hardening.
- Observability: metrics, logs, alerts для stuck
WAITING_DECISION,START_AMBIGUOUS, job failures. - JSONB вместо
TEXT payload_json, если потребуется query inside payload. - Нормализация окон видимости в отдельные таблицы, если появятся SQL-запросы по окнам.
- Timeout policy для отсутствующего decision event.
- Outbox/event publication, если сервис позже начнёт сам публиковать доменные события.
- Удаление legacy tables
tracked_plan, если миграция данных больше не нужна. - Отдельные 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
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