Не блокировать планирование неподтверждённым планом
Если план простоял в WAITING_DECISION дольше waiting-decision-grace-minutes (по умолчанию 30) после своего начала и решения по нему так и нет — перестаём ждать подтверждение: снимаем активную выдачу, двигаем якорь цепочки на этот план и выдаём следующий. Статус плана не меняется (остаётся WAITING_DECISION, не ACCEPTED) — это молчаливое продолжение цепочки, а не приёмка. Запоздалое решение по такому плану просто пишется на attempt и не откатывает ушедшую вперёд цепочку.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+8
-1
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+36
-1
@@ -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
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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()
|
||||||
|
|||||||
+67
-1
@@ -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(
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user