From 161914eeb8df26764268b33e3591f127f7cc7eb2 Mon Sep 17 00:00:00 2001 From: windyboy Date: Sat, 12 Sep 2026 20:29:23 +0800 Subject: [PATCH] fix(processing): keep exhausted failures terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcFailure.fail 在 markTerminal 后继续执行 update(FAILED),把 DEAD(EXHAUSTED) 覆写回非终态。改为耗尽分支写完终态即返回,保持 INV-6 终态不可逆与 INV-8 回填意图同语句。 回归:ProcFailureTest 覆盖临界次数保持 DEAD、不可回退 FAILED,以及非耗尽退避。 --- .../msgexchange/infra/retry/ProcFailure.kt | 1 + .../infra/retry/ProcFailureTest.kt | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/test/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailureTest.kt diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailure.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailure.kt index 749ac86..100138a 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailure.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailure.kt @@ -30,6 +30,7 @@ class ProcFailure( lastError = "$reason; attempts=$attempts", now = scheduler.now(), ) + return // 终态已写:不再执行下面的 FAILED 更新,否则会把 DEAD 覆写回非终态 } procState.update( head.msgId, ProcStatus.FAILED, diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailureTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailureTest.kt new file mode 100644 index 0000000..34fc2c6 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/retry/ProcFailureTest.kt @@ -0,0 +1,62 @@ +package com.gzzn.omms.msgexchange.infra.retry + +import com.gzzn.omms.msgexchange.MutableClock +import com.gzzn.omms.msgexchange.config.PipelineProps +import com.gzzn.omms.msgexchange.domain.ErrorClass +import com.gzzn.omms.msgexchange.domain.ProcStatus +import com.gzzn.omms.msgexchange.infra.stub.StubProcState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.Instant + +/** + * 处理失败的落账规矩:没到尝试上限才写 FAILED 退避重试;一到上限立刻写 DEAD(EXHAUSTED) 终态, + * 不允许同一次失败调用再把它覆写回 FAILED(INV-6 终态不可逆、INV-8 终态与回填意图同一次写入)。 + */ +class ProcFailureTest { + + private val base: Instant = MutableClock.BASE + + private fun scheduler(clock: MutableClock): FailureScheduler = + FailureScheduler(PipelineProps(), clock) + + private fun seed(repo: StubProcState, attempts: Int) { + repo.insertIfAbsent(1L, base, base) + repo.rows[1L] = repo.rows[1L]!!.copy(attempts = attempts) + } + + @Test + fun `exhausted failure stays DEAD and cannot fall back to FAILED`() { + val clock = MutableClock(base) + val repo = StubProcState() + seed(repo, attempts = 4) // 再失败一次即达到 max-attempts = 5 + + ProcFailure(repo, scheduler(clock)).fail(repo.find(1L)!!, ErrorClass.INFRA, "boom") + + val row = repo.find(1L)!! + assertEquals(ProcStatus.DEAD, row.state) + assertEquals(ErrorClass.EXHAUSTED, row.errorClass) + assertEquals(5, row.attempts) + assertNull(row.nextAttemptAt) + assertEquals("boom; attempts=5", row.lastError) + assertNotNull(row.backfillNextAt) // 终态与回填意图同一次写入 + } + + @Test + fun `non exhausted failure keeps FAILED with the backoff slot for this attempt`() { + val clock = MutableClock(base) + val repo = StubProcState() + seed(repo, attempts = 0) + + ProcFailure(repo, scheduler(clock)).fail(repo.find(1L)!!, ErrorClass.INFRA, "boom") + + val row = repo.find(1L)!! + assertEquals(ProcStatus.FAILED, row.state) + assertEquals(ErrorClass.INFRA, row.errorClass) + assertEquals(1, row.attempts) + assertEquals(base.plusMillis(1000), row.nextAttemptAt) + assertNull(row.backfillNextAt) // 非终态不登记回填意图 + } +}