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)
This commit is contained in:
@@ -22,7 +22,9 @@ class PipelineProps {
|
||||
var pollInterval: Duration = Duration.ofSeconds(1) // KEEP 现役节奏
|
||||
var claimBatch: Int = 50
|
||||
var maxAttempts: Int = 5 // 处理/投递同值
|
||||
var backoffMs: List<Long> = listOf(1000, 2000, 4000, 8000, 16000)
|
||||
// 档位数必须等于 max-attempts − 1:attempts 达到上限即转 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 上界(毒丸升级)
|
||||
|
||||
@@ -47,6 +49,45 @@ class PipelineProps {
|
||||
/** 每次回填扫描最多处理多少条。 */
|
||||
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 跑的时候才安全。
|
||||
@@ -58,6 +99,31 @@ class PipelineProps {
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user