fix(jobs): recheck archived flight version before purge

HistorySweepJob 归档成功后进 PIPELINE_LOCK 事务,按 (FLID, STATE_VERSION) 用同一判据复查,仍合格才登记 tombstone 并删除;purgeArchived 改收候选而非裸 FLID 列表,并带 (flid, state_version) 删除守卫。事件与删除同事务提交或回滚(INV-17、INV-18)。

验证:./gradlew test 131 tests / 0 fail(新增「归档后主泵更新同一 FLID → 不删、不发 tombstone」用例)。tombstone 与删除的回滚原子性依赖真实 PG 事务,本机无 Docker 时无法执行。
This commit is contained in:
windyboy
2026-09-12 20:40:32 +08:00
parent 592079055f
commit 46e901fd44
5 changed files with 75 additions and 26 deletions
@@ -7,6 +7,8 @@ import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -51,7 +53,7 @@ class HistorySweepJobTest {
fun `history store not connected must delete zero rows`() {
val f = seededFlight("F1", deleted = true, idleDays = 30)
val events = StubMsgEvents()
val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false }, OperationDayProps())
val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false }, OperationDayProps(), StubPipelineTx(), StubPipelineLock())
val outcome = job.run(now)
@@ -72,7 +74,7 @@ class HistorySweepJobTest {
val events = StubMsgEvents()
val store = RecordingHistoryStore().apply { confirmed.add("F1") } // 只有 F1 归档确认成功
val job = HistorySweepJob(
f, events, HistoryProps().apply { historyStoreEnabled = true }, OperationDayProps(), historyStore = store,
f, events, HistoryProps().apply { historyStoreEnabled = true }, OperationDayProps(), StubPipelineTx(), StubPipelineLock(), historyStore = store,
)
val outcome = job.run(now)
@@ -91,7 +93,7 @@ class HistorySweepJobTest {
val events = StubMsgEvents()
val store = RecordingHistoryStore()
val job = HistorySweepJob(
f, events, HistoryProps().apply { historyStoreEnabled = true }, OperationDayProps(), historyStore = store,
f, events, HistoryProps().apply { historyStoreEnabled = true }, OperationDayProps(), StubPipelineTx(), StubPipelineLock(), historyStore = store,
)
job.run(now)
@@ -103,13 +105,37 @@ class HistorySweepJobTest {
assertEquals(null, f.findMainRow("F3"))
}
@Test
fun `a flight updated after archival is not purged and emits no tombstone`() {
val f = seededFlight("F4", deleted = false, idleDays = 30)
val events = StubMsgEvents()
val store = object : HistorySweepJob.HistoryStore {
override fun archive(candidates: List<HistoryCandidate>): Set<String> {
// 模拟归档期间主泵对同一 FLID 提交了新版本:清理判据已不再成立/版本已变
f.markDeleted("F4", msgId = 9, now = now)
return setOf("F4")
}
}
val job = HistorySweepJob(
f, events, HistoryProps().apply { historyStoreEnabled = true }, OperationDayProps(),
StubPipelineTx(), StubPipelineLock(), historyStore = store,
)
val outcome = job.run(now)
assertEquals(1, outcome.archived)
assertEquals(0, outcome.purged) // 归档后版本变了:本次不删
assertTrue(f.findMainRow("F4") != null) // 在用航班不许被清掉
assertEquals(0, events.rows.size) // 也不登记 tombstone
}
@Test
fun `snap log purge runs independently of the history store switch`() {
val f = seededFlight("F1", deleted = true, idleDays = 30)
var cutoff: Instant? = null
val purge = com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogPurge { instant -> cutoff = instant; 7 }
val job = HistorySweepJob(
f, StubMsgEvents(), HistoryProps().apply { historyStoreEnabled = false }, OperationDayProps(), snapLogPurge = purge,
f, StubMsgEvents(), HistoryProps().apply { historyStoreEnabled = false }, OperationDayProps(), StubPipelineTx(), StubPipelineLock(), snapLogPurge = purge,
)
val outcome = job.run(now)