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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user