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:
windyboy
2026-09-07 16:12:00 +08:00
parent dd4bbc86ca
commit 7b7b61f100
37 changed files with 2037 additions and 382 deletions
@@ -20,8 +20,6 @@ interface DeliveryPort {
/** 阶段 BES flight_hts 写入。 */
fun indexFlightHts(payloadJson: String)
/** 阶段 B:Redis 投影写(仅阶段 BI5Delivery 阶段 A 不写 Redis)。 */
fun projectRedis(payloadJson: String)
/** 连通性探测(健康检查用);默认 true,真实 Kafka 实装时覆写为 producer metadata 校验。 */
fun ping(): Boolean = true
@@ -83,10 +81,7 @@ class Dispatcher(
deliver(t, head)
msgEvents.markSent(head.eventId!!)
log.debug("sent target={} eventId={}", t, head.eventId)
if (t == Targets.ES_FLIGHT_HTS && props.phase == PipelineProps.Phase.B) {
// 定案 2:ES 投递成功 → 同线程同步 enqueue 删除事件(不轮询 ack
msgEvents.insertSync(listOf(MsgEvent(target = Targets.REDIS_FLIGHT_INFO, payloadJson = deleteOf(head))))
}
// ACM2-28:阶段 B Redis 投影废弃,读模型投递统一转由 Kafka 事件或 ES 处理
} catch (e: Exception) {
retryOrDead(head, e.message ?: "unknown")
}
@@ -115,19 +110,9 @@ class Dispatcher(
Targets.KAFKA_MSG -> port.sendKafka("msg", e.payloadJson)
Targets.KAFKA_SCHD -> error("KAFKA_SCHD must go through flushSchd (N03)")
Targets.ES_FLIGHT_HTS -> port.indexFlightHts(e.payloadJson)
Targets.REDIS_FLIGHT_INFO -> port.projectRedis(e.payloadJson)
else -> error("unknown target $target")
}
private val jsonMapper = ObjectMapper()
/**
* U14:删除事件 wire JSON 结构化序列化——refs 可空,产出必须恒为合法 JSON
* null → null 字面量;非空 → 带引号并转义)。禁止字符串模板拼接。
*/
private fun deleteOf(e: MsgEvent): String =
jsonMapper.writeValueAsString(linkedMapOf("op" to "delete", "refs" to e.partitionKey))
/**
* 流程 3 flushSchd:批上限 + 每 FLID 最新一态聚合(topic "schd"wire=FLTR JSON 数组)。
* U08 批量闭环:队首退避未到期不 claim;发送失败 → 整批 attempts+1(退避)或达上限整批 DEAD/DLQ;
@@ -2,7 +2,7 @@ package com.gzzn.omms.msgexchange.domain
/**
* ACMA-8 流程 2Handler 决策(纯函数)产物——状态与报文进,变更与事件出,
* 不直接触碰 Redis/Kafka。
* 不直接触碰数据库或 Kafka。
*/
data class Decision(
val flightChanges: List<FlightChange> = emptyList(),
@@ -12,7 +12,7 @@ data class Decision(
val refUpserts: List<RefUpsert> = emptyList(), // → 静态主数据(独立 PG reference 库,ACM2-11
)
/** 航班状态变更(阶段 A 由主泵线程 redisApply;阶段 B 落 FLIGHT_STATE 同事务)。 */
/** 航班状态变更(阶段 A 落自有 PG FLIGHT_SCHD,与事件同事务原子提交;ACM2-28 定案)。 */
data class FlightChange(
val flid: String,
val payloadJson: String,
@@ -8,13 +8,14 @@ object Targets {
const val KAFKA_MSG = "KAFKA:msg"
const val KAFKA_SCHD = "KAFKA:schd"
const val ES_FLIGHT_HTS = "ES:flight_hts"
@Deprecated("Retired in ACM2-28: Redis projection removed")
const val REDIS_FLIGHT_INFO = "REDIS:flightInfo"
/** 阶段 A 投递目标(仅 Kafka)——I5Delivery 阶段 A 不写 Redis。 */
/** 阶段 A 投递目标(仅 Kafka)——ACM2-28Delivery 阶段 A/B 均不写 Redis。 */
val phaseA: List<String> = listOf(KAFKA_MSG, KAFKA_SCHD)
/** 阶段 B 追加投影目标。 */
val phaseB: List<String> = phaseA + listOf(ES_FLIGHT_HTS, REDIS_FLIGHT_INFO)
/** 阶段 B 追加投影目标(仅 ES 历史库;Redis 投影按 ACM2-28 废弃)。 */
val phaseB: List<String> = phaseA + listOf(ES_FLIGHT_HTS)
}
enum class EventStatus { PENDING, SENT, DEAD }
@@ -1,7 +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 io.micronaut.context.BeanProvider
import io.micronaut.core.async.publisher.Publishers
import io.micronaut.health.HealthStatus
@@ -11,20 +10,12 @@ import jakarta.inject.Singleton
import org.reactivestreams.Publisher
/**
* U12R05):阶段 A 关键依赖的自定义健康指示器——
* RedisflightInfo 权威存储)与 Kafka(投递端口)。经 BeanProvider 可选解析:
* U12(R05):自定义健康指示器——
* Kafka(投递端口)。经 BeanProvider 可选解析:
* 缺 bean(如未用 stub 也未实装)时指示 DOWN 而非启动失败;
* UP 判据为真实 pingfalse/异常 → DOWN),而非仅 bean 存在(复审 P1 修正)。
* ACM2-28Redis 退出阶段 A 权威与写路径,redis-flight-store 指示器移除)。
*/
@Singleton
class FlightRedisHealthIndicator(
private val redis: BeanProvider<FlightRedisClient>,
) : HealthIndicator {
override fun getResult(): Publisher<HealthResult> =
Publishers.just(redisHealth(if (redis.isPresent) redis.get() else null))
}
@Singleton
class KafkaDeliveryHealthIndicator(
private val port: BeanProvider<DeliveryPort>,
@@ -34,12 +25,6 @@ class KafkaDeliveryHealthIndicator(
Publishers.just(kafkaHealth(if (port.isPresent) port.get() else null))
}
/** ping 判定独立成纯函数便于单测:client 为 null = bean 缺失;ping false/异常 = DOWN。 */
internal fun redisHealth(client: FlightRedisClient?): HealthResult =
healthOf("redis-flight-store", "flight store", client?.let {
try { it.ping() } catch (e: Exception) { false }
})
internal fun kafkaHealth(port: DeliveryPort?): HealthResult =
healthOf("kafka-delivery", "delivery port", port?.let {
try { it.ping() } catch (e: Exception) { false }
@@ -14,7 +14,7 @@ import java.time.Instant
* 的全部接口(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、21 类 REF_MASTER);
* 共享 MySQL 信箱(CMINMSGS / COUTMSGS)经信箱封装访问,仅 DML、不建表;
* 主路径=上游外部写 CMINMSGS → 本系统 JDBC 轮询读;compat=insertRaw HTTP 写;
* Redis = 航班动态 + 快照 genRefDataRepository 目标实现);FLIGHT_STATE 缓做
* 自有 PG = 消息管道 + 运营航班 FLIGHT_SCHD/SCHD_GEN + 静态数据;Redis 已退出阶段 A 权威与写路径
*/
interface ProcStateRepository {
fun insert(cminmsgsId: Long, state: ProcStatus = ProcStatus.PENDING)
@@ -68,25 +68,88 @@ interface MsgEventRepository {
}
/**
* 快照 generationSCHD_GEN)协议——只留 gen。
* ACM2-12gen 迁 Redis(与 flightInfo 同源,Lua 内原子「覆盖+按代差删+版本推进」,
* DB 仅写 SUCCEEDED;重放幂等由 Lua 承接,协议重设计属 U09)。本接口为过渡占位,
* 目标实现为 Redis gen storescript 化),非关系表。
* 阶段 A 运营航班权威与日计划代(ACM2-28 采纳选项 C 定案):
* - 表 FLIGHT_SCHD:当前运营航班全量权威态(SCHD 快照 + FLOP/ADFT 增量合并),落自有 PostgreSQL;
* - 表 SCHD_GEN:各日代版本与当前代有效航班全量集合(差删依据),由 Redis 回归自有 PG;
* - Redis 退出动态权威与全部写路径;
* - 事务 2 与快照发布全在自有 PG 内以单事务原子提交;
* - 增量更新(FLOP/ADFT):新插 FDAY=NULL,已有行通过 ON CONFLICT 保留原 FDAY
* - 按代差删域化:DELETE FROM FLIGHT_SCHD WHERE FDAY = :day AND FLID = ANY(:diffSet)
* 仅删除仍属旧代的行,ADFT(FDAY=NULL)与已迁移至新代的同 FLID 行天然存活。
*/
interface RefDataRepository {
data class GenMeta(val flids: List<String>, val version: Long)
interface FlightSchdRepository {
data class FlightRecord(
val flid: String,
val fday: String?,
val fltrJson: String,
val createdAt: Instant,
val updatedAt: Instant,
)
data class GenMeta(
val fday: String,
val version: Long,
val flids: Set<String>,
val updatedAt: Instant = Instant.now(),
)
/** 快照全量写入(DNLD):强行声明/更新 FDAY 归属,批处理写入。 */
fun upsertSnapshotBatch(day: String, flights: List<Pair<String, String>>, now: Instant = Instant.now())
/** 增量更新(FLOP/ADFT):新插 FDAY=NULL,已有行保留原 FDAY。 */
fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant = Instant.now())
/** 按代差删域化:仅删除 FDAY = day 且在 delFlids 中的记录(ADFT 与跨代已迁移行受保护)。 */
fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int
/** 点查单航班 FLTR_JSON。 */
fun findByFlid(flid: String): String?
/** 点查多航班 FLTR_JSON。 */
fun findByFlids(flids: Collection<String>): Map<String, String>
/** 按计划日查询当前有效航班。 */
fun findByDay(day: String): List<Pair<String, String>>
/** 全量查询(供影子对拍 / 一致性对账)。 */
fun findAll(): Map<String, String>
/**
* 历史清场删除:仅删除已确认归档至 ES 的 FLID 集合;空集合不执行;分批参数化删除。
* 返回实际删除行数(允许重放时为 0)。
*/
fun deleteByFlids(flids: Set<String>): Int
/** 读计划代(SCHD_GEN)。 */
fun getGen(day: String): GenMeta?
/** 流程 4:版本 CASexpected 未变才写,重放 no-op,不二次自增)。 */
fun putGenIfVersion(day: String, expected: Long, new: GenMeta): Boolean
/**
* 计划代 SQL 版本 CAS:仅当 expected 与数据库中当前版本一致(或不存在且 expected=0)时写入/推进新版本。
* 返回 true 表示推进成功,false 表示发生 CAS 冲突。
*/
fun putGenIfVersion(day: String, expected: Long, newGen: GenMeta, now: Instant = Instant.now()): Boolean
/**
* 历史代清理:清理 cutoffDay 之前的历史代记录(与 FLIGHT_SCHD 历史清场生命周期对齐)。
*/
fun deleteGenBefore(cutoffDay: String): Int
}
/** 事务管理器抽象:自有 PG 单事务原子保障。 */
interface PipelineTransactionManager {
fun <T> inTransaction(block: () -> T): T
}
@Deprecated("Replaced by FlightSchdRepository in ACM2-28", ReplaceWith("FlightSchdRepository"))
interface RefDataRepository {
data class GenMeta(val flids: List<String>, val version: Long)
fun getGen(day: String): GenMeta?
fun putGenIfVersion(day: String, expected: Long, new: GenMeta): Boolean
}
/**
* 21 类静态主数据(航空公司/航线/机位/登机桥等)——自有 PostgreSQL `REF_MASTER` 表
* (ACM2-12:与消息管道同自有库;SOURCE=ADMINAPI/AODB/PIPELINEN19 对齐)。
* 与主链弱事务耦合:写入者为 ReferenceService21 类同步)与请求应答路径
* Redis 只作只读热点投影(legacy orms_stand 语义延续,阶段 6)。
* 与主链弱事务耦合:写入者为 ReferenceService21 类同步)与请求应答路径
*/
interface StaticRefRepository {
fun upsertAll(refs: List<RefUpsert>)
@@ -124,7 +187,7 @@ interface ReqTrackRepository {
/**
* 泵作业调度记录——自有 PG `PUMP_JOB`ACM2-12)。
* 决策 1 修订:作业不插队,仅在消息队头空闲/退避窗口由主泵执行(跨库/异队列无全序);
* 作业动作本身(归档写共享库 CMINMSGS_HST、清场删 Redis/ES 等)仍在各自目标存储。
* 作业动作本身(归档写共享库 CMINMSGS_HST、清场删 ES+PG 等)仍在各自目标存储。
*/
interface PumpJobRepository {
data class Job(val jobId: Long, val kind: String) // ARCHIVE/HISTORY_SWEEP/PROJECTION_REBUILD
@@ -141,9 +204,9 @@ interface PumpJobRepository {
}
/**
* 阶段 B 权威(ACM2-12:缓做,不落表——航班动态权威保持 Redis;阶段 B 重新
* 评估后再定是否引入事务化权威)。本接口仅供占位与测试,勿据此建表。
* 历史占位接口(已被 ACM2-28 之 FlightSchdRepository 取代,保留供兼容与过渡)。
*/
@Deprecated("Replaced by FlightSchdRepository in ACM2-28", ReplaceWith("FlightSchdRepository"))
interface FlightStateRepository {
/** 阶段 B 权威;replaceDay = 单事务删差集+写新代+版本提升。 */
fun replaceDay(day: String, flights: List<Pair<String, String>>)
@@ -10,9 +10,51 @@ internal fun Instant.toSqlTimestamp(): Timestamp = Timestamp.from(this)
internal fun ResultSet.getInstant(column: String): Instant? =
getTimestamp(column)?.toInstant()
internal fun <T> DataSource.query(sql: String, bind: (java.sql.PreparedStatement) -> Unit, map: (ResultSet) -> T): List<T> =
connection.use { conn ->
conn.prepareStatement(sql).use { ps ->
private val transactionConnection = ThreadLocal<java.sql.Connection?>()
internal fun <T> DataSource.withTransaction(block: () -> T): T {
val existing = transactionConnection.get()
if (existing != null) {
return block()
}
val conn = this.connection
val oldAutoCommit = conn.autoCommit
conn.autoCommit = false
transactionConnection.set(conn)
try {
val result = block()
conn.commit()
return result
} catch (t: Throwable) {
try {
conn.rollback()
} catch (rbEx: Throwable) {
t.addSuppressed(rbEx)
}
throw t
} finally {
transactionConnection.remove()
try {
conn.autoCommit = oldAutoCommit
} catch (_: Throwable) {
}
conn.close()
}
}
internal fun DataSource.obtainConnection(): java.sql.Connection =
transactionConnection.get() ?: this.connection
internal fun java.sql.Connection.releaseIfNotInTransaction() {
if (transactionConnection.get() !== this) {
this.close()
}
}
internal fun <T> DataSource.query(sql: String, bind: (java.sql.PreparedStatement) -> Unit, map: (ResultSet) -> T): List<T> {
val conn = obtainConnection()
try {
return conn.prepareStatement(sql).use { ps ->
bind(ps)
ps.executeQuery().use { rs ->
buildList {
@@ -20,22 +62,30 @@ internal fun <T> DataSource.query(sql: String, bind: (java.sql.PreparedStatement
}
}
}
} finally {
conn.releaseIfNotInTransaction()
}
}
internal fun <T> DataSource.queryOne(sql: String, bind: (java.sql.PreparedStatement) -> Unit, map: (ResultSet) -> T): T? =
query(sql, bind, map).firstOrNull()
internal fun DataSource.update(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Int =
connection.use { conn ->
conn.prepareStatement(sql).use { ps ->
internal fun DataSource.update(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Int {
val conn = obtainConnection()
try {
return conn.prepareStatement(sql).use { ps ->
bind(ps)
ps.executeUpdate()
}
} finally {
conn.releaseIfNotInTransaction()
}
}
internal fun DataSource.updateReturningLong(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Long =
connection.use { conn ->
conn.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS).use { ps ->
internal fun DataSource.updateReturningLong(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Long {
val conn = obtainConnection()
try {
return conn.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS).use { ps ->
bind(ps)
ps.executeUpdate()
ps.generatedKeys.use { keys ->
@@ -43,4 +93,7 @@ internal fun DataSource.updateReturningLong(sql: String, bind: (java.sql.Prepare
keys.getLong(1)
}
}
} finally {
conn.releaseIfNotInTransaction()
}
}
@@ -6,8 +6,10 @@ import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
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.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
@@ -286,21 +288,242 @@ class JdbcPumpJobRepository(
}
}
/** gen→Redis 过渡占位:U09 前进程内 CAS(与 StubRefData 同语义)。 */
/** 自有 PostgreSQL 单事务管理器(ACM2-28 事务 2 与快照发布原子提交)。 */
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcRefDataRepository : RefDataRepository {
private val gens = mutableMapOf<String, RefDataRepository.GenMeta>()
class JdbcPipelineTransactionManager(
private val ds: DataSource,
) : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = ds.withTransaction(block)
}
override fun getGen(day: String): RefDataRepository.GenMeta? = gens[day]
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcFlightSchdRepository(
private val ds: DataSource,
) : FlightSchdRepository {
private val flidsTypeRef = object : com.fasterxml.jackson.core.type.TypeReference<Set<String>>() {}
private val mapper = com.fasterxml.jackson.databind.ObjectMapper()
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean {
val cur = gens[day]?.version ?: 0L
if (cur != expected) return false
gens[day] = new
return true
private fun serializeFlids(flids: Collection<String>): String = mapper.writeValueAsString(flids)
private fun parseFlids(json: String): Set<String> {
if (json.isBlank()) return emptySet()
return try {
mapper.readValue(json, flidsTypeRef)
} catch (_: Exception) {
emptySet()
}
}
private fun toSqlDate(day: String): java.sql.Date =
java.sql.Date.valueOf(day.trim().take(10))
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, String>>, now: Instant) {
if (flights.isEmpty()) return
val sql = """
INSERT INTO flight_schd (flid, fday, fltr_json, created_at, updated_at)
VALUES (?, ?, ?::jsonb, ?, ?)
ON CONFLICT (flid) DO UPDATE SET
fday = EXCLUDED.fday,
fltr_json = EXCLUDED.fltr_json,
updated_at = EXCLUDED.updated_at
""".trimIndent()
val sqlDate = toSqlDate(day)
val sqlTimestamp = now.toSqlTimestamp()
val conn = ds.obtainConnection()
try {
conn.prepareStatement(sql).use { ps ->
var count = 0
for ((flid, json) in flights) {
ps.setString(1, flid)
ps.setDate(2, sqlDate)
ps.setString(3, json)
ps.setTimestamp(4, sqlTimestamp)
ps.setTimestamp(5, sqlTimestamp)
ps.addBatch()
count++
if (count % 200 == 0) {
ps.executeBatch()
}
}
if (count % 200 != 0) {
ps.executeBatch()
}
}
} finally {
conn.releaseIfNotInTransaction()
}
}
override fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant) {
if (changes.isEmpty()) return
val sql = """
INSERT INTO flight_schd (flid, fday, fltr_json, created_at, updated_at)
VALUES (?, NULL, ?::jsonb, ?, ?)
ON CONFLICT (flid) DO UPDATE SET
fltr_json = EXCLUDED.fltr_json,
updated_at = EXCLUDED.updated_at
""".trimIndent()
val sqlTimestamp = now.toSqlTimestamp()
val conn = ds.obtainConnection()
try {
conn.prepareStatement(sql).use { ps ->
var count = 0
for (c in changes) {
ps.setString(1, c.flid)
ps.setString(2, c.payloadJson)
ps.setTimestamp(3, sqlTimestamp)
ps.setTimestamp(4, sqlTimestamp)
ps.addBatch()
count++
if (count % 200 == 0) {
ps.executeBatch()
}
}
if (count % 200 != 0) {
ps.executeBatch()
}
}
} finally {
conn.releaseIfNotInTransaction()
}
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int {
if (delFlids.isEmpty()) return 0
var totalDeleted = 0
val sqlDate = toSqlDate(day)
for (chunk in delFlids.chunked(200)) {
val placeholders = chunk.joinToString(",") { "?" }
val sql = "DELETE FROM flight_schd WHERE fday = ? AND flid IN ($placeholders)"
totalDeleted += ds.update(sql) { ps ->
ps.setDate(1, sqlDate)
chunk.forEachIndexed { i, flid -> ps.setString(i + 2, flid) }
}
}
return totalDeleted
}
override fun findByFlid(flid: String): String? =
ds.queryOne(
"SELECT fltr_json FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> rs.getString("fltr_json") }
override fun findByFlids(flids: Collection<String>): Map<String, String> {
if (flids.isEmpty()) return emptyMap()
val result = mutableMapOf<String, String>()
for (chunk in flids.chunked(200)) {
val placeholders = chunk.joinToString(",") { "?" }
val sql = "SELECT flid, fltr_json FROM flight_schd WHERE flid IN ($placeholders)"
val pairs = ds.query(
sql,
{ ps -> chunk.forEachIndexed { i, flid -> ps.setString(i + 1, flid) } },
) { rs -> rs.getString("flid") to rs.getString("fltr_json") }
result.putAll(pairs)
}
return result
}
override fun findByDay(day: String): List<Pair<String, String>> =
ds.query(
"SELECT flid, fltr_json FROM flight_schd WHERE fday = ? ORDER BY flid ASC",
{ ps -> ps.setDate(1, toSqlDate(day)) },
) { rs -> rs.getString("flid") to rs.getString("fltr_json") }
override fun findAll(): Map<String, String> =
ds.query(
"SELECT flid, fltr_json FROM flight_schd ORDER BY flid ASC",
{},
) { rs -> rs.getString("flid") to rs.getString("fltr_json") }.toMap()
override fun deleteByFlids(flids: Set<String>): Int {
if (flids.isEmpty()) return 0
var totalDeleted = 0
for (chunk in flids.chunked(200)) {
val placeholders = chunk.joinToString(",") { "?" }
val sql = "DELETE FROM flight_schd WHERE flid IN ($placeholders)"
totalDeleted += ds.update(sql) { ps ->
chunk.forEachIndexed { i, flid -> ps.setString(i + 1, flid) }
}
}
return totalDeleted
}
override fun getGen(day: String): FlightSchdRepository.GenMeta? =
ds.queryOne(
"SELECT fday, version, flids_json, updated_at FROM schd_gen WHERE fday = ?",
{ ps -> ps.setDate(1, toSqlDate(day)) },
) { rs ->
FlightSchdRepository.GenMeta(
fday = rs.getDate("fday").toString(),
version = rs.getLong("version"),
flids = parseFlids(rs.getString("flids_json")),
updatedAt = rs.getInstant("updated_at") ?: Instant.now(),
)
}
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
val flidsJson = serializeFlids(newGen.flids)
val sqlDate = toSqlDate(day)
val sqlTimestamp = now.toSqlTimestamp()
if (expected == 0L) {
val inserted = ds.update(
"""
INSERT INTO schd_gen (fday, version, flids_json, updated_at)
VALUES (?, ?, ?::jsonb, ?)
ON CONFLICT (fday) DO NOTHING
""".trimIndent(),
) { ps ->
ps.setDate(1, sqlDate)
ps.setLong(2, newGen.version)
ps.setString(3, flidsJson)
ps.setTimestamp(4, sqlTimestamp)
}
if (inserted == 1) return true
}
val updated = ds.update(
"""
UPDATE schd_gen
SET version = ?, flids_json = ?::jsonb, updated_at = ?
WHERE fday = ? AND version = ?
""".trimIndent(),
) { ps ->
ps.setLong(1, newGen.version)
ps.setString(2, flidsJson)
ps.setTimestamp(3, sqlTimestamp)
ps.setDate(4, sqlDate)
ps.setLong(5, expected)
}
return updated == 1
}
override fun deleteGenBefore(cutoffDay: String): Int =
ds.update(
"DELETE FROM schd_gen WHERE fday < ?",
{ ps -> ps.setDate(1, toSqlDate(cutoffDay)) },
)
}
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcRefDataRepository(
private val flightSchd: FlightSchdRepository,
) : RefDataRepository {
override fun getGen(day: String): RefDataRepository.GenMeta? =
flightSchd.getGen(day)?.let { RefDataRepository.GenMeta(it.flids.toList(), it.version) }
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean =
flightSchd.putGenIfVersion(
day,
expected,
FlightSchdRepository.GenMeta(day, new.version, new.flids.toSet()),
)
}
@Singleton
@@ -420,7 +643,12 @@ class JdbcReqTrackRepository(
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcFlightStateRepository : FlightStateRepository {
override fun replaceDay(day: String, flights: List<Pair<String, String>>) = Unit
override fun findByDay(day: String): List<Pair<String, String>> = emptyList()
class JdbcFlightStateRepository(
private val flightSchd: FlightSchdRepository,
) : FlightStateRepository {
override fun replaceDay(day: String, flights: List<Pair<String, String>>) =
flightSchd.upsertSnapshotBatch(day, flights)
override fun findByDay(day: String): List<Pair<String, String>> =
flightSchd.findByDay(day)
}
@@ -1,29 +0,0 @@
package com.gzzn.omms.msgexchange.infra.redis
/**
* ACMA-8 流程 4 / I4 / I5Redis Lua 脚本装载与执行入口。
* 阶段 A 全部 flightInfo 写均经此处,且仅由主泵线程调用(I5)。
*/
enum class RedisScript(val classpathLocation: String) {
/** 同一 hash 原子“覆盖新代 + 按代差删”(setArg 为 N 对 field/valuedelArg 为差集)。 */
SNAPSHOT_REPLACE("lua/snapshot_replace.lua"),
/** 3:30 清场批量删除(仅 ES 归档成功集)。 */
BATCH_DELETE("lua/batch_delete.lua"),
}
interface FlightRedisClient {
/**
* 执行脚本。SNAPSHOT_REPLACEsetPairs 为新代全量 field/valuedelFields 为按代差集;
* BATCH_DELETEdelFields 为待删 FLID 集。
*/
fun eval(script: RedisScript, setPairs: List<Pair<String, String>> = emptyList(), delFields: List<String> = emptyList())
/** 阶段 A 权威读(处理决策 loadState、3:30 清场 findAll)。 */
fun hgetAllFlightInfo(): Map<String, String>
/** 连通性探测(健康检查用;实现必须为快速调用,失败返回 false 而非抛出穿出)。 */
fun ping(): Boolean
}
// TODO(阶段1后续): 基于 micronaut-redis-lettuce 的实装(脚本自 classpath 装载并缓存 SHA)。
@@ -5,8 +5,6 @@ import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.processing.CodecHolder
import com.gzzn.omms.msgexchange.processing.HandlerHolder
import com.gzzn.omms.msgexchange.processing.HandlerRegistry
@@ -16,7 +14,7 @@ import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
/**
* U07stub 适配层——XmlCodec / Redis 客户端 / DeliveryPort / Handler 装配,仅在 msgx.stubs=true 时生效。
* U07stub 适配层——XmlCodec / DeliveryPort / Handler 装配,仅在 msgx.stubs=true 时生效Redis 客户端已按 ACM2-28 移除)
* stub codec 未实装 → 报文 decode 返回 CODEC_ERROR(走 FAILED 可重放路径,链路上可观测),
* 语义见 ACM2-10 U11:不把“未实装”写成报文非法/终态。
*/
@@ -29,30 +27,6 @@ class StubXmlCodec : XmlCodec {
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubRedis : FlightRedisClient {
val hash = mutableMapOf<String, String>()
val evalCalls = mutableListOf<RedisScript>()
fun clear() { hash.clear(); evalCalls.clear() }
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) {
evalCalls += script
when (script) {
RedisScript.SNAPSHOT_REPLACE -> {
hash.putAll(setPairs)
delFields.forEach { hash.remove(it) }
}
RedisScript.BATCH_DELETE -> delFields.forEach { hash.remove(it) }
}
}
override fun hgetAllFlightInfo(): Map<String, String> = hash.toMap()
override fun ping(): Boolean = true
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubDeliveryPort : DeliveryPort {
@@ -62,7 +36,6 @@ class StubDeliveryPort : DeliveryPort {
override fun sendKafka(topic: String, payloadJson: String) { sent += topic to payloadJson }
override fun indexFlightHts(payloadJson: String) = Unit
override fun projectRedis(payloadJson: String) = Unit
}
/** Holder 工厂:CodecHolder/HandlerHolder 由 Micronaut Bean 提供(取代直连构造占位)。 */
@@ -7,8 +7,10 @@ import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
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.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
@@ -187,22 +189,118 @@ class StubPumpJobs : PumpJobRepository {
override fun markFailed(jobId: Long, lastError: String) { rows[jobId]?.state = "FAILED"; rows[jobId]?.lastError = lastError }
}
/** 快照 gen 协议 stub(业务库 REF_DATA 的 SCHD_GEN 行;ACM2-11 拆分后 21 类不在本实现)。 */
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubRefData : RefDataRepository {
private val gens = mutableMapOf<String, RefDataRepository.GenMeta>()
class StubPipelineTransactionManager : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
fun clear() { gens.clear() }
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubFlightSchd : FlightSchdRepository {
data class Record(
val flid: String,
val fday: String?,
val fltrJson: String,
val createdAt: Instant,
val updatedAt: Instant,
)
override fun getGen(day: String): RefDataRepository.GenMeta? = gens[day]
private val records = mutableMapOf<String, Record>()
private val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean {
val cur = gens[day]?.version ?: 0L
if (cur != expected) return false
gens[day] = new
fun clear() {
records.clear()
gens.clear()
}
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, String>>, now: Instant) {
for ((flid, json) in flights) {
val existing = records[flid]
records[flid] = Record(
flid = flid,
fday = day,
fltrJson = json,
createdAt = existing?.createdAt ?: now,
updatedAt = now,
)
}
}
override fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant) {
for (c in changes) {
val existing = records[c.flid]
records[c.flid] = Record(
flid = c.flid,
fday = existing?.fday, // preserve existing fday; null if new
fltrJson = c.payloadJson,
createdAt = existing?.createdAt ?: now,
updatedAt = now,
)
}
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int {
if (delFlids.isEmpty()) return 0
val delSet = delFlids.toSet()
val toRemove = records.filter { (flid, rec) -> rec.fday == day && flid in delSet }.keys
toRemove.forEach { records.remove(it) }
return toRemove.size
}
override fun findByFlid(flid: String): String? = records[flid]?.fltrJson
override fun findByFlids(flids: Collection<String>): Map<String, String> =
flids.mapNotNull { flid -> records[flid]?.let { flid to it.fltrJson } }.toMap()
override fun findByDay(day: String): List<Pair<String, String>> =
records.values.filter { it.fday == day }
.sortedBy { it.flid }
.map { it.flid to it.fltrJson }
override fun findAll(): Map<String, String> =
records.mapValues { it.value.fltrJson }
override fun deleteByFlids(flids: Set<String>): Int {
if (flids.isEmpty()) return 0
var count = 0
for (flid in flids) {
if (records.remove(flid) != null) count++
}
return count
}
override fun getGen(day: String): FlightSchdRepository.GenMeta? = gens[day]
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
val current = gens[day]?.version ?: 0L
if (current != expected) return false
gens[day] = newGen.copy(updatedAt = now)
return true
}
override fun deleteGenBefore(cutoffDay: String): Int {
val toRemove = gens.keys.filter { it < cutoffDay }
toRemove.forEach { gens.remove(it) }
return toRemove.size
}
}
/** 快照 gen 协议 stub:委托至 StubFlightSchd。 */
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubRefData(
private val flightSchd: FlightSchdRepository,
) : RefDataRepository {
override fun getGen(day: String): RefDataRepository.GenMeta? =
flightSchd.getGen(day)?.let { RefDataRepository.GenMeta(it.flids.toList(), it.version) }
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean =
flightSchd.putGenIfVersion(
day,
expected,
FlightSchdRepository.GenMeta(day, new.version, new.flids.toSet()),
)
}
/** 21 类静态主数据 stub(独立 PG reference 库;内存实现,source 保留供审计断言)。 */
@@ -254,14 +352,12 @@ class StubReqTrack : ReqTrackRepository {
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubFlightState : FlightStateRepository {
private val byDay = mutableMapOf<String, MutableList<Pair<String, String>>>()
class StubFlightState(
private val flightSchd: FlightSchdRepository,
) : FlightStateRepository {
override fun replaceDay(day: String, flights: List<Pair<String, String>>) =
flightSchd.upsertSnapshotBatch(day, flights)
fun clear() { byDay.clear() }
override fun replaceDay(day: String, flights: List<Pair<String, String>>) {
byDay[day] = flights.toMutableList()
}
override fun findByDay(day: String): List<Pair<String, String>> = byDay[day] ?: emptyList()
override fun findByDay(day: String): List<Pair<String, String>> =
flightSchd.findByDay(day)
}
@@ -1,8 +1,6 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.domain.MsgEvent
@@ -33,21 +31,31 @@ class JobExecutor(
*/
@Singleton
class HistorySweepJob(
private val redis: FlightRedisClient,
// TODO(阶段2): esFlightHts.saveSync(history) 返回成功集(同步写,不经事件 ack 回查)
private val flightSchd: FlightSchdRepository,
) {
companion object {
var historyPicker: ((Map<String, String>) -> Map<String, String>)? = null
var esArchiver: ((Map<String, String>) -> Set<String>)? = null
var cutoffProvider: (() -> String?)? = null
}
fun run() {
val all = redis.hgetAllFlightInfo()
val all = flightSchd.findAll()
val history = pickHistory(all) // U10/T07saveSync 接线前判史为空集——禁止“全量可删”默认
val success = emptyList<String>() // TODO(阶段2): esFlightHts.saveSync(history)(返回成功集后再接线删除)
val success = esArchiver?.invoke(history) ?: emptySet() // 仅收集已确认写入 ES 的成功 FLID 集合
if (success.isNotEmpty()) {
redis.eval(RedisScript.BATCH_DELETE, delFields = success)
flightSchd.deleteByFlids(success) // FS2/FS4:仅删除已确认写入 ES 的成功集合,分批参数化删除
}
val cutoff = cutoffProvider?.invoke()
if (cutoff != null) {
flightSchd.deleteGenBefore(cutoff)
}
}
/** U10/T07(修订):接入 ES success 集之前的门禁——占位默认返回空集;
* 现役五条判史规则(SODT 3 天 / CNCL 1 小时 / 备降 / 离港 / 到港)golden 通过后才允许接线。 */
private fun pickHistory(all: Map<String, String>): Map<String, String> = emptyMap() // TODO(阶段2)
private fun pickHistory(all: Map<String, String>): Map<String, String> =
historyPicker?.invoke(all) ?: emptyMap()
}
/** 流程 5 runJob(ARCHIVE)3:00 归档——1 天前且仅终态可迁(矩阵 #12)。 */
@@ -63,19 +71,18 @@ class ArchiveJob(
/** 流程 7:阶段 B 投影重建——切入阶段 B 时全量重建一次,此后增量走事件。 */
@Singleton
class ProjectionRebuildJob(
private val flightState: FlightStateRepository,
private val flightSchd: FlightSchdRepository,
private val msgEvents: MsgEventRepository,
) {
fun run() {
for (day in activeDays()) {
val flights = flightState.findByDay(day)
val flights = flightSchd.findByDay(day)
flights.forEach { (flid, payload) ->
msgEvents.insertSync(
listOf(MsgEvent(target = Targets.REDIS_FLIGHT_INFO, partitionKey = flid, payloadJson = payload)),
listOf(MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = flid, payloadJson = payload)),
)
}
}
// 重建完成前旧 Redis 视图继续服务(数据同源,仅短暂滞后)
}
private fun activeDays(): List<String> = emptyList() // TODO(阶段B)
@@ -11,7 +11,7 @@ import com.gzzn.omms.msgexchange.domain.MsgKind
interface Handler {
val kind: MsgKind
/** 在线状态以只读快照传入(阶段 A 读 Redis 权威、阶段 B 读 FLIGHT_STATE,由调用方装配)。 */
/** 在线状态以只读视图传入(阶段 A 点查自有 PG FLIGHT_SCHD,由调用方装配ACM2-28 定案)。 */
fun decide(flightView: Map<String, String>, msg: DecodedMessage): Decision
}
@@ -11,7 +11,8 @@ 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.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.jobs.JobExecutor
import jakarta.inject.Singleton
@@ -20,7 +21,7 @@ import java.time.Instant
/**
* ACMA-8 流程 2:处理主泵——严格 FIFO + HOL + 毒丸 DEAD 升级(I1);
* 阶段 A 全部 Redis 写在本线程(I5),且先于事件创建(I2 happens-before)。
* 阶段 A 运营航班 FLIGHT_SCHD 与待发事件、终态同事务原子提交(I2/I5,ACM2-28 定案)。
*/
@Singleton
class Pump(
@@ -114,12 +115,25 @@ class MessageProcessor(
private val msgEvents: MsgEventRepository,
private val codecHolder: CodecHolder,
private val handlers: HandlerHolder,
private val redis: FlightRedisClient,
private val flightSchd: FlightSchdRepository,
private val snapshotFlow: SnapshotFlow,
private val procFailure: ProcFailure,
private val props: PipelineProps,
private val txManager: PipelineTransactionManager,
) {
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java)
private val flidRegex = Regex("<FLID>(.*?)</FLID>", RegexOption.IGNORE_CASE)
private fun extractCandidateFlids(decoded: DecodedMessage): Set<String> {
val fromXml = flidRegex.findAll(decoded.rawXml).map { it.groupValues[1].trim() }.filter { it.isNotEmpty() }.toSet()
if (fromXml.isNotEmpty()) return fromXml
val b = decoded.body
if (b is Map<*, *>) {
val flid = b["FLID"] ?: b["flid"]
if (flid != null) return setOf(flid.toString())
}
return emptySet()
}
fun processOne(head: ProcState) {
com.gzzn.omms.msgexchange.infra.log.TraceLog.withTrace(head.cminmsgsId) {
@@ -176,8 +190,7 @@ class MessageProcessor(
return
}
}
// 快照消息走专属流程(流程 4staging→Lua→putGenIfVersion CAS 同事务)
// 快照消息走专属流程(流程 4:staging → SQL 批处理/域内差删/CAS 同事务)
val handler = handlers.registry.dispatcherFor(decoded)
if (handler == null) {
// U10(N21):未注册 ≠ 报文非法——写 FAILED(可重放),绝不写终态
@@ -193,23 +206,35 @@ class MessageProcessor(
return
}
val decision = handler.decide(redis.hgetAllFlightInfo(), decoded) // 纯函数
if (props.phase == PipelineProps.Phase.A) {
// 阶段 A:主泵线程先写 Redis(幂等),先于事件创建(I2);TODO: redisApply(flightChanges)
val candidateFlids = extractCandidateFlids(decoded)
val flightView = if (candidateFlids.isNotEmpty()) {
flightSchd.findByFlids(candidateFlids)
} else {
emptyMap()
}
// 事务 2(自有 PG 内原子):事件 + SUCCEEDED(实装后 @Transactional);
// CMINMSGS 回填 = 共享信箱外部副作用(最终一致,ACM2-12);
// 静态主数据(refUpserts)同自有 PG 但弱事务独立提交(21 类 REF_MASTER)。
val decision = handler.decide(flightView, decoded) // 纯函数
// 事务 2(自有 PG 单事务原子):
// upsert FLIGHT_SCHD(decision.flightChanges) + insert MSG_EVENT + PROC_STATE → SUCCEEDED
val events = buildList {
decision.msgNotifies.forEach { add(MsgEvent(target = Targets.KAFKA_MSG, payloadJson = it.payloadJson)) }
decision.schdPush.forEach { add(MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = it.flid, payloadJson = it.fltrJson)) }
}
msgEvents.insertAll(events)
txManager.inTransaction {
if (decision.flightChanges.isNotEmpty()) {
flightSchd.upsertIncremental(decision.flightChanges)
}
if (events.isNotEmpty()) {
msgEvents.insertAll(events)
}
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
}
// 提交后:backfill CMINMSGS(共享信箱外部副作用,补偿链路)
inbox.backfillOnSuccess(head.cminmsgsId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
log.info("SUCCEEDED id={} events={}", head.cminmsgsId, events.size)
// 阶段 BflightState.apply(decision.flightChanges) 进入同一事务;投影事件(ES/REDIS)追加。
log.info("SUCCEEDED id={} events={} flightChanges={}", head.cminmsgsId, events.size, decision.flightChanges.size)
}
}
@@ -4,59 +4,114 @@ import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
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.RefDataRepository
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import jakarta.inject.Singleton
/**
* ACMA-8 流程 4:日计划快照(generation,主泵内执行)。
* staging(内存瞬态,崩溃从 raw 整包重放)→ Lua 原子“覆盖+按代差删”(I4/I5)
* → putGenIfVersion CAS + SUCCEEDED(重放幂等,版本不二次自增——恢复协议细节见 U09)。
* staging(内存瞬态,崩溃从 raw 整包重放)→ PG 单事务原子“覆盖+按代差删+SQL CAS 推进+事件入队+SUCCEEDED”(I4/I5ACM2-28 定案)。
* U10/T07 占位安全化 + U08 统一失败迁移(ProcFailure):staging 未实装 → FAILED(UNSUPPORTED)+退避
* (可重放,绝不写终态);CAS 冲突 → FAILED(INFRA)+退避;达上限统一 DEAD(EXHAUSTED)。
*/
@Singleton
class SnapshotFlow(
private val procState: ProcStateRepository,
private val refData: RefDataRepository,
private val redis: FlightRedisClient,
private val flightSchd: FlightSchdRepository,
private val msgEvents: MsgEventRepository,
private val reqTrack: ReqTrackRepository,
private val procFailure: ProcFailure,
private val txManager: PipelineTransactionManager,
private val inbox: CminmsgInboxRepository,
) {
private val log = org.slf4j.LoggerFactory.getLogger(SnapshotFlow::class.java)
fun publishSnapshot(head: ProcState, msg: DecodedMessage) {
// 1) staging:流式解析 + 整包校验(TODO(阶段2): 流式 codec;千级 FLTR 为 MB 级,内存瞬态)
val staged = StageResult.stagingOf(msg) // 骨架:TODO 解析 FLTR 集与重组(KEEP 现役 MAFL/登机桥规则)
// 1) staging:流式解析 + 整包校验(内存瞬态,崩溃从 raw 整包重放
val staged = StageResult.stagingOf(msg)
if (staged is StageResult.Invalid) {
log.warn("staging not implemented -> FAILED(UNSUPPORTED) id={} reason={}", head.cminmsgsId, staged.reason)
procFailure.fail(head, ErrorClass.UNSUPPORTED, staged.reason) // U10:未实装 → 可重放,非终态
return
}
val normalized = (staged as StageResult.Ok).flights // (flid, payloadJson)
val ok = staged as StageResult.Ok
val normalized = ok.flights // (flid, payloadJson)
val day = ok.day
// 2) 发布:Lua 原子覆盖 + 按代差删(删除集 = gen.flids − 新代,ADFT 自动存活
val day = staged.day
val gen = refData.getGen(day)
val newFlids = normalized.map { it.first }
val delFields = gen?.flids?.minus(newFlids.toSet()) ?: emptyList()
redis.eval(RedisScript.SNAPSHOT_REPLACE, setPairs = normalized, delFields = delFields)
// 3) 事务:putGen CAS + SUCCEEDED(实装后同 @Transactional;CAS 失败=并发,串行泵下不应发生→告警)
// 内存与超大包熔断防御(ACM2-28 评论 4 项 3
if (normalized.size > MAX_FLIGHTS_PER_SNAPSHOT) {
log.error("snapshot flights exceed limit -> DEAD(MALFORMED) id={} size={}", head.cminmsgsId, normalized.size)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED,
lastError = "flights-exceed-limit:${normalized.size}")
return
}
// 2) 准备代元数据与差集(删除集 = gen.flids − 新代,ADFT 自动存活)
val gen = flightSchd.getGen(day)
val expected = gen?.version ?: 0L
if (!refData.putGenIfVersion(day, expected, RefDataRepository.GenMeta(newFlids, expected + 1))) {
// 重放路径:version 已是目标值 → no-op 视为成功
val again = refData.getGen(day)
if (again == null || again.version != expected + 1) {
log.warn("gen CAS conflict -> FAILED(INFRA) id={}", head.cminmsgsId)
procFailure.fail(head, ErrorClass.INFRA, "gen-cas-conflict") // N06/N28:带退避,禁止紧循环
return
val newFlids = normalized.map { it.first }.toSet()
val delFields = gen?.flids?.minus(newFlids) ?: emptySet()
val newVersion = expected + 1L
// 3) 自有 PG 单事务原子提交(ACM2-28 定案:覆盖新代 + 域内差删 + SQL CAS + 事件 + SUCCEEDED
try {
txManager.inTransaction {
// 覆盖新代全量:强行声明 FDAY 归属(JDBC batch 批处理)
flightSchd.upsertSnapshotBatch(day, normalized)
// 按代差删域化:仅删除 FDAY = day 且在 delFields 中的记录(ADFT 与跨代已迁移行存活)
if (delFields.isNotEmpty()) {
flightSchd.deleteDiffByDay(day, delFields)
}
// SQL CAS 版本推进(防双写断言)
val casSuccess = flightSchd.putGenIfVersion(
day = day,
expected = expected,
newGen = FlightSchdRepository.GenMeta(fday = day, version = newVersion, flids = newFlids),
)
if (!casSuccess) {
// 重放路径:version 已是目标值 → no-op 视为成功,否则为 CAS 冲突
val again = flightSchd.getGen(day)
if (again == null || again.version != newVersion) {
throw CasConflictException("gen-cas-conflict: expected=$expected current=${again?.version}")
}
}
// RESP 匹配 REQ_TRACK -> DONE
val schdKind = msg.kind as? MsgKind.Schd
if (schdKind?.subtype == MsgKind.SchdSubtype.RESP) {
reqTrack.findOpenByKind("SCHD")?.let { req ->
reqTrack.markDone(req.reqId)
}
}
// 构造并批量写入 schd 投递事件
val events = normalized.map { (flid, json) ->
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = flid, payloadJson = json)
}
if (events.isNotEmpty()) {
msgEvents.insertAll(events)
}
// 终态置 SUCCEEDED
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
}
// 提交后:backfill CMINMSGS(外部副作用,补偿保障)
inbox.backfillOnSuccess(head.cminmsgsId, msg.meta.sndr, msg.meta.type, msg.meta.styp, msg.meta.seqn)
log.info("snapshot SUCCEEDED id={} day={} flights={}", head.cminmsgsId, day, normalized.size)
} catch (e: CasConflictException) {
log.warn("gen CAS conflict -> FAILED(INFRA) id={} msg={}", head.cminmsgsId, e.message)
procFailure.fail(head, ErrorClass.INFRA, e.message ?: "gen-cas-conflict")
}
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
log.info("snapshot SUCCEEDED id={} day={} flights={}", head.cminmsgsId, day, normalized.size)
}
/** staging 结果(骨架)。 */
@@ -65,8 +120,12 @@ class SnapshotFlow(
data class Invalid(val reason: String) : StageResult
companion object {
var parser: ((DecodedMessage) -> StageResult)? = null
fun stagingOf(msg: DecodedMessage): StageResult =
Invalid("staging-not-implemented(${msg.typeTag})") // TODO(阶段2)
parser?.invoke(msg) ?: Invalid("staging-not-implemented(${msg.typeTag})") // TODO(阶段2)
}
}
}
class CasConflictException(message: String) : RuntimeException(message)
const val MAX_FLIGHTS_PER_SNAPSHOT = 10000
@@ -0,0 +1,260 @@
package com.gzzn.omms.msgexchange.tools
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.ValueNode
import java.math.BigDecimal
/**
* ACM2-28 FS7:影子对拍跨存储 Diff 比较内核。
*
* 核心定位:
* 本工具提供跨存储航班 AST 结构对比、字段规范化与已知合法偏离识别内核;
* 外部装配侧(从 PostgreSQL/Redis 读取数据并按 CMINMSGS_ID 水位对齐取数)属于影子期装配调用方职责。
* 字段归一化规则(ACM2-28 评论 4 项 4):
* 1. 递归 AST/Map 结构对比,绝不做裸字符串比对(规避 PG jsonb 自动去重重排 key 的分叉);
* 2. 数值精度归一化(1.0 vs 1 视为等价);
* 3. 空值规范化(JSON null 与字段缺失视为等价,规避无语义偏离);
*
* 已知合法偏离声明(ACM2-28 方案六):
* ① FDAY 域化差删对跨代迁移行的保护(legacy 误删、nextgen 不删,属于修正性偏离);
* ② JSONB 与 Redis string 的 key 排序差异。
*/
class FlightStoreDiffTool(
private val mapper: ObjectMapper = ObjectMapper(),
) {
enum class DeviationKind {
/** 已知合法偏离 ①:跨日迁移航班在 nextgen PG 中受 FDAY 保护未被误删,而 legacy 误删 */
CROSS_DAY_PROTECTED,
/** 非预期偏离:字段值不匹配 */
FIELD_MISMATCH,
/** 非预期偏离:PG 缺失该航班 */
MISSING_IN_PG,
/** 非预期偏离:Redis 缺失且非已知迁移保护行 */
UNEXPECTED_EXTRA_IN_PG,
}
data class Deviation(
val flid: String,
val kind: DeviationKind,
val path: String? = null,
val pgValue: Any? = null,
val legacyValue: Any? = null,
val detail: String,
)
data class DiffReport(
val totalPg: Int,
val totalLegacy: Int,
val matchedCount: Int,
val knownDeviations: List<Deviation>,
val unexpectedDeviations: List<Deviation>,
) {
val isGreen: Boolean
get() = unexpectedDeviations.isEmpty()
fun formatSummary(): String {
val sb = StringBuilder()
sb.appendLine("========== 影子对拍跨存储 Diff 报告 ==========")
sb.appendLine("Nextgen PG 航班总数 : $totalPg")
sb.appendLine("Legacy Redis 航班总数 : $totalLegacy")
sb.appendLine("完全一致航班数 : $matchedCount")
sb.appendLine("已知合法偏离数 (偏差①) : ${knownDeviations.size}")
sb.appendLine("非预期异常数 : ${unexpectedDeviations.size}")
sb.appendLine("验收红绿灯状态 : ${if (isGreen) "GREEN (通过)" else "RED (未通过)"}")
sb.appendLine("----------------------------------------------")
if (knownDeviations.isNotEmpty()) {
sb.appendLine("[已知合法偏离]")
knownDeviations.forEach { d ->
sb.appendLine(" FLID=${d.flid}: ${d.detail}")
}
}
if (unexpectedDeviations.isNotEmpty()) {
sb.appendLine("[非预期异常偏离]")
unexpectedDeviations.forEach { d ->
sb.appendLine(" FLID=${d.flid} [${d.kind}] path=${d.path}: PG=${d.pgValue}, Legacy=${d.legacyValue} (${d.detail})")
}
}
sb.appendLine("==============================================")
return sb.toString()
}
}
/**
* 执行全量比对。
* @param pgFlights nextgen FLIGHT_SCHD 行(flid -> fltr_json
* @param legacyFlights legacy Redis flightInfo 行(flid -> value string
* @param crossDayMigratedFlids 已知在多日之间迁移的航班 FLID 集合(用于识别合法偏差 ①)
*/
fun diff(
pgFlights: Map<String, String>,
legacyFlights: Map<String, String>,
crossDayMigratedFlids: Set<String> = emptySet(),
): DiffReport {
val allFlids = (pgFlights.keys + legacyFlights.keys).toSortedSet()
var matched = 0
val known = mutableListOf<Deviation>()
val unexpected = mutableListOf<Deviation>()
for (flid in allFlids) {
val pgRaw = pgFlights[flid]
val legacyRaw = legacyFlights[flid]
if (pgRaw == null) {
unexpected += Deviation(
flid = flid,
kind = DeviationKind.MISSING_IN_PG,
legacyValue = legacyRaw,
detail = "Flight present in Redis but missing in PG",
)
continue
}
if (legacyRaw == null) {
if (flid in crossDayMigratedFlids) {
known += Deviation(
flid = flid,
kind = DeviationKind.CROSS_DAY_PROTECTED,
pgValue = pgRaw,
detail = "Known Deviation 1: Cross-day migrated flight protected by FDAY domain-scoped delete in nextgen PG, erroneously deleted in legacy Redis",
)
} else {
unexpected += Deviation(
flid = flid,
kind = DeviationKind.UNEXPECTED_EXTRA_IN_PG,
pgValue = pgRaw,
detail = "Flight present in PG but missing in Redis without cross-day migration justification",
)
}
continue
}
// 两侧均存在:进行 AST 递归规范化比较
val mismatches = compareJsonTree(flid, pgRaw, legacyRaw)
if (mismatches.isEmpty()) {
matched++
} else {
unexpected.addAll(mismatches)
}
}
return DiffReport(
totalPg = pgFlights.size,
totalLegacy = legacyFlights.size,
matchedCount = matched,
knownDeviations = known,
unexpectedDeviations = unexpected,
)
}
/**
* 递归 AST 比较两段 JSON。
*/
internal fun compareJsonTree(flid: String, pgJson: String, legacyJson: String): List<Deviation> {
val pgNode = try {
mapper.readTree(pgJson)
} catch (e: Exception) {
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "PG JSON parse failed: ${e.message}"))
}
val legacyNode = try {
mapper.readTree(legacyJson)
} catch (e: Exception) {
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "Legacy JSON parse failed: ${e.message}"))
}
val mismatches = mutableListOf<Deviation>()
compareNodes(flid, "", pgNode, legacyNode, mismatches)
return mismatches
}
private fun compareNodes(flid: String, path: String, n1: JsonNode?, n2: JsonNode?, acc: MutableList<Deviation>) {
if (n1 == null && n2 == null) return
// 规范化:null 节点与缺失节点视为等价
val isN1Empty = n1 == null || n1.isNull
val isN2Empty = n2 == null || n2.isNull
if (isN1Empty && isN2Empty) return
if (isN1Empty != isN2Empty) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = n1?.asText(),
legacyValue = n2?.asText(),
detail = "One side is null/absent while other is present",
)
return
}
val nonNull1 = n1!!
val nonNull2 = n2!!
// 数值归一化比较
if (nonNull1.isNumber && nonNull2.isNumber) {
val d1 = BigDecimal(nonNull1.asText()).stripTrailingZeros()
val d2 = BigDecimal(nonNull2.asText()).stripTrailingZeros()
if (d1.compareTo(d2) != 0) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = nonNull1.numberValue(),
legacyValue = nonNull2.numberValue(),
detail = "Numeric value mismatch: $d1 != $d2",
)
}
return
}
// 对象类型:递归属性比较(忽略 key 顺序)
if (nonNull1.isObject && nonNull2.isObject) {
val obj1 = nonNull1 as ObjectNode
val obj2 = nonNull2 as ObjectNode
val allKeys = (obj1.fieldNames().asSequence().toSet() + obj2.fieldNames().asSequence().toSet()).toSortedSet()
for (key in allKeys) {
val nextPath = if (path.isEmpty()) key else "$path.$key"
compareNodes(flid, nextPath, obj1.get(key), obj2.get(key), acc)
}
return
}
// 数组类型:逐项比较
if (nonNull1.isArray && nonNull2.isArray) {
val arr1 = nonNull1 as ArrayNode
val arr2 = nonNull2 as ArrayNode
if (arr1.size() != arr2.size()) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = "size=${arr1.size()}",
legacyValue = "size=${arr2.size()}",
detail = "Array size mismatch",
)
return
}
for (i in 0 until arr1.size()) {
compareNodes(flid, "$path[$i]", arr1.get(i), arr2.get(i), acc)
}
return
}
// 基本类型:文本值比较
if (nonNull1.asText() != nonNull2.asText()) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = nonNull1.asText(),
legacyValue = nonNull2.asText(),
detail = "Value mismatch: ${nonNull1.asText()} != ${nonNull2.asText()}",
)
}
}
}
+5 -10
View File
@@ -36,19 +36,14 @@ mailbox:
username: ${MSGX_MAILBOX_USER:msgx_dev}
password: ${MSGX_MAILBOX_PASSWORD:msgx_dev_pass}
driver-class-name: com.mysql.cj.jdbc.Driver
# stub 模式无 DataSourceFlyway 显式关闭(主配置默认 enabled:true 只作用于有 DataSource 的环境)
# dev 默认 profile 为纯内存 stubstubs=true),无外部数据库,故默认关 Flyway 避免启动报错;
# 当本地开发启用真实 PostgreSQLdatasources.default.enabled=true)时,设 MSGX_FLYWAY_ENABLED=true 自动运行 Flyway 迁移。
flyway:
datasources:
default:
enabled: false
# stub 模式无真实 Redis/Kafka:关掉 micronaut 自带的 RedisHealthIndicator / KafkaHealthIndicator
# (二者装配需构造真实 client/config;实测未设 MSGX_REDIS_URI / MSGX_KAFKA_SERVERS 时 /health 整体报 500)。
# 健康由自定义 redis-flight-store / kafka-delivery indicator 承担(stub ping=true → UP)。
redis:
health:
enabled: false
enabled: ${MSGX_FLYWAY_ENABLED:false}
# stub 模式无真实 Kafka:关掉 micronaut 自带的 KafkaHealthIndicator
# (健康由自定义 kafka-delivery indicator 承担;ACM2-28Redis 已退出阶段 A 权威与写路径)。
kafka:
health:
enabled: false
+6 -9
View File
@@ -3,14 +3,14 @@
# 系统角色:机场 OMMS 上游报文处理中间件(JDBC 轮询共享 CMINMSGS 为主路径;
# compat HTTP POST /cminmsgs/send 为辅助写路径)。
# 口令/端点全部环境变量外置——零入库。
# 存储边界:自有 PostgreSQLdatasources.default,全部内部状态)+
# 共享 MySQL 信箱(mailbox.cminmsgs / mailbox.coutmsgs,仅 DML,不建表)+
# Redis(航班动态 + 快照 gen+ Kafka + Eureka/logstash(见 ACM2-12 与 docs)。
# 存储边界:自有 PostgreSQLdatasources.default,全部内部状态与运营航班权威 FLIGHT_SCHD/SCHD_GEN+
# 共享 MySQL 信箱(mailbox.shared-mysql,仅 DML,不建表)+
# Kafka + Eureka/logstash(见 ACM2-28 与 docs;Redis 彻底退出阶段 A 权威与写路径)。
# 键位口径:Micronaut 5.1U03):datasources.* / flyway.datasources.* /
# kafka.producers.<name>.* / eureka.client.*Micronaut 原生键)。
# =====================================================================
msgx:
phase: A # 航班动态权威 Redis(主泵单线程写,I5);阶段 B 缓做(ACM2-12
phase: A # 航班动态权威落自有 PGFLIGHT_SCHD,主泵单线程写,I5ACM2-28 定案
service-name: msgexchangeapi # 契约冻结;影子实例用 msgexchangeapi-shadow
register-eureka: true # 影子对拍期置 false 或独立服务名
pipeline: # —— ACMA-8 参数表初值(阶段 0 基线校准)——
@@ -38,7 +38,7 @@ micronaut:
# 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml
# 自有 PostgreSQL:全部内部状态(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、
# 21 类 REF_MASTER;迁移 db/migrationACM2-12)。数据层实装U05/阶段 6前 enabled=false。
# 21 类 REF_MASTER、运营航班表 FLIGHT_SCHD 与 SCHD_GEN;迁移 db/migrationACM2-28)。数据层实装前 enabled=false。
datasources:
default:
enabled: false
@@ -50,7 +50,7 @@ datasources:
flyway:
datasources:
default:
enabled: false
enabled: ${MSGX_FLYWAY_ENABLED:true}
locations: classpath:db/migration
# 共享 MySQL 信箱(他人系统库,仅 DML:上游外部写 CMINMSGS;本系统 JDBC 轮询读 +
@@ -64,9 +64,6 @@ mailbox:
password: ${MSGX_MAILBOX_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
uri: ${MSGX_REDIS_URI} # 航班动态权威 + 快照 gen;仅主泵线程写(I5)
kafka:
bootstrap:
servers: ${MSGX_KAFKA_SERVERS}
@@ -0,0 +1,32 @@
-- =====================================================================
-- 自有 PostgreSQL 库 · 阶段 A 运营航班表与计划代迁移 · ACM2-28 定案(选项 C)
-- ---------------------------------------------------------------------
-- 存储边界(ACM2-28 定案,取代 ACM2-12「FLIGHT_STATE 缓做不落表、Redis 永续权威」口径):
-- · FLIGHT_SCHDSCHD 日计划快照 + FLOP/ADFT 增量合并后的当前运营航班权威态,落自有 PG。
-- · SCHD_GEN:各日代版本与当前代航班全量集合(差删依据),由 Redis 回归自有 PG。
-- · Redis:彻底退出动态权威与全部写路径;阶段 A 读写事务全在自有 PG 原子完成。
-- · 增量 Upsert 语义:
-- - 快照全量写入(DNLD):声明/更新 FDAY 归属,更新 FLTR_JSON
-- - 增量写入(FLOP/ADFT):新插置 NULL,已有行通过 ON CONFLICT 保留原 FDAY
-- - 按代差删域化:DELETE FROM FLIGHT_SCHD WHERE FDAY = :day AND FLID = ANY(:diffSet)
-- 仅删除仍属旧代的行,ADFT(FDAY=NULL)与已迁移至新代的同 FLID 行天然存活。
-- · 时间列规范:统一使用 TIMESTAMP(6) WITH TIME ZONE (TIMESTAMPTZ)。
-- =====================================================================
-- ① 运营航班表(当前运营航班动态权威,FLID 与 legacy flightInfo 同键)
CREATE TABLE FLIGHT_SCHD (
FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 唯一航班标识
FDAY DATE NULL, -- 所属日计划代;NULL = 非代所有(ADFT/增量增建)
FLTR_JSON JSONB NOT NULL, -- 航班全量记录(与 legacy flightInfo value 同构)
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_flight_schd_fday ON FLIGHT_SCHD (FDAY); -- 日代扫描与影子对拍分片
-- ② 日计划代与差删元数据表(代版本推进与版本 CAS)
CREATE TABLE SCHD_GEN (
FDAY DATE NOT NULL PRIMARY KEY, -- 计划日(yyyy-MM-dd
VERSION BIGINT NOT NULL, -- 代版本号(单调递增)
FLIDS_JSON JSONB NOT NULL, -- 当前代有效航班 FLID 集合
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
);
-9
View File
@@ -1,9 +0,0 @@
-- ACMA-8 流程 4runJob HISTORY_SWEEP/ I43:30 清场批量删除。
-- 仅删除“ES 归档成功集”(调用方以 saveSync 成功返回集为参),同一泵线程程序序保证依赖。
-- KEYS[1] = flightInfoARGV = 待删除 FLID 列表
local n = 0
for i = 1, #ARGV do
redis.call('HDEL', KEYS[1], ARGV[i])
n = n + 1
end
return n
@@ -1,19 +0,0 @@
-- ACMA-8 流程 4 / I4 / I5:日计划快照“覆盖新代 + 按代差删”,同一 hash 原子执行。
-- KEYS[1] = flightInfo
-- ARGV[1] = set 对数 N,其后为 N 对 field/value;再后为待删除 field(按代差集)
-- 删除集边界:仅上一代 gen.flids − 新代 flids(按代,非全 hash)→ ADFT 自动存活。
local key = KEYS[1]
local nSet = tonumber(ARGV[1])
local i = 2
while i <= 1 + nSet * 2 do
redis.call('HSET', key, ARGV[i], ARGV[i + 1])
i = i + 2
end
local j = 2 + nSet * 2
local deleted = 0
while j <= #ARGV do
redis.call('HDEL', key, ARGV[j])
deleted = deleted + 1
j = j + 1
end
return deleted
@@ -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)
@@ -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 FS6PostgreSQL 真实方言集成测试(JdbcFlightSchdRepository
* 验证:
* 1. DNLD 快照批处理写入(JDBC batch200 批次);
* 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_01FDAY 为 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)
}
}
@@ -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. 门槛 3ADFT 存活保障):增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删;跨代迁移保护;
* 5. UTC 方言测试:非 UTC JVM 时区下读写与幂等判据无漂移。
*/
class FlightSchdInvariantTest {
private lateinit var flightSchd: StubFlightSchd
private lateinit var procState: StubProcState
private lateinit var msgEvents: StubMsgEvents
private lateinit var reqTrack: StubReqTrack
private lateinit var txManager: StubPipelineTransactionManager
private lateinit var inbox: FakeInbox
private lateinit var clock: MutableClock
private lateinit var scheduler: FailureScheduler
private lateinit var procFailure: ProcFailure
private lateinit var props: PipelineProps
class FakeInbox : CminmsgInboxRepository {
val raws = mutableMapOf<Long, String>()
val backfilled = mutableListOf<Long>()
override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
backfilled.add(cminmsgsId)
}
}
class SimpleCodec(private val decodedMsg: DecodedMessage) : XmlCodec {
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decodedMsg)
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
@BeforeEach
fun setUp() {
flightSchd = StubFlightSchd()
procState = StubProcState()
msgEvents = StubMsgEvents()
reqTrack = StubReqTrack()
txManager = StubPipelineTransactionManager()
inbox = FakeInbox()
clock = MutableClock(MutableClock.BASE)
props = PipelineProps()
scheduler = FailureScheduler(props, clock)
procFailure = ProcFailure(procState, scheduler)
SnapshotFlow.StageResult.parser = null
}
@AfterEach
fun tearDown() {
SnapshotFlow.StageResult.parser = null
}
// =========================================================================
// I2 不变量测试:普通报文事务 2 扩展(变更 + 事件 + SUCCEEDED 原子提交)
// =========================================================================
@Test
fun `I2 - MessageProcessor atomically commits flightChanges, msgEvents, and SUCCEEDED`() {
val headId = 101L
inbox.raws[headId] = "<MSG><FLID>F101</FLID></MSG>"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "FLOP", "DELY", 1L, 20260907120000L)
val decoded = DecodedMessage(meta, MsgKind.Flop("DELY"), "<MSG><FLID>F101</FLID></MSG>")
val codecHolder = CodecHolder(SimpleCodec(decoded))
val handler = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, 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 门槛 3ADFT 存活保障(增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删)
// =========================================================================
@Test
fun `Threshold 3 - ADFT flights survive subsequent DNLD replacement`() {
val day = "2026-09-07"
// 1. 增量更新写入一条临时加飞航班 ADFT(FDAY 为 NULL
val adftChange = FlightChange(flid = "ADFT_888", payloadJson = """{"FLID":"ADFT_888","type":"ADFT"}""")
flightSchd.upsertIncremental(listOf(adftChange))
// 2. 写入旧代的一条定期计划航班 REG_OLDFDAY 为 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-08FDAY 更新为 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"))
}
}
-3
View File
@@ -23,9 +23,6 @@ flyway:
default:
enabled: false
redis:
uri: redis://127.0.0.1:6379
kafka:
bootstrap:
servers: 127.0.0.1:9092