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
@@ -14,11 +14,14 @@ import java.time.Duration
import java.time.Instant
/**
* 信箱生命周期观测(docs/message-lifecycle.md §5.3 验收 / user-stories.md OPS-2
* 输出剩余积压、最老未处理信龄、未回填终态条数与水位滞后,供积压消化期间持续观察。
* 在 /health 里输出收报与回填的当前情况,用来观察积压消化得怎么样
* - `backlog`:还没处理完的消息条数;
* - `oldestUnprocessedSeconds`:最老一条未处理消息从收到到现在过了多久;
* - `unmarkedTerminal`:已经处理完、但还没把标记写回信箱的条数(回填跟不上时这个数会涨);
* - `watermark` / `watermarkLag`:收报读到哪个 ID 了、落后信箱最新 ID 多少。
*
* 端口缺省(未接通共享信箱或自有 PG)时报告未绑定不判 DOWN——可用性由各依赖自身的
* 健康指示器承担,本指示器只反映生命周期状态;端口查询失败判 DOWN。
* 相关工作没接上时(比如没连共享信箱)只提示"未绑定"不判 DOWN——依赖本身是否可用
* 由各自的健康指示器回答,这里只报告业务状态。只有查询出错才判 DOWN。
*/
@Singleton
class InboxLifecycleHealthIndicator(
@@ -39,6 +42,7 @@ class InboxLifecycleHealthIndicator(
)
}
/** 组装上面那几个指标;单独抽出来是为了能在测试里直接调用。 */
internal fun lifecycleHealth(
procState: ProcStateRepository?,
cursor: InboxCursorRepository?,
@@ -30,30 +30,36 @@ interface PipelineLockRepository {
}
/**
* PROC_STATE:每消息一行(design.md §2.1);SUCCEEDED 终态兼作日计划重放判定
* 回填事实(BACKFILL_AT/NEXT_AT/ATTEMPTS/ERROR)与处理事实同行,取代独立的回填待办表。
* PROC_STATE 表的读写入口:一条消息一行的处理记录
*
* 状态分两类:PENDING / FAILED 是还在处理中,SUCCEEDED / SKIPPED / DEAD 是终态。
* 只有终态才允许往信箱回填处理标记。回填进度就记在同一行上,不需要另一张待办表。
*/
interface ProcStateRepository {
/**
* 入队:MSG_ID 主键幂等(重复扫描与 compat 入口并发都不会重复建行)
* @param receivedAt 信箱 DATE_RECEIVED,用于 §5.2 超期判据与信龄观测。
* @return true = 本次实际新建
* 入队:把信箱里发现的消息登记成 PENDING
*
* 幂等:同一个消息 ID 重复登记既不报错、也不会建第二行(收报重扫和兼容入口并发调用都安全)。
*
* @param receivedAt 信箱里的接收时间,用于判断超期未回填和统计最老信龄
* @return true 表示这次真的新建了一行
*/
fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean
fun find(msgId: Long): ProcState?
/** 这条消息是否已经处理成功过(判断重放用,避免把同一份快照重复应用)。 */
fun findSuccessTerminal(msgId: Long): Boolean
/** 严格 FIFO 队头(最小未完成 MSG_ID。 */
/** 当前队头:还没处理完的消息里 ID 最小的那条。 */
fun headUnfinished(): ProcState?
/** identity 首次绑定;返回 false = 另一条消息已持有该键。 */
/** 给消息绑定业务身份;返回 false 表示这个身份已经被另一条消息占了(业务重复)。 */
fun tryBindIdentity(msgId: Long, identityKey: String): Boolean
fun ownerOfIdentity(identityKey: String): Long?
/** 非终态迁移(PENDING / FAILED 及退避),不触碰回填列。 */
/** 改写 PENDING / FAILED 这类非终态(含重试次数与下次重试时间),不动回填字段。 */
fun update(
msgId: Long,
state: ProcStatus,
@@ -64,8 +70,11 @@ interface ProcStateRepository {
)
/**
* 终态 + 回填意图同一条 UPDATEmessage-lifecycle §2/§4),由处理器在自己的业务
* 事务内调用:航班变更、事件、终态、回填意图同提交同回滚。
* 终态,同时记下"这条消息还欠一次回填"。
*
* 两件事是同一条 UPDATE,必须由处理器在自己的业务事务里调用:业务改动、待发事件、
* 终态和回填意图一起提交或一起回滚。这样即使进程恰好在这里挂掉,也不会留下
* "业务已经改了、却没人记得回填信箱"的记录。
*/
fun markTerminal(
msgId: Long,
@@ -76,29 +85,36 @@ interface ProcStateRepository {
now: Instant = Instant.now(),
)
/** 信箱行已确认持有处理标记。 */
/** 回填成功:记下完成时间,清掉待办。 */
fun markBackfilled(msgId: Long, now: Instant = Instant.now())
/** 回填失败:次数 +1、按退避推后、留错误;终态不得回改(§11。 */
/** 回填失败:次数 +1、按退避推后、记下原因。处理终态不受影响,不会被改回去。 */
fun recordBackfillFailure(msgId: Long, error: String?, attempts: Int, nextAttemptAt: Instant, now: Instant)
/**
* 待回填待办(message-lifecycle §3/§5.2):终态 + 未确认标记,
* 且(已到期 或 接收时间已达超期期限 overdueBefore)。
* 找出现在该回填的记录:已经到终态、还没确认回填,并且退避时间已到。
*
* [overdueBefore] 是兜底:消息接收时间早于它的(已经等了很久)无视退避直接补写。
* 没有这条兜底,退避一直失败的话这些行就永远打不上标记,库方也没法清理信箱。
*/
fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue>
/** 显式重放入口:仅把给定 errorClass 集合中的行从 FAILED/DEAD 回 PENDINGATTEMPTS=0。 */
/** 人工重放:把指定错误类别的 FAILED / DEAD 记录改回 PENDING,重试次数清零。 */
fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int
/** OPS-2 观测口径(message-lifecycle §5.3 验收):积压、最老信龄锚点、未回填终态。 */
/** 积压观测:还没处理完的条数、最老一条的接收时间、处理完但还没回填的条数。 */
fun backlog(): Backlog
}
/** 某条待回填记录(扫描输入)。 */
/** 扫描到的待回填记录。 */
data class BackfillDue(val msgId: Long, val attempts: Int)
/** 处理侧积压快照。 */
/**
* 处理侧积压快照。
* @param unfinished 还没处理完的消息条数
* @param oldestReceivedAt 其中最早一条的接收时间(据此算信龄)
* @param unmarkedTerminal 已经处理完、但还没把标记写回信箱的条数
*/
data class Backlog(val unfinished: Int, val oldestReceivedAt: Instant?, val unmarkedTerminal: Int)
/** MSG_EVENT outboxflight-state.md §5)。KAFKA_SCHD 合并同 FLID 未发事件按最新 STATE_VERSION 输出。 */
@@ -208,13 +224,20 @@ interface ReqTrackRepository {
}
/**
* 消费水位 Wmessage-lifecycle §5.1):单行游标,只随新 ID 成功入队推进、遇空洞即停,
* 与入队在同一 PG 事务提交——中断后 W 未前进,重扫即补建(§4 第一行)。
* 收报进度(水位 W):记下"信箱里到哪个 ID 为止已经全部读进自有库",全表只有一行。
*
* `holeSince` 记录 W+1 处空洞首次被观测到的时刻:超过最大提交时延(Q2 承诺)即判定为
* 永久空洞并放行,否则水位会永久停摆于一次自增回滚留下的空位,后续 ID 再无入队机会。
* 光记一个数字不够,还要记住缺口是什么时候出现的:如果 W 后面缺了一个 ID,就先停在
* 缺口前面等(可能是上游还没提交完,随时会补上)。等的时间超过最大提交时延,就改判为
* 永久缺失、跳过去继续推进——否则一次自增回滚留下的空位就能让水位永远卡住,
* 它后面的消息再也进不了队。
*
* 水位推进与入队在同一个 PG 事务里提交:中途崩溃时水位没动,重启后重扫一遍即可补齐。
*/
interface InboxCursorRepository {
/**
* @param committedUpTo 水位 W
* @param holeSince W 后面那个缺口最早被发现的时刻;当前没有缺口时为 null
*/
data class Cursor(val committedUpTo: Long = 0L, val holeSince: Instant? = null)
fun load(): Cursor
@@ -223,24 +246,36 @@ interface InboxCursorRepository {
}
/**
* 共享 MySQL 信箱 CMINMSGS 访问(他人系统库,本系统不建表,只做 DML)。
* 三个事实互不替代(message-lifecycle §5.1/§11):**发现**按 ID 区间读、**水位**只表示
* 读取进度、**处理标记**只用于回填与库方清除——标记不得作为扫描谓词。
* 共享 MySQL 信箱 CMINMSGS 的读写入口。这个库是别人的,本系统只做约定的读写,
* 不建表、不改结构。
*
* 这里把三件事分得很清楚,谁也不代替谁:
* - **发现**:按 ID 区间读有哪些新消息([readRange]);
* - **进度**:读到哪儿了记在自有库的水位里(见 [InboxCursorRepository]);
* - **标记**:处理完了把"已处理"写回信箱([markProcessedIfUnmarked])。
*
* 特别是发现,不能拿"有没有处理标记"当筛选条件:处理完但还没回填的行,以及永远不会
* 回填的死信,会一直占着每一批的名额,攒够一批之后新消息就再也读不到了。
*/
interface CminmsgInboxRepository {
/** 兼容入口往信箱写一条报文,返回新的信箱 ID。 */
fun insertRaw(rawXml: String): Long
/** 读某条消息的原文;返回 null 表示读不到(行已被清除,或原文本身为空)。 */
fun rawOf(msgId: Long): String?
/** 按 ID 区间升序有界读取(`ID > fromExclusive`),不以处理标记为谓词。 */
/** 按 ID 升序读一批 `ID > fromExclusive` 的行,不带别的过滤条件。 */
fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow>
/** 信箱当前最大 ID空表 null);仅用于观测水位滞后。 */
/** 信箱当前最大 ID空表返回 null;只用来观测收报落后了多少。 */
fun maxId(): Long?
/** 只把空标写为已处理(§11 单调);返回 true = 本次实际写入,重复执行无副作用。 */
/**
* 把处理标记写回信箱,并且**只写还是空标记的行**:库里已有值时不覆盖、不回退,
* 重复调用没有副作用。返回 true 表示这次真的写进去了。
*/
fun markProcessedIfUnmarked(msgId: Long, value: String): Boolean
}
/** 信箱行读取结果(发现阶段只需要身份与接收时间原文按需再取。 */
/** 信箱读到的一行:ID 加接收时间原文按需再取,扫描时不读大字段。 */
data class MailboxRow(val msgId: Long, val receivedAt: Instant?)
@@ -8,11 +8,13 @@ import jakarta.inject.Singleton
import javax.sql.DataSource
/**
* 共享 MySQL CMINMSGS 信箱适配(仅 DML,不建表;message-lifecycle §5.1/§6/§11
* 列名与 legacy `entity/Cminmsg.java` 一致
* 共享 MySQL CMINMSGS 信箱的 JDBC 实现。列名沿用 legacy 的 `CMINMSGS_*`
* 只做增删改查,不建表、不改结构——这个库属于别的系统
*
* 发现按 ID 区间读(`ID > ?`),**不以 `DATE_PROCESSED` 为扫描谓词**:已入队但尚未
* 回填的行否则会永久占据批次,正是 §5.1 与 US-01 条目 3 要求排除的场景。
* 两处刻意为之:
* - 取新消息只看 ID`ID > ?`),不看 `DATE_PROCESSED`。用处理标记当条件的话,
* 处理完但还没回填的行会长期占住每批名额,死信攒够一批就再也发现不了新消息。
* - 写回处理标记带 `DATE_PROCESSED IS NULL` 条件,一行只会被标记一次,不会覆盖已有值。
*/
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@@ -62,9 +64,9 @@ class JdbcCminmsgInboxRepository(
}
/**
* §11 单调:`DATE_PROCESSED IS NULL` 守卫保证只把空标写为已处理,已有值不回撤、
* 不覆盖;影响 0 行 = 已被其他路径标记,调用方按幂等成功处理。
* 写入值(DATE_PROCESSED 时间语义与 STATUS 值集)以库方契约为准(Q7)
* 只更新还是空标记的行,所以重复调用不会覆盖库里已有的值;
* 影响 0 行说明已经被标记过了,调用方按"成功"处理即可
* 具体写什么值、时间怎么解释,以与库方约定为准
*/
override fun markProcessedIfUnmarked(msgId: Long, value: String): Boolean =
ds.update(
@@ -70,7 +70,7 @@ class JdbcPipelineLockRepository(
class JdbcProcStateRepository(
private val ds: DataSource,
) : ProcStateRepository {
/** MSG_ID 主键幂等入队:重复扫描与 compat 入口并发都不重复建行(§5.1)。 */
/** 入队(幂等):主键冲突时什么都不做,所以重复扫描和兼容入口并发调用都安全。 */
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean =
ds.update(
"INSERT INTO proc_state (msg_id, state, received_at, updated_at) VALUES (?, 'PENDING', ?, ?) " +
@@ -144,7 +144,7 @@ class JdbcProcStateRepository(
)
}
/** 终态与回填意图同一条 UPDATE业务事务调用即原子提交(message-lifecycle §2/§4。 */
/** 终态并同时登记回填待办,一条 SQL 搞定;由处理器在业务事务调用。 */
override fun markTerminal(
msgId: Long,
state: ProcStatus,
@@ -199,8 +199,8 @@ class JdbcProcStateRepository(
}
/**
* 待回填:终态 + 未确认标记,且已到期或已达 §5.2 超期期限(R 覆盖退避,保证
* 有限时间内必然补写,否则库方清除的前提"边界内无未标记行"无法成立)
* 到了该回填的时候:终态 + 还没有标记 + (退避到期 或 收信时间已经很久)。
* 后面这个"很久"是兜底,保证标记最终一定会补上,库方才能按标记清理信箱
*/
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue> =
ds.query(
@@ -231,7 +231,7 @@ class JdbcProcStateRepository(
)
}
/** OPS-2message-lifecycle §5.3):积压条数、最老未处理接收时刻、未回填终态条数。 */
/** 积压观测:未处理条数、最老一条的接收时间、处理完但未回填条数。 */
override fun backlog(): Backlog =
ds.queryOne(
"""
@@ -273,7 +273,7 @@ class JdbcProcStateRepository(
}
}
/** 消费水位单行游标(message-lifecycle §5.1);与入队同事务写入。 */
/** 收报水位游标(单行)的 JDBC 实现;水位推进与入队在同一个事务里提交。 */
@Singleton
@Requires(property = "datasources.default.enabled", value = "true")
@Requires(missingProperty = "msgx.stubs")
@@ -7,16 +7,20 @@ import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton
/**
* ProcState 侧统一失败迁移(U08/U10):处理/快照路径共用——
* attempts+1 后若 exhausted → DEAD(EXHAUSTED)(终态 + 回填意图,errorClass 规范化,原因保留在 lastError);
* 否则 FAILED + attempts + nextAttemptAt(退避)(可重放)。任何“失败”都不得在无退避下直接终态化。
* 处理失败时统一改状态,主泵和快照路径共用
*
* 规则很简单:失败次数 +1 之后
* - 还没到上限:改成 FAILED,并按退避表算好下次重试时间(这种记录以后可以重放);
* - 已经到上限:改成 DEAD(EXHAUSTED) 终态,等人工复核,不再自动重试。
*
* 失败一定先退避、再重试,不允许一次失败就直接判死。
*/
@Singleton
class ProcFailure(
private val procState: ProcStateRepository,
val scheduler: FailureScheduler,
) {
/** @return 是否已终态DEAD 是终态FAILED 仍可重放/重试) */
/** @return 是否已经落到终态DEAD 是终态FAILED 还会再试 */
fun fail(head: ProcState, ec: ErrorClass, reason: String): Boolean {
val attempts = head.attempts + 1
if (scheduler.exhausted(attempts)) {
@@ -33,8 +33,10 @@ import java.time.ZoneId
import java.util.concurrent.atomic.AtomicLong
/**
* stub 仓储(msgx.stubs=true 时装配)——内存实现全部契约,测试与无库环境用。
* 事务管理器直接执行 block(无嵌套语义);锁为 no-op(单线程测试前提)。
* 内存版仓储,`msgx.stubs=true` 时装配,供测试和无外部依赖的环境使用。
* 行为对齐 JDBC 实现(例如入队幂等、写终态时同时登记回填待办)。
*
* 事务管理器直接执行代码块、不做回滚;锁是空操作——都建立在单线程测试的前提上。
*/
@Singleton
@Requires(property = "msgx.stubs", value = "true")
@@ -50,6 +52,7 @@ class StubPipelineLock : PipelineLockRepository {
@Singleton
@Requires(property = "msgx.stubs", value = "true")
/** 内存版 PROC_STATE:入队幂等,写终态时一并写下回填待办。 */
class StubProcState : ProcStateRepository {
val rows = linkedMapOf<Long, ProcState>()
val bound = linkedMapOf<String, Long>()
@@ -100,7 +103,7 @@ class StubProcState : ProcStateRepository {
)
}
/** 终态 + 回填意图同一动作(模拟单条 UPDATE 的原子性。 */
/** 终态并按同一次操作登记回填待办,模拟 JDBC 实现里"一条 SQL 写两件事"的原子性。 */
override fun markTerminal(
msgId: Long,
state: ProcStatus,
@@ -366,6 +369,7 @@ class StubReqTrack : ReqTrackRepository {
@Singleton
@Requires(property = "msgx.stubs", value = "true")
/** 内存版共享信箱:可以模拟上游写入、库方清除,并记录哪些行被打上了处理标记。 */
class StubInbox : CminmsgInboxRepository {
val raws = linkedMapOf<Long, String>()
private val received = linkedMapOf<Long, Instant>()
@@ -390,7 +394,7 @@ class StubInbox : CminmsgInboxRepository {
override fun maxId(): Long? = raws.keys.maxOrNull()
/** §11 单调:只把空标写为已处理;已有值不回撤、不覆盖。 */
/** 只写还没有标记的行;已经标记过就返回 false,不覆盖已有值。 */
override fun markProcessedIfUnmarked(msgId: Long, value: String): Boolean {
if (!raws.containsKey(msgId) || marks.containsKey(msgId)) return false
marks[msgId] = value
@@ -401,16 +405,16 @@ class StubInbox : CminmsgInboxRepository {
fun isMarked(msgId: Long): Boolean = marks.containsKey(msgId)
/** 测试辅助:模拟上游外部写入共享信箱(不经本系统)。 */
/** 测试辅助:模拟上游直接往信箱写报文(不经本系统)。 */
fun simulateExternalWrite(rawXml: String): Long = insertRaw(rawXml)
/** 测试辅助:模拟库方清除(原文不可读),用于 §9 原文缺失与空洞场景。 */
/** 测试辅助:模拟库方清除这条行,用来构造"原文读不到"和 ID 缺口。 */
fun removeRow(msgId: Long) {
raws.remove(msgId); received.remove(msgId); marks.remove(msgId)
}
}
/** 消费水位游标(stub。 */
/** 内存版收报水位游标。 */
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubInboxCursor : InboxCursorRepository {
@@ -427,6 +431,6 @@ class StubInboxCursor : InboxCursorRepository {
}
}
/** 终态判定(§2 状态总纲)。 */
/** 判断是不是终态:处理已经结束、不会再重试的状态。 */
private fun ProcStatus.isTerminal(): Boolean =
this == ProcStatus.SUCCEEDED || this == ProcStatus.SKIPPED || this == ProcStatus.DEAD