feat(acm2): 闭合 G-KAFKA-D3 / G-IGNORE / G-EVENT-RETENTION 三个缺口
- G-KAFKA-D3:max-in-flight 默认收敛到 1,KafkaD3Check 启动自检钉住三项联合满足 D3 - G-IGNORE:IgnoreRules 在身份绑定后精确匹配 TYPE 字段,命中写 SKIPPED + 回填意图 - G-EVENT-RETENTION:V10 迁移加 SENT_AT 列,投递原子写,EventCleanupJob 有界清理过期 SENT 行
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package com.gzzn.omms.msgexchange.config
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class KafkaD3CheckTest {
|
||||
|
||||
@Test
|
||||
fun `default config satisfies D3`() {
|
||||
assertDoesNotThrow { KafkaD3Check(acks = "all", idempotence = "true", maxInFlight = "1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acks=-1 also satisfies D3`() {
|
||||
assertDoesNotThrow { KafkaD3Check(acks = "-1", idempotence = "true", maxInFlight = "1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `max-in-flight not 1 rejects`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
KafkaD3Check(acks = "all", idempotence = "true", maxInFlight = "5")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idempotence false rejects`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
KafkaD3Check(acks = "all", idempotence = "false", maxInFlight = "1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acks not all rejects`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
KafkaD3Check(acks = "1", idempotence = "true", maxInFlight = "1")
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -45,8 +45,9 @@ class JdbcMsgEventUpsertPgTest {
|
||||
assertEquals(EventType.TOMBSTONE, pendingSchd(repo, key).single().eventType)
|
||||
|
||||
// 条件确认:旧代次影响 0 行,当前代次标记成功
|
||||
assertEquals(0, repo.markSentIfVersion(firstId, 1))
|
||||
assertEquals(1, repo.markSentIfVersion(tombstoneId, 2))
|
||||
val now = java.time.Instant.parse("2025-01-01T00:00:00Z")
|
||||
assertEquals(0, repo.markSentIfVersion(firstId, 1, now))
|
||||
assertEquals(1, repo.markSentIfVersion(tombstoneId, 2, now))
|
||||
assertEquals(0, pendingSchd(repo, key).size)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.gzzn.omms.msgexchange.jobs
|
||||
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.Targets
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 清理作业只删 `STATE='SENT'` 且 `SENT_AT` 超过保留期的行;
|
||||
* 未确认投递(`PENDING`)和未超期的 SENT 行不受影响。
|
||||
*/
|
||||
class EventCleanupJobTest {
|
||||
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
private val props = PipelineProps().apply { pipeline.eventRetention = Duration.ofDays(7) }
|
||||
|
||||
private fun sentEvent(sentAt: Instant): MsgEvent = MsgEvent(
|
||||
target = Targets.KAFKA_MSG,
|
||||
partitionKey = "pk",
|
||||
payloadJson = "{}",
|
||||
createdAt = MutableClock.BASE,
|
||||
sentAt = sentAt,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `deletes SENT rows older than retention`() {
|
||||
val repo = StubMsgEvents()
|
||||
val old = repo.insertAll(listOf(sentEvent(clock.instant().minus(Duration.ofDays(8))))).single()
|
||||
repo.markSent(old, clock.instant().minus(Duration.ofDays(8)))
|
||||
val recent = repo.insertAll(listOf(sentEvent(clock.instant().minus(Duration.ofDays(2))))).single()
|
||||
repo.markSent(recent, clock.instant().minus(Duration.ofDays(2)))
|
||||
|
||||
val deleted = EventCleanupJob(repo, props).run(clock.instant()).deleted
|
||||
|
||||
assertEquals(1, deleted)
|
||||
assertEquals(EventStatus.SENT, repo.rows[recent]!!.state)
|
||||
assertTrue(old !in repo.rows)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not delete PENDING rows regardless of age`() {
|
||||
val repo = StubMsgEvents()
|
||||
val pending = MsgEvent(
|
||||
target = Targets.KAFKA_MSG,
|
||||
partitionKey = "pk",
|
||||
payloadJson = "{}",
|
||||
createdAt = MutableClock.BASE,
|
||||
)
|
||||
val id = repo.insertAll(listOf(pending)).single()
|
||||
|
||||
val deleted = EventCleanupJob(repo, props).run(clock.instant()).deleted
|
||||
|
||||
assertEquals(0, deleted)
|
||||
assertEquals(EventStatus.PENDING, repo.rows[id]!!.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cleans up multiple expired SENT rows in one run`() {
|
||||
val repo = StubMsgEvents()
|
||||
val oldSentAt = clock.instant().minus(Duration.ofDays(30))
|
||||
repeat(5) {
|
||||
val id = repo.insertAll(listOf(sentEvent(oldSentAt))).single()
|
||||
repo.markSent(id, oldSentAt)
|
||||
}
|
||||
val smallBatchProps = PipelineProps().apply { pipeline.eventRetention = Duration.ofDays(7) }
|
||||
|
||||
val deleted = EventCleanupJob(repo, smallBatchProps).run(clock.instant()).deleted
|
||||
|
||||
assertEquals(5, deleted)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class JobRunnerTest {
|
||||
|
||||
private fun runner(proc: ProcStateRepository, activity: JobActivity) = JobRunner(
|
||||
BackfillService(proc, StubInbox(), MailboxProps(), props, clock, MessageLifecycleGate()),
|
||||
historySweep(), clock, activity, OperationDayProps(),
|
||||
historySweep(), EventCleanupJob(StubMsgEvents(), props), clock, activity, props, OperationDayProps(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.codec.DecodeResult
|
||||
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.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 忽略类报文(US-04):命中忽略清单的报文先绑定身份再写 SKIPPED,
|
||||
* 不产生航班或 outbox 副作用;未命中的合法类型继续按 UNSUPPORTED 处理。
|
||||
*/
|
||||
class IgnoreBranchTest {
|
||||
|
||||
private val msgId = 99L
|
||||
|
||||
private fun head() = ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH)
|
||||
|
||||
private fun decoded(type: String, styp: String) = DecodedMessage(
|
||||
meta = MetaFields("AODB", type, styp, 200L, 1L),
|
||||
kind = MsgKind.Unsupported("$type-$styp"),
|
||||
rawXml = "<MSG/>",
|
||||
)
|
||||
|
||||
private fun codecReturning(msg: DecodedMessage) = object : XmlCodec {
|
||||
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(msg)
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
}
|
||||
|
||||
private val noopTx = object : PipelineTransactionManager {
|
||||
override fun <T> inTransaction(block: () -> T): T = block()
|
||||
}
|
||||
private val noopLock = object : PipelineLockRepository { override fun lock() = Unit }
|
||||
|
||||
private fun processor(
|
||||
proc: StubProcState = StubProcState(),
|
||||
inbox: StubInbox = StubInbox(),
|
||||
counters: PipelineCounters = PipelineCounters(),
|
||||
codec: XmlCodec,
|
||||
): MessageProcessor {
|
||||
val cursor = StubInboxCursor()
|
||||
cursor.cursor = InboxCursorRepository.Cursor(committedUpTo = Long.MAX_VALUE)
|
||||
val clock = Clock.systemUTC()
|
||||
val props = PipelineProps()
|
||||
val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
|
||||
val flights = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val log = StubSnapshotLog()
|
||||
val procFailure = ProcFailure(proc, FailureScheduler(props, clock))
|
||||
return MessageProcessor(
|
||||
inbox = inbox,
|
||||
procState = proc,
|
||||
codec = codec,
|
||||
scheduleProcessor = ScheduleProcessor(noopTx, noopLock, proc, flights, events, log, opDay, ObjectMapper(), clock),
|
||||
flopProcessor = FlopProcessor(noopTx, noopLock, flights, events, proc, ObjectMapper(), clock),
|
||||
fdelProcessor = FdelProcessor(noopTx, noopLock, flights, events, proc, ObjectMapper(), clock),
|
||||
adftProcessor = AdftProcessor(noopTx, noopLock, flights, events, proc, opDay, ObjectMapper(), clock),
|
||||
procFailure = procFailure,
|
||||
props = props,
|
||||
clock = clock,
|
||||
operationDayProps = opDay,
|
||||
counters = counters,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LDM subtype hits ignore rule and writes SKIPPED with reason`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val msg = decoded("LDM", "ADQ")
|
||||
val p = processor(proc = proc, inbox = inbox, codec = codecReturning(msg))
|
||||
|
||||
p.processOne(head())
|
||||
|
||||
val row = proc.find(msgId)!!
|
||||
assertEquals(ProcStatus.SKIPPED, row.state)
|
||||
assertEquals("ignored:LDM-*", row.lastError)
|
||||
assertNotNull(row.backfillNextAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EROR subtype hits ignore rule`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val msg = decoded("EROR", "GEN")
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `REGN and RSTA both hit ignore rules`() {
|
||||
for ((type, rule) in listOf("REGN" to "REGN-*", "RSTA" to "RSTA-*")) {
|
||||
val proc = StubProcState()
|
||||
val id = msgId + type.hashCode().toLong()
|
||||
proc.insertIfAbsent(id, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[id] = "<RAW/>"
|
||||
val msg = DecodedMessage(
|
||||
meta = MetaFields("AODB", type, "X", 300L, 1L),
|
||||
kind = MsgKind.Unsupported("$type-X"),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore binds identity before skipping`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val msg = decoded("LDM", "ADQ")
|
||||
val p = processor(proc = proc, inbox = inbox, codec = codecReturning(msg))
|
||||
|
||||
p.processOne(head())
|
||||
|
||||
assertNotNull(proc.find(msgId)!!.identityKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate identity takes precedence over ignore`() {
|
||||
val ownerMsgId = msgId + 1
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
proc.insertIfAbsent(ownerMsgId, null)
|
||||
proc.tryBindIdentity(ownerMsgId, "AODB|LDM|ADQ|200")
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val msg = decoded("LDM", "ADQ")
|
||||
val p = processor(proc = proc, inbox = inbox, codec = codecReturning(msg))
|
||||
|
||||
p.processOne(head())
|
||||
|
||||
val row = proc.find(msgId)!!
|
||||
assertEquals(ProcStatus.SKIPPED, row.state)
|
||||
assertEquals("duplicate-of:$ownerMsgId", row.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-ignored unsupported type continues as UNSUPPORTED`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val msg = decoded("XYZZ", "TEST")
|
||||
val counters = PipelineCounters()
|
||||
val p = processor(proc = proc, inbox = inbox, counters = counters, codec = codecReturning(msg))
|
||||
|
||||
p.processOne(head())
|
||||
|
||||
assertEquals(ProcStatus.FAILED, proc.find(msgId)!!.state)
|
||||
assertEquals(0L, counters.ignoredCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore increments counter`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val msg = decoded("LDM", "ADQ")
|
||||
val counters = PipelineCounters()
|
||||
val p = processor(proc = proc, inbox = inbox, counters = counters, codec = codecReturning(msg))
|
||||
|
||||
p.processOne(head())
|
||||
|
||||
assertEquals(1L, counters.ignoredCount())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user