refactor(flight-state): 按 flight-state.md 审计定稿全量重构脚手架与 SQL (ACM2-31)
- SQL 基线 V1__flight_state_baseline.sql 整体取代 V1.0.0–V1.4.0: PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD + 8 张资源明细表 + FLIGHT_ROUTE_POINT + SCHD_SNAP_LOG 留痕层 - 废除 FDAY 日代/SCHD_GEN/名单差删:OPERATION_DAY 不可变(应用层校验 + 条件更新强化 §7.4),STATE 仅 ACTIVE/DELETED,物理清除只在历史归档后 - 处理器化:applyScheduleRecords(§5.1 七步同一事务,重放判定/整包 DEAD(PROTOCOL)/归属冲突不落地)+ FLOP/FDEL/ADFT(tombstone 仅 ACTIVE→DELETED,重复 FDEL 幂等不推进版本) - 投递:KAFKA_SCHD 同 FLID 按最新 STATE_VERSION 合并,被压掉事件关闭, TOMBSTONE 发 null 值消息(键缺失=删除旧值 §7.3) - 回填待办改为业务事务内预登记,消除提交后写待办的崩溃窗口(§7.2/§10) - XML 解码改为 jackson-dataformat-xml 数据类直接映射(SIS 信封强类型, FLTR 开放标签泛型承载) - 历史归档/物理清除顺序不可颠倒:归档确认成功集才物理删除,未接通删 0 条 - 移除 PUMP_JOB 队列/ReferenceService/FlightStoreDiffTool 等旧机制与测试, 新增运营日/引擎/快照/FDEL/归档顺序不变性回归测试
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.EventType
|
||||
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.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.domain.Targets
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
|
||||
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
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* FDEL(§6.2)+ ADFT(§6.3/§2.1)不变量:
|
||||
* ACTIVE→DELETED 发布一次 tombstone 且明细保留;重复/迟到 FDEL 幂等不推进版本;
|
||||
* ADFT 重激活恢复 ACTIVE;新实例建立;缺失标量不误删(§2.1 保守语义)。
|
||||
*/
|
||||
class FdelAndAdftProcessorTest {
|
||||
|
||||
private val msgId = 7L
|
||||
|
||||
private fun head() = ProcState(msgId, ProcStatus.PENDING)
|
||||
|
||||
private fun msg() = DecodedMessage(
|
||||
meta = MetaFields("AODB", "FDEL", "", 9L, 1L),
|
||||
kind = MsgKind.Fdel,
|
||||
rawXml = "<MSG/>",
|
||||
body = FlopPayload("121"),
|
||||
)
|
||||
|
||||
private fun flights(): StubFlightState {
|
||||
val f = StubFlightState()
|
||||
val outcome = f.persistFullState(
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
|
||||
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 4,
|
||||
mapOf("FLNO" to "CA001"), emptyMap(),
|
||||
),
|
||||
msgId = 1,
|
||||
now = java.time.Instant.now(),
|
||||
)
|
||||
check(outcome != PersistOutcome.DAY_GUARD_VIOLATION)
|
||||
return f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active flight deletion publishes single tombstone and keeps details`() {
|
||||
val f = flights()
|
||||
val events = StubMsgEvents()
|
||||
val todo = StubBackfillTodo()
|
||||
|
||||
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, todo, ObjectMapper())
|
||||
.apply(head(), msg(), FlopPayload("121"))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
assertEquals(FlightState.DELETED, f.findMainRow("121")!!.state)
|
||||
assertEquals(5L, f.findMainRow("121")!!.stateVersion)
|
||||
val tombstones = events.rows.values.filter { it.eventType == EventType.TOMBSTONE }
|
||||
assertEquals(1, tombstones.size)
|
||||
assertEquals(Targets.KAFKA_SCHD, tombstones.single().target)
|
||||
assertTrue(tombstones.single().payloadJson.contains("\"deleted\":true"))
|
||||
assertEquals(1, todo.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated FDEL is idempotent without version bump or duplicate event`() {
|
||||
val f = flights()
|
||||
val events = StubMsgEvents()
|
||||
val proc = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
|
||||
|
||||
proc.apply(head(), msg(), FlopPayload("121"))
|
||||
val version = f.findMainRow("121")!!.stateVersion
|
||||
val result = proc.apply(head(), msg(), FlopPayload("121"))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result) // §6.2 步骤 3:幂等成功
|
||||
assertEquals(version, f.findMainRow("121")!!.stateVersion)
|
||||
assertEquals(1, events.rows.values.count { it.eventType == EventType.TOMBSTONE })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FDEL of unknown flight is idempotent success`() {
|
||||
val f = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
|
||||
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
|
||||
.apply(head(), msg(), FlopPayload("999"))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result) // §6.2 步骤 4:迟到不报错
|
||||
assertEquals(0, events.rows.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ADFT revives deleted flight and sets provided scalars`() {
|
||||
val f = flights()
|
||||
f.markDeleted("121", msgId = 2, now = java.time.Instant.now())
|
||||
val events = StubMsgEvents()
|
||||
val adft = AdftProcessor(
|
||||
StubPipelineTx(), StubPipelineLock(), f, events, null,
|
||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
||||
)
|
||||
val record = com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(
|
||||
"121", mapOf("SODT" to "15DEC261723", "FLNO" to "CA002"),
|
||||
)
|
||||
|
||||
val result = adft.apply(head(), msg(), record)
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
val main = f.findMainRow("121")!!
|
||||
assertEquals(FlightState.ACTIVE, main.state) // §6.3 生命周期重激活
|
||||
assertEquals(LocalDate.of(2026, 12, 15), main.operationDay)
|
||||
assertEquals("CA002", f.loadFullSnapshot("121")!!.scalars["FLNO"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ADFT of unknown flight creates new instance with operation day from SODT`() {
|
||||
val f = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val adft = AdftProcessor(
|
||||
StubPipelineTx(), StubPipelineLock(), f, events, null,
|
||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
||||
)
|
||||
|
||||
val result = adft.apply(
|
||||
head(), msg(),
|
||||
com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord("555", mapOf("SODT" to "20DEC261723")),
|
||||
)
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
val main = f.findMainRow("555")!!
|
||||
assertEquals(FlightState.ACTIVE, main.state)
|
||||
assertEquals(LocalDate.of(2026, 12, 20), main.operationDay) // §2.1:含 SODT 直接计算
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ADFT merge does not clear absent scalars`() {
|
||||
val f = StubFlightState()
|
||||
f.persistFullState(
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
|
||||
"555", LocalDate.of(2026, 12, 20), FlightState.ACTIVE, 2,
|
||||
mapOf("FLNO" to "XX100", "REMC" to "keep-me"), emptyMap(),
|
||||
),
|
||||
msgId = 1, now = java.time.Instant.now(),
|
||||
)
|
||||
val adft = AdftProcessor(
|
||||
StubPipelineTx(), StubPipelineLock(), f, StubMsgEvents(), null,
|
||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
||||
)
|
||||
|
||||
adft.apply(head(), msg(), com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord("555", mapOf("FLNO" to "XX200")))
|
||||
|
||||
val scalars = f.loadFullSnapshot("555")!!.scalars
|
||||
assertEquals("XX200", scalars["FLNO"])
|
||||
assertEquals("keep-me", scalars["REMC"]) // §2.1 保守 Set-only:缺失不 Clear
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun unused() {
|
||||
MsgEvent(target = "x", partitionKey = "y", payloadJson = "")
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
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.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
|
||||
import com.gzzn.omms.msgexchange.processing.handlers.GtdtHandler
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** P2-0-1:GTDT 报文端到端 SUCCEEDED + 三门读回(真实 XmlCodec + GtdtHandler)。 */
|
||||
class GtdtPipelineTest {
|
||||
|
||||
@Test
|
||||
fun `GTDT XML end-to-end SUCCEEDED with three gates persisted`() {
|
||||
val raw = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MSG>
|
||||
<META>
|
||||
<SNDR>AODB</SNDR><SEQN>9001</SEQN><DTTM>20260908120000</DTTM>
|
||||
<TYPE>FLOP</TYPE><STYP>GTDT</STYP>
|
||||
</META>
|
||||
<FLOP>
|
||||
<FLID>FL_GTDT_E2E</FLID>
|
||||
<GTDT GTNO="1"><GATE>A1</GATE><GTYP>D</GTYP></GTDT>
|
||||
<GTDT GTNO="3"><GATE>B2</GATE><GTYP>I</GTYP></GTDT>
|
||||
<GTDT GTNO="7"><GATE>C3</GATE><GTYP>D</GTYP></GTDT>
|
||||
</FLOP>
|
||||
</MSG>
|
||||
""".trimIndent()
|
||||
|
||||
val procState = StubProcState()
|
||||
val flightSchd = StubFlightSchd()
|
||||
val msgEvents = StubMsgEvents()
|
||||
val reqTrack = StubReqTrack()
|
||||
val txManager = object : PipelineTransactionManager {
|
||||
override fun <T> inTransaction(block: () -> T): T = block()
|
||||
}
|
||||
val inbox = object : CminmsgInboxRepository {
|
||||
override fun insertRaw(rawXml: String) = 1L
|
||||
override fun rawOf(cminmsgsId: Long) = raw
|
||||
override fun pollUnprocessed(afterId: Long, limit: Int) = emptyList<Long>()
|
||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
|
||||
}
|
||||
val clock = MutableClock(MutableClock.BASE)
|
||||
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
|
||||
val codec = JacksonXmlCodec()
|
||||
val handler = GtdtHandler()
|
||||
val snapshot = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
|
||||
val processor = MessageProcessor(
|
||||
inbox, procState, msgEvents,
|
||||
CodecHolder(codec),
|
||||
HandlerHolder(HandlerRegistry(listOf(handler))),
|
||||
flightSchd, snapshot, procFailure, PipelineProps(), txManager,
|
||||
)
|
||||
|
||||
val headId = 42L
|
||||
procState.insert(headId, ProcStatus.PENDING)
|
||||
processor.processOne(ProcState(headId, ProcStatus.PENDING))
|
||||
|
||||
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
|
||||
val next = flightSchd.findNextStateByFlid("FL_GTDT_E2E")!!
|
||||
assertEquals(3, next.collections["GTDT"]!!.size)
|
||||
assertEquals("3", next.collections["GTDT"]!![1]["GTNO"])
|
||||
assertEquals("C3", next.collections["GTDT"]!![2]["GATE"])
|
||||
assertEquals(1L, next.stateVersion)
|
||||
|
||||
val roundtrip = FlightStateEngine.fromFlightFields("FL_GTDT_E2E", flightSchd.findByFlid("FL_GTDT_E2E")!!)
|
||||
assertEquals(3, roundtrip.collections["GTDT"]!!.size)
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import com.gzzn.omms.msgexchange.codec.DecodeFailure
|
||||
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.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
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.BackfillTodoRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* U08/U10/U11:processOne 失败闭环——
|
||||
* ① 意外异常在边界内写 FAILED(INFRA)+退避,重试达上限转 DEAD(EXHAUSTED)(Pump 内部异常不杀泵);
|
||||
* ② 未注册/CODEC_ERROR/staging 未实装 → FAILED(绝不直接终态);MALFORMED → DEAD;
|
||||
* ③ InterruptedException/致命 Error 不被普通恢复逻辑吞掉。
|
||||
*/
|
||||
class MessageProcessorTest {
|
||||
|
||||
private class FakeProcState : ProcStateRepository {
|
||||
var record = mutableMapOf<Long, ProcState>()
|
||||
val bound = mutableMapOf<String, Long>()
|
||||
|
||||
override fun insert(cminmsgsId: Long, state: ProcStatus) { record[cminmsgsId] = ProcState(cminmsgsId, state) }
|
||||
|
||||
override fun exists(cminmsgsId: Long): Boolean = record.containsKey(cminmsgsId)
|
||||
|
||||
override fun headUnfinished(): ProcState? = null
|
||||
|
||||
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
|
||||
val owner = bound[identityKey]
|
||||
if (owner != null && owner != cminmsgsId) return false
|
||||
bound[identityKey] = cminmsgsId
|
||||
record[cminmsgsId] = record[cminmsgsId]?.copy(identityKey = identityKey) ?: ProcState(cminmsgsId, ProcStatus.PENDING, identityKey = identityKey)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey]
|
||||
|
||||
override fun update(
|
||||
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
|
||||
errorClass: ErrorClass?, lastError: String?,
|
||||
) {
|
||||
val old = record[cminmsgsId] ?: ProcState(cminmsgsId, state)
|
||||
record[cminmsgsId] = old.copy(
|
||||
state = state,
|
||||
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
|
||||
attempts = attempts ?: old.attempts,
|
||||
errorClass = errorClass ?: old.errorClass,
|
||||
lastError = lastError ?: old.lastError,
|
||||
)
|
||||
}
|
||||
|
||||
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
||||
var n = 0
|
||||
record.keys.toList().forEach { id ->
|
||||
val s = record[id]!!
|
||||
if (s.errorClass != null && s.errorClass in errorClasses &&
|
||||
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
|
||||
record[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
fun state(id: Long): ProcState = record.getValue(id)
|
||||
}
|
||||
|
||||
private class FakeInbox : CminmsgInboxRepository {
|
||||
var raws = mutableMapOf<Long, String>()
|
||||
var throwOnRawOf: Throwable? = null
|
||||
var throwOnBackfill: Throwable? = null
|
||||
val backfilled = mutableListOf<List<Any?>>()
|
||||
|
||||
override fun insertRaw(rawXml: String): Long = 0
|
||||
|
||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
|
||||
|
||||
override fun rawOf(cminmsgsId: Long): String? {
|
||||
throwOnRawOf?.let { throw it }
|
||||
return raws[cminmsgsId]
|
||||
}
|
||||
|
||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
|
||||
throwOnBackfill?.let { throw it }
|
||||
backfilled += listOf(cminmsgsId, sndr, type, styp, seqn)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeEvents : MsgEventRepository {
|
||||
val inserted = mutableListOf<List<MsgEvent>>()
|
||||
|
||||
override fun insertAll(events: List<MsgEvent>): List<Long> { inserted += events; return events.indices.map { it.toLong() + 1 } }
|
||||
|
||||
override fun headUnsent(target: String): MsgEvent? = null
|
||||
override fun claimBatch(target: String, limit: Int): List<MsgEvent> = emptyList()
|
||||
override fun markSent(eventId: Long) = Unit
|
||||
override fun markAllSent(eventIds: List<Long>) = Unit
|
||||
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) = Unit
|
||||
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) = Unit
|
||||
override fun insertSync(events: List<MsgEvent>) = Unit
|
||||
}
|
||||
|
||||
private class FakeCodec : XmlCodec {
|
||||
var result: DecodeResult = DecodeResult.Ok(
|
||||
DecodedMessage(
|
||||
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = 1L, dttm = 20260906120000L),
|
||||
kind = MsgKind.Flop("DELY"),
|
||||
rawXml = "<MSG/>",
|
||||
),
|
||||
)
|
||||
override fun decode(rawXml: String): DecodeResult = result
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
}
|
||||
|
||||
private class FakeReqTrack : ReqTrackRepository {
|
||||
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? = null
|
||||
override fun forceExpireOpenOf(kind: String) = Unit
|
||||
override fun insert(kind: String, paramsJson: String): Long = 1L
|
||||
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) = Unit
|
||||
override fun markSent(reqId: Long, sentAt: Instant) = Unit
|
||||
override fun expireIfWaiting(reqId: Long) = Unit
|
||||
override fun markDone(reqId: Long) = Unit
|
||||
}
|
||||
|
||||
private class FakeFlightSchd : FlightSchdRepository {
|
||||
val flights = mutableMapOf<String, FlightFields>()
|
||||
val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
|
||||
val incrementalChanges = mutableListOf<com.gzzn.omms.msgexchange.domain.FlightChange>()
|
||||
|
||||
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> =
|
||||
flids.mapNotNull { f -> flights[f]?.let { f to it } }.toMap()
|
||||
override fun findByDay(day: String): List<Pair<String, FlightFields>> = flights.map { it.key to it.value }
|
||||
override fun findAll(): Map<String, FlightFields> = flights.toMap()
|
||||
override fun deleteByFlids(flids: Set<String>): Int = 0
|
||||
override fun getGen(day: String): FlightSchdRepository.GenMeta? = gens[day]
|
||||
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
|
||||
val cur = gens[day]?.version ?: 0L
|
||||
if (cur != expected) return false
|
||||
gens[day] = newGen
|
||||
return true
|
||||
}
|
||||
override fun deleteGenBefore(cutoffDay: String): Int = 0
|
||||
override fun findNextStateByFlid(flid: String): com.gzzn.omms.msgexchange.domain.flight.FlightNextState? =
|
||||
flights[flid]?.let { com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.fromFlightFields(flid, it) }
|
||||
override fun persistNextStates(
|
||||
day: String?,
|
||||
states: List<com.gzzn.omms.msgexchange.domain.flight.FlightNextState>,
|
||||
snapshotReplace: Boolean,
|
||||
now: Instant,
|
||||
) {
|
||||
states.forEach { state ->
|
||||
val scalarFields = linkedMapOf("FLID" to state.flid)
|
||||
state.scalars.forEach { (k, v) -> scalarFields[k] = v }
|
||||
flights[state.flid] = scalarFields + state.collections.mapValues { (_, items) ->
|
||||
com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeTxManager : PipelineTransactionManager {
|
||||
override fun <T> inTransaction(block: () -> T): T = block()
|
||||
}
|
||||
// ---------- helpers ----------
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
|
||||
private fun msg(seqn: String) = DecodedMessage(
|
||||
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = seqn.toLong(), dttm = 20260906120000L),
|
||||
kind = MsgKind.Flop("DELY"),
|
||||
rawXml = "<MSG/>",
|
||||
)
|
||||
|
||||
private fun head(id: Long = 1, status: ProcStatus = ProcStatus.PENDING, attempts: Int = 0, identityKey: String? = null) =
|
||||
ProcState(id, status, identityKey = identityKey, attempts = attempts)
|
||||
|
||||
private fun deliveHandler() = object : Handler {
|
||||
override val kind: MsgKind = MsgKind.Flop("DELY")
|
||||
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision = Decision(
|
||||
msgNotifies = listOf(NotifyPayload("""{"n":1}""")),
|
||||
schdPush = listOf(SchdPush(flid = "F1", payloadJson = """{"FLID":"F1","v":2}""")),
|
||||
)
|
||||
}
|
||||
|
||||
private fun processor(
|
||||
procState: FakeProcState, inbox: FakeInbox, events: FakeEvents, codec: FakeCodec,
|
||||
registry: HandlerRegistry = HandlerRegistry(emptyList()),
|
||||
backfillTodo: BackfillTodoRepository? = null,
|
||||
): MessageProcessor {
|
||||
val props = PipelineProps()
|
||||
val scheduler = FailureScheduler(props, clock)
|
||||
val procFailure = ProcFailure(procState, scheduler)
|
||||
val flightSchd = FakeFlightSchd()
|
||||
val txManager = FakeTxManager()
|
||||
val snapshot = SnapshotFlow(procState, flightSchd, events, FakeReqTrack(), procFailure, txManager, inbox, backfillTodo = backfillTodo)
|
||||
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), flightSchd, snapshot, procFailure, props, txManager, backfillTodo)
|
||||
}
|
||||
// ---------- tests ----------
|
||||
@Test
|
||||
fun `no handler is FAILED UNSUPPORTED with backoff - never terminal`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
|
||||
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
|
||||
|
||||
val s = ps.state(1)
|
||||
assertEquals(ProcStatus.FAILED, s.state)
|
||||
assertEquals(ErrorClass.UNSUPPORTED, s.errorClass)
|
||||
assertEquals(1, s.attempts)
|
||||
assertNotNull(s.nextAttemptAt)
|
||||
assertTrue(s.lastError?.startsWith("no-handler:FLOP-DELY") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CODEC_ERROR decode failure is FAILED retryable`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
|
||||
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "boom")) }
|
||||
processor(ps, inbox, ev, codec).processOne(ps.state(1))
|
||||
|
||||
val s = ps.state(1)
|
||||
assertEquals(ProcStatus.FAILED, s.state)
|
||||
assertEquals(ErrorClass.CODEC_ERROR, s.errorClass)
|
||||
assertEquals(1, s.attempts)
|
||||
assertNotNull(s.nextAttemptAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MALFORMED decode failure stays DEAD terminal`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
|
||||
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "bad-xml")) }
|
||||
processor(ps, inbox, ev, codec).processOne(ps.state(1))
|
||||
|
||||
val s = ps.state(1)
|
||||
assertEquals(ProcStatus.DEAD, s.state)
|
||||
assertEquals(ErrorClass.MALFORMED, s.errorClass)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy failed head at max attempts upgrades to DEAD at entry`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head(status = ProcStatus.FAILED, attempts = 5, identityKey = "k")
|
||||
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
|
||||
|
||||
assertEquals(ProcStatus.DEAD, ps.state(1).state)
|
||||
assertEquals(ErrorClass.EXHAUSTED, ps.state(1).errorClass)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success path inserts events backfills and marks SUCCEEDED`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
|
||||
val registry = HandlerRegistry(listOf(deliveHandler()))
|
||||
processor(ps, inbox, ev, FakeCodec(), registry).processOne(ps.state(1))
|
||||
|
||||
assertEquals(ProcStatus.SUCCEEDED, ps.state(1).state)
|
||||
val events = ev.inserted.single()
|
||||
assertEquals(listOf(Targets.KAFKA_MSG, Targets.KAFKA_SCHD), events.map { it.target })
|
||||
assertEquals("F1", events.first { it.target == Targets.KAFKA_SCHD }.partitionKey)
|
||||
assertEquals(listOf(1L, "AODB", "FLOP", "DELY", 1L), inbox.backfilled.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate identity leads to SKIPPED`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head()
|
||||
inbox.raws[1] = "<MSG/>"
|
||||
ps.bound["AODB|FLOP|DELY|1"] = 2L // 另一条消息已持有该键
|
||||
|
||||
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
|
||||
|
||||
val s = ps.state(1)
|
||||
assertEquals(ProcStatus.SKIPPED, s.state)
|
||||
assertTrue(s.lastError == "duplicate-of:2")
|
||||
assertTrue(ev.inserted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skipped duplicate still compensates mailbox backfill and persists todo on failure`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head()
|
||||
inbox.raws[1] = "<MSG/>"
|
||||
inbox.throwOnBackfill = IllegalStateException("mysql-down")
|
||||
ps.bound["AODB|FLOP|DELY|1"] = 2L
|
||||
val todo = com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo()
|
||||
|
||||
processor(ps, inbox, ev, FakeCodec(), backfillTodo = todo).processOne(ps.state(1))
|
||||
|
||||
// SKIPPED 不变,但共享信箱回填的补偿待办必须落库(否则重复报文被反复轮询)
|
||||
assertEquals(ProcStatus.SKIPPED, ps.state(1).state)
|
||||
assertEquals(1, todo.count())
|
||||
assertEquals(1L, todo.tasks().single().cminmsgsId)
|
||||
assertEquals("mysql-down", todo.lastErrorOf(1L))
|
||||
}
|
||||
|
||||
// ---------- U08 边界化(评审要求①③) ----------
|
||||
@Test
|
||||
fun `unexpected exception maps to FAILED INFRA with backoff at boundary`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head()
|
||||
inbox.throwOnRawOf = RuntimeException("db-down")
|
||||
|
||||
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
|
||||
|
||||
val s = ps.state(1)
|
||||
assertEquals(ProcStatus.FAILED, s.state)
|
||||
assertEquals(ErrorClass.INFRA, s.errorClass)
|
||||
assertEquals(1, s.attempts)
|
||||
assertNotNull(s.nextAttemptAt)
|
||||
assertTrue(s.lastError == "db-down")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated unexpected exceptions escalate to DEAD EXHAUSTED at max attempts`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head()
|
||||
inbox.throwOnRawOf = RuntimeException("db-down")
|
||||
val p = processor(ps, inbox, ev, FakeCodec())
|
||||
|
||||
var attempts = 0
|
||||
while (ps.state(1).state != ProcStatus.DEAD && attempts < 10) {
|
||||
p.processOne(ps.state(1))
|
||||
clock.advance(60_000) // 推进时钟:让退避/毒丸时间语义确定
|
||||
attempts++
|
||||
}
|
||||
|
||||
val s = ps.state(1)
|
||||
assertEquals(ProcStatus.DEAD, s.state, "should exhaust within maxAttempts(=5)")
|
||||
assertEquals(ErrorClass.EXHAUSTED, s.errorClass)
|
||||
assertTrue(s.lastError?.contains("attempts=5") == true)
|
||||
assertEquals(5, s.attempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fatal Error is not swallowed by exception recovery`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head()
|
||||
inbox.throwOnRawOf = AssertionError("fatal")
|
||||
|
||||
assertThrows(AssertionError::class.java) {
|
||||
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
|
||||
}
|
||||
// 未被普通恢复逻辑改写成 FAILED(Error 不落入 catch Exception)
|
||||
assertEquals(ProcStatus.PENDING, ps.state(1).state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InterruptedException restores flag and propagates`() {
|
||||
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
|
||||
ps.record[1] = head()
|
||||
inbox.throwOnRawOf = InterruptedException("stop")
|
||||
|
||||
assertThrows(InterruptedException::class.java) {
|
||||
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
|
||||
}
|
||||
assertTrue(Thread.interrupted(), "interrupt flag must be restored")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.gzzn.omms.msgexchange.codec.ScheduleBody
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
||||
import com.gzzn.omms.msgexchange.domain.SnapshotResult
|
||||
import com.gzzn.omms.msgexchange.domain.Targets
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* §5.1 applyScheduleRecords 不变量:重放幂等、完整性校验整包拒绝 DEAD(PROTOCOL)、
|
||||
* 归属日冲突不落地、DELETED 不恢复且记冲突告警、成功路径版本推进+事件+留痕+待办预登记。
|
||||
*/
|
||||
class ScheduleProcessorTest {
|
||||
|
||||
private val msgId = 42L
|
||||
|
||||
private fun head() = ProcState(msgId, ProcStatus.PENDING)
|
||||
|
||||
private fun message(body: ScheduleBody) = DecodedMessage(
|
||||
meta = MetaFields("AODB", "SCHD", "DNLD", 100L, 1L),
|
||||
kind = MsgKind.Schd(MsgKind.SchdSubtype.DNLD),
|
||||
rawXml = "<MSG/>",
|
||||
body = body,
|
||||
)
|
||||
|
||||
private fun makeBody(vararg recs: Pair<String, String>) = ScheduleBody(
|
||||
recsDeclared = recs.size,
|
||||
records = recs.map { (flid, sodt) ->
|
||||
com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(flid, mapOf("SODT" to sodt))
|
||||
},
|
||||
)
|
||||
|
||||
private class TxRunner(private val failOn: () -> Boolean) : PipelineTransactionManager {
|
||||
var entered = 0
|
||||
override fun <T> inTransaction(block: () -> T): T {
|
||||
entered++
|
||||
if (failOn()) throw IllegalStateException("db-down")
|
||||
return block()
|
||||
}
|
||||
}
|
||||
|
||||
private fun processor(
|
||||
proc: StubProcState = StubProcState(),
|
||||
flights: StubFlightState = StubFlightState(),
|
||||
events: StubMsgEvents = StubMsgEvents(),
|
||||
log: StubSnapshotLog = StubSnapshotLog(),
|
||||
todo: StubBackfillTodo? = StubBackfillTodo(),
|
||||
tx: PipelineTransactionManager? = null,
|
||||
): ScheduleProcessor {
|
||||
val txRunner = tx ?: object : PipelineTransactionManager {
|
||||
override fun <T> inTransaction(block: () -> T): T = block()
|
||||
}
|
||||
return ScheduleProcessor(
|
||||
txManager = txRunner,
|
||||
lock = object : PipelineLockRepository { override fun lock() = Unit },
|
||||
procState = proc,
|
||||
flightState = flights,
|
||||
msgEvents = events,
|
||||
snapshotLog = log,
|
||||
backfillTodo = todo,
|
||||
operationDayProps = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 },
|
||||
mapper = ObjectMapper(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `happy path persists snapshot with version bump, events, snap log and backfill todo`() {
|
||||
val flights = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val log = StubSnapshotLog()
|
||||
val todo = StubBackfillTodo()
|
||||
|
||||
val result = processor(flights = flights, events = events, log = log, todo = todo)
|
||||
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
val main = flights.findMainRow("121")!!
|
||||
assertEquals(1L, main.stateVersion)
|
||||
assertEquals(LocalDate.of(2026, 12, 15), main.operationDay)
|
||||
assertEquals(FlightState.ACTIVE, main.state)
|
||||
assertEquals(2, events.rows.size) // KAFKA_SCHD 整态 + KAFKA_MSG 通知
|
||||
assertEquals(1, log.entries.size)
|
||||
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
|
||||
assertEquals(1, log.entries.single().upserted)
|
||||
assertEquals(1, todo.count()) // §7.2 事务内预登记
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay of succeeded message records idempotent success without writes`() {
|
||||
val proc = StubProcState()
|
||||
proc.insert(msgId, ProcStatus.SUCCEEDED)
|
||||
val flights = StubFlightState()
|
||||
val log = StubSnapshotLog()
|
||||
|
||||
val result = processor(proc = proc, flights = flights, log = log)
|
||||
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
|
||||
|
||||
assertEquals(ApplyResult.ReplaySkipped, result) // §5.1 步骤 2
|
||||
assertNull(flights.findMainRow("121"))
|
||||
assertEquals(SnapshotResult.REPLAY_SKIPPED, log.entries.single().result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validation failure is DEAD protocol without any persistence`() {
|
||||
val flights = StubFlightState()
|
||||
val log = StubSnapshotLog()
|
||||
val schdBody = ScheduleBody(recsDeclared = 2, records = makeBody("121" to "15DEC261723").records)
|
||||
|
||||
val result = processor(flights = flights, log = log).applyScheduleRecords(head(), message(schdBody)) as ApplyResult.DeadProtocol
|
||||
assertTrue(result.flags.contains(SnapshotFlag.RECS_DROP)) // §5.2 RECS = 实收数
|
||||
assertNull(flights.findMainRow("121"))
|
||||
assertEquals(SnapshotResult.ROLLED_BACK, log.entries.single().result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same flid across operation days is rejected whole-batch without writes`() {
|
||||
val flights = StubFlightState()
|
||||
// 预置 121 已归属 12-15;报文声称 12-16 → §5.3 第四行
|
||||
flights.persistFullState(
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
|
||||
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1, mapOf("SODT" to "15DEC261723"), emptyMap(),
|
||||
),
|
||||
msgId = 1,
|
||||
now = java.time.Instant.now(),
|
||||
)
|
||||
val log = StubSnapshotLog()
|
||||
|
||||
val result = processor(flights = flights, log = log).applyScheduleRecords(
|
||||
head(), message(makeBody("121" to "16DEC261723")),
|
||||
)
|
||||
|
||||
assertTrue(result is ApplyResult.DeadProtocol)
|
||||
assertEquals(LocalDate.of(2026, 12, 15), flights.findMainRow("121")!!.operationDay) // 不迁移归属
|
||||
assertEquals(1L, flights.findMainRow("121")!!.stateVersion) // 不改写
|
||||
assertEquals(SnapshotResult.ROLLED_BACK, log.entries.single().result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DELETED flight stays deleted with revive conflict flag`() {
|
||||
val flights = StubFlightState()
|
||||
flights.persistFullState(
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
|
||||
"121", LocalDate.of(2026, 12, 15), FlightState.DELETED, 7,
|
||||
mapOf("SODT" to "15DEC261723"), emptyMap(),
|
||||
),
|
||||
msgId = 1,
|
||||
now = java.time.Instant.now(),
|
||||
)
|
||||
val log = StubSnapshotLog()
|
||||
|
||||
val result = processor(flights = flights, log = log).applyScheduleRecords(
|
||||
head(), message(makeBody("121" to "15DEC261723")),
|
||||
)
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
assertEquals(FlightState.DELETED, flights.findMainRow("121")!!.state) // §5.1 步骤 6 不恢复
|
||||
assertEquals(8L, flights.findMainRow("121")!!.stateVersion)
|
||||
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
|
||||
assertTrue(log.entries.single().flags.contains(SnapshotFlag.SCHD_REVIVE_CONFLICT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `infra failure inside transaction throws and leaves nothing persisted`() {
|
||||
val proc = StubProcState()
|
||||
val flights = StubFlightState()
|
||||
val log = StubSnapshotLog()
|
||||
val todo = StubBackfillTodo()
|
||||
proc.insert(msgId)
|
||||
val p = processor(proc = proc, flights = flights, log = log, todo = todo, tx = TxRunner { true })
|
||||
|
||||
// §9:数据库/内部故障 → 异常上抛,MessageProcessor 记 FAILED(INFRA) 退避;不写任何终态
|
||||
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) {
|
||||
p.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
|
||||
}
|
||||
assertNull(flights.findMainRow("121"))
|
||||
assertEquals(0, todo.count())
|
||||
assertEquals(0, log.entries.size)
|
||||
assertEquals(ProcStatus.PENDING, proc.find(msgId)!!.state)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
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.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
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 org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** v2 §2.6 / §5:提交后回填失败不得回滚 PROC_STATE。 */
|
||||
class SnapshotFlowBackfillTest {
|
||||
|
||||
private class FailingInbox : CminmsgInboxRepository {
|
||||
override fun insertRaw(rawXml: String): Long = 1L
|
||||
override fun rawOf(cminmsgsId: Long): String? = null
|
||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
|
||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
|
||||
throw RuntimeException("mysql-down")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backfill failure after commit leaves PROC_STATE SUCCEEDED`() {
|
||||
val flightSchd = StubFlightSchd()
|
||||
val procState = StubProcState()
|
||||
val msgEvents = StubMsgEvents()
|
||||
val reqTrack = StubReqTrack()
|
||||
val txManager = StubPipelineTransactionManager()
|
||||
val inbox = FailingInbox()
|
||||
val clock = MutableClock(MutableClock.BASE)
|
||||
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
|
||||
|
||||
val headId = 901L
|
||||
procState.insert(headId, ProcStatus.PENDING)
|
||||
val day = "2026-09-07"
|
||||
val meta = MetaFields("AODB", "SCHD", "DNLD", 9L, 20260907090000L)
|
||||
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
|
||||
SnapshotFlow.StageResult.parser = {
|
||||
SnapshotFlow.StageResult.Ok(day, listOf("FL_X" to mapOf("FLID" to "FL_X")))
|
||||
}
|
||||
|
||||
// v2 §5(P2-3):回填失败必须落持久补偿待办(否则重启后无从对账)
|
||||
val todo = com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo()
|
||||
val flow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox, backfillTodo = todo)
|
||||
flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
|
||||
|
||||
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
|
||||
assertTrue(flightSchd.findByFlid("FL_X") != null)
|
||||
assertEquals(1, todo.count())
|
||||
val recorded = todo.tasks().single()
|
||||
assertEquals(901L, recorded.cminmsgsId)
|
||||
assertEquals("SCHD", recorded.type)
|
||||
assertEquals("DNLD", recorded.styp)
|
||||
assertEquals("mysql-down", todo.lastErrorOf(901L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CAS replay requires matching lastMessageId not version alone`() {
|
||||
val base = StubFlightSchd()
|
||||
val procState = StubProcState()
|
||||
val msgEvents = StubMsgEvents()
|
||||
val reqTrack = StubReqTrack()
|
||||
val txManager = StubPipelineTransactionManager()
|
||||
val inbox = object : CminmsgInboxRepository {
|
||||
override fun insertRaw(rawXml: String) = 1L
|
||||
override fun rawOf(cminmsgsId: Long) = null
|
||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
|
||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
|
||||
}
|
||||
val clock = MutableClock(MutableClock.BASE)
|
||||
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
|
||||
|
||||
val headId = 902L
|
||||
val day = "2026-09-07"
|
||||
procState.insert(headId, ProcStatus.PENDING)
|
||||
|
||||
val conflictingGen = FlightSchdRepository.GenMeta(day, 2L, setOf("OTHER"), lastMessageId = "999")
|
||||
base.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("OTHER"), lastMessageId = "999"))
|
||||
val mockFlightSchd = object : FlightSchdRepository by base {
|
||||
override fun putGenIfVersion(
|
||||
day: String,
|
||||
expected: Long,
|
||||
newGen: FlightSchdRepository.GenMeta,
|
||||
now: java.time.Instant,
|
||||
): Boolean {
|
||||
base.putGenIfVersion(day, 1L, conflictingGen)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotFlow.StageResult.parser = {
|
||||
SnapshotFlow.StageResult.Ok(day, listOf("FL_Y" to mapOf("FLID" to "FL_Y")))
|
||||
}
|
||||
val meta = MetaFields("AODB", "SCHD", "DNLD", 10L, 20260907100000L)
|
||||
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
|
||||
val flow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
|
||||
flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
|
||||
|
||||
assertEquals(ProcStatus.FAILED, procState.snapshotOf(headId)!!.state)
|
||||
}
|
||||
}
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
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.FlightChange
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||
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.codec.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.processing.handlers.GtdtHandler
|
||||
import com.gzzn.omms.msgexchange.support.GuardedFlightSchd
|
||||
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
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.readText
|
||||
|
||||
class V2PipelineWritePathContractTest {
|
||||
|
||||
@Test
|
||||
fun `SnapshotFlow and MessageProcessor sources must not call legacy upsert helpers`() {
|
||||
val processingDir = Paths.get("src/main/kotlin/com/gzzn/omms/msgexchange/processing")
|
||||
val snapshotSource = processingDir.resolve("SnapshotFlow.kt").readText()
|
||||
val pumpSource = processingDir.resolve("Pump.kt").readText()
|
||||
|
||||
assertFalse(snapshotSource.contains("upsertSnapshotBatch"))
|
||||
assertFalse(snapshotSource.contains("upsertIncremental"))
|
||||
assertTrue(snapshotSource.contains("persistNextStates"))
|
||||
|
||||
assertFalse(pumpSource.contains("upsertSnapshotBatch"))
|
||||
assertFalse(pumpSource.contains("upsertIncremental"))
|
||||
assertTrue(pumpSource.contains("persistNextStates"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SnapshotFlow DNLD path calls persistNextStates not legacy upsert helpers`() {
|
||||
val flightSchd = GuardedFlightSchd()
|
||||
val procState = StubProcState()
|
||||
val msgEvents = StubMsgEvents()
|
||||
val reqTrack = StubReqTrack()
|
||||
val txManager = StubPipelineTransactionManager()
|
||||
val inbox = object : CminmsgInboxRepository {
|
||||
override fun insertRaw(rawXml: String) = 1L
|
||||
override fun rawOf(cminmsgsId: Long) = null
|
||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
|
||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
|
||||
}
|
||||
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), MutableClock(MutableClock.BASE)))
|
||||
|
||||
val headId = 1001L
|
||||
procState.insert(headId, ProcStatus.PENDING)
|
||||
val day = "2026-09-07"
|
||||
SnapshotFlow.StageResult.parser = {
|
||||
SnapshotFlow.StageResult.Ok(day, listOf("FL_DNLD" to mapOf("FLID" to "FL_DNLD", "FLNO" to "CA100", "GTDT" to """[{"GTNO":"1","GATE":"A1"}]""")))
|
||||
}
|
||||
val decoded = DecodedMessage(MetaFields("AODB", "SCHD", "DNLD", 11L, 20260907110000L), MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
|
||||
SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
|
||||
.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
|
||||
|
||||
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
|
||||
assertEquals(1, flightSchd.persistNextStatesCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MessageProcessor FLOP path calls persistNextStates not legacy upsert helpers`() {
|
||||
val flightSchd = GuardedFlightSchd()
|
||||
val procState = StubProcState()
|
||||
val msgEvents = StubMsgEvents()
|
||||
val reqTrack = StubReqTrack()
|
||||
val txManager = StubPipelineTransactionManager()
|
||||
val raws = mutableMapOf<Long, String>()
|
||||
val inbox = object : CminmsgInboxRepository {
|
||||
override fun insertRaw(rawXml: String) = 1L
|
||||
override fun rawOf(cminmsgsId: Long) = 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) = Unit
|
||||
}
|
||||
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), MutableClock(MutableClock.BASE)))
|
||||
val handler = GtdtHandler()
|
||||
val snapshot = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
|
||||
val processor = MessageProcessor(
|
||||
inbox, procState, msgEvents,
|
||||
CodecHolder(JacksonXmlCodec()),
|
||||
HandlerHolder(HandlerRegistry(listOf(handler))),
|
||||
flightSchd, snapshot, procFailure, PipelineProps(), txManager,
|
||||
)
|
||||
val headId = 1002L
|
||||
procState.insert(headId, ProcStatus.PENDING)
|
||||
raws[headId] = """
|
||||
<MSG>
|
||||
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260907120000</DTTM><TYPE>FLOP</TYPE><STYP>GTDT</STYP></META>
|
||||
<FLOP><FLID>FL_FLOP</FLID><GTDT GTNO="2"><GATE>B9</GATE></GTDT></FLOP>
|
||||
</MSG>
|
||||
""".trimIndent()
|
||||
processor.processOne(ProcState(headId, ProcStatus.PENDING))
|
||||
|
||||
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
|
||||
assertEquals(1, flightSchd.persistNextStatesCalls)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.processing.handlers
|
||||
|
||||
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GtdtHandlerTest {
|
||||
|
||||
private val handler = GtdtHandler()
|
||||
|
||||
@Test
|
||||
fun `decide replaces GTDT collection for three gates`() {
|
||||
val msg = DecodedMessage(
|
||||
meta = MetaFields("AODB", "FLOP", "GTDT", 1L, 20260908120000L),
|
||||
kind = MsgKind.Flop("GTDT"),
|
||||
rawXml = "<MSG/>",
|
||||
body = FlopPayload(
|
||||
flid = "121112312",
|
||||
scalars = mapOf("FFID" to "CA-CA101-A-12DEC031345-D"),
|
||||
collections = mapOf(
|
||||
"GTDT" to listOf(
|
||||
mapOf("GTNO" to "1", "GATE" to "G28", "GTYP" to "D"),
|
||||
mapOf("GTNO" to "2", "GATE" to "G33", "GTYP" to "I"),
|
||||
mapOf("GTNO" to "3", "GATE" to "G23", "GTYP" to "D"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val decision = handler.decide(emptyMap(), msg)
|
||||
assertEquals(1, decision.flightChanges.size)
|
||||
assertEquals("121112312", decision.flightChanges[0].flid)
|
||||
val gtdtJson = decision.flightChanges[0].fields["GTDT"]!!
|
||||
assertEquals(true, gtdtJson.contains("\"G28\""))
|
||||
assertEquals(1, decision.schdPush.size)
|
||||
assertEquals("121112312", decision.schdPush[0].flid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user