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:
@@ -58,18 +58,17 @@ class PipelineSmokeTest {
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 合法 META + FLOP,但未注册 Handler → FAILED(UNSUPPORTED) */
|
||||
val UNSUPPORTED_FLOP_XML = """
|
||||
/** 合法 META,但 TYPE 未在支持范围 → FAILED(UNSUPPORTED)(§9) */
|
||||
val UNSUPPORTED_XML = """
|
||||
<MSG>
|
||||
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260908120000</DTTM><TYPE>FLOP</TYPE><STYP>DELY</STYP></META>
|
||||
<FLOP><FLID>F1</FLID></FLOP>
|
||||
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260908120000</DTTM><TYPE>XYZQ</TYPE><STYP>FOO</STYP></META>
|
||||
</MSG>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accept then pump tick transitions message to FAILED UNSUPPORTED with backoff`() {
|
||||
val receipt = controller.send(UNSUPPORTED_FLOP_XML)
|
||||
val receipt = controller.send(UNSUPPORTED_XML)
|
||||
assertNotNull(receipt.body()) // 受理 ID
|
||||
val id = receipt.body()!!.toLong()
|
||||
|
||||
@@ -86,7 +85,7 @@ class PipelineSmokeTest {
|
||||
|
||||
@Test
|
||||
fun `replay reopens failed UNSUPPORTED row to PENDING`() {
|
||||
val receipt = controller.send(UNSUPPORTED_FLOP_XML)
|
||||
val receipt = controller.send(UNSUPPORTED_XML)
|
||||
val id = receipt.body()!!.toLong()
|
||||
pump.tick()
|
||||
val stub = ctx.getBean(StubProcState::class.java)
|
||||
@@ -106,14 +105,14 @@ class PipelineSmokeTest {
|
||||
val events = ctx.getBean(StubMsgEvents::class.java)
|
||||
val port = ctx.getBean(StubDeliveryPort::class.java)
|
||||
events.insertAll(listOf(
|
||||
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"old"}"""),
|
||||
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"new"}"""),
|
||||
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", stateVersion = 1, payloadJson = """{"FLID":"F1","v":"old"}"""),
|
||||
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", stateVersion = 2, payloadJson = """{"FLID":"F1","v":"new"}"""),
|
||||
))
|
||||
|
||||
dispatcher.flushSchd()
|
||||
|
||||
assertEquals(1, port.sent.count { it.first == "schd" })
|
||||
val payload = port.sent.first { it.first == "schd" }.second
|
||||
assertEquals(1, port.sent.count { it.topic == "schd" })
|
||||
val payload = port.sent.first { it.topic == "schd" }.payload!!
|
||||
assertTrue(payload.contains("new") && !payload.contains("old"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,24 +24,24 @@ class JacksonXmlCodecTest {
|
||||
</META>
|
||||
<FLOP>
|
||||
<FLID>121112312</FLID>
|
||||
<FFID>CA-CA101-A-12DEC031345-D</FFID>
|
||||
<FFID>CA-CA101-A-12DEC261345-D</FFID>
|
||||
<GTDT GTNO="1">
|
||||
<GATE>G28</GATE>
|
||||
<PGOT>15DEC031805</PGOT>
|
||||
<PGCT>15DEC031925</PGCT>
|
||||
<GOTM>15DEC031825</GOTM>
|
||||
<PGOT>15DEC261805</PGOT>
|
||||
<PGCT>15DEC261925</PGCT>
|
||||
<GOTM>15DEC261825</GOTM>
|
||||
<GTYP>D</GTYP>
|
||||
</GTDT>
|
||||
<GTDT GTNO="2">
|
||||
<GATE>G33</GATE>
|
||||
<PGOT>15DEC031805</PGOT>
|
||||
<PGCT>15DEC031925</PGCT>
|
||||
<PGOT>15DEC261805</PGOT>
|
||||
<PGCT>15DEC261925</PGCT>
|
||||
<GTYP>I</GTYP>
|
||||
</GTDT>
|
||||
<GTDT GTNO="3">
|
||||
<GATE>G23</GATE>
|
||||
<PGOT>15DEC031805</PGOT>
|
||||
<PGCT>15DEC031925</PGCT>
|
||||
<PGOT>15DEC261805</PGOT>
|
||||
<PGCT>15DEC261925</PGCT>
|
||||
<GTYP>D</GTYP>
|
||||
</GTDT>
|
||||
</FLOP>
|
||||
@@ -57,7 +57,7 @@ class JacksonXmlCodecTest {
|
||||
|
||||
val body = msg.body as FlopPayload
|
||||
assertEquals("121112312", body.flid)
|
||||
assertEquals("CA-CA101-A-12DEC031345-D", body.scalars["FFID"])
|
||||
assertEquals("CA-CA101-A-12DEC261345-D", body.scalars["FFID"])
|
||||
assertEquals(3, body.collections["GTDT"]!!.size)
|
||||
assertEquals("G28", body.collections["GTDT"]!![0]["GATE"])
|
||||
assertEquals("1", body.collections["GTDT"]!![0]["GTNO"])
|
||||
|
||||
@@ -12,7 +12,7 @@ import java.time.Duration
|
||||
|
||||
/**
|
||||
* U03(N02)验收:msgx.* 嵌套键「改值—回读」离线化。
|
||||
* 四个嵌套类(Pipeline/Schd/Identity/ConsistencyCheck)须标注 @ConfigurationProperties 才会被绑定;
|
||||
* 三个嵌套类(Pipeline/Schd/Identity)须标注 @ConfigurationProperties 才会被绑定;
|
||||
* 未标注时下列覆盖值会静默回落 Kotlin 默认值(本测试即红)。仅注入配置 bean,不触碰仓储/DB。
|
||||
* 注:Micronaut Test 5.x 移除 @Property 注解,改以 TestPropertyProvider 注入测试属性(需 PER_CLASS)。
|
||||
*/
|
||||
@@ -25,7 +25,6 @@ class PipelinePropsBindingTest : TestPropertyProvider {
|
||||
"msgx.pipeline.max-attempts" to "9",
|
||||
"msgx.schd.flush-limit" to "321",
|
||||
"msgx.identity.include-day-boundary" to "true",
|
||||
"msgx.consistency-check.daily-sample-ratio" to "0.05",
|
||||
)
|
||||
|
||||
@Inject
|
||||
@@ -37,12 +36,10 @@ class PipelinePropsBindingTest : TestPropertyProvider {
|
||||
assertEquals(9, props.pipeline.maxAttempts)
|
||||
assertEquals(321, props.schd.flushLimit)
|
||||
assertTrue(props.identity.includeDayBoundary)
|
||||
assertEquals(0.05, props.consistencyCheck.dailySampleRatio, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `top-level scalars still bind`() {
|
||||
assertEquals(PipelineProps.Phase.A, props.phase)
|
||||
assertEquals("msgexchangeapi", props.serviceName)
|
||||
assertFalse(props.registerEureka) // application-test.yml 置 false
|
||||
}
|
||||
|
||||
@@ -2,185 +2,131 @@ package com.gzzn.omms.msgexchange.delivery
|
||||
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.EventType
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.Targets
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* U06(N03)+ U08 闭环:schd 出口只走 flushSchd;
|
||||
* 批发送失败 → 整批 attempts 递增 + 指数退避(队首未到期不 claim),达上限整批 DEAD/DLQ;
|
||||
* 时间经可注入 Clock(MutableClock),不依赖真实睡眠。
|
||||
* 投递调度(docs/flight-state.md §7.3):
|
||||
* KAFKA_MSG 逐条 FIFO;KAFKA_SCHD 唯一出口 flushSchd——同 FLID 未发事件按最新
|
||||
* STATE_VERSION 合并;TOMBSTONE 发 null 值消息;失败退避重试、达上限 DEAD。
|
||||
*/
|
||||
class DispatcherTickTest {
|
||||
|
||||
private class FakeRepo : MsgEventRepository {
|
||||
val events = mutableListOf<MsgEvent>()
|
||||
|
||||
fun enqueue(e: MsgEvent) { events += e }
|
||||
|
||||
override fun insertAll(events: List<MsgEvent>) = events.map { it.eventId ?: 0L }
|
||||
|
||||
override fun headUnsent(target: String): MsgEvent? =
|
||||
events.filter { it.target == target && it.state != EventStatus.SENT && it.state != EventStatus.DEAD }
|
||||
.minByOrNull { it.eventId ?: Long.MAX_VALUE }
|
||||
|
||||
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
|
||||
events.filter { it.target == target && it.state == EventStatus.PENDING }
|
||||
.sortedBy { it.eventId ?: Long.MAX_VALUE }
|
||||
.take(limit)
|
||||
|
||||
override fun markSent(eventId: Long) {
|
||||
val i = events.indexOfFirst { it.eventId == eventId }
|
||||
if (i >= 0) events[i] = events[i].copy(state = EventStatus.SENT)
|
||||
}
|
||||
|
||||
override fun markAllSent(eventIds: List<Long>) = eventIds.forEach(::markSent)
|
||||
|
||||
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
|
||||
val i = events.indexOfFirst { it.eventId == eventId }
|
||||
if (i >= 0) events[i] = events[i].copy(state = EventStatus.PENDING, attempts = attempts, nextAttemptAt = nextAttemptAt)
|
||||
}
|
||||
|
||||
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) {
|
||||
val i = events.indexOfFirst { it.eventId == eventId }
|
||||
if (i >= 0) events[i] = events[i].copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError,
|
||||
attempts = attempts ?: events[i].attempts)
|
||||
}
|
||||
|
||||
override fun insertSync(events: List<MsgEvent>) { syncInserted += events }
|
||||
|
||||
val syncInserted = mutableListOf<MsgEvent>()
|
||||
}
|
||||
|
||||
private class FakePort : DeliveryPort {
|
||||
val sent = mutableListOf<Pair<String, String>>()
|
||||
var failTopic: String? = null
|
||||
|
||||
override fun sendKafka(topic: String, payloadJson: String) {
|
||||
if (failTopic == topic) throw RuntimeException("send-fail:$topic")
|
||||
sent += topic to payloadJson
|
||||
}
|
||||
|
||||
override fun indexFlightHts(payloadJson: String) = Unit
|
||||
}
|
||||
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
|
||||
private fun dispatcher(repo: FakeRepo, port: FakePort, p: PipelineProps = PipelineProps()): Dispatcher =
|
||||
private fun dispatcher(repo: MsgEventRepository, port: DeliveryPort, p: PipelineProps = PipelineProps()): Dispatcher =
|
||||
Dispatcher(repo, port, p, FailureScheduler(p, clock))
|
||||
|
||||
private fun ev(id: Long, target: String, key: String?, payload: String) =
|
||||
MsgEvent(eventId = id, target = target, partitionKey = key, payloadJson = payload, state = EventStatus.PENDING)
|
||||
private fun ev(id: Long, target: String, key: String, payload: String, version: Long = 0) =
|
||||
MsgEvent(eventId = id, target = target, partitionKey = key, stateVersion = version, payloadJson = payload)
|
||||
|
||||
@Test
|
||||
fun `schd flushed once via batch path, per-target tick never consumes it`() {
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort()
|
||||
val d = dispatcher(repo, port)
|
||||
repo.enqueue(ev(1, Targets.KAFKA_MSG, null, """{"msg":1}"""))
|
||||
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":1}"""))
|
||||
|
||||
d.tick() // lastFlush=EPOCH → 首 tick 即到 flush 周期
|
||||
|
||||
assertEquals(1, port.sent.count { it.first == "msg" })
|
||||
assertEquals(1, port.sent.count { it.first == "schd" })
|
||||
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state)
|
||||
|
||||
repo.enqueue(ev(4, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":4}"""))
|
||||
d.tick() // 时钟未推进 → flush 周期未到;逐条循环不得消费 schd
|
||||
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 4L }.state)
|
||||
assertEquals(1, port.sent.count { it.first == "schd" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flushSchd aggregates latest per flight and marks all sent`() {
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort()
|
||||
val d = dispatcher(repo, port)
|
||||
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", "old"))
|
||||
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", "only"))
|
||||
repo.enqueue(ev(3, Targets.KAFKA_SCHD, "F1", "new"))
|
||||
|
||||
d.flushSchd()
|
||||
|
||||
val payload = port.sent.first { it.first == "schd" }.second
|
||||
assertTrue(payload.contains("new") && payload.contains("only") && !payload.contains("old"))
|
||||
assertEquals(2, payload.removeSurrounding("[", "]").split(",").size) // 同 FLID 只发最新(矩阵 #7)
|
||||
assertTrue(repo.events.all { it.state == EventStatus.SENT })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flush failure schedules per-event backoff and retries after due`() {
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort().apply { failTopic = "schd" }
|
||||
val d = dispatcher(repo, port)
|
||||
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
|
||||
|
||||
d.flushSchd()
|
||||
val e1 = repo.events.single()
|
||||
assertEquals(EventStatus.PENDING, e1.state)
|
||||
assertEquals(1, e1.attempts)
|
||||
assertNotNull(e1.nextAttemptAt) // 指数退避落库(attempts=1 → 首档)
|
||||
assertEquals(0, port.sent.size)
|
||||
|
||||
clock.advance(1000) // 退避到期
|
||||
port.failTopic = null
|
||||
d.flushSchd()
|
||||
assertEquals(EventStatus.SENT, repo.events.single().state)
|
||||
assertEquals(1, port.sent.count { it.first == "schd" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated batch failures backoff grows and whole batch goes DEAD DLQ at limit`() {
|
||||
val props = PipelineProps().apply { pipeline.maxAttempts = 2 }
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort().apply { failTopic = "schd" }
|
||||
fun `per-target tick delivers KAFKA_MSG only and never consumes schd`() {
|
||||
val repo = StubMsgEvents()
|
||||
val port = StubDeliveryPort()
|
||||
val props = PipelineProps().apply { schd.flushPeriod = java.time.Duration.ofHours(1) }
|
||||
repo.insertAll(
|
||||
listOf(
|
||||
ev(1, Targets.KAFKA_MSG, "F1", """{"flid":"F1"}"""),
|
||||
ev(2, Targets.KAFKA_SCHD, "F1", """{"flid":"F1"}""", 1),
|
||||
),
|
||||
)
|
||||
val d = dispatcher(repo, port, props)
|
||||
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
|
||||
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", """{"v":"2"}"""))
|
||||
d.tick()
|
||||
|
||||
d.flushSchd() // 失败#1:attempts=1,退避 backoff[1]=1000ms
|
||||
val after1 = repo.events.map { it.attempts to it.state }
|
||||
assertEquals(listOf(1 to EventStatus.PENDING, 1 to EventStatus.PENDING), after1)
|
||||
assertTrue(repo.events.all { it.nextAttemptAt == MutableClock.BASE.plusMillis(1000) })
|
||||
assertEquals(1, port.sent.count { it.topic == "msg" })
|
||||
assertEquals(0, port.sent.count { it.topic == "schd" }) // N03:schd 唯一出口 flushSchd
|
||||
}
|
||||
|
||||
d.flushSchd() // 时钟未推进 → 队首未到期,不 claim(不推进 lastFlush)
|
||||
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts)
|
||||
|
||||
clock.advance(1000)
|
||||
d.flushSchd() // 失败#2:attempts=2 == maxAttempts → 整批 DEAD
|
||||
assertTrue(repo.events.all { it.state == EventStatus.DEAD && it.errorClass == ErrorClass.EXHAUSTED })
|
||||
assertTrue(repo.events.all { it.attempts == 2 })
|
||||
assertEquals(0, port.sent.size)
|
||||
|
||||
// DEAD 行不再参与 claim:空批 → 无新发送、无异常
|
||||
@Test
|
||||
fun `flushSchd aggregates latest version per flight and marks sent`() {
|
||||
val repo = StubMsgEvents()
|
||||
val port = StubDeliveryPort()
|
||||
repo.insertAll(
|
||||
listOf(
|
||||
ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"old"}""", 1),
|
||||
ev(2, Targets.KAFKA_SCHD, "F1", """{"v":"new"}""", 2),
|
||||
ev(3, Targets.KAFKA_SCHD, "F2", """{"v":"f2"}""", 1),
|
||||
),
|
||||
)
|
||||
val d = dispatcher(repo, port)
|
||||
d.flushSchd()
|
||||
assertEquals(0, port.sent.size)
|
||||
|
||||
val schd = port.sent.filter { it.topic == "schd" }
|
||||
assertEquals(2, schd.size)
|
||||
assertEquals(1, schd.count { it.key == "F1" && it.payload!!.contains("new") && !it.payload.contains("old") })
|
||||
assertEquals(0, repo.rows.values.count { it.state.name == "PENDING" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tombstone is delivered as null value message`() {
|
||||
val repo = StubMsgEvents()
|
||||
val port = StubDeliveryPort()
|
||||
repo.insertAll(
|
||||
listOf(
|
||||
MsgEvent(
|
||||
eventId = 1, target = Targets.KAFKA_SCHD, partitionKey = "F1",
|
||||
eventType = EventType.TOMBSTONE, stateVersion = 3, payloadJson = """{"flid":"F1","deleted":true}""",
|
||||
),
|
||||
),
|
||||
)
|
||||
val d = dispatcher(repo, port)
|
||||
d.flushSchd()
|
||||
|
||||
assertEquals(1, port.tombstones.size)
|
||||
assertEquals("F1", port.tombstones.single().key) // 整态键缺失表示删除旧值(§7.3)
|
||||
assertNull(port.tombstones.single().payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `schd send failure schedules backoff and goes DEAD at limit`() {
|
||||
val repo = StubMsgEvents()
|
||||
val p = PipelineProps()
|
||||
val d = dispatcher(repo, FailingSchdPort(), p)
|
||||
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}""", 1)))
|
||||
|
||||
d.flushSchd()
|
||||
assertEquals(1, repo.rows[1]!!.attempts)
|
||||
|
||||
repeat(p.pipeline.maxAttempts - 1) {
|
||||
clock.advance(p.pipeline.backoffFor(repo.rows[1]!!.attempts) + 1)
|
||||
d.flushSchd()
|
||||
}
|
||||
val final = repo.rows[1]!!
|
||||
assertTrue(final.attempts >= p.pipeline.maxAttempts || final.state.name == "DEAD")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KAFKA msg delivery unaffected while schd batch is retrying`() {
|
||||
val props = PipelineProps().apply { pipeline.maxAttempts = 2 }
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort().apply { failTopic = "schd" }
|
||||
val d = dispatcher(repo, port, props)
|
||||
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
|
||||
repo.enqueue(ev(2, Targets.KAFKA_MSG, null, """{"m":1}"""))
|
||||
|
||||
val repo = StubMsgEvents()
|
||||
val port = StubDeliveryPort()
|
||||
val d = dispatcher(repo, port)
|
||||
repo.insertAll(listOf(ev(1, Targets.KAFKA_MSG, "F9", """{"flid":"F9"}""")))
|
||||
d.tick()
|
||||
assertEquals(1, port.sent.count { it.topic == "msg" && it.key == "F9" })
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state) // msg 正常
|
||||
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts) // schd 进退避
|
||||
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 1L }.state)
|
||||
/** schd 发送恒失败的端口(退避/终态闭环验证用)。 */
|
||||
private class FailingSchdPort : DeliveryPort {
|
||||
var calls = 0
|
||||
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
|
||||
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
|
||||
calls++
|
||||
throw IllegalStateException("broker-down")
|
||||
}
|
||||
|
||||
override fun sendKafkaNull(topic: String, key: String) {
|
||||
throw IllegalStateException("broker-down")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.delivery
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.Targets
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* ACMA-8 流程 3 / 兼容矩阵 #7(FIX):同一 FLID 多次变更只发最新一态;
|
||||
* v4 显式“组内取 EVENT_ID 最大”,不依赖集合遍历序。
|
||||
*/
|
||||
class SchdAggregationTest {
|
||||
|
||||
private fun ev(flid: String, eventId: Long, payload: String) = MsgEvent(
|
||||
eventId = eventId,
|
||||
target = Targets.KAFKA_SCHD,
|
||||
partitionKey = flid,
|
||||
payloadJson = payload,
|
||||
state = EventStatus.PENDING,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `same flight collapsed to max EVENT_ID`() {
|
||||
val pending = listOf(
|
||||
ev("F1", 1, """{"FLID":"F1","v":"old"}"""),
|
||||
ev("F2", 2, """{"FLID":"F2","v":"only"}"""),
|
||||
ev("F1", 3, """{"FLID":"F1","v":"new"}"""),
|
||||
)
|
||||
val latest = SchdAggregation.latestPerFlight(pending)
|
||||
assertEquals(2, latest.size)
|
||||
assertEquals("""{"FLID":"F1","v":"new"}""", latest.first { it.contains("F1") })
|
||||
assertEquals("""{"FLID":"F2","v":"only"}""", latest.first { it.contains("F2") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order-independent grouping yields same latest per flight`() {
|
||||
val a = listOf(
|
||||
ev("F1", 1, "old"),
|
||||
ev("F1", 2, "new"),
|
||||
)
|
||||
val b = a.reversed()
|
||||
assertEquals(
|
||||
SchdAggregation.latestPerFlight(a).toSet(),
|
||||
SchdAggregation.latestPerFlight(b).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wire shape is FLTR JSON array`() {
|
||||
val payload = SchdAggregation
|
||||
.latestPerFlight(listOf(ev("F1", 1, "a"), ev("F2", 2, "b")))
|
||||
.joinToString(",", "[", "]")
|
||||
assertEquals("[a,b]", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* 运营日计算(docs/flight-state.md §3.5):SODT(ddMMMyyHHmm)+ 机场时区 + 业务切日边界。
|
||||
*/
|
||||
class OperationDayTest {
|
||||
|
||||
private val zone: ZoneId = ZoneId.of("Asia/Shanghai")
|
||||
|
||||
@Test
|
||||
fun `computes operation day from SODT with zero cutoff`() {
|
||||
val calc = OperationDayCalculator(zone, cutoffHour = 0)
|
||||
assertEquals(LocalDate.of(2026, 12, 15), calc.compute("15DEC261723"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time before cutoff rolls back to previous operation day`() {
|
||||
val calc = OperationDayCalculator(zone, cutoffHour = 4)
|
||||
// 04:00 之前 → 前一运营日
|
||||
assertEquals(LocalDate.of(2026, 12, 14), calc.compute("15DEC260230"))
|
||||
// 04:00 整点起归属当日
|
||||
assertEquals(LocalDate.of(2026, 12, 15), calc.compute("15DEC260400"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid or missing SODT returns null`() {
|
||||
val calc = OperationDayCalculator(zone, 0)
|
||||
assertNull(calc.compute(null))
|
||||
assertNull(calc.compute(""))
|
||||
assertNull(calc.compute("garbage"))
|
||||
assertNull(calc.compute("99XXC12345"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cutoff outside 0 to 23 is clamped to 23`() {
|
||||
val calc = OperationDayCalculator(zone, cutoffHour = 99)
|
||||
// 越界收敛为 23:当日 00:00 早于 23 点 → 归属前一运营日
|
||||
assertEquals(LocalDate.of(2026, 12, 14), calc.compute("15DEC260000"))
|
||||
}
|
||||
}
|
||||
@@ -1,202 +1,113 @@
|
||||
package com.gzzn.omms.msgexchange.domain.flight
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
|
||||
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
||||
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.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* 航班状态引擎(docs/flight-state.md §3.3/§5.2/§5.4/§6.1)不变量:
|
||||
* 快照整体替换(缺失=清除)、DELETED 不被 SCHD 恢复、FLOP 增量保留缺失字段、
|
||||
* §5.2 五项校验整包拒绝语义。
|
||||
*/
|
||||
class FlightStateEngineTest {
|
||||
|
||||
private val opDay = OperationDayCalculator(ZoneId.of("Asia/Shanghai"), 0)
|
||||
private val day = LocalDate.of(2026, 12, 15)
|
||||
|
||||
@Test
|
||||
fun `scalar unchanged does not modify existing value`() {
|
||||
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 1L, "m1")
|
||||
val next = FlightStateEngine.apply(
|
||||
fun `snapshot replaces scalars and clears absent ones`() {
|
||||
val current = FlightSnapshot(
|
||||
"121", day, FlightState.ACTIVE, 5,
|
||||
scalars = mapOf("FLNO" to "CA001", "REMC" to "old-note"),
|
||||
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
|
||||
)
|
||||
val next = FlightStateEngine.snapshotState(
|
||||
current,
|
||||
FlightFieldCommands("F1", scalars = emptyMap()),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
ScheduleRecord("121", scalars = mapOf("FLNO" to "CA002")),
|
||||
operationDay = day,
|
||||
keepDeleted = false,
|
||||
)
|
||||
assertEquals("CA100", next.scalars["FLNO"])
|
||||
assertEquals(2L, next.stateVersion)
|
||||
|
||||
assertEquals("CA002", next.scalars["FLNO"])
|
||||
assertFalse(next.scalars.containsKey("REMC")) // 缺失 = Clear(§5.4)
|
||||
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"]) // 缺失 = Replace 空集
|
||||
assertEquals(6, next.stateVersion) // 每次成功写入 +1(§4)
|
||||
assertEquals(FlightState.ACTIVE, next.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection replace preserves order and source sequence`() {
|
||||
val items = listOf(
|
||||
mapOf("GTNO" to "1", "GATE" to "A1"),
|
||||
mapOf("GTNO" to "3", "GATE" to "B2"),
|
||||
mapOf("GTNO" to "7", "GATE" to "C3"),
|
||||
fun `snapshot keeps DELETED state and flags revive conflict by caller`() {
|
||||
val current = FlightSnapshot("121", day, FlightState.DELETED, 3, mapOf(), emptyMap())
|
||||
val next = FlightStateEngine.snapshotState(
|
||||
current, ScheduleRecord("121", mapOf("FLNO" to "CA001")), day, keepDeleted = true,
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
null,
|
||||
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
|
||||
"m1",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals(3, next.collections["GTDT"]!!.size)
|
||||
assertEquals("3", next.collections["GTDT"]!![1]["GTNO"])
|
||||
assertEquals("C3", next.collections["GTDT"]!![2]["GATE"])
|
||||
assertEquals(FlightState.DELETED, next.state) // §5.1 步骤 6:普通 SCHD 不恢复
|
||||
assertEquals(4, next.stateVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection clear removes prior entries`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))),
|
||||
1L,
|
||||
"m1",
|
||||
fun `flop merge retains absent scalars and collections`() {
|
||||
val current = FlightSnapshot(
|
||||
"121", day, FlightState.ACTIVE, 1,
|
||||
scalars = mapOf("FLNO" to "CA001", "ESTT" to "15DEC261807"),
|
||||
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
val next = FlightStateEngine.mergedState(
|
||||
current,
|
||||
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Clear)),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
MergeChange("121", scalars = mapOf("ESTT" to "15DEC261900")),
|
||||
)
|
||||
assertFalse(next.collections.containsKey("GTDT"))
|
||||
|
||||
assertEquals("15DEC261900", next.scalars["ESTT"])
|
||||
assertEquals("CA001", next.scalars["FLNO"]) // 缺失 = 保留(§6.1)
|
||||
assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"])
|
||||
assertEquals(2, next.stateVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same gate number different attributes both retained on replace`() {
|
||||
val items = listOf(
|
||||
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "D"),
|
||||
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "I"),
|
||||
fun `validation rejects RECS mismatch and duplicate flid and day outside scope`() {
|
||||
val ok = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
null,
|
||||
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
|
||||
"m1",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals("D", next.collections["GTDT"]!![0]["GTYP"])
|
||||
assertEquals("I", next.collections["GTDT"]!![1]["GTYP"])
|
||||
}
|
||||
assertTrue(ok is SnapshotValidation.Ok)
|
||||
|
||||
@Test
|
||||
fun `incremental apply bumps state version from persisted value`() {
|
||||
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 5L, "m1")
|
||||
val next = FlightStateEngine.apply(
|
||||
current,
|
||||
FlightFieldCommands("F1", scalars = mapOf("STAT" to ScalarCommand.Set("DEP"))),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals(6L, next.stateVersion)
|
||||
}
|
||||
val recsMismatch = FlightStateEngine.validateMessage(
|
||||
2, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
) as SnapshotValidation.Invalid
|
||||
assertTrue(recsMismatch.flags.contains(SnapshotFlag.RECS_DROP))
|
||||
|
||||
@Test
|
||||
fun `apply by source sequence updates single row`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("GTDT" to listOf(mapOf("GTNO" to "2", "GATE" to "OLD"))),
|
||||
1L,
|
||||
"m1",
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
current,
|
||||
FlightFieldCommands(
|
||||
"F1",
|
||||
collections = mapOf(
|
||||
"GTDT" to CollectionCommand.Apply(mapOf("GTNO" to "2", "GATE" to "NEW"), "2"),
|
||||
),
|
||||
val duplicate = FlightStateEngine.validateMessage(
|
||||
2,
|
||||
listOf(
|
||||
ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
|
||||
ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
|
||||
),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
day, day, opDay,
|
||||
)
|
||||
assertEquals("NEW", next.collections["GTDT"]!![0]["GATE"])
|
||||
assertTrue(duplicate is SnapshotValidation.Invalid)
|
||||
|
||||
val outOfScope = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("121", mapOf("SODT" to "20DEC261723"))), day, day, opDay,
|
||||
) as SnapshotValidation.Invalid
|
||||
assertTrue(outOfScope.flags.contains(SnapshotFlag.DAY_MISMATCH)) // §5.2 覆盖范围校验
|
||||
|
||||
val empty = FlightStateEngine.validateMessage(0, emptyList(), day, day, opDay) as SnapshotValidation.Ok
|
||||
assertTrue(empty.flags.contains(SnapshotFlag.EMPTY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero sequence marker alone translates to explicit clear`() {
|
||||
val commands = FlightStateEngine.commandsFromFields(
|
||||
"F1",
|
||||
mapOf("GTDT" to """[{"GTNO":"0"}]"""),
|
||||
fun `validation rejects non numeric or oversized flid`() {
|
||||
val bad = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("ABC", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
)
|
||||
assertEquals(CollectionCommand.Clear, commands.collections["GTDT"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero sequence marker mixed with regular items fails fast instead of guessing`() {
|
||||
val error = runCatching {
|
||||
FlightStateEngine.commandsFromFields(
|
||||
"F1",
|
||||
mapOf("GTDT" to """[{"GTNO":"1","GATE":"A1"},{"GTNO":"0"}]"""),
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
assertTrue(error is IllegalArgumentException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty array replaces collection with empty set keeping key semantics`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))),
|
||||
1L,
|
||||
"m1",
|
||||
assertTrue(bad is SnapshotValidation.Invalid)
|
||||
val tooLong = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("1234567890123", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
)
|
||||
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("GTDT" to "[]"))
|
||||
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
|
||||
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply on DELY fails fast because protocol defines no sequence attribute`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("DELY" to listOf(mapOf("CODE" to "YY"))),
|
||||
1L,
|
||||
"m1",
|
||||
)
|
||||
val error = runCatching {
|
||||
FlightStateEngine.apply(
|
||||
current,
|
||||
FlightFieldCommands(
|
||||
"F1",
|
||||
collections = mapOf("DELY" to CollectionCommand.Apply(mapOf("CODE" to "ZZ"), "1")),
|
||||
),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
assertTrue(error is IllegalArgumentException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exception null payload translates to explicit clear and apply records cleared key`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
mapOf("FLNO" to "CA100", "FRET" to """{"REID":"R1","RSN":"diverted"}"""),
|
||||
emptyMap(),
|
||||
3L,
|
||||
"m1",
|
||||
)
|
||||
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to "null"))
|
||||
assertEquals(ScalarCommand.Clear, commands.scalars["FRET"])
|
||||
|
||||
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
|
||||
assertFalse(next.scalars.containsKey("FRET"))
|
||||
assertTrue(next.clearedKeys.contains("FRET"))
|
||||
assertEquals("CA100", next.scalars["FLNO"])
|
||||
|
||||
// 非空载荷仍为 Set,且清空后的下一跳 Set 恢复键(clearedKeys 不跨消息携带)
|
||||
val restored = FlightStateEngine.apply(
|
||||
next,
|
||||
FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to """{"REID":"R2"}""")),
|
||||
"m3",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals("""{"REID":"R2"}""", restored.scalars["FRET"])
|
||||
assertFalse(restored.clearedKeys.contains("FRET"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exception clear accepts null literal empty object and empty string`() {
|
||||
for (payload in listOf("null", "{}", "")) {
|
||||
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FDIV" to payload))
|
||||
assertEquals(ScalarCommand.Clear, commands.scalars["FDIV"], "payload=[$payload] must be Clear")
|
||||
}
|
||||
assertTrue(tooLong is SnapshotValidation.Invalid) // SIS §3.16.2 Number(1-12)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@ import org.junit.jupiter.api.Test
|
||||
class HealthIndicatorsTest {
|
||||
|
||||
private class FakePort(private val pingResult: Boolean) : DeliveryPort {
|
||||
override fun sendKafka(topic: String, payloadJson: String) = Unit
|
||||
override fun indexFlightHts(payloadJson: String) = Unit
|
||||
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
|
||||
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
|
||||
override fun sendKafkaNull(topic: String, key: String) = Unit
|
||||
override fun ping(): Boolean = pingResult
|
||||
}
|
||||
|
||||
private class ThrowingPort : DeliveryPort {
|
||||
override fun sendKafka(topic: String, payloadJson: String) = Unit
|
||||
override fun indexFlightHts(payloadJson: String) = Unit
|
||||
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
|
||||
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
|
||||
override fun sendKafkaNull(topic: String, key: String) = Unit
|
||||
override fun ping(): Boolean = throw RuntimeException("metadata fetch failed")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FlightFieldsJsonTest {
|
||||
private val mapper = ObjectMapper()
|
||||
|
||||
@Test
|
||||
fun `collection JSON is emitted as a native array`() {
|
||||
val json = FlightFieldsJson.toJson(
|
||||
mapOf("FLID" to "F1", "GTDT" to """[{"GTNO":"1","GATE":"A1"}]"""),
|
||||
)
|
||||
|
||||
val root = mapper.readTree(json)
|
||||
assertTrue(root["GTDT"].isArray)
|
||||
assertEquals("A1", root["GTDT"][0]["GATE"].asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed collection text stays a string instead of breaking delivery`() {
|
||||
val root = mapper.readTree(FlightFieldsJson.toJson(mapOf("GTDT" to "legacy-value")))
|
||||
assertTrue(root["GTDT"].isTextual)
|
||||
assertEquals("legacy-value", root["GTDT"].asText())
|
||||
}
|
||||
}
|
||||
-815
@@ -1,815 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.FlightChange
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
|
||||
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.Assumptions.assumeTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import com.gzzn.omms.msgexchange.support.PgTestSupport
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import java.sql.DriverManager
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* ACM2-28 FS6 + ACM2-29:PostgreSQL 真实方言集成测试(JdbcFlightSchdRepository)
|
||||
* 验证:
|
||||
* 1. DNLD 快照批处理写入(JDBC batch,200 批次);
|
||||
* 2. 增量 Upsert FDAY 保留策略(ON CONFLICT 保留原代,新插置 NULL);
|
||||
* 3. 域化差删(按 FDAY 严格隔离);
|
||||
* 4. SCHD_GEN SQL CAS(防并发断言);
|
||||
* 5. 单事务原子回滚(崩溃无残留);
|
||||
* 6. ES 历史清场 deleteByFlids 幂等删除;
|
||||
* 7. ACM2-29 零子表:集合平铺槽位列写读(集合级全量替换/序号 0 清除/里程碑与航路紧凑列),
|
||||
* 读侧视图重建 legacy 同构集合键;
|
||||
* 8. I5:PIPELINE_LOCK 行级单写者互斥与提交释放。
|
||||
*/
|
||||
class FlightSchdJdbcPgTest {
|
||||
|
||||
private lateinit var ds: HikariDataSource
|
||||
private lateinit var repo: JdbcFlightSchdRepository
|
||||
private val mapper = com.fasterxml.jackson.databind.ObjectMapper()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
|
||||
|
||||
ds = HikariDataSource().apply {
|
||||
jdbcUrl = PgTestSupport.jdbcUrl
|
||||
username = PgTestSupport.user
|
||||
password = PgTestSupport.password
|
||||
driverClassName = "org.postgresql.Driver"
|
||||
maximumPoolSize = 2
|
||||
}
|
||||
// 幂等前置迁移:全新容器(或库缺失 schema)时补齐 V1.0.0→V1.3.0
|
||||
org.flywaydb.core.Flyway.configure()
|
||||
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
.migrate()
|
||||
repo = JdbcFlightSchdRepository(ds)
|
||||
cleanup()
|
||||
}
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
if (::ds.isInitialized) {
|
||||
cleanup()
|
||||
ds.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
try {
|
||||
val testFlids = ds.query(
|
||||
"SELECT flid FROM flight_schd WHERE flid LIKE 'TEST_%'",
|
||||
{},
|
||||
) { rs -> rs.getString("flid") }
|
||||
if (testFlids.isNotEmpty()) {
|
||||
com.gzzn.omms.msgexchange.infra.persistence.jdbc.FlightDetailTables.deleteForFlids(ds, testFlids)
|
||||
}
|
||||
ds.update("DELETE FROM flight_schd WHERE flid LIKE 'TEST_%'") {}
|
||||
ds.update("DELETE FROM schd_gen WHERE fday >= '2026-09-01'") {}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
/** v2 写路径便捷构造:字段集 → 快照 nextState(版本顺序推进)。 */
|
||||
private fun snapshotStates(
|
||||
day: String,
|
||||
flights: List<Pair<String, Map<String, String>>>,
|
||||
version: Long = 1L,
|
||||
messageId: String = "msg-test",
|
||||
) = flights.map { (flid, fields) ->
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.apply(
|
||||
null,
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.commandsFromFields(flid, fields),
|
||||
messageId,
|
||||
bumpVersion = false,
|
||||
).copy(stateVersion = version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `day diff preserves all details of migrated and unowned flights`() {
|
||||
val day = "2026-09-07"
|
||||
val otherDay = "2026-09-08"
|
||||
val fields = mapOf("GTDT" to """[{"GTNO":"3","GATE":"G23"}]""")
|
||||
val removed = "TEST_DIFF_REMOVED"
|
||||
val moved = "TEST_DIFF_MOVED"
|
||||
val unowned = "TEST_DIFF_ADFT"
|
||||
repo.persistNextStates(day, snapshotStates(day, listOf(removed to fields)), snapshotReplace = true)
|
||||
repo.persistNextStates(otherDay, snapshotStates(otherDay, listOf(moved to fields)), snapshotReplace = true)
|
||||
repo.persistNextStates(null, snapshotStates(day, listOf(unowned to fields)), snapshotReplace = false)
|
||||
val beforeMoved = repo.findByFlid(moved)
|
||||
val beforeUnowned = repo.findByFlid(unowned)
|
||||
|
||||
JdbcPipelineTransactionManager(ds).inTransaction {
|
||||
assertEquals(1, repo.deleteDiffByDay(day, listOf(removed, moved, unowned)))
|
||||
}
|
||||
|
||||
assertNull(repo.findByFlid(removed))
|
||||
assertTrue(FlightDetailTables.loadCollections(ds, removed).isEmpty())
|
||||
assertEquals(beforeMoved, repo.findByFlid(moved))
|
||||
assertEquals(beforeUnowned, repo.findByFlid(unowned))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - snapshot batch upsert, find, and domain diff delete`() {
|
||||
val day = "2026-09-07"
|
||||
// 构造 250 条记录跨越 JDBC batch 200 门限
|
||||
val flights = (1..250).map { i ->
|
||||
"TEST_FL_$i" to mapOf("FLID" to "TEST_FL_$i", "FLNO" to "CA$i")
|
||||
}
|
||||
repo.persistNextStates(day, snapshotStates(day, flights), snapshotReplace = true)
|
||||
|
||||
// 验证全量写入成功
|
||||
val byDay = repo.findByDay(day)
|
||||
assertEquals(250, byDay.size)
|
||||
|
||||
// 点查测试
|
||||
val f1 = repo.findByFlid("TEST_FL_1")
|
||||
assertNotNull(f1)
|
||||
assertEquals("CA1", f1!!["FLNO"])
|
||||
|
||||
// 多键点查测试(分批查)
|
||||
val subset = (1..50).map { "TEST_FL_$it" }
|
||||
val map = repo.findByFlids(subset)
|
||||
assertEquals(50, map.size)
|
||||
|
||||
// 域内差删:删除 1..50
|
||||
val deleted = repo.deleteDiffByDay(day, subset)
|
||||
assertEquals(50, deleted)
|
||||
assertEquals(200, repo.findByDay(day).size)
|
||||
assertNull(repo.findByFlid("TEST_FL_1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - incremental upsert preserves existing FDAY and sets NULL for new`() {
|
||||
val day = "2026-09-07"
|
||||
// 1. 快照写入 REG_01,FDAY 为 2026-09-07
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
snapshotStates(day, listOf("TEST_REG_01" to mapOf("FLID" to "TEST_REG_01", "REMC" to "1"))),
|
||||
snapshotReplace = true,
|
||||
)
|
||||
|
||||
// 2. 增量更新 REG_01(已有行)与 ADFT_01(新行)
|
||||
repo.persistNextStates(
|
||||
null,
|
||||
listOf("TEST_REG_01" to "2", "TEST_ADFT_01" to "1").map { (flid, remc) ->
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.apply(
|
||||
null,
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.commandsFromFields(
|
||||
flid, mapOf("FLID" to flid, "REMC" to remc),
|
||||
),
|
||||
"msg-inc-01",
|
||||
bumpVersion = true,
|
||||
)
|
||||
},
|
||||
snapshotReplace = false,
|
||||
)
|
||||
|
||||
// 3. 验证 REG_01 内容更新但 FDAY 依然保留为 2026-09-07
|
||||
val reg01 = ds.queryOne("SELECT fday, remc FROM flight_schd WHERE flid = 'TEST_REG_01'", {}) { rs ->
|
||||
rs.getDate("fday")?.toString() to rs.getString("remc")
|
||||
}
|
||||
assertNotNull(reg01)
|
||||
assertEquals("2026-09-07", reg01!!.first)
|
||||
assertEquals("2", reg01.second)
|
||||
|
||||
// 4. 验证 ADFT_01 新插入行 FDAY 恒为 NULL
|
||||
val adft01 = ds.queryOne("SELECT fday, remc FROM flight_schd WHERE flid = 'TEST_ADFT_01'", {}) { rs ->
|
||||
rs.getDate("fday")?.toString() to rs.getString("remc")
|
||||
}
|
||||
assertNotNull(adft01)
|
||||
assertNull(adft01!!.first)
|
||||
|
||||
// 5. 执行 2026-09-07 的差删,ADFT_01 绝不被误删
|
||||
val deleted = repo.deleteDiffByDay(day, listOf("TEST_ADFT_01"))
|
||||
assertEquals(0, deleted)
|
||||
assertNotNull(repo.findByFlid("TEST_ADFT_01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resource collections replace as a whole and zero sequence clears them losslessly`() {
|
||||
val day = "2026-09-07"
|
||||
val flid = "TEST_RESOURCE"
|
||||
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
// 快照:两个登机门 → 明细表两行(保序保 GTNO)
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
listOf(
|
||||
engine.apply(
|
||||
null,
|
||||
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"1","GATE":"A1"},{"GTNO":"2","GATE":"A2"}]""")),
|
||||
"msg-res-1", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = true,
|
||||
)
|
||||
val gates = ds.query(
|
||||
"SELECT ordinal, source_seq, gate FROM flight_gate WHERE flid = ? ORDER BY ordinal",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> Triple(rs.getInt("ordinal"), rs.getString("source_seq"), rs.getString("gate")) }
|
||||
assertEquals(listOf(Triple(1, "1", "A1"), Triple(2, "2", "A2")), gates)
|
||||
// 读侧明细权威视图:两个登机门完整读回
|
||||
val view = repo.findByFlid(flid)!!["GTDT"]?.let { mapper.readTree(it) }
|
||||
assertEquals(2, view!!.size())
|
||||
assertEquals("A2", view[1]["GATE"].asText())
|
||||
assertEquals("2", view[1]["GTNO"].asText())
|
||||
|
||||
// 增量 = 集合级全量替换:1 个登机门(非连续 GTNO=3 保真)
|
||||
repo.persistNextStates(
|
||||
null,
|
||||
listOf(
|
||||
engine.apply(
|
||||
repo.findNextStateByFlid(flid),
|
||||
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"3","GATE":"B1"}]""")),
|
||||
"msg-res-2", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = false,
|
||||
)
|
||||
val replaced = mapper.readTree(repo.findByFlid(flid)!!["GTDT"])
|
||||
assertEquals(1, replaced.size())
|
||||
assertEquals("B1", replaced[0]["GATE"].asText())
|
||||
assertEquals("3", replaced[0]["GTNO"].asText())
|
||||
|
||||
// 序号属性 "0" = 显式删除标记 → 集合清除,明细行删除,视图无 GTDT 键
|
||||
repo.persistNextStates(
|
||||
null,
|
||||
listOf(
|
||||
engine.apply(
|
||||
repo.findNextStateByFlid(flid),
|
||||
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"0"}]""")),
|
||||
"msg-res-3", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = false,
|
||||
)
|
||||
assertNull(repo.findByFlid(flid)!!["GTDT"])
|
||||
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `milestone delay route and exception collections persist losslessly in v2 storage`() {
|
||||
val day = "2026-09-07"
|
||||
val flid = "TEST_SCALAR"
|
||||
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
listOf(
|
||||
engine.apply(
|
||||
null,
|
||||
engine.commandsFromFields(
|
||||
flid,
|
||||
mapOf(
|
||||
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200","REMC":"Flight Delayed"}]""",
|
||||
"ROUT" to """[{"RTNO":"1","APCD":"ORD","SCAT":"07SEP261125","SCDT":"07SEP261315"},{"RTNO":"2","APCD":"MSP"}]""",
|
||||
"ABTM" to """[{"ASNO":"1","ABDG":"B01","ABOP":"A","AOTM":"07SEP261700"}]""",
|
||||
"FDIV" to """{"DDES":"PEK","DDIR":"TO","REMC":"weather"}""",
|
||||
),
|
||||
),
|
||||
"msg-scalar-1", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = true,
|
||||
)
|
||||
// 库内明细表保序保源序号:延误、航路(route_kind 区分)、靠桥操作
|
||||
val delay = ds.queryOne(
|
||||
"SELECT source_seq, code, strt, remc FROM flight_delay WHERE flid = ?",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> listOf(rs.getString("source_seq"), rs.getString("code"), rs.getString("strt"), rs.getString("remc")) }
|
||||
assertEquals(listOf(null, "YY", "07SEP261605", "Flight Delayed"), delay)
|
||||
val routes = ds.query(
|
||||
"SELECT route_kind, source_seq, apcd, scdt FROM flight_route_point WHERE flid = ? ORDER BY ordinal",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> listOf(rs.getString("route_kind"), rs.getString("source_seq"), rs.getString("apcd"), rs.getString("scdt")) }
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf("ROUT", "1", "ORD", "07SEP261315"),
|
||||
listOf("ROUT", "2", "MSP", null),
|
||||
),
|
||||
routes,
|
||||
)
|
||||
val bridge = ds.queryOne(
|
||||
"SELECT source_seq, abdg, abop, aotm FROM flight_bridge_op WHERE flid = ?",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> listOf(rs.getString("source_seq"), rs.getString("abdg"), rs.getString("abop"), rs.getString("aotm")) }
|
||||
assertEquals(listOf("1", "B01", "A", "07SEP261700"), bridge)
|
||||
// 异常对象 = 主表前缀标量列;读侧视图重建 FDIV 键
|
||||
val fdiv = ds.queryOne(
|
||||
"SELECT fdiv_ddes, fdiv_ddir, fdiv_remc FROM flight_schd WHERE flid = ?",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> listOf(rs.getString("fdiv_ddes"), rs.getString("fdiv_ddir"), rs.getString("fdiv_remc")) }
|
||||
assertEquals(listOf("PEK", "TO", "weather"), fdiv)
|
||||
val fields = repo.findByFlid(flid)!!
|
||||
assertEquals("YY", mapper.readTree(fields["DELY"])[0]["CODE"].asText())
|
||||
assertEquals(2, mapper.readTree(fields["ROUT"]).size())
|
||||
assertEquals("A", mapper.readTree(fields["ABTM"])[0]["ABOP"].asText())
|
||||
assertEquals("PEK", mapper.readTree(fields["FDIV"])["DDES"].asText())
|
||||
|
||||
// 增量:空延误数组 = 集合替换为空 → 明细行删除;航路整集合替换
|
||||
repo.persistNextStates(
|
||||
null,
|
||||
listOf(
|
||||
engine.apply(
|
||||
repo.findNextStateByFlid(flid),
|
||||
engine.commandsFromFields(
|
||||
flid,
|
||||
mapOf(
|
||||
"DELY" to "[]",
|
||||
"ROUT" to """[{"RTNO":"1","APCD":"CTU"}]""",
|
||||
),
|
||||
),
|
||||
"msg-scalar-2", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = false,
|
||||
)
|
||||
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
|
||||
assertNull(repo.findByFlid(flid)!!["DELY"])
|
||||
val replacedRoute = mapper.readTree(repo.findByFlid(flid)!!["ROUT"])
|
||||
assertEquals(1, replacedRoute.size())
|
||||
assertEquals("CTU", replacedRoute[0]["APCD"].asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PIPELINE_LOCK row lock guards the writer transaction and releases on commit`() {
|
||||
val txManager = JdbcPipelineTransactionManager(ds)
|
||||
|
||||
// 持锁事务内:另一连接 NOWAIT 获取同行锁必须立即失败(互斥生效)
|
||||
assertEquals("held", txManager.inTransaction {
|
||||
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { other ->
|
||||
other.autoCommit = false
|
||||
try {
|
||||
other.prepareStatement(
|
||||
"SELECT lock_key FROM pipeline_lock WHERE lock_key = 'FLIGHT_SCHD_WRITER' FOR UPDATE NOWAIT",
|
||||
).use { ps -> ps.executeQuery() }
|
||||
throw AssertionError("expected PIPELINE_LOCK contention")
|
||||
} catch (_: java.sql.SQLException) {
|
||||
// 期望:行锁已被写者事务持有
|
||||
} finally {
|
||||
other.rollback()
|
||||
}
|
||||
}
|
||||
"held"
|
||||
})
|
||||
|
||||
// 提交后锁释放:可再次进入,且种子行完整
|
||||
assertEquals("again", txManager.inTransaction { "again" })
|
||||
val owner = ds.queryOne(
|
||||
"SELECT owner_info FROM pipeline_lock WHERE lock_key = 'FLIGHT_SCHD_WRITER'",
|
||||
{},
|
||||
) { rs -> rs.getString("owner_info") }
|
||||
assertNull(owner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - SCHD_GEN SQL CAS version advance and conflict rejection`() {
|
||||
val day = "2026-09-07"
|
||||
|
||||
// 初始 CAS: expected = 0,版本推进至 1
|
||||
val gen1 = FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_A", "TEST_B"))
|
||||
val ok1 = repo.putGenIfVersion(day, 0L, gen1)
|
||||
assertTrue(ok1)
|
||||
|
||||
val stored1 = repo.getGen(day)
|
||||
assertNotNull(stored1)
|
||||
assertEquals(1L, stored1!!.version)
|
||||
assertEquals(setOf("TEST_A", "TEST_B"), stored1.flids)
|
||||
|
||||
// 成功 CAS: expected = 1,版本推进至 2
|
||||
val gen2 = FlightSchdRepository.GenMeta(day, 2L, setOf("TEST_A", "TEST_C"))
|
||||
val ok2 = repo.putGenIfVersion(day, 1L, gen2)
|
||||
assertTrue(ok2)
|
||||
assertEquals(2L, repo.getGen(day)!!.version)
|
||||
|
||||
// 冲突 CAS: expected 依然传 1(已过期的旧版本),应被拒并返回 false
|
||||
val genConflict = FlightSchdRepository.GenMeta(day, 3L, setOf("TEST_CONFLICT"))
|
||||
val conflictResult = repo.putGenIfVersion(day, 1L, genConflict)
|
||||
assertFalse(conflictResult)
|
||||
|
||||
// 确认版本未被非法篡改,仍为 2
|
||||
assertEquals(2L, repo.getGen(day)!!.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - Transaction rollback leaves zero partial rows on failure`() {
|
||||
val day = "2026-09-07"
|
||||
try {
|
||||
ds.withTransaction {
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
snapshotStates(day, listOf("TEST_TX_01" to mapOf("REMC" to "tx"))),
|
||||
snapshotReplace = true,
|
||||
)
|
||||
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TX_01")))
|
||||
// 模拟事务内部抛出异常
|
||||
throw IllegalStateException("forced-abort-for-rollback-test")
|
||||
}
|
||||
} catch (_: IllegalStateException) {
|
||||
// 异常捕获
|
||||
}
|
||||
|
||||
// 断言:由于事务原子回滚,数据库中零残留
|
||||
assertNull(repo.findByFlid("TEST_TX_01"))
|
||||
assertNull(repo.getGen(day))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - ES history sweep deleteByFlids idempotent batch execution`() {
|
||||
val day = "2026-09-07"
|
||||
val flids = (1..50).map { "TEST_SWEEP_$it" }
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
snapshotStates(day, flids.map { it to mapOf("FLID" to it, "GTDT" to """[{"GTNO":"1","GATE":"A"}]""") }),
|
||||
snapshotReplace = true,
|
||||
)
|
||||
assertEquals(50, repo.findByFlids(flids).size)
|
||||
assertEquals(50, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid LIKE 'TEST_SWEEP_%'", {}) { rs -> rs.getInt(1) }.first())
|
||||
|
||||
// 第一次分批删除:主表与明细级联删除
|
||||
val deleted1 = repo.deleteByFlids(flids.toSet())
|
||||
assertEquals(50, deleted1)
|
||||
assertEquals(0, repo.findByFlids(flids).size)
|
||||
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid LIKE 'TEST_SWEEP_%'", {}) { rs -> rs.getInt(1) }.first())
|
||||
|
||||
// 重放删除:安全返回 0,无副作用
|
||||
val deleted2 = repo.deleteByFlids(flids.toSet())
|
||||
assertEquals(0, deleted2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - non-UTC JVM default timezone preserves exact Instant timestamps without drift`() {
|
||||
val originalTz = TimeZone.getDefault()
|
||||
try {
|
||||
// 1. 设置 JVM 默认时区为东京 (UTC+09:00)
|
||||
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
|
||||
val day = "2026-09-07"
|
||||
val fixedInstant = Instant.parse("2026-09-07T08:30:15.123456Z")
|
||||
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
snapshotStates(day, listOf("TEST_TZ_01" to mapOf("FLID" to "TEST_TZ_01"))),
|
||||
snapshotReplace = true,
|
||||
now = fixedInstant,
|
||||
)
|
||||
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TZ_01")), fixedInstant)
|
||||
|
||||
// 东京时区下回读
|
||||
val genTokyo = repo.getGen(day)
|
||||
assertNotNull(genTokyo)
|
||||
assertEquals(fixedInstant, genTokyo!!.updatedAt)
|
||||
|
||||
// 2. 切换 JVM 默认时区为纽约 (UTC-05:00)
|
||||
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
|
||||
|
||||
// 纽约时区下再次回读同一条记录
|
||||
val genNy = repo.getGen(day)
|
||||
assertNotNull(genNy)
|
||||
// 断言:TIMESTAMPTZ 映射到 Instant 完全相等,绝无 JVM 时区偏移
|
||||
assertEquals(fixedInstant, genNy!!.updatedAt)
|
||||
|
||||
val flightNy = ds.queryOne("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_TZ_01'", {}) { rs ->
|
||||
rs.getInstant("created_at") to rs.getInstant("updated_at")
|
||||
}
|
||||
assertNotNull(flightNy)
|
||||
assertEquals(fixedInstant, flightNy!!.first)
|
||||
assertEquals(fixedInstant, flightNy.second)
|
||||
} finally {
|
||||
TimeZone.setDefault(originalTz)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - non-UTC database session timezone produces zero Instant drift across sessions`() {
|
||||
val day = "2026-09-07"
|
||||
val writeInstant = Instant.parse("2026-09-07T14:45:00.654321Z")
|
||||
|
||||
// 1. 在会话 1 中显式设置会话时区为 'Asia/Shanghai' (+08:00) 并写入数据
|
||||
ds.connection.use { conn ->
|
||||
conn.createStatement().use { it.execute("SET TIME ZONE 'Asia/Shanghai'") }
|
||||
conn.prepareStatement(
|
||||
"""
|
||||
INSERT INTO flight_schd (flid, fday, remc, created_at, updated_at)
|
||||
VALUES ('TEST_SESS_01', '2026-09-07', 'shanghai', ?, ?)
|
||||
""".trimIndent(),
|
||||
).use { ps ->
|
||||
ps.setTimestamp(1, writeInstant.toSqlTimestamp())
|
||||
ps.setTimestamp(2, writeInstant.toSqlTimestamp())
|
||||
ps.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 在会话 2 中显式设置会话时区为 'America/Chicago' (-05:00) 并回读数据
|
||||
val instantChicago = ds.connection.use { conn ->
|
||||
conn.createStatement().use { it.execute("SET TIME ZONE 'America/Chicago'") }
|
||||
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
|
||||
ps.executeQuery().use { rs ->
|
||||
assertTrue(rs.next())
|
||||
rs.getInstant("created_at")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 在会话 3 中显式设置会话时区为 'UTC' 并回读数据
|
||||
val instantUtc = ds.connection.use { conn ->
|
||||
conn.createStatement().use { it.execute("SET TIME ZONE 'UTC'") }
|
||||
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
|
||||
ps.executeQuery().use { rs ->
|
||||
assertTrue(rs.next())
|
||||
rs.getInstant("created_at")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 断言:跨不同数据库会话时区(+08:00 vs -05:00 vs UTC),读取出的 Instant 与写入的 UTC Instant 完全一致!
|
||||
assertEquals(writeInstant, instantChicago)
|
||||
assertEquals(writeInstant, instantUtc)
|
||||
assertEquals(instantChicago, instantUtc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - persistNextStates roundtrip preserves collections source_seq and state_version`() {
|
||||
val day = "2026-09-07"
|
||||
val flid = "TEST_V2_ROUNDTRIP"
|
||||
val gates = listOf(
|
||||
mapOf("GTNO" to "1", "GATE" to "G28", "GTYP" to "D"),
|
||||
mapOf("GTNO" to "3", "GATE" to "G33", "GTYP" to "I"),
|
||||
mapOf("GTNO" to "5", "GATE" to "G23", "GTYP" to "D"),
|
||||
)
|
||||
val state = com.gzzn.omms.msgexchange.domain.flight.FlightNextState(
|
||||
flid = flid,
|
||||
scalars = mapOf("FLNO" to "CA888"),
|
||||
collections = mapOf("GTDT" to gates),
|
||||
stateVersion = 1L,
|
||||
lastMessageId = "msg-dnld-001",
|
||||
)
|
||||
|
||||
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
|
||||
|
||||
val roundtrip = repo.findNextStateByFlid(flid)!!
|
||||
assertEquals(1L, roundtrip.stateVersion)
|
||||
assertEquals("msg-dnld-001", roundtrip.lastMessageId)
|
||||
// 明细表读回保序保源序号;未填属性以空串回填,按语义字段断言
|
||||
val roundtripGates = roundtrip.collections["GTDT"]!!
|
||||
assertEquals(3, roundtripGates.size)
|
||||
gates.forEachIndexed { i, expected ->
|
||||
val actual = roundtripGates[i]
|
||||
assertEquals(expected["GTNO"], actual["GTNO"])
|
||||
assertEquals(expected["GATE"], actual["GATE"])
|
||||
assertEquals(expected["GTYP"], actual["GTYP"])
|
||||
}
|
||||
|
||||
val replaced = state.copy(
|
||||
collections = mapOf("GTDT" to listOf(mapOf("GTNO" to "2", "GATE" to "Z9"))),
|
||||
stateVersion = 2L,
|
||||
lastMessageId = "msg-flop-002",
|
||||
)
|
||||
repo.persistNextStates(null, listOf(replaced), snapshotReplace = false)
|
||||
assertEquals("Z9", repo.findNextStateByFlid(flid)!!.collections["GTDT"]!![0]["GATE"])
|
||||
assertEquals(2L, repo.findNextStateByFlid(flid)!!.stateVersion)
|
||||
val detailRows = ds.query(
|
||||
"SELECT ordinal, source_seq, gate FROM flight_gate WHERE flid = ? ORDER BY ordinal",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> Triple(rs.getInt("ordinal"), rs.getString("source_seq"), rs.getString("gate")) }
|
||||
assertEquals(1, detailRows.size)
|
||||
assertEquals(Triple(1, "2", "Z9"), detailRows[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - persistNextStates leaves all legacy collection storage columns null`() {
|
||||
val day = "2026-09-07"
|
||||
val flid = "TEST_V2_ALL_COLS"
|
||||
val state = com.gzzn.omms.msgexchange.domain.flight.FlightNextState(
|
||||
flid = flid,
|
||||
scalars = mapOf("FLNO" to "MU123"),
|
||||
collections = mapOf(
|
||||
"GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "G1", "GTYP" to "D")),
|
||||
"CKDT" to listOf(mapOf("CKNO" to "1", "CHKC" to "C1", "CTYP" to "Eco")),
|
||||
"CLDT" to listOf(mapOf("CLNO" to "1", "BELT" to "B1", "BTYP" to "D")),
|
||||
"DELY" to listOf(mapOf("DLNO" to "1", "CODE" to "01", "STRT" to "1200", "DURA" to "30")),
|
||||
"ABTM" to listOf(mapOf("ASNO" to "1", "ABDG" to "B12", "ABOP" to "A", "AOTM" to "0730")),
|
||||
"CHOT" to listOf(mapOf("CSNO" to "1", "CHID" to "ON", "CHTM" to "0800")),
|
||||
"ROUT" to listOf(mapOf("RTNO" to "1", "APCD" to "CTU")),
|
||||
),
|
||||
stateVersion = 1L,
|
||||
lastMessageId = "msg-dnld-002",
|
||||
)
|
||||
|
||||
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
|
||||
assertEquals("MU123", repo.findByFlid(flid)!!["FLNO"])
|
||||
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
|
||||
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - schd_gen stores last_message_id and rejects stale CAS without advancing`() {
|
||||
val day = "2026-09-07"
|
||||
|
||||
// 初始提交:last_message_id = "m1",版本 1
|
||||
val ok1 = repo.putGenIfVersion(
|
||||
day, 0L,
|
||||
FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_CAS_M"), lastMessageId = "m1"),
|
||||
)
|
||||
assertTrue(ok1)
|
||||
assertEquals("m1", repo.getGen(day)!!.lastMessageId)
|
||||
|
||||
// 重放判定:以过期 expected=0 重试必须失败(同一消息的身份凭 getGen().lastMessageId 判定,不是靠版本相等)
|
||||
val replayAttempt = repo.putGenIfVersion(
|
||||
day, 0L,
|
||||
FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_CAS_M"), lastMessageId = "m1"),
|
||||
)
|
||||
assertFalse(replayAttempt)
|
||||
assertEquals(1L, repo.getGen(day)!!.version)
|
||||
|
||||
// 正常推进 expected=1 → 版本 2,last_message_id 换为 "m2"
|
||||
val ok2 = repo.putGenIfVersion(
|
||||
day, 1L,
|
||||
FlightSchdRepository.GenMeta(day, 2L, setOf("TEST_CAS_M"), lastMessageId = "m2"),
|
||||
)
|
||||
assertTrue(ok2)
|
||||
val gen = repo.getGen(day)!!
|
||||
assertEquals(2L, gen.version)
|
||||
assertEquals("m2", gen.lastMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - flight_schd_display view projects first second resources and totals from detail tables`() {
|
||||
val day = "2026-09-07"
|
||||
val flid = "TEST_DISPLAY"
|
||||
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
listOf(
|
||||
engine.apply(
|
||||
null,
|
||||
engine.commandsFromFields(
|
||||
flid,
|
||||
mapOf(
|
||||
"FLNO" to "CA display",
|
||||
"GTDT" to """[{"GTNO":"1","GATE":"G28"},{"GTNO":"2","GATE":"G33"},{"GTNO":"3","GATE":"G23"}]""",
|
||||
"CKDT" to """[{"CKNO":"1","CHKC":"01","CCLS":"Y"},{"CKNO":"2","CHKC":"17","CCLS":"F"}]""",
|
||||
"CLDT" to """[{"CLNO":"1","BELT":"B01"}]""",
|
||||
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200"}]""",
|
||||
),
|
||||
),
|
||||
"msg-display-1", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = true,
|
||||
)
|
||||
|
||||
val row = ds.queryOne(
|
||||
"""
|
||||
SELECT flno, gate1, gate2, gate_total, chkc1, chkc2, checkin_total, belt1, belt_total, dely_code, dely_strt
|
||||
FROM flight_schd_display WHERE flid = ?
|
||||
""".trimIndent(),
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs ->
|
||||
listOf(
|
||||
rs.getString("flno"), rs.getString("gate1"), rs.getString("gate2"), rs.getInt("gate_total"),
|
||||
rs.getString("chkc1"), rs.getString("chkc2"), rs.getInt("checkin_total"),
|
||||
rs.getString("belt1"), rs.getInt("belt_total"),
|
||||
rs.getString("dely_code"), rs.getString("dely_strt"),
|
||||
)
|
||||
}
|
||||
// 视图提供首个/第二个资源 + 总数;完整明细仍在明细表可查(三门不丢失)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"CA display", "G28", "G33", 3,
|
||||
"01", "17", 2,
|
||||
"B01", 1,
|
||||
"YY", "07SEP261605",
|
||||
),
|
||||
row,
|
||||
)
|
||||
|
||||
// 集合替换后视图随明细表同步(3 门 → 1 门)
|
||||
repo.persistNextStates(
|
||||
null,
|
||||
listOf(
|
||||
engine.apply(
|
||||
repo.findNextStateByFlid(flid),
|
||||
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"5","GATE":"Z1"}]""")),
|
||||
"msg-display-2", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = false,
|
||||
)
|
||||
val after = ds.queryOne(
|
||||
"SELECT gate1, gate2, gate_total FROM flight_schd_display WHERE flid = ?",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> Triple(rs.getString("gate1"), rs.getString("gate2"), rs.getInt("gate_total")) }
|
||||
assertEquals(Triple("Z1", null, 1), after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - exception object explicit clear writes null columns and omits wire key`() {
|
||||
val day = "2026-09-07"
|
||||
val flid = "TEST_EXC_CLEAR"
|
||||
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
|
||||
// 1. 快照写入返航异常对象 → 主表前缀标量列
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
listOf(
|
||||
engine.apply(
|
||||
null,
|
||||
engine.commandsFromFields(flid, mapOf("FRET" to """{"REID":"CA108","RSN":"diverted"}""")),
|
||||
"msg-exc-1", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = true,
|
||||
)
|
||||
val stored1 = ds.queryOne(
|
||||
"SELECT fret_reid, fret_rsn FROM flight_schd WHERE flid = ?",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> listOf(rs.getString("fret_reid"), rs.getString("fret_rsn")) }
|
||||
assertEquals(listOf("CA108", "diverted"), stored1)
|
||||
|
||||
// 2. 增量显式清除(FRET="null")→ 列置 NULL,读视图无 FRET 键(评审 F4 修复闭环)
|
||||
repo.persistNextStates(
|
||||
null,
|
||||
listOf(
|
||||
engine.apply(
|
||||
repo.findNextStateByFlid(flid),
|
||||
engine.commandsFromFields(flid, mapOf("FRET" to "null")),
|
||||
"msg-exc-2", bumpVersion = true,
|
||||
),
|
||||
),
|
||||
snapshotReplace = false,
|
||||
)
|
||||
val stored2 = ds.queryOne(
|
||||
"SELECT fret_reid, fret_rsn FROM flight_schd WHERE flid = ?",
|
||||
{ ps -> ps.setString(1, flid) },
|
||||
) { rs -> listOf(rs.getString("fret_reid"), rs.getString("fret_rsn")) }
|
||||
assertEquals(listOf(null, null), stored2)
|
||||
assertNull(repo.findByFlid(flid)!!["FRET"])
|
||||
assertNull(repo.findNextStateByFlid(flid)!!.scalars["FRET"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - readAfterWrite equals nextState across all structures and wire stays consistent`() {
|
||||
val day = "2026-09-07"
|
||||
val flid = "TEST_MATRIX"
|
||||
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
val fields = mapOf(
|
||||
"FLNO" to "CA_MATRIX",
|
||||
"SODT" to "07SEP260800",
|
||||
"GTDT" to """[{"GTNO":"1","GATE":"G28","GTYP":"D"},{"GTNO":"3","GATE":"G33"},{"GTNO":"3","GATE":"G23"}]""",
|
||||
"CKDT" to """[{"CKNO":"1","CHKC":"01","CCLS":"Y"},{"CKNO":"2","CHKC":"01","CCLS":"F"}]""",
|
||||
"CLDT" to """[{"CLNO":"1","BELT":"B01"},{"CLNO":"2","BELT":"B02"}]""",
|
||||
"PSDT" to """[{"PSNO":"1","PSST":"S209"}]""",
|
||||
"CHDT" to """[{"CHNO":"1","CHUT":"C1"}]""",
|
||||
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200","REMC":"delayed"},{"CODE":"WX","STRT":"07SEP261800","DURA":"0030"}]""",
|
||||
"ABTM" to """[{"ASNO":"1","ABDG":"B01","ABOP":"A","AOTM":"07SEP261700"},{"ASNO":"2","ABDG":"B01","ABOP":"D","AOTM":"07SEP261900"}]""",
|
||||
"CHOT" to """[{"CSNO":"1","CHID":"ON","CHTM":"07SEP261650"},{"CSNO":"2","CHID":"OFF","CHTM":"07SEP261920"}]""",
|
||||
"ROUT" to """[{"RTNO":"1","APCD":"CTU"},{"RTNO":"2","APCD":"PEK","SCDT":"07SEP261100"}]""",
|
||||
"FDIV" to """{"DDES":"PEK","DDIR":"TO"}""",
|
||||
"SRVT" to """[{"SRTC":"WHEEL"}]""",
|
||||
)
|
||||
val state = engine.apply(null, engine.commandsFromFields(flid, fields), "msg-matrix-1", bumpVersion = true)
|
||||
|
||||
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
|
||||
|
||||
// 断言一:readAfterWrite == nextState(评审 F5 的持久化侧闭环)
|
||||
val readBack = repo.findNextStateByFlid(flid)!!
|
||||
assertEquals(state.stateVersion, readBack.stateVersion)
|
||||
assertEquals(state.lastMessageId, readBack.lastMessageId)
|
||||
assertEquals(state.scalars.keys, readBack.scalars.keys)
|
||||
state.scalars.forEach { (k, v) -> assertJsonEquivalent(v, readBack.scalars[k], "scalar[$k]") }
|
||||
assertEquals(state.collections.keys, readBack.collections.keys)
|
||||
state.collections.forEach { (key, items) ->
|
||||
val actual = readBack.collections[key]!!
|
||||
assertEquals(items.size, actual.size, "collection[$key] row count")
|
||||
items.forEachIndexed { i, item ->
|
||||
item.forEach { (attr, v) -> assertEquals(v, actual[i][attr], "collection[$key][$i].$attr") }
|
||||
}
|
||||
}
|
||||
|
||||
// 断言二:wire(nextState) == wire(readAfterWrite)(出站载荷与库态同源,F5 的线格式侧闭环)
|
||||
val wireBefore = mapper.readTree(com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson.toJson(state.toFlightFields()))
|
||||
val wireAfter = mapper.readTree(com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson.toJson(repo.findByFlid(flid)!!))
|
||||
assertEquals(wireBefore, wireAfter)
|
||||
}
|
||||
|
||||
/** JSON 文本按解析树等价比较(键序无关);非 JSON 退化为字符串比较。 */
|
||||
private fun assertJsonEquivalent(expected: String?, actual: String?, path: String) {
|
||||
if (expected == null || actual == null) {
|
||||
assertEquals(expected, actual, path)
|
||||
return
|
||||
}
|
||||
val e = runCatching { mapper.readTree(expected) }.getOrNull()
|
||||
val a = runCatching { mapper.readTree(actual) }.getOrNull()
|
||||
if (e != null && a != null) assertEquals(e, a, path) else assertEquals(expected, actual, path)
|
||||
}
|
||||
|
||||
}
|
||||
+39
-113
@@ -3,40 +3,43 @@ package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
import com.gzzn.omms.msgexchange.support.PgTestSupport
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.sql.DriverManager
|
||||
|
||||
/**
|
||||
* P2-1 验证测试:Flyway 迁移引擎在真实 PostgreSQL 上的端到端自动迁移验证。
|
||||
* 断言:
|
||||
* 1. Flyway 自动执行 V1.0.0__own_pg_pipeline.sql 与 V1.1.0__flight_schd.sql;
|
||||
* 2. 真实落库 flyway_schema_history 元数据表,且 success = true;
|
||||
* 3. 运营航班表 FLIGHT_SCHD 与计划代 SCHD_GEN 结构完整就绪。
|
||||
* Flyway 迁移引擎端到端验证(docs/flight-state.md §3.2 表职责基线):
|
||||
* 1. V1__flight_state_baseline.sql 在真实 PostgreSQL 上自动迁移成功;
|
||||
* 2. flyway_schema_history 落库且 success = true;
|
||||
* 3. 决策层/管道层/留痕层全表就绪;PIPELINE_LOCK 单行种子就位。
|
||||
*/
|
||||
class FlywayMigrationTest {
|
||||
|
||||
private val expectedTables = setOf(
|
||||
"pipeline_lock", "proc_state", "flight_schd",
|
||||
"flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute",
|
||||
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
|
||||
"msg_event", "req_track", "backfill_todo", "schd_snap_log",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `Flyway automatically migrates V1_0_0 and V1_1_0 onto real PostgreSQL`() {
|
||||
fun `Flyway migrates flight-state baseline onto real PostgreSQL`() {
|
||||
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
|
||||
val url = PgTestSupport.jdbcUrl
|
||||
val user = PgTestSupport.user
|
||||
val pass = PgTestSupport.password
|
||||
|
||||
val flyway = Flyway.configure()
|
||||
val migrateResult = Flyway.configure()
|
||||
.dataSource(url, user, pass)
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
|
||||
val migrateResult = flyway.migrate()
|
||||
.migrate()
|
||||
assertTrue(migrateResult.success, "Flyway migration must succeed")
|
||||
|
||||
// 验证 flyway_schema_history 元数据表与执行记录
|
||||
DriverManager.getConnection(url, user, pass).use { conn ->
|
||||
conn.createStatement().use { stmt ->
|
||||
// 迁移记录:单基线 V1
|
||||
stmt.executeQuery(
|
||||
"SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC",
|
||||
).use { rs ->
|
||||
@@ -44,115 +47,38 @@ class FlywayMigrationTest {
|
||||
while (rs.next()) {
|
||||
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
|
||||
}
|
||||
assertTrue(records.size >= 2, "At least 2 migrations must be recorded in flyway_schema_history")
|
||||
assertEquals("1.0.0", records[0].first)
|
||||
assertEquals("V1.0.0__own_pg_pipeline.sql", records[0].second)
|
||||
assertTrue(records.isNotEmpty(), "flyway_schema_history must record migrations")
|
||||
assertEquals("1", records[0].first)
|
||||
assertEquals("V1__flight_state_baseline.sql", records[0].second)
|
||||
assertTrue(records[0].third)
|
||||
|
||||
assertEquals("1.1.0", records[1].first)
|
||||
assertEquals("V1.1.0__flight_schd.sql", records[1].second)
|
||||
assertTrue(records[1].third)
|
||||
}
|
||||
|
||||
// 验证 FLIGHT_SCHD 与 SCHD_GEN 存在
|
||||
// §3.2 全表就绪
|
||||
stmt.executeQuery(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('flight_schd', 'schd_gen')",
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
|
||||
).use { rs ->
|
||||
val tables = mutableSetOf<String>()
|
||||
while (rs.next()) {
|
||||
tables.add(rs.getString("table_name"))
|
||||
}
|
||||
assertTrue(tables.contains("flight_schd"))
|
||||
assertTrue(tables.contains("schd_gen"))
|
||||
while (rs.next()) tables.add(rs.getString("table_name"))
|
||||
val missing = expectedTables - tables
|
||||
assertTrue(missing.isEmpty(), "missing tables: $missing")
|
||||
}
|
||||
|
||||
// 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列
|
||||
stmt.executeQuery(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'flight_schd' " +
|
||||
"AND column_name IN ('flid', 'operation_day', 'state', 'state_version', 'last_msg_id')",
|
||||
).use { rs ->
|
||||
val cols = mutableSetOf<String>()
|
||||
while (rs.next()) cols.add(rs.getString("column_name"))
|
||||
assertEquals(setOf("flid", "operation_day", "state", "state_version", "last_msg_id"), cols)
|
||||
}
|
||||
|
||||
// PIPELINE_LOCK 单行种子(§3.2)
|
||||
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
|
||||
assertTrue(rs.next())
|
||||
assertEquals(1, rs.getInt(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-4:带数据的 V1.1.0 → 最新版本升级链验证(隔离 schema,不污染 public)。
|
||||
* V1.2.0 删除 *_TXT 集合列是「已知损失边界」(评审 F6):标量列与结构完整存活,
|
||||
* 集合文本不可恢复——升级前必须完整导出或重放(docs/flight-state.md)。
|
||||
*/
|
||||
@Test
|
||||
fun `upgrade chain from seeded V1_1_0 data keeps scalars and adds v2 tracking`() {
|
||||
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
|
||||
val schema = "upgrade_chain_test"
|
||||
// 清理历史遗留的隔离 schema
|
||||
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
|
||||
conn.createStatement().use { it.execute("DROP SCHEMA IF EXISTS $schema CASCADE") }
|
||||
}
|
||||
|
||||
// 1. 迁移到 V1.1.0(含 *_TXT 集合文本列形态)并写入带数据行
|
||||
Flyway.configure()
|
||||
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
|
||||
.locations("classpath:db/migration")
|
||||
.schemas(schema)
|
||||
.defaultSchema(schema)
|
||||
.target("1.1.0")
|
||||
.load()
|
||||
.migrate()
|
||||
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
|
||||
conn.createStatement().use {
|
||||
it.execute(
|
||||
"""
|
||||
INSERT INTO $schema.flight_schd (flid, fday, flno, remc, gtdt_txt, created_at, updated_at)
|
||||
VALUES ('UPG_FL_1', '2026-09-07', 'CA_UPG', 'legacy-note', '[{"GTNO":"1","GATE":"G1"}]', now(), now())
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 一路升级到最新版本
|
||||
val result = Flyway.configure()
|
||||
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
|
||||
.locations("classpath:db/migration")
|
||||
.schemas(schema)
|
||||
.defaultSchema(schema)
|
||||
.load()
|
||||
.migrate()
|
||||
assertTrue(result.success)
|
||||
|
||||
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
|
||||
conn.createStatement().use { stmt ->
|
||||
// 3. 标量数据存活;v2 追踪列就位(存量行 state_version 默认 0)
|
||||
stmt.executeQuery(
|
||||
"SELECT flno, remc, state_version, last_message_id FROM $schema.flight_schd WHERE flid = 'UPG_FL_1'",
|
||||
).use { rs ->
|
||||
assertTrue(rs.next())
|
||||
assertEquals("CA_UPG", rs.getString("flno"))
|
||||
assertEquals("legacy-note", rs.getString("remc"))
|
||||
assertEquals(0L, rs.getLong("state_version"))
|
||||
assertNull(rs.getString("last_message_id"))
|
||||
}
|
||||
|
||||
// 4. 已知损失边界:*_TXT 集合列已不存在(升级前须导出/重放,评审 F6)
|
||||
stmt.executeQuery(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_schema = '$schema' AND table_name = 'flight_schd' AND column_name LIKE '%\\_TXT'",
|
||||
).use { rs ->
|
||||
assertFalse(rs.next(), "legacy *_TXT columns must be gone after V1.2.0 (documented lossy boundary)")
|
||||
}
|
||||
|
||||
// 5. v2 结构就位:display 视图可查询(存量行 gate_total=0)、backfill_todo 与明细表存在
|
||||
stmt.executeQuery(
|
||||
"SELECT flno, gate_total FROM $schema.flight_schd_display WHERE flid = 'UPG_FL_1'",
|
||||
).use { rs ->
|
||||
assertTrue(rs.next())
|
||||
assertEquals("CA_UPG", rs.getString("flno"))
|
||||
assertEquals(0, rs.getInt("gate_total"))
|
||||
}
|
||||
stmt.executeQuery("SELECT COUNT(*) FROM $schema.backfill_todo").use { rs ->
|
||||
assertTrue(rs.next())
|
||||
}
|
||||
stmt.executeQuery("SELECT COUNT(*) FROM $schema.flight_gate WHERE flid = 'UPG_FL_1'").use { rs ->
|
||||
assertTrue(rs.next())
|
||||
assertEquals(0, rs.getInt(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
|
||||
conn.createStatement().use { it.execute("DROP SCHEMA IF EXISTS $schema CASCADE") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,15 @@ class ReplayServiceTest {
|
||||
rows[id] = ProcState(id, status, identityKey = "k$id", attempts = 3, errorClass = ec, lastError = "x")
|
||||
}
|
||||
|
||||
override fun insert(cminmsgsId: Long, state: ProcStatus) = Unit
|
||||
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
|
||||
override fun insert(msgId: Long, state: ProcStatus) = Unit
|
||||
override fun exists(msgId: Long): Boolean = rows.containsKey(msgId)
|
||||
override fun find(msgId: Long): ProcState? = rows[msgId]
|
||||
override fun findSuccessTerminal(msgId: Long): Boolean = rows[msgId]?.state == ProcStatus.SUCCEEDED
|
||||
override fun headUnfinished(): ProcState? = null
|
||||
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean = true
|
||||
override fun tryBindIdentity(msgId: Long, identityKey: String): Boolean = true
|
||||
override fun ownerOfIdentity(identityKey: String): Long? = null
|
||||
override fun update(
|
||||
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
|
||||
msgId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
|
||||
errorClass: ErrorClass?, lastError: String?,
|
||||
) = Unit
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ class BackfillSweepJobTest {
|
||||
|
||||
assertEquals(BackfillSweepJob.SweepOutcome(inspected = 1, succeeded = 0, failed = 1), outcome)
|
||||
assertEquals(1, todo.count())
|
||||
assertEquals(1, todo.tasks().single().attempts)
|
||||
assertEquals(1, todo.tasks.values.single().attempts)
|
||||
assertEquals("mysql-down", todo.lastErrorOf(901L))
|
||||
// 一次失败后 30s 内不再到期(退避生效)
|
||||
assertEquals(emptyList<BackfillTodoRepository.BackfillTask>(), todo.findDue(t0.plusSeconds(29)))
|
||||
assertEquals(0, todo.findDue(t0.plusSeconds(29)).size)
|
||||
assertEquals(1, todo.findDue(t0.plusSeconds(30)).size)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +1,103 @@
|
||||
package com.gzzn.omms.msgexchange.jobs
|
||||
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
|
||||
import com.gzzn.omms.msgexchange.support.seedSnapshot
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import com.gzzn.omms.msgexchange.config.HistoryProps
|
||||
import com.gzzn.omms.msgexchange.domain.EventType
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import 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.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* ACM2-28 FS6 历史清场测试:
|
||||
* 覆盖 ES 全成功、部分成功、全部失败、ES 成功后 PG 删除前崩溃、PG 删除重放五种场景;
|
||||
* 断言失败项不丢、成功项最终删除、重复执行无副作用。
|
||||
* 历史归档与物理清除(docs/flight-state.md §8.2,顺序不可颠倒):
|
||||
* 历史存储未接通必须删 0 条;先归档确认再物理清除;
|
||||
* 未经 FDEL 的航班在清除前补发删除事件。
|
||||
*/
|
||||
class HistorySweepJobTest {
|
||||
|
||||
private lateinit var flightSchd: StubFlightSchd
|
||||
private lateinit var sweepJob: HistorySweepJob
|
||||
private val now: Instant = Instant.parse("2026-09-09T00:00:00Z")
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
flightSchd = StubFlightSchd()
|
||||
sweepJob = HistorySweepJob(flightSchd)
|
||||
HistorySweepJob.historyPicker = null
|
||||
HistorySweepJob.esArchiver = null
|
||||
HistorySweepJob.cutoffProvider = null
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
HistorySweepJob.historyPicker = null
|
||||
HistorySweepJob.esArchiver = null
|
||||
HistorySweepJob.cutoffProvider = null
|
||||
}
|
||||
|
||||
// 1. 场景一:ES 全成功
|
||||
@Test
|
||||
fun `Scenario 1 - ES All Success removes all confirmed flights and cleans old gens`() {
|
||||
val flights = listOf(
|
||||
"F1" to mapOf("FLID" to "F1"),
|
||||
"F2" to mapOf("FLID" to "F2"),
|
||||
"F3" to mapOf("FLID" to "F3"),
|
||||
private fun seededFlight(flid: String, deleted: Boolean, idleDays: Long): StubFlightState {
|
||||
val f = StubFlightState()
|
||||
f.persistFullState(
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
|
||||
flid, java.time.LocalDate.of(2026, 9, 1), if (deleted) FlightState.DELETED else FlightState.ACTIVE,
|
||||
1, mapOf("FLNO" to "CA001"), emptyMap(),
|
||||
),
|
||||
msgId = 1,
|
||||
now = now.minusSeconds(idleDays * 86400),
|
||||
)
|
||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
||||
flightSchd.putGenIfVersion("2026-09-01", 0L, FlightSchdRepository.GenMeta("2026-09-01", 1L, setOf("F1", "F2", "F3")))
|
||||
|
||||
// 模拟判史:F1, F2, F3 全部为历史候选
|
||||
HistorySweepJob.historyPicker = { it }
|
||||
// 模拟 ES 写入:全部成功返回
|
||||
HistorySweepJob.esArchiver = { it.keys }
|
||||
// 模拟历史代清理:清理 2026-09-02 之前的代
|
||||
HistorySweepJob.cutoffProvider = { "2026-09-02" }
|
||||
|
||||
sweepJob.run()
|
||||
|
||||
// 断言:PG 中 F1, F2, F3 全部被删除
|
||||
assertNull(flightSchd.findByFlid("F1"))
|
||||
assertNull(flightSchd.findByFlid("F2"))
|
||||
assertNull(flightSchd.findByFlid("F3"))
|
||||
// 断言:过期代元数据被清理
|
||||
assertNull(flightSchd.getGen("2026-09-01"))
|
||||
return f
|
||||
}
|
||||
|
||||
// 2. 场景二:ES 部分成功
|
||||
@Test
|
||||
fun `Scenario 2 - ES Partial Success deletes only successful flights and keeps failed ones`() {
|
||||
val flights = listOf(
|
||||
"F1" to mapOf("FLID" to "F1"),
|
||||
"F2" to mapOf("FLID" to "F2"),
|
||||
"F3" to mapOf("FLID" to "F3"),
|
||||
)
|
||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
||||
private class RecordingHistoryStore : HistorySweepJob.HistoryStore {
|
||||
val archivedBatches = mutableListOf<List<HistoryCandidate>>()
|
||||
val confirmed = mutableSetOf<String>()
|
||||
var fail = false
|
||||
|
||||
HistorySweepJob.historyPicker = { it }
|
||||
// 模拟 ES 写入:F1 和 F2 成功,F3 失败
|
||||
HistorySweepJob.esArchiver = { setOf("F1", "F2") }
|
||||
|
||||
sweepJob.run()
|
||||
|
||||
// 断言:成功项最终删除
|
||||
assertNull(flightSchd.findByFlid("F1"))
|
||||
assertNull(flightSchd.findByFlid("F2"))
|
||||
|
||||
// 断言:失败项 F3 绝不丢失,继续保留在 PG 中供下次重试
|
||||
assertNotNull(flightSchd.findByFlid("F3"))
|
||||
}
|
||||
|
||||
// 3. 场景三:ES 全部失败
|
||||
@Test
|
||||
fun `Scenario 3 - ES All Failure deletes nothing and keeps all candidates in PG`() {
|
||||
val flights = listOf(
|
||||
"F1" to mapOf("FLID" to "F1"),
|
||||
"F2" to mapOf("FLID" to "F2"),
|
||||
)
|
||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
||||
|
||||
HistorySweepJob.historyPicker = { it }
|
||||
// 模拟 ES 全部写入失败
|
||||
HistorySweepJob.esArchiver = { emptySet() }
|
||||
|
||||
sweepJob.run()
|
||||
|
||||
// 断言:PG 未执行任何删除,全部候选均安全保留
|
||||
assertNotNull(flightSchd.findByFlid("F1"))
|
||||
assertNotNull(flightSchd.findByFlid("F2"))
|
||||
}
|
||||
|
||||
// 4. 场景四:ES 成功后 PG 删除前崩溃重放
|
||||
@Test
|
||||
fun `Scenario 4 - Crash after ES success before PG delete recovers idempotently on next run`() {
|
||||
val flights = listOf(
|
||||
"F1" to mapOf("FLID" to "F1"),
|
||||
)
|
||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
||||
|
||||
// 第一次运行模拟:ES 写入成功,但在调用 deleteByFlids 前崩溃(PG 依然保留 F1)
|
||||
val esUpserted = mutableSetOf<String>()
|
||||
var crashed = false
|
||||
|
||||
HistorySweepJob.historyPicker = { it }
|
||||
HistorySweepJob.esArchiver = {
|
||||
esUpserted.addAll(it.keys)
|
||||
if (!crashed) {
|
||||
crashed = true
|
||||
throw RuntimeException("crash-before-pg-delete")
|
||||
}
|
||||
it.keys
|
||||
override fun archive(candidates: List<HistoryCandidate>): Set<String> {
|
||||
archivedBatches.add(candidates)
|
||||
if (fail) return emptySet()
|
||||
return candidates.map { it.flid }.toSet().intersect(confirmed.ifEmpty { candidates.map { it.flid }.toSet() })
|
||||
}
|
||||
|
||||
try {
|
||||
sweepJob.run()
|
||||
} catch (_: RuntimeException) {
|
||||
// 崩溃发生
|
||||
}
|
||||
|
||||
// 崩溃后断言:ES 已有数据,但 PG 中 F1 仍然存在作为可恢复事实
|
||||
assertTrue(esUpserted.contains("F1"))
|
||||
assertNotNull(flightSchd.findByFlid("F1"))
|
||||
|
||||
// 下一次作业重试:
|
||||
sweepJob.run()
|
||||
|
||||
// 断言:重试成功,PG 中 F1 最终完成删除
|
||||
assertNull(flightSchd.findByFlid("F1"))
|
||||
}
|
||||
|
||||
// 5. 场景五:PG 删除重复执行(无副作用与幂等性)
|
||||
@Test
|
||||
fun `Scenario 5 - Replaying deleteByFlids is completely idempotent with no side effects`() {
|
||||
val flights = listOf("F1" to mapOf("FLID" to "F1"))
|
||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
||||
fun `history store not connected must delete zero rows`() {
|
||||
val f = seededFlight("F1", deleted = true, idleDays = 30)
|
||||
val events = StubMsgEvents()
|
||||
val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false })
|
||||
|
||||
// 首次删除
|
||||
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
|
||||
assertEquals(1, deletedCount1)
|
||||
assertNull(flightSchd.findByFlid("F1"))
|
||||
val outcome = job.run(now)
|
||||
|
||||
// 重复执行删除(重放)
|
||||
val deletedCount2 = flightSchd.deleteByFlids(setOf("F1"))
|
||||
// 断言:允许重放时影响行数为 0,不抛出异常,无任何副作用
|
||||
assertEquals(0, deletedCount2)
|
||||
assertNull(flightSchd.findByFlid("F1"))
|
||||
assertEquals(0, outcome.purged) // §8.2 红线:先删历史再补当前态绝不允许
|
||||
assertTrue(f.findMainRow("F1") != null)
|
||||
assertEquals(0, events.rows.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archive success precedes physical purge and confirmed rows only`() {
|
||||
val f = seededFlight("F1", deleted = true, idleDays = 30)
|
||||
f.persistFullState(
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
|
||||
"F2", java.time.LocalDate.of(2026, 9, 1), FlightState.DELETED, 1, mapOf("FLNO" to "CA002"), emptyMap(),
|
||||
),
|
||||
msgId = 2, now = now.minusSeconds(30 * 86400),
|
||||
)
|
||||
val events = StubMsgEvents()
|
||||
val store = RecordingHistoryStore().apply { confirmed.add("F1") } // 只有 F1 归档确认成功
|
||||
val job = HistorySweepJob(
|
||||
f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
|
||||
)
|
||||
|
||||
val outcome = job.run(now)
|
||||
|
||||
assertEquals(2, outcome.selected)
|
||||
assertEquals(1, outcome.archived)
|
||||
assertEquals(1, outcome.purged)
|
||||
assertEquals(null, f.findMainRow("F1")) // §8.2 步骤 3:确认成功 → 物理删除
|
||||
assertTrue(f.findMainRow("F2") != null) // 失败或不明确的保留重试(步骤 5)
|
||||
assertEquals(0, events.rows.size) // DELETED 航班清除不再发业务删除事件
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never-fdel lifecycle purge emits tombstone before deletion`() {
|
||||
val f = seededFlight("F3", deleted = false, idleDays = 30) // ACTIVE 且超兜底窗(§8.1)
|
||||
val events = StubMsgEvents()
|
||||
val store = RecordingHistoryStore()
|
||||
val job = HistorySweepJob(
|
||||
f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
|
||||
)
|
||||
|
||||
job.run(now)
|
||||
|
||||
// §7.3/§8.2 步骤 4:未经 FDEL 的航班,清除前补发一次删除事件
|
||||
val tombstone = events.rows.values.single { it.eventType == EventType.TOMBSTONE }
|
||||
assertEquals("F3", tombstone.partitionKey)
|
||||
assertTrue(tombstone.payloadJson.contains("\"deleted\":true"))
|
||||
assertEquals(null, f.findMainRow("F3"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.support
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.FlightChange
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
|
||||
import java.time.Instant
|
||||
|
||||
/** 测试种子助手:v2 写路径(persistNextStates)等价替代已退场的 legacy upsert(P4)。 */
|
||||
fun seedSnapshot(
|
||||
repo: FlightSchdRepository,
|
||||
day: String,
|
||||
flights: List<Pair<String, FlightFields>>,
|
||||
now: Instant = Instant.now(),
|
||||
) {
|
||||
repo.persistNextStates(
|
||||
day,
|
||||
flights.map { (flid, fields) ->
|
||||
FlightStateEngine.apply(
|
||||
null,
|
||||
FlightStateEngine.commandsFromFields(flid, fields),
|
||||
"seed-$day",
|
||||
bumpVersion = false,
|
||||
).copy(stateVersion = 1L)
|
||||
},
|
||||
snapshotReplace = true,
|
||||
now = now,
|
||||
)
|
||||
}
|
||||
|
||||
fun seedIncremental(repo: FlightSchdRepository, changes: List<FlightChange>, messageId: String = "seed-inc") {
|
||||
repo.persistNextStates(
|
||||
null,
|
||||
changes.map { change ->
|
||||
FlightStateEngine.apply(
|
||||
repo.findNextStateByFlid(change.flid),
|
||||
FlightStateEngine.commandsFromFields(change.flid, change.fields),
|
||||
messageId,
|
||||
bumpVersion = true,
|
||||
)
|
||||
},
|
||||
snapshotReplace = false,
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.support
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
|
||||
import java.time.Instant
|
||||
|
||||
/** v2 写路径守卫(P4 退场后):persistNextStates 是唯一写路径,legacy upsert 入口已从接口删除。 */
|
||||
class GuardedFlightSchd(
|
||||
private val inner: FlightSchdRepository = StubFlightSchd(),
|
||||
) : FlightSchdRepository {
|
||||
var persistNextStatesCalls = 0
|
||||
|
||||
override fun persistNextStates(day: String?, states: List<FlightNextState>, snapshotReplace: Boolean, now: Instant) {
|
||||
persistNextStatesCalls++
|
||||
inner.persistNextStates(day, states, snapshotReplace, now)
|
||||
}
|
||||
|
||||
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = inner.deleteDiffByDay(day, delFlids)
|
||||
override fun findByFlid(flid: String): FlightFields? = inner.findByFlid(flid)
|
||||
override fun findNextStateByFlid(flid: String): FlightNextState? = inner.findNextStateByFlid(flid)
|
||||
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> = inner.findByFlids(flids)
|
||||
override fun findByDay(day: String): List<Pair<String, FlightFields>> = inner.findByDay(day)
|
||||
override fun findAll(): Map<String, FlightFields> = inner.findAll()
|
||||
override fun deleteByFlids(flids: Set<String>): Int = inner.deleteByFlids(flids)
|
||||
override fun getGen(day: String): FlightSchdRepository.GenMeta? = inner.getGen(day)
|
||||
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean =
|
||||
inner.putGenIfVersion(day, expected, newGen, now)
|
||||
override fun deleteGenBefore(cutoffDay: String): Int = inner.deleteGenBefore(cutoffDay)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.tools
|
||||
|
||||
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
|
||||
|
||||
class FlightStoreDiffToolTest {
|
||||
|
||||
private val tool = FlightStoreDiffTool()
|
||||
|
||||
@Test
|
||||
fun `Field map comparison ignores legacy JSON key ordering differences`() {
|
||||
val pg = mapOf(
|
||||
"F1" to mapOf(
|
||||
"flid" to "F1",
|
||||
"airline" to "CA",
|
||||
"flightNo" to "123",
|
||||
"status" to "SCHD",
|
||||
),
|
||||
)
|
||||
val legacy = mapOf(
|
||||
"F1" to """{"status":"SCHD","flightNo":"123","airline":"CA","flid":"F1"}""",
|
||||
)
|
||||
|
||||
val report = tool.diff(pg, legacy)
|
||||
assertTrue(report.isGreen)
|
||||
assertEquals(1, report.matchedCount)
|
||||
assertEquals(0, report.unexpectedDeviations.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Field map comparison normalizes numeric precision and null equivalents`() {
|
||||
val pg = mapOf(
|
||||
"F1" to mapOf(
|
||||
"flid" to "F1",
|
||||
"weight" to "100.0",
|
||||
"delay" to "0",
|
||||
),
|
||||
)
|
||||
val legacy = mapOf(
|
||||
"F1" to """{"flid":"F1","weight":100,"delay":0.0,"extra":null}""",
|
||||
)
|
||||
|
||||
val report = tool.diff(pg, legacy)
|
||||
assertTrue(report.isGreen)
|
||||
assertEquals(1, report.matchedCount)
|
||||
assertEquals(0, report.unexpectedDeviations.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Recognizes known deviation 1 - Cross-day migrated flights protected in PG`() {
|
||||
// PG 中包含跨日迁移未被误删的航班,Legacy Redis 中已被误删
|
||||
val pg = mapOf(
|
||||
"F_STABLE" to mapOf("flid" to "F_STABLE"),
|
||||
"F_MIGRATED" to mapOf("flid" to "F_MIGRATED", "FDAY" to "2026-09-08"),
|
||||
)
|
||||
val legacy = mapOf(
|
||||
"F_STABLE" to """{"flid":"F_STABLE"}""",
|
||||
// F_MIGRATED 在 legacy 中被按旧代差删误删
|
||||
)
|
||||
|
||||
val report = tool.diff(pg, legacy, crossDayMigratedFlids = setOf("F_MIGRATED"))
|
||||
assertTrue(report.isGreen) // 属于已知合法偏差,红绿灯依然为 GREEN
|
||||
assertEquals(1, report.matchedCount)
|
||||
assertEquals(1, report.knownDeviations.size)
|
||||
assertEquals(FlightStoreDiffTool.DeviationKind.CROSS_DAY_PROTECTED, report.knownDeviations[0].kind)
|
||||
assertEquals(0, report.unexpectedDeviations.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Detects real field mismatch and missing flight as unexpected deviation`() {
|
||||
val pg = mapOf(
|
||||
"F1" to mapOf("flid" to "F1", "status" to "BOARDING"),
|
||||
"F_EXTRA" to mapOf("flid" to "F_EXTRA"),
|
||||
)
|
||||
val legacy = mapOf(
|
||||
"F1" to """{"flid":"F1","status":"DEPARTED"}""",
|
||||
"F_MISSING" to """{"flid":"F_MISSING"}""",
|
||||
)
|
||||
|
||||
val report = tool.diff(pg, legacy)
|
||||
assertFalse(report.isGreen)
|
||||
assertEquals(0, report.matchedCount)
|
||||
assertEquals(3, report.unexpectedDeviations.size)
|
||||
|
||||
val kinds = report.unexpectedDeviations.map { it.kind }
|
||||
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.FIELD_MISMATCH))
|
||||
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.MISSING_IN_PG))
|
||||
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.UNEXPECTED_EXTRA_IN_PG))
|
||||
|
||||
val summary = report.formatSummary()
|
||||
assertTrue(summary.contains("RED (未通过)"))
|
||||
assertTrue(summary.contains("F1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Nested collection value is compared as parsed JSON tree`() {
|
||||
val pg = mapOf(
|
||||
"F1" to mapOf(
|
||||
"flid" to "F1",
|
||||
// 1:N 明细集合列:序列化文本(JSON 数组),按解析树与 legacy 递归比对
|
||||
"GTDT" to """[{"GATE":"A1","PGOT":"07SEP260800"}]""",
|
||||
),
|
||||
)
|
||||
val legacy = mapOf(
|
||||
"F1" to """{"flid":"F1","GTDT":[{"PGOT":"07SEP260800","GATE":"A1"}]}""",
|
||||
)
|
||||
|
||||
val report = tool.diff(pg, legacy)
|
||||
assertTrue(report.isGreen)
|
||||
assertEquals(1, report.matchedCount)
|
||||
assertEquals(0, report.unexpectedDeviations.size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user