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:
windyboy
2026-09-11 08:08:35 +08:00
parent b65728f312
commit 1e75a81107
42 changed files with 1745 additions and 168 deletions
@@ -0,0 +1,48 @@
package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.infra.persistence.Backlog
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import io.micronaut.context.BeanProvider
import io.micronaut.context.annotation.Value
import jakarta.inject.Singleton
import java.time.Clock
import java.time.Duration
import java.time.Instant
/**
* 处理侧积压快照的唯一取数点,带 TTL 缓存。
*
* `backlog()` 是 `PROC_STATE` 的**全表聚合**;健康检查(`/health`)与指标抓取(`/metrics`
* 都可能被高频调用,因此两者共用同一份缓存,避免把数据库拖慢。代价是数字最多滞后一个
* 缓存窗口——对积压/信龄这类告警信号足够。
*
* 缓存窗口由 `msgx.health.backlog-cache-ttl-ms` 配置(默认 30 秒;设为 0 表示不缓存)。
* 需要实时精确计数时应改为维护计数器,而不是把窗口调到 0。
*/
@Singleton
class BacklogSnapshotProvider(
private val procState: BeanProvider<ProcStateRepository>,
private val clock: Clock,
@Value("\${msgx.health.backlog-cache-ttl-ms:30000}") private val ttlMs: Long = 30_000,
) {
@Volatile
private var cached: Backlog? = null
@Volatile
private var cachedAt: Instant? = null
/** @return 积压快照;仓储未绑定(例如 stub 关闭)时返回 null,由调用方按"未绑定"处理。 */
fun snapshot(): Backlog? {
if (!procState.isPresent) return null
val now = clock.instant()
val at = cachedAt
val value = cached
if (ttlMs > 0 && at != null && value != null && Duration.between(at, now).toMillis() < ttlMs) {
return value
}
val fresh = procState.get().backlog()
cached = fresh
cachedAt = now
return fresh
}
}
@@ -1,5 +1,6 @@
package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.infra.persistence.Backlog
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
@@ -10,6 +11,7 @@ import io.micronaut.management.health.indicator.HealthIndicator
import io.micronaut.management.health.indicator.HealthResult
import jakarta.inject.Singleton
import org.reactivestreams.Publisher
import java.time.Clock
import java.time.Duration
import java.time.Instant
@@ -28,6 +30,8 @@ class InboxLifecycleHealthIndicator(
private val procState: BeanProvider<ProcStateRepository>,
private val cursor: BeanProvider<InboxCursorRepository>,
private val mailbox: BeanProvider<CminmsgInboxRepository>,
private val clock: Clock,
private val backlogs: BacklogSnapshotProvider,
) : HealthIndicator {
override fun getResult(): Publisher<HealthResult> =
@@ -37,6 +41,9 @@ class InboxLifecycleHealthIndicator(
procState = if (procState.isPresent) procState.get() else null,
cursor = if (cursor.isPresent) cursor.get() else null,
mailbox = if (mailbox.isPresent) mailbox.get() else null,
now = clock.instant(),
// 与 /metrics 共用同一份 30 秒缓存:两者都不该把全表聚合打成高频查询。
backlog = backlogs.snapshot(),
)
}.getOrElse { down(it) },
)
@@ -47,23 +54,31 @@ internal fun lifecycleHealth(
procState: ProcStateRepository?,
cursor: InboxCursorRepository?,
mailbox: CminmsgInboxRepository?,
now: Instant = Instant.now(),
now: Instant,
/** 允许调用方传入缓存/已算好的积压快照;为空时现查(`backlog()` 是全表聚合)。 */
backlog: Backlog? = null,
): HealthResult {
if (procState == null) {
return HealthResult.builder(NAME).status(HealthStatus.UP)
.details(mapOf("message" to "proc_state repository not bound (stub off, impl pending)"))
.build()
}
val backlog = procState.backlog()
val snapshot = backlog ?: procState.backlog()
val watermark = cursor?.load()?.committedUpTo
val maxId = runCatching { mailbox?.maxId() }.getOrNull()
return HealthResult.builder(NAME).status(HealthStatus.UP).details(
linkedMapOf<String, Any>(
"backlog" to backlog.unfinished,
"backlog" to snapshot.unfinished,
"oldestUnprocessedSeconds" to (
backlog.oldestReceivedAt?.let { Duration.between(it, now).seconds } ?: -1L
snapshot.oldestReceivedAt?.let { Duration.between(it, now).seconds } ?: -1L
),
"unmarkedTerminal" to snapshot.unmarkedTerminal,
// 已放弃自动回填的条数:**不等于**标记已完成,需要人工对账;非 0 应告警。
"backfillAbandoned" to snapshot.abandonedBackfill,
// 最老一条"仍待自动回填"记录的年龄(秒):回填延迟的真实观测值。
"oldestUnmarkedBackfillSeconds" to (
snapshot.oldestUnmarkedAt?.let { Duration.between(it, now).seconds } ?: -1L
),
"unmarkedTerminal" to backlog.unmarkedTerminal,
"watermark" to (watermark ?: -1L),
"watermarkLag" to if (watermark != null && maxId != null) maxId - watermark else -1L,
),
@@ -0,0 +1,37 @@
package com.gzzn.omms.msgexchange.infra.metrics
import jakarta.inject.Singleton
import java.util.concurrent.atomic.AtomicLong
/**
* 管道运行期的**进程内**计数。
*
* 存在的意义是让业务模块(如收报)能零依赖地上报事实:模块只依赖本类,
* 不直接依赖 Micrometer;指标如何在 `/metrics` 上暴露由 [PipelineMetrics] 决定。
*
* 注意:计数在重启后归零。需要跨重启的累计值应由指标后端聚合,不在这里做持久化。
*/
@Singleton
class PipelineCounters {
private val holeAgedOut = AtomicLong(0)
private val lateArrivalDetected = AtomicLong(0)
/** 水位因空洞超过老化阈值而放行(判定为永久空洞)的次数。 */
fun holeAgedOutIncrement() {
holeAgedOut.incrementAndGet()
}
fun holeAgedOutCount(): Long = holeAgedOut.get()
/**
* "迟到到达"检测命中的**不同**消息 ID 数(ACM2-41 阶段 0)。
*
* 含义:该 ID 曾被判定为永久空洞并放行,之后却真的出现在信箱里——即上游提交晚于水位推进。
* 阶段 0 只计数与告警,**不会**补入队;因此这个值 > 0 表示"确有迟到发生,需要与库方对契约"。
*/
fun lateArrivalDetectedIncrement() {
lateArrivalDetected.incrementAndGet()
}
fun lateArrivalDetectedCount(): Long = lateArrivalDetected.get()
}
@@ -0,0 +1,84 @@
package com.gzzn.omms.msgexchange.infra.metrics
import com.gzzn.omms.msgexchange.infra.health.BacklogSnapshotProvider
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import io.micronaut.context.BeanProvider
import io.micronaut.context.annotation.Context
import io.micronaut.context.annotation.Requires
import io.micrometer.core.instrument.Gauge
import io.micrometer.core.instrument.MeterRegistry
import jakarta.annotation.PostConstruct
import java.time.Clock
import java.time.Duration
/**
* 把管道的可观测事实暴露成 Micrometer 指标(默认经 `/metrics` 抓取)。
*
* 只暴露规范里要求观测的量:
* - `msgx.pipeline.backlog.unfinished`:还没处理完的消息条数
* - `msgx.pipeline.backlog.oldest_unprocessed_seconds`:最老未处理消息的信龄
* - `msgx.pipeline.backfill.unmarked_terminal`:已终态但未打标的条数
* - `msgx.pipeline.backfill.abandoned`:已放弃自动回填的条数(**非 0 需人工对账**)
* - `msgx.pipeline.backfill.oldest_unmarked_seconds`:最老一条仍待自动回填的年龄
* - `msgx.pipeline.watermark.lag`:水位落后信箱最新 ID 的距离
* - `msgx.pipeline.hole.aged_out.total`:永久空洞放行次数
*
* 取数统一走 [BacklogSnapshotProvider]30 秒 TTL),因此指标抓取不会打穿数据库。
* 无法取数时以 `NaN` 上报(Micrometer 的惯例表示"本次无值"),而不是伪造 0。
*
* `Gauge` 默认对目标对象持**弱引用**,这里显式 `strongReference(true)`
* 单例被容器持有本不会被回收,但显式声明可避免将来重构踩坑。
*
* 用 `@Context` 而不是 `@Singleton`:没有任何 bean 依赖它,惰性单例永远不会被创建,
* 指标也就注册不上。指标装配必须在启动时急切完成。
*/
@Context
@Requires(beans = [MeterRegistry::class])
class PipelineMetrics(
private val registry: MeterRegistry,
private val backlogs: BacklogSnapshotProvider,
private val cursor: BeanProvider<InboxCursorRepository>,
private val mailbox: BeanProvider<CminmsgInboxRepository>,
private val counters: PipelineCounters,
private val clock: Clock,
) {
@PostConstruct
fun bind() {
backlogGauge("msgx.pipeline.backlog.unfinished") { it.unfinished.toDouble() }
backlogGauge("msgx.pipeline.backlog.oldest_unprocessed_seconds") { snapshot ->
snapshot.oldestReceivedAt
?.let { Duration.between(it, clock.instant()).seconds.toDouble() }
?: -1.0
}
backlogGauge("msgx.pipeline.backfill.unmarked_terminal") { it.unmarkedTerminal.toDouble() }
backlogGauge("msgx.pipeline.backfill.abandoned") { it.abandonedBackfill.toDouble() }
backlogGauge("msgx.pipeline.backfill.oldest_unmarked_seconds") { snapshot ->
snapshot.oldestUnmarkedAt
?.let { Duration.between(it, clock.instant()).seconds.toDouble() }
?: -1.0
}
Gauge.builder("msgx.pipeline.watermark.lag", backlogs) { _ ->
val watermark = if (cursor.isPresent) cursor.get().load().committedUpTo else null
val maxId = if (mailbox.isPresent) runCatching { mailbox.get().maxId() }.getOrNull() else null
if (watermark != null && maxId != null) (maxId - watermark).toDouble() else -1.0
}.strongReference(true).register(registry)
Gauge.builder("msgx.pipeline.hole.aged_out.total", counters) { it.holeAgedOutCount().toDouble() }
.strongReference(true)
.register(registry)
// 迟到到达检测命中数(阶段 0 只观测):> 0 表示上游提交确实晚于水位推进,需要与库方对契约。
Gauge.builder("msgx.pipeline.late_arrival.detected.total", counters) { it.lateArrivalDetectedCount().toDouble() }
.strongReference(true)
.register(registry)
}
private fun backlogGauge(name: String, value: (com.gzzn.omms.msgexchange.infra.persistence.Backlog) -> Double) {
Gauge.builder(name, backlogs) { provider ->
provider.snapshot()?.let(value) ?: Double.NaN
}.strongReference(true).register(registry)
}
}
@@ -89,15 +89,31 @@ interface ProcStateRepository {
errorClass: ErrorClass? = null,
lastError: String? = null,
attempts: Int? = null,
now: Instant = Instant.now(),
now: Instant,
)
/** 回填成功:记下完成时间,清掉待办。 */
fun markBackfilled(msgId: Long, now: Instant = Instant.now())
fun markBackfilled(msgId: Long, now: Instant)
/** 回填失败:次数 +1、按退避推后、记下原因。处理终态不受影响,不会被改回去。 */
fun recordBackfillFailure(msgId: Long, error: String?, attempts: Int, nextAttemptAt: Instant, now: Instant)
/**
* 放弃回填:判定该行不必再自动尝试(信箱行不存在,或达到尝试上限)。
*
* **abandoned ≠ 标记已确认**`BACKFILL_AT` 仍为空,因此**不**满足"边界内全部行已打标"的
* 清除前提;放弃只是停止自动重试并把事实留痕,供人工对账。
*
* @return false 表示该行不存在
*/
fun markBackfillAbandoned(msgId: Long, reason: String, now: Instant): Boolean
/**
* 人工恢复入口:清除放弃标记并重新排队一次回填(暂时性故障恢复后、或人工对账确认后使用)。
* @return false 表示该行不存在或本来就未被放弃
*/
fun reopenBackfill(msgId: Long, now: Instant): Boolean
/**
* 找出现在该回填的记录:已经到终态、还没确认回填,并且退避时间已到。
*
@@ -111,6 +127,9 @@ interface ProcStateRepository {
/** 积压观测:还没处理完的条数、最老一条的接收时间、处理完但还没回填的条数。 */
fun backlog(): Backlog
/** 是否已有任何处理记录(含终态)。切流播种用它判断"这个实例是否已经消费过"。 */
fun hasAny(): Boolean
}
/** 扫描到的待回填记录。 */
@@ -120,9 +139,17 @@ data class BackfillDue(val msgId: Long, val attempts: Int)
* 处理侧积压快照。
* @param unfinished 还没处理完的消息条数
* @param oldestReceivedAt 其中最早一条的接收时间(据此算信龄)
* @param unmarkedTerminal 已经处理完、但还没把标记写回信箱的条数
* @param unmarkedTerminal 已经处理完、但还没把标记写回信箱的条数(含已放弃的)
* @param abandonedBackfill 其中已放弃自动回填的条数(需要人工对账的信号)
* @param oldestUnmarkedAt 最老一条待回填记录的接收时间(回填延迟观测)
*/
data class Backlog(val unfinished: Int, val oldestReceivedAt: Instant?, val unmarkedTerminal: Int)
data class Backlog(
val unfinished: Int,
val oldestReceivedAt: Instant?,
val unmarkedTerminal: Int,
val abandonedBackfill: Int = 0,
val oldestUnmarkedAt: Instant? = null,
)
/**
* 待发事件(outbox)表:业务提交时把要发的事件一起写进来,投递线程再从这张表往外发,
@@ -263,12 +290,25 @@ interface InboxCursorRepository {
/**
* @param committedUpTo 水位 W
* @param holeSince W 后面那个缺口最早被发现的时刻;当前没有缺口时为 null
* @param seededAt 非空 = 已按 `msgx.pipeline.cutover-watermark` 播种过。
* **它为空不等于"从未消费"**:已有库新增该列后同样是 null,判断必须叠加"水位为 0 且无处理记录"。
*/
data class Cursor(val committedUpTo: Long = 0L, val holeSince: Instant? = null)
data class Cursor(
val committedUpTo: Long = 0L,
val holeSince: Instant? = null,
val seededAt: Instant? = null,
)
fun load(): Cursor
/** 只推进水位与空洞计时,不改动 [Cursor.seededAt]。 */
fun save(cursor: Cursor)
/**
* 切流播种:一次性写入水位并把空洞计时清空,同时记录播种事实。
* 与 [save] 分开,避免"普通轮次推进水位"把播种标记抹掉。
*/
fun markSeeded(committedUpTo: Long, now: Instant)
}
/**
@@ -299,6 +339,15 @@ interface CminmsgInboxRepository {
/** 信箱当前最大 ID,空表返回 null;只用来观测收报落后了多少。 */
fun maxId(): Long?
/** 信箱当前最小 ID,空表返回 null;切流播种用它推算 `W = MIN(ID) 1`。 */
fun minId(): Long?
/**
* 批量查询这些 ID 里哪些**当前存在于信箱**。
* 只用于"迟到到达"检测(被放行的空洞 ID 后来是否真的出现),不读大字段。
*/
fun existingIds(msgIds: Collection<Long>): Set<Long>
/**
* 把处理标记写回信箱,并且**只写还是空标记的行**:库里已有值时不覆盖、不回退,
* 重复调用没有副作用。结果明确区分本次写入、已有标记和信箱行缺失。
@@ -71,6 +71,25 @@ class JdbcCminmsgInboxRepository(
rs.getLong("max_id").takeIf { !rs.wasNull() }
}
override fun minId(): Long? =
ds.queryOne("SELECT MIN(CMINMSGS_ID) AS min_id FROM cminmsgs", {}) { rs ->
rs.getLong("min_id").takeIf { !rs.wasNull() }
}
override fun existingIds(msgIds: Collection<Long>): Set<Long> {
if (msgIds.isEmpty()) return emptySet()
val out = linkedSetOf<Long>()
// 分块避免 IN 列表过长(迟到检测一次最多查 late-detect-batch 个)。
msgIds.chunked(200).forEach { chunk ->
val placeholders = chunk.joinToString(",") { "?" }
ds.query(
"SELECT CMINMSGS_ID FROM cminmsgs WHERE CMINMSGS_ID IN ($placeholders)",
{ ps -> chunk.forEachIndexed { i, id -> ps.setLong(i + 1, id) } },
) { rs -> rs.getLong("CMINMSGS_ID") }.forEach { out += it }
}
return out
}
/**
* 只更新还是空标记的行,所以重复调用不会覆盖库里已有的值;
* 影响 0 行时再查一次主键,区分“已有标记”和“信箱行缺失”;后者不能记为回填成功。
@@ -27,11 +27,13 @@ import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
import java.sql.ResultSet
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.concurrent.atomic.AtomicBoolean
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import java.time.format.DateTimeFormatter
import java.util.Locale
@@ -70,6 +72,7 @@ class JdbcPipelineLockRepository(
@Requires(missingProperty = "msgx.stubs")
class JdbcProcStateRepository(
private val ds: DataSource,
private val clock: Clock,
) : ProcStateRepository {
/** 入队(幂等):主键冲突时什么都不做,所以重复扫描和兼容入口并发调用都安全。 */
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean =
@@ -79,7 +82,7 @@ class JdbcProcStateRepository(
{ ps ->
ps.setLong(1, msgId)
ps.setTimestamp(2, receivedAt?.toSqlTimestamp())
ps.setTimestamp(3, Instant.now().toSqlTimestamp())
ps.setTimestamp(3, clock.instant().toSqlTimestamp())
},
) == 1
@@ -117,7 +120,7 @@ class JdbcProcStateRepository(
"UPDATE proc_state SET identity_key = ?, updated_at = ? WHERE msg_id = ? AND identity_key IS NULL",
{ ps ->
ps.setString(1, identityKey)
ps.setTimestamp(2, Instant.now().toSqlTimestamp())
ps.setTimestamp(2, clock.instant().toSqlTimestamp())
ps.setLong(3, msgId)
},
)
@@ -149,7 +152,7 @@ class JdbcProcStateRepository(
ps.setInt(3, attempts ?: 0)
ps.setString(4, errorClass?.name)
ps.setString(5, lastError)
ps.setTimestamp(6, Instant.now().toSqlTimestamp())
ps.setTimestamp(6, clock.instant().toSqlTimestamp())
ps.setLong(7, msgId)
},
)
@@ -170,6 +173,7 @@ class JdbcProcStateRepository(
SET state = ?, error_class = ?, last_error = ?, attempts = COALESCE(?, attempts),
next_attempt_at = NULL,
backfill_at = NULL, backfill_next_at = ?, backfill_attempts = 0, backfill_error = NULL,
backfill_abandoned_at = NULL, backfill_abandoned_reason = NULL,
updated_at = ?
WHERE msg_id = ?
""".trimIndent(),
@@ -209,18 +213,55 @@ class JdbcProcStateRepository(
)
}
/**
* 放弃自动回填。注意 **不写 `BACKFILL_AT`**:放弃 ≠ 标记已确认,
* 因此"边界内全部行已打标"的清除前提仍然不成立,库方不应据此清除。
*/
override fun markBackfillAbandoned(msgId: Long, reason: String, now: Instant): Boolean =
ds.update(
"UPDATE proc_state SET backfill_abandoned_at = ?, backfill_abandoned_reason = ?, " +
"backfill_next_at = NULL, updated_at = ? " +
"WHERE msg_id = ? AND backfill_at IS NULL AND backfill_abandoned_at IS NULL",
{ ps ->
ps.setTimestamp(1, now.toSqlTimestamp())
ps.setString(2, reason.take(64))
ps.setTimestamp(3, now.toSqlTimestamp())
ps.setLong(4, msgId)
},
) == 1
/** 人工恢复:清掉放弃标记并立刻重排一次回填(暂时性故障恢复后或对账后使用)。 */
override fun reopenBackfill(msgId: Long, now: Instant): Boolean =
ds.update(
"UPDATE proc_state SET backfill_abandoned_at = NULL, backfill_abandoned_reason = NULL, " +
"backfill_next_at = ?, updated_at = ? " +
"WHERE msg_id = ? AND backfill_at IS NULL AND backfill_abandoned_at IS NOT NULL",
{ ps ->
ps.setTimestamp(1, now.toSqlTimestamp())
ps.setTimestamp(2, now.toSqlTimestamp())
ps.setLong(3, msgId)
},
) == 1
/**
* 到了该回填的时候:终态 + 还没有标记 + (退避到期 或 收信时间已经很久)。
* 后面这个"很久"是兜底,保证标记最终一定会补上,库方才能按标记清理信箱。
*/
/**
* 到了该回填的时候:终态 + 还没有标记 + **未放弃** + (退避到期 或 收信时间已经很久)。
*
* 排序用**公平轮转**:先按已尝试次数升序,再按 msg_id。若只按 msg_id 升序,
* 最旧的一批永久失败行会持续占满批次,后面的记录永远轮不到(全局回填饥饿)。
*/
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue> =
ds.query(
"""
SELECT msg_id, backfill_attempts FROM proc_state
WHERE backfill_at IS NULL
AND backfill_abandoned_at IS NULL
AND state IN ('SUCCEEDED', 'SKIPPED', 'DEAD')
AND (backfill_next_at IS NULL OR backfill_next_at <= ? OR (received_at IS NOT NULL AND received_at < ?))
ORDER BY msg_id ASC LIMIT ?
ORDER BY backfill_attempts ASC, msg_id ASC LIMIT ?
""".trimIndent(),
{ ps ->
ps.setTimestamp(1, now.toSqlTimestamp())
@@ -236,20 +277,27 @@ class JdbcProcStateRepository(
"UPDATE proc_state SET state = 'PENDING', attempts = 0, next_attempt_at = NULL, processing_started_at = NULL, updated_at = ? " +
"WHERE state IN ('FAILED', 'DEAD') AND error_class IN ($placeholders)",
{ ps ->
ps.setTimestamp(1, Instant.now().toSqlTimestamp())
ps.setTimestamp(1, clock.instant().toSqlTimestamp())
errorClasses.forEachIndexed { i, ec -> ps.setString(i + 2, ec.name) }
},
)
}
/** 积压观测:未处理条数、最老一条的接收时间、处理完但未回填的条数。 */
override fun hasAny(): Boolean =
ds.queryOne("SELECT 1 FROM proc_state LIMIT 1", {}) { 1 } != null
override fun backlog(): Backlog =
ds.queryOne(
"""
SELECT
count(*) FILTER (WHERE state IN ('PENDING', 'FAILED')) AS unfinished,
min(received_at) FILTER (WHERE state IN ('PENDING', 'FAILED')) AS oldest_received_at,
count(*) FILTER (WHERE state IN ('SUCCEEDED', 'SKIPPED', 'DEAD') AND backfill_at IS NULL) AS unmarked_terminal
count(*) FILTER (WHERE state IN ('SUCCEEDED', 'SKIPPED', 'DEAD') AND backfill_at IS NULL) AS unmarked_terminal,
count(*) FILTER (WHERE state IN ('SUCCEEDED', 'SKIPPED', 'DEAD') AND backfill_at IS NULL
AND backfill_abandoned_at IS NOT NULL) AS abandoned_backfill,
min(received_at) FILTER (WHERE state IN ('SUCCEEDED', 'SKIPPED', 'DEAD') AND backfill_at IS NULL
AND backfill_abandoned_at IS NULL) AS oldest_unmarked_at
FROM proc_state
""".trimIndent(),
{},
@@ -258,6 +306,8 @@ class JdbcProcStateRepository(
unfinished = rs.getInt("unfinished"),
oldestReceivedAt = rs.getInstant("oldest_received_at"),
unmarkedTerminal = rs.getInt("unmarked_terminal"),
abandonedBackfill = rs.getInt("abandoned_backfill"),
oldestUnmarkedAt = rs.getInstant("oldest_unmarked_at"),
)
} ?: Backlog(0, null, 0)
@@ -274,14 +324,17 @@ class JdbcProcStateRepository(
backfillNextAt = rs.getInstant("backfill_next_at"),
backfillAttempts = rs.getInt("backfill_attempts"),
backfillError = rs.getString("backfill_error"),
backfillAbandonedAt = rs.getInstant("backfill_abandoned_at"),
backfillAbandonedReason = rs.getString("backfill_abandoned_reason"),
processingStartedAt = rs.getInstant("processing_started_at"),
updatedAt = rs.getInstant("updated_at") ?: Instant.now(),
updatedAt = rs.getInstant("updated_at") ?: clock.instant(),
)
private companion object {
const val SELECT_PROC =
"SELECT msg_id, state, identity_key, attempts, next_attempt_at, error_class, last_error, " +
"received_at, backfill_at, backfill_next_at, backfill_attempts, backfill_error, processing_started_at, updated_at FROM proc_state"
"received_at, backfill_at, backfill_next_at, backfill_attempts, backfill_error, " +
"backfill_abandoned_at, backfill_abandoned_reason, processing_started_at, updated_at FROM proc_state"
}
}
@@ -291,24 +344,82 @@ class JdbcProcStateRepository(
@Requires(missingProperty = "msgx.stubs")
class JdbcInboxCursorRepository(
private val ds: DataSource,
private val clock: Clock,
) : InboxCursorRepository {
override fun load(): InboxCursorRepository.Cursor =
ds.queryOne(
"SELECT committed_up_to, hole_since FROM inbox_cursor WHERE cursor_id = 1",
"SELECT committed_up_to, hole_since, seeded_at FROM inbox_cursor WHERE cursor_id = 1",
{},
) { rs -> InboxCursorRepository.Cursor(rs.getLong("committed_up_to"), rs.getInstant("hole_since")) }
?: InboxCursorRepository.Cursor()
) { rs ->
InboxCursorRepository.Cursor(
rs.getLong("committed_up_to"),
rs.getInstant("hole_since"),
rs.getInstant("seeded_at"),
)
}
?: InboxCursorRepository.Cursor().also {
// 缺行不是"水位为 0"的同义词:这里只报一次,随后 save() 会 upsert 自愈。
if (missingCursorWarned.compareAndSet(false, true)) {
log.error("INBOX_CURSOR row (cursor_id=1) is missing; watermark will be re-created on the next save()")
}
}
/**
* 水位推进必须是 upsert,不能是裸 UPDATE
* 裸 UPDATE 在游标行缺失时影响 0 行且不报错,会让水位永远停在初值、每轮重扫同一批,
* 收报静默死锁在第一批。upsert 让缺行自愈,且与入队同事务提交。
*/
override fun save(cursor: InboxCursorRepository.Cursor) {
ds.update(
// 先 UPDATE、缺行再 INSERT:比 ON CONFLICT 更可移植(H2 的 PostgreSQL 兼容模式不支持 ON CONFLICT),
// 且 InboxPoller 始终把它放在同一个 PG 事务里提交,因此两条语句对外仍是原子的。
// 关键点是**缺行必须能自愈**:裸 UPDATE 影响 0 行却不报错,会让水位永远停在初值、每轮重扫同一批。
val updated = ds.update(
"UPDATE inbox_cursor SET committed_up_to = ?, hole_since = ?, updated_at = ? WHERE cursor_id = 1",
{ ps ->
ps.setLong(1, cursor.committedUpTo)
ps.setTimestamp(2, cursor.holeSince?.toSqlTimestamp())
ps.setTimestamp(3, Instant.now().toSqlTimestamp())
ps.setTimestamp(3, clock.instant().toSqlTimestamp())
},
)
if (updated > 0) return
ds.update(
"INSERT INTO inbox_cursor (cursor_id, committed_up_to, hole_since, updated_at) VALUES (1, ?, ?, ?)",
{ ps ->
ps.setLong(1, cursor.committedUpTo)
ps.setTimestamp(2, cursor.holeSince?.toSqlTimestamp())
ps.setTimestamp(3, clock.instant().toSqlTimestamp())
},
)
}
/**
* 切流播种:一次性写入水位 + 清空空洞计时 + 记录播种事实(同一条语句)。
* 与 [save] 分开,普通轮次推进水位不会把播种标记抹掉。
*/
override fun markSeeded(committedUpTo: Long, now: Instant) {
val updated = ds.update(
"UPDATE inbox_cursor SET committed_up_to = ?, hole_since = NULL, seeded_at = ?, updated_at = ? " +
"WHERE cursor_id = 1",
{ ps ->
ps.setLong(1, committedUpTo)
ps.setTimestamp(2, now.toSqlTimestamp())
ps.setTimestamp(3, now.toSqlTimestamp())
},
)
if (updated > 0) return
ds.update(
"INSERT INTO inbox_cursor (cursor_id, committed_up_to, hole_since, seeded_at, updated_at) " +
"VALUES (1, ?, NULL, ?, ?)",
{ ps ->
ps.setLong(1, committedUpTo)
ps.setTimestamp(2, now.toSqlTimestamp())
ps.setTimestamp(3, now.toSqlTimestamp())
},
)
}
private val missingCursorWarned = AtomicBoolean(false)
private val log = org.slf4j.LoggerFactory.getLogger(JdbcInboxCursorRepository::class.java)
}
@Singleton
@@ -316,6 +427,7 @@ class JdbcInboxCursorRepository(
@Requires(missingProperty = "msgx.stubs")
class JdbcMsgEventRepository(
private val ds: DataSource,
private val clock: Clock,
) : MsgEventRepository {
override fun insertAll(events: List<MsgEvent>): List<Long> =
events.map { e ->
@@ -414,7 +526,7 @@ class JdbcMsgEventRepository(
nextAttemptAt = rs.getInstant("next_attempt_at"),
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
lastError = rs.getString("last_error"),
createdAt = rs.getInstant("created_at") ?: Instant.now(),
createdAt = rs.getInstant("created_at") ?: clock.instant(),
)
}
@@ -423,6 +535,7 @@ class JdbcMsgEventRepository(
@Requires(missingProperty = "msgx.stubs")
class JdbcFlightStateRepository(
private val ds: DataSource,
private val clock: Clock,
) : FlightStateRepository {
private fun mapMainRow(rs: ResultSet) = FlightMainRow(
@@ -431,7 +544,7 @@ class JdbcFlightStateRepository(
state = FlightState.valueOf(rs.getString("state")),
stateVersion = rs.getLong("state_version"),
lastMsgId = rs.getLong("last_msg_id").takeIf { !rs.wasNull() },
updatedAt = rs.getInstant("updated_at") ?: Instant.now(),
updatedAt = rs.getInstant("updated_at") ?: clock.instant(),
)
override fun findMainRow(flid: String): FlightMainRow? =
@@ -711,6 +824,7 @@ class JdbcFlightStateRepository(
@Requires(missingProperty = "msgx.stubs")
class JdbcSnapshotLogRepository(
private val ds: DataSource,
private val clock: Clock,
) : SnapshotLogRepository {
/** 只追加写;这里不抛异常,写失败由调用方捕获并记为指标。 */
override fun append(entry: SnapshotLogEntry) {
@@ -730,7 +844,7 @@ class JdbcSnapshotLogRepository(
ps.setString(8, entry.result.name)
ps.setString(9, entry.flags.joinToString(",") { it.name })
ps.setString(10, entry.archiveKey)
ps.setTimestamp(11, Instant.now().toSqlTimestamp())
ps.setTimestamp(11, clock.instant().toSqlTimestamp())
},
)
}
@@ -741,6 +855,7 @@ class JdbcSnapshotLogRepository(
@Requires(missingProperty = "msgx.stubs")
class JdbcReqTrackRepository(
private val ds: DataSource,
private val clock: Clock,
) : ReqTrackRepository {
override fun insert(reqType: String, operationDay: LocalDate, sender: String): Long =
ds.updateReturningLong(
@@ -749,7 +864,7 @@ class JdbcReqTrackRepository(
ps.setString(1, reqType)
ps.setDate(2, java.sql.Date.valueOf(operationDay))
ps.setString(3, sender)
ps.setTimestamp(4, Instant.now().toSqlTimestamp())
ps.setTimestamp(4, clock.instant().toSqlTimestamp())
},
)
@@ -789,7 +904,7 @@ class JdbcReqTrackRepository(
)
""".trimIndent(),
{ ps ->
ps.setTimestamp(1, Instant.now().toSqlTimestamp())
ps.setTimestamp(1, clock.instant().toSqlTimestamp())
ps.setString(2, reqType)
ps.setDate(3, java.sql.Date.valueOf(operationDay))
ps.setString(4, sender)
@@ -24,6 +24,12 @@ class MailboxDataSourceFactory {
driverClassName = cfg.driverClassName
maximumPoolSize = 5
poolName = "mailbox"
// 有界外部调用:连接、取连接、校验都必须有上限,否则一次网络黑洞会永久挂住调用线程。
connectionTimeout = cfg.poolConnectionTimeoutMs
validationTimeout = cfg.poolValidationTimeoutMs
// 驱动级超时通过 dataSourceProperties 透传给 Connector/JsocketTimeout 默认 0 = 无限等待)。
addDataSourceProperty("connectTimeout", cfg.connectTimeoutMs.toString())
addDataSourceProperty("socketTimeout", cfg.socketTimeoutMs.toString())
}
return HikariDataSource(hikari)
}
@@ -29,6 +29,7 @@ class ProcFailure(
attempts = attempts,
errorClass = ErrorClass.EXHAUSTED,
lastError = "$reason; attempts=$attempts",
now = scheduler.now(),
)
return true
}
@@ -10,11 +10,16 @@ import jakarta.inject.Singleton
*
* 只有"再试一次有可能成功"的错误类才放行报文本身不合法的MALFORMED重放多少次都一样
* 所以不在白名单里没列出的错误类也一律不放行
*
* 与回填共用同一个 [MessageLifecycleGate]**必须由容器注入同一个单例**若两者各持一把锁
* 互斥失效"旧回填给已重新入队的消息写标记"的窗口就会重新打开
* 装配正确性由 `PipelineSmokeTest` 的同例断言守不依赖 Kotlin 默认参数值
*/
@Singleton
class ReplayService(
private val procState: ProcStateRepository,
private val lifecycleGate: MessageLifecycleGate = MessageLifecycleGate(),
/** 与回填共用的互斥门;`internal` 以便装配测试断言两者拿到同一实例。 */
internal val lifecycleGate: MessageLifecycleGate,
) {
private val log = org.slf4j.LoggerFactory.getLogger(ReplayService::class.java)
/** 可以重放的错误类:解码逻辑修好后能过、处理器补齐后能过、基础设施抖动已恢复、以及重试耗尽但人工复核认为还能再试的。 */
@@ -130,6 +130,8 @@ class StubProcState : ProcStateRepository {
backfillNextAt = now,
backfillAttempts = 0,
backfillError = null,
backfillAbandonedAt = null,
backfillAbandonedReason = null,
updatedAt = now,
)
}
@@ -151,23 +153,54 @@ class StubProcState : ProcStateRepository {
}
}
override fun markBackfillAbandoned(msgId: Long, reason: String, now: Instant): Boolean {
val old = rows[msgId] ?: return false
if (old.backfillAt != null || old.backfillAbandonedAt != null) return false
// 放弃 ≠ 标记已确认:这里**不**设 backfillAt,清除前提因此仍不成立。
rows[msgId] = old.copy(
backfillAbandonedAt = now,
backfillAbandonedReason = reason,
backfillNextAt = null,
updatedAt = now,
)
return true
}
override fun reopenBackfill(msgId: Long, now: Instant): Boolean {
val old = rows[msgId] ?: return false
if (old.backfillAt != null || old.backfillAbandonedAt == null) return false
rows[msgId] = old.copy(
backfillAbandonedAt = null,
backfillAbandonedReason = null,
backfillNextAt = now,
updatedAt = now,
)
return true
}
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue> =
rows.values
.filter { it.state.isTerminal() && it.backfillAt == null }
.filter { it.state.isTerminal() && it.backfillAt == null && it.backfillAbandonedAt == null }
.filter {
it.backfillNextAt == null || it.backfillNextAt <= now ||
(it.receivedAt != null && it.receivedAt < overdueBefore)
}
.sortedBy { it.msgId }
// 公平轮转:先按已尝试次数,再按 msg_id。只按 msg_id 会让最旧的一批永久失败行占满批次。
.sortedWith(compareBy({ it.backfillAttempts }, { it.msgId }))
.take(limit)
.map { BackfillDue(it.msgId, it.backfillAttempts) }
override fun hasAny(): Boolean = rows.isNotEmpty()
override fun backlog(): Backlog {
val unfinished = rows.values.filter { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }
val unmarked = rows.values.filter { it.state.isTerminal() && it.backfillAt == null }
return Backlog(
unfinished = unfinished.size,
oldestReceivedAt = unfinished.mapNotNull { it.receivedAt }.minOrNull(),
unmarkedTerminal = rows.values.count { it.state.isTerminal() && it.backfillAt == null },
unmarkedTerminal = unmarked.size,
abandonedBackfill = unmarked.count { it.backfillAbandonedAt != null },
oldestUnmarkedAt = unmarked.filter { it.backfillAbandonedAt == null }.mapNotNull { it.receivedAt }.minOrNull(),
)
}
@@ -404,6 +437,10 @@ class StubInbox : CminmsgInboxRepository {
override fun maxId(): Long? = raws.keys.maxOrNull()
override fun minId(): Long? = raws.keys.minOrNull()
override fun existingIds(msgIds: Collection<Long>): Set<Long> = msgIds.filter { raws.containsKey(it) }.toSet()
/** 只写还没有标记的行,并区分已有标记与行缺失。 */
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult {
if (!raws.containsKey(msgId)) return MailboxMarkResult.MISSING
@@ -444,7 +481,12 @@ class StubInboxCursor : InboxCursorRepository {
override fun load(): InboxCursorRepository.Cursor = cursor
override fun save(cursor: InboxCursorRepository.Cursor) {
this.cursor = cursor
// 普通轮次只推进水位与空洞计时;播种标记由 markSeeded 负责,不能被这里抹掉。
this.cursor = this.cursor.copy(committedUpTo = cursor.committedUpTo, holeSince = cursor.holeSince)
}
override fun markSeeded(committedUpTo: Long, now: Instant) {
cursor = InboxCursorRepository.Cursor(committedUpTo = committedUpTo, holeSince = null, seededAt = now)
}
}