feat(ingress): 按处理时间为空扫描信箱,移除水位消费(ACM2-78)

readUnprocessed 替代 ID 水位;去掉 INBOX_CURSOR/cutover-watermark/max-commit-delay 及相关健康与指标。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-09-21 14:49:40 +08:00
co-authored by Cursor
parent 23b74ed554
commit 891088235c
29 changed files with 166 additions and 805 deletions
@@ -1,8 +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
import io.micronaut.context.BeanProvider
import io.micronaut.core.async.publisher.Publishers
@@ -19,8 +17,7 @@ import java.time.Instant
* 在 /health 里输出收报与回填的当前情况,用来观察积压消化得怎么样:
* - `backlog`:还没处理完的消息条数;
* - `oldestUnprocessedSeconds`:最老一条未处理消息从收到到现在过了多久;
* - `unmarkedTerminal`:已经处理完、但还没把标记写回信箱的条数(回填跟不上时这个数会涨)
* - `watermark` / `watermarkLag`:收报读到哪个 ID 了、落后信箱最新 ID 多少。
* - `unmarkedTerminal`:已经处理完、但还没把标记写回信箱的条数(回填跟不上时这个数会涨)
*
* 相关工作没接上时(比如没连共享信箱)只提示"未绑定",不判 DOWN——依赖本身是否可用
* 由各自的健康指示器回答,这里只报告业务状态。只有查询出错才判 DOWN。
@@ -28,8 +25,6 @@ import java.time.Instant
@Singleton
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 {
@@ -39,8 +34,6 @@ class InboxLifecycleHealthIndicator(
runCatching {
lifecycleHealth(
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(),
@@ -52,8 +45,6 @@ class InboxLifecycleHealthIndicator(
/** 组装上面那几个指标;单独抽出来是为了能在测试里直接调用。 */
internal fun lifecycleHealth(
procState: ProcStateRepository?,
cursor: InboxCursorRepository?,
mailbox: CminmsgInboxRepository?,
now: Instant,
/** 允许调用方传入缓存/已算好的积压快照;为空时现查(`backlog()` 是全表聚合)。 */
backlog: Backlog? = null,
@@ -64,8 +55,6 @@ internal fun lifecycleHealth(
.build()
}
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 snapshot.unfinished,
@@ -79,8 +68,6 @@ internal fun lifecycleHealth(
"oldestUnmarkedBackfillSeconds" to (
snapshot.oldestUnmarkedAt?.let { Duration.between(it, now).seconds } ?: -1L
),
"watermark" to (watermark ?: -1L),
"watermarkLag" to if (watermark != null && maxId != null) maxId - watermark else -1L,
),
).build()
}
@@ -2,9 +2,6 @@ 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
import io.micronaut.context.annotation.Context
import io.micronaut.context.annotation.Requires
import io.micrometer.core.instrument.Gauge
@@ -22,7 +19,6 @@ import java.time.Duration
* - `msgx.pipeline.backfill.unmarked_terminal`:已终态但未打标的条数
* - `msgx.pipeline.backfill.abandoned`:已放弃自动回填的条数(**非 0 需人工对账**)
* - `msgx.pipeline.backfill.oldest_unmarked_seconds`:最老一条仍待自动回填的年龄
* - `msgx.pipeline.watermark.lag`:水位落后信箱最新 ID 的距离
* - `msgx.pipeline.job.heartbeat_age_seconds`:距上一次作业 tick 完成的秒数(未跑过为 -1)
* - `msgx.pipeline.job.last_failure_age_seconds`:距最近一次作业 tick 失败的秒数(从未失败为 -1)
* - `msgx.pipeline.job.ticks.total` / `msgx.pipeline.job.failures.total`:作业 tick 完成/抛错次数
@@ -48,8 +44,6 @@ 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,
private val clock: Clock,
private val counters: PipelineCounters,
@@ -71,12 +65,6 @@ class PipelineMetrics(
?: -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)
// 作业心跳与扫描积压:作业线程是回填的唯一驱动,停摆必须能被 /metrics 与 /health 看见。
Gauge.builder("msgx.pipeline.job.heartbeat_age_seconds", activity) { a ->
a.snapshot().lastTickAt?.let { Duration.between(it, clock.instant()).seconds.toDouble() } ?: -1.0
@@ -308,52 +308,13 @@ interface ReqTrackRepository {
fun expire(reqId: Long)
}
/**
* 收报进度(水位 W):记下"信箱里到哪个 ID 为止已经全部读进自有库",全表只有一行。
*
* 光记一个数字不够,还要记住缺口是什么时候出现的:如果 W 后面缺了一个 ID,就先停在
* 缺口前面等(可能是上游还没提交完,随时会补上)。等的时间超过最大提交时延,就改判为
* 永久缺失、跳过去继续推进——否则一次自增回滚留下的空位就能让水位永远卡住,
* 它后面的消息再也进不了队。
*
* 水位推进与入队在同一个 PG 事务里提交:中途崩溃时水位没动,重启后重扫一遍即可补齐。
*/
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,
val seededAt: Instant? = null,
)
fun load(): Cursor
/** 只推进水位与空洞计时,不改动 [Cursor.seededAt]。 */
fun save(cursor: Cursor)
/**
* 切流播种:一次性写入水位并把空洞计时清空,同时记录播种事实。
* 与 [save] 分开,避免"普通轮次推进水位"把播种标记抹掉。
*/
fun markSeeded(committedUpTo: Long, now: Instant)
}
/**
* 共享 MySQL 信箱 CMINMSGS 的读写入口。这个库是别人的,本系统只做约定的读写,
* 不建表、不改结构。
*
* 这里把三件事分得很清楚,谁也不代替谁:
* - **发现**:按 ID 区间读有哪些新消息([readRange]);
* - **进度**:读到哪儿了记在自有库的水位里(见 [InboxCursorRepository]);
* - **标记**:处理完了把"已处理"写回信箱([markProcessedIfUnmarked])。
*
* 特别是发现,不能拿"有没有处理标记"当筛选条件:处理完但还没回填的行,以及永远不会
* 回填的死信,会一直占着每一批的名额,攒够一批之后新消息就再也读不到了。
* 发现与标记是两件事:发现只看处理时间是否为空([readUnprocessed]),
* 标记只在处理结束后补写([markProcessedIfUnmarked])。消费边界不落库,
* 重复读到同一行由 PROC_STATE 主键挡掉(`INV-1`)。
*/
interface CminmsgInboxRepository {
/** 兼容入口往信箱写一条报文,返回新的信箱 ID。 */
@@ -365,14 +326,13 @@ interface CminmsgInboxRepository {
/** 读取信箱库记录的实际接收时间。 */
fun receivedAtOf(msgId: Long): Instant?
/** 按 ID 升序读一批 `ID > fromExclusive` 的行,不带别的过滤条件。 */
fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow>
/** 信箱当前最大 ID,空表返回 null;只用来观测收报落后了多少。 */
fun maxId(): Long?
/** 信箱当前最小 ID,空表返回 null;切流播种用它推算 `W = MIN(ID) 1`。 */
fun minId(): Long?
/**
* 按 ID 升序读一批处理时间为空、且 `ID > afterId` 的行。
*
* [afterId] 只是同一轮扫描里的续读起点:已登记但还没回填的行会被反复读到,
* 若每轮都从头读同一批,它们攒够一批之后新行就再也读不到。
*/
fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow>
/**
* 把处理标记写回信箱,并且**只写还是空标记的行**:库里已有值时不覆盖、不回退,
@@ -14,8 +14,8 @@ import java.time.Instant
* 只做增删改查,不建表、不改结构——这个库属于别的系统。
*
* 两处刻意为之:
* - 取新消息只看 ID`ID > ?`),不看 `DATE_PROCESSED`。用处理标记当条件的话
* 处理完但还没回填的行长期占住每批名额,死信攒够一批就再也发现不了新消息
* - 取新消息只看 `DATE_PROCESSED IS NULL``INV-1`);`ID > ?` 只是同一轮扫描的续读起点
* 让已登记却还没回填的行长期占住每批名额。
* - 写回处理标记带 `DATE_PROCESSED IS NULL` 条件,一行只会被标记一次,不会覆盖已有值。
*/
@Singleton
@@ -57,16 +57,16 @@ class JdbcCminmsgInboxRepository(
{ ps -> ps.setLong(1, msgId) },
) { rs -> rs.getTimestamp("CMINMSGS_DATE_RECEIVED")?.toInstant() }
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> =
override fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow> =
ds.query(
"""
SELECT CMINMSGS_ID, CMINMSGS_DATE_RECEIVED FROM cminmsgs
WHERE CMINMSGS_ID > ?
WHERE CMINMSGS_DATE_PROCESSED IS NULL AND CMINMSGS_ID > ?
ORDER BY CMINMSGS_ID ASC
LIMIT ?
""".trimIndent(),
{ ps ->
ps.setLong(1, fromExclusive)
ps.setLong(1, afterId)
ps.setInt(2, limit)
},
) { rs ->
@@ -76,16 +76,6 @@ class JdbcCminmsgInboxRepository(
)
}
override fun maxId(): Long? =
ds.queryOne("SELECT MAX(CMINMSGS_ID) AS max_id FROM cminmsgs", {}) { rs ->
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() }
}
/**
* 只更新还是空标记的行,所以重复调用不会覆盖库里已有的值;
* 影响 0 行时再查一次主键,区分“已有标记”和“信箱行缺失”;后者不能记为回填成功。
@@ -18,7 +18,6 @@ import com.gzzn.omms.msgexchange.infra.persistence.BackfillDue
import com.gzzn.omms.msgexchange.infra.persistence.Backlog
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
@@ -37,7 +36,6 @@ 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
@@ -362,90 +360,6 @@ class JdbcProcStateRepository(
}
}
/** 收报水位游标(单行)的 JDBC 实现;水位推进与入队在同一个事务里提交。 */
@Singleton
@Requires(property = "datasources.default.enabled", value = "true")
@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, seeded_at FROM inbox_cursor WHERE cursor_id = 1",
{},
) { 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) {
// 先 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, 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
@Requires(property = "datasources.default.enabled", value = "true")
@Requires(missingProperty = "msgx.stubs")
@@ -15,7 +15,6 @@ import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
import com.gzzn.omms.msgexchange.infra.persistence.BackfillDue
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.MailboxRow
import com.gzzn.omms.msgexchange.infra.persistence.MailboxMarkResult
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
@@ -512,12 +511,9 @@ class StubInbox(private val clock: Clock = Clock.systemUTC()) : CminmsgInboxRepo
override fun receivedAtOf(msgId: Long): Instant? = received[msgId]
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> =
raws.keys.filter { it > fromExclusive }.sorted().take(limit).map { MailboxRow(it, received[it]) }
override fun maxId(): Long? = raws.keys.maxOrNull()
override fun minId(): Long? = raws.keys.minOrNull()
override fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow> =
raws.keys.filter { it > afterId && !marks.containsKey(it) }
.sorted().take(limit).map { MailboxRow(it, received[it]) }
/** 只写还没有标记的行,并区分已有标记与行缺失。 */
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult {
@@ -546,28 +542,6 @@ class StubInbox(private val clock: Clock = Clock.systemUTC()) : CminmsgInboxRepo
}
}
/** 内存版收报水位游标。 */
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubInboxCursor : InboxCursorRepository {
var cursor: InboxCursorRepository.Cursor = InboxCursorRepository.Cursor()
fun clear() {
cursor = InboxCursorRepository.Cursor()
}
override fun load(): InboxCursorRepository.Cursor = cursor
override fun save(cursor: InboxCursorRepository.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)
}
}
/** 判断是不是终态:处理已经结束、不会再重试的状态。 */
private fun ProcStatus.isTerminal(): Boolean =
this == ProcStatus.SUCCEEDED || this == ProcStatus.SKIPPED || this == ProcStatus.DEAD