feat(ingress): 实现 JDBC 信箱轮询、入站持久化与对拍测试 (U05)

This commit is contained in:
windyboy
2026-09-07 15:11:33 +08:00
parent dc68f1e1f8
commit c7b4b527ef
51 changed files with 1005 additions and 235 deletions
@@ -0,0 +1,45 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.test.assertEquals
/**
* ACMA-8 I3identity = SNDR|TYPE|STYP|SEQN;日边界含否集中可配(CONFIRM 矩阵 #11,默认关)。
*/
class IdentityTest {
private fun msg(seqn: Long, dttm: Long = 20260906120000) = DecodedMessage(
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = seqn, dttm = dttm),
kind = MsgKind.Flop("DELY"),
rawXml = "<MSG/>",
)
@Test
fun `identity is pipe-joined SNDR TYPE STYP SEQN`() {
val identity = Identity.of(msg(42), includeDayBoundary = false)
assertEquals("AODB|FLOP|DELY|42", identity)
}
@Test
fun `day boundary included only when configured`() {
val day = LocalDate.of(2026, 9, 6)
assertEquals(
"AODB|FLOP|DELY|42|2026-09-06",
Identity.of(msg(42), includeDayBoundary = true, day = day),
)
assertEquals(
"AODB|FLOP|DELY|42",
Identity.of(msg(42), includeDayBoundary = false, day = day),
)
}
@Test
fun `props default keeps day boundary off`() {
assertEquals(false, PipelineProps.Identity().includeDayBoundary)
}
}
@@ -0,0 +1,326 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeFailure
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.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.MetaFields
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.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.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
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.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
/**
* U08/U10/U11processOne 失败闭环——
* ① 意外异常在边界内写 FAILED(INFRA)+退避,重试达上限转 DEAD(EXHAUSTED)Pump 内部异常不杀泵);
* ② 未注册/CODEC_ERROR/staging 未实装 → FAILED(绝不直接终态);MALFORMED → DEAD
* ③ InterruptedException/致命 Error 不被普通恢复逻辑吞掉。
*/
class MessageProcessorTest {
private class FakeProcState : ProcStateRepository {
var record = mutableMapOf<Long, ProcState>()
val bound = mutableMapOf<String, Long>()
override fun insert(cminmsgsId: Long, state: ProcStatus) { record[cminmsgsId] = ProcState(cminmsgsId, state) }
override fun exists(cminmsgsId: Long): Boolean = record.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
val owner = bound[identityKey]
if (owner != null && owner != cminmsgsId) return false
bound[identityKey] = cminmsgsId
record[cminmsgsId] = record[cminmsgsId]?.copy(identityKey = identityKey) ?: ProcState(cminmsgsId, ProcStatus.PENDING, identityKey = identityKey)
return true
}
override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey]
override fun update(
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
errorClass: ErrorClass?, lastError: String?,
) {
val old = record[cminmsgsId] ?: ProcState(cminmsgsId, state)
record[cminmsgsId] = old.copy(
state = state,
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
attempts = attempts ?: old.attempts,
errorClass = errorClass ?: old.errorClass,
lastError = lastError ?: old.lastError,
)
}
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
var n = 0
record.keys.toList().forEach { id ->
val s = record[id]!!
if (s.errorClass != null && s.errorClass in errorClasses &&
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
record[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
n++
}
}
return n
}
fun state(id: Long): ProcState = record.getValue(id)
}
private class FakeInbox : CminmsgInboxRepository {
var raws = mutableMapOf<Long, String>()
var throwOnRawOf: Throwable? = null
val backfilled = mutableListOf<List<Any?>>()
override fun insertRaw(rawXml: String): Long = 0
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun rawOf(cminmsgsId: Long): String? {
throwOnRawOf?.let { throw it }
return raws[cminmsgsId]
}
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
backfilled += listOf(cminmsgsId, sndr, type, styp, seqn)
}
}
private class FakeEvents : MsgEventRepository {
val inserted = mutableListOf<List<MsgEvent>>()
override fun insertAll(events: List<MsgEvent>): List<Long> { inserted += events; return events.indices.map { it.toLong() + 1 } }
override fun headUnsent(target: String): MsgEvent? = null
override fun claimBatch(target: String, limit: Int): List<MsgEvent> = emptyList()
override fun markSent(eventId: Long) = Unit
override fun markAllSent(eventIds: List<Long>) = Unit
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) = Unit
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) = Unit
override fun insertSync(events: List<MsgEvent>) = Unit
}
private class FakeCodec : XmlCodec {
var result: DecodeResult = DecodeResult.Ok(
DecodedMessage(
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = 1L, dttm = 20260906120000L),
kind = MsgKind.Flop("DELY"),
rawXml = "<MSG/>",
),
)
override fun decode(rawXml: String): DecodeResult = result
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 FakeRefData : RefDataRepository {
override fun getGen(day: String): RefDataRepository.GenMeta? = null
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean = true
}
// ---------- helpers ----------
private val clock = MutableClock(MutableClock.BASE)
private fun msg(seqn: String) = DecodedMessage(
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = seqn.toLong(), dttm = 20260906120000L),
kind = MsgKind.Flop("DELY"),
rawXml = "<MSG/>",
)
private fun head(id: Long = 1, status: ProcStatus = ProcStatus.PENDING, attempts: Int = 0, identityKey: String? = null) =
ProcState(id, status, identityKey = identityKey, attempts = attempts)
private fun deliveHandler() = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, String>, msg: DecodedMessage): Decision = Decision(
msgNotifies = listOf(NotifyPayload("""{"n":1}""")),
schdPush = listOf(SchdPush(flid = "F1", fltrJson = """{"FLID":"F1","v":2}""")),
)
}
private fun processor(
procState: FakeProcState, inbox: FakeInbox, events: FakeEvents, codec: FakeCodec,
registry: HandlerRegistry = HandlerRegistry(emptyList()),
): MessageProcessor {
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)
}
// ---------- tests ----------
@Test
fun `no handler is FAILED UNSUPPORTED with backoff - never terminal`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.UNSUPPORTED, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
assertTrue(s.lastError?.startsWith("no-handler:FLOP-DELY") == true)
}
@Test
fun `CODEC_ERROR decode failure is FAILED retryable`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "boom")) }
processor(ps, inbox, ev, codec).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.CODEC_ERROR, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
}
@Test
fun `MALFORMED decode failure stays DEAD terminal`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "bad-xml")) }
processor(ps, inbox, ev, codec).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.DEAD, s.state)
assertEquals(ErrorClass.MALFORMED, s.errorClass)
}
@Test
fun `legacy failed head at max attempts upgrades to DEAD at entry`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(status = ProcStatus.FAILED, attempts = 5, identityKey = "k")
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
assertEquals(ProcStatus.DEAD, ps.state(1).state)
assertEquals(ErrorClass.EXHAUSTED, ps.state(1).errorClass)
}
@Test
fun `success path inserts events backfills and marks SUCCEEDED`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val registry = HandlerRegistry(listOf(deliveHandler()))
processor(ps, inbox, ev, FakeCodec(), registry).processOne(ps.state(1))
assertEquals(ProcStatus.SUCCEEDED, ps.state(1).state)
val events = ev.inserted.single()
assertEquals(listOf(Targets.KAFKA_MSG, Targets.KAFKA_SCHD), events.map { it.target })
assertEquals("F1", events.first { it.target == Targets.KAFKA_SCHD }.partitionKey)
assertEquals(listOf(1L, "AODB", "FLOP", "DELY", 1L), inbox.backfilled.single())
}
@Test
fun `duplicate identity leads to SKIPPED`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.raws[1] = "<MSG/>"
ps.bound["AODB|FLOP|DELY|1"] = 2L // 另一条消息已持有该键
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.SKIPPED, s.state)
assertTrue(s.lastError == "duplicate-of:2")
assertTrue(ev.inserted.isEmpty())
}
// ---------- U08 边界化(评审要求①③) ----------
@Test
fun `unexpected exception maps to FAILED INFRA with backoff at boundary`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = RuntimeException("db-down")
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.INFRA, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
assertTrue(s.lastError == "db-down")
}
@Test
fun `repeated unexpected exceptions escalate to DEAD EXHAUSTED at max attempts`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = RuntimeException("db-down")
val p = processor(ps, inbox, ev, FakeCodec())
var attempts = 0
while (ps.state(1).state != ProcStatus.DEAD && attempts < 10) {
p.processOne(ps.state(1))
clock.advance(60_000) // 推进时钟:让退避/毒丸时间语义确定
attempts++
}
val s = ps.state(1)
assertEquals(ProcStatus.DEAD, s.state, "should exhaust within maxAttempts(=5)")
assertEquals(ErrorClass.EXHAUSTED, s.errorClass)
assertTrue(s.lastError?.contains("attempts=5") == true)
assertEquals(5, s.attempts)
}
@Test
fun `fatal Error is not swallowed by exception recovery`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = AssertionError("fatal")
assertThrows(AssertionError::class.java) {
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
}
// 未被普通恢复逻辑改写成 FAILEDError 不落入 catch Exception
assertEquals(ProcStatus.PENDING, ps.state(1).state)
}
@Test
fun `InterruptedException restores flag and propagates`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = InterruptedException("stop")
assertThrows(InterruptedException::class.java) {
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
}
assertTrue(Thread.interrupted(), "interrupt flag must be restored")
}
}