Files
msgexchange-v2/docs/design.md
T
windyboy b8738554e6 refactor(ingress): 移除 G1 窗口补偿扫描与迟到到达检测
实际负载不足 10 条/秒,G1 属过度防御。删除迟到到达检测机制、
existingIds 接口、PipelineCounters 字段、lateDetect 配置,
以及文档中 CLM-1/CLM-2 声明与 G1 缺口索引。
2026-09-13 08:08:43 +08:00

339 lines
30 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 设计文档(管道机制)
本文件定义**管道机制**:记录模型、状态机、收报与水位、主泵与事务边界、回填、投递、失败恢复与维护作业。
- 系统边界、模块职责、存储归属、架构决策:architecture.md
- 前提 `PRE-x`、不变量 `INV-x`、声明边界 `CLM-x`invariants.md
- 对外承诺与要求 `C-x`、待确认事项 `Q`contracts.md
- 参数、指标、模块入口、错误分类:reference.md
- 航班域模型与合并语义:flight-state.md
正文描述**目标设计**。交付状态不在正文标注:主张能否对外声明记在 invariants.md 的声明边界,实现进度在 Plane(ACM2)。
## 1. 术语与持久化记录
| 术语 | 语义 |
|---|---|
| `W`(水位) | 信箱 ID 的连续上界:`(min, W]` 已全部读入自有 PG;只随新 ID 成功入队推进(永久空洞放行是唯一例外)。 |
| `holeSince` | `W+1` 处空洞最早被观测到的时刻;无空洞时为 NULL,跨重启保留。 |
| 队头 | 最小的未完成消息(`PENDING``FAILED` 都占位)。 |
| 终态 | `SUCCEEDED` / `SKIPPED` / `DEAD`;到达后队列方可推进。 |
| 回填意图 | 「还欠一次信箱标记」的持久化事实,与终态同一条语句落库。 |
| `R``R_keep`、处理标记 | 定义见 contracts.md(契约数值只在那里)。 |
| 记录 | 用途 | 关键约束 |
|---|---|---|
| `PROC_STATE` | 入站消息的处理状态、身份、尝试次数、错误原因与回填事实 | `MSG_ID = CMINMSGS_ID` 主键防重复入队;`IDENTITY_KEY` 唯一约束防业务重复;按最小未完成 `MSG_ID` 取队头;`BACKFILL_NEXT_AT` 非空 = 还欠一次回填,`BACKFILL_AT` 非空 = 标记已确认,`BACKFILL_ABANDONED_AT/REASON` 非空 = 已停止自动重试(**不等于**标记已确认);`RECEIVED_AT` 复制自信箱接收时间、**可能为 NULL**、仅用于对账与展示;`ENQUEUED_AT` 是本地入队时间、非空、是超期判据的唯一依据。 |
| `MSG_EVENT` | 等待投递的事件(outbox) | `EVENT_ID``KAFKA:msg` 是稳定事件身份并决定投递顺序;对 `KAFKA:schd` 是每次接受 upsert 时替换的写代次。`TARGET` 区分 `KAFKA:msg` / `KAFKA:schd``PARTITION_KEY` 当前取 `FLID``Q4` 定案前为假定,见 `C-29`);`EVENT_TYPE` 区分 UPSERT 与 TOMBSTONE。`KAFKA:schd``FLID` 单行 upsert,只保留最新 `STATE_VERSION`。 |
| `REQ_TRACK` | 上游请求及应答关联 | 状态 `PENDING / SENT / DONE / EXPIRED`;保存请求类型、覆盖运营日、发送方、出站信箱 ID 与发送/完成时间;**「同类只允许一个开放请求」的唯一键 = `(请求类型, 覆盖运营日, 发送方)`,且仅对开放状态生效**。登记、超时与应答匹配尚未实现 `[G-REQ-TRACK]`。 |
| `REF_MASTER` | 静态参考数据(目标表) | `(RTYPE, RKEY)` 唯一;尚未建表,客户端与刷新流程见 user-stories US-13/US-14US-14 两类映射的存储落点未定。 |
| `FLIGHT_SCHD` | 航班标量及单值异常字段 | `FLID` 主键;`OPERATION_DAY` 一经确定不可变;版本与最近消息 ID 用于追踪。现有变长集合存于 8 张资源明细表与 `FLIGHT_ROUTE_POINT``SRVT`/`VIPF` 专用明细尚未实现 `[G-SRVT-VIPF]`,规则见 flight-state.md。 |
| `INBOX_CURSOR` | 消费水位 `W`、空洞计时 `holeSince`、播种事实 `SEEDED_AT` | 单行游标;`W` 只随新 ID 成功入队推进,遇空洞即停;`HOLE_SINCE` 持久化空洞观测时刻,进程重启不丢计时。`SEEDED_AT IS NULL` **不等于**从未消费(已有库新增列后同样为 NULL)。 |
| `SCHD_SNAP_LOG` | 日计划处理留痕 | 只追加、可重建,不参与状态决策;保留期见 reference。 |
| `PROC_STATE_HST` | 终态处理记录的归档目标 | 尚未建表 `[G-PROC-HST]`;只归档到自有 PG 的目标表,不落共享库历史表。 |
字段与索引以 `src/main/resources/db/migration/` 的迁移链为准(Oracle 11g 目录为占位,未接入 Flyway)。报文原文仍从共享信箱读取,原文保留期必须覆盖处理与重放窗口(`C-7`)。
## 2. 消息、身份与决策
`XmlCodec`(实装 `JacksonXmlCodec`)把 XML 解码为 `DecodedMessage`,包含 `SNDR / TYPE / STYP / SEQN / DTTM` 元数据、`MsgKind` 与业务载荷。解码失败区分 `MALFORMED`(报文非法,不重试)与可随 codec 修复的 `CODEC_ERROR``MsgKind` 是一等分派键:`Schd(RESP/DNLD/ADFT)``Flop``Fdel``Unsupported`
业务身份统一由 `Identity.of` 生成:`SNDR | TYPE | STYP | SEQN`。接收时只按信箱 ID 去重,解码后才首次绑定业务身份;重试保留原有绑定,因此自身重试不会被判为重复。身份被另一条记录占用时,当前消息转 `SKIPPED`,记录 `duplicate-of:<id>`。是否加入日期边界取决于上游 `SEQN` 重置周期(见 `C-20`/`Q11``PARAM:msgx.identity.include-day-boundary`);上线后不能随意更换身份算法。
**身份绑定是独立的幂等单语句**`WHERE IDENTITY_KEY IS NULL`),不参与业务事务。它的前提是「报文不可变」(`PRE-7`):同一身份的重发不会被比对内容,若上游改发正文会被判为重复并跳过(`Q15`)。
分派与落库由 `MessageProcessor` 协调:按 `MsgKind` 把已绑定身份的队头消息交给对应事务协调器(DNLD/RESP → `ScheduleProcessor`ADFT → `AdftProcessor`FLOP → `FlopProcessor`FDEL → `FdelProcessor`,其余 → `FAILED(UNSUPPORTED)`)。这些处理器在 `PIPELINE_LOCK` 事务内读取当前完整态,调用纯领域决策逻辑得到下一完整态与待发事件,再统一落库并登记回填意图;它们不直接触碰 Kafka。领域决策逻辑不执行 I/O。
## 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`,这类记录仍可人工重放。
## 4. 收报与水位
### 4.1 收报流程
`InboxPoller` 按 ID 升序、有限批次读取水位之后的信箱记录(`ID > W`,**不以处理标记为谓词**),在自有 PG 建立 `PENDING` 并把水位推进到连续上界。每轮:
1. 读取游标 `(W, holeSince)`。信箱不可读时记日志、等下一轮,**不动水位**——这是基础设施失败,不能当成「没有新消息」。
2.`ID > W` 的升序前 `PARAM:msgx.pipeline.claim-batch` 行。
3.`W+1` 起逐 1 数,求连续上界;遇到第一个缺号即停止计数。
4. 空洞判定(仅当本批存在「缺号之后的行」时才可能成立):
- 缺号首次被观测到 → `holeSince = now`
- `now holeSince < PARAM:msgx.pipeline.max-commit-delay` → 水位停在缺号前,**本批缺号之后的行一律不入队**(否则晚提交的较小 ID 会排到它们后面,破坏 FIFO);
- `now holeSince ≥ PARAM:msgx.pipeline.max-commit-delay` → 判定为永久空洞,水位放行到「缺号后第一行 − 1」并清空 `holeSince`。放行**只跳过空洞本身,不越过任何已存在的行**。
5. 在同一个 PG 事务内:对水位以内的每一行 `insertIfAbsent(MSG_ID, RECEIVED_AT, ENQUEUED_AT)` 并写回 `(W, holeSince)`;主键冲突表示已入队(重复扫描与兼容入口并发都安全),不计入、不报错。
6. 提交。本批因空洞或批次上限未入队的行留待下一轮——**每轮最多解决一个空洞**。
`holeSince` 落在 `INBOX_CURSOR.HOLE_SINCE`,进程重启不丢计时。旧空洞补齐后出现的新空洞从新观测时刻重新计时,不继承旧等待时间。
**代价(必须接受并观测)**:水位遇空洞即停意味着空洞之后的所有消息最多要等一个老化窗口才能入队;自增回滚等会在 ID 序列留下永久空位,每出现一个永久空位就是一次等长的入队停摆,空位频繁时有效吞吐按比例下降。运行期必须观测永久空洞计数与水位滞后(指标见 reference)。
### 4.2 发现完整性依赖与扫描路径
水位的有效性依赖 `PRE-2``PRE-3``C-1`/`C-2`/`C-3`)。承诺缺失时 `W` 只是快路径提示,不足以证明该区间收齐。ID 分配 → 事务可见时延上界必须由库方直接给出,**不可由 SIS 报文 `Expiry` 推导**。
| 路径 | 目的 | 谓词 | 状态 |
|---|---|---|---|
| 快路径(日常) | 发现水位之后的新消息 | `ID > W ORDER BY ID ASC LIMIT claim-batch` | 已实现 |
### 4.3 切流播种
若信箱已有存量(典型情况是最老分区已被清除、`MIN(ID)` 远大于 1),从 `W=0` 启动会先把 `ID=1` 判成空洞、白等一个老化窗口。是否跳过存量属于**切流决策**,因此不做默认选择:只有显式配置 `PARAM:msgx.pipeline.cutover-watermark` 才播种,取值 `min`(读现存全部)/ `zero`(从 0 按空洞规则)/ `max`(跳过当前可见存量)/ 具体 ID。升级实例(已有水位或已有处理记录)拒绝重新播种;播种事实与水位同语句落库(`SEEDED_AT`)。
### 4.4 兼容 HTTP 入口
`POST /cminmsgs/send` 执行「写入共享信箱 → PG 入队」,两步不在同一事务:信箱成功而 PG 失败时原文仍在信箱中,由轮询补建;客户端失败重试可能再次写信箱,业务身份去重仍然必需。该入口直接写 `PROC_STATE`、不读不推水位,登记的行因此**超出水位**;主泵只领 `MSG_ID ≤ W`,所以在水位追平前不会被处理——顺序不受影响,代价是延迟到追平,且必须让收报轮询运行。响应语义见 `C-28`
### 4.5 单实例与水位
信箱读取不加锁,水位是单行覆盖写,本设计只在单活动实例下成立(`PRE-5`)。多实例并发收报会让水位互相覆盖(覆盖回退只会造成重复扫描,不会丢消息,但空洞计时会失真),必须先有实例级排他。
## 5. 主泵调度与单条处理
### 5.1 调度
每次 `Pump.tick` 只围绕最小未完成消息:
1. 无队头:按轮询间隔休眠。
2. 队头超出水位(`msgId > W`):**不领取**,休眠到下一轮。这类行只可能来自兼容入口的直接登记;允许领取会让它越过尚未入队的较小 ID。会打印一条限流 WARN(仅在水位值变化时打一次)。
3. 队头为 `FAILED` 且尝试次数达上限:转 `DEAD(EXHAUSTED)`,终态与回填意图同一条 UPDATE 落库,**不做跨库写**。该分支只写 `PROC_STATE`,不取 `PIPELINE_LOCK`、不在处理器事务内,也不在 `MessageLifecycleGate` 内。
4. 队头为 `FAILED` 且未到 `next_attempt_at`:休眠到可重试时刻,不处理后续消息。
5. 其余(新消息或退避到期的重试):调用 `MessageProcessor.processOne`,失败迁移在该边界内完成。
**终态判据只有尝试上限,没有按时间的毒丸**:处理卡死应由外部调用的有界超时兜底;用一个时间阈值把消息直接推入 `DEAD` 会绕过人工复核,并制造与人工重放并发的旁路写入者。队头年龄由 `msgx.pipeline.backlog.oldest_unprocessed_seconds` 观测,主泵不做这项判定。
**所有取时统一经注入 `Clock`**(收报空洞老化、主泵调度、处理器落库时间、回填重试、作业切日),不使用系统时钟。
### 5.2 processOne
```text
processOne(head)
1. 读原文:缺失 → DEAD(MALFORMED, raw-missing);读取异常 → FAILED(INFRA)
2. 解码:
报文非法(MALFORMED) → DEAD(MALFORMED),不重试
可修复解码错(CODEC_ERROR) → FAILED(CODEC_ERROR) 退避
3. 身份绑定(仅当 IDENTITY_KEY 为空):
已被本消息占用 → 继续
已被别的消息占用 → SKIPPED(duplicate-of:<id>),结束
空闲 → 写入 IDENTITY_KEY(独立单语句,不参与业务事务)
4. 按 MsgKind 分派:
SCHD-DNLD / SCHD-RESP → ScheduleProcessor(快照事务)
SCHD-ADFT → AdftProcessor(单航班事务)
FLOP / FDEL → Flop / FdelProcessor(单航班事务)
Unsupported → FAILED(UNSUPPORTED) 退避
载荷缺失 → DEAD(MALFORMED)
整包协议拒绝 → DEAD(PROTOCOL),不落半包
5. 业务型成功:处理器在自己的事务内写航班变更 + 待发事件 + SUCCEEDED + 回填意图
6. 结束:主泵不做回填;回填意图已随终态落库,由扫描补写信箱标记
```
- 原文缺失归为 `MALFORMED`;读取异常按基础设施失败进入重试,与「原文缺失」区分。
- 忽略规则(`LDM / REGN / RSTA / EROR``SKIPPED`)尚未实现(`[G-IGNORE]`,US-04);类型未覆盖不等于报文非法:忽略类报文在规则实现前不按 `MALFORMED` 处理。
- **主泵不回填**:终态与回填意图由同一条 UPDATE 落库,回填一律由扫描驱动,不占用 FIFO 关键路径。回填只需消息 ID,缺 META 或解码失败的死信同样可补写。影子环境禁写。
### 5.3 事务边界
| 动作 | 显式事务 | 持 `PIPELINE_LOCK` | 触及航班表 / 事件 | 原子性来源 |
|---|---|---|---|---|
| 收报入队(`insertIfAbsent` + `cursor.save`) | 是 | 否 | 否 | 同库事务 |
| 身份首次绑定 | 否 | 否 | 否 | 单语句 + 唯一约束 |
| 业务型终态(处理器产出 `SUCCEEDED`) | 是 | 是 | 是 | 同库事务:航班变更 + 事件 + 终态 + 回填意图 |
| 非业务型终态(`MALFORMED` / `PROTOCOL` / `SKIPPED` / `EXHAUSTED`) | 否 | 否 | 否 | 单语句(终态与回填意图同一条 UPDATE) |
| 航班历史清理的物理删除 | 是 | 是(`INV-18`) | 是 | 同库事务:复查判据 + 归档成功后删除 |
| 回填(信箱标记 + `BACKFILL_AT`) | 否 | 否 | 否 | 跨库两次单写;幂等可重跑 |
| 人工重放(批量改回 `PENDING`) | 否 | 否 | 否 | 单语句批量;`MessageLifecycleGate` 与回填互斥 |
结论:「航班变更与处理终态同事务」只对业务型终态成立。`PIPELINE_LOCK` 的竞争写者是**航班历史清理**`INV-18`),不是别的处理器线程;没有第二写者时该锁不产生额外串行度。
### 5.4 历史积压
信箱中的成规模存量(上线前遗留、停机累积)**不是特殊模式**:它逐条走与日常完全相同的 FIFO 路径。
- 顺序由 `MSG_ID` 决定,不由执行方式决定。入队与处理由不同线程驱动、可以并发,「先入队后处理」只是可选的运维规程,不是正确性前提;系统不提供「只入队」模式。
- 不加速、不分流、不走旁路:不允许并行队头,也不允许实时消息跳过积压。
- 尝试上限与退避对积压同样生效,不因积压而放宽。
- 经确认不再处理的行置 `SKIPPED` 并记录原因,到达终态后走回填通道;不存在「整段 DELETE」的快速通道(授权与留痕见 `C-27``Q12`)。
- 消化期间的可观测项与完成时限口径见 reference 与 CLM-9**扫描周期不是完成时限**。
## 6. 回填
### 6.1 事实与扫描谓词
终态落库时登记回填意图;标记回写由扫描驱动,跨库单写、幂等可重跑。扫描谓词(与实现一一对应):
```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 ENQUEUED_AT < NOW R ) -- 进入强补写窗口,覆盖退避
ORDER BY BACKFILL_ATTEMPTS ASC, MSG_ID ASC -- 公平轮转,永久失败行不占满批次
LIMIT PARAM:msgx.pipeline.backfill-batch
```
超期判据使用**本地入队时间**`ENQUEUED_AT`),不使用信箱的 `RECEIVED_AT`:后者来自外部时钟,前偏会在「打标即清除」语义下造成提前清除(`PRE-4`)。
### 6.2 四种结果与放弃
| 结果 | 判定 | 处置 |
|---|---|---|
| 写入成功 | 标记为空、写入 1 行 | 记 `BACKFILL_AT`,不再重试 |
| 早已有标记 | 写入 0 行且信箱行存在 | **视为成功**,不覆盖已有值,记 `BACKFILL_AT` |
| 信箱行不存在 | 写入 0 行且信箱行不存在 | **立即放弃自动重试**(原因 `MISSING_ROW`)并告警。终态行存在而信箱行不存在,只可能是该行在入队后被删除(永久空洞 ID 从不入队,不会进入本扫描) |
| 暂时性故障持续超期 | 超时 / 连接失败持续到 `R` 仍未打标 | **停止自动重试**(原因 `TRANSIENT_DEADLINE`)并告警;`R` 之前只退避重试,**不按尝试次数放弃**;保留人工恢复能力 |
**放弃 ≠ 标记已确认**:放弃行不写 `BACKFILL_AT`,因此不满足 `C-8` 的清除前提,库方不得据此清除;放弃清单需人工对账确认后才可用于清除判定。
### 6.3 `R` 的作用
`R`(契约值见 contracts)有两个作用:
1. **取消退避**:已终态但超期未打标的行,每轮扫描都被尝试,不再等退避到期;
2. **暂时性故障的放弃期限**:超时、连接失败这类暂时性故障**在 `R` 之前只退避重试、不放弃**;到 `R` 仍未打标才停止自动重试、记入放弃清单并告警。
放弃判据用**时间**而不是**尝试次数**:固定次数不能稳定表达允许的故障持续时间,因此按 `R` 判断放弃,`PARAM:msgx.pipeline.backfill-max-attempts` 只用于告警。
关于「最终一定打标」,准确表述是三段,缺一不可:
1. 退避重试(`R` 之前不放弃,参数见 reference);
2.`R` 仍失败则停止自动重试、告警,进入放弃清单,保留人工恢复(`reopen`);
3. `C-8` 允许以「放弃清单 + 人工确认」作为清除判定,避免一行永久卡住整个分区。
两个边界要说清:`MISSING_ROW`(信箱行不存在)是**确定性结论**,立即放弃,不受 `R` 保护;`R` 仍然**不保护重放窗口**——`R``R_keep` 只要求 `R ≤ R_keep`,重放窗口的唯一保证来源是 `C-7`
重放窗口的保护只有两条路:约定保留期(`C-6` + `C-7`,目标前提),或另设原文保留通道(`[G-REPLAY-CHANNEL]`,尚未设计)。若库方清除语义是「打标即可清除」,则当天打标的原文当天即可被清除,增大 `R` 无效。
## 7. 日计划快照与请求匹配
### 7.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 与业务身份控制;版本号不能单独证明消息身份。
**应答守卫 `[G-RESP-GUARD]`**`RESP` 应匹配开放请求(无匹配、过期或报文早于发送时间则不更新快照);当前 `RESP``DNLD` 无差别进入快照写入,因此当前不构成匹配闭环。
### 7.2 上游请求与静态数据
`REQ_TRACK` 表与仓储已存在,但没有运行时协调器:出站 `COUTMSGS` 适配、请求编码、超时与应答匹配均未实现(`[G-REQ-TRACK]`)。目标机制:
```text
PENDING → SENT → DONE
└──→ EXPIRED
```
- 注册同类新请求前使旧开放请求过期;只有 `COUTMSGS` 写入确认后才标记 `SENT` 并关联出站记录;写信箱成功但本地未确认的情况需要补偿与去重,不能无条件重新发送。
- 应答优先按已确认的回显字段精确匹配;降级匹配的跨代误配风险必须明确接受并审计(`C-23`)。
- 时间比较统一时区与单位,并需定义时钟偏斜容忍;容忍判据未定(`Q5`),在定义前不得把降级匹配描述为精确关联。
- 参考应答写入自有 `REF_MASTER`(尚未建表),日计划应答走快照流程;请求完成必须在相应数据处理成功之后,超时和迟到应答不能修改已关闭请求对应的状态。
- 出站承诺只到落信(`C-24`、CLM-8);主为 EROR 回报义务见 `C-25`
## 8. 事件投递
### 8.1 普通事件(`KAFKA:msg`
`Dispatcher``MSG_EVENT` 取待发事件。**保序边界是 `FLID`**(与分区键一致):同一 `FLID` 内按 `EVENT_ID` 保序,队头失败即暂停该 `FLID`;不同 `FLID` 之间不互相阻塞,也不承诺跨 `FLID` 顺序。消费者按 `(FLID, STATE_VERSION, UPDATED_AT)` 防旧覆盖新。
本批领取的 `EVENT_ID` 集合在**读取时刻冻结**:发送与标记只作用于这批事件,期间新提交的事件留待下一轮,不参与本批,也不被本批的「完成」带走。
`EVENT_ID` 由全局串行分配产生:事件生产者在事务内写 outbox,主泵单线程,历史清理与主泵互斥(`INV-18`),因此**分配顺序 = 提交顺序**,不存在「已提交的较大 ID 先于未提交的较小 ID 被投递」。
发送确认后才标记 `SENT`,失败记录次数并按退避推后,达到上限转 `DEAD`(记录保留作 DLQ)。所有外部调用需要有界超时,避免阻塞投递线程。
投递是至少一次:Broker 或其他目标已接受但本地未标记成功时可能重发;目标端接受不等于业务消费者已消费。Kafka 生产约束沿用 architecture 的 D3,生产者幂等不替代应用层事件去重。
### 8.2 `schd` 聚合
`KAFKA:schd` 只提供最新状态通知,不保留每次中间变化,因此 outbox 按 `FLID` 单行 upsert:同一 `FLID` 只保留最新 `STATE_VERSION` 的事件与投递状态。两条写规则:
`KAFKA:schd` 行的 `EVENT_ID` 不是跨代次稳定的事件句柄:每次接受更新都从全局序列取得新值并替换原主键,用作条件确认的写代次。重放和人工处置只能针对当前 `(TARGET, PARTITION_KEY, EVENT_ID)`;旧代次被替换后不再能按旧 ID 寻址。升级时若已有重复行,按 `STATE_VERSION DESC, EVENT_ID DESC` 保留一行,使迁移与运行时只进不退规则一致。
- **只进不退**:仅当新事件的 `STATE_VERSION ≥` 行内现有版本才覆盖,防止迟到的旧事件把新状态压回去。该合并规则以 `C-21``FLID` 在保留期内不复用)为前提。
- **条件标记**:发送成功后按**读取时刻的版本**做条件标记(`WHERE STATE_VERSION = <本批版本>`);该行若期间已被更新的版本覆盖,则不标记,留待下一轮重发。
发送时:
1. 到期领取批次:按 `FLID` 取未发送行,批次大小受 `PARAM:msgx.schd.flush-limit` 约束;
2. 逐条发送:UPSERT 发送该 `FLID` 的最新整态(key = `FLID`);TOMBSTONE 发送 null 值删除通知;
3. 成功后按上条规则标记完成并推进 `lastFlush`;失败按退避推后,达到上限转 `DEAD`
默认聚合周期与批上限见 reference。`KAFKA:msg``KAFKA:schd` 之间不承诺顺序。`schd` 行与 `msg` 行共用 `MSG_EVENT`,靠 `TARGET` 区分。
### 8.3 清理
`SENT` 的事件行按 `PARAM:msgx.pipeline.event-retention` 由维护作业清理。`KAFKA:msg``DEAD` 行保留作 DLQ,人工处置后再清理;`KAFKA:schd``DEAD` 行只保留到同一 `FLID` 出现新的、可接受的状态代次,新代次会把单行投影重置为 `PENDING` 并清空旧错误。该取舍服从 schd 只保存最新状态的契约,因此被替换的 schd DEAD 代次不再由 `MSG_EVENT` 提供持久审计句柄。
## 9. 失败恢复与维护作业
### 9.1 失败、重试与重放
`ProcFailure``FailureScheduler` 统一处理侧失败落账,投递侧按同一套次数与退避规则迁移事件。启动自检强制退避档位数与尝试上限匹配,让「表里有档但永不触发」的配置无法通过。失败必须在持有具体消息、事件或批次的位置记录,外层循环只做兜底日志和等待,不重复增加次数。线程中断应恢复中断标记并向上传递;不把 JVM `Error` 当普通业务失败捕获。
`ReplayService` 只允许 `CODEC_ERROR / UNSUPPORTED / INFRA / EXHAUSTED``FAILED / DEAD` 回到 `PENDING`,并重置尝试次数、下次重试时间与错误原因,**不重置 `IDENTITY_KEY`**(保留身份,避免重放时把自己判成重复消息)。它按错误类全局批量重放,尚无按记录预检、操作审计与管理入口(US-10)。重放与回填通过 `MessageLifecycleGate` 在同一实例内互斥;**旧消息进入终态后后续消息可能已执行,重新入队不等于恢复历史顺序**,重放前必须评估状态覆盖和版本保护(CLM-3)。
### 9.2 中断恢复
恢复的唯一依据是各存储中已持久化的记录,不依赖进程内存状态。在 PG 从备份恢复的场景下,已提交的终态与已发出的事件可能回退,后果是重复投递与重复回填(按至少一次与幂等接受),但不得据此重放业务;留痕(`SCHD_SNAP_LOG`)在业务事务外追加,崩溃会丢该条留痕,不影响状态。
| 中断位置 | 重启后的判定 | 恢复动作 |
|---|---|---|
| 已落信、未入队 | 信箱行位于应扫描的 ID 范围且 PG 无记录(不以处理标记为判据) | 重扫补建入队记录 |
| 水位卡在空洞 | `HOLE_SINCE` 有值且未超过 `PARAM:msgx.pipeline.max-commit-delay` | 等待;超期后放行空洞本身并继续推进 |
| 事务执行中 | PG 无该消息终态 | 事务整体回滚,按 `PENDING` 重新处理 |
| 事务已提交、标记未写 | 终态行仍持有回填意图 | 仅补写标记;业务处理结果保持不变 |
| 标记写入中途 | 标记仍为空 | 重新写入;重复写入同一值无副作用 |
| 兼容入口已入队、水位未追平 | PG 已有该 ID 的记录 | 轮询读到该行时主键幂等,水位照常推进 |
| 回填时信箱行已不存在 | 写入 0 行且信箱行不存在 | 立即放弃自动重试(`MISSING_ROW`)并告警;放弃不等于标记已确认,仍需人工对账 |
| `RECEIVED_AT` 为 NULL | 超期分支以本地 `ENQUEUED_AT` 判定,不受库方时钟与 NULL 影响 | 按 `R` 超期强补写;未超期则按退避重试 |
| 投递目标已接受、`SENT` 未置 | 事件仍 `PENDING` | 允许重发,消费方按事件身份去重 |
| PG 从备份恢复 | 终态与事件回退到备份点 | 按至少一次接受重复;不重放业务、不据此改写航班 |
### 9.3 维护作业与归档
`JobRunner` 用独立 daemon 线程按周期触发回填扫描、航班历史清理与留痕清理;作业不参与消息 FIFO,也不使到期消息饥饿。`INV-18` 要求历史清理的删除与主泵处理互斥。
- **航班历史清理**:按 reference 的历史判据选候选(含 `DELETED`),先成功归档再删除;未经 FDEL 的生命周期清除需先补发删除事件。语义与红线见 flight-state.md。
- **留痕清理**`SCHD_SNAP_LOG` 按保留期与 `(SCOPE_END, RECV_AT)` 删除,不依赖历史存储开关。
- **处理终态归档**`PROC_STATE` 终态记录归档至 `PROC_STATE_HST`(尚未建表 `[G-PROC-HST]`)。归档范围只含终态;归档后仍须保留业务去重能力。
- **出站事件清理**:见投递清理规则。
共享信箱保留策略由库方管理(contracts「保留与清除」)。历史写入与删除事件入队之间仍需恢复方案;顺序调用不构成原子提交。
## 10. 容量假设与设计取舍
本设计按以下量级选型(`[待确认]`,未实测;参数默认值的依据列见 reference,可声明性见 `CLM-10`):
- 单机场、单活动实例、单维护者;入站日消息量千级到万级;单条报文量级 ≤ 10⁴ 字节。
- 处理延迟秒级可接受;航班可见性延迟不劣于现役(轮询间隔 ≤ 1 秒 + 聚合周期秒级)。
- 因此:不引入多实例并行、分布式锁、分区表;用单行锁与单线程换确定性。
容量假设变化时,需要重新评估的项:批次大小与轮询间隔、聚合周期与批上限、指标取数口径(`backlog()``PROC_STATE` 聚合,`PROC_STATE_HST` 未交付前成本随历史增长)、以及 `MSG_EVENT` 保留期。