Files
msgexchange-v2/docs/design.md
T
windyboy fed0b5df26 docs: 同步取报-处理-回填链路的修复结论
- 事务边界表:区分业务型/非业务型终态;回填改为扫描驱动(主泵不再 inline 回填)
- 符号与配置:补 delivery-batch / backfill-max-attempts / cutover-watermark /
  late-detect-* / backlog-cache-ttl-ms / 邮箱超时与指标口径
- 闭环状态:G2/G3/G9 标记已修;G1 标注「阶段 0 只读迟到检测已交付」
- 修正 R/R_keep 论证:R 不保护重放窗口;原文保留归 R_keep 与 Q7/Q9,
  并删除「由 SIS Expiry 推导提交时延」的无效推论
- 回填语义补全:四种结果、放弃 ≠ 标记已确认、公平轮转与饥饿说明
- 兼容入口与水位:登记行超出水位、水位追平前不被领取,并写明运维含义

Plane: ACM2-35 ACM2-37 ACM2-38 ACM2-39 ACM2-41
2026-09-11 08:08:39 +08:00

319 lines
38 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 设计文档
## 1. 阅读说明
本文说明模块如何协作、状态如何流转,以及失败后如何恢复。系统范围、存储归属和部署约束见 [architecture.md](architecture.md),不在这里重复。
运营航班权威状态只落自有 PostgreSQL(`FLIGHT_SCHD` 及资源明细表),Redis 已彻底退出动态权威与全部写路径;ES 历史投影属暂缓范围,不参与当前设计。
本文描述处理机制与流程;尚未交付的能力在本文件中明确标注,并以 §10 差异为准。航班状态规则统一由
[运营航班状态设计](flight-state.md) 维护。
**文档标记约定**(全文沿用,[message-lifecycle.md](message-lifecycle.md) 同):
- 【目标】= 设计要求,是否已交付以「实现差异」节为准;
- 【现状】= 已按设计实现的行为;
- 【缺口】= 尚未实现,条目登记在本文 §10 与 [message-lifecycle.md](message-lifecycle.md) §12
- 【待确认】= 依赖开放问题(Q 编号),当前取值只是假定。
正文只描述目标设计。需要点明交付状态时,用上述标记写一句,不展开解释——缺口的唯一清单是 §10 与 [message-lifecycle.md](message-lifecycle.md) §12。
### 1.1 符号与术语
| 符号 / 术语 | 语义 | 存储与字段 | 配置键 | 当前取值 | 约束与唯一定义处 |
|---|---|---|---|---|---|
| `W`(水位) | 信箱 ID 的连续上界:`(min, W]` 已全部读入自有 PG | `INBOX_CURSOR.COMMITTED_UP_TO` | — | 初值 0 | 只随新 ID 成功入队推进(永久空洞放行是唯一例外);[message-lifecycle.md](message-lifecycle.md) §5.1 |
| `holeSince` | `W+1` 处空洞最早被观测到的时刻;无空洞时为 NULL | `INBOX_CURSOR.HOLE_SINCE` | — | NULL | 跨重启保留;旧空洞补齐后新空洞重新计时;[message-lifecycle.md](message-lifecycle.md) §5.1 |
| `R`(超期补写期限) | 回填长期失败时的强制补写上限(按接收时间计) | 判据用 `PROC_STATE.RECEIVED_AT` | `msgx.pipeline.overdue-backfill` | 30 天 | 仅 `R ≤ R_keep`;**不保护重放窗口**(回填由扫描驱动、不等 `R`,打标时刻与 `R` 解耦);[message-lifecycle.md](message-lifecycle.md) §5.2 |
| `R_keep`(清除保留期) | 信箱行可被物理清除前的最短保留时间 | 库方侧 | 库方策略 | 待定(Q9) | `R_keep ≥ max(人工重放期限 + 人工处置期限, 审计期限, 回填重试上限)`**仅在"标记 + 保留期"清除语义下成立**;[message-lifecycle.md](message-lifecycle.md) §6 |
| `head-deadline` | 队头滞留上限,超过即毒丸升级 | 计时起点 `PROC_STATE.PROCESSING_STARTED_AT` | `msgx.pipeline.head-deadline` | 10 分钟 | 为空时以 `UPDATED_AT` 兜底;§3.2 |
| 队头 | 最小的未完成消息(`PENDING``FAILED` 都占位) | `PROC_STATE``ORDER BY MSG_ID`) | — | — | 单线程串行处理,后续消息不得越过;§3.2 |
| 终态 | `SUCCEEDED` / `SKIPPED` / `DEAD` | `PROC_STATE.STATE` | — | — | 到达后队列方可推进;§2.3 |
| 回填意图 | 「还欠一次信箱标记」的持久化事实 | `PROC_STATE.BACKFILL_NEXT_AT` 非空 | — | 随终态写入 | 与终态同一条记录、同一条语句;§3.3 |
| 处理标记 | 信箱行上表示「本系统已处理」的约定字段 | 信箱 `DATE_PROCESSED` / `STATUS` | `mailbox.processed-value` | `PROCESSED` | 只写空标记,不回撤、不覆盖;[message-lifecycle.md](message-lifecycle.md) §5.2 |
## 2. 数据与领域模型
### 2.1 持久化记录
所有内部表都属于自有 PostgreSQL;共享 MySQL 只保留约定的信箱读写边界。
| 记录 | 用途 | 关键约束 |
|---|---|---|
| `PROC_STATE` | 入站消息的处理状态、身份、重试次数、错误原因与回填事实 | `MSG_ID = CMINMSGS_ID` 主键防止重复入队;`IDENTITY_KEY` 唯一约束防止业务重复;按最小未完成消息 ID 取队头;`PROCESSING_STARTED_AT` 是 HOL deadline 的稳定起点(为空时以 `UPDATED_AT` 兜底,见 §3.2);`BACKFILL_NEXT_AT` 非空 = 还欠一次回填(回填意图),`BACKFILL_AT` 非空 = 标记已确认,`BACKFILL_ABANDONED_AT/REASON` 非空 = 已停止自动重试(**不等于**标记已确认);`RECEIVED_AT` 复制自信箱接收时间,**可能为 NULL**,为 NULL 时 [message-lifecycle.md](message-lifecycle.md) §5.2 的超期兜底不生效。 |
| `MSG_EVENT` | 等待投递的事件(outbox) | `EVENT_ID` 决定投递顺序;`TARGET` 区分 `KAFKA:msg` / `KAFKA:schd``PARTITION_KEY` 恒为 `FLID``EVENT_TYPE` 区分 UPSERT 与 TOMBSTONE。 |
| `REQ_TRACK` | 上游请求及应答关联 | 保存请求类型、覆盖运营日、发送方、出站信箱 ID 与发送/完成时间;同类只允许一个开放请求。登记、超时与应答匹配尚未实现(§10)。 |
| `REF_MASTER` | 静态参考数据(目标表) | `(RTYPE, RKEY)` 唯一;尚未建表,客户端与刷新流程见 user-stories.md US-13/US-14US-14 两类映射的存储落点未定)。 |
| `FLIGHT_SCHD` | 航班标量及单值异常字段 | `FLID` 主键;`OPERATION_DAY` 一经确定不可变;版本与最近消息 ID 用于追踪。变长资源集合存于 8 张资源明细表与 `FLIGHT_ROUTE_POINT`,规则见 [flight-state.md](flight-state.md) §2,不在此重复。 |
| `INBOX_CURSOR` | 共享信箱消费水位 `W` 与空洞计时 `holeSince` | 单行游标;`W` 只随新 ID 成功入队推进(永久空洞放行是唯一例外),遇空洞即停;`HOLE_SINCE` 持久化空洞观测时刻,**进程重启不丢失计时**;[message-lifecycle.md](message-lifecycle.md) §5.1。 |
| `PROC_STATE_HST` | 终态处理记录的归档目标 | 尚未建表;不得改写为共享库历史表。 |
字段与索引定义以 `src/main/resources/db/migration/V1__flight_state_baseline.sql` 为准;Oracle 11g 的迁移形态见 `src/main/resources/db/migration/oracle11g/`(占位,未接入任何 Flyway 配置)。报文原文仍从共享信箱读取,因此必须协调原文保留期,不能在消息尚需处理或重放时提前清理;生命周期、回填与清除契约的唯一定义见 [message-lifecycle.md](message-lifecycle.md)。
### 2.2 消息、身份与决策
`XmlCodec`(实装 `JacksonXmlCodec`)将 XML 解码为 `DecodedMessage`,包含 `SNDR / TYPE / STYP / SEQN / DTTM` 元数据、`MsgKind` 与业务载荷。解码失败区分 `MALFORMED`(报文非法,不重试)与可随 codec 修复的编码错误。`MsgKind` 为一等分派键:`Schd(RESP/DNLD/ADFT)``Flop``Fdel``Unsupported`
业务身份统一由 `Identity.of` 生成:
```text
SNDR | TYPE | STYP | SEQN
```
接收时只按信箱 ID 去重;解码后才首次绑定业务身份。重试保留原有绑定,不能把自己判为重复消息。身份被另一条记录占用时,当前消息转为 `SKIPPED`,记录 `duplicate-of:<id>`。是否加入日期边界取决于上游序号重置周期,默认关闭(`SEQN` 的取值范围与回绕已由 `SIS_AODB_RMS-V0.1.md` §2.8.1 定义,重置周期见 Q11);上线后不能随意更换身份算法。身份绑定是**独立的幂等单语句**(`WHERE IDENTITY_KEY IS NULL`),不参与业务事务,见 §3.3 表 1。
分派与落库由 `MessageProcessor` 统一协调:按 `MsgKind` 把已绑定身份的队头消息交给对应事务协调器(DNLD/RESP → `ScheduleProcessor`ADFT → `AdftProcessor`FLOP → `FlopProcessor`FDEL → `FdelProcessor`,其余 → `FAILED(UNSUPPORTED)`)。这些 Processor 在 `PIPELINE_LOCK` 事务内读取当前完整态,调用纯领域决策逻辑得到下一完整态与待发事件,再统一落库并登记回填意图;它们不直接触碰 Kafka。处理终态与业务变更在同一事务边界提交,**该结论只对处理器产出的「业务型终态」成立**;`MALFORMED / PROTOCOL / SKIPPED(重复) / EXHAUSTED` 这类非业务型终态不涉及航班表,只需一条 `PROC_STATE` UPDATE(终态与回填意图同语句写入),不取 `PIPELINE_LOCK`。完整的事务边界见 §3.3 表 1。
### 2.3 状态与错误分类
```text
处理:PENDING / FAILED → SUCCEEDED(成功)
→ SKIPPED(业务重复已实现;忽略/无匹配分支见 US-04/US-06,尚未实现)
→ FAILED(等待退避重试)
→ DEADMALFORMED / PROTOCOL / EXHAUSTED,均需人工处置)
投递:PENDING → SENT
→ PENDING(退避后重试)
→ DEAD(重试耗尽,保留记录作 DLQ)
```
`SUCCEEDED / SKIPPED / DEAD` 是处理终态,不再阻塞后续消息;`FAILED` 不是终态,仍占据队头。`DEAD` 表示需要处置,不等于业务成功。
| 错误类别 | 处理方式 |
|---|---|
| `MALFORMED` | 报文非法或原文缺失,直接 `DEAD`,不在重放白名单内。 |
| `PROTOCOL` | 整包协议拒绝(运营日冲突、声明数量不符、缺载荷等),立即 `DEAD`,整包不落地、不重试。 |
| `CODEC_ERROR` | 解码能力问题,退避重试;修复后允许重放。 |
| `UNSUPPORTED` | 处理器或快照能力未实现,按可恢复失败处理,不直接当作非法报文;仍受重试上限约束。 |
| `INFRA` | 基础设施或执行异常,退避重试。 |
| `EXHAUSTED` | 重试耗尽或滞留超时,转 `DEAD`,人工复核后允许重放。 |
重试次数用尽时统一转 `DEAD(EXHAUSTED)``ERROR_CLASS` 被覆写为 `EXHAUSTED`**原始错误类别不再保留**`LAST_ERROR` 保留原因文本)。由于重放白名单包含 `EXHAUSTED`,这类记录仍可人工重放(§6.1)。
## 3. 收报与主泵
### 3.1 收报
`InboxPoller` 默认每秒按 ID 升序、有限批次(`claim-batch`,默认 50)读取水位之后的信箱记录(`ID > W`,**不以处理标记为谓词**),在自有 PG 建立 `PENDING` 并把水位推进到连续上界;入队与水位推进在同一 PG 事务内提交,重复扫描幂等、中断后重扫补建。收报层不解析业务载荷,也不回填已处理标记。
**收报流程**(每轮 `pollOnce`;完整判据、代价与前提见 [message-lifecycle.md](message-lifecycle.md) §5.1,本节不重复):
1. 读游标 `(W, holeSince)`;信箱不可读时记日志、等下一轮,**不动水位**——这属于基础设施失败,不能当成「没有新消息」。
2.`ID > W` 的升序前 `claim-batch` 行。
3.`W+1` 起逐 1 数求连续上界;出现缺号时按「遇空洞即停 / 超期放行」处理,本批缺号之后的行本轮一律不入队(判据见 §5.1)。
4. 在同一个 PG 事务内:对水位以内的每一行 `insertIfAbsent(MSG_ID, RECEIVED_AT)`,并写回 `(W, holeSince)`;主键冲突表示已入队,不计入也不报错。
5. 提交。本批中因空洞或批次上限未入队的行留待下一轮——**每轮最多解决一个空洞**。
水位不是已处理标记,其有效性以 Q2 的 ID 单调承诺为前提;空洞老化阈值取 `msgx.pipeline.max-commit-delay`
**单实例前提**:信箱读取不加锁,水位是单行覆盖写。本设计只在单活动实例下成立([architecture.md](architecture.md) §5、D2);多实例并发收报会让水位互相覆盖,必须先有实例级排他。
兼容 HTTP 入口执行“写入共享信箱 → PG 入队”。两步不在同一事务中:信箱成功而 PG 失败时,原文不能丢失,由轮询补建;客户端失败重试可能再次写信箱,业务身份去重仍然必需。
兼容入口只写 `PROC_STATE`、**不参与水位**,因此它登记的行会**超出水位**;主泵在水位追平前不领取(§3.2 步骤 2),顺序因此不受影响——代价是这类消息要等收报把 `W` 推到它的 ID 之后才开始处理(最长约一个空洞老化窗口)。**运维含义**:只使用兼容入口而不运行收报轮询时,这些行不会被处理,必须让 `InboxPoller` 运行(`msgx.pipeline.autostart=true` 或显式触发)。详见 [message-lifecycle.md](message-lifecycle.md) §5.1/§12。
### 3.2 主泵调度
每次 `Pump.tick` 只围绕最小未完成消息(`PENDING``FAILED` 都占队头):
1. 无队头:按轮询间隔休眠。
2. 队头超出水位(`msgId > W`):**不领取**,休眠到下一轮。这类行只可能来自兼容入口的直接登记;允许领取会让它越过尚未入队的较小 ID(G2)。会打印一条**限流 WARN**(仅在水位值变化时打一次)。
3. 队头为 `FAILED` 且已毒丸(`attempts ≥ max-attempts`,或 `now 计时起点 ≥ head-deadline`):转 `DEAD(EXHAUSTED)`,并立即尝试一次回填。**该分支只写 `PROC_STATE`,不取 `PIPELINE_LOCK`、不在处理器事务内,也不在 `MessageLifecycleGate` 内**(见 §3.3 表 1 与 §6.1)。
4. 队头为 `FAILED` 且未到 `next_attempt_at`:休眠到可重试时刻,不处理后续消息。
5. 其余(新消息或退避到期的重试):记录 `PROCESSING_STARTED_AT`(仅首次)后调用 `MessageProcessor.processOne`,失败迁移在该边界内完成。
维护作业由独立 job 线程调度(§6.1),不占用消息循环;作业有界且不使到期消息无限饥饿。**所有取时统一经注入 `Clock`**(收报空洞老化、主泵调度、处理器落库时间、回填重试、作业切日),不使用系统时钟;HOL deadline 以首次处理时写入的 `PROCESSING_STARTED_AT` 为稳定起点,**该列为空时(V3 迁移之前的存量行)以 `UPDATED_AT` 兜底**;人工重放会清空 `PROCESSING_STARTED_AT`,由新一轮首次处理重新记录。
### 3.3 单条处理
```text
processOne(head)
1. 入口守卫:head 已是 FAILED 且 attempts 达上限 → DEAD(EXHAUSTED),结束
2. 读原文:缺失 → DEAD(MALFORMED, raw-missing);读取异常 → FAILED(INFRA)
3. 解码:
报文非法(MALFORMED) → DEAD(MALFORMED),不重试
可修复解码错(CODEC_ERROR) → FAILED(CODEC_ERROR) 退避
4. 身份绑定(仅当 IDENTITY_KEY 为空):
已被本消息占用 → 继续
已被别的消息占用 → SKIPPED(duplicate-of:<id>),结束
空闲 → 写入 IDENTITY_KEY(独立单语句,不参与业务事务)
5. 按 MsgKind 分派:
SCHD-DNLD / SCHD-RESP → ScheduleProcessor(快照事务)
SCHD-ADFT → AdftProcessor(单航班事务)
FLOP / FDEL → Flop / FdelProcessor(单航班事务)
Unsupported → FAILED(UNSUPPORTED) 退避
载荷缺失 → DEAD(MALFORMED)
整包协议拒绝 → DEAD(PROTOCOL),不落半包
6. 业务型成功:处理器在自己的事务内写航班变更 + 待发事件 + SUCCEEDED + 回填意图
7. 返回终态标记:只有终态才调用 backfill.attempt(msgId) 试写一次信箱标记
```
**表 1 事务边界**(哪些动作在一个事务里、哪些不是):
| 动作 | 显式事务 | 持 `PIPELINE_LOCK` | 触及航班表/事件 | 原子性来源 |
|---|---|---|---|---|
| 收报入队(`insertIfAbsent` + `cursor.save`) | 是 | 否 | 否 | 同库事务 |
| 身份首次绑定 | 否 | 否 | 否 | 单语句 + `uk_proc_identity` |
| 业务型终态(处理器产出 `SUCCEEDED`) | 是 | 是 | 是 | 同库事务:航班变更 + 事件 + 终态 + 回填意图 |
| 非业务型终态(`MALFORMED` / `PROTOCOL` / `SKIPPED` / `EXHAUSTED`) | 否 | 否 | 否 | 单语句(终态与回填意图同一条 UPDATE) |
| 毒丸升级 `DEAD(EXHAUSTED)`(§3.2 步骤 2) | 否 | 否 | 否 | 单语句 |
| 回填(信箱标记 + `BACKFILL_AT`) | 否 | 否 | 否 | 跨库两次单写;幂等可重跑 |
| 人工重放(批量改回 `PENDING`) | 否 | 否 | 否 | 单语句批量;`MessageLifecycleGate` 与回填互斥 |
结论:**「航班变更与处理终态同事务」只对业务型终态成立**;非业务型终态不涉及跨表一致性,因此不需要 `PIPELINE_LOCK`,但终态与回填意图仍由同一条 UPDATE 保证不分离。
- 原文缺失归为 `MALFORMED`;读取异常不能伪装成“缺失”,应进入基础设施重试。
- 忽略规则(`LDM / REGN / RSTA / EROR``SKIPPED`)尚未实现(§10);不能因类型未覆盖就把合法忽略报文当非法报文处理。
- **主泵不回填**:终态与回填意图由同一条 UPDATE 落库,回填**一律由扫描驱动**(跨库写不能占用 FIFO 关键路径)。失败按退避重试,抵达超期期限 R 时强制补写,确认行不存在或达尝试上限则停止自动重试([message-lifecycle.md](message-lifecycle.md) §5.2US-09/Q7)。回填只需消息 ID,因此缺 META 或解码失败的死信同样可补写。`PENDING / FAILED` 禁止回填;影子环境禁写。
- 回填有三种结果:写入成功;**此前已被标记(视为成功,不覆盖已有值)**;信箱行不存在(视为失败,当前无终态,见 §10)。
## 4. 日计划快照与请求匹配
### 4.1 快照发布
`SCHD-DNLD``SCHD-RESP` 共用 `ScheduleProcessor.applyScheduleRecords`
1. **重放判定**`PROC_STATE` 已存在成功终态 → 幂等成功,仅追加留痕,不重复写入。
2. **整包校验**:声明记录数、航班标识与运营日推导等校验失败 → 整包 `DEAD(PROTOCOL)`,不写半包,既有状态保持不变。
3. **事务写入**:锁内按 `FLID` 点查归属日,发现同一航班跨运营日即整包回滚并 `DEAD(PROTOCOL)`;通过后合并写主表与资源明细。报文未携带的航班不因本次日计划报文被删除。
4. **提交结果**:同一事务保存 `KAFKA:schd` / `KAFKA:msg` 事件、置消息 `SUCCEEDED` 并预登记回填意图;事务提交后执行信箱回填,留痕在事务外追加。
单事务保证未提交变更整体回滚。消息重放由 `PROC_STATE` 的消息 ID 与业务身份控制;版本号不能单独证明消息身份。
**应答守卫仍是缺口**`RESP` 应匹配开放 `RQFD` 请求(无匹配、过期或报文早于发送时间则不更新快照);当前 RESP 与 DNLD 无差别进入快照写入(§4.2/§10),不能视为 RESP 匹配闭环。
### 4.2 上游请求与静态数据
`REQ_TRACK` 表与仓储已存在(状态 `PENDING / SENT / DONE / EXPIRED`),但没有运行时协调器:出站 `COUTMSGS` 适配、请求编码、超时与应答匹配均未实现(`XmlCodec.encodeRqrd` 只是占位)。本节是目标机制,不是现状。
请求生命周期目标:
```text
REGISTERED → SENT → WAITING → DONE
└──→ EXPIRED
```
- 注册同类新请求前使旧开放请求过期;只有 `COUTMSGS` 写入确认后才标记 `SENT` 并关联出站记录;写信箱成功但本地未确认的情况需要补偿与去重,不能无条件重新发送。
- 应答优先按已确认的回显字段精确匹配;回显契约未确认时的降级匹配(同类开放请求且 `DTTM ≥ sentAt`)存在跨代误配风险,必须明确接受并审计,不能宣称精确关联。比较前统一时区和时间单位。
- 参考应答写入自有 `REF_MASTER`(尚未建表),日计划应答走快照流程;请求完成必须在相应数据处理成功之后,超时和迟到应答不能修改已关闭请求对应的状态。
请求与参考数据的交付范围见 user-stories.md US-08/US-13/US-14 与本文 §10。
## 5. 事件投递
### 5.1 普通事件
`Dispatcher``TARGET` 读取最小未发送 `EVENT_ID`(当前逐条投递只处理 `KAFKA:msg`)。队头退避未到期时,该目标停止推进;发送确认后才标记 `SENT`,失败记录次数并按退避推后,达到上限转 `DEAD`(记录保留作 DLQ)。所有外部调用需要有界超时,避免阻塞整个投递线程。
投递是至少一次:Kafka Broker 或其他投递目标已接受、但本地未标记成功时可能重发;目标端接受不等于业务消费者已消费。Kafka 生产约束沿用架构决策 D3architecture.md §7),但生产者幂等不替代应用层事件去重;跨重启的事件身份和消费方去重契约仍需落实。共享出站信箱也必须单独解决重复写入,不能假设 Kafka 的保证适用于 MySQL。真实 Kafka 适配器尚未实现(`DeliveryPort` 仅有 stub),投递闭环须先交付适配与配置强制校验。
### 5.2 `schd` 聚合
`KAFKA:schd` 不进入逐条投递循环,只由 `flushSchd` 发送:
1. 到期领取批次:`mergePendingSchd``FLID` 合并未发事件,每个 `FLID` 只保留最新 `STATE_VERSION`,批次大小受 `flush-limit` 约束。
2. 逐条发送:UPSERT 发送该 `FLID` 的最新整态(key = `FLID`);TOMBSTONE 发送 null 值删除通知。
3. 成功后把本批被代表的事件(含被最新版本合并压掉的旧事件)一起标记完成,推进 `lastFlush`;失败的单条增加次数并退避,达到上限转 `DEAD`
默认聚合周期 3 秒、批上限 500。它提供最新状态通知,不保留每次中间变化;`KAFKA:msg``KAFKA:schd` 之间不承诺顺序。
## 6. 失败恢复与维护作业
### 6.1 失败、重试与重放
`ProcFailure``FailureScheduler` 统一处理侧失败落账,投递侧(`Dispatcher`)按同一套次数与退避规则迁移事件。默认最多 5 次(attempts ≥ 5 判耗尽);退避表配置为 1、2、4、8、16 秒、单档封顶 60 秒,但**耗尽判定与退避取值同源**(`attempts ≥ max-attempts` 即转 `DEAD`,不再计算下次重试),因此默认配置下实际只用 1、2、4、8 四档:**16 秒档与 60 秒封顶不会被触发**(要么调高 `max-attempts`,要么接受"5 次尝试 = 4 档退避")。时间经可注入 `Clock` 判定。
失败必须在持有具体消息、事件或批次的位置记录,外层循环只做兜底日志和等待,不重复增加次数。线程中断应恢复中断标记并向上传递;不把 JVM `Error` 当普通业务失败捕获。
`ReplayService` 只允许 `CODEC_ERROR / UNSUPPORTED / INFRA / EXHAUSTED``FAILED / DEAD` 回到 `PENDING`:重置 `ATTEMPTS=0``NEXT_ATTEMPT_AT=NULL``PROCESSING_STARTED_AT=NULL`**不重置 `IDENTITY_KEY`**(保留身份,避免重放时把自己判成重复消息)。它按错误类**全局批量**重放,尚无按记录预检、操作审计与管理入口(US-10)。重放与回填通过 `MessageLifecycleGate` 在同一实例内互斥,避免「旧回填给已重新入队的消息写标记」;【缺口】**毒丸升级路径不在该 gate 内**(§3.2 步骤 2),该竞态窗口登记于 §10。旧消息进入终态后后续消息可能已执行,**重新入队不等于恢复历史顺序**;人工重放前必须评估状态覆盖和版本保护,不能直接批量重放到生产。
**维护作业**`JobRunner` 用独立 daemon 线程每 30 秒触发 `BackfillService.sweep`(**调度周期,不是回填完成时限**:批次积压、单行调用超时与历史作业耗时都会延长实际延迟;回填扫描自身的退避为 30 秒起步、封顶 15 分钟),每天机场时区 03:30 后触发一次 `HistorySweepJob`(§6.2)。回填意图在写终态的同一条语句里登记在 `PROC_STATE``BACKFILL_NEXT_AT/ATTEMPTS/ERROR`),不再有独立待办表。扫描谓词见 [message-lifecycle.md](message-lifecycle.md) §5.2;超过超期期限 `R` 后超期分支恒成立并覆盖退避,但**永久失败不会无限重试**:确认行不存在立即放弃、暂时性故障到 `backfill-max-attempts` 后停止自动重试(两类都告警且可人工恢复)。扫描按 `BACKFILL_ATTEMPTS, MSG_ID` **公平轮转**并排除已放弃行,最旧的一批永久失败行不会再占满批次饿死后续记录。作业不再经 `PUMP_JOB` 队列插队,不参与消息 FIFO,也不使到期消息饥饿。作业与回填通道的生命周期口径见 [message-lifecycle.md](message-lifecycle.md) §3/§4。
### 6.2 历史清理与归档
**航班历史清理**`HistorySweepJob`,每天 03:30 触发):按 `HistoryProps` 的保留期与终态/静默判据选出候选(含 `DELETED`),先写历史存储,成功后物理删除主行与明细;历史存储未接通或 `msgx.history.history-store-enabled=false` 时删除 0 条。未经 FDEL、由生命周期直接清除的航班,清除前补发一次删除事件。语义与红线见 flight-state.md §6,不在此重复。
**留痕清理**`SCHD_SNAP_LOG` 保留 90 天,在历史清理窗口内按 `(SCOPE_END, RECV_AT)` 删除。
**处理终态归档**`PROC_STATE_HST` 仍是目标表(user-stories.md US-11),尚未建表;不得归档 `PENDING / FAILED`,也不能因移走身份记录而失去业务去重能力。ES 历史投影(阶段 B)不启用。自有记录归档与信箱原文保留的关系见 [message-lifecycle.md](message-lifecycle.md) §8/§9。
共享信箱保留策略由库所有方管理;历史写入与删除事件入队之间仍需恢复方案,顺序调用不构成原子提交。
## 7. 接口与运行配置
兼容入口为 `POST /cminmsgs/send`,请求体为原始 XML,当前成功响应为 HTTP 200、`text/plain` 格式的信箱记录 ID。这只表示接收结果,不表示业务处理成功。请求媒体类型、字符集和失败响应仍需与现役逐项对拍;查询与其他兼容端点不能因列入需求就视为已提供。
运行配置以 `application.yml``application-dev.yml``.env.example` 为准,设计上重点区分(下列 `msgx.*` 参数以 `msgx.` 为前缀,`mailbox.*` 不带前缀):
- `msgx.pipeline.autostart``msgx.stubs`:分别控制管道启动与内存适配器;生产禁止 stub,默认不自动启动。
- `msgx.pipeline.poll-interval / claim-batch / max-attempts / backoff-ms / backoff-cap-ms / head-deadline`:控制轮询节奏、批次、重试上限、退避与队头滞留;这些参数不能改变 FIFO。
- `msgx.pipeline.max-commit-delay / overdue-backfill / backfill-batch / backfill-max-attempts`:空洞老化阈值(Q2 的「ID 分配 → 事务可见时延上界」,同时决定目标补偿扫描的窗口宽度)、超期补写期限 R(Q6)、回填扫描批量与回填自动重试上限(达上限停止自动重试并可人工恢复);R 与老化阈值都不能为提速而下调。默认 5 分钟**只是缺少依据的占位假定值**——库方尚未给出该可见性时延上界,且它**不能由 SIS 报文的 `Expiry`480 分钟量级)推导**[message-lifecycle.md](message-lifecycle.md) §5.1),Q2 确认前属于上线门槛。`overdue-backfill`R)的完整约束见 [message-lifecycle.md](message-lifecycle.md) §5.2。
- `msgx.pipeline.delivery-batch / delivery-drain-rounds`:普通事件(`KAFKA:msg`)批量投递的批大小与每轮最多连取批数;把投递从"每条一次 DB 往返 + 一轮一次 sleep"提升到由下游决定,同时保留"队头失败即停止本轮"的目标内保序。
- `msgx.operation-day.zone / cutoff-hour`:运营日时区与切日边界,决定 `OPERATION_DAY` 推导(flight-state.md §2.1)。
- `mailbox.processed-value`:写回共享信箱的处理标记值,仅限库方认可的 legacy 值集(Q7)。
- `msgx.schd.flush-period / flush-limit`:控制状态通知的聚合延迟与批量大小。
- `msgx.identity.include-day-boundary`:影响去重语义,不能作为普通调优项切换;序号重置周期见 Q11。
- `mailbox.shared-mysql.enabled``msgx.history.history-store-enabled`:分别门控真实信箱与历史存储接线,默认关闭。
- `msgx.health.backlog-cache-ttl-ms`:积压快照缓存窗口(默认 30 秒,`/health``/metrics` 共用);设为 0 仅用于测试/排障,不作为实时性的替代。
- `msgx.pipeline.cutover-watermark`:**一次性、显式**的切流播种(默认不配置 = 不播种)。取值为 `min`(读当前全部现存行,`W=MIN(ID)1`)、`zero`(从 0 按空洞规则扫描)、`max`(跳过当前可见存量,`W=MAX(ID)`)或具体 ID。代码**不做默认选择**、也不会自动退化成 `max`;升级实例(已有水位或已有处理记录)**拒绝重新播种**,重新切流必须是显式操作;播种事实记在 `INBOX_CURSOR.SEEDED_AT`(与水位在同一条语句落库),而该列为 NULL **不等于**从未消费。非法取值由启动自检挡下。
- `msgx.pipeline.late-detect-period / late-detect-batch`**只读**迟到检测(ACM2-41 阶段 0)的周期与单轮复查量;周期设 0 即关闭。检测只计数与告警,不补入队、不改变处理语义。
日志关联消息 ID、事件 ID 和批次;失败记录错误分类、次数、下次执行时间。健康检查反映依赖实际可用性;队头滞留、积压、死信和补偿失败需要指标及告警。指标经 Micrometer 暴露(`PipelineMetrics`,启动时急切注册):`msgx.pipeline.backlog.unfinished``msgx.pipeline.backlog.oldest_unprocessed_seconds``msgx.pipeline.backfill.unmarked_terminal``msgx.pipeline.backfill.abandoned``msgx.pipeline.backfill.oldest_unmarked_seconds``msgx.pipeline.watermark.lag``msgx.pipeline.hole.aged_out.total``msgx.pipeline.late_arrival.detected.total`(迟到检测命中数——**> 0 表示上游提交确实晚于水位推进,需要与库方对契约**)。取数统一走 `BacklogSnapshotProvider``msgx.health.backlog-cache-ttl-ms`,默认 30 秒),`/health``/metrics` 共用同一份快照——`backlog()``PROC_STATE` 的全表聚合,不能被高频抓取打穿;无法取数时上报 `NaN`,不伪造 0。日志出口故障不得阻塞业务线程。
## 8. 验证要求
单元测试使用内存仓储和可推进的 `Clock`,不依赖睡眠或在线中间件。以下不变量必须有回归测试,接口级单测不能替代主泵调度测试:
| 场景 | 必须验证的结果 |
|---|---|
| 重复扫描、入队中断 | 不重复入队、不丢记录、不让后续消息越序;终态而未回填的行不得阻断后续消息发现。 |
| 较小 ID 迟到(Q2 未确认) | 【缺口 · 已固定基线用例】快路径只读 `ID > W``InboxPollerTest` 已把"水位越过后到达的较小 ID 不被发现"钉成基线。补偿扫描(G1)交付前禁止任何"迟到不越序"的验收声明。 |
| 空洞老化与重置 | 阈值内不推进水位、不越过入队;超期后只放行空洞本身;旧空洞补齐后出现的新空洞获得完整等待窗口。 |
| 兼容入口与空洞并发 | 兼容入口登记的行超出水位,主泵不领取;水位追平后按序处理。端到端用例:`PipelineSmokeTest`「compat injected high id is not claimed until the watermark catches up」。 |
| 队头失败、退避及作业竞争 | 消息不越队;到期后恢复;作业不使消息无限饥饿。 |
| 同身份多条记录、失败后重试、归档后重复 | 只产生一次有效业务处理,不把自身重试判为重复。 |
| 非业务型终态 | 不触碰 `FLIGHT_SCHD` / `MSG_EVENT`,只写 `PROC_STATE`,且终态与回填意图同语句生效。 |
| PG 事务失败、快照重复或迟到 | 整体回滚重试、不重复推进版本、不回退状态、不误删增量航班。 |
| 整包协议拒绝(运营日冲突、声明数不符) | 整包不落地、整体回滚,既有状态与版本不变,消息终态为 `DEAD(PROTOCOL)`。 |
| PG 提交失败、信箱回填失败 | 事件、处理终态与回填意图一起回滚;已提交结果只补写标记,不重放业务;中间态永不补写。 |
| 回填四种结果 | 写入成功 / 早已标记(不覆盖、记成功)/ 信箱行不存在(立即放弃并告警,**不得**视为已标记)/ 暂时性故障达上限(停止自动重试,可人工恢复)。 |
| 回填公平性 | 最旧的一批记录永久失败时,后续待回填记录仍能被扫描到;已放弃行不再进入扫描。 |
| 「打标即清除」语义 | 【待确认】若库方清除由打标触发,必须验证存在约定的保留期或独立原文保留通道,且**不依赖 `R` 的取值**。 |
| `RECEIVED_AT` 为 NULL | 超期兜底不生效的行为被显式验证,且不会导致标记被提前写入。 |
| 毒丸升级与人工重放并发 | 【缺口】毒丸路径不在 gate 内;窗口必须被复现,或用 gate 覆盖后验证互斥。 |
| 投递确认丢失、批次失败、次数耗尽 | 允许可识别的重发、保持目标顺序、整批退避并保留死信。 |
| 请求超时、无匹配 RESP、时间单位不一致 | 不误用迟到应答,不提前完成请求。 |
| stub 误配置、重复实例、停机中断 | 生产拒绝不安全启动,工作线程能正确退出。 |
真实适配器还需 PG 事务与补偿集成测试、日计划崩溃恢复测试、Kafka 故障投递验证;常规验证命令为 JDK 25 下执行 `./gradlew test`
## 9. 实现入口
生产代码根目录为 `src/main/kotlin/com/gzzn/omms/msgexchange/`
| 关注点 | 主要入口 |
|---|---|
| 收报与兼容接口 | `ingress/InboxPoller.kt``InboxService.kt``InboxController.kt` |
| 解码 | `codec/JacksonXmlCodec.kt``SisWireMapper.kt``SisMessageBody.kt` |
| 调度与处理 | `processing/Pump.kt`(含 `MessageProcessor`)、`DynamicProcessors.kt`FLOP/FDEL/ADFT)、`Identity.kt` |
| 日计划 | `processing/ScheduleProcessor.kt`;请求协调尚无实现(`REQ_TRACK``infra/persistence/` |
| 回填与投递作业 | `processing/BackfillService.kt``jobs/JobRunner.kt``HistorySweepJob.kt``delivery/Dispatcher.kt` |
| 持久化与恢复 | `infra/persistence/``infra/retry/``ProcFailure` / `ReplayService` / `FailureScheduler` |
| 启停与配置 | `PipelineLifecycle.kt``config/PipelineProps.kt``config/HistoryProps.kt` |
## 10. 当前实现差异
以下缺口直接影响上述设计是否成立,不能以类或接口已存在作为完成依据。收报与处理链路的逐条缺口另见 [message-lifecycle.md](message-lifecycle.md) §12。
- **事务与外部副作用**:业务型终态(处理器产出的 `SUCCEEDED`)与航班变更、待发事件、回填意图在同一 PG 事务提交;非业务型终态(`MALFORMED / PROTOCOL / SKIPPED / EXHAUSTED`)与毒丸升级只写 `PROC_STATE`,终态与回填意图同一条 UPDATE,不涉及跨表一致性(§3.3 表 1)。回填意图落在 `PROC_STATE``BACKFILL_AT/NEXT_AT/ATTEMPTS/ERROR`),`BACKFILL_TODO` 已随 V2 迁移下线,[message-lifecycle.md](message-lifecycle.md) §4 的两个崩溃窗口(提交后回填前崩溃、回填意图二次落账失败)不再存在。回填本身仍是跨库单写,失败按退避重试并由超期期限 R 兜底;R 的取值待 Q6 确认。
- **收报与调度**:水位 W 与空洞计时 `HOLE_SINCE` 落库并与入队同事务推进,遇空洞即停、空洞超过 `max-commit-delay` 判定为永久(Q2 未书面确认前该阈值只是**缺少依据的占位假定值**——库方尚未给出「ID 分配 → 事务可见」的时延上界,且不能由 SIS 报文 `Expiry` 推导);旧空洞补齐后出现的新空洞会重置老化起点。**较小 ID 迟提交目前没有任何发现机制**:[message-lifecycle.md](message-lifecycle.md) §5.1 描述的窗口补偿扫描尚未实现,水位越过空洞后到达的较小 ID 不会被快路径(`ID > W`)发现,端到端顺序保证仍待与库方联合验证。HOL deadline 已改用 `PROCESSING_STARTED_AT`(为空时以 `UPDATED_AT` 兜底)和可注入 `Clock`;Q6 仍需确认长期积压与人工重放的期限口径。**阶段 0 只读迟到检测已实装**(监视被放行的空洞 ID,命中即计入 `msgx.pipeline.late_arrival.detected.total` 并告警,不补入队);补偿扫描的阶段 1(按类型安全补入队)与 Q2 契约仍待推进。
- **兼容入口与水位(已修)**`POST /cminmsgs/send` 直接写 `PROC_STATE`、不参与水位,因此登记行会超出水位;主泵只领 `msgId ≤ W`,在水位追平前不领取(`Pump` 已实现,端到端用例守住)。代价是这类消息延迟到水位追平,且**必须让收报轮询运行**([message-lifecycle.md](message-lifecycle.md) §5.1/§12)。
- **回填闭环(已修 · V4)**:三种结果已区分(写入成功 / 早已标记 / 信箱行不存在),并新增放弃语义:确认 `MISSING` 立即放弃、暂时性故障达 `backfill-max-attempts` 后停止自动重试,两者都告警且可由人工恢复;**放弃 ≠ 标记已确认**(`BACKFILL_AT` 仍为空,清除前提不成立)。扫描改为公平轮转,饥饿问题关闭。仍存在的相关风险是"打标即清除"语义下**`R` 无法保护重放窗口**,必须另行约定保留期或引入独立原文保留通道([message-lifecycle.md](message-lifecycle.md) §12 G10)。
- **重放互斥**`MessageLifecycleGate` 只覆盖回填与人工重放,毒丸升级路径不在其中,存在「先标 DEAD 并打标、再被重放拨回 PENDING」的窗口。
- **快照与业务能力**DNLD/RESP/ADFT 与 FLOP/FDEL 处理器、整包校验与跨运营日整包拒绝均已接入;但 RESP 应答守卫与出站请求未实现,忽略规则(US-04)未实现,29 类 FLOP 与参考应答的逐类矩阵未补全,ADFT 缺失字段与 `FLID` 重用语义待上游确认。
- **航班读写**:唯一写入口与权威读已落地;ROUT/ERUT 联合主键、空值/未知属性保真、事件在事务内只算一次、逐航班多次查询仍待修正(见 flight-state.md §6)。`/all/flights` 尚未实现。
- **请求、参考数据与归档**`REQ_TRACK` 表与仓储已建,但无运行时协调与 `COUTMSGS` 出站适配;`REF_MASTER` 未建表;`PROC_STATE_HST` 未建表。航班历史清理脚手架已实现,历史存储未接通时删 0 条。
- **生产与运维**:已有事务行锁,但没有整个实例的排他/FIFO 协调;真实 Kafka 与信箱出站适配未交付(仅 stub),配置允许环境变量覆盖 `acks`/幂等/in-flight;启动校验、影子隔离、告警与指标未闭环。对拍比较工具存在不等于现场对拍已完成。Oracle 11g 方言与迁移未接入验证。
业务契约与待确认事项见 [user-stories.md](user-stories.md),进度由 Plane 跟踪;本文件不维护工单流水账、测试数量或历史方案全文。