2026-09-07 15:11:33 +08:00
|
|
|
|
package com.gzzn.omms.msgexchange.delivery
|
2026-09-06 18:19:04 +08:00
|
|
|
|
|
2026-09-07 15:11:33 +08:00
|
|
|
|
import com.gzzn.omms.msgexchange.MutableClock
|
|
|
|
|
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
2026-09-09 17:53:08 +08:00
|
|
|
|
import com.gzzn.omms.msgexchange.domain.EventType
|
2026-09-07 15:11:33 +08:00
|
|
|
|
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
|
|
|
|
|
import com.gzzn.omms.msgexchange.domain.Targets
|
|
|
|
|
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
|
|
|
|
|
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
2026-09-09 17:53:08 +08:00
|
|
|
|
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
|
|
|
|
|
|
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
2026-09-06 18:19:04 +08:00
|
|
|
|
import org.junit.jupiter.api.Assertions.assertEquals
|
2026-09-06 19:08:02 +08:00
|
|
|
|
import org.junit.jupiter.api.Assertions.assertNull
|
2026-09-06 18:19:04 +08:00
|
|
|
|
import org.junit.jupiter.api.Assertions.assertTrue
|
|
|
|
|
|
import org.junit.jupiter.api.Test
|
|
|
|
|
|
import java.time.Instant
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-09 17:53:08 +08:00
|
|
|
|
* 投递调度(docs/flight-state.md §7.3):
|
|
|
|
|
|
* KAFKA_MSG 逐条 FIFO;KAFKA_SCHD 唯一出口 flushSchd——同 FLID 未发事件按最新
|
|
|
|
|
|
* STATE_VERSION 合并;TOMBSTONE 发 null 值消息;失败退避重试、达上限 DEAD。
|
2026-09-06 18:19:04 +08:00
|
|
|
|
*/
|
|
|
|
|
|
class DispatcherTickTest {
|
|
|
|
|
|
|
2026-09-06 19:08:02 +08:00
|
|
|
|
private val clock = MutableClock(MutableClock.BASE)
|
|
|
|
|
|
|
2026-09-09 17:53:08 +08:00
|
|
|
|
private fun dispatcher(repo: MsgEventRepository, port: DeliveryPort, p: PipelineProps = PipelineProps()): Dispatcher =
|
2026-09-06 19:08:02 +08:00
|
|
|
|
Dispatcher(repo, port, p, FailureScheduler(p, clock))
|
2026-09-06 18:19:04 +08:00
|
|
|
|
|
2026-09-09 17:53:08 +08:00
|
|
|
|
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)
|
2026-09-06 18:19:04 +08:00
|
|
|
|
|
|
|
|
|
|
@Test
|
2026-09-09 17:53:08 +08:00
|
|
|
|
fun `per-target tick delivers KAFKA_MSG only and never consumes schd`() {
|
|
|
|
|
|
val repo = StubMsgEvents()
|
|
|
|
|
|
val port = StubDeliveryPort()
|
|
|
|
|
|
val props = PipelineProps().apply { schd.flushPeriod = java.time.Duration.ofHours(1) }
|
|
|
|
|
|
repo.insertAll(
|
|
|
|
|
|
listOf(
|
|
|
|
|
|
ev(1, Targets.KAFKA_MSG, "F1", """{"flid":"F1"}"""),
|
|
|
|
|
|
ev(2, Targets.KAFKA_SCHD, "F1", """{"flid":"F1"}""", 1),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-06 19:08:02 +08:00
|
|
|
|
val d = dispatcher(repo, port, props)
|
2026-09-09 17:53:08 +08:00
|
|
|
|
d.tick()
|
2026-09-06 19:08:02 +08:00
|
|
|
|
|
2026-09-09 17:53:08 +08:00
|
|
|
|
assertEquals(1, port.sent.count { it.topic == "msg" })
|
|
|
|
|
|
assertEquals(0, port.sent.count { it.topic == "schd" }) // N03:schd 唯一出口 flushSchd
|
|
|
|
|
|
}
|
2026-09-06 19:08:02 +08:00
|
|
|
|
|
2026-09-09 17:53:08 +08:00
|
|
|
|
@Test
|
|
|
|
|
|
fun `flushSchd aggregates latest version per flight and marks sent`() {
|
|
|
|
|
|
val repo = StubMsgEvents()
|
|
|
|
|
|
val port = StubDeliveryPort()
|
|
|
|
|
|
repo.insertAll(
|
|
|
|
|
|
listOf(
|
|
|
|
|
|
ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"old"}""", 1),
|
|
|
|
|
|
ev(2, Targets.KAFKA_SCHD, "F1", """{"v":"new"}""", 2),
|
|
|
|
|
|
ev(3, Targets.KAFKA_SCHD, "F2", """{"v":"f2"}""", 1),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
val d = dispatcher(repo, port)
|
2026-09-06 19:08:02 +08:00
|
|
|
|
d.flushSchd()
|
2026-09-09 17:53:08 +08:00
|
|
|
|
|
|
|
|
|
|
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(0, repo.rows.values.count { it.state.name == "PENDING" })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `tombstone is delivered as null value message`() {
|
|
|
|
|
|
val repo = StubMsgEvents()
|
|
|
|
|
|
val port = StubDeliveryPort()
|
|
|
|
|
|
repo.insertAll(
|
|
|
|
|
|
listOf(
|
|
|
|
|
|
MsgEvent(
|
|
|
|
|
|
eventId = 1, target = Targets.KAFKA_SCHD, partitionKey = "F1",
|
|
|
|
|
|
eventType = EventType.TOMBSTONE, stateVersion = 3, payloadJson = """{"flid":"F1","deleted":true}""",
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
val d = dispatcher(repo, port)
|
|
|
|
|
|
d.flushSchd()
|
|
|
|
|
|
|
|
|
|
|
|
assertEquals(1, port.tombstones.size)
|
|
|
|
|
|
assertEquals("F1", port.tombstones.single().key) // 整态键缺失表示删除旧值(§7.3)
|
|
|
|
|
|
assertNull(port.tombstones.single().payload)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `schd send failure schedules backoff and goes DEAD at limit`() {
|
|
|
|
|
|
val repo = StubMsgEvents()
|
|
|
|
|
|
val p = PipelineProps()
|
|
|
|
|
|
val d = dispatcher(repo, FailingSchdPort(), p)
|
|
|
|
|
|
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}""", 1)))
|
|
|
|
|
|
|
|
|
|
|
|
d.flushSchd()
|
|
|
|
|
|
assertEquals(1, repo.rows[1]!!.attempts)
|
|
|
|
|
|
|
|
|
|
|
|
repeat(p.pipeline.maxAttempts - 1) {
|
|
|
|
|
|
clock.advance(p.pipeline.backoffFor(repo.rows[1]!!.attempts) + 1)
|
|
|
|
|
|
d.flushSchd()
|
|
|
|
|
|
}
|
|
|
|
|
|
val final = repo.rows[1]!!
|
|
|
|
|
|
assertTrue(final.attempts >= p.pipeline.maxAttempts || final.state.name == "DEAD")
|
2026-09-06 19:08:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `KAFKA msg delivery unaffected while schd batch is retrying`() {
|
2026-09-09 17:53:08 +08:00
|
|
|
|
val repo = StubMsgEvents()
|
|
|
|
|
|
val port = StubDeliveryPort()
|
|
|
|
|
|
val d = dispatcher(repo, port)
|
|
|
|
|
|
repo.insertAll(listOf(ev(1, Targets.KAFKA_MSG, "F9", """{"flid":"F9"}""")))
|
2026-09-06 19:08:02 +08:00
|
|
|
|
d.tick()
|
2026-09-09 17:53:08 +08:00
|
|
|
|
assertEquals(1, port.sent.count { it.topic == "msg" && it.key == "F9" })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-06 19:08:02 +08:00
|
|
|
|
|
2026-09-09 17:53:08 +08:00
|
|
|
|
/** schd 发送恒失败的端口(退避/终态闭环验证用)。 */
|
|
|
|
|
|
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) {
|
|
|
|
|
|
calls++
|
|
|
|
|
|
throw IllegalStateException("broker-down")
|
2026-09-06 19:08:02 +08:00
|
|
|
|
}
|
2026-09-06 22:12:23 +08:00
|
|
|
|
|
2026-09-09 17:53:08 +08:00
|
|
|
|
override fun sendKafkaNull(topic: String, key: String) {
|
|
|
|
|
|
throw IllegalStateException("broker-down")
|
|
|
|
|
|
}
|
2026-09-06 18:19:04 +08:00
|
|
|
|
}
|