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
@@ -40,11 +40,11 @@ class BackfillSweepJob(
var failed = 0
for (task in due) {
try {
inbox.backfillOnSuccess(task.cminmsgsId, task.sndr, task.type, task.styp, task.seqn)
todo.delete(task.cminmsgsId)
inbox.backfillOnSuccess(task.msgId, task.sndr, task.type, task.styp, task.seqn)
todo.delete(task.msgId)
succeeded++
} catch (e: Exception) {
todo.markFailed(task.cminmsgsId, e.message, now.plus(backoffDelayFor(task.attempts + 1)), now)
todo.markFailed(task.msgId, e.message, now.plus(backoffDelayFor(task.attempts + 1)), now)
failed++
}
}
@@ -0,0 +1,80 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.config.HistoryProps
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.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
/**
* 历史归档与物理清除(docs/flight-state.md §8.2,顺序不可颠倒):
* 1. HISTORY_SWEEP 选出满足 §8.1 的航班(含 DELETED);
* 2. 写入历史存储;
* 3. 历史存储返回成功的 FLID 集合 → 物理删除主行与明细(归档结果只记录在历史存储,当前态无 ARCHIVED 态);
* 4. 未经 FDEL 的航班在清除前补发一次删除事件(§7.3);其余不发;
* 5. 失败或不明确的保留重试。
* 前提红线:历史存储未接通时必须删 0 条(§8.2)。
*/
@Singleton
class HistorySweepJob(
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val props: HistoryProps,
/** 历史存储端口:返回归档成功的 FLID 集合;未接通时不注入(null)。 */
private val historyStore: HistoryStore? = null,
/** §8.3 留痕清理端口(90 天,按 (SCOPE_END, RECV_AT));未接通时不注入。 */
private val snapLogPurge: SnapshotLogPurge? = null,
) {
/** 历史存储端口(由部署侧适配实现;脚手架默认未接通)。 */
fun interface HistoryStore {
/** 归档候选航班;返回归档确认成功的 FLID 集合(部分成功允许)。 */
fun archive(candidates: List<HistoryCandidate>): Set<String>
}
fun interface SnapshotLogPurge {
fun purgeBefore(instant: Instant): Int
}
data class SweepOutcome(val selected: Int, val archived: Int, val purged: Int, val snapLogPurged: Int = 0)
fun run(now: Instant = Instant.now()): SweepOutcome {
if (!props.historyStoreEnabled || historyStore == null) {
// 红线:历史存储未接通必须删 0 条;绝不允许先删当前态再补历史(§8.2)
return SweepOutcome(selected = 0, archived = 0, purged = 0)
}
val rules = HistoryRules(props.cancelledHours, props.terminalHours, props.deletedHours, props.idleHours)
val zone = ZoneId.of("Asia/Shanghai") // §8.1:窗口按机场时区计算
val candidates = flightState.findHistoryCandidates(rules, zone, now)
if (candidates.isEmpty()) return SweepOutcome(0, 0, 0)
val archivedFlids = historyStore.archive(candidates)
if (archivedFlids.isEmpty()) return SweepOutcome(candidates.size, archived = 0, purged = 0)
val toPurge = candidates.filter { it.flid in archivedFlids }
// §8.2 步骤 4:未经 FDEL、由生命周期直接清除的航班,清除前补发一次删除事件
val preDelete = toPurge.filter { it.wasNeverFdel }
if (preDelete.isNotEmpty()) {
msgEvents.insertAll(preDelete.map { tombstone(it) })
}
val purged = flightState.purgeArchived(archivedFlids)
val snapLogPurged = snapLogPurge?.purgeBefore(now.minus(Duration.ofDays(props.snapLogRetentionDays))) ?: 0
return SweepOutcome(candidates.size, archivedFlids.size, purged, snapLogPurged)
}
private fun tombstone(candidate: HistoryCandidate) = MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = candidate.flid,
eventType = EventType.TOMBSTONE,
stateVersion = candidate.stateVersion,
payloadJson = """{"flid":"${candidate.flid}","stateVersion":${candidate.stateVersion},"deleted":true}""",
)
}
@@ -1,99 +0,0 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import jakarta.inject.Singleton
/**
* ACMA-8:泵作业执行器。cron 触发入队(PUMP_JOB),主泵 FIFO 执行(决策 1)——
* 产物不绕过队头顺序;阶段 A 的清场同步链与归档在此落地(I4/I5)。
*/
@Singleton
class JobExecutor(
private val historySweep: HistorySweepJob,
private val archive: ArchiveJob,
private val projectionRebuild: ProjectionRebuildJob,
private val backfillSweep: BackfillSweepJob,
) {
fun execute(job: PumpJobRepository.Job) = when (job.kind) {
"HISTORY_SWEEP" -> historySweep.run()
"ARCHIVE" -> archive.run()
"PROJECTION_REBUILD" -> projectionRebuild.run()
"BACKFILL_SWEEP" -> backfillSweep.sweep()
else -> error("unknown job ${job.kind}")
}
}
/**
* 流程 4 runJob(HISTORY_SWEEP):3:30 清场——主泵作业内同步链(I4,KEEP 现役
* FlightHisScheduled 同步语义):判史 → 同步写 ES(成功集)→ 仅删成功集。
*/
@Singleton
class HistorySweepJob(
private val flightSchd: FlightSchdRepository,
) {
companion object {
var historyPicker: ((Map<String, FlightFields>) -> Map<String, FlightFields>)? = null
var esArchiver: ((Map<String, FlightFields>) -> Set<String>)? = null
var cutoffProvider: (() -> String?)? = null
}
fun run() {
val all = flightSchd.findAll()
val history = pickHistory(all) // U10/T07:saveSync 接线前判史为空集——禁止“全量可删”默认
val success = esArchiver?.invoke(history) ?: emptySet() // 仅收集已确认写入 ES 的成功 FLID 集合
if (success.isNotEmpty()) {
flightSchd.deleteByFlids(success) // FS2/FS4:仅删除已确认写入 ES 的成功集合,分批参数化删除
}
val cutoff = cutoffProvider?.invoke()
if (cutoff != null) {
flightSchd.deleteGenBefore(cutoff)
}
}
/** U10/T07(修订):接入 ES success 集之前的门禁——占位默认返回空集;
* 现役五条判史规则(SODT 3 天 / CNCL 1 小时 / 备降 / 离港 / 到港)golden 通过后才允许接线。 */
private fun pickHistory(all: Map<String, FlightFields>): Map<String, FlightFields> =
historyPicker?.invoke(all) ?: emptyMap()
}
/** 流程 5 runJob(ARCHIVE):3:00 归档——1 天前且仅终态可迁(矩阵 #12)。 */
@Singleton
class ArchiveJob(
// TODO(阶段1后续): CMINMSGS⇆CMINMSGS_HST 迁移 SQL:JOIN PROC_STATE,STATE ∈ {SUCCEEDED,DEAD,SKIPPED}
) {
fun run() {
// TODO: 迁移(DEAD 带 ERROR_CLASS、SKIPPED 带 duplicate-of 审计随行保留)
}
}
/** 流程 7:阶段 B 投影重建——切入阶段 B 时全量重建一次,此后增量走事件。 */
@Singleton
class ProjectionRebuildJob(
private val flightSchd: FlightSchdRepository,
private val msgEvents: MsgEventRepository,
) {
fun run() {
for (day in activeDays()) {
val flights = flightSchd.findByDay(day)
flights.forEach { (flid, fields) ->
msgEvents.insertSync(
listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = flid,
payloadJson = FlightFieldsJson.toJson(fields),
),
),
)
}
}
}
private fun activeDays(): List<String> = emptyList() // TODO(阶段B)
}
@@ -0,0 +1,75 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.config.HistoryProps
import com.gzzn.omms.msgexchange.config.PipelineProps
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* 维护作业调度(docs/flight-state.md §8):单 daemon 线程,独立于主泵——
* 旧「作业不插队 PUMP_JOB 队列」机制随审计口径移除(§3.2 表清单无 PUMP_JOB);
* 历史归档/留痕清理均为内部清理路径,不参与 FIFO 消息序。
* 触发:回填补偿 30s 固定间隔;历史归档/留痕清理每日 03:30(机场时区)后首个 tick。
*/
@Singleton
class JobRunner(
private val backfillSweep: BackfillSweepJob,
private val historySweep: HistorySweepJob,
@Suppress("unused") private val pipelineProps: PipelineProps,
@Suppress("unused") private val historyProps: HistoryProps,
) {
private val log = org.slf4j.LoggerFactory.getLogger(JobRunner::class.java)
private val zone: ZoneId = ZoneId.of("Asia/Shanghai")
@Volatile
private var running = true
@Volatile
private var lastHistoryDay: LocalDate? = null
private var thread: Thread? = null
fun start() {
if (thread != null) return
running = true
thread = Thread.ofPlatform().name("msgx-jobs").daemon(true).start { loop() }
log.info("job runner started (backfill 30s, history daily 03:30 {})", zone)
}
fun stop() {
running = false
thread?.interrupt()
thread = null
}
internal fun loop() {
while (running) {
try {
backfillSweep.sweep()
maybeHistorySweep()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
return
} catch (e: Exception) {
log.warn("job tick failed: {}", e.message ?: e.javaClass.simpleName)
}
if (running) runCatching { Thread.sleep(Duration.ofSeconds(30).toMillis()) }
}
}
private fun maybeHistorySweep() {
val today = LocalDate.now(zone)
if (lastHistoryDay == today) return
val now = java.time.LocalTime.now(zone)
if ((now.hour == 3 && now.minute >= 30) || now.hour > 3) {
val outcome = historySweep.run()
lastHistoryDay = today
if (outcome.selected > 0 || outcome.snapLogPurged > 0) {
log.info("history sweep: {}", outcome)
}
}
}
}