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
@@ -1,9 +1,9 @@
package com.gzzn.omms.msgexchange.delivery
import com.fasterxml.jackson.databind.ObjectMapper
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
@@ -12,26 +12,29 @@ import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
/** 对外投递端口(Kafka 同步确认;阶段 B 追加 ES/Redis 投影写入)。 */
/** 对外投递端口(Kafka 同步确认at-least-once;阶段 B 追加 ES 投影写入)。 */
interface DeliveryPort {
/** Kafka 发送(同步确认,at-least-once)。topic 由 target 映射:KAFKA:msg→"msg"KAFKA:schd→"schd"。 */
fun sendKafka(topic: String, payloadJson: String)
/** KAFKA_MSG 变化通知(key=FLID。 */
fun sendKafka(topic: String, key: String, payloadJson: String)
/** 阶段 BES flight_hts 写入。 */
fun indexFlightHts(payloadJson: String)
/**
* KAFKA_SCHD 整态(key=FLID)。KAFKA_MSG 与 KAFKA_SCHD 映射同一 topic 语义由适配层定;
* target→topicKAFKA:msg→"msg"KAFKA:schd→"schd"。
*/
fun sendKafkaSchd(topic: String, key: String, payloadJson: String)
/** TOMBSTONEkey=FLID、value=null——整态键缺失表示删除旧值(§7.3)。 */
fun sendKafkaNull(topic: String, key: String)
/** 连通性探测(健康检查用);默认 true,真实 Kafka 实装时覆写为 producer metadata 校验。 */
fun ping(): Boolean = true
}
/**
* ACMA-8 流程 3:投递调度——每 target 严格 FIFO(I1 双层同策略)。
* N03U06):KAFKA_SCHD 不走逐条循环(唯一出口是 flushSchd 批量聚合),逐条循环显式排除。
* U08 闭环:逐条与批量失败都在持有具体事件/批次的边界完成状态迁移——
* · 逐条:scheduleRetry(attempts+1, backoff) / 达上限 markDead(EXHAUSTED)DLQ 保留行);
* · 批量(flushSchd):整批退避,队首 nextAttemptAt 未到不 claim;达上限整批 DEAD/DLQ
* · loop 只作最后防线,不吞 InterruptedException(致命/中断错误不被普通恢复吞掉)。
* 投递调度(docs/flight-state.md §7.3):逐条 KAFKA_MSG 严格 FIFO
* KAFKA_SCHD flushSchd 批量——同一 FLID 未发事件按最新 STATE_VERSION 合并输出,
* TOMBSTONE 发 null 值消息。两主题间不保证顺序(§7.3)。
* 批量闭环:队首退避未到期不 claim;发送失败整批 attempts+1 退避,达上限整批 DEAD/DLQ。
*/
@Singleton
class Dispatcher(
@@ -45,9 +48,9 @@ class Dispatcher(
@Volatile
private var running = true
private var lastFlush: Instant = Instant.EPOCH
private var lastFlush: Instant? = null
/** 优雅停机:loop 收尾后退出;线程中断由 Runner 负责。 */
/** 优雅停机:loop 收尾后退出;线程中断由 PipelineLifecycle 负责。 */
fun stop() {
running = false
}
@@ -60,88 +63,84 @@ class Dispatcher(
Thread.currentThread().interrupt()
return
} catch (e: Exception) {
// U08:投递循环只作最后防线;致命 Error 不吞。tick 内失败迁移已完成。
sleepQuietly(props.pipeline.pollInterval)
}
// N18:轮询间隔取参数表(下限 50ms,避免退避节律被吞)
sleepQuietly(props.pipeline.pollInterval.coerceAtLeast(Duration.ofMillis(50)))
}
}
internal fun tick() {
val targets = if (props.phase == PipelineProps.Phase.A) Targets.phaseA else Targets.phaseB
for (t in targets) {
if (t == Targets.KAFKA_SCHD) continue // N03schd 唯一出口 flushSchd
val head = msgEvents.headUnsent(t) ?: continue
if (head.state == EventStatus.PENDING && head.nextAttemptAt != null && head.nextAttemptAt > scheduler.now()) {
// 队头退避未到期:等待,不跳过(保序);超时升级归 U13(WP2)
continue
}
try {
deliver(t, head)
msgEvents.markSent(head.eventId!!)
log.debug("sent target={} eventId={}", t, head.eventId)
// ACM2-28:阶段 B Redis 投影废弃,读模型投递统一转由 Kafka 事件或 ES 处理
} catch (e: Exception) {
retryOrDead(head, e.message ?: "unknown")
}
}
if (flushDue()) {
flushSchd()
val head = msgEvents.headUnsent(Targets.KAFKA_MSG)
if (head != null && (head.state == EventStatus.SENT || head.nextAttemptAt == null || head.nextAttemptAt <= scheduler.now())) {
deliver(Targets.KAFKA_MSG, head)
}
if (flushDue()) flushSchd()
if (running) sleepQuietly(props.pipeline.pollInterval)
}
private fun flushDue(): Boolean =
Duration.between(lastFlush, scheduler.now()) >= props.schd.flushPeriod
lastFlush?.let { Duration.between(it, scheduler.now()) >= props.schd.flushPeriod } ?: false
/** 单条事件失败迁移:attempts+1;达上限 DEAD(EXHAUSTED)DLQattempts 落库审计),否则退避重试。 */
private fun retryOrDead(e: MsgEvent, lastError: String) {
val attempts = e.attempts + 1
if (scheduler.exhausted(attempts)) {
log.error("event DEAD(DLQ) eventId={} attempts={} lastError={}", e.eventId, attempts, lastError)
msgEvents.markDead(e.eventId!!, ErrorClass.EXHAUSTED, lastError, attempts)
} else {
log.warn("event retry scheduled eventId={} attempts={} nextAttemptAt={}", e.eventId, attempts, scheduler.nextAttemptAt(attempts))
msgEvents.scheduleRetry(e.eventId!!, scheduler.nextAttemptAt(attempts), attempts)
private fun deliver(target: String, e: MsgEvent) {
try {
when (target) {
Targets.KAFKA_MSG -> port.sendKafka("msg", e.partitionKey, e.payloadJson)
}
msgEvents.markSent(e.eventId ?: return)
} catch (ex: Exception) {
retryOrDead(e, ex.message ?: ex.javaClass.simpleName)
}
}
private fun deliver(target: String, e: MsgEvent) = when (target) {
Targets.KAFKA_MSG -> port.sendKafka("msg", e.payloadJson)
Targets.KAFKA_SCHD -> error("KAFKA_SCHD must go through flushSchd (N03)")
Targets.ES_FLIGHT_HTS -> port.indexFlightHts(e.payloadJson)
else -> error("unknown target $target")
}
/**
* 流程 3 flushSchd:批上限 + 每 FLID 最新一态聚合(topic "schd"wire=FLTR JSON 数组)。
* U08 批量闭环:队首退避未到期不 claim;发送失败 → 整批 attempts+1(退避)或达上限整批 DEAD/DLQ;
* lastFlush 仅在成功(含空批)后推进。
*/
/** flushSchd:同 FLID 未发事件按最新 STATE_VERSION 合并(§7.3);TOMBSTONE 发 null。 */
internal fun flushSchd() {
val pending = msgEvents.claimBatch(Targets.KAFKA_SCHD, limit = props.schd.flushLimit)
if (pending.isEmpty()) {
val batch = try {
msgEvents.mergePendingSchd(props.schd.flushLimit)
} catch (e: Exception) {
log.warn("mergePendingSchd failed: {}", e.message)
return
}
if (batch.isEmpty()) {
lastFlush = scheduler.now()
return
}
val due = pending.first()
if (due.nextAttemptAt != null && due.nextAttemptAt > scheduler.now()) {
return // 队首仍在退避:整批等待,不推进 lastFlush(到期再试)
val failures = mutableListOf<MsgEvent>()
for (e in batch) {
try {
when (e.eventType) {
EventType.TOMBSTONE -> port.sendKafkaNull("schd", e.partitionKey)
EventType.UPSERT -> port.sendKafkaSchd("schd", e.partitionKey, e.payloadJson)
}
} catch (ex: Exception) {
failures.add(e)
}
}
val payload = SchdAggregation.latestPerFlight(pending).joinToString(",", "[", "]")
log.debug("flushSchd batch size={} payloadLen={}", pending.size, payload.length)
try {
port.sendKafka("schd", payload)
} catch (e: Exception) {
log.warn("flushSchd send failed batch={} -> batch retryOrDead: {}", pending.size, e.message)
pending.forEach { retryOrDead(it, "schd-send: ${e.message ?: "unknown"}") }
return // 不推进 lastFlush:整批退避(含 DEAD 出队)后到期重试
}
msgEvents.markAllSent(pending.mapNotNull { it.eventId })
log.info("flushSchd sent batch={}", pending.size)
val sentIds = batch.mapNotNull { it.eventId }.toSet() - failures.mapNotNull { it.eventId }.toSet()
if (sentIds.isNotEmpty()) msgEvents.markAllSent(sentIds.toList())
// 被最新版本合并压掉的未发事件同样关闭(§7.3:同 FLID 只按最新 STATE_VERSION 输出一次)
val sentVersions = batch.associate { it.partitionKey to it.stateVersion }
runCatching {
while (true) {
val superseded = msgEvents.mergePendingSchd(props.schd.flushLimit)
.filter { sentVersions[it.partitionKey]?.let { v -> it.stateVersion < v } == true }
if (superseded.isEmpty()) break
msgEvents.markAllSent(superseded.mapNotNull { it.eventId })
}
}.onFailure { log.warn("superseded cleanup failed: {}", it.message) }
failures.forEach { retryOrDead(it, it.lastError ?: "send-failed") }
lastFlush = scheduler.now()
}
/** 单条事件失败迁移:attempts+1;达上限 DEAD(EXHAUSTED)DLQattempts 落库审计),否则退避重试。 */
private fun retryOrDead(e: MsgEvent, lastError: String) {
val eventId = e.eventId ?: return
val attempts = e.attempts + 1
if (scheduler.exhausted(attempts)) {
msgEvents.markDead(eventId, ErrorClass.EXHAUSTED, lastError, attempts)
} else {
msgEvents.scheduleRetry(eventId, scheduler.nextAttemptAt(attempts), attempts)
}
}
private fun sleepQuietly(d: Duration) {
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
}