feat(persistence)!: V1.4.0 退场 legacy 槽位存储,persistNextStates 成为唯一写路径 (ACM2-29 P4)

- V1.4.0 迁移:DROP FLIGHT_SCHD 全部 77 个槽位/里程碑/紧凑航路列(V1.3.0 起
  零写入、P2-2 起零读取)+ 三个与 PK 前缀冗余的 (flid) 单列索引;回退窗口关闭
  (对拍留档:P2-1 FS7 v2 双读差异记录)
- 接口收敛:FlightSchdRepository 删除 upsertSnapshotBatch/upsertIncremental,
  删除废弃 FlightStateRepository 及 Jdbc/Stub 适配器;仓储与装配器删除全部
  legacy flatten/slot/milestone/route 视图代码,ALL_COLUMNS 收敛为无损承载列
- FS7 对拍工具移除 diffDualRead(legacy 槽位基准退场);diff() 对 legacy
  FLTR JSON 比对保留;FlightStoreDualReadTest 随工具移除
- 测试种子统一走 v2 写路径(support/SeedHelpers),GuardedFlightSchd 简化
- docs/decision-flight-state.md:头部复核入口更新 + 新增 §10 v2 落地终态索引,
  §1–§9 保留为历史记录

验证:MSGX_PG_PORT=5433 真实 PG 全量 100 用例 0 失败 0 跳过;无环境变量
Testcontainers 全新容器 V1.0.0→V1.4.0 迁移链 18/18 全绿
This commit is contained in:
windyboy
2026-09-08 17:44:20 +08:00
parent 107be5d74a
commit 7c2d22e8f1
15 changed files with 142 additions and 746 deletions
@@ -231,7 +231,6 @@ class FlightSchdJdbcPgTest {
)
assertNull(repo.findByFlid(flid)!!["GTDT"])
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertLegacyCollectionColumnsAllNull(flid)
}
@Test
@@ -533,7 +532,6 @@ class FlightSchdJdbcPgTest {
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
assertLegacyCollectionColumnsAllNull(flid)
val roundtrip = repo.findNextStateByFlid(flid)!!
assertEquals(1L, roundtrip.stateVersion)
@@ -585,7 +583,6 @@ class FlightSchdJdbcPgTest {
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
assertLegacyCollectionColumnsAllNull(flid)
assertEquals("MU123", repo.findByFlid(flid)!!["FLNO"])
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
@@ -794,16 +791,4 @@ class FlightSchdJdbcPgTest {
if (e != null && a != null) assertEquals(e, a, path) else assertEquals(expected, actual, path)
}
private fun assertLegacyCollectionColumnsAllNull(flid: String) {
val columnList = com.gzzn.omms.msgexchange.support.LEGACY_COLLECTION_STORAGE_COLUMNS.joinToString(", ")
ds.queryOne(
"SELECT $columnList FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs ->
com.gzzn.omms.msgexchange.support.LEGACY_COLLECTION_STORAGE_COLUMNS.forEach { column ->
assertNull(rs.getString(column), "legacy column $column must be null for v2 persistNextStates (flid=$flid)")
}
true
}
}
}
@@ -2,6 +2,7 @@ package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.support.seedSnapshot
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -44,7 +45,7 @@ class HistorySweepJobTest {
"F2" to mapOf("FLID" to "F2"),
"F3" to mapOf("FLID" to "F3"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
flightSchd.putGenIfVersion("2026-09-01", 0L, FlightSchdRepository.GenMeta("2026-09-01", 1L, setOf("F1", "F2", "F3")))
// 模拟判史:F1, F2, F3 全部为历史候选
@@ -72,7 +73,7 @@ class HistorySweepJobTest {
"F2" to mapOf("FLID" to "F2"),
"F3" to mapOf("FLID" to "F3"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
HistorySweepJob.historyPicker = { it }
// 模拟 ES 写入:F1 和 F2 成功,F3 失败
@@ -95,7 +96,7 @@ class HistorySweepJobTest {
"F1" to mapOf("FLID" to "F1"),
"F2" to mapOf("FLID" to "F2"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
HistorySweepJob.historyPicker = { it }
// 模拟 ES 全部写入失败
@@ -114,7 +115,7 @@ class HistorySweepJobTest {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
// 第一次运行模拟:ES 写入成功,但在调用 deleteByFlids 前崩溃(PG 依然保留 F1
val esUpserted = mutableSetOf<String>()
@@ -151,7 +152,7 @@ class HistorySweepJobTest {
@Test
fun `Scenario 5 - Replaying deleteByFlids is completely idempotent with no side effects`() {
val flights = listOf("F1" to mapOf("FLID" to "F1"))
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
// 首次删除
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
@@ -29,6 +29,8 @@ import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import com.gzzn.omms.msgexchange.support.seedIncremental
import com.gzzn.omms.msgexchange.support.seedSnapshot
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -269,10 +271,10 @@ class FlightSchdInvariantTest {
// 1. 增量更新写入一条临时加飞航班 ADFT(FDAY 为 NULL
val adftChange = FlightChange(flid = "ADFT_888", fields = mapOf("FLID" to "ADFT_888", "FLTY" to "ADFT"))
flightSchd.upsertIncremental(listOf(adftChange))
seedIncremental(flightSchd, listOf(adftChange))
// 2. 写入旧代的一条定期计划航班 REG_OLDFDAY 为 2026-09-07
flightSchd.upsertSnapshotBatch(day, listOf("REG_OLD" to mapOf("FLID" to "REG_OLD", "FLTY" to "REG")))
seedSnapshot(flightSchd, day, listOf("REG_OLD" to mapOf("FLID" to "REG_OLD", "FLTY" to "REG")))
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("REG_OLD")))
// 3. 执行下一轮快照 DNLD,新代仅包含 REG_NEW(REG_OLD 不在新代中,属于待删差集;ADFT 也不在新代中)
@@ -308,11 +310,11 @@ class FlightSchdInvariantTest {
val dayNew = "2026-09-08"
// 初始属旧代
flightSchd.upsertSnapshotBatch(dayOld, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "07")))
seedSnapshot(flightSchd, dayOld, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "07")))
flightSchd.putGenIfVersion(dayOld, 0L, FlightSchdRepository.GenMeta(dayOld, 1L, setOf("FL_MIG")))
// 随后 09-08 快照写入将 FDAY 更新为 2026-09-08
flightSchd.upsertSnapshotBatch(dayNew, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "08")))
seedSnapshot(flightSchd, dayNew, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "08")))
// 此时 09-07 再次执行差删(差集中包含 FL_MIG)
val deleted = flightSchd.deleteDiffByDay(dayOld, listOf("FL_MIG"))
@@ -334,7 +336,7 @@ class FlightSchdInvariantTest {
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val now = Instant.parse("2026-09-07T08:00:00.123456Z")
flightSchd.upsertSnapshotBatch("2026-09-07", listOf("TZ_01" to mapOf("FLID" to "TZ_01")), now)
seedSnapshot(flightSchd, "2026-09-07", listOf("TZ_01" to mapOf("FLID" to "TZ_01")), now)
flightSchd.putGenIfVersion("2026-09-07", 0L, FlightSchdRepository.GenMeta("2026-09-07", 1L, setOf("TZ_01")), now)
val gen = flightSchd.getGen("2026-09-07")
@@ -156,13 +156,6 @@ class MessageProcessorTest {
val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
val incrementalChanges = mutableListOf<com.gzzn.omms.msgexchange.domain.FlightChange>()
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, FlightFields>>, now: Instant) {
this.flights.putAll(flights)
}
override fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant) {
incrementalChanges.addAll(changes)
changes.forEach { flights[it.flid] = it.fields }
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = 0
override fun findByFlid(flid: String): FlightFields? = flights[flid]
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> =
@@ -70,8 +70,6 @@ class V2PipelineWritePathContractTest {
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
assertEquals(0, flightSchd.legacySnapshotCalls)
assertEquals(0, flightSchd.legacyIncrementalCalls)
}
@Test
@@ -109,7 +107,5 @@ class V2PipelineWritePathContractTest {
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
assertEquals(0, flightSchd.legacySnapshotCalls)
assertEquals(0, flightSchd.legacyIncrementalCalls)
}
}
@@ -0,0 +1,44 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import java.time.Instant
/** 测试种子助手:v2 写路径(persistNextStates)等价替代已退场的 legacy upsertP4)。 */
fun seedSnapshot(
repo: FlightSchdRepository,
day: String,
flights: List<Pair<String, FlightFields>>,
now: Instant = Instant.now(),
) {
repo.persistNextStates(
day,
flights.map { (flid, fields) ->
FlightStateEngine.apply(
null,
FlightStateEngine.commandsFromFields(flid, fields, snapshotReplace = true),
"seed-$day",
bumpVersion = false,
).copy(stateVersion = 1L)
},
snapshotReplace = true,
now = now,
)
}
fun seedIncremental(repo: FlightSchdRepository, changes: List<FlightChange>, messageId: String = "seed-inc") {
repo.persistNextStates(
null,
changes.map { change ->
FlightStateEngine.apply(
repo.findNextStateByFlid(change.flid),
FlightStateEngine.commandsFromFields(change.flid, change.fields, snapshotReplace = false),
messageId,
bumpVersion = true,
)
},
snapshotReplace = false,
)
}
@@ -1,46 +1,17 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import java.time.Instant
/** V1.2.0 宽表集合存储列;v2 persistNextStates 不得写入。 */
val LEGACY_COLLECTION_STORAGE_COLUMNS: List<String> = listOf(
"gate1", "pgot1", "pgct1", "gotm1", "gctm1", "gtyp1",
"gate2", "pgot2", "pgct2", "gotm2", "gctm2", "gtyp2",
"chkc1", "ccls1", "pcot1", "pcct1", "cotm1", "cctm1", "ctyp1",
"chkc2", "ccls2", "pcot2", "pcct2", "cotm2", "cctm2", "ctyp2",
"chkc3", "ccls3", "pcot3", "pcct3", "cotm3", "cctm3", "ctyp3",
"belt1", "bcls1", "bpcot1", "bpcct1", "fbag1", "lbag1", "btyp1",
"belt2", "bcls2", "bpcot2", "bpcct2", "fbag2", "lbag2", "btyp2",
"psst1", "stst1", "stet1", "psst2", "stst2", "stet2",
"chut1", "chcls1", "pcbt1", "pcet1", "cbtm1", "cetm1", "chtyp1",
"chut2", "chcls2", "pcbt2", "pcet2", "cbtm2", "cetm2", "chtyp2",
"dely_code", "dely_strt", "dely_dura", "dely_remc",
"abtm_a", "abtm_d", "chot_on", "chot_off",
"rout_path", "erut_path",
)
/** v2 写路径守卫(P4 退场后):persistNextStates 是唯一写路径,legacy upsert 入口已从接口删除。 */
class GuardedFlightSchd(
private val inner: FlightSchdRepository = StubFlightSchd(),
) : FlightSchdRepository {
var legacySnapshotCalls = 0
var legacyIncrementalCalls = 0
var persistNextStatesCalls = 0
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, FlightFields>>, now: Instant) {
legacySnapshotCalls++
throw AssertionError("v2 path must not call upsertSnapshotBatch")
}
override fun upsertIncremental(changes: List<FlightChange>, now: Instant) {
legacyIncrementalCalls++
throw AssertionError("v2 path must not call upsertIncremental")
}
override fun persistNextStates(day: String?, states: List<FlightNextState>, snapshotReplace: Boolean, now: Instant) {
persistNextStatesCalls++
inner.persistNextStates(day, states, snapshotReplace, now)
@@ -1,156 +0,0 @@
package com.gzzn.omms.msgexchange.tools
import com.gzzn.omms.msgexchange.infra.persistence.jdbc.FlightSchdReadAssembler
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* FS7 v2 双读对拍:明细表组装 vs legacy 槽位列组装。
* 反例来自 review-flight-state-2026-09-08 §12.1 / SIS §3.34.3G28/G33/G23)。
*/
class FlightStoreDualReadTest {
private val tool = FlightStoreDiffTool()
private val flid = "TEST_DUAL_READ"
private fun detailView(vararg collections: Pair<String, String>) = mapOf(
flid to linkedMapOf(
"FLID" to flid,
*collections,
),
)
private fun legacyView(vararg collections: Pair<String, String>) = mapOf(
flid to linkedMapOf(
"FLID" to flid,
*collections,
),
)
@Test
fun `Dual read GREEN when detail and legacy slot views agree within slot capacity`() {
val gtdt = """[{"GTNO":"1","GATE":"G28","PGOT":"07SEP260800"},{"GTNO":"2","GATE":"G33"}]"""
val detail = detailView("GTDT" to gtdt)
val legacy = legacyView("GTDT" to gtdt)
val report = tool.diffDualRead(detail, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
}
@Test
fun `Dual read RED for SIS three-gate sample G28 G33 G23 - third gate must not be ignored`() {
val detailGtdt = """[
{"GTNO":"1","GATE":"G28"},
{"GTNO":"2","GATE":"G33"},
{"GTNO":"3","GATE":"G23"}
]""".replace("\n", "").replace(" ", "")
val legacyGtdt = """[{"GTNO":"1","GATE":"G28"},{"GTNO":"2","GATE":"G33"}]"""
val report = tool.diffDualRead(
detailView("GTDT" to detailGtdt),
legacyView("GTDT" to legacyGtdt),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path == "GTDT" })
}
@Test
fun `Dual read RED for non-contiguous GTNO=3 - must not rewrite to GTNO=1`() {
val detailGtdt = """[{"GTNO":"3","GATE":"G28"}]"""
val legacyGtdt = """[{"GTNO":"1","GATE":"G28"}]"""
val report = tool.diffDualRead(
detailView("GTDT" to detailGtdt),
legacyView("GTDT" to legacyGtdt),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path?.startsWith("GTDT") == true })
}
@Test
fun `Dual read RED for duplicate CHKC different cabin classes - legacy dedupe loses row`() {
val detailCkdt = """[
{"CKNO":"1","CHKC":"01","CCLS":"F"},
{"CKNO":"2","CHKC":"01","CCLS":"Y"}
]""".replace("\n", "").replace(" ", "")
val legacyCkdt = """[{"CKNO":"1","CHKC":"01","CCLS":"F"}]"""
val report = tool.diffDualRead(
detailView("CKDT" to detailCkdt),
legacyView("CKDT" to legacyCkdt),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path == "CKDT" })
}
@Test
fun `Dual read RED for two bridge ops with ABDG - legacy milestone view drops bridge id`() {
val detailAbtm = """[
{"ASNO":"1","ABDG":"B12","ABOP":"A","AOTM":"07SEP260730"},
{"ASNO":"2","ABDG":"B15","ABOP":"D","AOTM":"07SEP261200"}
]""".replace("\n", "").replace(" ", "")
val legacyAbtm = """[
{"ASNO":"1","ABOP":"A","AOTM":"07SEP260730"},
{"ASNO":"2","ABOP":"D","AOTM":"07SEP261200"}
]""".replace("\n", "").replace(" ", "")
val report = tool.diffDualRead(
detailView("ABTM" to detailAbtm),
legacyView("ABTM" to legacyAbtm),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path?.contains("ABDG") == true || it.path?.startsWith("ABTM") == true })
}
@Test
fun `Dual read RED for two DELY reasons - legacy keeps only first`() {
val detailDely = """[
{"DLNO":"1","CODE":"01","STRT":"07SEP260800","DURA":"30"},
{"DLNO":"2","CODE":"02","STRT":"07SEP260900","DURA":"15"}
]""".replace("\n", "").replace(" ", "")
val legacyDely = """[{"CODE":"01","STRT":"07SEP260800","DURA":"30"}]"""
val report = tool.diffDualRead(
detailView("DELY" to detailDely),
legacyView("DELY" to legacyDely),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path == "DELY" })
}
@Test
fun `FlightSchdReadAssembler exposes lossy legacy slot view for three-gate SIS sample`() {
val row = mapOf(
"GATE1" to "G28",
"GATE2" to "G33",
)
val detailCollections = mapOf(
"GTDT" to """[
{"GTNO":"1","GATE":"G28"},
{"GTNO":"2","GATE":"G33"},
{"GTNO":"3","GATE":"G23"}
]""".replace("\n", "").replace(" ", ""),
)
val detailView = FlightSchdReadAssembler.assembleDetailOnly(flid, row, detailCollections)
val legacyView = FlightSchdReadAssembler.assembleLegacySlotsOnly(flid, row)
val report = tool.diffDualRead(mapOf(flid to detailView), mapOf(flid to legacyView))
assertFalse(report.isGreen)
assertTrue(legacyView["GTDT"]!!.contains("G23").not())
assertTrue(detailView["GTDT"]!!.contains("G23"))
}
@Test
fun `FlightSchdReadAssembler merged view follows detail table when present`() {
val row = mapOf("GATE1" to "OLD")
val detailCollections = mapOf(
"GTDT" to """[{"GTNO":"1","GATE":"G28"}]""",
)
val merged = FlightSchdReadAssembler.assembleMerged(flid, row, detailCollections)
assertEquals(detailCollections["GTDT"], merged["GTDT"])
}
}