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:
@@ -68,6 +68,7 @@ class JacksonXmlCodec : XmlCodec {
|
||||
MsgKind.Fdel -> msg.flop?.let(SisWireMapper::flopPayload)
|
||||
// 参考数据类别报文的正文结构随 13 类各异(ACM2-93 实装 ReferenceDataProcessor 时绑定)
|
||||
is MsgKind.RefData -> null
|
||||
MsgKind.Eror -> null
|
||||
is MsgKind.Unsupported -> null
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ class JacksonXmlCodec : XmlCodec {
|
||||
else -> MsgKind.Unsupported("FLOP-$styp")
|
||||
}
|
||||
in REF_DATA_TYPES -> MsgKind.RefData(type)
|
||||
"EROR" -> MsgKind.Eror
|
||||
else -> MsgKind.Unsupported("$type-$styp")
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ interface DeliveryPort {
|
||||
/** 发一条变化通知,消息 key 是 FLID(航班实例 ID)。 */
|
||||
fun sendKafka(topic: String, key: String, payloadJson: String)
|
||||
|
||||
/** 发一条完整状态,消息 key 是 FLID。适配层负责把 target 映射成 topic:KAFKA:msg 对应 "msg",KAFKA:schd 对应 "schd"。 */
|
||||
fun sendKafkaSchd(topic: String, key: String, payloadJson: String)
|
||||
/**
|
||||
* 发一整批 schd 状态:`payloadJson` 是 `SCHD.FLTR` JSON 数组(`C-9`),**不设 message key**。
|
||||
*/
|
||||
fun sendKafkaSchd(topic: String, payloadJson: String)
|
||||
|
||||
/** 发一条删除通知:key 是 FLID、value 为空;下游按"整态里这个键没了"理解成删除。 */
|
||||
fun sendKafkaNull(topic: String, key: String)
|
||||
@@ -33,8 +35,8 @@ interface DeliveryPort {
|
||||
*
|
||||
* KAFKA_MSG 按 `EVENT_ID` 顺序发,保序以 FLID 为单位(D2):某航班的队头失败或退避未到期
|
||||
* 只暂停该 FLID,其他航班照常推进。KAFKA_SCHD 走 flushSchd:outbox 每个 FLID 只保留
|
||||
* 一行(写入侧单行 upsert),发出后按读取时刻的代次做条件确认;删除通知发 value 为空的 tombstone。
|
||||
* 两个主题之间不保证先后顺序。
|
||||
* 一行(写入侧单行 upsert),聚合成一条无 key 的 `SCHD.FLTR` JSON 数组发出(`C-9`);
|
||||
* 删航班只走 msg。两个主题之间不保证先后顺序。
|
||||
*
|
||||
* 失败处理:某 FLID 队首的重试时间没到就跳过该 FLID;发送失败重试次数加一并推后退避,
|
||||
* 次数用尽转 DEAD 当死信。见 docs/implementation.md「Kafka 与读取」。
|
||||
@@ -46,6 +48,7 @@ class Dispatcher(
|
||||
private val props: PipelineProps,
|
||||
private val scheduler: FailureScheduler,
|
||||
private val counters: com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters,
|
||||
private val mapper: com.fasterxml.jackson.databind.ObjectMapper,
|
||||
) {
|
||||
private val log = org.slf4j.LoggerFactory.getLogger(Dispatcher::class.java)
|
||||
|
||||
@@ -152,7 +155,10 @@ class Dispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
/** 发 KAFKA_SCHD:每个 FLID 只有一行待发事件,删除通知发空 value,成功后按代次条件确认。 */
|
||||
/**
|
||||
* 发 `KAFKA:schd`:本 tick 窗口内待发 UPSERT 聚成一条 `SCHD.FLTR` JSON 数组、不设 key(`C-9`)。
|
||||
* 空窗口不发。schd 侧 TOMBSTONE 不发送(删航班只走 msg)。
|
||||
*/
|
||||
internal fun flushSchd() {
|
||||
val batch = try {
|
||||
msgEvents.mergePendingSchd(scheduler.now(), props.schd.flushLimit)
|
||||
@@ -160,25 +166,28 @@ class Dispatcher(
|
||||
log.warn("mergePendingSchd failed: {}", e.message)
|
||||
return
|
||||
}
|
||||
if (batch.isEmpty()) {
|
||||
val upserts = batch.filter { it.eventType == EventType.UPSERT }
|
||||
if (upserts.isEmpty()) {
|
||||
lastFlush = scheduler.now()
|
||||
return
|
||||
}
|
||||
val failures = mutableListOf<Pair<MsgEvent, String>>()
|
||||
for (e in batch) {
|
||||
val fltrs: List<Any> = upserts.map { e ->
|
||||
try {
|
||||
when (e.eventType) {
|
||||
EventType.TOMBSTONE -> port.sendKafkaNull("schd", e.partitionKey)
|
||||
EventType.UPSERT -> port.sendKafkaSchd("schd", e.partitionKey, e.payloadJson)
|
||||
}
|
||||
// 条件确认:读取时刻的代次(EVENT_ID + STATE_VERSION)被新写入覆盖时不标记,留待下一轮重发。
|
||||
e.eventId?.let { msgEvents.markSentIfVersion(it, e.stateVersion, scheduler.now()) }
|
||||
} catch (ex: Exception) {
|
||||
// 首次失败就带上真实原因;不能退回字面量把根因抹掉
|
||||
failures += e to (ex.message ?: ex.javaClass.simpleName)
|
||||
mapper.readValue(e.payloadJson, Any::class.java)
|
||||
} catch (_: Exception) {
|
||||
e.payloadJson
|
||||
}
|
||||
}
|
||||
failures.forEach { (e, error) -> retryOrDead(e, error) }
|
||||
val arrayJson = mapper.writeValueAsString(fltrs)
|
||||
try {
|
||||
port.sendKafkaSchd("schd", arrayJson)
|
||||
for (e in upserts) {
|
||||
e.eventId?.let { msgEvents.markSentIfVersion(it, e.stateVersion, scheduler.now()) }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
val error = ex.message ?: ex.javaClass.simpleName
|
||||
upserts.forEach { e -> retryOrDead(e, error) }
|
||||
}
|
||||
lastFlush = scheduler.now()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ data class MetaFields(
|
||||
val dttm: Long,
|
||||
)
|
||||
|
||||
/** 报文的种类:日计划、运行动态、FDEL(航班终止)、静态参考数据,或者还没支持的类型。分派时用穷尽 when 保证不漏分支。 */
|
||||
/** 报文的种类:日计划、运行动态、FDEL(航班终止)、静态参考数据、错误回报,或者还没支持的类型。分派时用穷尽 when 保证不漏分支。 */
|
||||
sealed interface MsgKind {
|
||||
data class Schd(val subtype: SchdSubtype) : MsgKind
|
||||
data class Flop(val subtype: String) : MsgKind // 运行动态,subtype 是报文子类型;FDEL 单独成类,不走这里
|
||||
@@ -26,6 +26,11 @@ sealed interface MsgKind {
|
||||
*/
|
||||
data class RefData(val type: String) : MsgKind
|
||||
|
||||
/**
|
||||
* AODB 错误回报(US-09 AC3):须匹配出站请求并标 FAILED;协调器落地前保持可重试失败并告警。
|
||||
*/
|
||||
data object Eror : MsgKind
|
||||
|
||||
/** 已识别但还没有对应处理器的类型:跳过留档记 `SKIPPED(unsupported)`,不作为可重试失败占住队头。 */
|
||||
data class Unsupported(val tag: String) : MsgKind
|
||||
|
||||
@@ -45,6 +50,7 @@ data class DecodedMessage(
|
||||
is MsgKind.Flop -> "FLOP-${k.subtype}"
|
||||
MsgKind.Fdel -> "FDEL"
|
||||
is MsgKind.RefData -> "REF-${k.type}"
|
||||
MsgKind.Eror -> "EROR"
|
||||
is MsgKind.Unsupported -> k.tag
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,28 @@ object FlightStateEngine {
|
||||
"GTDT", "CKDT", "CLDT", "PSDT", "CHDT", "DELY", "ABTM", "CHOT", "ROUT", "ERUT",
|
||||
)
|
||||
|
||||
private val ROUTE_COLLECTION_KEYS = setOf("ROUT", "ERUT")
|
||||
private const val ROUTE_POINT_LIMIT = 4
|
||||
|
||||
/**
|
||||
* US-05 AC3:ROUT/ERUT 各自最多 4 条;按上游序号(RTNO / 读回的 SOURCE_SEQ)升序保留,
|
||||
* 不写入当前态的 SCAT/SCDT。
|
||||
*/
|
||||
fun normalizeRouteCollection(items: List<Map<String, String>>): List<Map<String, String>> =
|
||||
items.withIndex()
|
||||
.sortedWith(compareBy({ (_, row) -> routeSeqKey(row) }, { (idx, _) -> idx }))
|
||||
.map { it.value }
|
||||
.take(ROUTE_POINT_LIMIT)
|
||||
.map { row -> row.filterKeys { it != "SCAT" && it != "SCDT" } }
|
||||
|
||||
private fun routeSeqKey(row: Map<String, String>): Int {
|
||||
val raw = row["RTNO"] ?: row["SOURCE_SEQ"]
|
||||
return raw?.trim()?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
}
|
||||
|
||||
private fun normalizeCollection(key: String, items: List<Map<String, String>>): List<Map<String, String>> =
|
||||
if (key in ROUTE_COLLECTION_KEYS) normalizeRouteCollection(items) else items
|
||||
|
||||
/**
|
||||
* 校验一包日计划:报头声明的记录数(RECS)要在 0–9999 之间、且等于实际收到的条数;
|
||||
* 每条记录的 FLID 必须是 1–12 位数字,快照内不能有重复 FLID,每条记录的运营日都要
|
||||
@@ -99,7 +121,7 @@ object FlightStateEngine {
|
||||
putAll(current.collections) // 报文没带的集合保持原值
|
||||
}
|
||||
record.collections.forEach { (key, items) ->
|
||||
if (key in COLLECTION_KEYS) put(key, items) // 报文带了整个集合就整体替换
|
||||
if (key in COLLECTION_KEYS) put(key, normalizeCollection(key, items))
|
||||
}
|
||||
}
|
||||
return FlightSnapshot(
|
||||
@@ -124,7 +146,7 @@ object FlightStateEngine {
|
||||
val collections = buildMap {
|
||||
putAll(current.collections)
|
||||
change.collections.forEach { (key, items) ->
|
||||
if (key in COLLECTION_KEYS) put(key, items)
|
||||
if (key in COLLECTION_KEYS) put(key, normalizeCollection(key, items))
|
||||
}
|
||||
}
|
||||
return current.copy(
|
||||
|
||||
@@ -29,8 +29,9 @@ class KafkaDeliveryPort(
|
||||
producer.send(ProducerRecord(topic, key, payloadJson)).get()
|
||||
}
|
||||
|
||||
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
|
||||
producer.send(ProducerRecord(topic, key, payloadJson)).get()
|
||||
override fun sendKafkaSchd(topic: String, payloadJson: String) {
|
||||
// C-9:不设 message key
|
||||
producer.send(ProducerRecord<String, String>(topic, null, payloadJson)).get()
|
||||
}
|
||||
|
||||
override fun sendKafkaNull(topic: String, key: String) {
|
||||
|
||||
+23
-5
@@ -8,6 +8,7 @@ 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.Targets
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightMainRow
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
@@ -679,7 +680,12 @@ class JdbcFlightStateRepository(
|
||||
|
||||
val collections = linkedMapOf<String, List<Map<String, String>>>()
|
||||
COLLECTIONS.forEach { (key, spec) ->
|
||||
collections[key] = loadDetails(flid, key, spec)
|
||||
val loaded = loadDetails(flid, key, spec)
|
||||
collections[key] = if (key == "ROUT" || key == "ERUT") {
|
||||
FlightStateEngine.normalizeRouteCollection(loaded)
|
||||
} else {
|
||||
loaded
|
||||
}
|
||||
}
|
||||
return FlightSnapshot(
|
||||
flid = main.flid,
|
||||
@@ -812,8 +818,20 @@ class JdbcFlightStateRepository(
|
||||
private fun replaceDetails(snapshot: FlightSnapshot, now: Instant) {
|
||||
val flid = snapshot.flid
|
||||
COLLECTIONS.forEach { (key, spec) ->
|
||||
ds.update("DELETE FROM ${spec.table} WHERE flid = ?", { ps -> ps.setString(1, flid) })
|
||||
val items = snapshot.collections[key].orEmpty()
|
||||
if (spec.routeKind != null) {
|
||||
ds.update(
|
||||
"DELETE FROM ${spec.table} WHERE flid = ? AND route_kind = ?",
|
||||
{ ps ->
|
||||
ps.setString(1, flid)
|
||||
ps.setString(2, spec.routeKind)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ds.update("DELETE FROM ${spec.table} WHERE flid = ?", { ps -> ps.setString(1, flid) })
|
||||
}
|
||||
val items = snapshot.collections[key].orEmpty().let { raw ->
|
||||
if (key == "ROUT" || key == "ERUT") FlightStateEngine.normalizeRouteCollection(raw) else raw
|
||||
}
|
||||
items.forEachIndexed { ordinal, item ->
|
||||
val cols = mutableListOf("flid", "ordinal", "source_seq")
|
||||
val vals = mutableListOf<Any?>(flid)
|
||||
@@ -915,8 +933,8 @@ class JdbcFlightStateRepository(
|
||||
"DELY" to DetailSpec("flight_delay", listOf("code", "strt", "dura", "remc"), null),
|
||||
"ABTM" to DetailSpec("flight_bridge_op", listOf("abdg", "abop", "aotm"), "ASNO"),
|
||||
"CHOT" to DetailSpec("flight_chock_op", listOf("chid", "chst", "chtm"), "CSNO"),
|
||||
"ROUT" to DetailSpec("flight_route_point", listOf("apcd", "scat", "scdt"), "RTNO", routeKind = "ROUT"),
|
||||
"ERUT" to DetailSpec("flight_route_point", listOf("apcd", "scat", "scdt"), "RTNO", routeKind = "ERUT"),
|
||||
"ROUT" to DetailSpec("flight_route_point", listOf("apcd"), "RTNO", routeKind = "ROUT"),
|
||||
"ERUT" to DetailSpec("flight_route_point", listOf("apcd"), "RTNO", routeKind = "ERUT"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import jakarta.inject.Singleton
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubDeliveryPort : DeliveryPort {
|
||||
data class Sent(val topic: String, val key: String, val payload: String?)
|
||||
data class Sent(val topic: String, val key: String?, val payload: String?)
|
||||
|
||||
val sent = mutableListOf<Sent>()
|
||||
val tombstones: List<Sent> get() = sent.filter { it.payload == null }
|
||||
@@ -24,8 +24,8 @@ class StubDeliveryPort : DeliveryPort {
|
||||
sent += Sent(topic, key, payloadJson)
|
||||
}
|
||||
|
||||
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
|
||||
sent += Sent(topic, key, payloadJson)
|
||||
override fun sendKafkaSchd(topic: String, payloadJson: String) {
|
||||
sent += Sent(topic, null, payloadJson)
|
||||
}
|
||||
|
||||
override fun sendKafkaNull(topic: String, key: String) {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
/**
|
||||
* US-04 忽略清单:基线规则为 `LDM-*`、`REGN-*`、`RSTA-*`、`EROR-*`。
|
||||
* 忽略清单:仅保留文档未要求处理的 `LDM-*`。
|
||||
*
|
||||
* 匹配按 `TYPE` 前缀做(`TYPE-*` 表示该 TYPE 下所有 STYP 均忽略);
|
||||
* `MetaFields.type` 已由 codec 统一大写,这里直接用大写常量比较。
|
||||
* 返回命中的规则标签(如 `LDM-*`),未命中返回 null。
|
||||
* `REGN`/`RSTA` 走参考数据(US-13);`EROR` 走出站错误回报(US-09)——不得在此跳过。
|
||||
* 匹配按 `TYPE` 精确相等;返回命中规则标签,未命中返回 null。
|
||||
*/
|
||||
object IgnoreRules {
|
||||
private val rules: List<Pair<String, String>> = listOf(
|
||||
"LDM" to "LDM-*",
|
||||
"REGN" to "REGN-*",
|
||||
"RSTA" to "RSTA-*",
|
||||
"EROR" to "EROR-*",
|
||||
)
|
||||
|
||||
fun match(type: String): String? {
|
||||
|
||||
@@ -251,6 +251,11 @@ class MessageProcessor(
|
||||
log.warn("refdata without handler -> FAILED(UNSUPPORTED) msgId={} type={}", head.msgId, kind.type)
|
||||
return procFailure.fail(head, ErrorClass.UNSUPPORTED, "refdata-pending:${kind.type}")
|
||||
}
|
||||
MsgKind.Eror -> {
|
||||
// US-09 AC3:须匹配出站请求;REQ_TRACK 协调器落地前(ACM2-92)先按可重试失败留队。
|
||||
log.warn("eror without handler -> FAILED(UNSUPPORTED) msgId={}", head.msgId)
|
||||
return procFailure.fail(head, ErrorClass.UNSUPPORTED, "eror-pending")
|
||||
}
|
||||
is MsgKind.Unsupported -> {
|
||||
// 合法但不支持的类型:跳过留档记 SKIPPED(US-03 AC2;markTerminal 同一条 UPDATE
|
||||
// 登记回填意图),不当可重试失败占住队头
|
||||
|
||||
@@ -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