feat(processing): 实现自有 PostgreSQL 运营航班权威存储与单事务闭环 (ACM2-28)
- FS1: 增加 Flyway 迁移 V1.1.0__flight_schd.sql,创建 FLIGHT_SCHD 与 SCHD_GEN - FS2: 实现 FlightSchdRepository 接口及 JdbcFlightSchdRepository 与 StubFlightSchd,增强 JdbcOps 事务管理 - FS3: 扩展 MessageProcessor 事务 2 与按 FLID 点查视图,合并变更、事件与终态入单事务提交 - FS4: SnapshotFlow SQL 化(批处理 upsert、域内差删、SQL CAS 推进与熔断保护),JobExecutor 接入 PG 清场删除 - FS5: 彻底退役 Redis 权威与写路径,移除 FlightRedisClient、Lua 脚本、健康指示器与配置残留 - FS6: 补齐 U09/U29 不变量门禁(崩溃幂等、CAS 防并发、ADFT 存活保障、非 UTC JVM/会话时区无漂移)与 FlywayMigrationTest - FS7: 交付影子对拍比较内核 FlightStoreDiffTool 与单元测试 - FS8: 全面回改 decision-flight-state、architecture、design、user-stories 权威文档与规范
This commit is contained in:
@@ -71,7 +71,6 @@ class DispatcherTickTest {
|
||||
}
|
||||
|
||||
override fun indexFlightHts(payloadJson: String) = Unit
|
||||
override fun projectRedis(payloadJson: String) = Unit
|
||||
}
|
||||
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
@@ -184,23 +183,4 @@ class DispatcherTickTest {
|
||||
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 1L }.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete events always carry legal JSON refs - null and non-null partitionKey`() {
|
||||
val props = PipelineProps().apply { phase = PipelineProps.Phase.B }
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort()
|
||||
val d = dispatcher(repo, port, props)
|
||||
repo.enqueue(ev(1, Targets.ES_FLIGHT_HTS, "F1", """{"h":1}"""))
|
||||
repo.enqueue(ev(2, Targets.ES_FLIGHT_HTS, null, """{"h":2}"""))
|
||||
|
||||
d.tick(); d.tick() // 每 target 每 tick 仅出队一条:两条 ES 事件需两个 tick
|
||||
|
||||
val deletes = repo.syncInserted.filter { it.target == Targets.REDIS_FLIGHT_INFO }
|
||||
assertEquals(2, deletes.size)
|
||||
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
|
||||
val parsed = deletes.map { mapper.readTree(it.payloadJson) } // readTree 即合法性断言
|
||||
assertEquals(listOf("delete", "delete"), parsed.map { it.get("op").asText() })
|
||||
assertEquals("F1", parsed[0].get("refs").asText()) // 非空 → 带引号字符串
|
||||
assertTrue(parsed[1].get("refs").isNull) // null → null 字面量
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.gzzn.omms.msgexchange.infra.health
|
||||
|
||||
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
|
||||
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
|
||||
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
|
||||
import io.micronaut.health.HealthStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -13,40 +11,18 @@ import org.junit.jupiter.api.Test
|
||||
*/
|
||||
class HealthIndicatorsTest {
|
||||
|
||||
private class FakeRedis(private val pingResult: Boolean) : FlightRedisClient {
|
||||
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) = Unit
|
||||
override fun hgetAllFlightInfo(): Map<String, String> = emptyMap()
|
||||
override fun ping(): Boolean = pingResult
|
||||
}
|
||||
|
||||
private class ThrowingRedis : FlightRedisClient {
|
||||
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) = Unit
|
||||
override fun hgetAllFlightInfo(): Map<String, String> = emptyMap()
|
||||
override fun ping(): Boolean = throw RuntimeException("connection refused")
|
||||
}
|
||||
|
||||
private class FakePort(private val pingResult: Boolean) : DeliveryPort {
|
||||
override fun sendKafka(topic: String, payloadJson: String) = Unit
|
||||
override fun indexFlightHts(payloadJson: String) = Unit
|
||||
override fun projectRedis(payloadJson: 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 projectRedis(payloadJson: String) = Unit
|
||||
override fun ping(): Boolean = throw RuntimeException("metadata fetch failed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `redis up only when ping succeeds`() {
|
||||
assertEquals(HealthStatus.UP, redisHealth(FakeRedis(true)).status)
|
||||
assertEquals(HealthStatus.DOWN, redisHealth(FakeRedis(false)).status)
|
||||
assertEquals(HealthStatus.DOWN, redisHealth(ThrowingRedis()).status)
|
||||
assertEquals(HealthStatus.DOWN, redisHealth(null).status) // bean 缺失
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kafka up only when ping succeeds`() {
|
||||
assertEquals(HealthStatus.UP, kafkaHealth(FakePort(true)).status)
|
||||
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
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.zaxxer.hikari.HikariDataSource
|
||||
import java.sql.DriverManager
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* ACM2-28 FS6:PostgreSQL 真实方言集成测试(JdbcFlightSchdRepository)
|
||||
* 验证:
|
||||
* 1. DNLD 快照批处理写入(JDBC batch,200 批次);
|
||||
* 2. 增量 Upsert FDAY 保留策略(ON CONFLICT 保留原代,新插置 NULL);
|
||||
* 3. 域化差删(按 FDAY 严格隔离);
|
||||
* 4. SCHD_GEN SQL CAS(防并发断言);
|
||||
* 5. 单事务原子回滚(崩溃无残留);
|
||||
* 6. ES 历史清场 deleteByFlids 幂等删除。
|
||||
*/
|
||||
class FlightSchdJdbcPgTest {
|
||||
|
||||
private lateinit var ds: HikariDataSource
|
||||
private lateinit var repo: JdbcFlightSchdRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val url = "jdbc:postgresql://localhost:5432/msgx"
|
||||
val user = "msgx_dev"
|
||||
val pass = "msgx_dev_pass"
|
||||
|
||||
// 仅当本地 PostgreSQL 容器可用时执行
|
||||
val canConnect = try {
|
||||
DriverManager.getConnection(url, user, pass).use { true }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
assumeTrue(canConnect, "Local PostgreSQL on port 5432 is not accessible, skipping PG dialect integration tests")
|
||||
|
||||
ds = HikariDataSource().apply {
|
||||
jdbcUrl = url
|
||||
username = user
|
||||
this.password = pass
|
||||
driverClassName = "org.postgresql.Driver"
|
||||
maximumPoolSize = 2
|
||||
}
|
||||
repo = JdbcFlightSchdRepository(ds)
|
||||
cleanup()
|
||||
}
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
if (::ds.isInitialized) {
|
||||
cleanup()
|
||||
ds.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
try {
|
||||
ds.update("DELETE FROM flight_schd WHERE flid LIKE 'TEST_%'") {}
|
||||
ds.update("DELETE FROM schd_gen WHERE fday >= '2026-09-01'") {}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PG dialect - snapshot batch upsert, find, and domain diff delete`() {
|
||||
val day = "2026-09-07"
|
||||
// 构造 250 条记录跨越 batchSize 200 门限
|
||||
val flights = (1..250).map { i ->
|
||||
"TEST_FL_$i" to """{"FLID":"TEST_FL_$i","air":"CA$i"}"""
|
||||
}
|
||||
|
||||
repo.upsertSnapshotBatch(day, flights)
|
||||
|
||||
// 验证全量写入成功
|
||||
val byDay = repo.findByDay(day)
|
||||
assertEquals(250, byDay.size)
|
||||
|
||||
// 点查测试
|
||||
val f1 = repo.findByFlid("TEST_FL_1")
|
||||
assertNotNull(f1)
|
||||
assertTrue(f1!!.contains("CA1"))
|
||||
|
||||
// 多键点查测试(分批查)
|
||||
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.upsertSnapshotBatch(day, listOf("TEST_REG_01" to """{"FLID":"TEST_REG_01","v":1}"""))
|
||||
|
||||
// 2. 增量更新 REG_01(已有行)与 ADFT_01(新行)
|
||||
val changes = listOf(
|
||||
FlightChange("TEST_REG_01", """{"FLID":"TEST_REG_01","v":2}"""),
|
||||
FlightChange("TEST_ADFT_01", """{"FLID":"TEST_ADFT_01","v":1}"""),
|
||||
)
|
||||
repo.upsertIncremental(changes)
|
||||
|
||||
// 3. 验证 REG_01 内容更新但 FDAY 依然保留为 2026-09-07
|
||||
val reg01 = ds.queryOne("SELECT fday, fltr_json FROM flight_schd WHERE flid = 'TEST_REG_01'", {}) { rs ->
|
||||
rs.getDate("fday")?.toString() to rs.getString("fltr_json")
|
||||
}
|
||||
assertNotNull(reg01)
|
||||
assertEquals("2026-09-07", reg01!!.first)
|
||||
assertTrue(reg01.second.contains("\"v\": 2") || reg01.second.contains("\"v\":2"))
|
||||
|
||||
// 4. 验证 ADFT_01 新插入行 FDAY 恒为 NULL
|
||||
val adft01 = ds.queryOne("SELECT fday, fltr_json FROM flight_schd WHERE flid = 'TEST_ADFT_01'", {}) { rs ->
|
||||
rs.getDate("fday")?.toString() to rs.getString("fltr_json")
|
||||
}
|
||||
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 `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.upsertSnapshotBatch(day, listOf("TEST_TX_01" to """{"tx":1}"""))
|
||||
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.upsertSnapshotBatch(day, flids.map { it to """{"flid":"$it"}""" })
|
||||
assertEquals(50, repo.findByFlids(flids).size)
|
||||
|
||||
// 第一次分批删除
|
||||
val deleted1 = repo.deleteByFlids(flids.toSet())
|
||||
assertEquals(50, deleted1)
|
||||
assertEquals(0, repo.findByFlids(flids).size)
|
||||
|
||||
// 重放删除:安全返回 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.upsertSnapshotBatch(day, listOf("TEST_TZ_01" to """{"tz":1}"""), 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, fltr_json, created_at, updated_at)
|
||||
VALUES ('TEST_SESS_01', '2026-09-07', '{"session":"shanghai"}'::jsonb, ?, ?)
|
||||
""".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)
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
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 结构完整就绪。
|
||||
*/
|
||||
class FlywayMigrationTest {
|
||||
|
||||
@Test
|
||||
fun `Flyway automatically migrates V1_0_0 and V1_1_0 onto real PostgreSQL`() {
|
||||
val url = "jdbc:postgresql://localhost:5432/msgx"
|
||||
val user = "msgx_dev"
|
||||
val pass = "msgx_dev_pass"
|
||||
|
||||
val canConnect = try {
|
||||
DriverManager.getConnection(url, user, pass).use { true }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
assumeTrue(canConnect, "PostgreSQL on port 5432 not accessible, skipping Flyway test")
|
||||
|
||||
val flyway = Flyway.configure()
|
||||
.dataSource(url, user, pass)
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
|
||||
val migrateResult = flyway.migrate()
|
||||
assertTrue(migrateResult.success, "Flyway migration must succeed")
|
||||
|
||||
// 验证 flyway_schema_history 元数据表与执行记录
|
||||
DriverManager.getConnection(url, user, pass).use { conn ->
|
||||
conn.createStatement().use { stmt ->
|
||||
stmt.executeQuery(
|
||||
"SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC",
|
||||
).use { rs ->
|
||||
val records = mutableListOf<Triple<String, String, Boolean>>()
|
||||
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[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 存在
|
||||
stmt.executeQuery(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('flight_schd', 'schd_gen')",
|
||||
).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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.gzzn.omms.msgexchange.jobs
|
||||
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
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
|
||||
|
||||
/**
|
||||
* ACM2-28 FS6 历史清场测试:
|
||||
* 覆盖 ES 全成功、部分成功、全部失败、ES 成功后 PG 删除前崩溃、PG 删除重放五种场景;
|
||||
* 断言失败项不丢、成功项最终删除、重复执行无副作用。
|
||||
*/
|
||||
class HistorySweepJobTest {
|
||||
|
||||
private lateinit var flightSchd: StubFlightSchd
|
||||
private lateinit var sweepJob: HistorySweepJob
|
||||
|
||||
@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 """{"FLID":"F1"}""",
|
||||
"F2" to """{"FLID":"F2"}""",
|
||||
"F3" to """{"FLID":"F3"}""",
|
||||
)
|
||||
flightSchd.upsertSnapshotBatch("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"))
|
||||
}
|
||||
|
||||
// 2. 场景二:ES 部分成功
|
||||
@Test
|
||||
fun `Scenario 2 - ES Partial Success deletes only successful flights and keeps failed ones`() {
|
||||
val flights = listOf(
|
||||
"F1" to """{"FLID":"F1"}""",
|
||||
"F2" to """{"FLID":"F2"}""",
|
||||
"F3" to """{"FLID":"F3"}""",
|
||||
)
|
||||
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
|
||||
|
||||
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 """{"FLID":"F1"}""",
|
||||
"F2" to """{"FLID":"F2"}""",
|
||||
)
|
||||
flightSchd.upsertSnapshotBatch("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 """{"FLID":"F1"}""",
|
||||
)
|
||||
flightSchd.upsertSnapshotBatch("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
|
||||
}
|
||||
|
||||
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 """{"FLID":"F1"}""")
|
||||
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
|
||||
|
||||
// 首次删除
|
||||
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
|
||||
assertEquals(1, deletedCount1)
|
||||
assertNull(flightSchd.findByFlid("F1"))
|
||||
|
||||
// 重复执行删除(重放)
|
||||
val deletedCount2 = flightSchd.deleteByFlids(setOf("F1"))
|
||||
// 断言:允许重放时影响行数为 0,不抛出异常,无任何副作用
|
||||
assertEquals(0, deletedCount2)
|
||||
assertNull(flightSchd.findByFlid("F1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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 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, String>, msg: DecodedMessage): Decision {
|
||||
return Decision(
|
||||
flightChanges = listOf(FlightChange("F101", """{"FLID":"F101","status":"DELAYED"}""")),
|
||||
msgNotifies = listOf(NotifyPayload("""{"flid":"F101","event":"DELAY"}""")),
|
||||
schdPush = listOf(SchdPush("F101", """{"FLID":"F101","status":"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 有更新
|
||||
val fltr = flightSchd.findByFlid("F101")
|
||||
assertNotNull(fltr)
|
||||
assertTrue(fltr!!.contains("DELAYED"))
|
||||
|
||||
// 断言: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 """{"FLID":"FL_01","air":"CA1234"}""",
|
||||
"FL_02" to """{"FLID":"FL_02","air":"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 """{"FLID":"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", payloadJson = """{"FLID":"ADFT_888","type":"ADFT"}""")
|
||||
flightSchd.upsertIncremental(listOf(adftChange))
|
||||
|
||||
// 2. 写入旧代的一条定期计划航班 REG_OLD(FDAY 为 2026-09-07)
|
||||
flightSchd.upsertSnapshotBatch(day, listOf("REG_OLD" to """{"FLID":"REG_OLD","type":"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 """{"FLID":"REG_NEW","type":"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)
|
||||
assertTrue(adftRecord!!.contains("ADFT_888"))
|
||||
}
|
||||
|
||||
@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"
|
||||
|
||||
// 初始属旧代
|
||||
flightSchd.upsertSnapshotBatch(dayOld, listOf("FL_MIG" to """{"FLID":"FL_MIG","day":"07"}"""))
|
||||
flightSchd.putGenIfVersion(dayOld, 0L, FlightSchdRepository.GenMeta(dayOld, 1L, setOf("FL_MIG")))
|
||||
|
||||
// 随后 09-08 快照写入将 FDAY 更新为 2026-09-08
|
||||
flightSchd.upsertSnapshotBatch(dayNew, listOf("FL_MIG" to """{"FLID":"FL_MIG","day":"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")
|
||||
flightSchd.upsertSnapshotBatch("2026-09-07", listOf("TZ_01" to """{"test":true}"""), 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,9 @@ import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||
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.RefDataRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
|
||||
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
|
||||
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
|
||||
@@ -139,17 +137,48 @@ class MessageProcessorTest {
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
}
|
||||
|
||||
private object FakeRedis : FlightRedisClient {
|
||||
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) = Unit
|
||||
override fun hgetAllFlightInfo(): Map<String, String> = emptyMap()
|
||||
override fun ping(): Boolean = true
|
||||
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 FakeRefData : RefDataRepository {
|
||||
override fun getGen(day: String): RefDataRepository.GenMeta? = null
|
||||
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean = true
|
||||
private class FakeFlightSchd : FlightSchdRepository {
|
||||
val flights = mutableMapOf<String, String>()
|
||||
val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
|
||||
val incrementalChanges = mutableListOf<com.gzzn.omms.msgexchange.domain.FlightChange>()
|
||||
|
||||
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, String>>, now: Instant) {
|
||||
this.flights.putAll(flights)
|
||||
}
|
||||
override fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant) {
|
||||
incrementalChanges.addAll(changes)
|
||||
changes.forEach { flights[it.flid] = it.payloadJson }
|
||||
}
|
||||
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = 0
|
||||
override fun findByFlid(flid: String): String? = flights[flid]
|
||||
override fun findByFlids(flids: Collection<String>): Map<String, String> =
|
||||
flids.mapNotNull { f -> flights[f]?.let { f to it } }.toMap()
|
||||
override fun findByDay(day: String): List<Pair<String, String>> = flights.map { it.key to it.value }
|
||||
override fun findAll(): Map<String, String> = 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
|
||||
}
|
||||
|
||||
private class FakeTxManager : PipelineTransactionManager {
|
||||
override fun <T> inTransaction(block: () -> T): T = block()
|
||||
}
|
||||
// ---------- helpers ----------
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
|
||||
@@ -177,10 +206,11 @@ class MessageProcessorTest {
|
||||
val props = PipelineProps()
|
||||
val scheduler = FailureScheduler(props, clock)
|
||||
val procFailure = ProcFailure(procState, scheduler)
|
||||
val snapshot = SnapshotFlow(procState, FakeRefData(), FakeRedis, procFailure)
|
||||
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), FakeRedis, snapshot, procFailure, props)
|
||||
val flightSchd = FakeFlightSchd()
|
||||
val txManager = FakeTxManager()
|
||||
val snapshot = SnapshotFlow(procState, flightSchd, events, FakeReqTrack(), procFailure, txManager, inbox)
|
||||
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), flightSchd, snapshot, procFailure, props, txManager)
|
||||
}
|
||||
|
||||
// ---------- tests ----------
|
||||
@Test
|
||||
fun `no handler is FAILED UNSUPPORTED with backoff - never terminal`() {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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 `AST comparison ignores key ordering differences`() {
|
||||
val pg = mapOf(
|
||||
"F1" to """{"flid":"F1","airline":"CA","flightNo":"123","status":"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 `AST comparison normalizes numeric precision and null equivalents`() {
|
||||
val pg = mapOf(
|
||||
"F1" to """{"flid":"F1","weight":100.0,"delay":0,"extra":null}""",
|
||||
)
|
||||
val legacy = mapOf(
|
||||
"F1" to """{"flid":"F1","weight":100,"delay":0.0}""",
|
||||
)
|
||||
|
||||
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 """{"flid":"F_STABLE"}""",
|
||||
"F_MIGRATED" to """{"flid":"F_MIGRATED","day":"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 """{"flid":"F1","status":"BOARDING"}""",
|
||||
"F_EXTRA" to """{"flid":"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"))
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,6 @@ flyway:
|
||||
default:
|
||||
enabled: false
|
||||
|
||||
redis:
|
||||
uri: redis://127.0.0.1:6379
|
||||
|
||||
kafka:
|
||||
bootstrap:
|
||||
servers: 127.0.0.1:9092
|
||||
|
||||
Reference in New Issue
Block a user