fix(processing): WP1 核心语义(ACM2-10 U06/U08/U10/U11)
- U06/N03:Dispatcher 逐条循环显式排除 KAFKA_SCHD(唯一出口 flushSchd 批量聚合), 修复 schd 事件被无条件 markSent 吞掉、flush 永远 claim 不到的整链死路;flush 失败整批 保持 PENDING、lastFlush 仅成功后推进(N24) - U08 部分:Pump/Dispatcher loop 异常隔离(单次异常不静默死亡,U12 补告警日志); processOne FAILED 入口 attempts 毒丸升级 DEAD(防退避到期后无限重试); N28:backoffFor(attempt≤0) 下界保护不再抛异常 - U10/N21:未注册 handler → FAILED(UNSUPPORTED)+退避(绝不写终态);占位安全化—— HistorySweep.pickHistory 占位改空集(T07 修订)、SnapshotFlow staging 未实装改 FAILED(UNSUPPORTED)+退避而非 DEAD(N05),CAS 冲突 FAILED(INFRA)+退避(N06 紧循环修复) - U11/T06:decode 失败差异化——MALFORMED→DEAD 不重试;CODEC_ERROR→FAILED 可重放 - 新增 ErrorClass.UNSUPPORTED;单测:DispatcherTickTest(3)/MessageProcessorTest(6)/ PipelinePropsTest(2),19 个测试全绿
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package com.gzzn.omms.msgexchange.nextgen.config
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* U08/N28:backoffFor 必须对 attempt ≤ 0(FAILED 行未递增 attempts)给出首档退避而非抛异常。
|
||||
*/
|
||||
class PipelinePropsTest {
|
||||
|
||||
private val pipeline = PipelineProps().pipeline
|
||||
|
||||
@Test
|
||||
fun `backoff follows table then caps`() {
|
||||
assertEquals(1000, pipeline.backoffFor(1))
|
||||
assertEquals(2000, pipeline.backoffFor(2))
|
||||
assertEquals(16000, pipeline.backoffFor(5))
|
||||
assertEquals(60_000, pipeline.backoffFor(6)) // 表外 → 封顶
|
||||
assertEquals(60_000, pipeline.backoffFor(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-positive attempt never throws and falls back to first slot`() {
|
||||
assertEquals(1000, pipeline.backoffFor(0))
|
||||
assertEquals(1000, pipeline.backoffFor(-1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.gzzn.omms.msgexchange.nextgen.delivery
|
||||
|
||||
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
|
||||
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
|
||||
import kotlin.test.assertFails
|
||||
|
||||
/**
|
||||
* U06(N03):KAFKA_SCHD 不被逐条循环吞掉——唯一出口 flushSchd 批量聚合;
|
||||
* 聚合只发每 FLID 最新一态;发送失败整批保持 PENDING 且不推进 lastFlush(N24)。
|
||||
*/
|
||||
class DispatcherTickTest {
|
||||
|
||||
private class FakeRepo : MsgEventRepository {
|
||||
val events = mutableListOf<MsgEvent>()
|
||||
|
||||
fun enqueue(e: MsgEvent) { events += e }
|
||||
|
||||
override fun insertAll(events: List<MsgEvent>) = events.map { it.eventId ?: 0L }
|
||||
|
||||
override fun headUnsent(target: String): MsgEvent? =
|
||||
events.filter { it.target == target && it.state != EventStatus.SENT && it.state != EventStatus.DEAD }
|
||||
.minByOrNull { it.eventId ?: Long.MAX_VALUE }
|
||||
|
||||
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
|
||||
events.filter { it.target == target && it.state == EventStatus.PENDING }
|
||||
.sortedBy { it.eventId ?: Long.MAX_VALUE }
|
||||
.take(limit)
|
||||
|
||||
override fun markSent(eventId: Long) {
|
||||
val i = events.indexOfFirst { it.eventId == eventId }
|
||||
if (i >= 0) events[i] = events[i].copy(state = EventStatus.SENT)
|
||||
}
|
||||
|
||||
override fun markAllSent(eventIds: List<Long>) = eventIds.forEach(::markSent)
|
||||
|
||||
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
|
||||
val i = events.indexOfFirst { it.eventId == eventId }
|
||||
if (i >= 0) events[i] = events[i].copy(state = EventStatus.PENDING, attempts = attempts, nextAttemptAt = nextAttemptAt)
|
||||
}
|
||||
|
||||
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String) {
|
||||
val i = events.indexOfFirst { it.eventId == eventId }
|
||||
if (i >= 0) events[i] = events[i].copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError)
|
||||
}
|
||||
|
||||
override fun insertSync(events: List<MsgEvent>) = Unit
|
||||
}
|
||||
|
||||
private class FakePort : DeliveryPort {
|
||||
val sent = mutableListOf<Pair<String, String>>()
|
||||
var failTopic: String? = null
|
||||
|
||||
override fun sendKafka(topic: String, payloadJson: String) {
|
||||
if (failTopic == topic) throw RuntimeException("send-fail:$topic")
|
||||
sent += topic to payloadJson
|
||||
}
|
||||
|
||||
override fun indexFlightHts(payloadJson: String) = Unit
|
||||
override fun projectRedis(payloadJson: String) = Unit
|
||||
}
|
||||
|
||||
private fun props(): PipelineProps = PipelineProps() // schd.flush-period 默认 3s
|
||||
|
||||
private fun ev(id: Long, target: String, key: String?, payload: String) =
|
||||
MsgEvent(eventId = id, target = target, partitionKey = key, payloadJson = payload, state = EventStatus.PENDING)
|
||||
|
||||
@Test
|
||||
fun `schd flushed once via batch path, per-target tick never consumes it`() {
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort()
|
||||
val d = Dispatcher(repo, port, props())
|
||||
repo.enqueue(ev(1, Targets.KAFKA_MSG, null, """{"msg":1}"""))
|
||||
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":1}"""))
|
||||
|
||||
d.tick() // lastFlush=EPOCH → 首 tick 即到 flush 周期
|
||||
|
||||
// KAFKA:msg 逐条投递;schd 经 flushSchd 聚合发出一次(而非逐条 markSent 吞掉)
|
||||
assertEquals(1, port.sent.count { it.first == "msg" })
|
||||
assertEquals(1, port.sent.count { it.first == "schd" })
|
||||
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state)
|
||||
|
||||
// 新的 schd 事件:flush 周期未到 → 逐条循环不得消费它(仍 PENDING、未发)
|
||||
repo.enqueue(ev(4, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":4}"""))
|
||||
d.tick()
|
||||
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 4L }.state)
|
||||
assertEquals(1, port.sent.count { it.first == "schd" }) // 第二次 tick 未额外发送
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flushSchd aggregates latest per flight and marks all sent`() {
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort()
|
||||
val d = Dispatcher(repo, port, props())
|
||||
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", "old"))
|
||||
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", "only"))
|
||||
repo.enqueue(ev(3, Targets.KAFKA_SCHD, "F1", "new"))
|
||||
|
||||
d.flushSchd()
|
||||
|
||||
assertEquals(1, port.sent.count { it.first == "schd" })
|
||||
val payload = port.sent.first { it.first == "schd" }.second
|
||||
assertTrue(payload.startsWith("[") && payload.endsWith("]"))
|
||||
assertTrue(payload.contains("new") && payload.contains("only") && !payload.contains("old"))
|
||||
assertEquals(2, payload.removeSurrounding("[", "]").split(",").size) // 同 FLID 只发最新(矩阵 #7)
|
||||
assertTrue(repo.events.all { it.state == EventStatus.SENT })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flush failure keeps batch PENDING and retries after next due`() {
|
||||
val repo = FakeRepo()
|
||||
val port = FakePort().apply { failTopic = "schd" }
|
||||
val d = Dispatcher(repo, port, props())
|
||||
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
|
||||
|
||||
assertFails { d.flushSchd() } // 发送失败 → 异常上抛(tick 侧隔离),事件保持 PENDING
|
||||
assertEquals(EventStatus.PENDING, repo.events.single().state)
|
||||
assertEquals(0, port.sent.size)
|
||||
|
||||
port.failTopic = null
|
||||
d.flushSchd() // lastFlush 未推进 → 下个周期整批重试成功
|
||||
assertEquals(EventStatus.SENT, repo.events.single().state)
|
||||
assertEquals(1, port.sent.count { it.first == "schd" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.gzzn.omms.msgexchange.nextgen.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeFailure
|
||||
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeResult
|
||||
import com.gzzn.omms.msgexchange.nextgen.codec.XmlCodec
|
||||
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.Decision
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.NotifyPayload
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.SchdPush
|
||||
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient
|
||||
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* U10/U11/U08(N21/T06/N06/N28):processOne 失败语义——
|
||||
* 未注册 handler / CODEC_ERROR / staging 未实装 → FAILED(可重放,带退避,绝不写终态);
|
||||
* MALFORMED → DEAD(不重试);FAILED attempts≥maxAttempts 入口升级 DEAD;成功路径事件+回填+SUCCEEDED。
|
||||
*/
|
||||
class MessageProcessorTest {
|
||||
|
||||
// ---------- fakes ----------
|
||||
private class FakeProcState : ProcStateRepository {
|
||||
var record = mutableMapOf<Long, ProcState>()
|
||||
val bound = mutableMapOf<String, Long>() // identityKey -> owner
|
||||
|
||||
override fun insert(cminmsgsId: Long, state: ProcStatus) { record[cminmsgsId] = ProcState(cminmsgsId, state) }
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
fun state(id: Long): ProcState = record.getValue(id)
|
||||
}
|
||||
|
||||
private class FakeInbox : CminmsgInboxRepository {
|
||||
var raws = mutableMapOf<Long, String>()
|
||||
val backfilled = mutableListOf<List<Any?>>()
|
||||
|
||||
override fun insertRaw(rawXml: String): Long = 0
|
||||
|
||||
override fun rawOf(cminmsgsId: Long): String? = 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) = 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()
|
||||
}
|
||||
|
||||
private class FakeRefData : RefDataRepository {
|
||||
override fun getGen(day: String): RefDataRepository.GenMeta? = null
|
||||
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean = true
|
||||
override fun upsertAll(rows: List<Triple<String, String, String>>) = Unit
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
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 snapshot = SnapshotFlow(procState, FakeRefData(), FakeRedis, props)
|
||||
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), FakeRedis, snapshot, 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)
|
||||
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 `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))
|
||||
|
||||
val s = ps.state(1)
|
||||
assertEquals(ProcStatus.DEAD, s.state)
|
||||
assertEquals(ErrorClass.EXHAUSTED, s.errorClass)
|
||||
assertTrue(inbox.raws.isEmpty() || inbox.backfilled.isEmpty()) // 入口即升级,未进入处理
|
||||
}
|
||||
|
||||
@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)
|
||||
assertEquals(1, ev.inserted.size)
|
||||
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(); ps.record[2] = head(id = 2)
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user