Files
msgexchange-v2/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt
T
windyboy 1e75a81107 fix(pipeline): 修复取报-处理-回填链路的超时、吞吐、回填与 FIFO 问题
外部调用(ACM2-33)
- 共享 MySQL 与自有 PG 全部补有界超时:驱动 connect/socket 超时 + Hikari 池超时
  (注意 Micronaut 的 Hikari 项是毫秒数,不是 Duration 字面量)
- 删除主泵/毒丸路径的 inline 回填:跨库写不再占用 FIFO 关键路径,回填统一由扫描驱动
- 游标自愈:save() 改为「先 UPDATE、缺行 INSERT」,缺行只记一次 ERROR
- Pump/Dispatcher 外层 catch 补 error 日志与失败计数

投递吞吐(ACM2-34)
- Dispatcher 改批量领取;队头退避未到期或首条发送失败即停止本轮(保持目标内保序)
- 有活不再 sleep;删除不可达的 state==SENT 死条件;markSent 移入分支;
  superseded 清理加轮数上限与空 eventId 保护

回填闭环(ACM2-36,V4)
- MISSING(运行时确认行不存在)立即放弃自动重试并告警;暂时性故障达上限后停止自动重试
- 新增 reopen 人工恢复入口;放弃 ≠ 标记已确认(BACKFILL_AT 仍为空,清除前提不成立)
- 扫描改 (BACKFILL_ATTEMPTS, MSG_ID) 公平轮转并排除放弃行,消除全局回填饥饿
- V4 只加字段与必要索引,不按年龄做任何存量推断

切流播种(ACM2-35,V5)
- cutover-watermark 四模式(min/zero/max/显式 ID),默认不播种、代码不做默认选择
- 升级实例拒绝重新播种(SEEDED_AT 为 NULL ≠ 从未消费);播种与水位同语句落库
- 非法取值由启动自检挡下

错误分类与入口契约(ACM2-37)
- 未知 SCHD 子类型改为 UNSUPPORTED,不再静默当全量日计划合并
- ADFT 运营日冲突改走 ProtocolViolation → DEAD(PROTOCOL)
- 兼容入口 receivedAt 缺失回退到注入 Clock;MessageLifecycleGate 强制注入 + 装配断言
- FIFO:主泵只领取 msgId ≤ W,兼容入口登记的行在水位追平前不被领取

时间源与可观测(ACM2-38 / ACM2-41 阶段 0)
- 仓储/处理器/作业全部经注入 Clock;移除 markTerminal/markBackfilled 的 Instant.now() 默认值
- 退避表档位与 max-attempts 对齐并加启动自检
- Micrometer 7 个 gauge(@Context 急切注册)+ /health 与 /metrics 共用积压快照缓存
- 只读迟到检测:监视被放行的空洞 ID 是否后来真的出现,只计数告警、不补入队

Plane: ACM2-33 ACM2-34 ACM2-35 ACM2-36 ACM2-37 ACM2-38 ACM2-41
Tests: 88 → 120(1 skipped 需真实 PG)
2026-09-11 08:08:35 +08:00

141 lines
6.6 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.gzzn.omms.msgexchange.config
import io.micronaut.context.annotation.ConfigurationProperties
import java.time.Duration
/**
* 管道运行参数,对应配置文件里的 `msgx.*`。
*
* 注意:嵌套的配置类也必须标 `@ConfigurationProperties`,否则 Micronaut 不会绑定
* 里面的键,配置会静默失效、悄悄用回代码里的默认值。
*/
@ConfigurationProperties("msgx")
class PipelineProps {
var serviceName: String = "msgexchangeapi"
var registerEureka: Boolean = true
var pipeline: Pipeline = Pipeline()
var schd: Schd = Schd()
var identity: Identity = Identity()
@ConfigurationProperties("pipeline")
class Pipeline {
var pollInterval: Duration = Duration.ofSeconds(1) // KEEP 现役节奏
var claimBatch: Int = 50
var maxAttempts: Int = 5 // 处理/投递同值
// 档位数必须等于 max-attempts 1attempts 达到上限即转 DEAD,不再计算下次重试,
// 因此最多只用得到 max-attempts − 1 个档位。多出来的档位永远走不到(会被 validate() 拦下)。
var backoffMs: List<Long> = listOf(1000, 2000, 4000, 8000)
var backoffCapMs: Long = 60_000
var headDeadline: Duration = Duration.ofMinutes(10) // 最坏 HOL 上界(毒丸升级)
/**
* 缺口等待时长:水位后面缺了一个 ID 时,等这么久还没出现就认定它永远不会来了,
* 跳过缺口继续推进水位。
*
* 取值应该等于库方承诺的"上游提交到消息可见的最长时间"。设太小,可能把一条
* 迟到的消息误判成永久缺失,导致它排到后面的消息之后;设太大,收报会在缺口上白等。
*/
var maxCommitDelay: Duration = Duration.ofMinutes(5)
/**
* 超期补写期限:一条消息处理完之后,如果过了这么久还是没能把处理标记写回信箱
* (比如回填一直失败),就直接强制补写一次,不再等退避。
*
* 这是保证库方能清理信箱的兜底期限,必须覆盖人工重放所需的保留期,
* 确认之前不要调小,否则还在重放窗口内的消息会先被库方清掉。
*/
var overdueBackfill: Duration = Duration.ofDays(30)
/** 每次回填扫描最多处理多少条。 */
var backfillBatch: Int = 100
/**
* 回填的自动重试次数上限。达到上限后**停止自动重试**(置为 abandoned 并告警),
* 但保留人工恢复能力——暂时性故障不该变成永久失去补偿,也不该永久占满扫描批次。
*/
var backfillMaxAttempts: Int = 100
/**
* 切流水位播种(一次性、显式)。取值:
* `min` = `W:MIN(ID)1`(读当前全部现存行)、`zero` = `W:0`(按空洞规则从 0 扫)、
* `max` = `W:MAX(ID)`(跳过当前可见存量)、或一个具体 ID。
*
* **默认 null = 不播种**,保持既有行为。是否跳过存量属于切流决策,必须由人显式配置:
* 代码不做默认选择,也不会自动退化成 `max`;升级实例(已有水位或已有处理记录)会拒绝重新播种。
*/
var cutoverWatermark: String? = null
/**
* 迟到到达检测(只读,ACM2-41 阶段 0):复查"已判定为永久空洞的 ID"是否后来真的出现。
* 默认 60 秒一轮;设为 0 或负数即关闭。
*
* 检测只计数与告警,**不入队、不改变任何处理语义**——自动补入队(阶段 1)需要先与库方定案。
*/
var lateDetectPeriod: Duration = Duration.ofSeconds(60)
/** 每轮最多复查多少个被放行的空洞 ID。 */
var lateDetectBatch: Int = 200
/**
* 普通事件(`KAFKA:msg`)每轮向一个目标领取的条数上限。
* 逐条领取会让投递吞吐被"每条一次 DB 往返 + 一轮一次 sleep"压到每秒 1 条。
*/
var deliveryBatch: Int = 200
/**
* 每轮最多连续领取多少批,之后让出一次循环去跑 `schd` flush
* 避免长积压把状态通知饿死。
*/
var deliveryDrainRounds: Int = 10
/**
* 服务启动后是否自动拉起收报、主泵、投递三个循环。
* 默认关闭:只有接了真实仓储、或者明确用内存 stub 跑的时候才安全。
*/
var autostart: Boolean = false
/** N28attempt ≤ 0(如 FAILED 未递增 attempts 的行)不得抛异常,取下界=首档退避。 */
fun backoffFor(attempt: Int): Long {
val index = (attempt - 1).coerceAtLeast(0)
return backoffMs.getOrNull(index)?.coerceAtMost(backoffCapMs) ?: backoffCapMs
}
/**
* 配置自检:退避表档位数必须等于 `max-attempts 1`。
*
* 因为 attempts 一达到 `max-attempts` 就转 `DEAD`、不再计算下次重试,能真正用到的档位
* 只有 `max-attempts 1` 个。表更长会有一段**永远走不到**(默认配置里的 16 秒档就是这样),
* 表更短则会提前封顶。两种错位都应该在启动时暴露,而不是静默生效。
*/
fun validate() {
require(backoffMs.size == maxAttempts - 1) {
"msgx.pipeline.backoff-ms has ${backoffMs.size} slots, " +
"but max-attempts=$maxAttempts implies exactly ${maxAttempts - 1}"
}
// 切流播种只接受四种取值;非法值必须在启动时挡掉,而不是每轮轮询刷错误日志。
val cutover = cutoverWatermark
require(
cutover == null ||
cutover.equals("min", ignoreCase = true) ||
cutover.equals("zero", ignoreCase = true) ||
cutover.equals("max", ignoreCase = true) ||
cutover.toLongOrNull() != null,
) {
"msgx.pipeline.cutover-watermark must be one of min|zero|max|<id>, got '$cutover'"
}
}
}
@ConfigurationProperties("schd")
class Schd {
var flushPeriod: Duration = Duration.ofSeconds(3) // KEEP 现役节律
var flushLimit: Int = 500
}
@ConfigurationProperties("identity")
class Identity {
/** CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持 false,计算集中此处(I3)。 */
var includeDayBoundary: Boolean = false
}
}