pcp-tgu-service: Ф5 — promote черновика с оптимистичным локом базы (POST /plans/{id}/promote)
This commit is contained in:
+11
@@ -59,4 +59,15 @@ class PlanController(
|
||||
@PostMapping("/{planId}/issue")
|
||||
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
|
||||
issueDuePlanWorker.issuePlanNow(planId)
|
||||
|
||||
/**
|
||||
* «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и выдаёт его. Под lock'ом
|
||||
* состояния КА: оптимистичный лок базы (версия цепочки не уехала), контроль валидности слота
|
||||
* и гард точки невозврата.
|
||||
* 404 — черновика нет; 422 — план не DRAFT / слот сместился / точка невозврата пройдена;
|
||||
* 409 — база изменилась с момента создания черновика либо по КА уже идёт выдача.
|
||||
*/
|
||||
@PostMapping("/{draftPlanId}/promote")
|
||||
fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse =
|
||||
issueDuePlanWorker.promoteDraftNow(draftPlanId)
|
||||
}
|
||||
|
||||
+14
-4
@@ -13,6 +13,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
@@ -36,7 +37,8 @@ class CreateDraftUseCase(
|
||||
private val plannedPlanRepository: PlannedPlanRepository,
|
||||
private val planProjectionService: PlanProjectionService,
|
||||
private val missionPlaningClient: MissionPlaningClient,
|
||||
private val planningProperties: PlanningProperties
|
||||
private val planningProperties: PlanningProperties,
|
||||
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@@ -182,6 +184,13 @@ class CreateDraftUseCase(
|
||||
kppId: String,
|
||||
clonedMissionId: UUID
|
||||
): CreateDraftResponse {
|
||||
// Снимок версии цепочки КА на момент создания черновика — оптимистичный токен для promote (Ф5):
|
||||
// любой ACCEPT/grace-сдвиг базы инкрементит chain_version, и «Применить» по устаревшему черновику
|
||||
// упадёт 409 «база уехала». chain_version у DRAFT хранит именно этот снимок (== живой при promote).
|
||||
val baseChainVersion = spacecraftPlanningStateJpaRepository.findById(base.spacecraftId)
|
||||
.map { it.chainVersion }
|
||||
.orElse(0L)
|
||||
|
||||
return try {
|
||||
val draft = plannedPlanRepository.save(
|
||||
PlannedPlanEntity(
|
||||
@@ -191,12 +200,13 @@ class CreateDraftUseCase(
|
||||
endTime = endTime,
|
||||
kppId = kppId,
|
||||
status = PlannedPlanStatus.DRAFT,
|
||||
basePlanId = base.planId
|
||||
basePlanId = base.planId,
|
||||
chainVersion = baseChainVersion
|
||||
)
|
||||
)
|
||||
log.info(
|
||||
"Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}",
|
||||
draft.planId, base.planId, base.spacecraftId
|
||||
"Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}",
|
||||
draft.planId, base.planId, base.spacecraftId, baseChainVersion
|
||||
)
|
||||
CreateDraftResponse(draftPlanId = draft.planId)
|
||||
} catch (ex: Exception) {
|
||||
|
||||
+104
@@ -59,6 +59,29 @@ class IssueDuePlanWorker(
|
||||
return startPreparedAttempt(prepared)
|
||||
}
|
||||
|
||||
/**
|
||||
* «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и сразу выдаёт его.
|
||||
*
|
||||
* Под lock'ом состояния КА проверяет (в порядке):
|
||||
* 1. план существует и действительно DRAFT (иначе 404 / 422);
|
||||
* 2. **оптимистичный лок базы** — версия цепочки не изменилась с момента создания черновика
|
||||
* ([assertDraftBaseUnchanged]); иначе 409 «база уехала, посмотрите дифф»;
|
||||
* 3. **слот ещё валиден** — окно черновика по-прежнему соответствует реальной будущей ЗРВ
|
||||
* (дрейф снапшота / перекрытие слота при replace, [assertDraftSlotStillValid]); иначе 422;
|
||||
* 4. гард точки невозврата ([assertReissuableInSlot]) — не заморожено и нет активной выдачи
|
||||
* другого плана; иначе 422 / 409.
|
||||
*
|
||||
* Затем DRAFT→ISSUING, новая attempt, и старт BPMN. Цепочка продолжается штатно: на внешний
|
||||
* ACCEPT существующий acceptPlan сделает супершединг (chainVersion++). Прежние версии остаются
|
||||
* в БД (откат = новый черновик из старой версии). basePlanId черновика не трогаем — это его
|
||||
* провенанс (какой план он замещает/продолжает).
|
||||
*/
|
||||
fun promoteDraftNow(draftPlanId: UUID): ProcessStartResponse {
|
||||
val prepared = transactionTemplate.execute { prepareDraftPromotion(draftPlanId) }
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Draft plan with planId=$draftPlanId was not found")
|
||||
return startPreparedAttempt(prepared)
|
||||
}
|
||||
|
||||
/** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
|
||||
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
@@ -156,6 +179,87 @@ class IssueDuePlanWorker(
|
||||
return PreparedAttempt(plan.planId, attempt.attemptId)
|
||||
}
|
||||
|
||||
private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null
|
||||
if (plan.status != PlannedPlanStatus.DRAFT) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"План $draftPlanId не является черновиком (status=${plan.status}) — «Применить» доступно только для DRAFT"
|
||||
)
|
||||
}
|
||||
|
||||
val state = getOrCreateLockedState(plan.spacecraftId)
|
||||
assertDraftBaseUnchanged(plan, state)
|
||||
assertDraftSlotStillValid(plan)
|
||||
assertReissuableInSlot(plan, state, now)
|
||||
|
||||
plan.status = PlannedPlanStatus.ISSUING
|
||||
plan.updatedAt = now
|
||||
plannedPlanRepository.save(plan)
|
||||
|
||||
val attempt = createStartingAttempt(plan.planId, now)
|
||||
state.activePlanId = plan.planId
|
||||
state.activeAttemptId = attempt.attemptId
|
||||
state.urgentReissueFrom = null
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
log.info(
|
||||
"Черновик {} применён (promote): spacecraft={}, окно [{}–{}], chainVersion={}",
|
||||
plan.planId, plan.spacecraftId, plan.startTime, plan.endTime, plan.chainVersion
|
||||
)
|
||||
return PreparedAttempt(plan.planId, attempt.attemptId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Оптимистичный лок базы для promote: версия цепочки, снятая при создании черновика
|
||||
* ([PlannedPlanEntity.chainVersion] у DRAFT), должна совпадать с текущей версией цепочки КА.
|
||||
* Любой ACCEPT/grace-сдвиг базы между созданием черновика и «Применить» инкрементит версию —
|
||||
* тогда черновик строился от устаревшей базы и его нужно пересоздать (посмотрев дифф).
|
||||
*/
|
||||
private fun assertDraftBaseUnchanged(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity) {
|
||||
if (plan.chainVersion != state.chainVersion) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"База КА ${plan.spacecraftId} изменилась с момента создания черновика ${plan.planId} " +
|
||||
"(цепочка v${plan.chainVersion} → v${state.chainVersion}) — посмотрите дифф и пересоздайте черновик"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно
|
||||
* по-прежнему соответствовать реальной будущей ЗРВ. Для fork-forward слот есть в проекции;
|
||||
* для replace слот = окно заменяемого базового плана, которое проекция (заякоренная ПОСЛЕ
|
||||
* плана в полёте) не возвращает, поэтому окно базы учитывается явно. Сверка по КПП и времени
|
||||
* с точностью до секунды (как при создании черновика). Не совпало → снапшот сместился → 422.
|
||||
*/
|
||||
private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) {
|
||||
val candidates = planProjectionService.project(plan.spacecraftId)
|
||||
.map { Triple(it.kppId, it.startTime, it.endTime) }
|
||||
.toMutableList()
|
||||
plan.basePlanId
|
||||
?.let { plannedPlanRepository.findById(it).orElse(null) }
|
||||
?.let { candidates.add(Triple(it.kppId, it.startTime, it.endTime)) }
|
||||
|
||||
val matches = candidates.any { (kppId, start, end) ->
|
||||
kppId == plan.kppId &&
|
||||
diffSeconds(start, plan.startTime) < 1 &&
|
||||
diffSeconds(end, plan.endTime) < 1
|
||||
}
|
||||
if (!matches) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " +
|
||||
"больше не соответствует ни одной будущей ЗРВ — снапшот сместился, пересоздайте черновик"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long =
|
||||
Math.abs(java.time.Duration.between(a, b).toSeconds())
|
||||
|
||||
/**
|
||||
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
|
||||
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
|
||||
|
||||
+21
-2
@@ -17,9 +17,11 @@ import space.nstart.pcp_tgu_service.dto.CreateDraftRequest
|
||||
import space.nstart.pcp_tgu_service.dto.DraftMode
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Optional
|
||||
@@ -50,6 +52,19 @@ class CreateDraftUseCaseTest {
|
||||
assertEquals(base.kppId, saved.kppId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft snapshots the current chain version as its optimistic token`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val fixture = fixture(
|
||||
base,
|
||||
planningState = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 7)
|
||||
)
|
||||
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
|
||||
assertEquals(7L, fixture.lastSaved!!.chainVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace rejects a DRAFT base`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT)
|
||||
@@ -209,7 +224,8 @@ class CreateDraftUseCaseTest {
|
||||
cloneResult: UUID = UUID.randomUUID(),
|
||||
projection: List<CalculatedPlan> = emptyList(),
|
||||
existingDrafts: List<PlannedPlanEntity> = emptyList(),
|
||||
failSave: Boolean = false
|
||||
failSave: Boolean = false,
|
||||
planningState: SpacecraftPlanningStateEntity? = null
|
||||
): Fixture {
|
||||
val repo = mock(PlannedPlanRepository::class.java)
|
||||
val basePlanId = base?.planId ?: UUID.randomUUID()
|
||||
@@ -231,7 +247,10 @@ class CreateDraftUseCaseTest {
|
||||
`when`(client.cloneMission(anyUuid(), anyNonNull()))
|
||||
.thenReturn(MissionCloneResponse(newMissionId = cloneResult))
|
||||
|
||||
val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10))
|
||||
val stateRepo = mock(SpacecraftPlanningStateJpaRepository::class.java)
|
||||
`when`(stateRepo.findById(spacecraftId)).thenReturn(Optional.ofNullable(planningState))
|
||||
|
||||
val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10), stateRepo)
|
||||
return Fixture(useCase, client, saved)
|
||||
}
|
||||
|
||||
|
||||
+140
@@ -285,6 +285,146 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||
}
|
||||
|
||||
// --- Применить черновик: promoteDraftNow (Ф5, оптимистичный лок базы) ---
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow promotes a fresh draft into the chain and issues it`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// Заменяемый базовый план (даёт окно для проверки валидности слота при replace).
|
||||
val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[base.planId] = base
|
||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||
planStore[draft.planId] = draft
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
worker.promoteDraftNow(draft.planId)
|
||||
|
||||
// DRAFT ушёл в выдачу: markStarted перевёл его в WAITING_DECISION, КА указывает на новую attempt.
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status)
|
||||
assertEquals(1, attemptStore.size)
|
||||
assertEquals(draft.planId, state.activePlanId)
|
||||
assertNotNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow rejects with 409 when the chain moved since the draft was created`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[base.planId] = base
|
||||
// Черновик снят на версии цепочки 0, а база уже уехала вперёд (accept/grace) → состояние v1.
|
||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||
planStore[draft.planId] = draft
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 1)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
assertNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow rejects a non-draft plan with 422`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val live = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[live.planId] = live
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(live.planId) }
|
||||
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow returns 404 for unknown draft`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(UUID.randomUUID()) }
|
||||
|
||||
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow rejects a draft inside the freeze window with 422`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// Старт через 5 мин (< порога 10 мин): слот валиден (база даёт окно), но точка невозврата пройдена.
|
||||
val base = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3))
|
||||
planStore[base.planId] = base
|
||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||
planStore[draft.planId] = draft
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow rejects with 409 when another plan is being issued`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[base.planId] = base
|
||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||
planStore[draft.planId] = draft
|
||||
// По КА уже идёт активная выдача другого плана.
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
chainVersion = 0,
|
||||
activePlanId = UUID.randomUUID(),
|
||||
activeAttemptId = UUID.randomUUID()
|
||||
)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow rejects with 422 when the draft window no longer matches any future ZRV`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// Нет базового плана и пустая проекция → окно слота не подтверждается (снапшот сместился).
|
||||
val draft = draftPlan(now().plusMinutes(30), now().plusHours(3), chainVersion = 0, basePlanId = null)
|
||||
planStore[draft.planId] = draft
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
}
|
||||
|
||||
private fun draftPlan(
|
||||
start: LocalDateTime,
|
||||
end: LocalDateTime,
|
||||
chainVersion: Long,
|
||||
basePlanId: UUID?
|
||||
): PlannedPlanEntity =
|
||||
persistedPlan(PlannedPlanStatus.DRAFT, start, end).apply {
|
||||
this.chainVersion = chainVersion
|
||||
this.basePlanId = basePlanId
|
||||
}
|
||||
|
||||
private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
|
||||
Reference in New Issue
Block a user