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:
@@ -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
|
||||
|
||||
/**
|
||||
* FLOP(docs/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(
|
||||
}
|
||||
|
||||
/**
|
||||
* FDEL(docs/flight-state.md §3.3 删除):ACTIVE → 置 DELETED、推进版本、明细保留、
|
||||
* 与删除同事务登记 tombstone(§5);已 DELETED / 不存在 → 幂等成功,不推进版本、不重复发布。
|
||||
* 处理 FDEL 删除报文:把在用的航班标记为 DELETED,版本号加一,明细数据保留,
|
||||
* 并且只发布一次删除事件。
|
||||
*
|
||||
* 已经删除过、或航班本来就不存在时,算处理成功但不再动版本、不重复发事件。
|
||||
*/
|
||||
@Singleton
|
||||
class FdelProcessor(
|
||||
@@ -107,9 +111,11 @@ class FdelProcessor(
|
||||
}
|
||||
|
||||
/**
|
||||
* ADFT(docs/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):任何意外异常归于本条 head,FAILED(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.1:OPERATION_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_LOG(design.md §6.2):事务外追加,失败只记 error 不阻塞;
|
||||
* scope 为报文各记录归属运营日的最小/最大(单日快照两者相等)。 */
|
||||
/**
|
||||
* 写一条处理留痕(仅供排查和统计,不参与业务判断)。放在事务外做,
|
||||
* 写失败也只记日志,不会连累处理结果。scope 是这份报文覆盖的运营日范围。
|
||||
*/
|
||||
private fun logSnapshot(
|
||||
head: ProcState,
|
||||
body: ScheduleBody,
|
||||
|
||||
Reference in New Issue
Block a user