docs(kdoc): 重写信箱生命周期相关 KDoc,改为直白说明并去掉内部编号

原有注释大量引用 §5.1/§5.2/Q2/US-01 这类文档编号和内部简称,跳过了"这段代码在做什么、
为什么这么做",没有读过设计文档的人基本读不懂。本次统一改成先讲清这件事本身、
再说为什么要这样做,编号只在末尾留一处指路。

覆盖本次改动涉及的 24 个 Kotlin 文件(生产 16 个 + 测试 8 个):

- 领域与端口:ProcState(补齐全量字段说明与状态/错误分类逐项注释)、
  ProcStateRepository / InboxCursorRepository / CminmsgInboxRepository 及
  MailboxRow / BackfillDue / Backlog;
- 收报:InboxPoller(把"水位连续、遇缺口停下、缺口老化"用大白话讲透)、
  InboxService、JdbcCminmsgInboxRepository;
- 处理:Pump / MessageProcessor、ScheduleProcessor、DynamicProcessors、
  ProcFailure、JdbcProcStateRepository 与游标实现;
- 回填与观测:BackfillService、InboxLifecycleHealthIndicator、JobRunner;
- 配置与 stub:PipelineProps(三个新增参数说清取值理由)、MailboxProps、
  StubRepositories;
- 测试:8 个测试类改为"这些用例在守哪几条规矩",并保留 H2 不覆盖
  ON CONFLICT 的说明。

术语统一按第一次出现就地解释:水位、处理标记、回填、死信、队头、终态。
纯注释改动;除拆分枚举时按仓库风格补的两个行尾逗号外无代码变更
(已用剥离注释后比对 HEAD 的方式逐文件核对)。测试仍为 78 passed / 1 skipped。
This commit is contained in:
windyboy
2026-09-10 11:05:55 +08:00
parent 8c6bb83b62
commit d475feb790
24 changed files with 332 additions and 186 deletions
@@ -10,15 +10,20 @@ import java.time.Duration
import java.time.Instant
/**
* 信箱处理标记回填(docs/message-lifecycle.md §3/§4/§5.2
* 把"已处理"标记写回共享信箱
*
* 回填意图(`BACKFILL_NEXT_AT`)由处理器在终态事务内登记,与业务写入同提交同回滚
* 因此不存在"业务已提交、待办未记"的窗口;本服务只做两件事:
* 1. [attempt]:终态提交后立即尝试一次(低延迟,失败静默留给扫描);
* 2. [sweep]:到期或已达超期期限 R 的记录批量补写(§5.2),指数退避 30s 起步、封顶 15 分钟。
* 一条消息处理完要做两件事:记下终态、把标记写回信箱。第一件在业务事务里完成
* 同时留下"还欠一次回填"的意图(PROC_STATE 上的 BACKFILL_NEXT_AT);
* 这个类负责第二件:
*
* 回填失败绝不重放业务变更,也绝不回改终态(§11);写入侧只把空标写为已处理
* 重复执行无副作用
* - [attempt]:处理刚结束时马上试一次,让标记尽快落到信箱。失败也不影响处理结果
* 留给扫描重试即可
* - [sweep]:定时把还欠回填的记录挑出来重试。失败就按 30 秒起步、最长 15 分钟的
* 退避往后推;如果一条消息从收到现在已经超过超期期限,则无视退避强制补写——
* 否则退避可能一直失败下去,这些行永远打不上标记,库方就没法清理信箱。
*
* 两条底线:回填失败不会把终态改回去,也不会重新执行业务逻辑;写标记只写还是空标记的
* 行,重复执行没有副作用。
*/
@Singleton
class BackfillService(
@@ -40,7 +45,10 @@ class BackfillService(
}
}
/** 单条最佳努力回填;失败只登记退避(异常不外抛,不阻塞提交后的处理路径)。 */
/**
* 处理完立刻试一次。失败只记一笔退避信息就返回,不抛异常——
* 调用方是主泵的处理路径,不能被回填问题拖住。
*/
fun attempt(msgId: Long, now: Instant = clock.instant()) {
record(msgId, attempts = 0, now = now)?.let {
log.warn("backfill failed msgId={} error={} (sweep will retry)", msgId, it)
@@ -48,8 +56,9 @@ class BackfillService(
}
/**
* 批量补写JobRunner 每 30s 触发;重启即继续,不依赖内存状态)。
* @return 本批检查条数
* 批量补写,由 JobRunner 每 30 秒调用一次。待办状态都在数据库里,
* 进程重启后接着跑,不需要额外恢复步骤。
* @return 本批处理的条数
*/
fun sweep(now: Instant = clock.instant()): Int {
val due = procState.findBackfillDue(now, now.minus(props.pipeline.overdueBackfill), props.pipeline.backfillBatch)
@@ -57,10 +66,10 @@ class BackfillService(
return due.size
}
/** @return 失败原因;null = 已确认标记(含"已被其他路径标记"的幂等成功) */
/** 回填一条。@return 失败原因;返回 null 表示标记已确认(包括"别的路径已经标过了")。 */
private fun record(msgId: Long, attempts: Int, now: Instant): String? =
try {
// 影响 0 行 = 已有标记;按幂等成功处理(§11 标记单调:不回撤、不覆盖
// 没有真正写进去说明库里已经有标记了,同样算成功(不覆盖已有值
mailbox.markProcessedIfUnmarked(msgId, mailboxProps.processedValue)
procState.markBackfilled(msgId, now)
null
@@ -25,9 +25,11 @@ import java.time.Instant
import java.time.ZoneId
/**
* FLOPdocs/flight-state.md §3.2 动态运行事件:读取完整当前态 → 合并变化 →
* 保留运营日 → STATE_VERSION+1 → 同事务登记 KAFKA_MSG / KAFKA_SCHD 与处理终态
* 未知/迟到航班按幂等成功处理,不创建实例(创建入口只有 SCHD/ADFT)。
* 处理 FLOP 动态运行事件:读出航班当前态,把报文里的变化合并进去,版本号加一,
* 并登记状态事件
*
* 航班不存在时按"迟到的消息"处理——直接算处理成功,不会顺手建一个新航班
* (新建只发生在 SCHD 和 ADFT 里)。
*/
@Singleton
class FlopProcessor(
@@ -57,8 +59,10 @@ class FlopProcessor(
}
/**
* FDELdocs/flight-state.md §3.3 删除):ACTIVE → 置 DELETED、推进版本、明细保留
* 与删除同事务登记 tombstone(§5);已 DELETED / 不存在 → 幂等成功,不推进版本、不重复发布
* 处理 FDEL 删除报文:把在用的航班标记为 DELETED,版本号加一,明细数据保留
* 并且只发布一次删除事件
*
* 已经删除过、或航班本来就不存在时,算处理成功但不再动版本、不重复发事件。
*/
@Singleton
class FdelProcessor(
@@ -107,9 +111,11 @@ class FdelProcessor(
}
/**
* ADFTdocs/flight-state.md §3.3):字段缺失语义待上游确认——确认前按保守 Set-only
* 处理(出现字段覆盖、缺失不清空)。FLID 已存在且 DELETED → 生命周期重激活;不存在 →
* 新实例建立(含 SODT 时直接计算 OPERATION_DAY,§2.1;否则保留 NULL 待日计划收录)
* 处理 ADFT 异常航班报文:已删除的航班重新激活,不存在的航班新建。
*
* 字段按"出现才覆盖"处理:报文里带来的字段写进去,没带到的字段保持原值、不清空
* 这是上游缺失字段的语义确认之前的保守做法。新航班如果带了计划时间就直接算出运营日,
* 否则先留空,等日计划报文来收录。
*/
@Singleton
class AdftProcessor(
@@ -182,7 +188,7 @@ class AdftProcessor(
// 共享小工具(处理器层私有约定)
// =====================================================================
/** KAFKA_SCHD 整态 + KAFKA_MSG 变化通知(flight-state.md §3.1/§5。 */
/** 航班状态变化后要发的两类事件:KAFKA_SCHD 发完整状态,KAFKA_MSG 发一条变更通知。 */
internal fun eventsFor(next: FlightSnapshot, mapper: ObjectMapper): List<MsgEvent> {
val payload = linkedMapOf<String, Any>(
"flid" to next.flid,
@@ -19,11 +19,16 @@ import java.time.Duration
import java.time.Instant
/**
* 处理主泵docs/flight-state.md §1 严格有序 + §4 处理事务与失败规则):
* 单活动主泵严格 FIFO + HOL 阻塞 + 队头滞留转 DEAD。
* 处理主泵:一个线程按消息 ID 从小到大一条条处理,保证先来的先处理。
*
* 终态与业务写入在处理器事务内原子提交;本泵只负责调度、边界化失败迁移与
* 提交后的最佳努力回填(message-lifecycle §3/§4)。
* 每次 tick 只看当前最小的未完成消息("队头"):
* - 没有待处理消息就睡一个轮询间隔;
* - 队头失败了还在退避期,就等到能重试的时刻;如果重试次数用尽或滞留太久,
* 直接转死信,不放任它一直堵着;
* - 其余情况交给 [MessageProcessor] 处理。
*
* 一次只处理一条是刻意的。后面的消息不能越过卡住的队头,否则同一条航班的报文
* 可能被乱序应用,几十秒后才到的旧报文会把新状态覆盖回去。
*/
@Singleton
class Pump(
@@ -38,7 +43,7 @@ class Pump(
@Volatile
private var running = true
/** 优雅停机:loop 在当前 tick 收尾后退出线程中断由 PipelineLifecycle 负责。 */
/** 请求停机:当前 tick 跑完就退出线程中断由 PipelineLifecycle 负责。 */
fun stop() {
running = false
}
@@ -51,7 +56,7 @@ class Pump(
Thread.currentThread().interrupt()
return
} catch (e: Exception) {
// 最后防线:失败状态迁移已在 processOne 边界内完成;致命 Error 不捕获
// 兜底:单条消息的失败状态已在 processOne 内记录,这里只避免线程退出
sleepQuietly(props.pipeline.pollInterval)
}
}
@@ -74,7 +79,7 @@ class Pump(
} else {
sleepQuietly(Duration.between(clock.instant(), head.nextAttemptAt))
}
// PENDING、或 FAILED 退避到期:交处理入口(内部有边界化失败迁移与 attempts 守卫)
// 其余情况(新消息,或退避到期的重试)交给处理入口
else -> processor.processOne(head)
}
}
@@ -89,9 +94,13 @@ class Pump(
}
/**
* processOne:解码 → 绑定 → 处理器(事务内决策 + 落库 + 终态 + 回填意图)→ 提交后最佳努力回填
* 边界化失败迁移(ProcFailure):任何意外异常归于本条 headFAILED(INFRA)+退避,不穿出杀泵;
* MALFORMED / PROTOCOL 直接 DEAD 不重试(docs/design.md §2.3 错误分类)。
* 处理一条消息:读原文 → 解码 → 绑定业务身份 → 分派给对应处理器 → 提交后回填标记
*
* 业务数据和终态由各处理器在自己的事务里写入。终态一旦落下(成功、跳过或死信),
* 这里马上试一次把处理标记写回信箱;写不进去也没关系,回填扫描会按退避继续重试。
*
* 任何意外异常都算在当前这条消息头上(记 FAILED(INFRA) 后重试),不会把主泵线程带崩。
* 报文非法和整包协议拒绝不重试,直接进死信等人工处置。
*/
@Singleton
class MessageProcessor(
@@ -126,7 +135,7 @@ class MessageProcessor(
}
}
/** @return 是否已达终态(终态才允许回填信箱标记) */
/** @return 这条消息是否已经落到终态(只有终态才允许回填信箱标记) */
private fun processInternal(head: ProcState): Boolean {
// 守卫:手工/遗留 FAILED 行若 attempts 已达上限,直接终态(防止退避到期后无限重试)
if (head.state == ProcStatus.FAILED && procFailure.scheduler.exhausted(head.attempts)) {
@@ -29,25 +29,32 @@ import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/** 处理器执行结果——终态与回填意图由处理器在自己的业务事务内落库(message-lifecycle §2/§4)。 */
/**
* 处理器告诉调用方这一条消息处理成了什么。
* 无论哪种结果,终态和回填待办都由处理器在自己的事务里写好。
*/
sealed interface ApplyResult {
/** 业务成功(幂等成功)。 */
/** 业务处理成功(包含"重复写入但结果一致"这种幂等成功)。 */
data object Succeeded : ApplyResult
/** MSG_ID 已有成功终态 → 重放,直接记幂等成功(docs/flight-state.md §4 重放判定)。 */
/** 这条消息之前已经成功处理过,这次只是重放,不重复写数据。 */
data object ReplaySkipped : ApplyResult
/** 整包拒绝 DEAD(PROTOCOL)flight-state.md §3.1/§4),不重试,交人工确认。 */
/** 整包拒绝(如运营日冲突、声明条数不符):整包不落地、不重试,交人工确认。 */
data class DeadProtocol(val reason: String, val flags: Set<SnapshotFlag> = emptySet()) : ApplyResult
}
/** 归属日冲突(flight-state.md §2.1OPERATION_DAY 一经确定不可变)= 串日/错发/污染,整包拒绝。 */
/** 同一航班的运营日对不上(一个航班只能属于一个运营日):可能是串日错发,整包拒绝。 */
class ProtocolViolation(message: String) : RuntimeException(message)
/**
* SCHD 日计划主链路(docs/flight-state.md §3.1/§4):对 DNLD RESP 统一适用
* 同一事务内:锁 → 归属日校验 → 逐条合并写完整当前态 → 登记事件与回填待办;
* 整包校验失败或运营日冲突整包不落地。
* 处理 SCHD 日计划报文(DNLD RESP 走同一条路):把报文里的航班记录合并进航班当前态
*
* 顺序是:整包校验 → 加锁 → 核对每条航班的运营日 → 逐条合并写入 → 登记待发事件 →
* 写终态和回填待办。除了校验,后面所有步骤都在同一个事务里,任何一步失败整包回滚,
* 不会留下写了一半的数据。
*
* 报文里没提到的航班不会被删除——日计划只负责写它带来的那部分。
*/
@Singleton
class ScheduleProcessor(
@@ -75,7 +82,7 @@ class ScheduleProcessor(
return ApplyResult.ReplaySkipped
}
// 整包校验(§4 步骤 2 / design.md §4.1):任一失败整包不落地 → DEAD(PROTOCOL)
// 整包校验:任何一项不通过就整包拒绝,不写半份数据
val validation = FlightStateEngine.validateMessage(
recsDeclared = body.recsDeclared,
records = body.records,
@@ -88,18 +95,18 @@ class ScheduleProcessor(
val ok = validation as SnapshotValidation.Ok
if (ok.perRecordDay.isEmpty()) {
// 空快照:合法但无写入,仍算成功终态
// 报文合法但没有记录:不需要写数据,照样算处理成功
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted = 0, setOf(SnapshotFlag.EMPTY), started)
return ApplyResult.Succeeded
}
val flags = linkedSetOf<SnapshotFlag>()
return try {
// 同一事务flight-state.md §4):锁 → 归属校验 → 逐条合并写 → 事件/待办预登记
// 以下都在同一事务里:加锁 → 校验运营日 → 逐条合并写 → 登记事件与回填待办
val upserted = txManager.inTransaction {
lock.lock()
// 归属校验(§2.1):OPERATION_DAY 不可变,批量点查避免逐航班往返
// 一次批量查出这些航班现有的运营日,逐个比对(避免逐条查询)
val mains = flightState.findMainRows(ok.perRecordDay.keys)
ok.perRecordDay.forEach { (flid, day) ->
val existing = mains[flid] ?: return@forEach
@@ -132,7 +139,7 @@ class ScheduleProcessor(
events += snapshotEvents(next)
}
if (events.isNotEmpty()) msgEvents.insertAll(events)
// 终态与回填意图同一事务(message-lifecycle §2/§4):业务写入、事件、终态、回填意图同提交同回滚
// 终态与回填待办跟业务数据同事务提交:要么全成,要么全回滚
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED)
written
}
@@ -164,8 +171,10 @@ class ScheduleProcessor(
)
}
/** 留痕 SCHD_SNAP_LOGdesign.md §6.2):事务外追加,失败只记 error 不阻塞;
* scope 为报文各记录归属运营日的最小/最大(单日快照两者相等)。 */
/**
* 写一条处理留痕(仅供排查和统计,不参与业务判断)。放在事务外做,
* 写失败也只记日志,不会连累处理结果。scope 是这份报文覆盖的运营日范围。
*/
private fun logSnapshot(
head: ProcState,
body: ScheduleBody,