Files
msgexchange-v2/src/test/kotlin/com/gzzn/omms/msgexchange/processing/FlightSchdInvariantTest.kt
T
windyboy 7c2d22e8f1 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 全绿
2026-09-08 17:44:20 +08:00

356 lines
17 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
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
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZoneId
import java.util.TimeZone
/**
* U09 / U29 不变量门禁测试(ACM2-28 FS6 核心验收门槛):
* 1. I2 不变量:航班状态变更、MSG_EVENT 插入、PROC_STATE 终态在单事务原子提交;失败整体回滚重放;
* 2. 门槛 1(崩溃幂等无自增):重放相同快照报文后 SCHD_GEN.VERSION 绝不发生二次自增;
* 3. 门槛 2(CAS 防并发):伪造版本过期模拟并发快照,准确拦截 CAS 冲突并转入 FAILED(INFRA) 与退避;
* 4. 门槛 3ADFT 存活保障):增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删;跨代迁移保护;
* 5. UTC 方言测试:非 UTC JVM 时区下读写与幂等判据无漂移。
*/
class FlightSchdInvariantTest {
private lateinit var flightSchd: StubFlightSchd
private lateinit var procState: StubProcState
private lateinit var msgEvents: StubMsgEvents
private lateinit var reqTrack: StubReqTrack
private lateinit var txManager: StubPipelineTransactionManager
private lateinit var inbox: FakeInbox
private lateinit var clock: MutableClock
private lateinit var scheduler: FailureScheduler
private lateinit var procFailure: ProcFailure
private lateinit var props: PipelineProps
class FakeInbox : CminmsgInboxRepository {
val raws = mutableMapOf<Long, String>()
val backfilled = mutableListOf<Long>()
override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
backfilled.add(cminmsgsId)
}
}
class SimpleCodec(private val decodedMsg: DecodedMessage) : XmlCodec {
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decodedMsg)
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
@BeforeEach
fun setUp() {
flightSchd = StubFlightSchd()
procState = StubProcState()
msgEvents = StubMsgEvents()
reqTrack = StubReqTrack()
txManager = StubPipelineTransactionManager()
inbox = FakeInbox()
clock = MutableClock(MutableClock.BASE)
props = PipelineProps()
scheduler = FailureScheduler(props, clock)
procFailure = ProcFailure(procState, scheduler)
SnapshotFlow.StageResult.parser = null
}
@AfterEach
fun tearDown() {
SnapshotFlow.StageResult.parser = null
}
// =========================================================================
// I2 不变量测试:普通报文事务 2 扩展(变更 + 事件 + SUCCEEDED 原子提交)
// =========================================================================
@Test
fun `I2 - MessageProcessor atomically commits flightChanges, msgEvents, and SUCCEEDED`() {
val headId = 101L
inbox.raws[headId] = "<MSG><FLID>F101</FLID></MSG>"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "FLOP", "DELY", 1L, 20260907120000L)
val decoded = DecodedMessage(meta, MsgKind.Flop("DELY"), "<MSG><FLID>F101</FLID></MSG>")
val codecHolder = CodecHolder(SimpleCodec(decoded))
val handler = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision {
return Decision(
flightChanges = listOf(FlightChange("F101", mapOf("FLID" to "F101", "STAT" to "DELAYED"))),
msgNotifies = listOf(NotifyPayload("""{"flid":"F101","event":"DELAY"}""")),
schdPush = listOf(SchdPush("F101", """{"FLID":"F101","STAT":"DELAYED"}""")),
)
}
}
val handlerHolder = HandlerHolder(HandlerRegistry(listOf(handler)))
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents, codecHolder, handlerHolder,
flightSchd, snapshotFlow, procFailure, props, txManager,
)
val head = procState.headUnfinished()!!
processor.processOne(head)
// 断言:FLIGHT_SCHD 有更新(字段列 DELAYED 已落库)
val fields = flightSchd.findByFlid("F101")
assertNotNull(fields)
assertEquals("DELAYED", fields!!["STAT"])
// 断言:MSG_EVENT 写入了 KAFKA_MSG 和 KAFKA_SCHD 两条事件
val evtMsg = msgEvents.headUnsent(Targets.KAFKA_MSG)
assertNotNull(evtMsg)
val evtSchd = msgEvents.headUnsent(Targets.KAFKA_SCHD)
assertNotNull(evtSchd)
// 断言:伴生状态更新为 SUCCEEDED
val currentHead = procState.headUnfinished()
assertNull(currentHead) // 无未完成队头,说明 101 已终态
// 断言:共享信箱 backfill 成功调用
assertTrue(inbox.backfilled.contains(headId))
}
// =========================================================================
// FS6 门槛 1:崩溃幂等无自增(重放相同快照报文,SCHD_GEN.VERSION 绝不发生二次自增)
// =========================================================================
@Test
fun `Threshold 1 - Crash idempotency without version re-increment`() {
val headId = 201L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 1L, 20260907030000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
// 配置 staging 解析模拟产出 2 条航班
val flights = listOf(
"FL_01" to mapOf("FLID" to "FL_01", "FLNO" to "CA1234"),
"FL_02" to mapOf("FLID" to "FL_02", "FLNO" to "MU5678"),
)
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 模拟快照单事务内崩溃注入(例如在提交前崩溃回滚)
var crashInjected = true
val rollbackTxManager = object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T {
if (crashInjected) {
try {
block()
} finally {
// 事务回滚:清除未提交修改并恢复状态
flightSchd.clear()
procState.update(headId, ProcStatus.PENDING)
}
throw RuntimeException("crash-before-pg-commit")
}
return block()
}
}
val snapshotFlowCrashing = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, rollbackTxManager, inbox)
// 首次运行:事务内崩溃注入
val head1 = procState.headUnfinished()!!
try {
snapshotFlowCrashing.publishSnapshot(head1, decoded)
} catch (_: RuntimeException) {
// 崩溃发生
}
// 崩溃后断言:由于 PG 单事务回滚,SCHD_GEN 不存在半成品中间态,版本未推进
assertNull(flightSchd.getGen(day))
// 重放相同报文(正常完成)
crashInjected = false
val snapshotFlowNormal = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val headReplay = procState.headUnfinished()!!
snapshotFlowNormal.publishSnapshot(headReplay, decoded)
// 门槛 1 断言:版本绝不发生二次自增,版本号精确为 1L(根除旧 Redis 两阶段二次自增缺陷)
val genAfterReplay = flightSchd.getGen(day)
assertNotNull(genAfterReplay)
assertEquals(1L, genAfterReplay!!.version)
assertEquals(setOf("FL_01", "FL_02"), genAfterReplay.flids)
}
// =========================================================================
// FS6 门槛 2:CAS 防并发(伪造版本过期模拟并发快照,拦截 CAS 冲突并转入 FAILED(INFRA)
// =========================================================================
@Test
fun `Threshold 2 - CAS prevents concurrency and enters FAILED INFRA`() {
val headId = 202L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 2L, 20260907033000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val flights = listOf("FL_01" to mapOf("FLID" to "FL_01"))
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 先预置日代版本为 5L(模拟另一并发实例已经推进了版本)
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD")))
// 构造一个在读取版本后版本被篡改的场景(模拟读到 5L 后,外部并发变成了 6L)
val mockFlightSchd = object : FlightSchdRepository by flightSchd {
override fun getGen(day: String): FlightSchdRepository.GenMeta? {
// 模拟读取时返回版本 5L
return FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD"))
}
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
// 模拟 CAS 校验失败(数据库已被并发推进,expected 5 已过期)
return false
}
}
val snapshotFlow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 2 断言:CAS 冲突被拦截,状态转换为 FAILED(INFRA),带有退避与告警错误信息
val failedHead = procState.headUnfinished()!!
assertEquals(ProcStatus.FAILED, failedHead.state)
assertEquals(ErrorClass.INFRA, failedHead.errorClass)
assertTrue(failedHead.lastError?.contains("gen-cas-conflict") == true)
assertNotNull(failedHead.nextAttemptAt)
}
// =========================================================================
// FS6 门槛 3ADFT 存活保障(增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删)
// =========================================================================
@Test
fun `Threshold 3 - ADFT flights survive subsequent DNLD replacement`() {
val day = "2026-09-07"
// 1. 增量更新写入一条临时加飞航班 ADFT(FDAY 为 NULL
val adftChange = FlightChange(flid = "ADFT_888", fields = mapOf("FLID" to "ADFT_888", "FLTY" to "ADFT"))
seedIncremental(flightSchd, listOf(adftChange))
// 2. 写入旧代的一条定期计划航班 REG_OLDFDAY 为 2026-09-07
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 也不在新代中)
val headId = 203L
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 3L, 20260907040000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val newFlights = listOf("REG_NEW" to mapOf("FLID" to "REG_NEW", "FLTY" to "REG"))
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, newFlights) }
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 3 核心断言:
// ① 旧代航班 REG_OLD 被按代差删清除
assertNull(flightSchd.findByFlid("REG_OLD"))
// ② 新代航班 REG_NEW 成功写入
assertNotNull(flightSchd.findByFlid("REG_NEW"))
// ③ 增量 ADFT_888 航班由于 FDAY=NULL 天然存活、绝不被误删!
val adftRecord = flightSchd.findByFlid("ADFT_888")
assertNotNull(adftRecord)
assertEquals("ADFT_888", adftRecord!!["FLID"])
}
@Test
fun `Threshold 3 - Migrated flight across days is protected from diff deletion of old day`() {
// 场景:航班 FL_MIG 原属 2026-09-07,后来迁移到了 2026-09-08FDAY 更新为 09-08
val dayOld = "2026-09-07"
val dayNew = "2026-09-08"
// 初始属旧代
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
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"))
// 断言:FL_MIG 虽在差集,但由于 FDAY 已迁移至 09-08,受到域化差删保护,删除数为 0,记录依然存活!
assertEquals(0, deleted)
assertNotNull(flightSchd.findByFlid("FL_MIG"))
}
// =========================================================================
// UTC 时区规范测试:非 UTC JVM 默认时区与会话下写入读取无漂移
// =========================================================================
@Test
fun `UTC Dialect - Timestamps and instant evaluations are immune to JVM timezone drift`() {
val originalTz = TimeZone.getDefault()
try {
// 切换 JVM 默认时区为非 UTC(东京 +09:00 与 纽约 -05:00
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val now = Instant.parse("2026-09-07T08:00:00.123456Z")
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")
assertNotNull(gen)
assertEquals(now, gen!!.updatedAt)
// 再次切换到西五区
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
val genNy = flightSchd.getGen("2026-09-07")
assertNotNull(genNy)
assertEquals(now, genNy!!.updatedAt) // 绝无时区漂移
} finally {
TimeZone.setDefault(originalTz)
}
}
}