Files
msgexchange-v2/docs/message-lifecycle.md
T

349 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 上游消息生命周期设计
## 本文范围与读者
本文定义 msgexchange-v2(下称"本系统")对共享 MySQL 信箱中报文的完整生命周期:从上游写入、本系统处理、处理标记回信箱,到信箱数据最终清除。各阶段的输入输出、幂等方式、故障恢复方法,以及本系统与信箱库管理方之间的分工,都在本文界定。
- 模块协作、状态机与配置参数见 [design.md](design.md);系统边界与"不建表、不改结构"的红线见 [architecture.md](architecture.md);验收口径见 [user-stories.md](user-stories.md)。符号(`W` / `holeSince` / `R` / `R_keep` / `head-deadline` 等)与配置键的对应关系统一见 [design.md](design.md) §1.1。
- 读者包括本系统开发与运维人员、以及负责共享 MySQL 的库方接口人;**§5–§7 中标注 Q 编号的条款**即需要与库方书面确认的开放项。
- 状态标记沿用 [design.md](design.md) §1:【目标】/【现状】/【缺口】/【待确认】。正文描述目标设计,本文范围内的缺口清单集中在 §12;跨文档引用一律带文档名。
**角色与术语约定**(全文沿用):
- **上游**:向信箱写入报文的源头系统(CIIMS、AODB 等)。
- **信箱**:共享 MySQL 中的入站信箱表 `CMINMSGS`;出站方向为 `COUTMSGS`(§7)。
- **库方**:共享 MySQL 的管理方;表结构变更与数据清除只能由库方执行或书面授权。
- **处理标记**:信箱行上表示"本系统已处理"的约定字段;逻辑名 `DATE_PROCESSED` / `STATUS`,实际列名以库方契约为准(legacy 为 `CMINMSGS_DATE_PROCESSED` 等真实列)。正文统一用"处理标记",不再混用列名。
- **自有 PG**:本系统唯一的业务数据库 PostgreSQL;信箱与自有 PG 之间不存在跨库事务。
## 1. 五个独立事实
消息的认定分为五个环节,每个环节都有独立的证据,互不替代:上游写入了不等于本系统接手了,本系统处理完了也不等于信箱标记已写、下游已收到。
| 环节 | 认定依据 | 执行方 |
|---|---|---|
| 落信:报文进入信箱 | `CMINMSGS` 中存在该行 | 上游 |
| 入队:本系统开始处理 | 自有 PG 建立 `PROC_STATE` 记录 | `InboxPoller` |
| 处理完成:业务处理完成 | `PROC_STATE` 到达终态(SUCCEEDED / SKIPPED / DEAD | 主泵 |
| 已回填:信箱写入处理标记 | 信箱行持有处理标记 | 回填与补偿通道 |
| 投递确认:投递目标已接受 | 目标端返回成功且 `MSG_EVENT``SENT` | `delivery`Dispatcher / flushSchd |
分环节的原因是存储边界:信箱与自有 PG 之间的写入不共享事务。“落信”以共享 MySQL 为准,“入队”和“处理完成”以自有 PG 为准;“已回填”与“投递确认”各以外部操作成功和本地确认事实共同判定。后两个环节各自独立重试,都可能单独失败。“投递确认”只表示 Kafka Broker 或其他投递目标已接受,不表示业务消费者已消费。
"入队"这一环还有一个不由本系统单独保证的前提:**发现是完整的**——依赖 Q2 的两条上游承诺(§5.1)。
## 2. 状态总纲
```text
信箱行(共享 MySQL
未标记 ──[本系统写处理标记]──→ 已处理标记 ──[库方按保留期执行]──→ 已归档 ──→ 已清除
↑ §6(本系统不执行 DDL)
└── 回填与超期补写(§5.2)
PROC_STATE(自有 PG
PENDING ──处理成功────────→ SUCCEEDED
│ ↑ ─→ SKIPPED(业务重复,非错误)
│ └── 人工重放(白名单错误类)
└─ 处理失败 → FAILED ──退避到期──→ PENDING
└── 次数耗尽 / 队头滞留超限 ──→ DEAD(人工处置)
MSG_EVENT(自有 PG,投递侧,见 design.md §5
PENDING ──投递确认──→ SENT
└── 失败退避 ──→ DEAD(DLQ)
```
- `PENDING``FAILED` 是处理中的状态,`FAILED` 继续退避重试并占用 FIFO 队头;`SUCCEEDED / SKIPPED / DEAD` 是终态,到达后队列方可推进。
- `DEAD``FAILED` 不是不可逆:经人工批准,指定错误类别的记录可重新置回 `PENDING` 处理(放行范围见 §9 / design.md §6.1)。重放处理的是当前状态,不恢复历史处理顺序。
- **写事务边界**:业务型终态与航班变更、待发事件、回填意图同处一个 PG 事务;其余终态与毒丸升级只写 `PROC_STATE`(终态与回填意图同一条 UPDATE)。完整边界见 [design.md](design.md) §3.3 表 1。
- 信箱处理标记写在该事务**之后**,由与终态同行的回填意图驱动重试与超期补写。
## 3. 处理阶段
**表 1 取报与处理**(收报、主泵、解码与身份):
| 阶段 | 输入 | 输出 | 幂等依据 | 失败处理 |
|---|---|---|---|---|
| 收报(发现 + 入队) | `ID > W` 的升序信箱行(谓词见 §5.1) | `PROC_STATE(PENDING)` 与推进后的 `(W, holeSince)` | `MSG_ID` 主键(冲突即已入队) | 下轮扫描补入队;整轮失败不动水位 |
| 取队头 | 最小未完成 `MSG_ID``PENDING``FAILED` 都占位) | 本轮唯一处理对象 | 表状态即队列 | 无队头则等待;`FAILED` 未到期则等待 |
| 解码与身份 | 信箱原文 | `DecodedMessage``IDENTITY_KEY` | 身份唯一约束(仅首次绑定) | 报文非法→`DEAD`;解码能力不足→`FAILED` 退避 |
| 事务处理 | 当前完整状态 + 报文载荷 | 航班变更、终态、事件、回填意图 | 消息 ID + 业务身份 | 业务型事务整体回滚重试 |
**表 2 回填**(终态之后的信箱标记):
| 阶段 | 输入 | 输出 | 幂等依据 | 失败处理 |
|---|---|---|---|---|
| 回填扫描(每 30 秒,**调度周期 ≠ 完成时限**) | 扫描谓词见 §5.2 | 追平标记,或判定放弃自动重试 | 消息 ID(仅空标生效) | 退避、超期强制补写与放弃口径见 §5.2 |
主泵**不做**回填:终态与回填意图由同一条 UPDATE 落库,回填一律由扫描驱动(跨库写不能占用 FIFO 关键路径)。
**处理链路流程**`├─` 括起的是同一个 PG 事务):
```text
① 取报(InboxPoller,默认每秒一轮)
读游标 (W, holeSince) → 取 ID > W 的升序前 N 行 → 求连续上界 → 空洞判定
├─ PG 事务:insertIfAbsent(MSG_ID, RECEIVED_AT) × k + 写回 (W, holeSince) → 提交
└─ 未入队的行留到下一轮(每轮最多解决一个空洞)
② 调度(Pump,单线程)
headUnfinished()(最小未完成 MSG_ID
├─ 队头为 FAILED 且已毒丸 → DEAD(EXHAUSTED)(单语句,不取锁)
├─ FAILED 未到期 → 等待
└─ 可执行 → 记 PROCESSING_STARTED_AT(仅首次)→ processOne()
③ 处理(MessageProcessor
读原文(共享 MySQL)→ 解码 → 身份绑定(独立单语句)
└─ PG 事务(持 PIPELINE_LOCK):航班变更 + 待发事件 + 终态 + 回填意图 → 提交
④ 回填(终态之后,跨库两次单写,无事务)
写信箱处理标记 → 记 BACKFILL_AT;失败则回填意图留在该行上
└─ 回填扫描(每 30 秒)按表 2 的谓词重试,越过 R 后强制补写
```
领域决策逻辑不执行 I/O;业务型/非业务型终态的写入边界与全部外部副作用(读信箱原文、写信箱标记)都在 PG 事务之外。跨库不共享事务。
## 4. 故障恢复
恢复的唯一依据是各存储中已持久化的记录,不依赖任何进程内存中的状态:
| 中断位置 | 重启后的判定 | 恢复动作 |
|---|---|---|
| 已落信、未入队 | 信箱行位于应扫描的 ID 范围且 PG 无记录(不以处理标记为判据) | 重扫补建入队记录 |
| 水位卡在空洞 | `INBOX_CURSOR.HOLE_SINCE` 有值且未超过 `max-commit-delay` | 等待;超期后放行空洞本身并继续推进(§5.1) |
| 事务执行中 | PG 无该消息终态 | 事务整体回滚,按 `PENDING` 重新处理 |
| 事务已提交、标记未写 | 终态行仍持有回填意图(`BACKFILL_NEXT_AT` 非空) | 仅补写标记;业务处理结果保持不变 |
| 标记写入中途 | 标记仍为空 | 重新写入;重复写入同一值无副作用 |
| 兼容入口已入队、水位未追平 | PG 已有该 ID 的记录 | 轮询读到该行时主键幂等,水位照常推进;顺序风险见 §5.1/§12 |
| 回填时信箱行已不存在 | 写入 0 行且信箱行不存在 | **立即放弃自动重试**(原因 `MISSING_ROW`)并告警;**放弃 ≠ 标记已确认**,仍需人工对账(§5.2) |
| `RECEIVED_AT` 为 NULL | 终态行无标记且无超期判据 | 仅由退避重试保证;【目标】以入队时间兜底(§5.2/§12) |
| 投递目标已接受、`SENT` 未置 | 事件仍 `PENDING` | 允许重发,消费方按事件身份去重 |
两个历史窗口(提交后回填前崩溃、回填意图二次落账失败)已随"终态与回填意图同体同行"消除——意图不存在第二处落账(口径与 design.md §10「事务与外部副作用」一致)。仍属未闭环的只有回填本身在跨库单写期间的持续失败:由退避重试与 §5.2 的超期期限 `R` 兜底,登记于 design.md §10 与 user-stories.md US-09"信箱行不存在"已有终态(§5.2 放弃语义)。
## 5. 消费水位、超期补写与历史积压
### 5.1 消费水位 W 与空洞
**定义**:水位 `W` 是信箱 ID 的连续上界——从最小 ID 到 `W` 的区间已全部读入自有 PG,无空洞;遇到空洞即停止推进。`W` 只随新 ID 的成功入队推进(永久空洞放行是唯一例外),不依赖处理完成或标记回写。
**收报流程**(每轮,谓词不以处理标记为条件):
1. 读取游标 `(W, holeSince)`;信箱不可读时记日志、等下一轮,**不动水位**——这属于基础设施失败,不能当成"没有新消息"。
2.`CMINMSGS_ID > W` 的升序前 `claim-batch` 行(`ORDER BY ID ASC LIMIT N`)。
3.`W+1` 起逐 1 数,求连续上界;遇到第一个缺号即停止计数。
4. 空洞判定(仅当本批存在"缺号之后的行"时才可能成立):
- 缺号首次被观测到 → `holeSince = now`
- `now holeSince < max-commit-delay` → 水位停在缺号前,**本批缺号之后的行一律不入队**。没有这条规则,晚提交的较小 ID 会排到它们后面,破坏 FIFO;
- `now holeSince ≥ max-commit-delay` → 判定为永久空洞,水位放行到"缺号后第一行 − 1"`holeSince` 清空。放行**只跳过空洞本身,不越过任何已存在的行**。
5. 在同一个 PG 事务内:对水位以内的每一行 `insertIfAbsent(MSG_ID, RECEIVED_AT)`,并写回 `(W, holeSince)`;主键冲突表示已入队(重复扫描与兼容入口并发都安全),不计入也不报错。
6. 提交。本批中因空洞或批次上限未入队的行留待下一轮——**每轮最多解决一个空洞**。
**空洞计时跨重启保留**`holeSince` 落在 `INBOX_CURSOR.HOLE_SINCE`,进程重启不丢失。旧空洞补齐后出现的新空洞,从新观测时刻重新计时,不继承旧等待时间。
**代价(必须接受并观测)**:水位遇空洞即停意味着**该空洞之后的所有消息最多要等 `max-commit-delay` 才能入队**。自增回滚等会在 ID 序列中留下永久空位,因此每出现一个永久空位就是一次等长的入队停摆;空位频繁时有效吞吐按比例下降。运行期必须观测永久空洞计数与水位滞后(§5.3 验收口径、OPS-2)。
**水位的有效性依赖 Q2 的两条上游承诺**
1. **ID 单调**:信箱 ID 按提交顺序分配,晚提交的较小 ID 不参与。承诺缺失时,`W` 只能作为快路径的提示,不能证明该区间收齐。
2. **ID 分配 → 事务可见时延上界**:从"库方为该报文分配 ID"到"该 ID 对其他事务可见"的最长时间。它同时决定空洞老化阈值与补偿扫描窗口宽度。
**这条时延必须由库方直接给出,不能由 SIS 的报文有效期推导。** SIS 的 `Expiry` = 480 分钟、断连 120480 分钟按 Level 2 处理(`SIS_AODB_RMS-V0.1.md` §2.4.2.3.3、§3.16、§3.17、§5.2)描述的是**报文保留与传输恢复**,与"共享 MySQL 里 ID 分配后多久对读事务可见"不是同一个量,二者之间没有推导关系。因此当前默认的 `max-commit-delay = 5 分钟`**缺少依据**,只是占位假定值,属于上线门槛([design.md](design.md) §7/§10)。报文有效期只能作为**原文保留期 / 重放窗口**(§6、§9)的参照,不能反过来给本阈值背书。
**两条扫描路径(发现机制)**
| 路径 | 目的 | 谓词 | 状态 |
|---|---|---|---|
| 快路径(日常) | 发现水位之后的新消息 | `ID > W ORDER BY ID ASC LIMIT claim-batch` | 【现状】已实现 |
| 迟到检测(只读,阶段 0) | 复查**被放行的空洞 ID** 是否后来真的出现在信箱 | 进程内监视队列 + `existingIds` 批量存在性检查(`late-detect-period`) | 【现状】已实现:只计数与告警,**不补入队** |
| 补偿扫描(阶段 1) | 发现"提交晚于水位推进"的迟到行并**安全补入队** | 按周期重扫 `W` 之前一个窗口(宽度由最大提交时延决定)内的 ID 区间 | 【缺口】未实现 |
【缺口】在补偿扫描交付之前,"较小 ID 迟提交"**没有补入队机制**:快路径只读 `ID > W`,一旦水位越过某个 ID,该 ID 之后到达的消息永远不会被发现。阶段 0 的**只读迟到检测**已实现——监视"被放行的空洞 ID"是否后来真的出现,命中即计数(`msgx.pipeline.late_arrival.detected.total`)并告警,把原先的**静默丢失**变成可观测事实;但它**不补入队**,因此当前设计仍**不能对外声明"迟到不越序"**[design.md](design.md) §8/§10)。检测是进程内尽力而为(重启丢失监视;监视队列有上界,被放行 ID 超量后只保留后段),且只覆盖"曾被放行过的空洞 ID"——这已足够:水位只会越过连续存在的行与被判定为空洞的 ID。
**水位与其余两条写路径的关系**
- **兼容 HTTP 入口**`POST /cminmsgs/send`):它直接写 `PROC_STATE`、不读不推水位,登记的行因此**超出水位**;主泵只领 `msgId ≤ W`,所以在水位追平前不会被处理——顺序不受影响,代价是延迟到追平,且**必须让收报轮询运行**(收报侧事实由 `InboxPollerTest` 固定,端到端由 `PipelineSmokeTest` 守住)。
- **回填**:不回写水位。水位不是处理标记,两者互不替代。
**单实例前提**:信箱读取不加锁,水位是单行覆盖写。本设计只在单活动实例下成立([architecture.md](architecture.md) §5、D2);多实例并发收报会让水位互相覆盖(覆盖回退只会造成重复扫描,不会丢消息,但空洞计时会失真),必须先有实例级排他。
**首次启动**:游标初值为 `(W=0, holeSince=NULL)`,第一轮会重读信箱中全部现存行;重复登记由主键幂等挡住,因此历史上"已入队但未回填"造成的漏读会一并补齐。
**切流播种(显式、一次性)**:若信箱已有存量(典型情况是最老分区已被清除、`MIN(ID)` 远大于 1),从 `W=0` 启动会先把 `ID=1` 判成空洞、白等一个老化窗口,同时也意味着要重新处理保留期内的全部存量。是否跳过存量属于**切流决策**,因此代码不做默认选择:只有显式配置 `msgx.pipeline.cutover-watermark` 才播种,取值 `min`(读现存全部)/ `zero`(从 0 按空洞规则)/ `max`(跳过可见存量)/ 具体 ID。升级实例(已有水位或已有处理记录)**拒绝重新播种**,重新切流必须是显式操作;播种事实与水位同语句落库(`INBOX_CURSOR.SEEDED_AT`),且该列为 NULL **不等于**"从未消费"(已有库新增列后同样为 NULL)。
### 5.2 超期标记补写
所有处理终态都只依赖消息 ID 执行回填;报文残缺或缺少 META 不妨碍正常回填。本节规定回填的四种结果、"长期失败"时的超期兜底,以及 `R` 对重放窗口的实际作用。
**处理规则**:已达 `PROC_STATE` 终态、且接收时间超期(信箱 `DATE_RECEIVED`,落库为 `PROC_STATE.RECEIVED_AT`;判据 `RECEIVED_AT < NOW R`)仍无标记的信箱行,由回填通道补写一个库方认可的"已处理"类标记。
**扫描谓词**(与实现一一对应):
```text
STATE ∈ {SUCCEEDED, SKIPPED, DEAD} -- 终态
AND BACKFILL_AT IS NULL -- 标记尚未确认
AND BACKFILL_ABANDONED_AT IS NULL -- 未放弃(放弃行可人工恢复)
AND ( BACKFILL_NEXT_AT IS NULL -- 异常兜底:终态行没有退避时间
OR BACKFILL_NEXT_AT ≤ NOW -- 退避到期
OR ( RECEIVED_AT IS NOT NULL -- 接收时间可用
AND RECEIVED_AT < NOW R ) ) -- 已达超期期限,覆盖退避(一旦成立恒成立)
ORDER BY BACKFILL_ATTEMPTS ASC, MSG_ID ASC -- 公平轮转,永久失败行不占满批次
LIMIT backfill-batch
```
**回填的可能结果**
| 结果 | 判定 | 处置 |
|---|---|---|
| 写入成功 | 标记为空、写入 1 行 | 记 `BACKFILL_AT`,不再重试 |
| 早已有标记 | 写入 0 行且信箱行存在 | **视为成功**,不覆盖已有值,记 `BACKFILL_AT` |
| 信箱行不存在 | 写入 0 行且信箱行不存在 | **立即放弃自动重试**(原因 `MISSING_ROW`)并告警。这是确定性结论,重试不会改变结果 |
| 暂时性故障达上限 | 超时/连接失败累计达到 `backfill-max-attempts` | **停止自动重试**(原因 `MAX_ATTEMPTS`)并告警;保留人工恢复能力 |
**放弃 ≠ 标记已确认**:放弃行不写 `BACKFILL_AT`,因此**不满足** §6"边界内全部行已打标"的清除前提,库方不得据此清除。放弃行可经人工恢复(清标记后重排一次回填)。
**约束**
- 期限 `R` 的语义是"回填长期失败时的强制补写上限"。**`R` 在任何清除语义下都不保护重放窗口**——因为回填不等 `R`:终态落库后由扫描尽快补写(§3 表 2),`R` 只在"回填持续失败"时把补写**提前**,从不推迟补写。
- 重放窗口的保护只能来自下面两条路之一,且 Q7/Q9 的确认是**阻塞性前提**,不是"调大 `R`"就能覆盖的参数问题:
- **约定保留期(目标前提)**:库方按"标记 + 保留期 `R_keep`"清除(打标本身不触发清除,§6),且 `R_keep ≥ 人工重放期限 + 人工处置期限`。此时 `R` 只需满足 `R ≤ R_keep`,与重放窗口无关。
- **另设原文保留机制**:若库方的清除语义是"一旦打标即可清除"(未确认),则**增大 `R` 无效**——当天进入终态、当天打标的消息会被当天清除,即使 `R = 30 天`。此时必须另行约定保留期,或引入独立的原文保留通道(例如由库方把原文归档到 `CMINMSGS_HST` 后供重放读取);该通道**尚未设计**(§12 G10)。
- 补写值仅限于库方认可的 legacy 值集(Q7);死信、业务重复等内部原因记录在 `PROC_STATE` 与审计日志,不在信箱新增枚举。
- `RECEIVED_AT` 为 NULL(上游未写 `DATE_RECEIVED`)时,超期分支不成立,`R` 兜底**不生效**;该行只能靠退避(封顶 15 分钟)反复重试。【目标】接收时间缺失时以入队时间兜底。
- **扫描周期 ≠ 完成时限**:30 秒只是**调度周期**。`JobRunner` 串行执行 `backfill.sweep` 与历史作业后才等待 30 秒,批次积压、单行调用超时与历史作业耗时长都会延长实际回填延迟;只有在"正常无积压"时实际延迟才近似等于扫描周期。因此任何"标记延迟 ≤ 30 秒"的表述都不成立,**回填完成目标是独立指标**(§5.3 验收口径、OPS-2),需要硬时限时必须另行补齐容量、作业隔离与故障条件。
### 5.3 历史积压消息的处理方案
"历史积压"指信箱中成规模的未处理存量:上线前遗留、停机期间累积、或消量未完成的批次。处理方案分三步,并约束四条行为红线。
**第一步:摸底。** 处理动作开始前,先确定积压的范围与构成:ID 区间、条数、时间跨度、报文类型分布(`SCHD-DNLD/RESP/ADFT``FLOP-*``FDEL` 等,逐类清单依据见 Q8),并与库方确认其中哪些仍需业务处理、哪些按约定跳过(跳过基调只在该批确认时成立,授权与留痕见 Q12,标记值集见 Q7)。
**第二步:入队与处理的顺序关系。** 顺序由 `MSG_ID` 决定,**不由"先入队后处理"这种执行方式决定**:
1. 入队:`InboxPoller` 按升序有限批次将历史行全部建为 `PROC_STATE(PENDING)`,水位随之推到积压末端(连续推进以 §5.1 的 Q2 承诺为前提)。此阶段只写自有 PG,不触碰信箱标记。
2. 处理:主泵按最小未完成 ID 顺序消化。顺序、阈限与日常完全相同:不加速、不分流、不走旁路。积压期间到达的实时消息 ID 更大,自然排在积压之后。
**入队与处理可以并发**:两者由不同线程驱动,FIFO 由"队头取最小未完成 `MSG_ID`"保证。因此"先入队后处理"是**可选的运维规程**、**不是正确性前提**,系统也不提供"只入队"模式;入队阶段的中断恢复只涉及重扫(§4 第一行)。
**第三步:等待过程中的四个边界。**
- 不插队:主泵按 FIFO 消化,积压期间到达的实时消息排在积压之后。本设计不允许并行队头,也不允许实时通道跳过积压。
- 不失控:队头滞留上限(`msgx.pipeline.head-deadline`,当前 10 分钟)对积压同样生效;队头长期失败按既有规则转 `DEAD(EXHAUSTED)` 并告警,不会因积压而延长容忍(判据口径见 design.md §3.2);长期积压与人工重放的期限口径仍由 Q6 定案。
- 报文类型的处理方式不变:旧的全量日计划(`SCHD-DNLD`)按最新一份**合并**即可收敛(缺席不删、缺失字段保留;字段级冲突见 flight-state.md §3.1 与 Q13),但仍逐条执行;动态增量(`FLOP-*` / `FDEL` / `ADFT`)持有时序语义,必须逐条。
- 可放弃但必须留痕:摸底批中经库方确认"不再处理"的行,处置方式为——`PROC_STATE``SKIPPED` 并记录跳过原因,到达终态后走 §5.2 通道补写标记;本方案不支持任何"整段 DELETE"的快速通道。
**验收口径**:积压消化期间持续输出三项指标——剩余积压条数(`unfinished`)、最老未处理信龄(`oldestUnprocessedSeconds`)、**预计消化时长**(定义:`剩余积压条数 ÷ 近 N 分钟实际处理速率`,速率样本窗口 N 需在实现时固定;若实现不提供该估算,本条降级为"由运维按前两项指标自行推算")。另需观测水位滞后与永久空洞计数(§5.1)。期间不允许出现 FIFO 越序、身份去重失效或头行滞留超时未告警。红线依据:architecture.md §5(消息严格 FIFO)、单写者约束。以上指标并入 user-stories.md OPS-2 的可观测性验收。
## 6. 信箱数据清除
表结构变更与物理清除由库方执行或书面授权执行;本系统对共享 MySQL 不建表、不改结构(红线见 architecture.md §6)。本系统不执行 DDL、不写共享历史表:`CMINMSGS_HST` 的历史归档写入由库方执行,属 Q9 授权范围。本系统在清除事务中的义务只有一项:为处理完成的行及时写入处理标记,使可清除范围存在明确边界。
具体方案由库方选择(Q9)。以下两种方案均基于 MySQL 自身能力:支持 `RANGE` 分区与 `TRUNCATE / DROP PARTITION`,不支持 `EXCHANGE PARTITION`(能力与现场版本待库方书面确认,Q9)。
**方案 A:按日分区(首选)**,适用于库方可以为表增加分区的场合。
1. `CMINMSGS``DATE_RECEIVED` 建立日粒度 RANGE 分区;
2. 某分区到达保留期时,确认该分区全部行已持有处理标记(§5.2 只在回填可持续成功时保证该条件;放弃行与"打标即清除"语义都会破坏该前提,见 §12 G10);
3. 将该分区复制入历史表:`INSERT INTO CMINMSGS_HST SELECT``NOT EXISTS` 判重);
4. `TRUNCATE / DROP PARTITION` 执行清除:DDL 级操作,无行锁竞争,页外大字段(`CMINMSGS_CLOB_MSG` 列)整块释放;碎片与 binlog 影响待现场验证。
前提:库方确认现场 MySQL 版本支持分区 DDL,并已授权执行。
**方案 B:整表轮换**,适用于库方拒绝增加分区的场合。
1. `CREATE TABLE CMINMSGS_NEW LIKE CMINMSGS`,并将 `AUTO_INCREMENT` 种子设为 `max(ID) + 1`
2. 将保留窗内行(`DATE_RECEIVED ≥ NOW R_keep`)复制至新表;
3. 以单语句原子 `RENAME TABLE` 完成换名;
4. 对账:换名与复制之间新写入及新标记的行,从旧表补回;只补新表缺失的行,已存在行按标记单调取并集,不回退、不覆盖(§11);
5. 旧表中早于保留窗的部分追加写入 `CMINMSGS_HST``NOT EXISTS` 判重);
6. `DROP TABLE` 旧表:秒级完成,碎片与大字段一并释放。
两种中断均安全:换名前失败则废弃新表重新执行;换名后失败则旧表仍完整,对账与归档语句均可重跑。
方案共同前提:
- **方案要求库方按"标记 + 保留期"清除**(打标本身不触发清除):处理标记只是可清除的**必要条件**,触发条件是"到达保留期 `R_keep`" ∧ "边界内全部行已持有处理标记"。该语义属 Q7/Q9 确认范围;若确认结果是"打标即可清除",则**本节前提不成立,且增大 `R` 无法补救**(原因唯一见 §5.2),必须另行约定保留期或引入独立原文保留通道(§12 G10)。
- 执行清除时,边界内不存在未打标记的行;未达终态的行顺延至处理完成后清除(§5.2 仅对终态行补标)。
- 时间比较与换算统一采用机场时区 Asia/Shanghai 及明确的类型转换(口径同 user-stories.md §6)。
- 保留期 `R_keep` 的下界(**本节是唯一表述处**):`R_keep ≥ max(人工重放期限 + 人工处置期限, 审计期限, 回填重试上限)`——**仅在"标记 + 保留期"清除语义下**,这是"重放窗口内原文仍在"的**唯一保证来源**。报文在 CIIMS 的 `Expiry`480 分钟量级,`SIS_AODB_RMS-V0.1.md` §3.16)可作为原文保留期的参照,但它是报文有效期,不等于本处所需的保留期,也不能用于推导 §5.1 的可见性时延。
- 超期补写期限 `R` 的约束(仅 `R ≤ R_keep`**唯一见 §5.2**`R` 不参与重放窗口保护,本节不重复。
- legacy 现役按接收时间超过 1 天即归档并删除 `CMINMSGS`(legacy 行为基线 §3.6);若沿用该窗口,则与上一行的保留期下限冲突,须在 Q9 中与库方一并确认;
- 信箱 ID 全程不断链:方案 A 天然满足;方案 B 依赖 `AUTO_INCREMENT` 种子,种子缺失时新 ID 与旧记录主键冲突,水位随之失效。
## 7. 出站信箱(COUTMSGS
本系统一侧的规则如下(出站适配与请求协调**尚未实现**;请求生命周期的机制目标见 design.md §4.2,本节不重复):
出站前在 `REQ_TRACK` 登记 `REGISTERED`;写入 `COUTMSGS` 并确认落信后置 `SENT` 并关联出站记录 ID。"落信"指写入信箱成功,不等于下游已读取或已发送,交付承诺仅到落信为止(US-08)。
主/共享删除顺序违例时 SIS 要求向 AODB 回发 EROR`SIS_AODB_RMS-V0.1.md` §1.6.1-1.d,事件定义见 SIS §4.8);当前没有该出站路径,取舍见 Q14。
本系统写入的行由谁消费、按什么顺序消费,**当前未知**。以下事项在 Q10 关闭前按未知处理,不得作为已具备能力描述:
- 消费方与消费顺序;
- `COUTMSGS_ACK_DATE_RECV``COUTMSGS_ACK_RESEND_TIMES``COUTMSGS_DATE_SENT``COUTMSGS_ERROR` 各列的语义与写入责任;
- 出站行的清除责任与保留期;
- 落信成功但本地未置 `SENT` 时的重复写入风险及下游去重契约。
参考事实:legacy 的 SIS 接口规范记载各系统持有各自的 `COUTMSGS` 副本,由 JDBC Adapter 读取后发送至 CIIMS;同时记载从 RMS 发出的报文不执行 CIIMS 确认(`SIS_AODB_RMS-V0.1.md` §2.4.2.2.2、§2.4.2.2.4)。这两点是否在现场沿用、与 `COUTMSGS_ACK_*` 列的关系如何,均属 Q10 的确认范围,不作为现状依据。
## 8. 自有记录归档
- `PROC_STATE`:终态记录归档至 `PROC_STATE_HST`(尚未建表)。未达终态的记录不归档;归档不得使"同一业务身份只能绑定一条有效处理记录"的去重能力失效(US-11)。
- `SCHD_SNAP_LOG`:保留 90 天,清理窗口与判据见 design.md §6.2。
- 航班当前态的清理规则唯一归属 [flight-state.md](flight-state.md) §6;其判据不依赖信箱原文。信箱原文的可用性仅影响重放能力(§9)。
## 9. 重放与原文可用性
- 可重放的错误类别为 `CODEC_ERROR / UNSUPPORTED / INFRA / EXHAUSTED` 四类(以 design.md §6.1 为准);重放处理当前状态,不恢复历史顺序。
- 重放的前提是信箱原文仍可读取。该前提仅在"标记 + 保留期"清除语义下可由配置满足(`R_keep` 覆盖重放与处置期限,§6);"打标即清除"语义下的处置唯一见 §5.2 与 §12 G10。
- 确认信箱行或原文已缺失时,按 `MALFORMED → DEAD` 处理;共享库超时、连接失败等暂时性读取异常按 `INFRA` 退避重试,不得伪装成原文缺失。若原文缺失源于库方违反保留契约提前清除,按契约违例走运维追责通道(契约条款见 Q7/Q9);该消息的死信处置本身不变。
- **回填阶段的"信箱行缺失"是另一回事**(§5.2):它表示"本系统已经处理完、却找不到要标记的那一行"。此时不改变处理终态,也不重新处理业务;需要区分"该 ID 本就属于被判定的永久空洞"与"行被提前清除"。处置按 §5.2:确认 `MISSING_ROW` 即放弃自动重试并告警,人工对账后才能重排回填。
## 10. 开放问题索引
| 编号 | 待确认事项 | 当前假定值 | 阻塞谁 | 风险 | 本文相关章节 |
|---|---|---|---|---|---|
| Q2 | 信箱 ID 单调承诺;**ID 分配 → 事务可见时延上界**;空洞与迟到处理 | 时延按 5 分钟(`max-commit-delay`,**缺少依据的占位值**;不可由 SIS 报文 `Expiry` 推导) | 快路径的发现完整性声明、空洞老化阈值、补偿扫描窗口 | 迟到小 ID 永久不被发现;空洞被误判为永久 | §5.1、§6 |
| Q6 | 重放 deadline 与人工处置期限的取值(唯一作用是决定 `R_keep` 下界) | R = 30 天;重放/处置期限未定 | `R_keep` 的最终取值 | `R_keep` 不足导致重放窗口内原文被清除;**若清除语义为"打标即清除",则任何取值都无效**(§5.2) | §5.2、§6、§9 |
| Q7 | 处理标记值集与写权限;原文保留期;处理时间语义 | 写入 `PROCESSED` | 回填值集、原文保留期下限 | 值集不被库方接受;保留期短于重放窗口 | §5.2、§6 |
| Q8 | 逐类覆盖清单(积压摸底的类型分布依据) | — | 积压摸底与逐类矩阵 | 类型漏项导致积压处置误判 | §5.3 |
| Q9 | 清除执行方与 DDL 授权;方案 A / B 选型 | 首选方案 A | `R_keep` 与清除边界 | 方案不可执行;ID 断链 | §6 |
| Q10 | 出站消费方、ACK 列语义、出站清理与去重契约 | — | 出站信箱 | 重复写入、无法清理 | §7 |
| Q11 | 上游 `SEQN` 重置周期与业务身份的日期边界 | 不含日期边界 | 身份算法 | 跨周期误判重复 | design.md §2.2 |
| Q12 | 历史积压批次“不再处理”的确认主体、审批留痕与跳过值集 | — | 积压跳过处置 | 无授权跳过或删除 | §5.3 |
| Q13 | 日计划缺失可选字段的删除语义(与 SIS §3.16 注释 4 冲突) | 保留未携带字段 | 快照合并 | 与 SIS 语义冲突 | §5.3、flight-state.md §3.1 |
| Q14 | 主/共享删除顺序与 EROR 回报义务 | 自动幂等级联 | 出站事件类型 | 上游状态不一致 | §7、flight-state.md §3.3 |
Q2、Q6Q14 的完整登记见 [user-stories.md](user-stories.md) §6 的 Q 表。
## 11. 不变量
- 五个事实互不替代:入队不引用信箱标记,回填不引用投递,投递不引用回填。
- 发现与处理互不阻塞:收报只看 `ID > W`,终态而未回填的行不阻断后续消息的发现;处理状态不改变扫描谓词。
- 队头唯一:任一时刻只有一个可执行队头,`FAILED` 未退避到期时后续消息不得越过。
- 只领取已发现的行:主泵只领 `MSG_ID ≤ W`;水位之外的行只可能来自兼容入口,必须等水位追平后按序处理(否则会越过尚未入队的较小 ID)。
- 处理终态不可逆:已提交的 `SUCCEEDED` 不因回填或投递失败回改。
- 处理标记单调:任何路径只将空标写为已处理,不回撤、不覆盖。
- 回填只针对终态:`PENDING / FAILED` 永不写信箱标记。
- 一信一行:每条信箱行在 `PROC_STATE` 至多一条记录(`MSG_ID` 主键);同一业务身份至多绑定一条有效处理记录。
- 水位不越过任何已存在的行;遇空洞即停,只有 §5.1 判定为永久空洞时才放行。
- 写入水位与入队同事务:不允许出现"水位已推进、消息未入队"的持久化状态。
- 执行清除前,边界内全部行已持有处理标记;物理删除仅发生在归档成功之后(追加写入与分区留档均构成归档成功)。
- 信箱 ID 全局单调、不断链:以 Q2 的 ID 单调承诺与 §6 各方案前提成立为条件(方案 B 依赖 `AUTO_INCREMENT` 种子)。
- 对外投递按至少一次设计;端到端恰好一次不在交付范围内。
## 12. 与当前实现的差异
本节是本文范围内的缺口清单,与 [design.md](design.md) §10 对应。正文描述目标设计;下列条目尚未交付,不得作为已具备能力引用。
| 编号 | 缺口 | 影响 | 相关章节 |
|---|---|---|---|
| G1 | **窗口补偿扫描未实现**(阶段 0 只读检测已交付) | 迟提交的小 ID 无补入队机制,不能声明"迟到不越序";检测覆盖与口径唯一见 §5.1 | §5.1、§4 |
| G4 | **`RECEIVED_AT` 为 NULL 时 R 兜底失效** | 该行只能靠退避重试;V2 只对存量行用 `UPDATED_AT` 兜底,新行没有 | §5.2、§4 |
| G5 | **毒丸升级不在 `MessageLifecycleGate` 内** | 与人工重放并发时,存在"先标 DEAD 并写标记、再被重放拨回 `PENDING`"的窗口。**注意**:回填与重放**共用同一个 gate 单例**这一点已由装配断言守住(`PipelineSmokeTest`),但毒丸路径仍在 gate 之外 | §2、§5.3design.md §3.2/§6.1 |
| G6 | **"预计消化时长"指标无实现定义** | §5.3 验收口径的第三项指标尚无算法与实现(已降级为可推算项) | §5.3 |
| G7 | **`max-commit-delay` 默认值缺少依据** | 库方尚未给出"ID 分配 → 事务可见"的时延上界;5 分钟只是占位假定值,**不能由 SIS 报文 `Expiry` 推导**(§5.1)。取值无依据时可能大量误判永久空洞 | §5.1、§6 |
| G8 | **Q 编号条款尚未与库方书面确认** | 当前取值均为假定,见 §10 | §10 |
| G10 | **"打标即清除"语义下没有原文保留通道** | 若库方清除由打标触发,则**回填成功后当天打标**会让原文当天即可被清除,增大 `R` 无效 → 重放窗口失去保护。需另行约定保留期,或引入独立原文保留通道(如库方归档 `CMINMSGS_HST` 供重放读取);该通道尚未设计 | §5.2、§6、§9 |
已关闭(现行行为并入正文,不再列为缺口):G2 兼容入口与水位领取(§5.1)、G3 回填缺失行放弃语义(§5.2)、G9 回填扫描公平轮转(§5.2 谓词)。