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:
@@ -5,7 +5,7 @@
|
||||
--
|
||||
-- 报文格式:SIS 接口规范(docs/legacy/SIS_AODB_RMS-V0.1.md)META + 类型体,
|
||||
-- XML 按 docs/legacy/unisysaodbsis.xsd;只 INSERT 契约内列,不建表不变更(C-2)。
|
||||
-- INBOX_CURSOR 初值 W=0,应用首启即全部消费。SEQN 1–5,与 01-mailbox.sql
|
||||
-- 处理时间为空,应用首启即全部消费。SEQN 1–5,与 01-mailbox.sql
|
||||
-- (仅建表、无数据)无冲突。
|
||||
--
|
||||
-- 两条参考数据报文验证的是【现状行为】,US-13 落地后行为会变,需同步更新:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
-- 故按 FLID 先删后插(implementation「字段与集合」的完整合并口径)。
|
||||
-- CREATED_AT/UPDATED_AT 为 NOT NULL,显式给值。
|
||||
-- 只写决策层(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 本文件
|
||||
-- =====================================================================
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ msgexchange-v2 怎么处理报文:记录模型、状态机、事务边界、
|
||||
1. 信箱不可读 → 记日志,下轮再试(不是「无新消息」)。
|
||||
2. 同事务 `insertIfAbsent(MSG_ID, RECEIVED_AT, ENQUEUED_AT)`;冲突 = 已有记录,跳过。
|
||||
3. 已写回处理时间的行不再出现;终态未写回的行会重复扫到,幂等跳过。
|
||||
4. 读满一批就按编号续读下一批,续读起点只在本轮有效:否则未写回的行攒满一批之后,后面的新行再也读不到。
|
||||
|
||||
写回延迟期间扫描量会涨,须观测积压([reference.md](reference.md) 指标)。
|
||||
|
||||
|
||||
+1
-5
@@ -22,7 +22,7 @@
|
||||
| `msgx.pipeline.event-retention` | `7d` | 发送成功的消息在数据库保留多久,从 `SENT_AT` 起算 | 暂定 |
|
||||
| `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-backoff-ms` | `30000` 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` | 取值 0–23 | `PARAM:msgx.operation-day.cutoff-hour` |
|
||||
| `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.cutover-watermark` | 仅 `min`/`zero`/`max`/数字 | 切流播种(`G-SCAN-PREDICATE` 前) |
|
||||
|
||||
**缺值**:带环境变量占位的必需键(`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.delivery.send_failures.total{target}` | 该投递目标累计发送失败次数(进程内,重启归零) | 持续增长且 `dead` 非零:投递链路故障(`OPS-2`) |
|
||||
| `msgx.pipeline.delivery.dead{target}` | 该投递目标当前死信(`DEAD`)行数 | 非零即告警:死信需人工处置(`OPS-2`;状态语义见 [implementation.md](implementation.md)「状态与错误分类」) |
|
||||
| `msgx.pipeline.watermark.lag` | 信箱最新编号比已扫描编号大多少 | 改用处理时间筛选后删除(`G-SCAN-PREDICATE`) |
|
||||
|
||||
未处理消息的统计使用同一份缓存;统计不可用显示 `NaN`,没有记录可比时年龄与编号差显示 `-1`。作业计数在进程重启后归零;作业已启动却连续三个检查周期没有成功运行时,`/health` 报 `DOWN`。
|
||||
|
||||
|
||||
@@ -159,7 +159,6 @@
|
||||
| `G-REQ-TRACK` | 出站请求跟踪未做 | `US-09` |
|
||||
| `G-REQ-TRACK-RETENTION` | `REQ_TRACK` 已结案保留期未定(`Q24`) | `US-09` |
|
||||
| `G-RESP-GUARD` | `SCHD-RESP` 过期判断未做 | `US-07` |
|
||||
| `G-SCAN-PREDICATE` | 按「处理时间为空」扫描未做 | `US-01`;`INV-1` |
|
||||
| `G-SCHD-SNAPSHOT` | 日计划快照删除、清空、分批未做 | `INV-7`、`INV-9` |
|
||||
| `G-SRVT-VIPF` | `SRVT`、`VIPF` 明细未入库(`Q2`) | `US-05` |
|
||||
|
||||
|
||||
@@ -27,15 +27,6 @@ class PipelineProps {
|
||||
var backoffMs: List<Long> = listOf(1000, 2000, 4000, 8000)
|
||||
var backoffCapMs: Long = 60_000
|
||||
|
||||
/**
|
||||
* 缺口等待时长:水位后面缺了一个 ID 时,等这么久还没出现就认定它永远不会来了,
|
||||
* 跳过缺口继续推进水位。
|
||||
*
|
||||
* 取值应该等于库方承诺的"上游提交到消息可见的最长时间"。设太小,可能把一条
|
||||
* 迟到的消息误判成永久缺失,导致它排到后面的消息之后;设太大,收报会在缺口上白等。
|
||||
*/
|
||||
var maxCommitDelay: Duration = Duration.ofMinutes(5)
|
||||
|
||||
/**
|
||||
* 超期补写期限 R:一条消息到达终态后,过了这么久处理标记仍未写回信箱
|
||||
* (比如回填一直失败),扫描谓词的超期分支成立,无视退避强制补写(只会提前、从不推迟打标)。
|
||||
@@ -59,16 +50,6 @@ class PipelineProps {
|
||||
/** 回填独立退避的封顶间隔。 */
|
||||
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`)每轮向一个目标领取的条数上限。
|
||||
* 逐条领取会让投递吞吐被"每条一次 DB 往返 + 一轮一次 sleep"压到每秒 1 条。
|
||||
@@ -119,17 +100,6 @@ class PipelineProps {
|
||||
"msgx.pipeline.backoff-ms has ${backoffMs.size} slots, " +
|
||||
"but max-attempts=$maxAttempts implies exactly ${maxAttempts - 1}"
|
||||
}
|
||||
// 切流播种只接受四种取值;非法值必须在启动时挡掉,而不是每轮轮询刷错误日志。
|
||||
val cutover = cutoverWatermark
|
||||
require(
|
||||
cutover == null ||
|
||||
cutover.equals("min", ignoreCase = true) ||
|
||||
cutover.equals("zero", ignoreCase = true) ||
|
||||
cutover.equals("max", ignoreCase = true) ||
|
||||
cutover.toLongOrNull() != null,
|
||||
) {
|
||||
"msgx.pipeline.cutover-watermark must be one of min|zero|max|<id>, got '$cutover'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-14
@@ -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>
|
||||
|
||||
/**
|
||||
* 把处理标记写回信箱,并且**只写还是空标记的行**:库里已有值时不覆盖、不回退,
|
||||
|
||||
+5
-15
@@ -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 行时再查一次主键,区分“已有标记”和“信箱行缺失”;后者不能记为回填成功。
|
||||
|
||||
-86
@@ -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
|
||||
|
||||
@@ -2,32 +2,26 @@ package com.gzzn.omms.msgexchange.ingress
|
||||
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
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.ProcStateRepository
|
||||
import jakarta.inject.Singleton
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 收报:轮询共享信箱,把新消息登记到自有 PG 的 PROC_STATE,等着主泵处理。
|
||||
* 收报:轮询共享信箱,把处理时间为空的行登记到自有 PG 的 PROC_STATE,等着主泵处理。
|
||||
*
|
||||
* 每一轮只做三件事:
|
||||
* 1. 从"水位"之后按 ID 升序读一批行(只看 ID,不看处理标记)。
|
||||
* 水位记的是"信箱里到哪个 ID 为止已经全部读进自有库",存在自己的库里,重启不丢;
|
||||
* 2. 把读到的行登记成 PENDING,并把水位往前推;
|
||||
* 3. 登记和水位推进写在同一个事务里——中途崩溃时水位没动,下一轮重扫即可补齐。
|
||||
* 每一轮做两件事:
|
||||
* 1. 按处理时间为空、编号升序读一批,每批 `msgx.pipeline.claim-batch` 条;
|
||||
* 2. 逐条登记成 PENDING,主键冲突即已登记过,跳过(`INV-1`)。
|
||||
*
|
||||
* 水位一次只推到"连续"的位置,这是这里唯一需要理解的规则。如果下一个 ID 缺号,
|
||||
* 说明可能有 ID 更小的消息还没提交上来。此时先停在缺口前,不把缺口后面的消息放进队列:
|
||||
* 否则那条迟到的消息会排到它们后面,破坏"先来先处理"的约定,同一航班的报文可能被乱序应用。
|
||||
* 读满一批就接着往后读。处理完却还没回填的行(尤其永远不回填的死信)会被反复读到,
|
||||
* 只读第一批的话,这些行攒够一批之后新消息就再也发现不了。续读起点只活在本轮的内存里,
|
||||
* 不落库、也不是消费边界:重启后从头重扫,重复登记建不出第二行。
|
||||
*
|
||||
* 缺口等超过 `msgx.pipeline.max-commit-delay` 仍未出现,就认定它永远不会来了
|
||||
* (典型情况是自增回滚留下的空位),跳过它继续推进——否则水位会卡在第一个空位上
|
||||
* 再也不动。
|
||||
* 编号较小的行晚提交时,最坏只是被发现得晚(下一轮仍会读到),不会丢,但它不会排在
|
||||
* 已经处理完的较大编号之前。
|
||||
*
|
||||
* 这一层不解析报文,也不写信箱处理标记:标记由处理完成后的 BackfillService 负责补。
|
||||
*/
|
||||
@@ -35,7 +29,6 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
class InboxPoller(
|
||||
private val mailbox: CminmsgInboxRepository,
|
||||
private val procState: ProcStateRepository,
|
||||
private val cursor: InboxCursorRepository,
|
||||
private val txManager: PipelineTransactionManager,
|
||||
private val props: PipelineProps,
|
||||
private val clock: Clock,
|
||||
@@ -45,93 +38,35 @@ class InboxPoller(
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
/** @return 本轮新登记的消息条数(已登记过的行不计入,也不影响水位推进)。 */
|
||||
/** @return 本轮新登记的消息条数(已登记过的行不计入)。 */
|
||||
fun pollOnce(now: Instant): Int {
|
||||
seedCutoverWatermarkIfConfigured(now)
|
||||
val batch = props.pipeline.claimBatch.coerceAtLeast(1)
|
||||
val watermark = cursor.load()
|
||||
val rows = mailbox.readRange(watermark.committedUpTo, batch)
|
||||
if (rows.isEmpty()) return 0
|
||||
|
||||
// 找到连续部分的末尾;如果这批里出现了缺口,缺口后面的行这一轮先不入队
|
||||
val contiguous = contiguousUpTo(watermark.committedUpTo, rows) ?: watermark.committedUpTo
|
||||
var committedTo = contiguous
|
||||
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 ->
|
||||
var afterId = 0L
|
||||
var enqueued = 0
|
||||
var scanned = 0
|
||||
while (true) {
|
||||
val rows = mailbox.readUnprocessed(afterId, batch)
|
||||
if (rows.isEmpty()) break
|
||||
enqueued += txManager.inTransaction {
|
||||
// 入队时间用本轮注入的本地时钟,与超期判据 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))
|
||||
n
|
||||
afterId = rows.last().msgId
|
||||
scanned += rows.size
|
||||
if (rows.size < batch) break
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 切流水位播种:**只在显式配置 `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() {
|
||||
running = true
|
||||
log.info("inbox poller loop started")
|
||||
while (running) {
|
||||
try {
|
||||
// 时间一律走注入的 Clock:空洞老化阈值是"等多久算永久缺失"的唯一判据,不能依赖系统时钟。
|
||||
// 时间一律走注入的 Clock:入队时间是超期判据 R 的比较对象,不能依赖系统时钟。
|
||||
pollOnce(clock.instant())
|
||||
sleepQuietly(props.pipeline.pollInterval)
|
||||
} catch (_: InterruptedException) {
|
||||
@@ -149,21 +84,6 @@ class InboxPoller(
|
||||
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) {
|
||||
try {
|
||||
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.metrics.PipelineCounters
|
||||
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.retry.ProcFailure
|
||||
import jakarta.inject.Singleton
|
||||
@@ -38,7 +37,6 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
@Singleton
|
||||
class Pump(
|
||||
private val procState: ProcStateRepository,
|
||||
private val cursor: InboxCursorRepository,
|
||||
private val processor: MessageProcessor,
|
||||
private val props: PipelineProps,
|
||||
private val clock: Clock,
|
||||
@@ -80,17 +78,6 @@ class Pump(
|
||||
sleepQuietly(props.pipeline.pollInterval)
|
||||
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()
|
||||
when {
|
||||
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) {
|
||||
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
|
||||
}
|
||||
|
||||
@@ -18,12 +18,9 @@ msgx:
|
||||
max-attempts: 5 # 处理/投递同值
|
||||
backoff-ms: [1000, 2000, 4000, 8000] # 指数退避;档位数必须 = max-attempts - 1(启动自检)
|
||||
backoff-cap-ms: 60000
|
||||
max-commit-delay: 5m # 空洞老化:W+1 空洞超过该时延判定为永久(Q7 最大提交时延)
|
||||
overdue-backfill: 30d # 超期补写期限 R:仅须 R ≤ R_keep;判据比较本地 ENQUEUED_AT(implementation.md「回填」)
|
||||
backfill-batch: 100 # 回填扫描单批条数
|
||||
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)
|
||||
schd:
|
||||
flush-period: 3s # KEEP 现役推送节律
|
||||
@@ -46,7 +43,7 @@ micronaut:
|
||||
port: 8080
|
||||
# 管理端点(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(原多版净结构合并)
|
||||
# + V2__flight_chute_class_type_rename.sql(chute 类字段 CCLS/CTYP);回填事实与处理终态同表同行)。
|
||||
# 数据层实装前 enabled=false(stub 模式不建连)。
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
-- 系统库,只做契约内 DML,不建表、不改结构(C-2)。
|
||||
--
|
||||
-- 本文件原为 V1 基线;原 V2–V10 的净结构已全部合并进来,版本链收敛为一条:
|
||||
-- · INBOX_CURSOR 及 SEEDED_AT(原 V2/V5);
|
||||
-- · PROC_STATE 的收信/入队时间与回填事实列,独立待办表 BACKFILL_TODO 不再存在
|
||||
-- (原 V2/V3/V4/V6/V7:回填意图并入 PROC_STATE,处理开始时间列加后即删);
|
||||
-- · REQ_TRACK 开放态部分唯一索引 uq_req_open(原 V8);
|
||||
@@ -20,7 +19,7 @@
|
||||
--
|
||||
-- 分层(职责与写者见 docs/implementation.md「航班域」):
|
||||
-- · 决策层: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 —— 只追加、可重建、不参与决策;
|
||||
-- · 证据层:报文原文归档 —— 冷路径,尚未交付(ARCHIVE_KEY 仅留引用位)。
|
||||
-- 类型口径: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)
|
||||
WHERE BACKFILL_AT IS NULL AND BACKFILL_ABANDONED_AT IS NULL;
|
||||
|
||||
-- ③ 消费水位:全表只有一行。W 只随新 ID 成功入队推进、只增不减,遇空洞即停;
|
||||
-- 入队与水位推进同事务(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)
|
||||
-- ③ 航班当前态主行:一行一 FLID(决策层权威;展示视图是投影,不是权威,INV-5)
|
||||
CREATE TABLE FLIGHT_SCHD (
|
||||
FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 实例 ID(Number(1-12));不得由航班号或资源号推断
|
||||
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,行级条件更新即满足;
|
||||
-- 不使用触发器(Oracle/PG 双方言成本)。
|
||||
|
||||
-- ⑤ 资源明细表 ×8 + 路线点表:每 FLID 多行;每次完整状态写入按该 FLID 先删后插,
|
||||
-- ④ 资源明细表 ×8 + 路线点表:每 FLID 多行;每次完整状态写入按该 FLID 先删后插,
|
||||
-- 以完整合并结果为准(implementation「字段与集合」)。ORDINAL 是持久化顺序(从 1 起),SOURCE_SEQ 是上游
|
||||
-- 序号(允许为空或重复);相同资源号不代表同一条分配,禁止按资源号去重。删除一律
|
||||
-- 标记在主行 STATE,明细物理清除只发生在受控历史归档(US-14)。
|
||||
-- ⑤-1 登机门 GTDT
|
||||
-- ④-1 登机门 GTDT
|
||||
CREATE TABLE FLIGHT_GATE (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -159,7 +143,7 @@ CREATE TABLE FLIGHT_GATE (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-2 值机柜台 CKDT
|
||||
-- ④-2 值机柜台 CKDT
|
||||
CREATE TABLE FLIGHT_CHECKIN (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -175,7 +159,7 @@ CREATE TABLE FLIGHT_CHECKIN (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-3 行李转盘 CLDT
|
||||
-- ④-3 行李转盘 CLDT
|
||||
CREATE TABLE FLIGHT_BELT (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -191,7 +175,7 @@ CREATE TABLE FLIGHT_BELT (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-4 计划机位 PSDT
|
||||
-- ④-4 计划机位 PSDT
|
||||
CREATE TABLE FLIGHT_STAND_PLAN (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -203,7 +187,7 @@ CREATE TABLE FLIGHT_STAND_PLAN (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-5 滑槽 CHDT
|
||||
-- ④-5 滑槽 CHDT
|
||||
CREATE TABLE FLIGHT_CHUTE (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -219,7 +203,7 @@ CREATE TABLE FLIGHT_CHUTE (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-6 延误 DELY(业务上任意时刻仅 1 个有效延误,保留多行能力以无损承接)
|
||||
-- ④-6 延误 DELY(业务上任意时刻仅 1 个有效延误,保留多行能力以无损承接)
|
||||
CREATE TABLE FLIGHT_DELAY (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -232,7 +216,7 @@ CREATE TABLE FLIGHT_DELAY (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-7 靠撤桥 ABTM(桥号 ABDG;A/D 各一条)
|
||||
-- ④-7 靠撤桥 ABTM(桥号 ABDG;A/D 各一条)
|
||||
CREATE TABLE FLIGHT_BRIDGE_OP (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -244,7 +228,7 @@ CREATE TABLE FLIGHT_BRIDGE_OP (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-8 轮挡 CHOT(机位 CHID;ON/OFF 各一条)
|
||||
-- ④-8 轮挡 CHOT(机位 CHID;ON/OFF 各一条)
|
||||
CREATE TABLE FLIGHT_CHOCK_OP (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
@@ -256,7 +240,7 @@ CREATE TABLE FLIGHT_CHOCK_OP (
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ⑤-9 路线点 ROUT/ERUT 共用(ROUTE_KIND 区分两类;主键含该列,避免序号冲突)
|
||||
-- ④-9 路线点 ROUT/ERUT 共用(ROUTE_KIND 区分两类;主键含该列,避免序号冲突)
|
||||
CREATE TABLE FLIGHT_ROUTE_POINT (
|
||||
FLID VARCHAR(32) 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_chock_flid ON FLIGHT_CHOCK_OP (FLID);
|
||||
|
||||
-- ⑥ 统一投递事件 outbox(C-9、INV-3):KAFKA:schd 发整态、KAFKA:msg 只通知变化;
|
||||
-- ⑤ 统一投递事件 outbox(C-9、INV-3):KAFKA:schd 发整态、KAFKA:msg 只通知变化;
|
||||
-- tombstone 仅在 ACTIVE→DELETED 时与删除同事务登记,投递失败按退避持续重试。
|
||||
-- 目标级全序投递是当前实现,按 FLID 保序见 CLM-3。
|
||||
CREATE TABLE MSG_EVENT (
|
||||
@@ -302,7 +286,7 @@ CREATE UNIQUE INDEX uq_schd_event
|
||||
ON MSG_EVENT (TARGET, PARTITION_KEY)
|
||||
WHERE TARGET = 'KAFKA:schd';
|
||||
|
||||
-- ⑦ 请求状态机:只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)匹配最新一条
|
||||
-- ⑥ 请求状态机:只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)匹配最新一条
|
||||
-- 开放请求。同类请求只留一条有效,新请求置旧请求为 EXPIRED。登记、超时与应答匹配
|
||||
-- 尚未实现(G-REQ-TRACK、G-REQ-OPEN-UNIQUE)。
|
||||
CREATE TABLE REQ_TRACK (
|
||||
@@ -322,7 +306,7 @@ CREATE UNIQUE INDEX uq_req_open
|
||||
ON REQ_TRACK (REQ_TYPE, OPERATION_DAY, SENDER)
|
||||
WHERE STATE IN ('PENDING', 'SENT');
|
||||
|
||||
-- ⑧ SCHD 快照留痕:事务外追加,只追加留痕、不参与决策;一行=一次尝试,重放也记。
|
||||
-- ⑦ SCHD 快照留痕:事务外追加,只追加留痕、不参与决策;一行=一次尝试,重放也记。
|
||||
-- RESULT 与 FLAGS 分列(可「成功且告警」);写失败只记指标。
|
||||
CREATE TABLE SCHD_SNAP_LOG (
|
||||
LOG_ID BIGSERIAL PRIMARY KEY,
|
||||
|
||||
@@ -16,7 +16,7 @@ Flyway 配置**;PG 路径使用 `classpath:db/migration`,两者互不混用
|
||||
## 计划内容
|
||||
|
||||
- 单基线:`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 语义钉死。
|
||||
- `INSERT ... ON CONFLICT` 改 11g MERGE(OPERATION_DAY 不可变条件,implementation「航班身份与运营日」)。
|
||||
- 空串按 NULL 的语义回归:显式清空的 presence 信息不得被 11g 空串语义吞掉
|
||||
|
||||
@@ -52,7 +52,6 @@ class PipelineSmokeTest {
|
||||
fun cleanStubs() {
|
||||
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.StubInboxCursor::class.java).clear()
|
||||
ctx.getBean(StubMsgEvents::class.java).clear()
|
||||
ctx.getBean(StubDeliveryPort::class.java).clear()
|
||||
}
|
||||
@@ -98,8 +97,6 @@ class PipelineSmokeTest {
|
||||
val receipt = controller.send(UNSUPPORTED_XML)
|
||||
assertNotNull(receipt.body()) // 受理 ID
|
||||
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)
|
||||
|
||||
@@ -115,7 +112,6 @@ class PipelineSmokeTest {
|
||||
fun `replay reopens failed UNSUPPORTED row to PENDING`() {
|
||||
val receipt = controller.send(REFDATA_XML)
|
||||
val id = receipt.body()!!.toLong()
|
||||
ctx.getBean(com.gzzn.omms.msgexchange.ingress.InboxPoller::class.java).pollOnce(Instant.now())
|
||||
pump.tick()
|
||||
val stub = ctx.getBean(StubProcState::class.java)
|
||||
assertEquals(ProcStatus.FAILED, stub.snapshotOf(id)!!.state)
|
||||
@@ -193,9 +189,6 @@ class PipelineSmokeTest {
|
||||
val proc = ctx.getBean(StubProcState::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)
|
||||
// 队头必须在水位以内才可领取:直接播种 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.update(
|
||||
9001L, ProcStatus.FAILED,
|
||||
@@ -225,40 +218,32 @@ class PipelineSmokeTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* FIFO 不变量"只领取已发现的行"端到端验收:兼容入口写入的高 ID **不会被提前领取**,
|
||||
* 必须等水位追平(较小 ID 补齐并入队)之后才按顺序处理。
|
||||
* 扫描谓词端到端验收(`INV-1`):处理标记为空的行都会被发现并登记,处理完并回填之后
|
||||
* 退出扫描谓词;兼容入口已登记的行不会被收报建成第二条记录。
|
||||
*/
|
||||
@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 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)
|
||||
|
||||
// 信箱:1 存在、2 是空洞(被删)、3 存在 → 水位停在 1
|
||||
inbox.simulateExternalWrite("<MSG/>")
|
||||
val hole = inbox.simulateExternalWrite("<MSG/>")
|
||||
inbox.simulateExternalWrite("<MSG/>")
|
||||
inbox.removeRow(hole)
|
||||
val external = inbox.simulateExternalWrite("<MSG/>") // 上游直接写信箱
|
||||
val compat = controller.send(UNSUPPORTED_XML).body()!!.toLong() // 兼容入口:已自行入队
|
||||
|
||||
poller.pollOnce(Instant.now())
|
||||
assertEquals(1L, cursor.cursor.committedUpTo)
|
||||
assertEquals(setOf(external, compat), proc.rows.keys.toSet())
|
||||
|
||||
// 兼容入口写入高 ID:直接进 PG,但水位没追平(这正是原来的越序路径)
|
||||
val high = controller.send(UNSUPPORTED_XML).body()!!.toLong()
|
||||
assertTrue(high > 3L)
|
||||
poller.pollOnce(Instant.now()) // 重扫不建第二条
|
||||
assertEquals(2, proc.rows.size)
|
||||
|
||||
pump.tick() // 先按 FIFO 处理 ID=1
|
||||
assertEquals(ProcStatus.DEAD, proc.find(1L)!!.state)
|
||||
pump.tick() // 队头变成高 ID,但它在水位之外 → 不领取
|
||||
assertEquals(ProcStatus.PENDING, proc.find(high)!!.state)
|
||||
repeat(2) { pump.tick() }
|
||||
ctx.getBean(com.gzzn.omms.msgexchange.processing.BackfillService::class.java).sweep()
|
||||
assertTrue(inbox.isMarked(external) && inbox.isMarked(compat))
|
||||
|
||||
// 空洞补齐 → 水位追平 → 才允许继续按顺序处理
|
||||
inbox.restoreRow(hole, "<MSG/>", Instant.now())
|
||||
// 已回填的行退出扫描谓词:清掉处理记录后也不会被重新登记
|
||||
proc.clear()
|
||||
poller.pollOnce(Instant.now())
|
||||
assertTrue(cursor.cursor.committedUpTo >= high)
|
||||
repeat(3) { pump.tick() } // 依次处理 2、3、高 ID
|
||||
assertEquals(ProcStatus.SKIPPED, proc.find(high)!!.state)
|
||||
assertEquals("unsupported:XYZQ-FOO", proc.find(high)!!.lastError)
|
||||
assertTrue(proc.rows.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -23,13 +23,4 @@ class PipelineConfigCheckTest {
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
/** 切流播种只接受 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
|
||||
fun `non-positive attempt never throws and falls back to first slot`() {
|
||||
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.delivery.DeliveryPort
|
||||
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 io.micronaut.health.HealthStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -41,18 +38,14 @@ class HealthIndicatorsTest {
|
||||
|
||||
/** 检查 /health 里那几个积压指标算得对不对。 */
|
||||
@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 inbox = StubInbox().apply { clear() }
|
||||
val cursor = StubInboxCursor()
|
||||
val oldest = MutableClock.BASE.minusSeconds(3600)
|
||||
proc.insertIfAbsent(1L, oldest)
|
||||
proc.insertIfAbsent(2L, 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)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -60,12 +53,11 @@ class HealthIndicatorsTest {
|
||||
assertEquals(1, details["backlog"])
|
||||
assertEquals(3600L, details["oldestUnprocessedSeconds"])
|
||||
assertEquals(1, details["unmarkedTerminal"])
|
||||
assertEquals(2L, details["watermarkLag"])
|
||||
}
|
||||
|
||||
@Test
|
||||
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) // 可用性由依赖自身指示器承担
|
||||
}
|
||||
|
||||
@@ -48,8 +48,6 @@ class PipelineMetricsTest {
|
||||
assertEquals(1.0, gauge("msgx.pipeline.backlog.unfinished"), 0.001)
|
||||
assertEquals(1.0, gauge("msgx.pipeline.backfill.unmarked_terminal"), 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
|
||||
|
||||
+5
-10
@@ -11,8 +11,8 @@ import java.sql.DriverManager
|
||||
|
||||
/**
|
||||
* 在真实 PostgreSQL 上跑一遍迁移链,确认结果符合预期:`V1__flight_state_baseline.sql`
|
||||
* 基线加 `V2__flight_chute_class_type_rename.sql` 更名依序执行成功,该建的表和单行种子
|
||||
* (PIPELINE_LOCK、INBOX_CURSOR)都在,回填事实与收报水位都落在基线里,`BACKFILL_TODO`、
|
||||
* 基线加 `V2__flight_chute_class_type_rename.sql` 更名依序执行成功,该建的表和 PIPELINE_LOCK
|
||||
* 单行种子都在,回填事实落在基线里,`INBOX_CURSOR`、`BACKFILL_TODO`、
|
||||
* `idx_evt_flid`、`PROC_STATE` 的处理开始时间列都不复存在;FLIGHT_CHUTE 的类字段列
|
||||
* 已由 V2 更名为 CCLS/CTYP(SIS 口径)。
|
||||
*
|
||||
@@ -24,7 +24,7 @@ class FlywayMigrationTest {
|
||||
"pipeline_lock", "proc_state", "flight_schd",
|
||||
"flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute",
|
||||
"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
|
||||
@@ -78,6 +78,7 @@ class FlywayMigrationTest {
|
||||
val missing = expectedTables - tables
|
||||
assertTrue(missing.isEmpty(), "missing tables: $missing")
|
||||
assertTrue("backfill_todo" !in tables, "BACKFILL_TODO 已由 PROC_STATE 回填列取代")
|
||||
assertTrue("inbox_cursor" !in tables, "扫描谓词按处理时间取行,不再有消费水位表")
|
||||
}
|
||||
|
||||
// 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列
|
||||
@@ -200,17 +201,11 @@ class FlywayMigrationTest {
|
||||
assertEquals("NO", rs.getString("is_nullable"), "ENQUEUED_AT 必须非空")
|
||||
}
|
||||
|
||||
// 两张单行表(管道锁、收报水位)的种子数据都要在
|
||||
// 管道锁的单行种子要在
|
||||
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
|
||||
assertTrue(rs.next())
|
||||
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(), "初始无空洞观测")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-36
@@ -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.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MailboxMarkResult
|
||||
import org.h2.jdbcx.JdbcDataSource
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -24,7 +23,7 @@ import javax.sql.DataSource
|
||||
*
|
||||
* 库用 H2 的 PostgreSQL 兼容模式,表结构照抄 V1 + V2,因此不需要 docker 或外接数据库
|
||||
* 就能跑。覆盖两边的真实语句:自有 PG 侧(写终态时一并写下回填待办、挑选待回填记录、
|
||||
* 积压统计、水位游标读写)和共享信箱侧(按 ID 区间读、标记只写一次)。
|
||||
* 积压统计)和共享信箱侧(按处理时间为空扫描、标记只写一次)。
|
||||
*
|
||||
* 有一处覆盖不到:H2 不支持 `INSERT ... ON CONFLICT DO NOTHING`,所以入队幂等没在这里验证,
|
||||
* 由 `InboxPollerTest`(重复轮询不再登记)和 PostgreSQL 本身的语义来保证。
|
||||
@@ -34,7 +33,6 @@ class InboxLifecycleJdbcSqlTest {
|
||||
private lateinit var ds: DataSource
|
||||
private lateinit var proc: JdbcProcStateRepository
|
||||
private lateinit var mailbox: JdbcCminmsgInboxRepository
|
||||
private lateinit var cursor: JdbcInboxCursorRepository
|
||||
|
||||
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
|
||||
private val overdue: Instant = t0.minus(Duration.ofDays(31))
|
||||
@@ -49,18 +47,12 @@ class InboxLifecycleJdbcSqlTest {
|
||||
ds.connection.use { conn ->
|
||||
conn.createStatement().use { st ->
|
||||
st.execute(PROC_STATE_DDL)
|
||||
st.execute(CURSOR_DDL)
|
||||
st.execute(MAILBOX_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())
|
||||
mailbox = JdbcCminmsgInboxRepository(ds)
|
||||
cursor = JdbcInboxCursorRepository(ds, java.time.Clock.systemUTC())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -133,15 +125,6 @@ class InboxLifecycleJdbcSqlTest {
|
||||
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
|
||||
fun `replay resets attempts, backoff and the previous error reason`() {
|
||||
seed(12L, t0)
|
||||
@@ -185,22 +168,22 @@ class InboxLifecycleJdbcSqlTest {
|
||||
}
|
||||
|
||||
@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 second = mailbox.insertRaw("<B/>")
|
||||
val third = mailbox.insertRaw("<C/>")
|
||||
|
||||
// 发现按 ID 区间;处理标记不参与扫描谓词
|
||||
assertEquals(listOf(first, second, third), mailbox.readRange(0L, 50).map { it.msgId })
|
||||
assertNotNull(mailbox.readRange(0L, 50).first().receivedAt)
|
||||
assertEquals(listOf(second, third), mailbox.readRange(first, 50).map { it.msgId })
|
||||
assertEquals(third, mailbox.maxId())
|
||||
// 扫描谓词只看处理时间为空;afterId 是同一轮的续读起点
|
||||
assertEquals(listOf(first, second, third), mailbox.readUnprocessed(0L, 50).map { it.msgId })
|
||||
assertNotNull(mailbox.readUnprocessed(0L, 50).first().receivedAt)
|
||||
assertEquals(listOf(second, third), mailbox.readUnprocessed(first, 50).map { it.msgId })
|
||||
assertEquals(listOf(first, second), mailbox.readUnprocessed(0L, 2).map { it.msgId })
|
||||
|
||||
assertEquals(MailboxMarkResult.MARKED, mailbox.markProcessedIfUnmarked(second, "PROCESSED"))
|
||||
assertEquals(MailboxMarkResult.ALREADY_MARKED, mailbox.markProcessedIfUnmarked(second, "OTHER"))
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
CREATE TABLE cminmsgs (
|
||||
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.ProcStatus
|
||||
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.assertFalse
|
||||
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
|
||||
|
||||
/**
|
||||
* 收报环节最要紧的几条规矩:
|
||||
* - 取新消息只看 ID,不看处理标记。处理完却没能回填的行(尤其是永远不回填的死信)
|
||||
* 不允许占住批次,也不允许挡住后面的新消息——这是曾经的线上隐患;
|
||||
* - 水位只在成功登记后才推进,而且和登记写在同一个事务里,中断后重扫就能补齐;
|
||||
* - 遇到 ID 缺口先停下来(可能有更小的消息还没到),缺口等太久则跳过(否则水位永远卡住);
|
||||
* 收报环节最要紧的几条规矩(`INV-1`):
|
||||
* - 取新消息只看处理时间是否为空,按编号升序、每批 `claim-batch` 条;
|
||||
* - 同一编号只建一条处理记录:重扫、重启、与兼容入口并发都不会多建;
|
||||
* - 已回填的行不再被扫到;终态但未回填的行会重复扫到,但不许占住批次挡住后面的新行;
|
||||
* - 这一层不碰信箱的处理标记,标记留给回填环节写。
|
||||
*/
|
||||
class InboxPollerTest {
|
||||
|
||||
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
|
||||
private val props = PipelineProps()
|
||||
private val clock: Clock = Clock.fixed(t0, ZoneOffset.UTC)
|
||||
|
||||
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() }
|
||||
poller = InboxPoller(
|
||||
inbox, proc, cursor, StubPipelineTx(), props,
|
||||
java.time.Clock.fixed(t0, java.time.ZoneOffset.UTC),
|
||||
)
|
||||
poller = newPoller()
|
||||
}
|
||||
|
||||
private fun newPoller() = InboxPoller(inbox, proc, StubPipelineTx(), props, clock)
|
||||
|
||||
@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 second = inbox.simulateExternalWrite("<MSG/>")
|
||||
|
||||
@@ -54,16 +51,29 @@ class InboxPollerTest {
|
||||
|
||||
assertEquals(ProcStatus.PENDING, proc.find(first)!!.state)
|
||||
assertEquals(ProcStatus.PENDING, proc.find(second)!!.state)
|
||||
assertEquals(second, cursor.cursor.committedUpTo)
|
||||
assertNull(cursor.cursor.holeSince)
|
||||
assertEquals(listOf(first, second), proc.rows.keys.sorted())
|
||||
// 收报只写自有库,不碰信箱的处理标记
|
||||
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
|
||||
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(ProcStatus.PENDING, proc.find(fresh)!!.state)
|
||||
assertEquals(fresh, cursor.cursor.committedUpTo)
|
||||
}
|
||||
|
||||
/** 未回填的存量远多于一批时,续读要一直翻到最后一批,才能发现末尾的新行。 */
|
||||
@Test
|
||||
fun `watermark stops at a hole so later ids cannot overtake a missing smaller id`() {
|
||||
val first = inbox.simulateExternalWrite("<MSG/>")
|
||||
val hole = inbox.simulateExternalWrite("<MSG/>")
|
||||
val afterHole = inbox.simulateExternalWrite("<MSG/>")
|
||||
inbox.removeRow(hole)
|
||||
fun `multi batch scan reaches new rows behind several full batches`() {
|
||||
props.pipeline.claimBatch = 2
|
||||
val stale = (1..5).map { inbox.simulateExternalWrite("<MSG/>") }
|
||||
assertEquals(5, poller.pollOnce(t0))
|
||||
stale.forEach { proc.markTerminal(it, ProcStatus.SUCCEEDED, now = t0) }
|
||||
|
||||
val fresh = inbox.simulateExternalWrite("<MSG/>")
|
||||
|
||||
assertEquals(1, poller.pollOnce(t0))
|
||||
|
||||
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)
|
||||
assertNotNull(proc.find(fresh))
|
||||
}
|
||||
|
||||
/** 重启不带任何消费边界:从头重扫,已登记的行不会被建成第二行。 */
|
||||
@Test
|
||||
fun `an aged hole is released and later ids resume enqueuing`() {
|
||||
val hole = inbox.simulateExternalWrite("<MSG/>")
|
||||
val afterHole = inbox.simulateExternalWrite("<MSG/>")
|
||||
inbox.removeRow(hole)
|
||||
poller.pollOnce(t0) // 记录空洞观测时刻
|
||||
fun `a restarted poller rescans from the start without duplicating rows`() {
|
||||
val ids = (1..3).map { inbox.simulateExternalWrite("<MSG/>") }
|
||||
assertEquals(3, poller.pollOnce(t0))
|
||||
|
||||
val agedOut = t0.plus(props.pipeline.maxCommitDelay)
|
||||
assertEquals(0, poller.pollOnce(agedOut)) // 空洞判永久:推进水位但不越过入队
|
||||
val restarted = newPoller()
|
||||
|
||||
assertNull(cursor.cursor.holeSince)
|
||||
assertEquals(afterHole - 1, cursor.cursor.committedUpTo)
|
||||
assertEquals(1, poller.pollOnce(agedOut)) // 下一轮恢复发现
|
||||
assertEquals(afterHole, cursor.cursor.committedUpTo)
|
||||
assertNotNull(proc.find(afterHole))
|
||||
assertEquals(0, restarted.pollOnce(t0.plusSeconds(60)))
|
||||
assertEquals(ids.size, proc.rows.size)
|
||||
assertEquals(ids, proc.rows.keys.sorted())
|
||||
}
|
||||
|
||||
/** 编号较小的行晚提交:本轮读不到不算丢,下一轮照常发现。 */
|
||||
@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
|
||||
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(receipt.msgId, cursor.cursor.committedUpTo) // 已在 PG:读取进度照常推进
|
||||
assertEquals(1, proc.rows.size)
|
||||
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.ProcStateRepository
|
||||
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
|
||||
@@ -43,11 +42,7 @@ class InboxServiceTest {
|
||||
else -> Instant.parse("2026-09-06T01:59:55Z")
|
||||
}
|
||||
|
||||
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> = emptyList()
|
||||
|
||||
override fun maxId(): Long? = null
|
||||
|
||||
override fun minId(): Long? = null
|
||||
override fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow> = emptyList()
|
||||
|
||||
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`() {
|
||||
val inbox = StubInbox().apply { clear() }
|
||||
val proc = StubProcState().apply { clear() }
|
||||
val cursor = StubInboxCursor().apply { clear() }
|
||||
val clock = MutableClock(t0)
|
||||
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 入队失败但不抛
|
||||
assertEquals(1L, service.pgEnqueueFailures.get())
|
||||
@@ -112,7 +106,6 @@ class InboxServiceTest {
|
||||
assertEquals(1, poller.pollOnce(t0)) // 轮询补建
|
||||
assertNotNull(proc.find(receipt.msgId))
|
||||
assertEquals(1, proc.rows.size)
|
||||
assertEquals(receipt.msgId, cursor.cursor.committedUpTo)
|
||||
|
||||
assertEquals(0, poller.pollOnce(t0)) // 已存在:不重复建行,也不重复计数
|
||||
assertEquals(1, proc.rows.size)
|
||||
|
||||
@@ -46,9 +46,7 @@ class BackfillServiceTest {
|
||||
override fun insertRaw(rawXml: String): Long = 1L
|
||||
override fun rawOf(msgId: Long): String? = null
|
||||
override fun receivedAtOf(msgId: Long): Instant? = null
|
||||
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> = emptyList()
|
||||
override fun maxId(): Long? = null
|
||||
override fun minId(): Long? = null
|
||||
override fun readUnprocessed(afterId: Long, limit: Int): List<MailboxRow> = emptyList()
|
||||
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult {
|
||||
if (fail) throw IllegalStateException("mysql-down")
|
||||
if (missing) return MailboxMarkResult.MISSING
|
||||
@@ -237,9 +235,7 @@ class BackfillServiceTest {
|
||||
override fun insertRaw(rawXml: String) = 1L
|
||||
override fun rawOf(msgId: Long): String? = "<MSG/>"
|
||||
override fun receivedAtOf(msgId: Long) = t0
|
||||
override fun readRange(fromExclusive: Long, limit: Int) = emptyList<MailboxRow>()
|
||||
override fun maxId(): Long? = 1L
|
||||
override fun minId(): Long? = 1L
|
||||
override fun readUnprocessed(afterId: Long, limit: Int) = emptyList<MailboxRow>()
|
||||
override fun markProcessedIfUnmarked(msgId: Long, value: String): MailboxMarkResult {
|
||||
entered.countDown()
|
||||
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.ProcStatus
|
||||
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.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
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.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||
@@ -63,8 +61,6 @@ class IgnoreBranchTest {
|
||||
counters: PipelineCounters = PipelineCounters(),
|
||||
codec: XmlCodec,
|
||||
): MessageProcessor {
|
||||
val cursor = StubInboxCursor()
|
||||
cursor.cursor = InboxCursorRepository.Cursor(committedUpTo = Long.MAX_VALUE)
|
||||
val clock = Clock.systemUTC()
|
||||
val props = PipelineProps()
|
||||
val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
|
||||
|
||||
Reference in New Issue
Block a user