feat(metrics): 投递失败与死信可观测指标,修 flushSchd 首次失败原因丢失(ACM2-98)

- PipelineCounters 按 target 累计发送失败;Dispatcher.retryOrDead 记账(OPS-2)
- 新增 deadByTarget 查询与 DeliveryDeadSnapshotProvider(TTL 缓存),死信存量按 target 暴露
- /metrics 注册 send_failures.total{target} 与 dead{target},登记进 reference.md「指标与健康」
- flushSchd 失败改记真实异常原因,不再退回固定 send-failed
This commit is contained in:
windyboy
2026-09-21 12:40:53 +08:00
parent a1c56f546f
commit 9bf879ff76
10 changed files with 142 additions and 6 deletions
+2
View File
@@ -95,6 +95,8 @@
| `msgx.pipeline.codec.srvt_seen.total` | 收到含 `SRVT` 段的消息数 | 非零:核对 `G-SRVT-VIPF` |
| `msgx.pipeline.codec.vipf_seen.total` | 收到含 `VIPF` 段的消息数 | 非零:核对 `G-SRVT-VIPF` |
| `msgx.pipeline.processing.ignored.total` | 被 `IgnoreRules` 跳过的消息数 | 清单含 `REGN``RSTA``EROR`,与 `US-13``US-09` 冲突 |
| `msgx.pipeline.delivery.send_failures.total{target}` | 该投递目标累计发送失败次数(进程内,重启归零) | 持续增长且 `dead` 非零:投递链路故障(`OPS-2` |
| `msgx.pipeline.delivery.dead{target}` | 该投递目标当前死信(`DEAD`)行数 | 非零即告警:死信需人工处置(`OPS-2`;状态语义见 [implementation.md](implementation.md)「状态与错误分类」) |
| `msgx.pipeline.watermark.lag` | 信箱最新编号比已扫描编号大多少 | 改用处理时间筛选后删除(`G-SCAN-PREDICATE` |
未处理消息的统计使用同一份缓存;统计不可用显示 `NaN`,没有记录可比时年龄与编号差显示 `-1`。作业计数在进程重启后归零;作业已启动却连续三个检查周期没有成功运行时,`/health``DOWN`
@@ -45,6 +45,7 @@ class Dispatcher(
private val port: DeliveryPort,
private val props: PipelineProps,
private val scheduler: FailureScheduler,
private val counters: com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters,
) {
private val log = org.slf4j.LoggerFactory.getLogger(Dispatcher::class.java)
@@ -162,7 +163,7 @@ class Dispatcher(
lastFlush = scheduler.now()
return
}
val failures = mutableListOf<MsgEvent>()
val failures = mutableListOf<Pair<MsgEvent, String>>()
for (e in batch) {
try {
when (e.eventType) {
@@ -172,15 +173,17 @@ class Dispatcher(
// 条件确认:读取时刻的代次(EVENT_ID + STATE_VERSION)被新写入覆盖时不标记,留待下一轮重发。
e.eventId?.let { msgEvents.markSentIfVersion(it, e.stateVersion, scheduler.now()) }
} catch (ex: Exception) {
failures.add(e)
// 首次失败就带上真实原因;不能退回字面量把根因抹掉
failures += e to (ex.message ?: ex.javaClass.simpleName)
}
}
failures.forEach { retryOrDead(it, it.lastError ?: "send-failed") }
failures.forEach { (e, error) -> retryOrDead(e, error) }
lastFlush = scheduler.now()
}
/** 一条事件发失败之后怎么走:重试次数加一,到上限就标成 DEAD(EXHAUSTED) 留作死信(次数落库便于追查),否则按退避推到下次再发。 */
private fun retryOrDead(e: MsgEvent, lastError: String) {
counters.sendFailedAdd(e.target) // OPS-2:发送失败按 target 计数,供 /metrics 告警
val eventId = e.eventId ?: return
val attempts = e.attempts + 1
if (scheduler.exhausted(attempts)) {
@@ -0,0 +1,44 @@
package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
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
/**
* 投递死信统计(每个 target 的 `DEAD` 行数)的唯一取数点,带 TTL 缓存。
*
* 与 [BacklogSnapshotProvider] 同一设计:`/metrics` 抓取可能高频发生,
* `GROUP BY target` 聚合不该每次直打数据库;数字最多滞后一个缓存窗口,对告警信号足够。
* 缓存窗口沿用 `msgx.health.backlog-cache-ttl-ms`。
*/
@Singleton
class DeliveryDeadSnapshotProvider(
private val msgEvents: BeanProvider<MsgEventRepository>,
private val clock: Clock,
@Value("\${msgx.health.backlog-cache-ttl-ms:30000}") private val ttlMs: Long = 30_000,
) {
@Volatile
private var cached: Map<String, Int>? = null
@Volatile
private var cachedAt: Instant? = null
/** @return target → 死信行数;仓储未绑定时返回 null,由调用方按"未绑定"处理。 */
fun snapshot(): Map<String, Int>? {
if (!msgEvents.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 = msgEvents.get().deadByTarget()
cached = fresh
cachedAt = now
return fresh
}
}
@@ -16,6 +16,7 @@ class PipelineCounters {
private val srvtSeen = AtomicLong(0)
private val vipfSeen = AtomicLong(0)
private val ignored = AtomicLong(0)
private val sendFailures = java.util.concurrent.ConcurrentHashMap<String, AtomicLong>()
/**
* 入站记录里出现 `SRVT`/`VIPF` 段的条数(`[G-SRVT-VIPF]`)。
@@ -36,4 +37,14 @@ class PipelineCounters {
fun ignoredAdd(count: Int = 1) { ignored.addAndGet(count.toLong()) }
fun ignoredCount(): Long = ignored.get()
/**
* 按投递目标(`KAFKA:msg`/`KAFKA:schd`)累计的发送失败次数(`OPS-2`)。
* 每次记退避或死信时累加;重启归零,跨重启的累计由指标后端聚合。
*/
fun sendFailedAdd(target: String) {
sendFailures.computeIfAbsent(target) { AtomicLong(0) }.incrementAndGet()
}
fun sendFailedCount(target: String): Long = sendFailures[target]?.get() ?: 0L
}
@@ -1,6 +1,7 @@
package com.gzzn.omms.msgexchange.infra.metrics
import com.gzzn.omms.msgexchange.infra.health.BacklogSnapshotProvider
import com.gzzn.omms.msgexchange.infra.health.DeliveryDeadSnapshotProvider
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import io.micronaut.context.BeanProvider
@@ -29,6 +30,8 @@ import java.time.Duration
* - `msgx.pipeline.codec.srvt_seen.total` / `msgx.pipeline.codec.vipf_seen.total`:入站记录里出现
* `SRVT`/`VIPF` 段的条数(尚未落明细表,`[G-SRVT-VIPF]`> 0 表示真实流量确有该段)
* - `msgx.pipeline.processing.ignored.total`:命中 US-04 忽略清单的报文条数
* - `msgx.pipeline.delivery.send_failures.total{target}`:按 target 的投递发送失败次数(`OPS-2`)
* - `msgx.pipeline.delivery.dead{target}`:按 target 的死信(`DEAD`)存量行数(`OPS-2`,非零需人工处置)
*
* 取数统一走 [BacklogSnapshotProvider]30 秒 TTL),因此指标抓取不会打穿数据库。
* 无法取数时以 `NaN` 上报(Micrometer 的惯例表示"本次无值"),而不是伪造 0。
@@ -44,6 +47,7 @@ import java.time.Duration
class PipelineMetrics(
private val registry: MeterRegistry,
private val backlogs: BacklogSnapshotProvider,
private val deadEvents: DeliveryDeadSnapshotProvider,
private val cursor: BeanProvider<InboxCursorRepository>,
private val mailbox: BeanProvider<CminmsgInboxRepository>,
private val activity: JobActivity,
@@ -106,6 +110,24 @@ class PipelineMetrics(
Gauge.builder("msgx.pipeline.processing.ignored.total", counters) { it.ignoredCount().toDouble() }
.strongReference(true)
.register(registry)
// OPS-2:投递失败可观测。发送失败按 target 计数(进程内,重启归零);
// 死信存量按 target 从库端聚合取(带 TTL 缓存)。非零死信是人工处置信号。
for (target in listOf(com.gzzn.omms.msgexchange.domain.Targets.KAFKA_MSG, com.gzzn.omms.msgexchange.domain.Targets.KAFKA_SCHD)) {
Gauge.builder("msgx.pipeline.delivery.send_failures.total", counters) { it.sendFailedCount(target).toDouble() }
.tag("target", target)
.strongReference(true)
.register(registry)
Gauge.builder("msgx.pipeline.delivery.dead", deadEvents) { provider ->
val dead = provider.snapshot()
// 仓储未绑定按无值(NaN)上报;已绑定但该 target 无死信行是确定的 0
if (dead == null) Double.NaN else (dead[target] ?: 0).toDouble()
}
.tag("target", target)
.strongReference(true)
.register(registry)
}
}
private fun backlogGauge(name: String, value: (com.gzzn.omms.msgexchange.infra.persistence.Backlog) -> Double) {
@@ -186,6 +186,9 @@ interface MsgEventRepository {
fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int? = null)
/** 死信统计:每个 target 的 `STATE='DEAD'` 行数(投递告警信号,`OPS-2`)。 */
fun deadByTarget(): Map<String, Int>
/**
* 删除已确认投递且超过保留期的事件行:`STATE='SENT' AND SENT_AT < cutoff`。
* 带 `STATE='SENT'` 条件,避免与 upsertSchd 的并发写入冲突。
@@ -562,6 +562,13 @@ class JdbcMsgEventRepository(
)
}
override fun deadByTarget(): Map<String, Int> =
ds.query(
"SELECT target, count(*) AS total FROM msg_event WHERE state = 'DEAD' GROUP BY target",
{ /* 无绑定参数 */ },
{ rs -> rs.getString("target") to rs.getInt("total") },
).toMap()
override fun deleteExpiredSent(cutoff: Instant, limit: Int): Int =
ds.update(
"""
@@ -304,6 +304,9 @@ class StubMsgEvents : MsgEventRepository {
)
}
override fun deadByTarget(): Map<String, Int> =
rows.values.filter { it.state == EventStatus.DEAD }.groupingBy { it.target }.eachCount()
override fun deleteExpiredSent(cutoff: Instant, limit: Int): Int {
val ids = rows.values
.filter { it.state == EventStatus.SENT && it.sentAt != null && it.sentAt < cutoff }
@@ -8,6 +8,7 @@ import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
@@ -28,8 +29,12 @@ class DispatcherTickTest {
private val clock = MutableClock(MutableClock.BASE)
private fun dispatcher(repo: MsgEventRepository, port: DeliveryPort, p: PipelineProps = PipelineProps()): Dispatcher =
Dispatcher(repo, port, p, FailureScheduler(p, clock))
private fun dispatcher(
repo: MsgEventRepository,
port: DeliveryPort,
p: PipelineProps = PipelineProps(),
counters: PipelineCounters = PipelineCounters(),
): Dispatcher = Dispatcher(repo, port, p, FailureScheduler(p, clock), counters)
private fun ev(id: Long, target: String, key: String, payload: String, version: Long = 0) =
MsgEvent(eventId = id, target = target, partitionKey = key, stateVersion = version, payloadJson = payload, createdAt = MutableClock.BASE)
@@ -123,11 +128,14 @@ class DispatcherTickTest {
fun `schd send failure schedules backoff and goes DEAD at limit`() {
val repo = StubMsgEvents()
val p = PipelineProps()
val d = dispatcher(repo, FailingSchdPort(), p)
val counters = PipelineCounters()
val d = dispatcher(repo, FailingSchdPort(), p, counters)
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}""", 1)))
d.flushSchd()
assertEquals(1, repo.rows[1]!!.attempts)
// OPS-2:发送失败按 target 计数,且失败原因不是固定字面量
assertEquals(1L, counters.sendFailedCount(Targets.KAFKA_SCHD))
repeat(p.pipeline.maxAttempts - 1) {
clock.advance(p.pipeline.backoffFor(repo.rows[1]!!.attempts) + 1)
@@ -135,6 +143,7 @@ class DispatcherTickTest {
}
val final = repo.rows[1]!!
assertTrue(final.attempts >= p.pipeline.maxAttempts || final.state.name == "DEAD")
assertEquals(0L, counters.sendFailedCount(Targets.KAFKA_MSG)) // msg 目标不受 schd 失败影响
}
@Test
@@ -1,6 +1,10 @@
package com.gzzn.omms.msgexchange.infra.metrics
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import io.micrometer.core.instrument.MeterRegistry
import io.micronaut.context.ApplicationContext
@@ -74,6 +78,34 @@ class PipelineMetricsTest {
assertEquals(vipfBefore + 1.0, gauge("msgx.pipeline.codec.vipf_seen.total"), 0.001)
}
@Test
fun `delivery send-failure and dead-letter gauges are registered per target`() {
val counters = ctx.getBean(PipelineCounters::class.java)
val events = ctx.getBean(StubMsgEvents::class.java)
val failuresBefore = taggedGauge("msgx.pipeline.delivery.send_failures.total", Targets.KAFKA_SCHD)
counters.sendFailedAdd(Targets.KAFKA_SCHD)
val ids = events.insertAll(
listOf(
MsgEvent(target = Targets.KAFKA_MSG, partitionKey = "F1", stateVersion = 1, payloadJson = "{}", createdAt = Instant.now()),
),
)
events.markDead(ids.single(), ErrorClass.EXHAUSTED, "boom", attempts = 5)
assertEquals(
failuresBefore + 1.0,
taggedGauge("msgx.pipeline.delivery.send_failures.total", Targets.KAFKA_SCHD),
0.001,
)
assertEquals(1.0, taggedGauge("msgx.pipeline.delivery.dead", Targets.KAFKA_MSG), 0.001)
assertEquals(0.0, taggedGauge("msgx.pipeline.delivery.dead", Targets.KAFKA_SCHD), 0.001)
}
private fun gauge(name: String): Double =
requireNotNull(registry.find(name).gauge()) { "gauge not registered: $name" }.value()
private fun taggedGauge(name: String, target: String): Double =
requireNotNull(registry.find(name).tag("target", target).gauge()) {
"gauge not registered: $name(target=$target)"
}.value()
}