feat(jobs): 提交后回填补偿落库 backfill_todo 并到期重试 (ACM2-29 P2-3)

- V1.3.2 迁移:backfill_todo 持久补偿表(attempts/next_attempt_at/last_error)
- BackfillTodoRepository 接口 + PG 实现与 stub;record 幂等 upsert
- BackfillSweepJob:到期重试,成功即删,失败指数退避(30s 起步封顶 15 分钟);
  触发点 = InboxPoller 每轮心跳(重启即对账)+ JobExecutor BACKFILL_SWEEP 类型
- MessageProcessor/SnapshotFlow 回填失败落待办(成功终态不降级,业务不重放);
  SKIPPED 重复报文同样补偿共享信箱回填,防止重复信件被反复轮询
- 新测试:扫描重试/退避/恢复对账 4 用例;快照回填失败落库断言;
  跳过路径补偿落库断言

验证:MSGX_PG_PORT=5433 真实 PG 全量 102 用例 0 失败 0 跳过 (ACM2-29)
This commit is contained in:
windyboy
2026-09-08 13:04:43 +08:00
parent 44fbac0d8b
commit 48a27b26dd
12 changed files with 402 additions and 3 deletions
@@ -0,0 +1,53 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
/**
* v2 §5(ACM2-29 P2-3):共享信箱回填补偿扫描。
*
* 业务事务提交(SUCCEEDED)后 backfillOnSuccess 失败 → 落 backfill_todo;
* 本任务到期重试:成功即删除待办,失败按指数退避推后(30s 起步,封顶 15 分钟)。
* 回填失败绝不重放航班业务变更——待办里只有信箱回填所需的路由元数据。
*
* 触发点:InboxPoller 心跳(重启即对账)+ JobExecutor("BACKFILL_SWEEP")(FIFO/手工)。
*/
@Singleton
class BackfillSweepJob(
private val todo: BackfillTodoRepository,
private val inbox: CminmsgInboxRepository,
@Suppress("unused") private val props: PipelineProps,
) {
data class SweepOutcome(val inspected: Int, val succeeded: Int, val failed: Int)
companion object {
private val INITIAL_BACKOFF: Duration = Duration.ofSeconds(30)
private val MAX_BACKOFF: Duration = Duration.ofMinutes(15)
fun backoffDelayFor(attempts: Int): Duration {
val shift = (attempts - 1).coerceIn(0, 20)
return INITIAL_BACKOFF.multipliedBy(1L shl shift).coerceAtMost(MAX_BACKOFF)
}
}
fun sweep(now: Instant = Instant.now()): SweepOutcome {
val due = todo.findDue(now)
var succeeded = 0
var failed = 0
for (task in due) {
try {
inbox.backfillOnSuccess(task.cminmsgsId, task.sndr, task.type, task.styp, task.seqn)
todo.delete(task.cminmsgsId)
succeeded++
} catch (e: Exception) {
todo.markFailed(task.cminmsgsId, e.message, now.plus(backoffDelayFor(task.attempts + 1)), now)
failed++
}
}
return SweepOutcome(due.size, succeeded, failed)
}
}
@@ -18,11 +18,13 @@ class JobExecutor(
private val historySweep: HistorySweepJob,
private val archive: ArchiveJob,
private val projectionRebuild: ProjectionRebuildJob,
private val backfillSweep: BackfillSweepJob,
) {
fun execute(job: PumpJobRepository.Job) = when (job.kind) {
"HISTORY_SWEEP" -> historySweep.run()
"ARCHIVE" -> archive.run()
"PROJECTION_REBUILD" -> projectionRebuild.run()
"BACKFILL_SWEEP" -> backfillSweep.sweep()
else -> error("unknown job ${job.kind}")
}
}