fix(processing): Wave1 — IgnoreRules/ROUT截断/Q9文档/schd FLTR 数组(ACM2-94/88/81/96)

REGN/RSTA/EROR 退出忽略清单;ROUT/ERUT 升序留 4 且不落 SCAT/SCDT;C-1/Q9 统一库方清除;flushSchd 聚合成无 key 的 FLTR 数组。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-09-21 14:18:40 +08:00
co-authored by Cursor
parent d298fcc48f
commit 23b74ed554
18 changed files with 268 additions and 71 deletions
@@ -1,5 +1,6 @@
package com.gzzn.omms.msgexchange.delivery
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ErrorClass
@@ -21,20 +22,21 @@ import java.time.Instant
/**
* 守着投递的几条规矩:
* - KAFKA_MSG 一条条按顺序发,而且不会顺手把 KAFKA_SCHD 的事件发掉;
* - KAFKA_SCHD 只有 flushSchd 一个出口,同一个 FLID(航班实例 ID)只发版本号最新的那条
* - 删除通知发成 value 为空的 tombstone 消息
* - KAFKA_SCHD 只有 flushSchd 一个出口:本 tick 待发 UPSERT 聚成一条无 key 的 FLTR 数组(C-9
* - schd 不发 tombstone(删航班只走 msg
* - 发送失败按退避重试,次数用尽转 DEAD 当死信。
*/
class DispatcherTickTest {
private val clock = MutableClock(MutableClock.BASE)
private val mapper = ObjectMapper()
private fun dispatcher(
repo: MsgEventRepository,
port: DeliveryPort,
p: PipelineProps = PipelineProps(),
counters: PipelineCounters = PipelineCounters(),
): Dispatcher = Dispatcher(repo, port, p, FailureScheduler(p, clock), counters)
): Dispatcher = Dispatcher(repo, port, p, FailureScheduler(p, clock), counters, mapper)
private fun ev(id: Long, target: String, key: String, payload: String, version: Long = 0) =
MsgEvent(eventId = id, target = target, partitionKey = key, stateVersion = version, payloadJson = payload, createdAt = MutableClock.BASE)
@@ -70,7 +72,7 @@ class DispatcherTickTest {
}
@Test
fun `flushSchd sends one row per flight and marks it sent`() {
fun `flushSchd sends one FLTR array without key and marks rows sent`() {
val repo = StubMsgEvents()
val port = StubDeliveryPort()
repo.insertAll(
@@ -84,8 +86,12 @@ class DispatcherTickTest {
d.flushSchd()
val schd = port.sent.filter { it.topic == "schd" }
assertEquals(2, schd.size)
assertEquals(1, schd.count { it.key == "F1" && it.payload!!.contains("new") && !it.payload.contains("old") })
assertEquals(1, schd.size)
assertNull(schd.single().key)
val payload = schd.single().payload!!
assertTrue(payload.startsWith("["))
assertTrue(payload.contains("new") && !payload.contains("old"))
assertTrue(payload.contains("f2"))
assertEquals(0, repo.rows.values.count { it.state.name == "PENDING" })
}
@@ -104,7 +110,7 @@ class DispatcherTickTest {
}
@Test
fun `tombstone is delivered as null value message`() {
fun `schd tombstone is not sent on schd topic`() {
val repo = StubMsgEvents()
val port = StubDeliveryPort()
repo.insertAll(
@@ -119,9 +125,8 @@ class DispatcherTickTest {
val d = dispatcher(repo, port)
d.flushSchd()
assertEquals(1, port.tombstones.size)
assertEquals("F1", port.tombstones.single().key) // value 为空表示删掉这个 key 的旧值
assertNull(port.tombstones.single().payload)
assertEquals(0, port.sent.size)
assertEquals(0, port.tombstones.size)
}
@Test
@@ -325,7 +330,7 @@ private class FailingMsgKeyPort(private val failingKey: String) : DeliveryPort {
sent.add(StubDeliveryPort.Sent(topic, key, payloadJson))
}
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, payloadJson: String) = Unit
override fun sendKafkaNull(topic: String, key: String) = Unit
}
@@ -333,7 +338,7 @@ private class FailingMsgKeyPort(private val failingKey: String) : DeliveryPort {
private class FailingSchdPort : DeliveryPort {
var calls = 0
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
override fun sendKafkaSchd(topic: String, payloadJson: String) {
calls++
throw IllegalStateException("broker-down")
}
@@ -346,10 +351,10 @@ private class FailingSchdPort : DeliveryPort {
/** 在发送过程中给同一 FLID 写入新代次,用来验证「条件确认」不会把新内容标记成已发。 */
private class ReentrantSchdPort(private val repo: StubMsgEvents) : DeliveryPort {
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
override fun sendKafkaSchd(topic: String, payloadJson: String) {
repo.insertAll(
listOf(
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = key, stateVersion = 99, payloadJson = """{"v":"new"}""", createdAt = MutableClock.BASE),
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", stateVersion = 99, payloadJson = """{"v":"new"}""", createdAt = MutableClock.BASE),
),
)
}
@@ -169,6 +169,37 @@ class FlightStateEngineTest {
assertTrue(empty.flags.contains(SnapshotFlag.EMPTY))
}
@Test
fun `route collections keep lowest four by rtno ascending and drop scat scdt`() {
val sixRout = (1..6).map { n ->
mapOf("RTNO" to n.toString(), "APCD" to "AP$n", "SCAT" to "15DEC031000", "SCDT" to "15DEC031100")
}
val merged = FlightStateEngine.snapshotState(
null,
ScheduleRecord("121", emptyMap(), mapOf("ROUT" to sixRout)),
operationDay = day,
keepDeleted = false,
)
val rout = merged.collections["ROUT"]!!
assertEquals(4, rout.size)
assertEquals(listOf("1", "2", "3", "4"), rout.map { it["RTNO"] })
assertEquals(listOf("AP1", "AP2", "AP3", "AP4"), rout.map { it["APCD"] })
assertTrue(rout.all { !it.containsKey("SCAT") && !it.containsKey("SCDT") })
}
@Test
fun `rout and erut route caps are independent`() {
val five = (1..5).map { n -> mapOf("RTNO" to n.toString(), "APCD" to "X$n") }
val merged = FlightStateEngine.snapshotState(
null,
ScheduleRecord("121", emptyMap(), mapOf("ROUT" to five, "ERUT" to five)),
operationDay = day,
keepDeleted = false,
)
assertEquals(4, merged.collections["ROUT"]!!.size)
assertEquals(4, merged.collections["ERUT"]!!.size)
}
@Test
fun `validation rejects non numeric or oversized flid`() {
val bad = FlightStateEngine.validateMessage(
@@ -19,14 +19,14 @@ class HealthIndicatorsTest {
private class FakePort(private val pingResult: Boolean) : DeliveryPort {
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, payloadJson: String) = Unit
override fun sendKafkaNull(topic: String, key: String) = Unit
override fun ping(): Boolean = pingResult
}
private class ThrowingPort : DeliveryPort {
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, payloadJson: String) = Unit
override fun sendKafkaNull(topic: String, key: String) = Unit
override fun ping(): Boolean = throw RuntimeException("metadata fetch failed")
}
@@ -0,0 +1,97 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.support.PgTestSupport
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
import java.sql.DriverManager
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.UUID
/** US-05 AC3ROUT/ERUT 各自最多 4 条、不存 SCAT/SCDT,且两类路线同 FLID 并存。 */
class JdbcFlightStateRoutePgTest {
private val t0: Instant = Instant.parse("2026-09-12T00:00:00Z")
@Test
fun `persist keeps four rout and four erut without scat scdt`() {
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
val ds = dataSource()
val clock = Clock.fixed(t0, ZoneOffset.UTC)
val repo = JdbcFlightStateRepository(ds, clock)
val flid = "RT-" + UUID.randomUUID().toString().take(8)
val rout = (1..6).map { n ->
mapOf("RTNO" to n.toString(), "APCD" to "R$n", "SCAT" to "15DEC031000", "SCDT" to "15DEC031100")
}
val erut = (1..5).map { n -> mapOf("RTNO" to n.toString(), "APCD" to "E$n", "SCAT" to "x", "SCDT" to "y") }
repo.persistFullState(
FlightSnapshot(
flid, LocalDate.of(2026, 9, 12), FlightState.ACTIVE, 1,
mapOf("SODT" to "12Sep261200"),
mapOf("ROUT" to rout, "ERUT" to erut),
),
msgId = 1,
now = t0,
)
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
conn.prepareStatement(
"SELECT route_kind, ordinal, source_seq, apcd, scat, scdt FROM flight_route_point WHERE flid = ? ORDER BY route_kind, ordinal",
).use { ps ->
ps.setString(1, flid)
ps.executeQuery().use { rs ->
val rows = mutableListOf<List<String?>>()
while (rs.next()) {
rows.add(
listOf(
rs.getString("route_kind"),
rs.getInt("ordinal").toString(),
rs.getString("source_seq"),
rs.getString("apcd"),
rs.getString("scat"),
rs.getString("scdt"),
),
)
}
assertEquals(8, rows.size)
assertEquals(4, rows.count { it[0] == "ROUT" })
assertEquals(4, rows.count { it[0] == "ERUT" })
assertTrue(rows.all { it[4] == null && it[5] == null })
assertEquals(listOf("1", "2", "3", "4"), rows.filter { it[0] == "ROUT" }.map { it[2] })
assertEquals(listOf("1", "2", "3", "4"), rows.filter { it[0] == "ERUT" }.map { it[2] })
}
}
}
val loaded = repo.loadFullSnapshot(flid)!!
assertFalse(loaded.collections["ROUT"]!!.any { it.containsKey("SCAT") || it.containsKey("SCDT") })
assertEquals(4, loaded.collections["ROUT"]!!.size)
assertEquals(4, loaded.collections["ERUT"]!!.size)
}
private fun dataSource(): javax.sql.DataSource {
Flyway.configure()
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
.locations("classpath:db/migration")
.load()
.migrate()
return HikariDataSource(
HikariConfig().apply {
jdbcUrl = PgTestSupport.jdbcUrl
username = PgTestSupport.user
password = PgTestSupport.password
maximumPoolSize = 2
},
)
}
}
@@ -5,6 +5,7 @@ import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState
@@ -105,23 +106,25 @@ class IgnoreBranchTest {
}
@Test
fun `EROR subtype hits ignore rule`() {
fun `EROR routes to eror path not ignore list`() {
val proc = StubProcState()
proc.insertIfAbsent(msgId, null)
val inbox = StubInbox()
inbox.raws[msgId] = "<RAW/>"
val msg = decoded("EROR", "GEN")
val msg = decoded("EROR", "GEN").copy(kind = MsgKind.Eror)
val p = processor(proc = proc, inbox = inbox, codec = codecReturning(msg))
p.processOne(head())
assertEquals(ProcStatus.SKIPPED, proc.find(msgId)!!.state)
assertEquals("ignored:EROR-*", proc.find(msgId)!!.lastError)
val row = proc.find(msgId)!!
assertEquals(ProcStatus.FAILED, row.state)
assertEquals(ErrorClass.UNSUPPORTED, row.errorClass)
assertEquals("eror-pending", row.lastError)
}
@Test
fun `REGN and RSTA both hit ignore rules`() {
for ((type, rule) in listOf("REGN" to "REGN-*", "RSTA" to "RSTA-*")) {
fun `REGN and RSTA route to refdata path not ignore list`() {
for (type in listOf("REGN", "RSTA")) {
val proc = StubProcState()
val id = msgId + type.hashCode().toLong()
proc.insertIfAbsent(id, null)
@@ -129,15 +132,17 @@ class IgnoreBranchTest {
inbox.raws[id] = "<RAW/>"
val msg = DecodedMessage(
meta = MetaFields("AODB", type, "X", 300L, 1L),
kind = MsgKind.Unsupported("$type-X"),
kind = MsgKind.RefData(type),
rawXml = "<MSG/>",
)
val p = processor(proc = proc, inbox = inbox, codec = codecReturning(msg))
p.processOne(ProcState(id, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
assertEquals(ProcStatus.SKIPPED, proc.find(id)!!.state)
assertEquals("ignored:$rule", proc.find(id)!!.lastError)
val row = proc.find(id)!!
assertEquals(ProcStatus.FAILED, row.state)
assertEquals(ErrorClass.UNSUPPORTED, row.errorClass)
assertEquals("refdata-pending:$type", row.lastError)
}
}