feat(ingress): 实现 JDBC 信箱轮询、入站持久化与对拍测试 (U05)

This commit is contained in:
windyboy
2026-09-07 15:11:33 +08:00
parent dc68f1e1f8
commit c7b4b527ef
51 changed files with 1005 additions and 235 deletions
@@ -0,0 +1,36 @@
package com.gzzn.omms.msgexchange.infra.retry
import com.gzzn.omms.msgexchange.config.PipelineProps
import io.micronaut.context.annotation.Factory
import jakarta.inject.Singleton
import java.time.Clock
import java.time.Instant
/**
* 统一重试策略(ACM2-10 U08):供 MessageProcessor / SnapshotFlowProcState 侧)与
* DispatcherMsgEvent 侧)共用——attempts 递增后按 backoff 表给 nextAttemptAt
* exhausted 判定与两侧同源(maxAttempts)。时间一律经可注入 Clock(测试用固定钟,避免脆弱睡眠)。
*/
@Singleton
class FailureScheduler(
private val props: PipelineProps,
private val clock: Clock,
) {
/** 便捷构造:默认系统时钟(生产路径)。 */
constructor(props: PipelineProps) : this(props, Clock.systemUTC())
fun now(): Instant = clock.instant()
fun exhausted(attempts: Int): Boolean = attempts >= props.pipeline.maxAttempts
/** attempts 指递增后的值;N28attempt ≤ 0 由 backoffFor 兜底为首档。 */
fun nextAttemptAt(attemptsAfterIncrement: Int): Instant =
now().plusMillis(props.pipeline.backoffFor(attemptsAfterIncrement))
}
/** 提供可注入 Clockjava.time.Clock);测试可用 Clock.fixed(...) 或自定义可变钟覆盖。 */
@Factory
class TimeFactory {
@Singleton
fun systemClock(): Clock = Clock.systemUTC()
}
@@ -0,0 +1,38 @@
package com.gzzn.omms.msgexchange.infra.retry
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton
/**
* ProcState 侧统一失败迁移(U08/U10):处理/快照路径共用——
* attempts+1 后若 exhausted → DEAD(EXHAUSTED)(终态,errorClass 规范化,原因保留在 lastError);
* 否则 FAILED + attempts + nextAttemptAt(退避)(可重放)。任何“失败”都不得在无退避下直接终态化。
*/
@Singleton
class ProcFailure(
private val procState: ProcStateRepository,
val scheduler: FailureScheduler,
) {
fun fail(head: ProcState, ec: ErrorClass, reason: String) {
val attempts = head.attempts + 1
if (scheduler.exhausted(attempts)) {
procState.update(
head.cminmsgsId, ProcStatus.DEAD,
attempts = attempts,
errorClass = ErrorClass.EXHAUSTED,
lastError = "$reason; attempts=$attempts",
)
} else {
procState.update(
head.cminmsgsId, ProcStatus.FAILED,
attempts = attempts,
nextAttemptAt = scheduler.nextAttemptAt(attempts),
errorClass = ec,
lastError = reason,
)
}
}
}
@@ -0,0 +1,34 @@
package com.gzzn.omms.msgexchange.infra.retry
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton
/**
* U11 显式重放入口:只允许“可恢复”的错误类从 FAILED/DEAD 回到 PENDING(主泵重领)。
* 不可恢复类(MALFORMED——报文非法,重放必再失败)与未知类一律不在白名单内。
*/
@Singleton
class ReplayService(
private val procState: ProcStateRepository,
) {
private val log = org.slf4j.LoggerFactory.getLogger(ReplayService::class.java)
/** 可恢复错误类:codec 修复可重放 / 未实装补齐可重放 / 基础设施抖动可重放 / 重试耗尽后人工复核可重放。 */
val replayableErrorClasses: Set<ErrorClass> =
setOf(ErrorClass.CODEC_ERROR, ErrorClass.UNSUPPORTED, ErrorClass.INFRA, ErrorClass.EXHAUSTED)
/** 只重放白名单内的类;请求含 MALFORMED 等非法类时静默忽略该类。 */
fun replay(requested: Collection<ErrorClass>): Int {
val allowed = requested.filter { it in replayableErrorClasses }
if (allowed.isEmpty()) {
log.warn("replay requested only non-replayable classes: {}", requested)
return 0
}
val n = procState.requeueByErrorClasses(allowed)
log.info("replayed rows={} classes={}", n, allowed)
return n
}
/** 默认入口:重放全部可恢复类。 */
fun replayAll(): Int = replay(replayableErrorClasses)
}