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:
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user