Files
msgexchange-v2/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt
T
windyboy 817236ca26 fix(processing): 落地 D1/D5/D6 三项裁决,删除 head-deadline 参数
D5(终态判据只保留尝试上限):
- Pump.tick 内联 attempts 判定,删除 head-deadline 相关的毒丸分支与滞留告警代码
- 删除配置项 head-deadline(PipelineProps / application.yml)与 PumpDeadlineTest
- PROCESSING_STARTED_AT 变为只写,注释如实说明当前无判据消费它

D1(回填放弃判据改为时间):
- 暂时性故障在 R 之前只退避重试,不再按尝试次数放弃;到 R 才放弃并记 TRANSIENT_DEADLINE
- backfill-max-attempts 降级为单行重试的告警阈值

D6(超期判据改用本地入队时间):
- 新增 V6 迁移:PROC_STATE 加 ENQUEUED_AT(回填存量后置为非空 + 默认)
- findBackfillDue 的谓词与 overdue 标记改比较 enqueued_at,不再用库方时钟的 received_at
- BackfillDue 增加 overdue;收报与兼容入口显式写入本地入队时间

文档同步:
- 清理 4 处 message-lifecycle.md 章节号死链(Pump/InboxService/PipelineProps/application.yml)
- 关闭 G-HEAD-DEADLINE、G-BACKFILL-ABANDON-BYTIME、G-ENQUEUED-AT 三条缺口登记
- reference/user-stories/README 与实现对齐

验证:./gradlew test ⇒ 122 tests, 0 failures, 1 skipped

Refs: ACM2-45
2026-09-11 20:44:30 +08:00

140 lines
6.5 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
/**
* 缺口等待时长:水位后面缺了一个 ID 时,等这么久还没出现就认定它永远不会来了,
* 跳过缺口继续推进水位。
*
* 取值应该等于库方承诺的"上游提交到消息可见的最长时间"。设太小,可能把一条
* 迟到的消息误判成永久缺失,导致它排到后面的消息之后;设太大,收报会在缺口上白等。
*/
var maxCommitDelay: Duration = Duration.ofMinutes(5)
/**
* 超期补写期限 R:一条消息到达终态后,过了这么久处理标记仍未写回信箱
* (比如回填一直失败),扫描谓词的超期分支成立,无视退避强制补写(只会提前、从不推迟打标)。
*
* 唯一约束是 R ≤ R_keep,**不保护重放窗口**(打标时刻与 R 解耦,完整论证见
* design「回填」)。取值待 Q6 定案,确认之前不要为提速下调。
*/
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` 个。表更长会有一段**永远走不到**,
* 表更短则会提前封顶。两种错位都应该在启动时暴露,而不是静默生效。
*/
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
}
}