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:
@@ -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")
|
||||
}
|
||||
|
||||
+97
@@ -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 AC3:ROUT/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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user