- 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 全绿
356 lines
17 KiB
Kotlin
356 lines
17 KiB
Kotlin
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. 门槛 3(ADFT 存活保障):增量 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 门槛 3:ADFT 存活保障(增量 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_OLD(FDAY 为 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-08(FDAY 更新为 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)
|
||
}
|
||
}
|
||
}
|