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
@@ -5,7 +5,7 @@
-- --
-- 报文格式:SIS 接口规范(docs/legacy/SIS_AODB_RMS-V0.1.mdMETA + 类型体, -- 报文格式:SIS 接口规范(docs/legacy/SIS_AODB_RMS-V0.1.mdMETA + 类型体,
-- XML 按 docs/legacy/unisysaodbsis.xsd;只 INSERT 契约内列,不建表不变更(C-2)。 -- XML 按 docs/legacy/unisysaodbsis.xsd;只 INSERT 契约内列,不建表不变更(C-2)。
-- INBOX_CURSOR 初值 W=0,应用首启即全部消费。SEQN 15,与 01-mailbox.sql -- 处理时间为空,应用首启即全部消费。SEQN 15,与 01-mailbox.sql
-- (仅建表、无数据)无冲突。 -- (仅建表、无数据)无冲突。
-- --
-- 两条参考数据报文验证的是【现状行为】,US-13 落地后行为会变,需同步更新: -- 两条参考数据报文验证的是【现状行为】,US-13 落地后行为会变,需同步更新:
+1 -1
View File
@@ -9,7 +9,7 @@
-- 故按 FLID 先删后插(implementation「字段与集合」的完整合并口径)。 -- 故按 FLID 先删后插(implementation「字段与集合」的完整合并口径)。
-- CREATED_AT/UPDATED_AT 为 NOT NULL,显式给值。 -- CREATED_AT/UPDATED_AT 为 NOT NULL,显式给值。
-- 只写决策层(FLIGHT_SCHD + 明细),不碰管道层(PROC_STATE/MSG_EVENT/ -- 只写决策层(FLIGHT_SCHD + 明细),不碰管道层(PROC_STATE/MSG_EVENT/
-- INBOX_CURSOR/PIPELINE_LOCK/REQ_TRACK/SCHD_SNAP_LOG)。 -- PIPELINE_LOCK/REQ_TRACK/SCHD_SNAP_LOG)。
-- 执行:psql -h 127.0.0.1 -p 15432 -U msgx_dev -d msgx -f 本文件 -- 执行:psql -h 127.0.0.1 -p 15432 -U msgx_dev -d msgx -f 本文件
-- ===================================================================== -- =====================================================================
+1
View File
@@ -76,6 +76,7 @@ msgexchange-v2 怎么处理报文:记录模型、状态机、事务边界、
1. 信箱不可读 → 记日志,下轮再试(不是「无新消息」)。 1. 信箱不可读 → 记日志,下轮再试(不是「无新消息」)。
2. 同事务 `insertIfAbsent(MSG_ID, RECEIVED_AT, ENQUEUED_AT)`;冲突 = 已有记录,跳过。 2. 同事务 `insertIfAbsent(MSG_ID, RECEIVED_AT, ENQUEUED_AT)`;冲突 = 已有记录,跳过。
3. 已写回处理时间的行不再出现;终态未写回的行会重复扫到,幂等跳过。 3. 已写回处理时间的行不再出现;终态未写回的行会重复扫到,幂等跳过。
4. 读满一批就按编号续读下一批,续读起点只在本轮有效:否则未写回的行攒满一批之后,后面的新行再也读不到。
写回延迟期间扫描量会涨,须观测积压([reference.md](reference.md) 指标)。 写回延迟期间扫描量会涨,须观测积压([reference.md](reference.md) 指标)。
+1 -5
View File
@@ -22,7 +22,7 @@
| `msgx.pipeline.event-retention` | `7d` | 发送成功的消息在数据库保留多久,从 `SENT_AT` 起算 | 暂定 | | `msgx.pipeline.event-retention` | `7d` | 发送成功的消息在数据库保留多久,从 `SENT_AT` 起算 | 暂定 |
| `msgx.pipeline.terminal-retention` | `90d` | 处理记录(PROC_STATE 终态行)保留多久,从 `UPDATED_AT` 起算;到期且已回填才删除(`US-11` | 暂定 | | `msgx.pipeline.terminal-retention` | `90d` | 处理记录(PROC_STATE 终态行)保留多久,从 `UPDATED_AT` 起算;到期且已回填才删除(`US-11` | 暂定 |
### 写回信箱与编号扫描 ### 写回信箱
| 参数 | 默认值 | 用途与约束 | 依据 | | 参数 | 默认值 | 用途与约束 | 依据 |
|---|---|---|---| |---|---|---|---|
@@ -31,8 +31,6 @@
| `msgx.pipeline.backfill-max-attempts` | `100` 次 | 失败次数达到此值时告警,仍继续重试直到期限 `R` | 暂定 | | `msgx.pipeline.backfill-max-attempts` | `100` 次 | 失败次数达到此值时告警,仍继续重试直到期限 `R` | 暂定 |
| `msgx.pipeline.backfill-backoff-ms` | `30000` ms | 首次写回失败后等多久再试 | 暂定 | | `msgx.pipeline.backfill-backoff-ms` | `30000` ms | 首次写回失败后等多久再试 | 暂定 |
| `msgx.pipeline.backfill-backoff-cap-ms` | `900000` ms | 以后每次重试最长等多久 | 暂定 | | `msgx.pipeline.backfill-backoff-cap-ms` | `900000` ms | 以后每次重试最长等多久 | 暂定 |
| `msgx.pipeline.max-commit-delay` | `5m` | 当前按编号读信箱时,遇到缺号最多等待多久;改用处理时间筛选后删除(`G-SCAN-PREDICATE` | 待替换 |
| `msgx.pipeline.cutover-watermark` | 不设置 | 首次启动时从哪个编号开始读信箱;可填 `min``zero``max` 或具体编号;改用处理时间筛选后删除(`G-SCAN-PREDICATE` | 待替换 |
### 固定调度 ### 固定调度
@@ -89,7 +87,6 @@
| `msgx.operation-day.cutoff-hour` | 取值 023 | `PARAM:msgx.operation-day.cutoff-hour` | | `msgx.operation-day.cutoff-hour` | 取值 023 | `PARAM:msgx.operation-day.cutoff-hour` |
| `kafka.producers.default.acks` / `enable-idempotence` / `max-in-flight-requests-per-connection` | 联合满足幂等生产前提(`all`+`true`+`1`;旧 Broker 降级组合见 `D2` | `D2` | | `kafka.producers.default.acks` / `enable-idempotence` / `max-in-flight-requests-per-connection` | 联合满足幂等生产前提(`all`+`true`+`1`;旧 Broker 降级组合见 `D2` | `D2` |
| `msgx.pipeline.backoff-ms` | 档位数必须等于 `max-attempts - 1` | 退避表(implementation.md「参数」) | | `msgx.pipeline.backoff-ms` | 档位数必须等于 `max-attempts - 1` | 退避表(implementation.md「参数」) |
| `msgx.pipeline.cutover-watermark` | 仅 `min`/`zero`/`max`/数字 | 切流播种(`G-SCAN-PREDICATE` 前) |
**缺值**:带环境变量占位的必需键(`MSGX_PG_URL``MSGX_MAILBOX_URL``MSGX_KAFKA_SERVERS` 等)缺失时由 DI 解析失败拒绝启动,无需自检代码。 **缺值**:带环境变量占位的必需键(`MSGX_PG_URL``MSGX_MAILBOX_URL``MSGX_KAFKA_SERVERS` 等)缺失时由 DI 解析失败拒绝启动,无需自检代码。
@@ -114,7 +111,6 @@
| `msgx.pipeline.processing.ignored.total` | 被 `IgnoreRules` 跳过的消息数 | 现行清单仅 `LDM-*``REGN`/`RSTA`/`EROR` 已改走 US-13/US-09 | | `msgx.pipeline.processing.ignored.total` | 被 `IgnoreRules` 跳过的消息数 | 现行清单仅 `LDM-*``REGN`/`RSTA`/`EROR` 已改走 US-13/US-09 |
| `msgx.pipeline.delivery.send_failures.total{target}` | 该投递目标累计发送失败次数(进程内,重启归零) | 持续增长且 `dead` 非零:投递链路故障(`OPS-2` | | `msgx.pipeline.delivery.send_failures.total{target}` | 该投递目标累计发送失败次数(进程内,重启归零) | 持续增长且 `dead` 非零:投递链路故障(`OPS-2` |
| `msgx.pipeline.delivery.dead{target}` | 该投递目标当前死信(`DEAD`)行数 | 非零即告警:死信需人工处置(`OPS-2`;状态语义见 [implementation.md](implementation.md)「状态与错误分类」) | | `msgx.pipeline.delivery.dead{target}` | 该投递目标当前死信(`DEAD`)行数 | 非零即告警:死信需人工处置(`OPS-2`;状态语义见 [implementation.md](implementation.md)「状态与错误分类」) |
| `msgx.pipeline.watermark.lag` | 信箱最新编号比已扫描编号大多少 | 改用处理时间筛选后删除(`G-SCAN-PREDICATE` |
未处理消息的统计使用同一份缓存;统计不可用显示 `NaN`,没有记录可比时年龄与编号差显示 `-1`。作业计数在进程重启后归零;作业已启动却连续三个检查周期没有成功运行时,`/health``DOWN` 未处理消息的统计使用同一份缓存;统计不可用显示 `NaN`,没有记录可比时年龄与编号差显示 `-1`。作业计数在进程重启后归零;作业已启动却连续三个检查周期没有成功运行时,`/health``DOWN`
-1
View File
@@ -159,7 +159,6 @@
| `G-REQ-TRACK` | 出站请求跟踪未做 | `US-09` | | `G-REQ-TRACK` | 出站请求跟踪未做 | `US-09` |
| `G-REQ-TRACK-RETENTION` | `REQ_TRACK` 已结案保留期未定(`Q24` | `US-09` | | `G-REQ-TRACK-RETENTION` | `REQ_TRACK` 已结案保留期未定(`Q24` | `US-09` |
| `G-RESP-GUARD` | `SCHD-RESP` 过期判断未做 | `US-07` | | `G-RESP-GUARD` | `SCHD-RESP` 过期判断未做 | `US-07` |
| `G-SCAN-PREDICATE` | 按「处理时间为空」扫描未做 | `US-01``INV-1` |
| `G-SCHD-SNAPSHOT` | 日计划快照删除、清空、分批未做 | `INV-7``INV-9` | | `G-SCHD-SNAPSHOT` | 日计划快照删除、清空、分批未做 | `INV-7``INV-9` |
| `G-SRVT-VIPF` | `SRVT``VIPF` 明细未入库(`Q2` | `US-05` | | `G-SRVT-VIPF` | `SRVT``VIPF` 明细未入库(`Q2` | `US-05` |
@@ -27,15 +27,6 @@ class PipelineProps {
var backoffMs: List<Long> = listOf(1000, 2000, 4000, 8000) var backoffMs: List<Long> = listOf(1000, 2000, 4000, 8000)
var backoffCapMs: Long = 60_000 var backoffCapMs: Long = 60_000
/**
* 缺口等待时长水位后面缺了一个 ID 等这么久还没出现就认定它永远不会来了
* 跳过缺口继续推进水位
*
* 取值应该等于库方承诺的"上游提交到消息可见的最长时间"设太小可能把一条
* 迟到的消息误判成永久缺失导致它排到后面的消息之后设太大收报会在缺口上白等
*/
var maxCommitDelay: Duration = Duration.ofMinutes(5)
/** /**
* 超期补写期限 R一条消息到达终态后过了这么久处理标记仍未写回信箱 * 超期补写期限 R一条消息到达终态后过了这么久处理标记仍未写回信箱
* 比如回填一直失败扫描谓词的超期分支成立无视退避强制补写只会提前从不推迟打标 * 比如回填一直失败扫描谓词的超期分支成立无视退避强制补写只会提前从不推迟打标
@@ -59,16 +50,6 @@ class PipelineProps {
/** 回填独立退避的封顶间隔。 */ /** 回填独立退避的封顶间隔。 */
var backfillBackoffCapMs: Long = 900_000 var backfillBackoffCapMs: Long = 900_000
/**
* 切流水位播种一次性显式取值
* `min` = `W:MIN(ID)1`读当前全部现存行`zero` = `W:0`按空洞规则从 0
* `max` = `W:MAX(ID)`跳过当前可见存量或一个具体 ID
*
* **默认 null = 不播种**保持既有行为是否跳过存量属于切流决策必须由人显式配置
* 代码不做默认选择也不会自动退化成 `max`升级实例已有水位或已有处理记录会拒绝重新播种
*/
var cutoverWatermark: String? = null
/** /**
* 普通事件`KAFKA:msg`每轮向一个目标领取的条数上限 * 普通事件`KAFKA:msg`每轮向一个目标领取的条数上限
* 逐条领取会让投递吞吐被"每条一次 DB 往返 + 一轮一次 sleep"压到每秒 1 * 逐条领取会让投递吞吐被"每条一次 DB 往返 + 一轮一次 sleep"压到每秒 1
@@ -119,17 +100,6 @@ class PipelineProps {
"msgx.pipeline.backoff-ms has ${backoffMs.size} slots, " + "msgx.pipeline.backoff-ms has ${backoffMs.size} slots, " +
"but max-attempts=$maxAttempts implies exactly ${maxAttempts - 1}" "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'"
}
} }
} }
@@ -1,8 +1,6 @@
package com.gzzn.omms.msgexchange.infra.health package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.infra.persistence.Backlog 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 com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import io.micronaut.context.BeanProvider import io.micronaut.context.BeanProvider
import io.micronaut.core.async.publisher.Publishers import io.micronaut.core.async.publisher.Publishers
@@ -19,8 +17,7 @@ import java.time.Instant
* /health 里输出收报与回填的当前情况用来观察积压消化得怎么样 * /health 里输出收报与回填的当前情况用来观察积压消化得怎么样
* - `backlog`还没处理完的消息条数 * - `backlog`还没处理完的消息条数
* - `oldestUnprocessedSeconds`最老一条未处理消息从收到到现在过了多久 * - `oldestUnprocessedSeconds`最老一条未处理消息从收到到现在过了多久
* - `unmarkedTerminal`已经处理完但还没把标记写回信箱的条数回填跟不上时这个数会涨 * - `unmarkedTerminal`已经处理完但还没把标记写回信箱的条数回填跟不上时这个数会涨
* - `watermark` / `watermarkLag`收报读到哪个 ID 落后信箱最新 ID 多少
* *
* 相关工作没接上时比如没连共享信箱只提示"未绑定"不判 DOWN依赖本身是否可用 * 相关工作没接上时比如没连共享信箱只提示"未绑定"不判 DOWN依赖本身是否可用
* 由各自的健康指示器回答这里只报告业务状态只有查询出错才判 DOWN * 由各自的健康指示器回答这里只报告业务状态只有查询出错才判 DOWN
@@ -28,8 +25,6 @@ import java.time.Instant
@Singleton @Singleton
class InboxLifecycleHealthIndicator( class InboxLifecycleHealthIndicator(
private val procState: BeanProvider<ProcStateRepository>, private val procState: BeanProvider<ProcStateRepository>,
private val cursor: BeanProvider<InboxCursorRepository>,
private val mailbox: BeanProvider<CminmsgInboxRepository>,
private val clock: Clock, private val clock: Clock,
private val backlogs: BacklogSnapshotProvider, private val backlogs: BacklogSnapshotProvider,
) : HealthIndicator { ) : HealthIndicator {
@@ -39,8 +34,6 @@ class InboxLifecycleHealthIndicator(
runCatching { runCatching {
lifecycleHealth( lifecycleHealth(
procState = if (procState.isPresent) procState.get() else null, 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(), now = clock.instant(),
// 与 /metrics 共用同一份 30 秒缓存:两者都不该把全表聚合打成高频查询。 // 与 /metrics 共用同一份 30 秒缓存:两者都不该把全表聚合打成高频查询。
backlog = backlogs.snapshot(), backlog = backlogs.snapshot(),
@@ -52,8 +45,6 @@ class InboxLifecycleHealthIndicator(
/** 组装上面那几个指标;单独抽出来是为了能在测试里直接调用。 */ /** 组装上面那几个指标;单独抽出来是为了能在测试里直接调用。 */
internal fun lifecycleHealth( internal fun lifecycleHealth(
procState: ProcStateRepository?, procState: ProcStateRepository?,
cursor: InboxCursorRepository?,
mailbox: CminmsgInboxRepository?,
now: Instant, now: Instant,
/** 允许调用方传入缓存/已算好的积压快照;为空时现查(`backlog()` 是全表聚合)。 */ /** 允许调用方传入缓存/已算好的积压快照;为空时现查(`backlog()` 是全表聚合)。 */
backlog: Backlog? = null, backlog: Backlog? = null,
@@ -64,8 +55,6 @@ internal fun lifecycleHealth(
.build() .build()
} }
val snapshot = 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( return HealthResult.builder(NAME).status(HealthStatus.UP).details(
linkedMapOf<String, Any>( linkedMapOf<String, Any>(
"backlog" to snapshot.unfinished, "backlog" to snapshot.unfinished,
@@ -79,8 +68,6 @@ internal fun lifecycleHealth(
"oldestUnmarkedBackfillSeconds" to ( "oldestUnmarkedBackfillSeconds" to (
snapshot.oldestUnmarkedAt?.let { Duration.between(it, now).seconds } ?: -1L 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() ).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.BacklogSnapshotProvider
import com.gzzn.omms.msgexchange.infra.health.DeliveryDeadSnapshotProvider 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.Context
import io.micronaut.context.annotation.Requires import io.micronaut.context.annotation.Requires
import io.micrometer.core.instrument.Gauge import io.micrometer.core.instrument.Gauge
@@ -22,7 +19,6 @@ import java.time.Duration
* - `msgx.pipeline.backfill.unmarked_terminal`已终态但未打标的条数 * - `msgx.pipeline.backfill.unmarked_terminal`已终态但未打标的条数
* - `msgx.pipeline.backfill.abandoned`已放弃自动回填的条数** 0 需人工对账** * - `msgx.pipeline.backfill.abandoned`已放弃自动回填的条数** 0 需人工对账**
* - `msgx.pipeline.backfill.oldest_unmarked_seconds`最老一条仍待自动回填的年龄 * - `msgx.pipeline.backfill.oldest_unmarked_seconds`最老一条仍待自动回填的年龄
* - `msgx.pipeline.watermark.lag`水位落后信箱最新 ID 的距离
* - `msgx.pipeline.job.heartbeat_age_seconds`距上一次作业 tick 完成的秒数未跑过为 -1 * - `msgx.pipeline.job.heartbeat_age_seconds`距上一次作业 tick 完成的秒数未跑过为 -1
* - `msgx.pipeline.job.last_failure_age_seconds`距最近一次作业 tick 失败的秒数从未失败为 -1 * - `msgx.pipeline.job.last_failure_age_seconds`距最近一次作业 tick 失败的秒数从未失败为 -1
* - `msgx.pipeline.job.ticks.total` / `msgx.pipeline.job.failures.total`作业 tick 完成/抛错次数 * - `msgx.pipeline.job.ticks.total` / `msgx.pipeline.job.failures.total`作业 tick 完成/抛错次数
@@ -48,8 +44,6 @@ class PipelineMetrics(
private val registry: MeterRegistry, private val registry: MeterRegistry,
private val backlogs: BacklogSnapshotProvider, private val backlogs: BacklogSnapshotProvider,
private val deadEvents: DeliveryDeadSnapshotProvider, private val deadEvents: DeliveryDeadSnapshotProvider,
private val cursor: BeanProvider<InboxCursorRepository>,
private val mailbox: BeanProvider<CminmsgInboxRepository>,
private val activity: JobActivity, private val activity: JobActivity,
private val clock: Clock, private val clock: Clock,
private val counters: PipelineCounters, private val counters: PipelineCounters,
@@ -71,12 +65,6 @@ class PipelineMetrics(
?: -1.0 ?: -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 看见。 // 作业心跳与扫描积压:作业线程是回填的唯一驱动,停摆必须能被 /metrics 与 /health 看见。
Gauge.builder("msgx.pipeline.job.heartbeat_age_seconds", activity) { a -> Gauge.builder("msgx.pipeline.job.heartbeat_age_seconds", activity) { a ->
a.snapshot().lastTickAt?.let { Duration.between(it, clock.instant()).seconds.toDouble() } ?: -1.0 a.snapshot().lastTickAt?.let { Duration.between(it, clock.instant()).seconds.toDouble() } ?: -1.0
@@ -308,52 +308,13 @@ interface ReqTrackRepository {
fun expire(reqId: Long) 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 的读写入口这个库是别人的本系统只做约定的读写 * 共享 MySQL 信箱 CMINMSGS 的读写入口这个库是别人的本系统只做约定的读写
* 不建表不改结构 * 不建表不改结构
* *
* 这里把三件事分得很清楚谁也不代替谁 * 发现与标记是两件事发现只看处理时间是否为空[readUnprocessed]
* - **发现** ID 区间读有哪些新消息[readRange] * 标记只在处理结束后补写[markProcessedIfUnmarked]消费边界不落库
* - **进度**读到哪儿了记在自有库的水位里 [InboxCursorRepository] * 重复读到同一行由 PROC_STATE 主键挡掉`INV-1`
* - **标记**处理完了把"已处理"写回信箱[markProcessedIfUnmarked]
*
* 特别是发现不能拿"有没有处理标记"当筛选条件处理完但还没回填的行以及永远不会
* 回填的死信会一直占着每一批的名额攒够一批之后新消息就再也读不到了
*/ */
interface CminmsgInboxRepository { interface CminmsgInboxRepository {
/** 兼容入口往信箱写一条报文,返回新的信箱 ID。 */ /** 兼容入口往信箱写一条报文,返回新的信箱 ID。 */
@@ -365,14 +326,13 @@ interface CminmsgInboxRepository {
/** 读取信箱库记录的实际接收时间。 */ /** 读取信箱库记录的实际接收时间。 */
fun receivedAtOf(msgId: Long): Instant? fun receivedAtOf(msgId: Long): Instant?
/** 按 ID 升序读一批 `ID > fromExclusive` 的行,不带别的过滤条件。 */ /**
fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> * ID 升序读一批处理时间为空 `ID > afterId` 的行
*
/** 信箱当前最大 ID,空表返回 null;只用来观测收报落后了多少。 */ * [afterId] 只是同一轮扫描里的续读起点已登记但还没回填的行会被反复读到
fun maxId(): Long? * 若每轮都从头读同一批它们攒够一批之后新行就再也读不到
*/
/** 信箱当前最小 ID,空表返回 null;切流播种用它推算 `W = MIN(ID) 1`。 */ fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow>
fun minId(): Long?
/** /**
* 把处理标记写回信箱并且**只写还是空标记的行**库里已有值时不覆盖不回退 * 把处理标记写回信箱并且**只写还是空标记的行**库里已有值时不覆盖不回退
@@ -14,8 +14,8 @@ import java.time.Instant
* 只做增删改查不建表不改结构这个库属于别的系统 * 只做增删改查不建表不改结构这个库属于别的系统
* *
* 两处刻意为之 * 两处刻意为之
* - 取新消息只看 ID`ID > ?`不看 `DATE_PROCESSED`用处理标记当条件的话 * - 取新消息只看 `DATE_PROCESSED IS NULL``INV-1``ID > ?` 只是同一轮扫描的续读起点
* 处理完但还没回填的行长期占住每批名额死信攒够一批就再也发现不了新消息 * 让已登记却还没回填的行长期占住每批名额
* - 写回处理标记带 `DATE_PROCESSED IS NULL` 条件一行只会被标记一次不会覆盖已有值 * - 写回处理标记带 `DATE_PROCESSED IS NULL` 条件一行只会被标记一次不会覆盖已有值
*/ */
@Singleton @Singleton
@@ -57,16 +57,16 @@ class JdbcCminmsgInboxRepository(
{ ps -> ps.setLong(1, msgId) }, { ps -> ps.setLong(1, msgId) },
) { rs -> rs.getTimestamp("CMINMSGS_DATE_RECEIVED")?.toInstant() } ) { 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( ds.query(
""" """
SELECT CMINMSGS_ID, CMINMSGS_DATE_RECEIVED FROM cminmsgs 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 ORDER BY CMINMSGS_ID ASC
LIMIT ? LIMIT ?
""".trimIndent(), """.trimIndent(),
{ ps -> { ps ->
ps.setLong(1, fromExclusive) ps.setLong(1, afterId)
ps.setInt(2, limit) ps.setInt(2, limit)
}, },
) { rs -> ) { 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 行时再查一次主键区分已有标记信箱行缺失后者不能记为回填成功 * 影响 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.Backlog
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository 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.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
@@ -37,7 +36,6 @@ import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.util.concurrent.atomic.AtomicBoolean
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale 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 @Singleton
@Requires(property = "datasources.default.enabled", value = "true") @Requires(property = "datasources.default.enabled", value = "true")
@Requires(missingProperty = "msgx.stubs") @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.BackfillDue
import com.gzzn.omms.msgexchange.infra.persistence.Backlog import com.gzzn.omms.msgexchange.infra.persistence.Backlog
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository 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.MailboxRow
import com.gzzn.omms.msgexchange.infra.persistence.MailboxMarkResult import com.gzzn.omms.msgexchange.infra.persistence.MailboxMarkResult
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository 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 receivedAtOf(msgId: Long): Instant? = received[msgId]
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> = override fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow> =
raws.keys.filter { it > fromExclusive }.sorted().take(limit).map { MailboxRow(it, received[it]) } raws.keys.filter { it > afterId && !marks.containsKey(it) }
.sorted().take(limit).map { MailboxRow(it, received[it]) }
override fun maxId(): Long? = raws.keys.maxOrNull()
override fun minId(): Long? = raws.keys.minOrNull()
/** 只写还没有标记的行,并区分已有标记与行缺失。 */ /** 只写还没有标记的行,并区分已有标记与行缺失。 */
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult { 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 = private fun ProcStatus.isTerminal(): Boolean =
this == ProcStatus.SUCCEEDED || this == ProcStatus.SKIPPED || this == ProcStatus.DEAD this == ProcStatus.SUCCEEDED || this == ProcStatus.SKIPPED || this == ProcStatus.DEAD
@@ -2,32 +2,26 @@ package com.gzzn.omms.msgexchange.ingress
import com.gzzn.omms.msgexchange.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository 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.PipelineTransactionManager import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Clock import java.time.Clock
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* 收报轮询共享信箱新消息登记到自有 PG PROC_STATE等着主泵处理 * 收报轮询共享信箱处理时间为空的行登记到自有 PG PROC_STATE等着主泵处理
* *
* 每一轮只做三件事 * 每一轮做两件事
* 1. "水位"之后按 ID 升序读一批只看 ID不看处理标记 * 1. 按处理时间为空编号升序读一批每批 `msgx.pipeline.claim-batch`
* 水位记的是"信箱里到哪个 ID 为止已经全部读进自有库"存在自己的库里重启不丢 * 2. 逐条登记成 PENDING主键冲突即已登记过跳过`INV-1`
* 2. 把读到的行登记成 PENDING并把水位往前推
* 3. 登记和水位推进写在同一个事务里中途崩溃时水位没动下一轮重扫即可补齐
* *
* 水位一次只推到"连续"的位置这是这里唯一需要理解的规则如果下一个 ID 缺号 * 读满一批就接着往后读处理完却还没回填的行尤其永远不回填的死信会被反复读到
* 说明可能有 ID 更小的消息还没提交上来此时先停在缺口前不把缺口后面的消息放进队列 * 只读第一批的话这些行攒够一批之后新消息就再也发现不了续读起点只活在本轮的内存里
* 否则那条迟到的消息会排到它们后面破坏"先来先处理"的约定同一航班的报文可能被乱序应用 * 不落库也不是消费边界重启后从头重扫重复登记建不出第二行
* *
* 缺口等超过 `msgx.pipeline.max-commit-delay` 仍未出现就认定它永远不会来了 * 编号较小的行晚提交时最坏只是被发现得晚下一轮仍会读到不会丢但它不会排在
* 典型情况是自增回滚留下的空位跳过它继续推进否则水位会卡在第一个空位上 * 已经处理完的较大编号之前
* 再也不动
* *
* 这一层不解析报文也不写信箱处理标记标记由处理完成后的 BackfillService 负责补 * 这一层不解析报文也不写信箱处理标记标记由处理完成后的 BackfillService 负责补
*/ */
@@ -35,7 +29,6 @@ import java.util.concurrent.atomic.AtomicBoolean
class InboxPoller( class InboxPoller(
private val mailbox: CminmsgInboxRepository, private val mailbox: CminmsgInboxRepository,
private val procState: ProcStateRepository, private val procState: ProcStateRepository,
private val cursor: InboxCursorRepository,
private val txManager: PipelineTransactionManager, private val txManager: PipelineTransactionManager,
private val props: PipelineProps, private val props: PipelineProps,
private val clock: Clock, private val clock: Clock,
@@ -45,93 +38,35 @@ class InboxPoller(
@Volatile @Volatile
private var running = false private var running = false
/** @return 本轮新登记的消息条数(已登记过的行不计入,也不影响水位推进)。 */ /** @return 本轮新登记的消息条数(已登记过的行不计入)。 */
fun pollOnce(now: Instant): Int { fun pollOnce(now: Instant): Int {
seedCutoverWatermarkIfConfigured(now)
val batch = props.pipeline.claimBatch.coerceAtLeast(1) val batch = props.pipeline.claimBatch.coerceAtLeast(1)
val watermark = cursor.load() var afterId = 0L
val rows = mailbox.readRange(watermark.committedUpTo, batch) var enqueued = 0
if (rows.isEmpty()) return 0 var scanned = 0
while (true) {
// 找到连续部分的末尾;如果这批里出现了缺口,缺口后面的行这一轮先不入队 val rows = mailbox.readUnprocessed(afterId, batch)
val contiguous = contiguousUpTo(watermark.committedUpTo, rows) ?: watermark.committedUpTo if (rows.isEmpty()) break
var committedTo = contiguous enqueued += txManager.inTransaction {
var holeSince: Instant? = null
if (rows.last().msgId > contiguous) {
// 连续上界已前移说明旧空洞已补齐;后面是新空洞,不得继承旧等待时间。
val since = if (contiguous == watermark.committedUpTo) watermark.holeSince ?: now else now
if (Duration.between(since, now) < props.pipeline.maxCommitDelay) {
holeSince = since
} else {
// 缺口等太久了:当成永久缺失跳过,让水位继续往前走
committedTo = rows.first { it.msgId > contiguous }.msgId - 1
log.warn("hole after W={} aged out, watermark advanced to {}", contiguous, committedTo)
}
}
val enqueued = txManager.inTransaction {
var n = 0
rows.takeWhile { it.msgId <= committedTo }.forEach { row ->
// 入队时间用本轮注入的本地时钟,与超期判据 R 同源。 // 入队时间用本轮注入的本地时钟,与超期判据 R 同源。
if (procState.insertIfAbsent(row.msgId, row.receivedAt, enqueuedAt = now)) n++ rows.count { procState.insertIfAbsent(it.msgId, it.receivedAt, enqueuedAt = now) }
} }
cursor.save(InboxCursorRepository.Cursor(committedTo, holeSince)) afterId = rows.last().msgId
n scanned += rows.size
if (rows.size < batch) break
} }
if (enqueued > 0) { if (enqueued > 0) {
log.info("polled {} new messages, W {} -> {}", enqueued, watermark.committedUpTo, committedTo) log.info("polled {} new messages out of {} unprocessed rows", enqueued, scanned)
} }
return enqueued return enqueued
} }
/**
* 切流水位播种**只在显式配置 `msgx.pipeline.cutover-watermark` 时动作且至多一次**
*
* 安全约束评审要求逐条对应
* - 默认不配置即不动作代码不做默认选择也不会自动退化成 `max`
* - `SEEDED_AT` NULL **不等于**"从未消费"已有库新增列后同样是 NULL
* 因此必须叠加"水位为 0 且没有任何处理记录"这条判据
* - 升级实例已消费过**拒绝重新播种**重新切流必须是显式操作
* - 播种事实与水位在同一条语句里落库失败后下一轮重试不会留下部分状态
* - 信箱为空时无从界定边界保持未播种等有行时再判断
*/
private fun seedCutoverWatermarkIfConfigured(now: Instant) {
val mode = props.pipeline.cutoverWatermark ?: return
val current = cursor.load()
if (current.seededAt != null) return
if (current.committedUpTo > 0L || procState.hasAny()) {
if (refusedReseed.compareAndSet(false, true)) {
log.error(
"cutover-watermark={} is configured but this instance has already consumed messages " +
"(W={}); refusing to re-seed. Re-cutover must be an explicit operation.",
mode, current.committedUpTo,
)
}
return
}
val min = mailbox.minId() ?: return
val target = when {
mode.equals("min", ignoreCase = true) -> min - 1
mode.equals("zero", ignoreCase = true) -> 0L
mode.equals("max", ignoreCase = true) -> mailbox.maxId() ?: return
else -> mode.toLongOrNull() ?: return // 非法值已在启动 validate() 挡掉
}
txManager.inTransaction { cursor.markSeeded(target, now) }
log.warn("cutover watermark seeded: mode={} W={} (mailbox min={})", mode, target, min)
}
/** "拒绝重新播种"的告警只打一次,避免每轮刷屏。 */
private val refusedReseed = AtomicBoolean(false)
fun loop() { fun loop() {
running = true running = true
log.info("inbox poller loop started") log.info("inbox poller loop started")
while (running) { while (running) {
try { try {
// 时间一律走注入的 Clock空洞老化阈值是"等多久算永久缺失"的唯一判据,不能依赖系统时钟。 // 时间一律走注入的 Clock入队时间是超期判据 R 的比较对象,不能依赖系统时钟。
pollOnce(clock.instant()) pollOnce(clock.instant())
sleepQuietly(props.pipeline.pollInterval) sleepQuietly(props.pipeline.pollInterval)
} catch (_: InterruptedException) { } catch (_: InterruptedException) {
@@ -149,21 +84,6 @@ class InboxPoller(
running = false running = false
} }
/**
* W+1 开始数返回 ID 1 相连的最后一个 ID遇到第一个缺号就停
* 返回 null 表示 W+1 本身就不存在
*/
private fun contiguousUpTo(from: Long, rows: List<MailboxRow>): Long? {
var expected = from + 1
var last: Long? = null
for ((msgId, _) in rows) {
if (msgId != expected) break
last = msgId
expected++
}
return last
}
private fun sleepQuietly(d: Duration) { private fun sleepQuietly(d: Duration) {
try { try {
Thread.sleep(d.toMillis().coerceAtLeast(1)) Thread.sleep(d.toMillis().coerceAtLeast(1))
@@ -14,7 +14,6 @@ import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.log.TraceLog import com.gzzn.omms.msgexchange.infra.log.TraceLog
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository 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 com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import jakarta.inject.Singleton import jakarta.inject.Singleton
@@ -38,7 +37,6 @@ import java.util.concurrent.atomic.AtomicLong
@Singleton @Singleton
class Pump( class Pump(
private val procState: ProcStateRepository, private val procState: ProcStateRepository,
private val cursor: InboxCursorRepository,
private val processor: MessageProcessor, private val processor: MessageProcessor,
private val props: PipelineProps, private val props: PipelineProps,
private val clock: Clock, private val clock: Clock,
@@ -80,17 +78,6 @@ class Pump(
sleepQuietly(props.pipeline.pollInterval) sleepQuietly(props.pipeline.pollInterval)
return return
} }
// 只领取"已被水位覆盖"的队头(`msgId <= W`)。
//
// 水位以内的行都是收报按 ID 顺序发现并登记的;水位之外的行只可能来自兼容入口
// 直接写 PROC_STATE(它不参与水位)。若允许领取,它就会越过那些尚未入队的较小 ID,
// 破坏 FIFO(不变量"只领取已发现的行"`specification.md` `INV-4`)。这种行在空洞补齐、`W` 追平之后自然可领取。
val watermark = cursor.load().committedUpTo
if (head.msgId > watermark) {
warnBeyondWatermark(head.msgId, watermark)
sleepQuietly(props.pipeline.pollInterval)
return
}
val now = clock.instant() val now = clock.instant()
when { when {
head.state == ProcStatus.FAILED && head.attempts >= props.pipeline.maxAttempts -> { head.state == ProcStatus.FAILED && head.attempts >= props.pipeline.maxAttempts -> {
@@ -111,19 +98,6 @@ class Pump(
} }
} }
/** 上一次"队头在水位之外"告警时的水位值:只在它变化时告警,避免每秒刷屏。 */
private val warnedWatermark = AtomicLong(Long.MIN_VALUE)
private fun warnBeyondWatermark(msgId: Long, watermark: Long) {
if (warnedWatermark.getAndSet(watermark) != watermark) {
log.warn(
"head msgId={} is beyond watermark W={}; waiting for discovery " +
"(row injected by the compat entry point?)",
msgId, watermark,
)
}
}
private fun sleepQuietly(d: Duration) { private fun sleepQuietly(d: Duration) {
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1)) if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
} }
+1 -4
View File
@@ -18,12 +18,9 @@ msgx:
max-attempts: 5 # 处理/投递同值 max-attempts: 5 # 处理/投递同值
backoff-ms: [1000, 2000, 4000, 8000] # 指数退避;档位数必须 = max-attempts - 1(启动自检) backoff-ms: [1000, 2000, 4000, 8000] # 指数退避;档位数必须 = max-attempts - 1(启动自检)
backoff-cap-ms: 60000 backoff-cap-ms: 60000
max-commit-delay: 5m # 空洞老化:W+1 空洞超过该时延判定为永久(Q7 最大提交时延)
overdue-backfill: 30d # 超期补写期限 R:仅须 R ≤ R_keep;判据比较本地 ENQUEUED_ATimplementation.md「回填」) overdue-backfill: 30d # 超期补写期限 R:仅须 R ≤ R_keep;判据比较本地 ENQUEUED_ATimplementation.md「回填」)
backfill-batch: 100 # 回填扫描单批条数 backfill-batch: 100 # 回填扫描单批条数
backfill-max-attempts: 100 # 单行重试的告警阈值;放弃判据是 R 超期,不是次数(implementation.md「回填」) backfill-max-attempts: 100 # 单行重试的告警阈值;放弃判据是 R 超期,不是次数(implementation.md「回填」)
# 一次性切流播种:默认(注释掉)不播种。min=读现存全部 | zero=从 0 按空洞规则 | max=跳过可见存量 | <id>
# cutover-watermark: min
autostart: false # U07:启动即拉起 Pump/Dispatcher 循环;需真实仓储或 msgx.stubs=true 才开启(dev 见 application-dev.yml autostart: false # U07:启动即拉起 Pump/Dispatcher 循环;需真实仓储或 msgx.stubs=true 才开启(dev 见 application-dev.yml
schd: schd:
flush-period: 3s # KEEP 现役推送节律 flush-period: 3s # KEEP 现役推送节律
@@ -46,7 +43,7 @@ micronaut:
port: 8080 port: 8080
# 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml # 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml
# 自有 PostgreSQL:全部内部状态(PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/INBOX_CURSOR/ # 自有 PostgreSQL:全部内部状态(PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/
# FLIGHT_SCHD 及明细表/SCHD_SNAP_LOG;迁移链:V1__flight_state_baseline.sql(原多版净结构合并) # FLIGHT_SCHD 及明细表/SCHD_SNAP_LOG;迁移链:V1__flight_state_baseline.sql(原多版净结构合并)
# + V2__flight_chute_class_type_rename.sqlchute 类字段 CCLS/CTYP);回填事实与处理终态同表同行)。 # + V2__flight_chute_class_type_rename.sqlchute 类字段 CCLS/CTYP);回填事实与处理终态同表同行)。
# 数据层实装前 enabled=falsestub 模式不建连)。 # 数据层实装前 enabled=falsestub 模式不建连)。
@@ -5,7 +5,6 @@
-- 系统库,只做契约内 DML,不建表、不改结构(C-2)。 -- 系统库,只做契约内 DML,不建表、不改结构(C-2)。
-- --
-- 本文件原为 V1 基线;原 V2–V10 的净结构已全部合并进来,版本链收敛为一条: -- 本文件原为 V1 基线;原 V2–V10 的净结构已全部合并进来,版本链收敛为一条:
-- · INBOX_CURSOR 及 SEEDED_AT(原 V2/V5);
-- · PROC_STATE 的收信/入队时间与回填事实列,独立待办表 BACKFILL_TODO 不再存在 -- · PROC_STATE 的收信/入队时间与回填事实列,独立待办表 BACKFILL_TODO 不再存在
-- (原 V2/V3/V4/V6/V7:回填意图并入 PROC_STATE,处理开始时间列加后即删); -- (原 V2/V3/V4/V6/V7:回填意图并入 PROC_STATE,处理开始时间列加后即删);
-- · REQ_TRACK 开放态部分唯一索引 uq_req_open(原 V8); -- · REQ_TRACK 开放态部分唯一索引 uq_req_open(原 V8);
@@ -20,7 +19,7 @@
-- --
-- 分层(职责与写者见 docs/implementation.md「航班域」): -- 分层(职责与写者见 docs/implementation.md「航班域」):
-- · 决策层:FLIGHT_SCHD + 8 张资源明细表 + FLIGHT_ROUTE_POINT —— 权威当前态(INV-11); -- · 决策层:FLIGHT_SCHD + 8 张资源明细表 + FLIGHT_ROUTE_POINT —— 权威当前态(INV-11);
-- · 管道层:PIPELINE_LOCK / INBOX_CURSOR / PROC_STATE / MSG_EVENT / REQ_TRACK -- · 管道层:PIPELINE_LOCK / PROC_STATE / MSG_EVENT / REQ_TRACK
-- · 留痕层:SCHD_SNAP_LOG —— 只追加、可重建、不参与决策; -- · 留痕层:SCHD_SNAP_LOG —— 只追加、可重建、不参与决策;
-- · 证据层:报文原文归档 —— 冷路径,尚未交付(ARCHIVE_KEY 仅留引用位)。 -- · 证据层:报文原文归档 —— 冷路径,尚未交付(ARCHIVE_KEY 仅留引用位)。
-- 类型口径:TIMESTAMP(6) WITH TIME ZONE 统一 UTC 语义;BIGSERIAL 为 PostgreSQL 方言 -- 类型口径:TIMESTAMP(6) WITH TIME ZONE 统一 UTC 语义;BIGSERIAL 为 PostgreSQL 方言
@@ -65,22 +64,7 @@ CREATE INDEX idx_proc_head ON PROC_STATE (STATE, MSG_ID); -- 主泵队头查询
CREATE INDEX idx_proc_backfill_due ON PROC_STATE (BACKFILL_ATTEMPTS, MSG_ID) CREATE INDEX idx_proc_backfill_due ON PROC_STATE (BACKFILL_ATTEMPTS, MSG_ID)
WHERE BACKFILL_AT IS NULL AND BACKFILL_ABANDONED_AT IS NULL; WHERE BACKFILL_AT IS NULL AND BACKFILL_ABANDONED_AT IS NULL;
-- ③ 消费水位:全表只有一行。W 只随新 ID 成功入队推进、只增不减,遇空洞即停; -- ③ 航班当前态主行:一行一 FLID(决策层权威;展示视图是投影,不是权威,INV-5)
-- 入队与水位推进同事务(INV-2),因此不存在「水位已推进、消息未入队」的持久化状态。
-- HOLE_SINCE 持久化空洞观测时刻,进程重启不丢计时;老化阈值取
-- PARAM:msgx.pipeline.max-commit-delay,超期只放行空洞本身、不越过任何已存在的行。
CREATE TABLE INBOX_CURSOR (
CURSOR_ID INT NOT NULL PRIMARY KEY, -- 固定为 1
COMMITTED_UP_TO BIGINT NOT NULL, -- 水位 W:到哪个 ID 为止已全部读进自有库
HOLE_SINCE TIMESTAMP(6) WITH TIME ZONE, -- 后续缺号最早被发现的时间;不缺号时为 NULL
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
SEEDED_AT TIMESTAMP(6) WITH TIME ZONE -- 记录「已按 PARAM:msgx.pipeline.cutover-watermark 播种」这一事实;
-- 为 NULL **不等于**从未消费——已有库新增列后同样为 NULL
);
-- 初值 W=0:首轮把信箱现存行全部重新读一遍,重复登记不会建出第二行。
INSERT INTO INBOX_CURSOR (CURSOR_ID, COMMITTED_UP_TO, HOLE_SINCE, UPDATED_AT) VALUES (1, 0, NULL, now());
-- ④ 航班当前态主行:一行一 FLID(决策层权威;展示视图是投影,不是权威,INV-5)
CREATE TABLE FLIGHT_SCHD ( CREATE TABLE FLIGHT_SCHD (
FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 实例 IDNumber(1-12));不得由航班号或资源号推断 FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 实例 IDNumber(1-12));不得由航班号或资源号推断
OPERATION_DAY DATE NULL, -- 运营保障日:未由日计划收录时为 NULL;非空后不可改变(implementation「航班身份与运营日」) OPERATION_DAY DATE NULL, -- 运营保障日:未由日计划收录时为 NULL;非空后不可改变(implementation「航班身份与运营日」)
@@ -140,11 +124,11 @@ CREATE INDEX idx_flight_schd_state ON FLIGHT_SCHD (STATE);
-- WHERE OPERATION_DAY IS NULL OR OPERATION_DAY = :day,行级条件更新即满足; -- WHERE OPERATION_DAY IS NULL OR OPERATION_DAY = :day,行级条件更新即满足;
-- 不使用触发器(Oracle/PG 双方言成本)。 -- 不使用触发器(Oracle/PG 双方言成本)。
-- 资源明细表 ×8 + 路线点表:每 FLID 多行;每次完整状态写入按该 FLID 先删后插, -- 资源明细表 ×8 + 路线点表:每 FLID 多行;每次完整状态写入按该 FLID 先删后插,
-- 以完整合并结果为准(implementation「字段与集合」)。ORDINAL 是持久化顺序(从 1 起),SOURCE_SEQ 是上游 -- 以完整合并结果为准(implementation「字段与集合」)。ORDINAL 是持久化顺序(从 1 起),SOURCE_SEQ 是上游
-- 序号(允许为空或重复);相同资源号不代表同一条分配,禁止按资源号去重。删除一律 -- 序号(允许为空或重复);相同资源号不代表同一条分配,禁止按资源号去重。删除一律
-- 标记在主行 STATE,明细物理清除只发生在受控历史归档(US-14)。 -- 标记在主行 STATE,明细物理清除只发生在受控历史归档(US-14)。
-- -1 登机门 GTDT -- -1 登机门 GTDT
CREATE TABLE FLIGHT_GATE ( CREATE TABLE FLIGHT_GATE (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -159,7 +143,7 @@ CREATE TABLE FLIGHT_GATE (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -2 值机柜台 CKDT -- -2 值机柜台 CKDT
CREATE TABLE FLIGHT_CHECKIN ( CREATE TABLE FLIGHT_CHECKIN (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -175,7 +159,7 @@ CREATE TABLE FLIGHT_CHECKIN (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -3 行李转盘 CLDT -- -3 行李转盘 CLDT
CREATE TABLE FLIGHT_BELT ( CREATE TABLE FLIGHT_BELT (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -191,7 +175,7 @@ CREATE TABLE FLIGHT_BELT (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -4 计划机位 PSDT -- -4 计划机位 PSDT
CREATE TABLE FLIGHT_STAND_PLAN ( CREATE TABLE FLIGHT_STAND_PLAN (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -203,7 +187,7 @@ CREATE TABLE FLIGHT_STAND_PLAN (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -5 滑槽 CHDT -- -5 滑槽 CHDT
CREATE TABLE FLIGHT_CHUTE ( CREATE TABLE FLIGHT_CHUTE (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -219,7 +203,7 @@ CREATE TABLE FLIGHT_CHUTE (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -6 延误 DELY(业务上任意时刻仅 1 个有效延误,保留多行能力以无损承接) -- -6 延误 DELY(业务上任意时刻仅 1 个有效延误,保留多行能力以无损承接)
CREATE TABLE FLIGHT_DELAY ( CREATE TABLE FLIGHT_DELAY (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -232,7 +216,7 @@ CREATE TABLE FLIGHT_DELAY (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -7 靠撤桥 ABTM(桥号 ABDGA/D 各一条) -- -7 靠撤桥 ABTM(桥号 ABDGA/D 各一条)
CREATE TABLE FLIGHT_BRIDGE_OP ( CREATE TABLE FLIGHT_BRIDGE_OP (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -244,7 +228,7 @@ CREATE TABLE FLIGHT_BRIDGE_OP (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -8 轮挡 CHOT(机位 CHIDON/OFF 各一条) -- -8 轮挡 CHOT(机位 CHIDON/OFF 各一条)
CREATE TABLE FLIGHT_CHOCK_OP ( CREATE TABLE FLIGHT_CHOCK_OP (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -256,7 +240,7 @@ CREATE TABLE FLIGHT_CHOCK_OP (
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
PRIMARY KEY (FLID, ORDINAL) PRIMARY KEY (FLID, ORDINAL)
); );
-- -9 路线点 ROUT/ERUT 共用(ROUTE_KIND 区分两类;主键含该列,避免序号冲突) -- -9 路线点 ROUT/ERUT 共用(ROUTE_KIND 区分两类;主键含该列,避免序号冲突)
CREATE TABLE FLIGHT_ROUTE_POINT ( CREATE TABLE FLIGHT_ROUTE_POINT (
FLID VARCHAR(32) NOT NULL, FLID VARCHAR(32) NOT NULL,
ORDINAL INT NOT NULL, ORDINAL INT NOT NULL,
@@ -278,7 +262,7 @@ CREATE INDEX idx_flight_delay_flid ON FLIGHT_DELAY (FLID);
CREATE INDEX idx_flight_bridge_flid ON FLIGHT_BRIDGE_OP (FLID); CREATE INDEX idx_flight_bridge_flid ON FLIGHT_BRIDGE_OP (FLID);
CREATE INDEX idx_flight_chock_flid ON FLIGHT_CHOCK_OP (FLID); CREATE INDEX idx_flight_chock_flid ON FLIGHT_CHOCK_OP (FLID);
-- 统一投递事件 outboxC-9、INV-3):KAFKA:schd 发整态、KAFKA:msg 只通知变化; -- 统一投递事件 outboxC-9、INV-3):KAFKA:schd 发整态、KAFKA:msg 只通知变化;
-- tombstone 仅在 ACTIVE→DELETED 时与删除同事务登记,投递失败按退避持续重试。 -- tombstone 仅在 ACTIVE→DELETED 时与删除同事务登记,投递失败按退避持续重试。
-- 目标级全序投递是当前实现,按 FLID 保序见 CLM-3。 -- 目标级全序投递是当前实现,按 FLID 保序见 CLM-3。
CREATE TABLE MSG_EVENT ( CREATE TABLE MSG_EVENT (
@@ -302,7 +286,7 @@ CREATE UNIQUE INDEX uq_schd_event
ON MSG_EVENT (TARGET, PARTITION_KEY) ON MSG_EVENT (TARGET, PARTITION_KEY)
WHERE TARGET = 'KAFKA:schd'; WHERE TARGET = 'KAFKA:schd';
-- 请求状态机:只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)匹配最新一条 -- 请求状态机:只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)匹配最新一条
-- 开放请求。同类请求只留一条有效,新请求置旧请求为 EXPIRED。登记、超时与应答匹配 -- 开放请求。同类请求只留一条有效,新请求置旧请求为 EXPIRED。登记、超时与应答匹配
-- 尚未实现(G-REQ-TRACK、G-REQ-OPEN-UNIQUE)。 -- 尚未实现(G-REQ-TRACK、G-REQ-OPEN-UNIQUE)。
CREATE TABLE REQ_TRACK ( CREATE TABLE REQ_TRACK (
@@ -322,7 +306,7 @@ CREATE UNIQUE INDEX uq_req_open
ON REQ_TRACK (REQ_TYPE, OPERATION_DAY, SENDER) ON REQ_TRACK (REQ_TYPE, OPERATION_DAY, SENDER)
WHERE STATE IN ('PENDING', 'SENT'); WHERE STATE IN ('PENDING', 'SENT');
-- SCHD 快照留痕:事务外追加,只追加留痕、不参与决策;一行=一次尝试,重放也记。 -- SCHD 快照留痕:事务外追加,只追加留痕、不参与决策;一行=一次尝试,重放也记。
-- RESULT 与 FLAGS 分列(可「成功且告警」);写失败只记指标。 -- RESULT 与 FLAGS 分列(可「成功且告警」);写失败只记指标。
CREATE TABLE SCHD_SNAP_LOG ( CREATE TABLE SCHD_SNAP_LOG (
LOG_ID BIGSERIAL PRIMARY KEY, LOG_ID BIGSERIAL PRIMARY KEY,
@@ -16,7 +16,7 @@ Flyway 配置**PG 路径使用 `classpath:db/migration`,两者互不混用
## 计划内容 ## 计划内容
- 单基线:`V1__flight_state_baseline.sql` 的 11g 等价 DDL—— - 单基线:`V1__flight_state_baseline.sql` 的 11g 等价 DDL——
PIPELINE_LOCK/INBOX_CURSOR/PROC_STATE/MSG_EVENT/REQ_TRACK/FLIGHT_SCHD + 9 张明细表/SCHD_SNAP_LOG PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/FLIGHT_SCHD + 9 张明细表/SCHD_SNAP_LOG
`NUMBER`/序列替代 `BIGSERIAL``TIMESTAMP WITH TIME ZONE``VARCHAR2` BYTE/CHAR 语义钉死。 `NUMBER`/序列替代 `BIGSERIAL``TIMESTAMP WITH TIME ZONE``VARCHAR2` BYTE/CHAR 语义钉死。
- `INSERT ... ON CONFLICT` 改 11g MERGEOPERATION_DAY 不可变条件,implementation「航班身份与运营日」)。 - `INSERT ... ON CONFLICT` 改 11g MERGEOPERATION_DAY 不可变条件,implementation「航班身份与运营日」)。
- 空串按 NULL 的语义回归:显式清空的 presence 信息不得被 11g 空串语义吞掉 - 空串按 NULL 的语义回归:显式清空的 presence 信息不得被 11g 空串语义吞掉
@@ -52,7 +52,6 @@ class PipelineSmokeTest {
fun cleanStubs() { fun cleanStubs() {
ctx.getBean(StubProcState::class.java).clear() ctx.getBean(StubProcState::class.java).clear()
ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java).clear() ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java).clear()
ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor::class.java).clear()
ctx.getBean(StubMsgEvents::class.java).clear() ctx.getBean(StubMsgEvents::class.java).clear()
ctx.getBean(StubDeliveryPort::class.java).clear() ctx.getBean(StubDeliveryPort::class.java).clear()
} }
@@ -98,8 +97,6 @@ class PipelineSmokeTest {
val receipt = controller.send(UNSUPPORTED_XML) val receipt = controller.send(UNSUPPORTED_XML)
assertNotNull(receipt.body()) // 受理 ID assertNotNull(receipt.body()) // 受理 ID
val id = receipt.body()!!.toLong() val id = receipt.body()!!.toLong()
// 兼容入口只保证"已落信 + 已入队",**不推进水位**;必须先被收报发现(W 追平)才可领取。
ctx.getBean(com.gzzn.omms.msgexchange.ingress.InboxPoller::class.java).pollOnce(Instant.now())
pump.tick() // 解码成功但无 XYZQ Handler → SKIPPED(unsupported) pump.tick() // 解码成功但无 XYZQ Handler → SKIPPED(unsupported)
@@ -115,7 +112,6 @@ class PipelineSmokeTest {
fun `replay reopens failed UNSUPPORTED row to PENDING`() { fun `replay reopens failed UNSUPPORTED row to PENDING`() {
val receipt = controller.send(REFDATA_XML) val receipt = controller.send(REFDATA_XML)
val id = receipt.body()!!.toLong() val id = receipt.body()!!.toLong()
ctx.getBean(com.gzzn.omms.msgexchange.ingress.InboxPoller::class.java).pollOnce(Instant.now())
pump.tick() pump.tick()
val stub = ctx.getBean(StubProcState::class.java) val stub = ctx.getBean(StubProcState::class.java)
assertEquals(ProcStatus.FAILED, stub.snapshotOf(id)!!.state) assertEquals(ProcStatus.FAILED, stub.snapshotOf(id)!!.state)
@@ -193,9 +189,6 @@ class PipelineSmokeTest {
val proc = ctx.getBean(StubProcState::class.java) val proc = ctx.getBean(StubProcState::class.java)
val props = ctx.getBean(com.gzzn.omms.msgexchange.config.PipelineProps::class.java) val props = ctx.getBean(com.gzzn.omms.msgexchange.config.PipelineProps::class.java)
val backfill = ctx.getBean(com.gzzn.omms.msgexchange.processing.BackfillService::class.java) val backfill = ctx.getBean(com.gzzn.omms.msgexchange.processing.BackfillService::class.java)
// 队头必须在水位以内才可领取:直接播种 PROC_STATE 的用例要显式把水位推上去。
ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor::class.java)
.save(com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository.Cursor(committedUpTo = 9001L))
proc.insertIfAbsent(9001L, Instant.now()) proc.insertIfAbsent(9001L, Instant.now())
proc.update( proc.update(
9001L, ProcStatus.FAILED, 9001L, ProcStatus.FAILED,
@@ -225,40 +218,32 @@ class PipelineSmokeTest {
} }
/** /**
* FIFO 不变量"只领取已发现的行"端到端验收兼容入口写入的高 ID **不会被提前领取** * 扫描谓词端到端验收`INV-1`处理标记为空的行都会被发现并登记处理完并回填之后
* 必须等水位追平较小 ID 补齐并入队之后才按顺序处理 * 退出扫描谓词兼容入口已登记的行不会被收报建成第二条记录
*/ */
@Test @Test
fun `compat injected high id is not claimed until the watermark catches up`() { fun `scan predicate discovers every unmarked row exactly once`() {
val inbox = ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java) val inbox = ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java)
val proc = ctx.getBean(StubProcState::class.java) val proc = ctx.getBean(StubProcState::class.java)
val cursor = ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor::class.java)
val poller = ctx.getBean(com.gzzn.omms.msgexchange.ingress.InboxPoller::class.java) val poller = ctx.getBean(com.gzzn.omms.msgexchange.ingress.InboxPoller::class.java)
// 信箱:1 存在、2 是空洞(被删)、3 存在 → 水位停在 1 val external = inbox.simulateExternalWrite("<MSG/>") // 上游直接写信箱
inbox.simulateExternalWrite("<MSG/>") val compat = controller.send(UNSUPPORTED_XML).body()!!.toLong() // 兼容入口:已自行入队
val hole = inbox.simulateExternalWrite("<MSG/>")
inbox.simulateExternalWrite("<MSG/>")
inbox.removeRow(hole)
poller.pollOnce(Instant.now()) poller.pollOnce(Instant.now())
assertEquals(1L, cursor.cursor.committedUpTo) assertEquals(setOf(external, compat), proc.rows.keys.toSet())
// 兼容入口写入高 ID:直接进 PG,但水位没追平(这正是原来的越序路径) poller.pollOnce(Instant.now()) // 重扫不建第二条
val high = controller.send(UNSUPPORTED_XML).body()!!.toLong() assertEquals(2, proc.rows.size)
assertTrue(high > 3L)
pump.tick() // 先按 FIFO 处理 ID=1 repeat(2) { pump.tick() }
assertEquals(ProcStatus.DEAD, proc.find(1L)!!.state) ctx.getBean(com.gzzn.omms.msgexchange.processing.BackfillService::class.java).sweep()
pump.tick() // 队头变成高 ID,但它在水位之外 → 不领取 assertTrue(inbox.isMarked(external) && inbox.isMarked(compat))
assertEquals(ProcStatus.PENDING, proc.find(high)!!.state)
// 空洞补齐 → 水位追平 → 才允许继续按顺序处理 // 已回填的行退出扫描谓词:清掉处理记录后也不会被重新登记
inbox.restoreRow(hole, "<MSG/>", Instant.now()) proc.clear()
poller.pollOnce(Instant.now()) poller.pollOnce(Instant.now())
assertTrue(cursor.cursor.committedUpTo >= high) assertTrue(proc.rows.isEmpty())
repeat(3) { pump.tick() } // 依次处理 2、3、高 ID
assertEquals(ProcStatus.SKIPPED, proc.find(high)!!.state)
assertEquals("unsupported:XYZQ-FOO", proc.find(high)!!.lastError)
} }
@Test @Test
@@ -23,13 +23,4 @@ class PipelineConfigCheckTest {
assertThrows(IllegalArgumentException::class.java) { PipelineConfigCheck(props) } assertThrows(IllegalArgumentException::class.java) { PipelineConfigCheck(props) }
} }
@Test
fun `illegal cutover watermark rejects startup even without autostart`() {
val props = PipelineProps().apply {
pipeline.cutoverWatermark = "yesterday"
}
assertThrows(IllegalArgumentException::class.java) { PipelineConfigCheck(props) }
}
} }
@@ -33,19 +33,6 @@ class PipelinePropsTest {
assertThrows(IllegalArgumentException::class.java) { pipeline.validate() } assertThrows(IllegalArgumentException::class.java) { pipeline.validate() }
} }
/** 切流播种只接受 min|zero|max|<id>:非法值必须在启动时挡掉。 */
@Test
fun `validate rejects an unknown cutover watermark mode`() {
pipeline.cutoverWatermark = "bogus"
assertThrows(IllegalArgumentException::class.java) { pipeline.validate() }
pipeline.cutoverWatermark = "max"
pipeline.validate()
pipeline.cutoverWatermark = "12345"
pipeline.validate()
}
@Test @Test
fun `non-positive attempt never throws and falls back to first slot`() { fun `non-positive attempt never throws and falls back to first slot`() {
assertEquals(1000, pipeline.backoffFor(0)) assertEquals(1000, pipeline.backoffFor(0))
@@ -3,9 +3,6 @@ package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.MutableClock import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.delivery.DeliveryPort import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
import com.gzzn.omms.msgexchange.infra.stub.StubProcState import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import io.micronaut.health.HealthStatus import io.micronaut.health.HealthStatus
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@@ -41,18 +38,14 @@ class HealthIndicatorsTest {
/** 检查 /health 里那几个积压指标算得对不对。 */ /** 检查 /health 里那几个积压指标算得对不对。 */
@Test @Test
fun `inbox lifecycle reports backlog, oldest age, unmarked terminals and watermark lag`() { fun `inbox lifecycle reports backlog, oldest age and unmarked terminals`() {
val proc = StubProcState() val proc = StubProcState()
val inbox = StubInbox().apply { clear() }
val cursor = StubInboxCursor()
val oldest = MutableClock.BASE.minusSeconds(3600) val oldest = MutableClock.BASE.minusSeconds(3600)
proc.insertIfAbsent(1L, oldest) proc.insertIfAbsent(1L, oldest)
proc.insertIfAbsent(2L, MutableClock.BASE) proc.insertIfAbsent(2L, MutableClock.BASE)
proc.markTerminal(2L, ProcStatus.SUCCEEDED, now = MutableClock.BASE) proc.markTerminal(2L, ProcStatus.SUCCEEDED, now = MutableClock.BASE)
val newest = inbox.insertRaw("<MSG/>")
cursor.save(InboxCursorRepository.Cursor(committedUpTo = newest - 2))
val result = lifecycleHealth(proc, cursor, inbox, now = MutableClock.BASE) val result = lifecycleHealth(proc, now = MutableClock.BASE)
assertEquals(HealthStatus.UP, result.status) assertEquals(HealthStatus.UP, result.status)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -60,12 +53,11 @@ class HealthIndicatorsTest {
assertEquals(1, details["backlog"]) assertEquals(1, details["backlog"])
assertEquals(3600L, details["oldestUnprocessedSeconds"]) assertEquals(3600L, details["oldestUnprocessedSeconds"])
assertEquals(1, details["unmarkedTerminal"]) assertEquals(1, details["unmarkedTerminal"])
assertEquals(2L, details["watermarkLag"])
} }
@Test @Test
fun `inbox lifecycle stays up when ports are not bound`() { fun `inbox lifecycle stays up when ports are not bound`() {
val result = lifecycleHealth(procState = null, cursor = null, mailbox = null, now = MutableClock.BASE) val result = lifecycleHealth(procState = null, now = MutableClock.BASE)
assertEquals(HealthStatus.UP, result.status) // 可用性由依赖自身指示器承担 assertEquals(HealthStatus.UP, result.status) // 可用性由依赖自身指示器承担
} }
@@ -48,8 +48,6 @@ class PipelineMetricsTest {
assertEquals(1.0, gauge("msgx.pipeline.backlog.unfinished"), 0.001) assertEquals(1.0, gauge("msgx.pipeline.backlog.unfinished"), 0.001)
assertEquals(1.0, gauge("msgx.pipeline.backfill.unmarked_terminal"), 0.001) assertEquals(1.0, gauge("msgx.pipeline.backfill.unmarked_terminal"), 0.001)
assertEquals(0.0, gauge("msgx.pipeline.backfill.abandoned"), 0.001) assertEquals(0.0, gauge("msgx.pipeline.backfill.abandoned"), 0.001)
// 信箱为空 → 没有"最新 ID"可比,滞后用 -1(无值)而不是伪造 0
assertEquals(-1.0, gauge("msgx.pipeline.watermark.lag"), 0.001)
} }
@Test @Test
@@ -11,8 +11,8 @@ import java.sql.DriverManager
/** /**
* 在真实 PostgreSQL 上跑一遍迁移链确认结果符合预期`V1__flight_state_baseline.sql` * 在真实 PostgreSQL 上跑一遍迁移链确认结果符合预期`V1__flight_state_baseline.sql`
* 基线加 `V2__flight_chute_class_type_rename.sql` 更名依序执行成功该建的表和单行种子 * 基线加 `V2__flight_chute_class_type_rename.sql` 更名依序执行成功该建的表和 PIPELINE_LOCK
* PIPELINE_LOCKINBOX_CURSOR都在回填事实与收报水位都落在基线里`BACKFILL_TODO` * 单行种子都在回填事实落在基线里`INBOX_CURSOR``BACKFILL_TODO`
* `idx_evt_flid``PROC_STATE` 的处理开始时间列都不复存在FLIGHT_CHUTE 的类字段列 * `idx_evt_flid``PROC_STATE` 的处理开始时间列都不复存在FLIGHT_CHUTE 的类字段列
* 已由 V2 更名为 CCLS/CTYPSIS 口径 * 已由 V2 更名为 CCLS/CTYPSIS 口径
* *
@@ -24,7 +24,7 @@ class FlywayMigrationTest {
"pipeline_lock", "proc_state", "flight_schd", "pipeline_lock", "proc_state", "flight_schd",
"flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute", "flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute",
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point", "flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
"msg_event", "req_track", "inbox_cursor", "schd_snap_log", "msg_event", "req_track", "schd_snap_log",
) )
@Test @Test
@@ -78,6 +78,7 @@ class FlywayMigrationTest {
val missing = expectedTables - tables val missing = expectedTables - tables
assertTrue(missing.isEmpty(), "missing tables: $missing") assertTrue(missing.isEmpty(), "missing tables: $missing")
assertTrue("backfill_todo" !in tables, "BACKFILL_TODO 已由 PROC_STATE 回填列取代") assertTrue("backfill_todo" !in tables, "BACKFILL_TODO 已由 PROC_STATE 回填列取代")
assertTrue("inbox_cursor" !in tables, "扫描谓词按处理时间取行,不再有消费水位表")
} }
// 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列 // 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列
@@ -200,17 +201,11 @@ class FlywayMigrationTest {
assertEquals("NO", rs.getString("is_nullable"), "ENQUEUED_AT 必须非空") assertEquals("NO", rs.getString("is_nullable"), "ENQUEUED_AT 必须非空")
} }
// 两张单行表(管道锁、收报水位)的种子数据都要在 // 管道锁的单行种子要在
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs -> stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
assertTrue(rs.next()) assertTrue(rs.next())
assertEquals(1, rs.getInt(1)) assertEquals(1, rs.getInt(1))
} }
stmt.executeQuery("SELECT committed_up_to, hole_since FROM inbox_cursor WHERE cursor_id = 1").use { rs ->
assertTrue(rs.next(), "INBOX_CURSOR 单行种子必须就位")
assertEquals(0L, rs.getLong("committed_up_to"))
rs.getTimestamp("hole_since")
assertTrue(rs.wasNull(), "初始无空洞观测")
}
} }
} }
} }
@@ -2,7 +2,6 @@ package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import com.gzzn.omms.msgexchange.infra.persistence.MailboxMarkResult import com.gzzn.omms.msgexchange.infra.persistence.MailboxMarkResult
import org.h2.jdbcx.JdbcDataSource import org.h2.jdbcx.JdbcDataSource
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@@ -24,7 +23,7 @@ import javax.sql.DataSource
* *
* 库用 H2 PostgreSQL 兼容模式表结构照抄 V1 + V2因此不需要 docker 或外接数据库 * 库用 H2 PostgreSQL 兼容模式表结构照抄 V1 + V2因此不需要 docker 或外接数据库
* 就能跑覆盖两边的真实语句自有 PG 写终态时一并写下回填待办挑选待回填记录 * 就能跑覆盖两边的真实语句自有 PG 写终态时一并写下回填待办挑选待回填记录
* 积压统计水位游标读写和共享信箱侧 ID 区间读标记只写一次 * 积压统计和共享信箱侧处理时间为空扫描标记只写一次
* *
* 有一处覆盖不到H2 不支持 `INSERT ... ON CONFLICT DO NOTHING`所以入队幂等没在这里验证 * 有一处覆盖不到H2 不支持 `INSERT ... ON CONFLICT DO NOTHING`所以入队幂等没在这里验证
* `InboxPollerTest`重复轮询不再登记 PostgreSQL 本身的语义来保证 * `InboxPollerTest`重复轮询不再登记 PostgreSQL 本身的语义来保证
@@ -34,7 +33,6 @@ class InboxLifecycleJdbcSqlTest {
private lateinit var ds: DataSource private lateinit var ds: DataSource
private lateinit var proc: JdbcProcStateRepository private lateinit var proc: JdbcProcStateRepository
private lateinit var mailbox: JdbcCminmsgInboxRepository private lateinit var mailbox: JdbcCminmsgInboxRepository
private lateinit var cursor: JdbcInboxCursorRepository
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z") private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
private val overdue: Instant = t0.minus(Duration.ofDays(31)) private val overdue: Instant = t0.minus(Duration.ofDays(31))
@@ -49,18 +47,12 @@ class InboxLifecycleJdbcSqlTest {
ds.connection.use { conn -> ds.connection.use { conn ->
conn.createStatement().use { st -> conn.createStatement().use { st ->
st.execute(PROC_STATE_DDL) st.execute(PROC_STATE_DDL)
st.execute(CURSOR_DDL)
st.execute(MAILBOX_DDL) st.execute(MAILBOX_DDL)
st.execute(EVENT_DDL) st.execute(EVENT_DDL)
st.execute(
"INSERT INTO inbox_cursor (cursor_id, committed_up_to, hole_since, updated_at) " +
"VALUES (1, 0, NULL, CURRENT_TIMESTAMP)",
)
} }
} }
proc = JdbcProcStateRepository(ds, java.time.Clock.systemUTC()) proc = JdbcProcStateRepository(ds, java.time.Clock.systemUTC())
mailbox = JdbcCminmsgInboxRepository(ds) mailbox = JdbcCminmsgInboxRepository(ds)
cursor = JdbcInboxCursorRepository(ds, java.time.Clock.systemUTC())
} }
@Test @Test
@@ -133,15 +125,6 @@ class InboxLifecycleJdbcSqlTest {
assertEquals(1, backlog.unmarkedTerminal) assertEquals(1, backlog.unmarkedTerminal)
} }
@Test
fun `watermark round trips with and without a pending hole`() {
assertEquals(InboxCursorRepository.Cursor(0L, null), cursor.load())
cursor.save(InboxCursorRepository.Cursor(42L, t0))
assertEquals(InboxCursorRepository.Cursor(42L, t0), cursor.load())
}
@Test @Test
fun `replay resets attempts, backoff and the previous error reason`() { fun `replay resets attempts, backoff and the previous error reason`() {
seed(12L, t0) seed(12L, t0)
@@ -185,22 +168,22 @@ class InboxLifecycleJdbcSqlTest {
} }
@Test @Test
fun `mailbox range read ignores processing marks and marking never overwrites`() { fun `mailbox scan takes unprocessed rows in id order and marking never overwrites`() {
val first = mailbox.insertRaw("<A/>") val first = mailbox.insertRaw("<A/>")
val second = mailbox.insertRaw("<B/>") val second = mailbox.insertRaw("<B/>")
val third = mailbox.insertRaw("<C/>") val third = mailbox.insertRaw("<C/>")
// 发现按 ID 区间;处理标记不参与扫描谓词 // 扫描谓词只看处理时间为空;afterId 是同一轮的续读起点
assertEquals(listOf(first, second, third), mailbox.readRange(0L, 50).map { it.msgId }) assertEquals(listOf(first, second, third), mailbox.readUnprocessed(0L, 50).map { it.msgId })
assertNotNull(mailbox.readRange(0L, 50).first().receivedAt) assertNotNull(mailbox.readUnprocessed(0L, 50).first().receivedAt)
assertEquals(listOf(second, third), mailbox.readRange(first, 50).map { it.msgId }) assertEquals(listOf(second, third), mailbox.readUnprocessed(first, 50).map { it.msgId })
assertEquals(third, mailbox.maxId()) assertEquals(listOf(first, second), mailbox.readUnprocessed(0L, 2).map { it.msgId })
assertEquals(MailboxMarkResult.MARKED, mailbox.markProcessedIfUnmarked(second, "PROCESSED")) assertEquals(MailboxMarkResult.MARKED, mailbox.markProcessedIfUnmarked(second, "PROCESSED"))
assertEquals(MailboxMarkResult.ALREADY_MARKED, mailbox.markProcessedIfUnmarked(second, "OTHER")) assertEquals(MailboxMarkResult.ALREADY_MARKED, mailbox.markProcessedIfUnmarked(second, "OTHER"))
assertEquals("PROCESSED", statusOf(second)) assertEquals("PROCESSED", statusOf(second))
// 已标记行仍出现在区间读结果中(发现与标记彻底解耦) // 打过标记的行退出扫描谓词
assertEquals(listOf(first, second, third), mailbox.readRange(0L, 50).map { it.msgId }) assertEquals(listOf(first, third), mailbox.readUnprocessed(0L, 50).map { it.msgId })
assertEquals(MailboxMarkResult.MISSING, mailbox.markProcessedIfUnmarked(999L, "PROCESSED")) assertEquals(MailboxMarkResult.MISSING, mailbox.markProcessedIfUnmarked(999L, "PROCESSED"))
} }
@@ -252,16 +235,6 @@ class InboxLifecycleJdbcSqlTest {
) )
""" """
const val CURSOR_DDL = """
CREATE TABLE inbox_cursor (
cursor_id INT PRIMARY KEY,
committed_up_to BIGINT NOT NULL,
hole_since TIMESTAMP WITH TIME ZONE,
seeded_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
)
"""
const val MAILBOX_DDL = """ const val MAILBOX_DDL = """
CREATE TABLE cminmsgs ( CREATE TABLE cminmsgs (
CMINMSGS_ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, CMINMSGS_ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -1,164 +0,0 @@
package com.gzzn.omms.msgexchange.ingress
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
/**
* 切流水位播种ACM2-35**显式一次性升级安全**
*
* 覆盖评审提出的四条硬要求
* 1. 默认未配置不动作代码不做默认选择
* 2. 四种模式严格区分`min` 读现存全部 / `zero` 0 按空洞规则 / `max` 跳过可见存量 / 显式 ID
* 3. 升级实例已有水位或已有处理记录**拒绝重新播种**`SEEDED_AT` NULL 不等于"从未消费"
* 4. 播种至多一次且不会被普通的水位推进抹掉
*/
class CutoverSeedTest {
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
private val props = PipelineProps()
private lateinit var inbox: StubInbox
private lateinit var proc: StubProcState
private lateinit var cursor: StubInboxCursor
private lateinit var poller: InboxPoller
@BeforeEach
fun setUp() {
inbox = StubInbox().apply { clear() }
proc = StubProcState().apply { clear() }
cursor = StubInboxCursor().apply { clear() }
props.pipeline.cutoverWatermark = null
poller = InboxPoller(
inbox, proc, cursor, StubPipelineTx(), props,
Clock.fixed(t0, ZoneOffset.UTC),
)
}
/** 造一个 MIN(ID)=5 的信箱:1..4 已被库方清除(典型"最老分区已 DROP")。 */
private fun mailboxWithMinId5(): List<Long> {
val ids = (1..8).map { inbox.insertRaw("<MSG/>") }
ids.take(4).forEach { inbox.removeRow(it) }
return ids.drop(4)
}
@Test
fun `default does nothing - no seeding without explicit configuration`() {
val kept = mailboxWithMinId5()
assertEquals(0, poller.pollOnce(t0)) // W=0 → ID=1 判为空洞,不推进
assertNull(cursor.cursor.seededAt)
assertEquals(0L, cursor.cursor.committedUpTo)
assertNotNull(cursor.cursor.holeSince)
assertTrue(kept.all { proc.find(it) == null })
}
@Test
fun `min mode reads all currently existing rows`() {
val kept = mailboxWithMinId5()
props.pipeline.cutoverWatermark = "min"
assertEquals(4, poller.pollOnce(t0)) // 播种 W=4,同一轮把 5..8 全部读入
assertNotNull(cursor.cursor.seededAt)
assertEquals(8L, cursor.cursor.committedUpTo)
assertTrue(kept.all { proc.find(it) != null })
}
@Test
fun `max mode skips the currently visible backlog`() {
mailboxWithMinId5()
props.pipeline.cutoverWatermark = "max"
assertEquals(0, poller.pollOnce(t0))
assertNotNull(cursor.cursor.seededAt)
assertEquals(8L, cursor.cursor.committedUpTo) // 直接跳到 MAX(ID)
assertEquals(0, proc.rows.size)
}
@Test
fun `zero mode scans from zero and stops at the first hole`() {
mailboxWithMinId5()
props.pipeline.cutoverWatermark = "zero"
assertEquals(0, poller.pollOnce(t0))
assertNotNull(cursor.cursor.seededAt)
assertEquals(0L, cursor.cursor.committedUpTo) // 1..4 是空洞 → 按空洞规则停住
assertNotNull(cursor.cursor.holeSince)
assertEquals(0, proc.rows.size)
}
@Test
fun `explicit boundary mode is accepted`() {
mailboxWithMinId5()
props.pipeline.cutoverWatermark = "6"
poller.pollOnce(t0)
assertNotNull(cursor.cursor.seededAt)
assertEquals(8L, cursor.cursor.committedUpTo) // 从 6 起读 7..8(6 本身已在界内)
}
@Test
fun `upgraded instance with existing watermark refuses to re-seed`() {
mailboxWithMinId5()
cursor.save(InboxCursorRepository.Cursor(committedUpTo = 3L, holeSince = t0))
props.pipeline.cutoverWatermark = "max"
poller.pollOnce(t0)
assertNull(cursor.cursor.seededAt) // 没有重新播种
assertNotEquals(8L, cursor.cursor.committedUpTo)
}
@Test
fun `instance that already consumed messages refuses to seed even at W zero`() {
mailboxWithMinId5()
proc.insertIfAbsent(1L, t0) // 已有处理记录(即使水位还是 0
props.pipeline.cutoverWatermark = "max"
poller.pollOnce(t0)
assertNull(cursor.cursor.seededAt)
}
@Test
fun `seeding happens at most once and survives normal watermark saves`() {
mailboxWithMinId5()
props.pipeline.cutoverWatermark = "max"
poller.pollOnce(t0)
val seededAt = cursor.cursor.seededAt
assertNotNull(seededAt)
cursor.save(InboxCursorRepository.Cursor(committedUpTo = 2L, holeSince = null))
poller.pollOnce(t0.plusSeconds(1))
assertEquals(seededAt, cursor.cursor.seededAt) // 标记仍在:普通 save 不抹播种事实
assertEquals(2L, cursor.cursor.committedUpTo)
}
@Test
fun `empty mailbox is left unseeded`() {
props.pipeline.cutoverWatermark = "max"
assertEquals(0, poller.pollOnce(t0))
assertNull(cursor.cursor.seededAt)
}
}
@@ -4,49 +4,46 @@ import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.stub.StubInbox import com.gzzn.omms.msgexchange.infra.stub.StubInbox
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
import com.gzzn.omms.msgexchange.infra.stub.StubProcState import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset
/** /**
* 收报环节最要紧的几条规矩 * 收报环节最要紧的几条规矩`INV-1`
* - 取新消息只看 ID不看处理标记处理完却没能回填的行尤其是永远不回填的死信 * - 取新消息只看处理时间是否为空按编号升序每批 `claim-batch`
* 不允许占住批次也不允许挡住后面的新消息这是曾经的线上隐患 * - 同一编号只建一条处理记录重扫重启与兼容入口并发都不会多建
* - 水位只在成功登记后才推进而且和登记写在同一个事务里中断后重扫就能补齐 * - 已回填的行不再被扫到终态但未回填的行会重复扫到但不许占住批次挡住后面的新行
* - 遇到 ID 缺口先停下来可能有更小的消息还没到缺口等太久则跳过否则水位永远卡住
* - 这一层不碰信箱的处理标记标记留给回填环节写 * - 这一层不碰信箱的处理标记标记留给回填环节写
*/ */
class InboxPollerTest { class InboxPollerTest {
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z") private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
private val props = PipelineProps() private val props = PipelineProps()
private val clock: Clock = Clock.fixed(t0, ZoneOffset.UTC)
private lateinit var inbox: StubInbox private lateinit var inbox: StubInbox
private lateinit var proc: StubProcState private lateinit var proc: StubProcState
private lateinit var cursor: StubInboxCursor
private lateinit var poller: InboxPoller private lateinit var poller: InboxPoller
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
inbox = StubInbox().apply { clear() } inbox = StubInbox().apply { clear() }
proc = StubProcState().apply { clear() } proc = StubProcState().apply { clear() }
cursor = StubInboxCursor().apply { clear() } poller = newPoller()
poller = InboxPoller(
inbox, proc, cursor, StubPipelineTx(), props,
java.time.Clock.fixed(t0, java.time.ZoneOffset.UTC),
)
} }
private fun newPoller() = InboxPoller(inbox, proc, StubPipelineTx(), props, clock)
@Test @Test
fun `external rows are enqueued in id order and advance the watermark without marking the mailbox`() { fun `unprocessed rows are enqueued in id order without marking the mailbox`() {
val first = inbox.simulateExternalWrite("<MSG/>") val first = inbox.simulateExternalWrite("<MSG/>")
val second = inbox.simulateExternalWrite("<MSG/>") val second = inbox.simulateExternalWrite("<MSG/>")
@@ -54,16 +51,29 @@ class InboxPollerTest {
assertEquals(ProcStatus.PENDING, proc.find(first)!!.state) assertEquals(ProcStatus.PENDING, proc.find(first)!!.state)
assertEquals(ProcStatus.PENDING, proc.find(second)!!.state) assertEquals(ProcStatus.PENDING, proc.find(second)!!.state)
assertEquals(second, cursor.cursor.committedUpTo) assertEquals(listOf(first, second), proc.rows.keys.sorted())
assertNull(cursor.cursor.holeSince)
// 收报只写自有库,不碰信箱的处理标记 // 收报只写自有库,不碰信箱的处理标记
assertFalse(inbox.isMarked(first)) assertFalse(inbox.isMarked(first))
assertEquals(0, poller.pollOnce(t0)) // 重复扫描幂等 assertEquals(0, poller.pollOnce(t0)) // 重扫幂等:同编号不再建第二行
assertEquals(2, proc.rows.size)
}
/** 已写回处理时间的行退出扫描谓词,不再被读到。 */
@Test
fun `rows already backfilled leave the scan predicate`() {
val done = inbox.simulateExternalWrite("<MSG/>")
assertEquals(1, poller.pollOnce(t0))
proc.markTerminal(done, ProcStatus.SUCCEEDED, now = t0)
inbox.markProcessedIfUnmarked(done, "PROCESSED")
proc.rows.clear() // 模拟到期清理后处理记录已删除
assertEquals(0, poller.pollOnce(t0)) // 标记已写回 → 不再重建记录
assertNull(proc.find(done))
} }
/** /**
* 回归用例处理完却永远不会回填的行典型是解码失败的死信曾经占满每一批的名额 * 回归用例处理完却还没回填的行典型是等待回填的死信会被反复扫到
* 导致收报整体停摆取新消息这件事必须和"有没有处理标记"彻底分开 * 但不得占满首批窗口否则攒够一批之后新消息就再也发现不了曾经的线上隐患
*/ */
@Test @Test
fun `terminal rows without a mailbox mark do not block discovery of later messages`() { fun `terminal rows without a mailbox mark do not block discovery of later messages`() {
@@ -78,105 +88,60 @@ class InboxPollerTest {
assertEquals(1, poller.pollOnce(t0)) assertEquals(1, poller.pollOnce(t0))
assertEquals(ProcStatus.PENDING, proc.find(fresh)!!.state) assertEquals(ProcStatus.PENDING, proc.find(fresh)!!.state)
assertEquals(fresh, cursor.cursor.committedUpTo)
} }
/** 未回填的存量远多于一批时,续读要一直翻到最后一批,才能发现末尾的新行。 */
@Test @Test
fun `watermark stops at a hole so later ids cannot overtake a missing smaller id`() { fun `multi batch scan reaches new rows behind several full batches`() {
val first = inbox.simulateExternalWrite("<MSG/>") props.pipeline.claimBatch = 2
val hole = inbox.simulateExternalWrite("<MSG/>") val stale = (1..5).map { inbox.simulateExternalWrite("<MSG/>") }
val afterHole = inbox.simulateExternalWrite("<MSG/>") assertEquals(5, poller.pollOnce(t0))
inbox.removeRow(hole) stale.forEach { proc.markTerminal(it, ProcStatus.SUCCEEDED, now = t0) }
val fresh = inbox.simulateExternalWrite("<MSG/>")
assertEquals(1, poller.pollOnce(t0)) assertEquals(1, poller.pollOnce(t0))
assertNotNull(proc.find(fresh))
assertEquals(first, cursor.cursor.committedUpTo)
assertNotNull(cursor.cursor.holeSince)
assertNull(proc.find(afterHole)) // 不得越过空洞入队(FIFO
assertEquals(0, poller.pollOnce(t0.plusSeconds(60))) // 宽限期内水位不推进
assertEquals(first, cursor.cursor.committedUpTo)
} }
/** 重启不带任何消费边界:从头重扫,已登记的行不会被建成第二行。 */
@Test @Test
fun `an aged hole is released and later ids resume enqueuing`() { fun `a restarted poller rescans from the start without duplicating rows`() {
val hole = inbox.simulateExternalWrite("<MSG/>") val ids = (1..3).map { inbox.simulateExternalWrite("<MSG/>") }
val afterHole = inbox.simulateExternalWrite("<MSG/>") assertEquals(3, poller.pollOnce(t0))
inbox.removeRow(hole)
poller.pollOnce(t0) // 记录空洞观测时刻
val agedOut = t0.plus(props.pipeline.maxCommitDelay) val restarted = newPoller()
assertEquals(0, poller.pollOnce(agedOut)) // 空洞判永久:推进水位但不越过入队
assertNull(cursor.cursor.holeSince) assertEquals(0, restarted.pollOnce(t0.plusSeconds(60)))
assertEquals(afterHole - 1, cursor.cursor.committedUpTo) assertEquals(ids.size, proc.rows.size)
assertEquals(1, poller.pollOnce(agedOut)) // 下一轮恢复发现 assertEquals(ids, proc.rows.keys.sorted())
assertEquals(afterHole, cursor.cursor.committedUpTo) }
assertNotNull(proc.find(afterHole))
/** 编号较小的行晚提交:本轮读不到不算丢,下一轮照常发现。 */
@Test
fun `a late committed smaller id is discovered on the next round`() {
val first = inbox.simulateExternalWrite("<MSG/>")
val late = inbox.simulateExternalWrite("<MSG/>")
val third = inbox.simulateExternalWrite("<MSG/>")
inbox.removeRow(late)
assertEquals(2, poller.pollOnce(t0))
assertNotNull(proc.find(first))
assertNotNull(proc.find(third))
assertNull(proc.find(late))
inbox.restoreRow(late, "<MSG/>", t0.plusSeconds(30))
assertEquals(1, poller.pollOnce(t0.plusSeconds(30)))
assertEquals(ProcStatus.PENDING, proc.find(late)!!.state)
} }
@Test @Test
fun `compat http path and poller do not double enqueue the same message`() { fun `compat http path and poller do not double enqueue the same message`() {
val receipt = InboxService(inbox, proc, java.time.Clock.fixed(t0, java.time.ZoneOffset.UTC)).accept("<MSG/>") val receipt = InboxService(inbox, proc, clock).accept("<MSG/>")
assertEquals(0, poller.pollOnce(t0)) assertEquals(0, poller.pollOnce(t0))
assertEquals(receipt.msgId, cursor.cursor.committedUpTo) // 已在 PG:读取进度照常推进 assertEquals(1, proc.rows.size)
assertNotNull(proc.find(receipt.msgId)) assertNotNull(proc.find(receipt.msgId))
} }
@Test
fun `a newly exposed hole does not inherit the age of the previous hole`() {
val first = inbox.simulateExternalWrite("<MSG/>")
val oldHole = inbox.simulateExternalWrite("<MSG/>")
val third = inbox.simulateExternalWrite("<MSG/>")
val newHole = inbox.simulateExternalWrite("<MSG/>")
val fifth = inbox.simulateExternalWrite("<MSG/>")
inbox.removeRow(oldHole)
inbox.removeRow(newHole)
assertEquals(1, poller.pollOnce(t0))
val almostAged = t0.plus(props.pipeline.maxCommitDelay).minusSeconds(1)
inbox.restoreRow(oldHole, "<MSG/>", almostAged)
assertEquals(2, poller.pollOnce(almostAged))
assertEquals(third, cursor.cursor.committedUpTo)
assertEquals(almostAged, cursor.cursor.holeSince)
assertNull(proc.find(fifth))
// 旧空洞的期限已到,但新空洞必须获得完整等待窗口。
assertEquals(0, poller.pollOnce(t0.plus(props.pipeline.maxCommitDelay)))
assertEquals(third, cursor.cursor.committedUpTo)
assertNull(proc.find(fifth))
assertEquals(first + 2, third)
}
/**
* 兼容入口会把消息登记在水位**之外**`msgId > W`因此它天然成为"最小未完成行"
* 领取侧的守卫在主泵只领 `msgId <= W`端到端验收见 `PipelineSmokeTest`
* "compat injected high id is not claimed until the watermark catches up"
* 本用例只固定收报侧的事实**登记发生但水位不动**
*/
@Test
fun `compat accept registers a row above the watermark without advancing it`() {
props.pipeline.claimBatch = 10
val one = inbox.simulateExternalWrite("<MSG/>") // 1
val hole = inbox.simulateExternalWrite("<MSG/>") // 2(空洞)
val third = inbox.simulateExternalWrite("<MSG/>") // 3
inbox.removeRow(hole)
assertEquals(1, poller.pollOnce(t0)) // W=1,遇空洞即停
assertEquals(one, cursor.cursor.committedUpTo)
assertNull(proc.find(third)) // 3 还没入队
val receipt = InboxService(inbox, proc, java.time.Clock.fixed(t0, java.time.ZoneOffset.UTC))
.accept("<MSG/>") // 兼容入口:高 ID 直接进 PG
val high = receipt.msgId
assertTrue(high > third)
assertEquals(one, cursor.cursor.committedUpTo) // 水位不动(不参与水位)
// 主泵处理掉队头 1 之后,最小未完成行就是兼容入口写进来的高 ID……
proc.markTerminal(one, ProcStatus.SUCCEEDED, now = t0)
assertEquals(high, proc.headUnfinished()!!.msgId)
assertTrue(high > cursor.cursor.committedUpTo) // ……但它超出水位,主泵不会领取
assertNull(proc.find(third))
}
} }
@@ -7,7 +7,6 @@ import com.gzzn.omms.msgexchange.infra.persistence.MailboxMarkResult
import com.gzzn.omms.msgexchange.infra.persistence.MailboxRow import com.gzzn.omms.msgexchange.infra.persistence.MailboxRow
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.stub.StubInbox import com.gzzn.omms.msgexchange.infra.stub.StubInbox
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
import com.gzzn.omms.msgexchange.infra.stub.StubProcState import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@@ -43,11 +42,7 @@ class InboxServiceTest {
else -> Instant.parse("2026-09-06T01:59:55Z") else -> Instant.parse("2026-09-06T01:59:55Z")
} }
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> = emptyList() override fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow> = emptyList()
override fun maxId(): Long? = null
override fun minId(): Long? = null
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult = MailboxMarkResult.MISSING override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult = MailboxMarkResult.MISSING
} }
@@ -100,10 +95,9 @@ class InboxServiceTest {
fun `the poller backfills a compat receipt whose pg enqueue failed, exactly once`() { fun `the poller backfills a compat receipt whose pg enqueue failed, exactly once`() {
val inbox = StubInbox().apply { clear() } val inbox = StubInbox().apply { clear() }
val proc = StubProcState().apply { clear() } val proc = StubProcState().apply { clear() }
val cursor = StubInboxCursor().apply { clear() }
val clock = MutableClock(t0) val clock = MutableClock(t0)
val service = InboxService(inbox, FlakyProcState(proc), clock) val service = InboxService(inbox, FlakyProcState(proc), clock)
val poller = InboxPoller(inbox, proc, cursor, StubPipelineTx(), PipelineProps(), clock) val poller = InboxPoller(inbox, proc, StubPipelineTx(), PipelineProps(), clock)
val receipt = service.accept("<MSG/>") // 落信成功;PG 入队失败但不抛 val receipt = service.accept("<MSG/>") // 落信成功;PG 入队失败但不抛
assertEquals(1L, service.pgEnqueueFailures.get()) assertEquals(1L, service.pgEnqueueFailures.get())
@@ -112,7 +106,6 @@ class InboxServiceTest {
assertEquals(1, poller.pollOnce(t0)) // 轮询补建 assertEquals(1, poller.pollOnce(t0)) // 轮询补建
assertNotNull(proc.find(receipt.msgId)) assertNotNull(proc.find(receipt.msgId))
assertEquals(1, proc.rows.size) assertEquals(1, proc.rows.size)
assertEquals(receipt.msgId, cursor.cursor.committedUpTo)
assertEquals(0, poller.pollOnce(t0)) // 已存在:不重复建行,也不重复计数 assertEquals(0, poller.pollOnce(t0)) // 已存在:不重复建行,也不重复计数
assertEquals(1, proc.rows.size) assertEquals(1, proc.rows.size)
@@ -46,9 +46,7 @@ class BackfillServiceTest {
override fun insertRaw(rawXml: String): Long = 1L override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(msgId: Long): String? = null override fun rawOf(msgId: Long): String? = null
override fun receivedAtOf(msgId: Long): Instant? = null override fun receivedAtOf(msgId: Long): Instant? = null
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> = emptyList() override fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow> = emptyList()
override fun maxId(): Long? = null
override fun minId(): Long? = null
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult { override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult {
if (fail) throw IllegalStateException("mysql-down") if (fail) throw IllegalStateException("mysql-down")
if (missing) return MailboxMarkResult.MISSING if (missing) return MailboxMarkResult.MISSING
@@ -237,9 +235,7 @@ class BackfillServiceTest {
override fun insertRaw(rawXml: String) = 1L override fun insertRaw(rawXml: String) = 1L
override fun rawOf(msgId: Long): String? = "<MSG/>" override fun rawOf(msgId: Long): String? = "<MSG/>"
override fun receivedAtOf(msgId: Long) = t0 override fun receivedAtOf(msgId: Long) = t0
override fun readRange(fromExclusive: Long, limit: Int) = emptyList<MailboxRow>() override fun readUnprocessed(afterId: Long, limit: Int) = emptyList<MailboxRow>()
override fun maxId(): Long? = 1L
override fun minId(): Long? = 1L
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult { override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult {
entered.countDown() entered.countDown()
release.await() release.await()
@@ -11,14 +11,12 @@ import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubInbox import com.gzzn.omms.msgexchange.infra.stub.StubInbox
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
@@ -63,8 +61,6 @@ class IgnoreBranchTest {
counters: PipelineCounters = PipelineCounters(), counters: PipelineCounters = PipelineCounters(),
codec: XmlCodec, codec: XmlCodec,
): MessageProcessor { ): MessageProcessor {
val cursor = StubInboxCursor()
cursor.cursor = InboxCursorRepository.Cursor(committedUpTo = Long.MAX_VALUE)
val clock = Clock.systemUTC() val clock = Clock.systemUTC()
val props = PipelineProps() val props = PipelineProps()
val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 } val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }