fix(processing): 落地 D1/D5/D6 三项裁决,删除 head-deadline 参数

D5(终态判据只保留尝试上限):
- Pump.tick 内联 attempts 判定,删除 head-deadline 相关的毒丸分支与滞留告警代码
- 删除配置项 head-deadline(PipelineProps / application.yml)与 PumpDeadlineTest
- PROCESSING_STARTED_AT 变为只写,注释如实说明当前无判据消费它

D1(回填放弃判据改为时间):
- 暂时性故障在 R 之前只退避重试,不再按尝试次数放弃;到 R 才放弃并记 TRANSIENT_DEADLINE
- backfill-max-attempts 降级为单行重试的告警阈值

D6(超期判据改用本地入队时间):
- 新增 V6 迁移:PROC_STATE 加 ENQUEUED_AT(回填存量后置为非空 + 默认)
- findBackfillDue 的谓词与 overdue 标记改比较 enqueued_at,不再用库方时钟的 received_at
- BackfillDue 增加 overdue;收报与兼容入口显式写入本地入队时间

文档同步:
- 清理 4 处 message-lifecycle.md 章节号死链(Pump/InboxService/PipelineProps/application.yml)
- 关闭 G-HEAD-DEADLINE、G-BACKFILL-ABANDON-BYTIME、G-ENQUEUED-AT 三条缺口登记
- reference/user-stories/README 与实现对齐

验证:./gradlew test ⇒ 122 tests, 0 failures, 1 skipped

Refs: ACM2-45
This commit is contained in:
windyboy
2026-09-11 20:44:30 +08:00
parent 6eade95a97
commit 817236ca26
21 changed files with 217 additions and 152 deletions
@@ -45,10 +45,11 @@ interface ProcStateRepository {
*
* 幂等:同一个消息 ID 重复登记既不报错、也不会建第二行(收报重扫和兼容入口并发调用都安全)。
*
* @param receivedAt 信箱里的接收时间,用于判断超期未回填和统计最老信龄
* @param receivedAt 信箱里的接收时间,来自库方时钟、可能为 NULL;只作对账与展示
* @param enqueuedAt 本地入队时间;不传时由实现用自己注入的时钟填(列非空)
* @return true 表示这次真的新建了一行
*/
fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean
fun insertIfAbsent(msgId: Long, receivedAt: Instant?, enqueuedAt: Instant? = null): Boolean
fun find(msgId: Long): ProcState?
@@ -99,11 +100,13 @@ interface ProcStateRepository {
fun recordBackfillFailure(msgId: Long, error: String?, attempts: Int, nextAttemptAt: Instant, now: Instant)
/**
* 放弃回填:判定该行不必再自动尝试(信箱行不存在,或达到尝试上限)。
* 放弃回填:判定该行不必再自动尝试(信箱行不存在,或暂时性故障持续到 `R` 仍未打标)。
*
* **abandoned ≠ 标记已确认**`BACKFILL_AT` 仍为空,因此**不**满足"边界内全部行已打标"的
* 清除前提;放弃只是停止自动重试并把事实留痕,供人工对账。
*
* 放弃判据是**时间**(`R` 超期)而不是尝试次数:一次小时级的共享库故障不该把待回填行成批判死。
*
* @return false 表示该行不存在
*/
fun markBackfillAbandoned(msgId: Long, reason: String, now: Instant): Boolean
@@ -117,8 +120,9 @@ interface ProcStateRepository {
/**
* 找出现在该回填的记录:已经到终态、还没确认回填,并且退避时间已到。
*
* [overdueBefore] 是兜底:消息接收时间早于它的(已经等了很久)无视退避直接补写。
* [overdueBefore] 是兜底:入队时间早于它的(已经等了超过 `R`)无视退避直接补写。
* 没有这条兜底,退避一直失败的话这些行就永远打不上标记,库方也没法清理信箱。
* 判据比较的是**本地** `ENQUEUED_AT`,不是库方时钟的 `RECEIVED_AT``PRE-4`)。
*/
fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue>
@@ -132,8 +136,12 @@ interface ProcStateRepository {
fun hasAny(): Boolean
}
/** 扫描到的待回填记录。 */
data class BackfillDue(val msgId: Long, val attempts: Int)
/**
* 扫描到的待回填记录。
* @param overdue 入队时间早于 `NOW − R`:本轮已经进入强补写窗口
* (调用方据此决定"暂时性故障是否到放弃期限")
*/
data class BackfillDue(val msgId: Long, val attempts: Int, val overdue: Boolean = false)
/**
* 处理侧积压快照。
@@ -76,16 +76,20 @@ class JdbcProcStateRepository(
private val clock: Clock,
) : ProcStateRepository {
/** 入队(幂等):主键冲突时什么都不做,所以重复扫描和兼容入口并发调用都安全。 */
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean =
ds.update(
"INSERT INTO proc_state (msg_id, state, received_at, updated_at) VALUES (?, 'PENDING', ?, ?) " +
"ON CONFLICT (msg_id) DO NOTHING",
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?, enqueuedAt: Instant?): Boolean {
val now = clock.instant()
return ds.update(
"INSERT INTO proc_state (msg_id, state, received_at, enqueued_at, updated_at) " +
"VALUES (?, 'PENDING', ?, ?, ?) ON CONFLICT (msg_id) DO NOTHING",
{ ps ->
ps.setLong(1, msgId)
ps.setTimestamp(2, receivedAt?.toSqlTimestamp())
ps.setTimestamp(3, clock.instant().toSqlTimestamp())
// 入队时间必须由本系统时钟给出:它同时是超期判据 R 的比较对象(PRE-4)。
ps.setTimestamp(3, (enqueuedAt ?: now).toSqlTimestamp())
ps.setTimestamp(4, now.toSqlTimestamp())
},
) == 1
}
override fun find(msgId: Long): ProcState? =
ds.queryOne("$SELECT_PROC WHERE msg_id = ?", { ps -> ps.setLong(1, msgId) }, ::mapProcState)
@@ -254,22 +258,29 @@ class JdbcProcStateRepository(
* 排序用**公平轮转**:先按已尝试次数升序,再按 msg_id。若只按 msg_id 升序,
* 最旧的一批永久失败行会持续占满批次,后面的记录永远轮不到(全局回填饥饿)。
*/
/**
* 超期判据用 `enqueued_at`(本地时钟、非空),不用 `received_at`
* 后者来自库方时钟,前偏会让 `NOW − R` 提前成立(`PRE-4`)。
*/
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue> =
ds.query(
"""
SELECT msg_id, backfill_attempts FROM proc_state
SELECT msg_id, backfill_attempts, (enqueued_at < ?) AS overdue FROM proc_state
WHERE backfill_at IS NULL
AND backfill_abandoned_at IS NULL
AND state IN ('SUCCEEDED', 'SKIPPED', 'DEAD')
AND (backfill_next_at IS NULL OR backfill_next_at <= ? OR (received_at IS NOT NULL AND received_at < ?))
AND (backfill_next_at IS NULL OR backfill_next_at <= ? OR enqueued_at < ?)
ORDER BY backfill_attempts ASC, msg_id ASC LIMIT ?
""".trimIndent(),
{ ps ->
ps.setTimestamp(1, now.toSqlTimestamp())
ps.setTimestamp(2, overdueBefore.toSqlTimestamp())
ps.setInt(3, limit)
ps.setTimestamp(1, overdueBefore.toSqlTimestamp())
ps.setTimestamp(2, now.toSqlTimestamp())
ps.setTimestamp(3, overdueBefore.toSqlTimestamp())
ps.setInt(4, limit)
},
) { rs -> BackfillDue(rs.getLong("msg_id"), rs.getInt("backfill_attempts")) }
) { rs ->
BackfillDue(rs.getLong("msg_id"), rs.getInt("backfill_attempts"), rs.getBoolean("overdue"))
}
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
if (errorClasses.isEmpty()) return 0
@@ -321,6 +332,7 @@ class JdbcProcStateRepository(
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
lastError = rs.getString("last_error"),
receivedAt = rs.getInstant("received_at"),
enqueuedAt = rs.getInstant("enqueued_at"),
backfillAt = rs.getInstant("backfill_at"),
backfillNextAt = rs.getInstant("backfill_next_at"),
backfillAttempts = rs.getInt("backfill_attempts"),
@@ -334,7 +346,7 @@ class JdbcProcStateRepository(
private companion object {
const val SELECT_PROC =
"SELECT msg_id, state, identity_key, attempts, next_attempt_at, error_class, last_error, " +
"received_at, backfill_at, backfill_next_at, backfill_attempts, backfill_error, " +
"received_at, enqueued_at, backfill_at, backfill_next_at, backfill_attempts, backfill_error, " +
"backfill_abandoned_at, backfill_abandoned_reason, processing_started_at, updated_at FROM proc_state"
}
}
@@ -63,9 +63,17 @@ class StubProcState : ProcStateRepository {
rows.clear(); bound.clear()
}
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean {
/**
* 内存版没有独立时钟,入队时间默认沿用 `receivedAt`(测试可显式传入以区分两者);
* 生产实现由注入的 Clock 填,`ENQUEUED_AT` 列非空。
*/
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?, enqueuedAt: Instant?): Boolean {
if (rows.containsKey(msgId)) return false
rows[msgId] = ProcState(msgId, ProcStatus.PENDING, receivedAt = receivedAt)
rows[msgId] = ProcState(
msgId, ProcStatus.PENDING,
receivedAt = receivedAt,
enqueuedAt = enqueuedAt ?: receivedAt,
)
return true
}
@@ -182,14 +190,13 @@ class StubProcState : ProcStateRepository {
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue> =
rows.values
.filter { it.state.isTerminal() && it.backfillAt == null && it.backfillAbandonedAt == null }
.filter {
it.backfillNextAt == null || it.backfillNextAt <= now ||
(it.receivedAt != null && it.receivedAt < overdueBefore)
}
// 超期判据是本地入队时间,与 JDBC 实现保持一致(不用库方时钟的 receivedAt)。
.map { it to (it.enqueuedAt?.let { e -> e < overdueBefore } ?: false) }
.filter { (row, overdue) -> overdue || row.backfillNextAt == null || row.backfillNextAt <= now }
// 公平轮转:先按已尝试次数,再按 msg_id。只按 msg_id 会让最旧的一批永久失败行占满批次。
.sortedWith(compareBy({ it.backfillAttempts }, { it.msgId }))
.sortedWith(compareBy({ it.first.backfillAttempts }, { it.first.msgId }))
.take(limit)
.map { BackfillDue(it.msgId, it.backfillAttempts) }
.map { (row, overdue) -> BackfillDue(row.msgId, row.backfillAttempts, overdue) }
override fun hasAny(): Boolean = rows.isNotEmpty()