Не блокировать планирование неподтверждённым планом

Если план простоял в WAITING_DECISION дольше waiting-decision-grace-minutes
(по умолчанию 30) после своего начала и решения по нему так и нет — перестаём
ждать подтверждение: снимаем активную выдачу, двигаем якорь цепочки на этот
план и выдаём следующий. Статус плана не меняется (остаётся WAITING_DECISION,
не ACCEPTED) — это молчаливое продолжение цепочки, а не приёмка. Запоздалое
решение по такому плану просто пишется на attempt и не откатывает ушедшую
вперёд цепочку.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 14:51:43 +03:00
parent 86215b300a
commit e738c236a3
9 changed files with 119 additions and 7 deletions
+1
View File
@@ -41,6 +41,7 @@ planning:
min-reissue-lead-minutes: 5 min-reissue-lead-minutes: 5
reissue-prep-minutes: 30 reissue-prep-minutes: 30
worker-lock-ttl-seconds: 300 worker-lock-ttl-seconds: 300
waiting-decision-grace-minutes: 30
topics: topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
@@ -39,5 +39,12 @@ data class PlanningProperties(
val reissuePrepMinutes: Long, val reissuePrepMinutes: Long,
/** TTL lease'а worker job claim в секундах. */ /** TTL lease'а worker job claim в секундах. */
val workerLockTtlSeconds: Long val workerLockTtlSeconds: Long,
/**
* Сколько минут после начала плана ждать решения оператора, прежде чем продолжить цепочку
* без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION,
* но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки.
*/
val waitingDecisionGraceMinutes: Long
) )
@@ -58,7 +58,7 @@ class IssueDuePlanWorker(
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val state = getOrCreateLockedState(spacecraftId) val state = getOrCreateLockedState(spacecraftId)
if (state.activeAttemptId != null) { if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
return null return null
} }
@@ -96,6 +96,41 @@ class IssueDuePlanWorker(
return PreparedAttempt(plan.planId, attempt.attemptId) return PreparedAttempt(plan.planId, attempt.attemptId)
} }
/**
* Если активная выдача — это план в WAITING_DECISION, по которому уже прошло
* [PlanningProperties.waitingDecisionGraceMinutes] минут после старта и решения так и нет,
* перестаём ждать подтверждение: снимаем блокировку (activePlanId/activeAttemptId),
* двигаем якорь цепочки на этот план и идём дальше. Статус плана НЕ меняем — он остаётся
* WAITING_DECISION (молчаливое продолжение, а не ACCEPTED). Attempt оставляем как есть:
* запоздалое решение по нему просто запишется на attempt, не трогая ушедшую вперёд цепочку.
*
* @return true, если блокировка снята и можно выдавать следующий план в этом же тике.
*/
private fun tryAdvancePastUnconfirmedPlan(state: SpacecraftPlanningStateEntity, now: LocalDateTime): Boolean {
val activePlanId = state.activePlanId ?: return false
val plan = plannedPlanRepository.findById(activePlanId).orElse(null) ?: return false
if (plan.status != PlannedPlanStatus.WAITING_DECISION) {
return false
}
if (now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))) {
return false
}
state.currentBasePlanId = plan.planId
state.activePlanId = null
state.activeAttemptId = null
state.urgentReissueFrom = null
state.chainVersion += 1
state.updatedAt = now
spacecraftPlanningStateJpaRepository.save(state)
log.info(
"Plan {} (spacecraft {}) stayed WAITING_DECISION past grace; continuing chain without confirmation.",
plan.planId, plan.spacecraftId
)
return true
}
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
@@ -24,6 +24,7 @@ planning:
min-reissue-lead-minutes: 5 min-reissue-lead-minutes: 5
reissue-prep-minutes: 30 reissue-prep-minutes: 30
worker-lock-ttl-seconds: 300 worker-lock-ttl-seconds: 300
waiting-decision-grace-minutes: 30
topics: topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
@@ -115,6 +115,7 @@ class ExternalPointsClientTest {
notificationCheckFixedDelayMs = 30_000, notificationCheckFixedDelayMs = 30_000,
minReissueLeadMinutes = 5, minReissueLeadMinutes = 5,
reissuePrepMinutes = 30, reissuePrepMinutes = 30,
workerLockTtlSeconds = 300 workerLockTtlSeconds = 300,
waitingDecisionGraceMinutes = 30
) )
} }
@@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest {
starter: AcceptedPlanSlotsUpdateProcessStarter starter: AcceptedPlanSlotsUpdateProcessStarter
): AcceptedPlanSlotsUpdateWorker = ): AcceptedPlanSlotsUpdateWorker =
AcceptedPlanSlotsUpdateWorker( AcceptedPlanSlotsUpdateWorker(
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300), planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30),
acceptedPlanSlotsUpdateJobRepository = repository, acceptedPlanSlotsUpdateJobRepository = repository,
acceptedPlanSlotsUpdateProcessStarter = starter, acceptedPlanSlotsUpdateProcessStarter = starter,
transactionTemplate = transactionTemplate() transactionTemplate = transactionTemplate()
@@ -103,6 +103,72 @@ class IssueDuePlanWorkerOnDemandTest {
verify(projection, never()).project(spacecraftId) verify(projection, never()).project(spacecraftId)
} }
@Test
fun `advances chain past WAITING_DECISION plan once grace elapsed and issues next without accepting it`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// Активный план висит в WAITING_DECISION; старт был 40 минут назад (grace=30 → истёк).
val unconfirmed = persistedPlan(
status = PlannedPlanStatus.WAITING_DECISION,
start = now().minusMinutes(40),
end = now().plusHours(1)
)
planStore[unconfirmed.planId] = unconfirmed
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
currentBasePlanId = UUID.randomUUID(),
activePlanId = unconfirmed.planId,
activeAttemptId = UUID.randomUUID()
)
val next = plan(now().plusMinutes(10), now().plusHours(3))
val worker = worker(planStore, attemptStore, state, projection = listOf(next))
worker.issueDuePlans()
// Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION.
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status)
// Якорь цепочки сдвинут на него, следующий план выдан с новым attempt.
assertEquals(unconfirmed.planId, state.currentBasePlanId)
assertEquals(2, planStore.size)
assertNotNull(state.activeAttemptId)
assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime)
}
@Test
fun `keeps blocking on WAITING_DECISION plan before grace elapses`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// Старт был всего 10 минут назад (grace=30 → ещё не истёк) → блокировка держится.
val unconfirmed = persistedPlan(
status = PlannedPlanStatus.WAITING_DECISION,
start = now().minusMinutes(10),
end = now().plusHours(1)
)
planStore[unconfirmed.planId] = unconfirmed
val attemptId = UUID.randomUUID()
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
activePlanId = unconfirmed.planId,
activeAttemptId = attemptId
)
val worker = worker(planStore, attemptStore, state, projection = listOf(plan(now().plusMinutes(10), now().plusHours(3))))
worker.issueDuePlans()
assertEquals(1, planStore.size)
assertEquals(attemptId, state.activeAttemptId)
}
private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity =
PlannedPlanEntity(
planId = UUID.randomUUID(),
spacecraftId = spacecraftId,
startTime = start,
endTime = end,
kppId = "KPP-1",
status = status
)
private fun worker( private fun worker(
planStore: MutableMap<UUID, PlannedPlanEntity>, planStore: MutableMap<UUID, PlannedPlanEntity>,
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>, attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
@@ -195,7 +261,7 @@ class IssueDuePlanWorkerOnDemandTest {
private fun now(): LocalDateTime = LocalDateTime.now() private fun now(): LocalDateTime = LocalDateTime.now()
private fun planningProperties(): PlanningProperties = private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300) PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30)
private fun transactionTemplate(): TransactionTemplate = private fun transactionTemplate(): TransactionTemplate =
TransactionTemplate( TransactionTemplate(
@@ -339,7 +339,8 @@ class PlanCalculationServiceTest {
notificationCheckFixedDelayMs = 30_000, notificationCheckFixedDelayMs = 30_000,
minReissueLeadMinutes = 5, minReissueLeadMinutes = 5,
reissuePrepMinutes = 30, reissuePrepMinutes = 30,
workerLockTtlSeconds = 300 workerLockTtlSeconds = 300,
waitingDecisionGraceMinutes = 30
) )
) )
@@ -332,7 +332,7 @@ class PlanIssueAndDecisionFlowTest {
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
private fun planningProperties(): PlanningProperties = private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300) PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30)
private fun transactionTemplate(): TransactionTemplate = private fun transactionTemplate(): TransactionTemplate =
TransactionTemplate( TransactionTemplate(