Не блокировать планирование неподтверждённым планом
Если план простоял в WAITING_DECISION дольше waiting-decision-grace-minutes (по умолчанию 30) после своего начала и решения по нему так и нет — перестаём ждать подтверждение: снимаем активную выдачу, двигаем якорь цепочки на этот план и выдаём следующий. Статус плана не меняется (остаётся WAITING_DECISION, не ACCEPTED) — это молчаливое продолжение цепочки, а не приёмка. Запоздалое решение по такому плану просто пишется на attempt и не откатывает ушедшую вперёд цепочку.
This commit is contained in:
+8
-1
@@ -39,5 +39,12 @@ data class PlanningProperties(
|
||||
val reissuePrepMinutes: Long,
|
||||
|
||||
/** TTL lease'а worker job claim в секундах. */
|
||||
val workerLockTtlSeconds: Long
|
||||
val workerLockTtlSeconds: Long,
|
||||
|
||||
/**
|
||||
* Сколько минут после начала плана ждать решения оператора, прежде чем продолжить цепочку
|
||||
* без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION,
|
||||
* но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки.
|
||||
*/
|
||||
val waitingDecisionGraceMinutes: Long
|
||||
)
|
||||
|
||||
+36
-1
@@ -58,7 +58,7 @@ class IssueDuePlanWorker(
|
||||
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
|
||||
val now = LocalDateTime.now()
|
||||
val state = getOrCreateLockedState(spacecraftId)
|
||||
if (state.activeAttemptId != null) {
|
||||
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -96,6 +96,41 @@ class IssueDuePlanWorker(
|
||||
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? {
|
||||
val now = LocalDateTime.now()
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
|
||||
|
||||
@@ -24,6 +24,7 @@ planning:
|
||||
min-reissue-lead-minutes: 5
|
||||
reissue-prep-minutes: 30
|
||||
worker-lock-ttl-seconds: 300
|
||||
waiting-decision-grace-minutes: 30
|
||||
|
||||
topics:
|
||||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||||
|
||||
+2
-1
@@ -115,6 +115,7 @@ class ExternalPointsClientTest {
|
||||
notificationCheckFixedDelayMs = 30_000,
|
||||
minReissueLeadMinutes = 5,
|
||||
reissuePrepMinutes = 30,
|
||||
workerLockTtlSeconds = 300
|
||||
workerLockTtlSeconds = 300,
|
||||
waitingDecisionGraceMinutes = 30
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest {
|
||||
starter: AcceptedPlanSlotsUpdateProcessStarter
|
||||
): 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,
|
||||
acceptedPlanSlotsUpdateProcessStarter = starter,
|
||||
transactionTemplate = transactionTemplate()
|
||||
|
||||
+67
-1
@@ -103,6 +103,72 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
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(
|
||||
planStore: MutableMap<UUID, PlannedPlanEntity>,
|
||||
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
|
||||
@@ -195,7 +261,7 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
private fun now(): LocalDateTime = LocalDateTime.now()
|
||||
|
||||
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 =
|
||||
TransactionTemplate(
|
||||
|
||||
+2
-1
@@ -339,7 +339,8 @@ class PlanCalculationServiceTest {
|
||||
notificationCheckFixedDelayMs = 30_000,
|
||||
minReissueLeadMinutes = 5,
|
||||
reissuePrepMinutes = 30,
|
||||
workerLockTtlSeconds = 300
|
||||
workerLockTtlSeconds = 300,
|
||||
waitingDecisionGraceMinutes = 30
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -332,7 +332,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
|
||||
|
||||
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 =
|
||||
TransactionTemplate(
|
||||
|
||||
Reference in New Issue
Block a user