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
@@ -301,25 +301,44 @@ class BackfillServiceTest {
assertNotNull(proc.find(911L)!!.backfillNextAt)
}
/**
* 门禁裁决 D1:暂时性故障**不按次数放弃**。即使超过 `backfill-max-attempts` 这个
* 告警阈值,也继续退避重试——一次小时级的共享库故障不该把待回填行成批判死。
*/
@Test
fun `transient failures keep retrying until the attempt cap and stay recoverable`() {
fun `transient failures keep retrying past the warning threshold`() {
props.pipeline.backfillMaxAttempts = 3
val proc = StubProcState()
val mailbox = FakeMailbox(fail = true)
succeeded(proc, 912L)
val svc = service(proc, mailbox)
svc.attempt(912L) // 1 次:暂时性故障,只退避
svc.attempt(912L) // 2 次
assertNull(proc.find(912L)!!.backfillAbandonedAt)
repeat(5) { svc.attempt(912L) }
svc.attempt(912L) // 3 次:达到上限 → 停止自动重试
val row = proc.find(912L)!!
assertEquals(BackfillService.ABANDON_MAX_ATTEMPTS, row.backfillAbandonedReason)
assertEquals(5, row.backfillAttempts)
assertNull(row.backfillAbandonedAt) // 次数不是放弃判据
assertNull(row.backfillAt)
assertNotNull(row.backfillNextAt) // 仍在退避重试
}
assertTrue(svc.reopen(912L)) // 人工恢复入口存在且有效
assertNull(proc.find(912L)!!.backfillAbandonedAt)
/** 到 `R` 仍未打标(overdue)时,暂时性故障才停止自动重试并进放弃清单。 */
@Test
fun `overdue transient failure is abandoned with the deadline reason and stays recoverable`() {
val proc = StubProcState()
val mailbox = FakeMailbox(fail = true)
succeeded(proc, 913L)
val svc = service(proc, mailbox)
svc.attempt(913L, overdue = true)
val row = proc.find(913L)!!
assertEquals(BackfillService.ABANDON_TRANSIENT_DEADLINE, row.backfillAbandonedReason)
assertNotNull(row.backfillAbandonedAt)
assertNull(row.backfillAt) // 放弃 ≠ 标记已确认
assertTrue(svc.reopen(913L)) // 人工恢复入口存在且有效
assertNull(proc.find(913L)!!.backfillAbandonedAt)
}
@Test
@@ -346,18 +365,20 @@ class BackfillServiceTest {
}
/**
* 语义固定(G4):`RECEIVED_AT` 为 NULL 时"超期"分支不成立,`R` 兜底**不生效**,
* 该行只能靠退避重试。把这条钉住,避免以后误以为 `R` 一定能兜底
* 语义固定(门禁裁决 D6):超期判据是**本地入队时间** `ENQUEUED_AT`,与 `RECEIVED_AT` 无关。
* 上游没给接收时间(NULL)不再让 `R` 兜底失效——这正是引入 `ENQUEUED_AT` 要消除的窗口
*/
@Test
fun `null received time disables the overdue shortcut so only backoff applies`() {
fun `null received time no longer disables the overdue shortcut`() {
val proc = StubProcState()
proc.insertIfAbsent(921L, null) // 上游未提供接收时间
val old = t0.minus(props.pipeline.overdueBackfill).minusSeconds(60)
proc.insertIfAbsent(921L, null, enqueuedAt = old) // 上游未提供接收时间
proc.markTerminal(921L, ProcStatus.SUCCEEDED, now = t0)
proc.recordBackfillFailure(921L, "mysql-down", attempts = 1, nextAttemptAt = t0.plusSeconds(3600), now = t0)
val due = proc.findBackfillDue(now = t0, overdueBefore = t0.plusSeconds(10_000), limit = 10)
val due = proc.findBackfillDue(now = t0, overdueBefore = t0.minus(props.pipeline.overdueBackfill), limit = 10)
assertTrue(due.isEmpty()) // 超期分支无效 + 退避未到期
assertEquals(listOf(921L), due.map { it.msgId }) // 退避未到期,但入队时间已超 R → 仍被扫描
assertTrue(due.single().overdue)
}
}