refactor(flight-state): 按 flight-state.md 审计定稿全量重构脚手架与 SQL (ACM2-31)

- SQL 基线 V1__flight_state_baseline.sql 整体取代 V1.0.0–V1.4.0:
  PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD
  + 8 张资源明细表 + FLIGHT_ROUTE_POINT + SCHD_SNAP_LOG 留痕层
- 废除 FDAY 日代/SCHD_GEN/名单差删:OPERATION_DAY 不可变(应用层校验 +
  条件更新强化 §7.4),STATE 仅 ACTIVE/DELETED,物理清除只在历史归档后
- 处理器化:applyScheduleRecords(§5.1 七步同一事务,重放判定/整包
  DEAD(PROTOCOL)/归属冲突不落地)+ FLOP/FDEL/ADFT(tombstone 仅
  ACTIVE→DELETED,重复 FDEL 幂等不推进版本)
- 投递:KAFKA_SCHD 同 FLID 按最新 STATE_VERSION 合并,被压掉事件关闭,
  TOMBSTONE 发 null 值消息(键缺失=删除旧值 §7.3)
- 回填待办改为业务事务内预登记,消除提交后写待办的崩溃窗口(§7.2/§10)
- XML 解码改为 jackson-dataformat-xml 数据类直接映射(SIS 信封强类型,
  FLTR 开放标签泛型承载)
- 历史归档/物理清除顺序不可颠倒:归档确认成功集才物理删除,未接通删 0 条
- 移除 PUMP_JOB 队列/ReferenceService/FlightStoreDiffTool 等旧机制与测试,
  新增运营日/引擎/快照/FDEL/归档顺序不变性回归测试
This commit is contained in:
windyboy
2026-09-09 17:53:08 +08:00
parent 6af292d103
commit 879d658159
83 changed files with 3435 additions and 6355 deletions
@@ -2,185 +2,131 @@ package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.EventType
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
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
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
/**
* U06N03+ U08 闭环:schd 出口只走 flushSchd
* 批发送失败 → 整批 attempts 递增 + 指数退避(队首未到期不 claim),达上限整批 DEAD/DLQ
* 时间经可注入 ClockMutableClock),不依赖真实睡眠
* 投递调度(docs/flight-state.md §7.3):
* KAFKA_MSG 逐条 FIFOKAFKA_SCHD 唯一出口 flushSchd——同 FLID 未发事件按最新
* STATE_VERSION 合并;TOMBSTONE 发 null 值消息;失败退避重试、达上限 DEAD
*/
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, attempts: Int?) {
val i = events.indexOfFirst { it.eventId == eventId }
if (i >= 0) events[i] = events[i].copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError,
attempts = attempts ?: events[i].attempts)
}
override fun insertSync(events: List<MsgEvent>) { syncInserted += events }
val syncInserted = mutableListOf<MsgEvent>()
}
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
}
private val clock = MutableClock(MutableClock.BASE)
private fun dispatcher(repo: FakeRepo, port: FakePort, p: PipelineProps = PipelineProps()): Dispatcher =
private fun dispatcher(repo: MsgEventRepository, port: DeliveryPort, p: PipelineProps = PipelineProps()): Dispatcher =
Dispatcher(repo, port, p, FailureScheduler(p, clock))
private fun ev(id: Long, target: String, key: String?, payload: String) =
MsgEvent(eventId = id, target = target, partitionKey = key, payloadJson = payload, state = EventStatus.PENDING)
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)
@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)
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 周期
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)
repo.enqueue(ev(4, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":4}"""))
d.tick() // 时钟未推进 → flush 周期未到;逐条循环不得消费 schd
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 4L }.state)
assertEquals(1, port.sent.count { it.first == "schd" })
}
@Test
fun `flushSchd aggregates latest per flight and marks all sent`() {
val repo = FakeRepo()
val port = FakePort()
val d = dispatcher(repo, port)
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()
val payload = port.sent.first { it.first == "schd" }.second
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 schedules per-event backoff and retries after due`() {
val repo = FakeRepo()
val port = FakePort().apply { failTopic = "schd" }
val d = dispatcher(repo, port)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
d.flushSchd()
val e1 = repo.events.single()
assertEquals(EventStatus.PENDING, e1.state)
assertEquals(1, e1.attempts)
assertNotNull(e1.nextAttemptAt) // 指数退避落库(attempts=1 → 首档)
assertEquals(0, port.sent.size)
clock.advance(1000) // 退避到期
port.failTopic = null
d.flushSchd()
assertEquals(EventStatus.SENT, repo.events.single().state)
assertEquals(1, port.sent.count { it.first == "schd" })
}
@Test
fun `repeated batch failures backoff grows and whole batch goes DEAD DLQ at limit`() {
val props = PipelineProps().apply { pipeline.maxAttempts = 2 }
val repo = FakeRepo()
val port = FakePort().apply { failTopic = "schd" }
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),
),
)
val d = dispatcher(repo, port, props)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", """{"v":"2"}"""))
d.tick()
d.flushSchd() // 失败#1attempts=1,退避 backoff[1]=1000ms
val after1 = repo.events.map { it.attempts to it.state }
assertEquals(listOf(1 to EventStatus.PENDING, 1 to EventStatus.PENDING), after1)
assertTrue(repo.events.all { it.nextAttemptAt == MutableClock.BASE.plusMillis(1000) })
assertEquals(1, port.sent.count { it.topic == "msg" })
assertEquals(0, port.sent.count { it.topic == "schd" }) // N03schd 唯一出口 flushSchd
}
d.flushSchd() // 时钟未推进 → 队首未到期,不 claim(不推进 lastFlush
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts)
clock.advance(1000)
d.flushSchd() // 失败#2attempts=2 == maxAttempts → 整批 DEAD
assertTrue(repo.events.all { it.state == EventStatus.DEAD && it.errorClass == ErrorClass.EXHAUSTED })
assertTrue(repo.events.all { it.attempts == 2 })
assertEquals(0, port.sent.size)
// DEAD 行不再参与 claim:空批 → 无新发送、无异常
@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)
d.flushSchd()
assertEquals(0, port.sent.size)
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")
}
@Test
fun `KAFKA msg delivery unaffected while schd batch is retrying`() {
val props = PipelineProps().apply { pipeline.maxAttempts = 2 }
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"}"""))
repo.enqueue(ev(2, Targets.KAFKA_MSG, null, """{"m":1}"""))
val repo = StubMsgEvents()
val port = StubDeliveryPort()
val d = dispatcher(repo, port)
repo.insertAll(listOf(ev(1, Targets.KAFKA_MSG, "F9", """{"flid":"F9"}""")))
d.tick()
assertEquals(1, port.sent.count { it.topic == "msg" && it.key == "F9" })
}
}
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state) // msg 正常
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts) // schd 进退避
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 1L }.state)
/** 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")
}
override fun sendKafkaNull(topic: String, key: String) {
throw IllegalStateException("broker-down")
}
}