docs(kdoc): V2 迁移与处理/持久化层剩余注释改为直白说明

接上一提交,把本次工作范围内还没改到的注释补齐:V2 迁移脚本的注释原文几乎全是
段落编号引用,处理层与持久化层还留着一批 `(§3.3/§5)` 形式的行内注释。

V2__inbox_lifecycle.sql:文件头改为先讲清这次迁移解决的三个问题(记住收报读到哪里、
回填记录并进 PROC_STATE 而不是单开一张表、记下收信时间做什么用),再落到每个字段;
字段级注释补上"这个值非空代表什么"。SQL 语句一字未动(已用去注释后比对确认)。

processing:Pump 的分派与终态注释、ScheduleProcessor 的重放判定与运营日冲突、
DynamicProcessors 的删除通知与重新激活,都改成说明白"这一步在做什么、为什么这么做"。
infra/persistence:Repositories.kt 的仓储约定、航班状态读写、待发事件与请求跟踪接口,
JdbcPgRepositories 的锁、整态合并、历史清理判据、明细表映射,StubRepositories 的
对应实现,一律先说清用途再谈规则。
测试:8 个测试类里的行内引用改为说明这条断言在守什么。

注释里保留的文档指向只在需要延伸阅读时出现,不再作为解释本身。
全部为注释改动,测试仍为 78 passed / 1 skipped。
This commit is contained in:
windyboy
2026-09-10 11:09:19 +08:00
parent d475feb790
commit 6e7d819034
15 changed files with 154 additions and 114 deletions
@@ -14,17 +14,21 @@ import java.time.LocalDate
import java.time.ZoneId
// =====================================================================
// 仓储契约(docs/flight-state.md §2 权威模型/§3 写入语义,design.md §2.1
// 决策层写路径约定:所有 FLIGHT_SCHD 及明细写操作必须发生在
// 「持有 PIPELINE_LOCK 的同一事务」内(§1/§4:锁只串行化 DB 事务)。
// 仓储接口定义
//
// 有一条约定贯穿航班相关的写操作:改航班状态(FLIGHT_SCHD 和明细表)必须发生在
// 同一个事务里,并且进事务后第一件事就是拿 PIPELINE_LOCK 这把单行锁。锁只负责让
// 写事务排队,不负责选主或故障切换。
//
// 航班模型与合并规则的完整说明见 docs/flight-state.md。
// =====================================================================
/** 自有 PG 单事务原子保障(flight-state.md §1/§4状态、事件、处理终态同事务提交)。 */
/** 把一段代码包进一个数据库事务:航班状态、待发事件、处理终态要么一起提交,要么一起回滚。 */
interface PipelineTransactionManager {
fun <T> inTransaction(block: () -> T): T
}
/** 单行锁 PIPELINE_LOCK:事务内第一步 SELECT ... FOR UPDATE,串行化状态写事务(§1/§4。 */
/** 单行锁:进事务后先 `SELECT ... FOR UPDATE`,让并发的状态写事务排队执行。 */
interface PipelineLockRepository {
fun lock()
}
@@ -117,7 +121,12 @@ data class BackfillDue(val msgId: Long, val attempts: Int)
*/
data class Backlog(val unfinished: Int, val oldestReceivedAt: Instant?, val unmarkedTerminal: Int)
/** MSG_EVENT outboxflight-state.md §5)。KAFKA_SCHD 合并同 FLID 未发事件按最新 STATE_VERSION 输出。 */
/**
* 待发事件(outbox)表:业务提交时把要发的事件一起写进来,投递线程再从这张表往外发,
* 这样发送失败不会影响业务事务。
*
* 其中 KAFKA_SCHD 是"最新状态"通知:同一条航班的多个未发事件只留最新的那个版本。
*/
interface MsgEventRepository {
fun insertAll(events: List<MsgEvent>): List<Long>
@@ -125,7 +134,7 @@ interface MsgEventRepository {
fun claimBatch(target: String, limit: Int): List<MsgEvent>
/** 同 FLID 未发 KAFKA_SCHD 事件合并:每 FLID 取最新 STATE_VERSION 一条(§5)。 */
/** 挑出待发的整态事件,同一条航班只取版本最高的那一条(中间的版本不用发)。 */
fun mergePendingSchd(limit: Int): List<MsgEvent>
fun markSent(eventId: Long)
@@ -137,63 +146,74 @@ interface MsgEventRepository {
fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int? = null)
}
/** 完整态落库结果:DAY_GUARD_VIOLATION = OPERATION_DAY 不可变条件更新未命中(§2.1)。 */
/**
* 落库结果。
* [DAY_GUARD_VIOLATION] 表示这次写入被"运营日不可变"的条件挡住:库里已有运营日,
* 和这次的报文对不上,说明这条报文可能串了日子。
*/
enum class PersistOutcome { INSERTED, UPDATED, DAY_GUARD_VIOLATION }
/**
* 航班当前态权威(§2 FLIGHT_SCHD + 9 张明细表
* 唯一写路径 = persistFullState:写入前在内存生成完整新状态(引擎产物)再落库,
* 明细集合按组先删后插(§2.2 完整合并结果为准)
* 航班当前态的读写入口:主表 FLIGHT_SCHD 9 张明细表。
*
* 写只有一个入口 [persistFullState]:先在内存里算出合并后的完整状态,再整体落库
* 明细数据按组先删后插,所以入库的永远是"合并后的完整结果",不依赖增量更新。
*/
interface FlightStateRepository {
/** 主行点查(身份/版本判定;批量用于日计划归属校验 §2.1)。 */
/** 查航班主行,用于判断它是否存在、属于哪个运营日、当前版本是多少。 */
fun findMainRow(flid: String): FlightMainRow?
fun findMainRows(flids: Collection<String>): Map<String, FlightMainRow>
/** 完整当前态主行 + 全部明细(一致性读边界由调用方事务保证,§5。 */
/** 读一条航班的完整当前态主行全部明细)。要读得一致,得由调用方放在事务里读。 */
fun loadFullSnapshot(flid: String): FlightSnapshot?
/**
* 完整当前态落库(§3:主行 upsert + 全部明细按组先删后插
* STATE_VERSION 以 snapshot.stateVersion 落库
* SCHD(§3.1)、FLOP/ADFT(§3.2/§3.3)合并后的全量结果共用此唯一写路径。
* 条件更新带 `WHERE operation_day IS NULL OR operation_day = :day`(§2.1 不可变强化)
* 把合并后的完整状态写库:主行 upsert明细按组先删后插
* 版本号用 [snapshot] 里算好的那个
*
* 日计划、动态事件、异常航班的合并结果都走这一个入口
* 写入带运营日条件(`WHERE operation_day IS NULL OR operation_day = :day`):
* 一旦航班已经归属某个运营日,别的日子的报文就写不进来。
*/
fun persistFullState(snapshot: FlightSnapshot, msgId: Long, now: Instant): PersistOutcome
/**
* FDEL(§3.3):ACTIVE → 置 DELETED、推进版本、明细保留、返回 true(发布删除事件);
* 已 DELETED 或不存在 → 返回 false(幂等成功,不推进版本不重复发布)。
* 删除航班:把在用的航班标成 DELETED、版本号加一,明细数据保留不删。
*
* 返回 true 表示这次真的删了(调用方据此发一次删除通知);返回 false 表示航班已经
* 是删除状态或根本不存在,按成功处理,不推进版本也不重复发通知。
*/
fun markDeleted(flid: String, msgId: Long, now: Instant): Boolean
/** ADFT 生命周期重激活(§3.3):DELETED → ACTIVE,推进版本;非 DELETED 返回 false。 */
/** 把已删除的航班重新激活(ADFT 报文触发):DELETED → ACTIVE、版本号加一;不是删除状态则返回 false。 */
fun revive(flid: String, msgId: Long, now: Instant): Boolean
/** §6按保留期与终态/静默判据选出历史候选(含 DELETED;窗口按机场时区折算)。 */
/** 按保留期挑出可以归档清理的航班(含已删除的)。时间窗口按机场时区计算。 */
fun findHistoryCandidates(rules: HistoryRules, zone: ZoneId, now: Instant): List<HistoryCandidate>
/**
* §6物理删除主行与明细(仅历史存储确认成功后调用;
* 历史存储接通时调用方必须传空集合——删 0 条)
* 物理删除主行与明细。**只能在历史存储确认归档成功后调用**
* 历史存储接通时调用方必须传空集合,也就是一条都不删
*/
fun purgeArchived(flids: Collection<String>): Int
/** §2.1 观测:OPERATION_DAY 仍为 NULL 的航班数(只增不删,终止规则未定 §6)。 */
/** 运营日还没定下来的航班有多少条(观测用;这些航班暂时不参与清理)。 */
fun countOperationDayNull(): Int
}
/** SCHD_SNAP_LOGdesign.md §6.2):事务外追加留痕,不参与决策;一行 = 一次尝试(重放也记)。 */
/** 日计划处理留痕:只追加、不参与业务判断,一次处理(含重放)记一行,写失败不影响业务。 */
interface SnapshotLogRepository {
fun append(entry: SnapshotLogEntry)
}
/**
* 请求状态机 REQ_TRACKdesign.md §4.2 目标机制,尚无运行时协调器):
* 只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)匹配最新一条 PENDING;
* 同类请求只留一条有效,新请求置旧为 EXPIRED
* 上游请求的跟踪表:记录我们发出去的请求、以及对方回来的应答。
*
* 目前只有表和读写方法,**还没有运行时协调器**(出站写信箱、超时、应答匹配都没实现)
* 设计意图是:RESP 报文按(运营日、发送方、请求类型)匹配最近一条待应答的请求;
* 同一类请求同时只保留一条有效,发新请求时把旧的置为已过期。
*/
interface ReqTrackRepository {
enum class ReqState { PENDING, SENT, DONE, EXPIRED }
@@ -38,9 +38,10 @@ import java.util.Locale
import javax.sql.DataSource
// =====================================================================
// 自有 PostgreSQL 仓储实现docs/flight-state.md §2 权威模型/§3 写入语义;schema 见
// db/migration/V1__flight_state_baseline.sql)。全部写路径约定在
// withTransaction + PIPELINE_LOCK 内调用(§1/§4)。
// 自有 PostgreSQL 仓储实现,表结构见 db/migration/V1__flight_state_baseline.sql。
//
// 航班相关的写操作都要求调用方先开事务、再拿 PIPELINE_LOCK 单行锁(见 Repositories.kt
// 顶部说明)。航班模型与合并规则见 docs/flight-state.md。
// =====================================================================
@Singleton
@@ -58,7 +59,7 @@ class JdbcPipelineTransactionManager(
class JdbcPipelineLockRepository(
private val ds: DataSource,
) : PipelineLockRepository {
/** 事务第一步:行 FOR UPDATE 串行化状态写事务(§1/§4 PIPELINE_LOCK。 */
/** 事务后的第一步:对锁`FOR UPDATE`,让并发的状态写事务排队。 */
override fun lock() {
ds.queryOne("SELECT lock_id FROM pipeline_lock WHERE lock_id = 1 FOR UPDATE", {}) { 1 } ?: error("PIPELINE_LOCK row missing")
}
@@ -340,7 +341,7 @@ class JdbcMsgEventRepository(
::mapEvent,
)
/** §5:同 FLID 未发事件按最新 STATE_VERSION 合并;PG DISTINCT ON 方言(注释明示)。 */
/** 同一条航班只取版本最高的待发整态事件;用了 PostgreSQL 的 DISTINCT ON 语法。 */
override fun mergePendingSchd(limit: Int): List<MsgEvent> =
ds.query(
"""
@@ -477,7 +478,7 @@ class JdbcFlightStateRepository(
},
)
if (updated == 0) {
// §2.1 不可变强化:条件更新未命中 = 归属日冲突
// 一行都没更新到,说明被运营日条件挡住了:这条航班已经属于别的运营日
return if (existed != null) PersistOutcome.DAY_GUARD_VIOLATION else PersistOutcome.UPDATED
}
replaceDetails(snapshot, now)
@@ -499,7 +500,7 @@ class JdbcFlightStateRepository(
) == 1
override fun findHistoryCandidates(rules: HistoryRules, zone: ZoneId, now: Instant): List<HistoryCandidate> {
// §6 四条判定在应用层执行(SIS 时间串解析无法下推 SQL
// 四条清理判据都在应用层算:里面的时间字段是 SIS 格式的字符串,没法交给 SQL 比较
// 不按 updated_at 粗筛——取消/终态时间可能早于最近一次更新,粗筛会漏删。
val rows = ds.query(
"SELECT flid, state, state_version, operation_day, cncl, naat, neat, updated_at FROM flight_schd",
@@ -528,7 +529,7 @@ class JdbcFlightStateRepository(
r.updatedAt < now.minus(Duration.ofHours(rules.idleHours)) -> true
else -> false
}
// NAAT/NEAT 业务含义待术语表确认(§6 开放项):解析失败视为不命中,不误
// NAAT/NEAT 业务含义还没确认,所以解析不出来时一律当成"不满足条件",宁可漏
if (hit) HistoryCandidate(r.flid, r.state, r.version, wasNeverFdel = r.state == FlightState.ACTIVE) else null
}
}
@@ -585,7 +586,7 @@ class JdbcFlightStateRepository(
items.forEachIndexed { ordinal, item ->
val cols = mutableListOf("flid", "ordinal", "source_seq")
val vals = mutableListOf<Any?>(flid)
vals.add(ordinal + 1) // ORDINAL 保留输入顺序(§2.2
vals.add(ordinal + 1) // 序号按报文里的先后顺序编,读回来顺序不变
vals.add(item[spec.seqAttr])
spec.columns.forEach { col ->
cols.add(col)
@@ -617,7 +618,7 @@ class JdbcFlightStateRepository(
private fun loadDetails(flid: String, key: String, spec: DetailSpec): List<Map<String, String>> {
val sql = if (spec.routeKind != null) {
// ROUT/ERUT 共表:按 ROUTE_KIND 过滤(§2.2
// ROUTERUT 存在同一张表里,靠 ROUTE_KIND 区分,读的时候要带上这个条件
"SELECT * FROM ${spec.table} WHERE flid = ? AND route_kind = ? ORDER BY ordinal ASC"
} else {
"SELECT * FROM ${spec.table} WHERE flid = ? ORDER BY ordinal ASC"
@@ -673,7 +674,7 @@ class JdbcFlightStateRepository(
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
)
/** 10 类集合 ↔ 明细表/列映射(§2.2列名与基线一致)。 */
/** 报文里的 10 类集合分别存到哪张表、哪些列(列名与基线脚本一致)。 */
internal val COLLECTIONS: Map<String, DetailSpec> = mapOf(
"GTDT" to DetailSpec("flight_gate", listOf("gate", "pgot", "pgct", "gotm", "gctm", "gtyp"), "GTNO"),
"CKDT" to DetailSpec("flight_checkin", listOf("chkc", "ccls", "pcot", "pcct", "cotm", "cctm", "ctyp"), "CKNO"),
@@ -695,7 +696,7 @@ class JdbcFlightStateRepository(
class JdbcSnapshotLogRepository(
private val ds: DataSource,
) : SnapshotLogRepository {
/** design.md §6.2:只追加;写失败由调用方捕获记指标(append 自身不抛出)。 */
/** 只追加写;这里不抛异常,写失败由调用方捕获并记为指标。 */
override fun append(entry: SnapshotLogEntry) {
ds.update(
"""
@@ -757,7 +758,10 @@ class JdbcReqTrackRepository(
)
}
/** design.md §4.2 RESP 完成请求:匹配最新一条 PENDING/SENT;无匹配返回 false(迟到不报错)。 */
/**
* 应答报文到达时,把对应请求标记为已完成:找最近一条待应答的请求(PENDING 或 SENT)。
* 找不到就返回 false——迟到或多余的应答不算错误。
*/
override fun completeLatest(reqType: String, operationDay: LocalDate, sender: String): Boolean =
ds.update(
"""
@@ -199,7 +199,7 @@ class StubMsgEvents : MsgEventRepository {
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }.sortedBy { it.eventId!! }.take(limit)
/** §5:同 FLID 取最新 STATE_VERSION,按事件输出。 */
/** 同一条航班只取版本最高的待发整态事件,按事件先后输出。 */
override fun mergePendingSchd(limit: Int): List<MsgEvent> =
rows.values
.filter { it.target == "KAFKA:schd" && it.state == EventStatus.PENDING }
@@ -245,7 +245,7 @@ class StubFlightState : FlightStateRepository {
override fun loadFullSnapshot(flid: String): FlightSnapshot? = snapshots[flid]
/** §2.1 不可变条件:已有非空 OPERATION_DAY 且与新值不同 DAY_GUARD_VIOLATION。 */
/** 运营日不可变:库里已有运营日且与新值不同时,返回 DAY_GUARD_VIOLATION。 */
override fun persistFullState(snapshot: FlightSnapshot, msgId: Long, now: Instant): PersistOutcome {
val existing = mains[snapshot.flid]
if (existing?.operationDay != null && existing.operationDay != snapshot.operationDay) {
@@ -327,7 +327,7 @@ class StubReqTrack : ReqTrackRepository {
fun clear() = rows.clear()
override fun insert(reqType: String, operationDay: LocalDate, sender: String): Long {
// design.md §4.2:同类(类型+运营日+发送方)旧有效请求先置 EXPIRED
// 同一类请求(类型 + 运营日 + 发送方)只保留一条有效,旧的先置为已过期
rows.values.filter {
it.reqType == reqType && it.operationDay == operationDay && it.sender == sender &&
(it.state == ReqTrackRepository.ReqState.PENDING || it.state == ReqTrackRepository.ReqState.SENT)
@@ -78,7 +78,7 @@ class FdelProcessor(
val deleted = flightState.markDeleted(payload.flid, msgId = head.msgId, now = Instant.now())
if (deleted) {
val current = flightState.loadFullSnapshot(payload.flid)
// tombstone 仅在 ACTIVE→DELETED 时登记(§3.3/§5),与删除同事务
// 只有"在用 → 删除"这一步才发删除通知,而且和状态变更写在同一个事务
msgEvents.insertAll(
listOf(
MsgEvent(
@@ -105,7 +105,7 @@ class FdelProcessor(
),
)
}
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED) // 未命中 = 迟到/重复,幂等成功(§3.3
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED) // 没删到东西说明是迟到重复报文,照样算成功
ApplyResult.Succeeded
}
}
@@ -137,7 +137,7 @@ class AdftProcessor(
lock.lock()
val main = flightState.findMainRow(record.flid)
if (main != null && main.state == FlightState.DELETED) {
// §3.3 重激活:DELETED → ACTIVE,推进版本,登记状态事件
// 已删除的航班重新激活:状态改回 ACTIVE、版本号加一,并登记状态事件
if (flightState.revive(record.flid, msgId = head.msgId, now = Instant.now())) {
val current = flightState.loadFullSnapshot(record.flid)
if (current != null) {
@@ -152,11 +152,11 @@ class AdftProcessor(
val current = flightState.loadFullSnapshot(record.flid)
val next: FlightSnapshot = if (current == null) {
// 新实例建立:ADFT 含 SODT 时直接计算运营日(§2.1),不可算则置 null 待日计划收录
// 新建航班:带了计划时间就算出运营日,算不出来先留空,等日计划报文来收录
val day = opDay.compute(record.scalars["SODT"])
FlightSnapshot(
flid = record.flid,
operationDay = day, // §2.1:不可算时不得默认写接收日
operationDay = day, // 算不出来就留空,不能默认拿收报当天顶上
state = FlightState.ACTIVE,
stateVersion = 1L,
scalars = record.scalars,
@@ -129,8 +129,8 @@ class MessageProcessor(
log.warn("processOne unexpected failure msgId={} ec=INFRA msg={}", head.msgId, e.message ?: e.javaClass.simpleName)
procFailure.fail(head, ErrorClass.INFRA, e.message ?: e.javaClass.simpleName)
}
// 终态已提交:立即尝试一次回填;失败留待回填扫描按退避重试(意图已在终态事务内登记)。
// 中间态(PENDING/FAILED)不适用(message-lifecycle §5.2
// 终态已经写好了,马上试一次回填;写不进去也没关系,回填扫描按退避继续重试
// (待办在写终态时就一起登记了)。还没处理完的消息不打标记
if (terminal) backfill.attempt(head.msgId)
}
}
@@ -178,7 +178,7 @@ class MessageProcessor(
}
}
// 处理器分派:SCHD 日计划主链路(§3.1)/ FLOP / FDEL / ADFT;缺载荷按 MALFORMED 终态
// 按报文类型分派:日计划走 SCHD,其余走 FLOP / FDEL / ADFT报文缺载荷直接判为非法报文的死信
val result: ApplyResult = when (val kind = decoded.kind) {
is MsgKind.Schd -> {
val body = decoded.body as? ScheduleBody
@@ -204,14 +204,14 @@ class MessageProcessor(
flopProcessor.apply(head, decoded, payload)
}
is MsgKind.Unsupported -> {
// design.md §2.3:未支持类型 → FAILED(UNSUPPORTED) 退避重试,达阈值转 DEAD;绝不写终态
// 还没有对应处理器的报文类型:先按可重试的失败处理,等能力补齐,不直接判死
log.warn("unsupported type -> FAILED(UNSUPPORTED) msgId={} tag={}", head.msgId, kind.tag)
return procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${kind.tag}")
}
}
if (result is ApplyResult.DeadProtocol) {
// design.md §2.3整包拒绝 DEAD(PROTOCOL),立即释放队头,人工确认
// 整包拒绝:不重试、立刻放掉队头,人工确认
log.error("DEAD(PROTOCOL) msgId={} reason={} flags={}", head.msgId, result.reason, result.flags)
procState.markTerminal(
head.msgId, ProcStatus.DEAD,
@@ -76,7 +76,7 @@ class ScheduleProcessor(
val body = msg.body as? ScheduleBody ?: return ApplyResult.DeadProtocol("missing-schd-body")
val started = System.nanoTime()
// 重放判定:MSG_ID 已有成功终态 → 幂等成功(flight-state.md §4),重放也记留痕
// 这条消息以前处理成功过:直接算成功,不重复写数据(留痕照样记一条)
if (procState.findSuccessTerminal(head.msgId)) {
logSnapshot(head, body, SnapshotResult.REPLAY_SKIPPED, upserted = 0, flags = emptySet(), started)
return ApplyResult.ReplaySkipped
@@ -123,7 +123,7 @@ class ScheduleProcessor(
val record = body.records.first { it.flid == flid }
val existingMain = mains[flid]
val keepDeleted = existingMain?.state == FlightState.DELETED
if (keepDeleted) flags.add(SnapshotFlag.SCHD_REVIVE_CONFLICT) // §3.3:日计划不复活 DELETED
if (keepDeleted) flags.add(SnapshotFlag.SCHD_REVIVE_CONFLICT) // 日计划不会让已删除的航班复活
val current = if (existingMain != null) flightState.loadFullSnapshot(flid) else null
val next = FlightStateEngine.snapshotState(
current = current,
@@ -151,7 +151,7 @@ class ScheduleProcessor(
}
}
/** 状态事件:KAFKA_SCHD 整态 + KAFKA_MSG 变化通知(flight-state.md §3.1 登记、§5 语义)。 */
/** 每次航班状态变化登记两个事件:KAFKA_SCHD 发完整状态,KAFKA_MSG 发一条变更通知。 */
private fun snapshotEvents(next: FlightSnapshot): List<MsgEvent> {
val payload = linkedMapOf<String, Any>(
"flid" to next.flid,
@@ -1,44 +1,60 @@
-- =====================================================================
-- V2信箱生命周期边界层(权威设计:docs/message-lifecycle.md §2/§4/§5.1/§5.2
-- V2收报进度与回填记录的存放方式调整
-- ---------------------------------------------------------------------
-- 三处契约级修正,均只动自有 PG共享 MySQL 不建表、不改结构,红线不破):
-- 1. 消费水位 W 落库(INBOX_CURSOR):只随新 ID 成功入队推进、遇空洞即停,
-- 与入队同事务——中断后 W 未前进,重扫即补建(§4 第一行);
-- HOLE_SINCE 支撑"空洞老化",避免一次自增回滚空位永久停摆水位(§5.1)。
-- 2. 回填事实并入 PROC_STATE:终态与回填意图同一条 UPDATE 落下,
-- 消除"业务已提交、回填待办未记"与"待办二次落账失败"两个崩溃窗口(§4);
-- BACKFILL_TODO 随之下线(它承载的 sndr/type/styp/seqn 从未参与回填,
-- 共享信箱回填只需要 MSG_ID——§5.2 补写同样如此)。
-- 3. 超期补写判据 RECEIVED_AT(§5.2 的 R)与 OPS-2「最老未处理信龄」锚点
-- 这次迁移解决三个问题,全部只动自有 PostgreSQL。共享 MySQL 那边不建表、
-- 不改结构,边界没有变化。
--
-- 1) 收报读到哪儿了要能记住(新增 INBOX_CURSOR
-- 原先每一轮都从 0 开始扫信箱,靠"有没有处理标记"判断该不该取。这个做法有问题:
-- 处理完但还没把标记写回信箱的行(以及永远不会回填的死信)会一直占着每批的
-- 名额,攒够一批之后新消息就再也读不到了。
-- 改成记住"读到哪个 ID 了"(水位),每轮只往后读,并且登记和水位推进放在同一个
-- 事务里——中途崩溃时水位没动,重启后重扫一遍就补齐了
-- 水位还有个附带规则:如果后面的 ID 缺号,先停下来等(可能是上游还没提交完),
-- 等太久就认定它不会来了、跳过去继续,否则水位会卡在第一个空位上再也不动。
--
-- 2) 回填记录并进 PROC_STATE,不再单独建表
-- 处理完有两件事要做:记下终态、把处理标记写回信箱。原先第二步靠一张独立的
-- 待办表,两张表两次写入,中间崩溃就会出现"业务处理完了却没人记得回填"。
-- 现在这两件事是同一条 UPDATE:在一个事务里提交,要么都成、要么都不做。
-- BACKFILL_TODO 因此下线——它保存的 sndr/type/styp/seqn 在回填时从来没用上,
-- 回填只需要一个消息 ID。
--
-- 3) 记下收信时间(新增 RECEIVED_AT
-- 有两个用途:判断一条消息等了多久还是没能回填(超期就强制补写),
-- 以及统计"最老一条未处理消息收了多久",供运维观察积压。
-- =====================================================================
-- ① 消费水位:单行游标(CURSOR_ID 恒为 1
-- ① 收报水位:全表只有一行
CREATE TABLE INBOX_CURSOR (
CURSOR_ID INT NOT NULL PRIMARY KEY, -- 为 1
COMMITTED_UP_TO BIGINT NOT NULL, -- 水位 W:连续上界,无空洞
HOLE_SINCE TIMESTAMP(6) WITH TIME ZONE, -- W+1 处空洞首次观测时刻;无空洞为 NULL
CURSOR_ID INT NOT NULL PRIMARY KEY, -- 固定为 1
COMMITTED_UP_TO BIGINT NOT NULL, -- 水位:到哪个 ID 为止已经全部读进自有库
HOLE_SINCE TIMESTAMP(6) WITH TIME ZONE, -- 后面那个缺号最早是什么时候发现的;不缺号时为 NULL
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
);
-- 初值 0:首轮从最小 ID 起重扫全部信箱行,入队幂等(ON CONFLICT DO NOTHING),
-- 上线即自愈既有"已入队未回填"造成的窗口污染。
-- 初值 0:上线后第一轮会把信箱里所有行重新读一遍,重复登记不会建出第二行,
-- 所以顺手把历史上"已入队但没回填"造成的漏读一并补上。
INSERT INTO INBOX_CURSOR (CURSOR_ID, COMMITTED_UP_TO, HOLE_SINCE, UPDATED_AT) VALUES (1, 0, NULL, now());
-- ② PROC_STATE接收时间 + 回填事实(替代 BACKFILL_TODO
ALTER TABLE PROC_STATE ADD COLUMN RECEIVED_AT TIMESTAMP(6) WITH TIME ZONE;
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_AT TIMESTAMP(6) WITH TIME ZONE; -- 非空 = 已确认信箱行持有标记
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_NEXT_AT TIMESTAMP(6) WITH TIME ZONE; -- 非空 = 待回填(终态同事务登记)
-- ② PROC_STATE加上收信时间与回填进度(取代 BACKFILL_TODO
ALTER TABLE PROC_STATE ADD COLUMN RECEIVED_AT TIMESTAMP(6) WITH TIME ZONE; -- 信箱里的接收时间
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_AT TIMESTAMP(6) WITH TIME ZONE; -- 有值 = 已确认信箱行带上了标记
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_NEXT_AT TIMESTAMP(6) WITH TIME ZONE; -- 有值 = 还欠一次回填,写终态时一起写下
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_ATTEMPTS INT NOT NULL DEFAULT 0;
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_ERROR VARCHAR(512);
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_ERROR VARCHAR(512); -- 回填失败的原因,供排查
-- 存量行接收时间:无法跨库回读 DATE_RECEIVED入队时间为下界(§5.2 判据只会偏晚,不会提前补写)
-- 存量数据补收信时间:跨库读不到信箱里的 DATE_RECEIVED只能拿入队时间当兜底。
-- 这个值只会偏晚,宁可晚一点补写标记,也不会提前把还会重放的消息标掉。
UPDATE PROC_STATE SET RECEIVED_AT = UPDATED_AT WHERE RECEIVED_AT IS NULL;
-- 存量终态行补登记回填意图:立即交给回填扫描(已持有标记的行由空标守卫返回 0 行,幂等)
-- 存量里已经处理完的行补一条回填待办,交给回填扫描处理。
-- 其中已经打过标记的行会被"只写空标记"的条件挡住,重复执行没有副作用。
UPDATE PROC_STATE SET BACKFILL_NEXT_AT = now()
WHERE STATE IN ('SUCCEEDED', 'SKIPPED', 'DEAD') AND BACKFILL_AT IS NULL;
-- 回填扫描索引:仅覆盖未确认标记的行
-- 回填扫描用的索引,只覆盖还没确认回填的行
CREATE INDEX idx_proc_backfill_due ON PROC_STATE (BACKFILL_NEXT_AT) WHERE BACKFILL_AT IS NULL;
-- ③ BACKFILL_TODO 下线回填意图已并入 PROC_STATE,机制收敛为一套索引随表一释放
-- ③ BACKFILL_TODO 下线回填进度已经并进 PROC_STATE,机制只剩一套索引随表一释放
DROP TABLE BACKFILL_TODO;
@@ -60,7 +60,7 @@ class PipelineSmokeTest {
}
companion object {
/** 合法 META,但 TYPE 未在支持范围 → FAILED(UNSUPPORTED)design.md §2.3 */
/** 报文头合法,但类型没有对应处理器,会走"暂不支持、先重试"这条路 */
val UNSUPPORTED_XML = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260908120000</DTTM><TYPE>XYZQ</TYPE><STYP>FOO</STYP></META>
@@ -77,7 +77,7 @@ class FlywayMigrationTest {
assertEquals(setOf("flid", "operation_day", "state", "state_version", "last_msg_id"), cols)
}
// 回填事实并入 PROC_STATE(§5.2 判据 + 回填待办
// 回填相关的列都落在 PROC_STATE 上(收信时间用来判断超期,其余记录回填进度
stmt.executeQuery(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'proc_state' " +
"AND column_name IN ('received_at', 'backfill_at', 'backfill_next_at', " +
@@ -91,7 +91,7 @@ class FlywayMigrationTest {
)
}
// PIPELINE_LOCK 与 INBOX_CURSOR 单行种子(§3.2 / §5.1
// 两张单行表(管道锁、收报水位)的种子数据都要在
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
assertTrue(rs.next())
assertEquals(1, rs.getInt(1))
@@ -70,7 +70,7 @@ class InboxLifecycleJdbcSqlTest {
val row = proc.find(11L)!!
assertEquals(ProcStatus.DEAD, row.state)
assertEquals(ErrorClass.MALFORMED, row.errorClass)
assertEquals(t0, row.backfillNextAt) // 终态与回填意图同一条 UPDATE(§2/§4
assertEquals(t0, row.backfillNextAt) // 终态的同时就登记了回填待办(同一条 UPDATE
assertNull(row.backfillAt)
assertEquals(t0, row.receivedAt)
}
@@ -83,7 +83,7 @@ class InboxLifecycleJdbcSqlTest {
proc.recordBackfillFailure(11L, "mysql-down", attempts = 2, nextAttemptAt = t0.plusSeconds(120), now = t0)
val row = proc.find(11L)!!
assertEquals(ProcStatus.SUCCEEDED, row.state) // §11 终态不可逆
assertEquals(ProcStatus.SUCCEEDED, row.state) // 回填失败不改处理结果
assertEquals(2, row.backfillAttempts)
assertEquals(t0.plusSeconds(120), row.backfillNextAt)
assertEquals("mysql-down", row.backfillError)
@@ -98,11 +98,11 @@ class InboxLifecycleJdbcSqlTest {
seed(2L, t0)
proc.markTerminal(2L, ProcStatus.SUCCEEDED, now = t0)
proc.recordBackfillFailure(2L, "mysql-down", 1, t0.plus(Duration.ofMinutes(15)), t0)
// ③ 未到期但接收时间已达超期期限 R(§5.2 覆盖退避)
// ③ 退避还没到,但收信时间已经很久了:应当无视退避直接补写
seed(3L, overdue)
proc.markTerminal(3L, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, now = t0)
proc.recordBackfillFailure(3L, "mysql-down", 1, t0.plus(Duration.ofMinutes(15)), t0)
// ④ 中间态:永不补写(§5.2
// ④ 还没处理完:不补写
seed(4L, overdue)
// ⑤ 已确认标记
seed(5L, t0)
@@ -124,7 +124,7 @@ class InboxLifecycleJdbcSqlTest {
val backlog = proc.backlog()
assertEquals(1, backlog.unfinished)
assertEquals(overdue, backlog.oldestReceivedAt) // OPS-2 最老未处理信龄锚点
assertEquals(overdue, backlog.oldestReceivedAt) // 最老一条未处理消息的接收时间
assertEquals(1, backlog.unmarkedTerminal)
}
@@ -150,7 +150,7 @@ class InboxLifecycleJdbcSqlTest {
assertEquals(third, mailbox.maxId())
assertTrue(mailbox.markProcessedIfUnmarked(second, "PROCESSED"))
assertFalse(mailbox.markProcessedIfUnmarked(second, "OTHER")) // §11 只把空标写为已处理
assertFalse(mailbox.markProcessedIfUnmarked(second, "OTHER")) // 已有标记,不覆盖
assertEquals("PROCESSED", statusOf(second))
// 已标记行仍出现在区间读结果中(发现与标记彻底解耦)
assertEquals(listOf(first, second, third), mailbox.readRange(0L, 50).map { it.msgId })
@@ -10,8 +10,8 @@ import org.junit.jupiter.api.Test
import java.time.Instant
/**
* U11:显式重放入口只允许可恢复错误从 FAILED/DEAD 返回 PENDINGattempts 清零、立即重试);
* MALFORMED(报文非法)永不被重放。
* 人工重放的规矩:只有"可以重放"的失败原因才能把消息从 FAILED / DEAD 拉回队列
* (重试次数清零,马上再试一次);报文本身非法的记录永远不允许重放。
*/
class ReplayServiceTest {
@@ -52,14 +52,14 @@ class InboxPollerTest {
assertEquals(ProcStatus.PENDING, proc.find(second)!!.state)
assertEquals(second, cursor.cursor.committedUpTo)
assertNull(cursor.cursor.holeSince)
// §5.3:消化阶段只写自有 PG,不碰信箱标记
// 收报只写自有,不碰信箱的处理标记
assertFalse(inbox.isMarked(first))
assertEquals(0, poller.pollOnce(t0)) // 重复扫描幂等
}
/**
* 回归US-01 条目 3 / §5.3「积压挡批」):终态且永不回填的行(解码失败死信)曾占满
* 有限批次使收报整体停摆——发现必须与处理标记彻底解耦
* 回归用例:处理完却永远不会回填的行(典型是解码失败死信)曾占满每一批的名额,
* 导致收报整体停摆。取新消息这件事必须和"有没有处理标记"彻底分开
*/
@Test
fun `terminal rows without a mailbox mark do not block discovery of later messages`() {
@@ -32,7 +32,7 @@ class BackfillServiceTest {
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
private val props = PipelineProps()
/** 可注入故障的信箱验证回填失败路径(§3 失败处理。 */
/** 可以人为制造故障的信箱,用来验证回填失败时怎么处理。 */
private class FakeMailbox(var fail: Boolean = false) : CminmsgInboxRepository {
val marked = linkedSetOf<Long>()
override fun insertRaw(rawXml: String): Long = 1L
@@ -76,7 +76,7 @@ class BackfillServiceTest {
val id = inbox.insertRaw("<MSG/>")
assertTrue(inbox.markProcessedIfUnmarked(id, "PROCESSED"))
assertFalse(inbox.markProcessedIfUnmarked(id, "OTHER")) // §11 只把空标写为已处理
assertFalse(inbox.markProcessedIfUnmarked(id, "OTHER")) // 已经有标记了,不再写第二次
assertEquals("PROCESSED", inbox.markOf(id))
}
@@ -89,7 +89,7 @@ class BackfillServiceTest {
val backfill = service(proc, inbox)
backfill.attempt(id)
backfill.attempt(id) // 重复执行无副作用(§5.2 补写只针对空标记)
backfill.attempt(id) // 重复补写没有副作用
assertNull(proc.find(id)!!.backfillError)
assertEquals(0, proc.find(id)!!.backfillAttempts)
@@ -104,7 +104,7 @@ class BackfillServiceTest {
service(proc, mailbox).attempt(901L)
val row = proc.find(901L)!!
assertEquals(ProcStatus.SUCCEEDED, row.state) // §11 终态不可逆
assertEquals(ProcStatus.SUCCEEDED, row.state) // 回填失败不会把处理结果改回去
assertEquals(1, row.backfillAttempts)
assertEquals("mysql-down", row.backfillError)
assertEquals(t0.plus(Duration.ofSeconds(30)), row.backfillNextAt)
@@ -129,7 +129,7 @@ class BackfillServiceTest {
assertNotNull(proc.find(901L)!!.backfillAt)
}
/** §5.2:超期期限 R 覆盖退避,保证库方清除前提「边界内无未标记行」在有限时间内成立。 */
/** 等得太久的消息不再等退避、直接补写:保证标记最终一定会写上。 */
@Test
fun `overdue rows bypass the retry backoff`() {
val proc = StubProcState()
@@ -145,7 +145,7 @@ class BackfillServiceTest {
assertNotNull(proc.find(id)!!.backfillAt)
}
/** §5.2:中间态PENDING / FAILED不适用超期补写——处理未完成时不打标。 */
/** 还没处理完的消息PENDING / FAILED永远不打标,等再久也不行。 */
@Test
fun `mid states are never marked even when far past the deadline`() {
val proc = StubProcState()
@@ -73,7 +73,7 @@ class FdelAndAdftProcessorTest {
assertEquals(1, tombstones.size)
assertEquals(Targets.KAFKA_SCHD, tombstones.single().target)
assertTrue(tombstones.single().payloadJson.contains("\"deleted\":true"))
// 终态 + 回填意图由处理器在自己的事务内落库(message-lifecycle §2/§4
// 终态和回填待办由处理器在自己的事务里写好
val row = proc.find(msgId)!!
assertEquals(ProcStatus.SUCCEEDED, row.state)
assertNotNull(row.backfillNextAt)
@@ -89,7 +89,7 @@ class FdelAndAdftProcessorTest {
val version = f.findMainRow("121")!!.stateVersion
val result = proc.apply(head(), msg(), FlopPayload("121"))
assertEquals(ApplyResult.Succeeded, result) // §3.3:重复 FDEL 幂等成功
assertEquals(ApplyResult.Succeeded, result) // 重复的删除报文算成功,但不再动数据
assertEquals(version, f.findMainRow("121")!!.stateVersion)
assertEquals(1, events.rows.values.count { it.eventType == EventType.TOMBSTONE })
}
@@ -102,7 +102,7 @@ class FdelAndAdftProcessorTest {
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(), ObjectMapper())
.apply(head(), msg(), FlopPayload("999"))
assertEquals(ApplyResult.Succeeded, result) // §3.3:迟到/不存在幂等成功
assertEquals(ApplyResult.Succeeded, result) // 航班不存在(迟到或多余)也算成功
assertEquals(0, events.rows.size)
}
@@ -123,7 +123,7 @@ class FdelAndAdftProcessorTest {
assertEquals(ApplyResult.Succeeded, result)
val main = f.findMainRow("121")!!
assertEquals(FlightState.ACTIVE, main.state) // §3.3 生命周期重激活
assertEquals(FlightState.ACTIVE, main.state) // 已删除的航班被重新激活
assertEquals(LocalDate.of(2026, 12, 15), main.operationDay)
assertEquals("CA002", f.loadFullSnapshot("121")!!.scalars["FLNO"])
}
@@ -145,7 +145,7 @@ class FdelAndAdftProcessorTest {
assertEquals(ApplyResult.Succeeded, result)
val main = f.findMainRow("555")!!
assertEquals(FlightState.ACTIVE, main.state)
assertEquals(LocalDate.of(2026, 12, 20), main.operationDay) // §2.1:含 SODT 直接计算
assertEquals(LocalDate.of(2026, 12, 20), main.operationDay) // 报文带了计划时间,运营日直接算出来
}
@Test
@@ -167,7 +167,7 @@ class FdelAndAdftProcessorTest {
val scalars = f.loadFullSnapshot("555")!!.scalars
assertEquals("XX200", scalars["FLNO"])
assertEquals("keep-me", scalars["REMC"]) // §2.1 保守 Set-only:缺失不 Clear
assertEquals("keep-me", scalars["REMC"]) // 报文没带的字段保持原值,不清空
}
@Suppress("unused")
@@ -106,7 +106,7 @@ class ScheduleProcessorTest {
assertEquals(1, log.entries.size)
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
assertEquals(1, log.entries.single().upserted)
// message-lifecycle §2/§4终态回填意图随业务写入在同一事务提交(不依赖主泵补写)
// 终态回填待办跟业务数据一起提交,不靠主泵事后再补
val row = proc.find(msgId)!!
assertEquals(ProcStatus.SUCCEEDED, row.state)
assertNotNull(row.backfillNextAt)
@@ -123,7 +123,7 @@ class ScheduleProcessorTest {
val result = processor(proc = proc, flights = flights, log = log)
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
assertEquals(ApplyResult.ReplaySkipped, result) // §4 重放判定:幂等成功
assertEquals(ApplyResult.ReplaySkipped, result) // 已经成功处理过,重放不重复写
assertNull(flights.findMainRow("121"))
assertEquals(SnapshotResult.REPLAY_SKIPPED, log.entries.single().result)
}
@@ -135,7 +135,7 @@ class ScheduleProcessorTest {
val schdBody = ScheduleBody(recsDeclared = 2, records = makeBody("121" to "15DEC261723").records)
val result = processor(flights = flights, log = log).applyScheduleRecords(head(), message(schdBody)) as ApplyResult.DeadProtocol
assertTrue(result.flags.contains(SnapshotFlag.RECS_DROP)) // §4:声明数量不符整包拒绝
assertTrue(result.flags.contains(SnapshotFlag.RECS_DROP)) // 声明条数和实收条数不符整包拒绝
assertNull(flights.findMainRow("121"))
assertEquals(SnapshotResult.ROLLED_BACK, log.entries.single().result)
}
@@ -143,7 +143,7 @@ class ScheduleProcessorTest {
@Test
fun `same flid across operation days is rejected whole-batch without writes`() {
val flights = StubFlightState()
// 预置 121 已归属 12-15报文声称 12-16 → §2.1 归属冲突
// 库里 121 已经属于 12-15报文却说是 12-16:运营日对不上
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1, mapOf("SODT" to "15DEC261723"), emptyMap(),
@@ -181,7 +181,7 @@ class ScheduleProcessorTest {
)
assertEquals(ApplyResult.Succeeded, result)
assertEquals(FlightState.DELETED, flights.findMainRow("121")!!.state) // §3.3:日计划不恢复
assertEquals(FlightState.DELETED, flights.findMainRow("121")!!.state) // 日计划不会让已删除的航班复活
assertEquals(8L, flights.findMainRow("121")!!.stateVersion)
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
assertTrue(log.entries.single().flags.contains(SnapshotFlag.SCHD_REVIVE_CONFLICT))
@@ -195,7 +195,7 @@ class ScheduleProcessorTest {
proc.insertIfAbsent(msgId, null)
val p = processor(proc = proc, flights = flights, log = log, tx = TxRunner { true })
// design.md §2.3:数据库/内部故障 → 异常上抛,MessageProcessor 记 FAILED(INFRA) 退避;不写任何终态
// 数据库故障时异常直接上抛,MessageProcessor 记成可重试的失败;这里不留下任何终态
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) {
p.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
}