Files
msgexchange-v2/docs/spec-boundary-closure.md
T
windyboy 30dc8adf00 docs(spec): 领域边界闭合 spec 与配套文档修正
新增 docs/spec-boundary-closure.md:自包含 spec,内联展开所有
PRE/INV/CLM/D/C/US/PARAM/Q/错误分类/表结构/时间常数序引用,
覆盖 G-KAFKA-D3 / G-IGNORE / G-EVENT-RETENTION / G-BACKFILL-BACKOFF
/ G-PROC-HST 五个缺口的实现决策、验收与测试决策。

同步修正 design.md(归档段落改条件 DELETE、ARCHIVED 影子行、
SENT_AT 描述)与 user-stories.md US-11(AC1 改 UPDATED_AT、
AC2 删投递判据、AC3 补条件回滚语义)。
2026-09-13 12:52:21 +08:00

368 lines
26 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.
# Spec: 领域边界闭合(当前迭代)
## Problem Statement
msgexchange-v2 的领域模型在文档中定义了完整的边界,但部分边界未在代码中落地。经过审查,REQ_TRACK 全套机制、MAFL 级联投影、归档保留期三连、REPLAY-CHANNEL 属于当前迭代的过度工程,推至 phase B。剩余必要缺口若不闭合,系统面临:
- Kafka 有序性违反(`G-KAFKA-D3`
- 运维无法区分已知忽略与真实异常(`G-IGNORE`
- outbox 无限增长(`G-EVENT-RETENTION`
- 回填退避不可调(`G-BACKFILL-BACKOFF`
- `PROC_STATE` 无生命周期终点(`G-PROC-HST`
## Solution
闭合 5 个必要的领域边界缺口,同步推进 3 个阻断性 Q 项确认,将过度工程化部分正式标记为 phase B deferred。
**当前迭代交付:**
1. `G-KAFKA-D3`:把 `PARAM:kafka.producers.default.max-in-flight-requests-per-connection` 收敛到 `D3` 要求值,满足 architecture D3 有序性约束
2. `G-IGNORE`codec 层识别 LDM/REGN/RSTA/EROR,分派为 `SKIPPED` 终态,走非业务型终态路径
3. `G-EVENT-RETENTION`jobs 模块新增 MSG_EVENT 已发送行清理作业,按 `PARAM:msgx.pipeline.event-retention` 定期清除
4. `G-BACKFILL-BACKOFF`:将 BackfillService 硬编码退避提取为 `PARAM:msgx.pipeline.backfill-backoff-ms` / `-cap-ms`
5. `G-PROC-HST`:建 `PROC_STATE_HST` 归档表,jobs 模块新增终态行归档作业
**同步推进确认:**
- Q2(ID 单调递增):G1 移除后水位线正确性的唯一保障
- Q7processing-mark 语义):回填契约基础
- Q9(purge 权限与方案):系统对共享 MySQL 的清除权利
**收窄:**
- `G-FLOP-IDEMPOTENT`:从「29 类全矩阵」收窄为「按实际到达类型增量补齐」
---
## 约束锚点(内联展开)
以下约束全部引自 `docs/` 各锚点文件的稳定 ID,在此内联展开以便 spec 自包含。
### 前提(PRE-x,引自 invariants.md §1
| 编号 | 前提 | 与本 spec 的关系 |
|---|---|---|
| PRE-2 | ID 单调 + 可见时延上界(见 `C-1`/`C-2`) | G1 移除后水位线正确性依赖此前提 |
| PRE-3 | ID 空间不复位、不复用、不回退(见 `C-3` | 同上 |
| PRE-5 | 单活动实例运行(信箱读取不加锁、水位是单行覆盖写) | 作业与主泵共存的部署前提 |
| PRE-6 | 信箱与自有 PG 之间没有跨库事务 | 回填、归档都不能声称原子 |
| PRE-7 | 报文不可变(见 `C-4`) | 身份绑定的前提;忽略规则在解码后判定 |
### 不变量(INV-x,引自 invariants.md §2
**管道不变量:**
- **INV-1** 五个独立事实互不替代:落信 / 入队 / 处理完成 / 已回填 / 投递确认各有独立证据,前一个不蕴含后一个。
- **INV-3** 队头唯一:任一时刻只有一个可执行队头(最小未完成 `MSG_ID``PENDING``FAILED` 都占位);`FAILED` 未退避到期时后续消息不得越过。
- **INV-7** 处理标记单调:任何路径只把空标记写成已处理值,不回撤、不覆盖。
- **INV-8** 回填只针对终态(`PENDING` / `FAILED` 永不写标记);「还欠一次回填」的事实与终态由**同一条语句**落库,不存在第二处落账。
- **INV-9** 一信一行、一身份一记录:`PROC_STATE``MSG_ID` 唯一;同一业务身份至多绑定一条有效处理记录。
- **INV-10** 对外投递至少一次;端到端恰好一次不在交付范围。
- **INV-18** 航班表的写者集合是「主泵处理器」与「历史清理」;两者必须互斥(同一 `PIPELINE_LOCK`,或清理在同一事务内复查判据后再删除),不得出现清理删除与处理器更新同一 `FLID` 的竞态。
### 声明边界(CLM-x,引自 invariants.md §3
- **CLM-3** 重放不产生重复业务副作用——依赖 `INV-20``G-FLOP-IDEMPOTENT`;当前**不可声明**(29 类 FLOP 幂等矩阵未补全)
- **CLM-9** 处理标记延迟由调度周期决定(≤30 秒)——当前**不可声明**(批次积压、单行超时与历史作业都会延长实际延迟)
- **CLM-16**(即 contracts.md `C-16`)回填放弃清单在对应信箱边界被清除前必须保持可查
### 架构决策(D-x,引自 architecture.md §7
- **D3** Kafka 生产要求 `acks=all``enable.idempotence=true``max.in.flight=1`;不允许通过关闭幂等来满足生产接入。当前证据/缺口:参数默认值与 D3 不一致 `[G-KAFKA-D3]`
- **D4** 自有库终态记录只归档到 `PROC_STATE_HST`,不侵入共享库的表结构或保留策略。当前证据/缺口:目标表未建 `[G-PROC-HST]`
### 对外契约(C-x,引自 contracts.md
**保留与清除:**
- **C-5** 处理标记值集与写权限:本系统只写入库方认可的 legacy 值集内的「已处理」值(默认 `PROCESSED`),只写空标记、不回撤、不覆盖;内部原因记录在自有 PG,**不在信箱新增枚举**。`[待确认 Q7]`
- **C-6** 清除语义必须是「标记 + 保留期」:打标本身不触发清除,触发条件是「到达保留期 `R_keep`」且「边界内全部行已打标」。`[待确认 Q7][待确认 Q9]`
- **C-7** 保留期下界:`R_keep ≥ max(人工重放期限 + 人工处置期限, 审计期限, 回填重试上限)`。这是「重放窗口内原文仍在」的**唯一保证来源**。`[待确认 Q6][待确认 Q9]`
- **C-8** 清除前置条件:执行清除时,边界内**每行必须已有终局**——即「已持有处理标记」**或**「已登记在本系统的回填放弃清单中且经人工对账确认」。放弃行不写标记,未达终态的行顺延至处理完成后清除。`[待确认 Q7][待确认 Q9]`
- **C-16** 回填放弃清单在对应信箱边界被清除前必须保持可查:`C-8` 以本清单作为清除授权证据之一,该证据不得随处理记录的归档或清除而消失。`[我们单方承诺]`
**我们向库方的承诺:**
- **C-14** 不建表、不改表结构、不迁移 schema、不写共享历史表。`[我们单方承诺]`
### 用户故事验收标准(US-x,引自 user-stories.md
**US-04 明确忽略非业务报文:**
1. 解码 META 后、身份绑定及处理器分派前,大小写不敏感匹配 `TYPE-STYP``TYPE-*`;基线为 `LDM-* / REGN-* / RSTA-* / EROR-*`,不混用 `ERROR`
2. 命中后转 `SKIPPED`,记录 `ignored:<rule>` 和计数;不更新航班、不创建业务通知。
3. 通过 US-09 保存回填意图;命中、未命中、大小写和重扫均有测试。合法忽略报文不应因 `MsgKind` 尚不能表达它而先解码失败。
**US-11 归档自有库终态记录(已按裁定修正):**
1. 终态记录在**了结后**经过的时间(`UPDATED_AT`)达到 `PARAM:msgx.proc-state.archive-after` 时列为归档候选;`PENDING`/`FAILED` 禁止归档,回填未了结的终态行不进入候选。
2. 归档到自有 PG `PROC_STATE_HST`,主表保留 `STATE='ARCHIVED'` 的去重影子行(仅 `IDENTITY_KEY``MSG_ID`),使归档后同业务身份再次到达仍可去重;`MSG_EVENT` 的历史目标与保留规则由 `G-EVENT-RETENTION` 独立处理,不构成归档判据。
3. 归档写入与主行置 `ARCHIVED` 在同一自有库事务内完成,按候选时的状态条件复查,影响 0 行即整体回滚;重复执行幂等,失败保留源记录并报告计数。
4. 本系统不写共享 MySQL `CMINMSGS_HST`、不清理外部信箱;由库方按 `Q9` 执行的清除与历史归档见 contracts.md「保留与清除」。
### 参数(PARAM:x,引自 reference.md
| PARAM 键 | 当前默认 | 依据 | 与本 spec 的关系 |
|---|---|---|---|
| `kafka.producers.default.acks` | `all` | 契约(D3 | G-KAFKA-D3 不动此键 |
| `kafka.producers.default.enable-idempotence` | `true` | 契约(D3 | G-KAFKA-D3 不动此键 |
| `kafka.producers.default.max-in-flight-requests-per-connection` | `5` | **与 D3 不一致** | G-KAFKA-D3 收敛到 `1` |
| `msgx.pipeline.event-retention` | 目标参数(**未实现** | 假定 | G-EVENT-RETENTION 登记默认值(建议 7 天) |
| `msgx.proc-state.archive-after` | `1d` | 假定,**未实现** | G-PROC-HST 实现后登记实际默认值(建议范围 1~7 天) |
| `msgx.pipeline.backfill-backoff-ms` | 目标参数(**未实现** | 假定 | G-BACKFILL-BACKOFF 提取硬编码 30 秒起步 |
| `msgx.pipeline.backfill-backoff-cap-ms` | 目标参数(**未实现** | 假定 | G-BACKFILL-BACKOFF 提取硬编码 15 分钟封顶 |
| `msgx.pipeline.max-attempts` | `5` | 假定 | 处理与投递共用;达到即转 `DEAD(EXHAUSTED)` |
| `msgx.pipeline.overdue-backfill``R` | `30d` | 契约(`R ≤ R_keep` | 进入强补写窗口的阈值 |
### 错误分类(引自 reference.md §4
| 错误类别 | 触发 | 处置 | 可重放 |
|---|---|---|---|
| `MALFORMED` | 报文非法、原文缺失或缺少该类型必需的业务载荷 | 立即 `DEAD` | 否 |
| `PROTOCOL` | 载荷存在但整包违反业务协议(运营日冲突、声明数不符) | 立即 `DEAD`,整包不落地 | 否 |
| `CODEC_ERROR` | 解码能力问题 | `FAILED` 退避 | 是 |
| `UNSUPPORTED` | 处理器或快照能力未实现 | `FAILED` 退避,受尝试上限约束 | 是 |
| `INFRA` | 基础设施或执行异常 | `FAILED` 退避 | 是 |
| `EXHAUSTED` | 尝试次数耗尽 | `DEAD``ERROR_CLASS` 被覆写为 `EXHAUSTED` | 是 |
### 表结构基线(引自 Flyway V1–V9 迁移链)
**PROC_STATE**(自有 PG,处理状态主表):
| 列 | 类型 | 约束 | 加入迁移 |
|---|---|---|---|
| `MSG_ID` | BIGINT | PK | V1 |
| `STATE` | VARCHAR(16) | NOT NULL | V1 |
| `IDENTITY_KEY` | VARCHAR(200) | UNIQUE (`uk_proc_identity`) | V1 |
| `ATTEMPTS` | INT | NOT NULL DEFAULT 0 | V1 |
| `NEXT_ATTEMPT_AT` | TIMESTAMPTZ(6) | | V1 |
| `ERROR_CLASS` | VARCHAR(20) | | V1 |
| `LAST_ERROR` | VARCHAR(1000) | | V1 |
| `UPDATED_AT` | TIMESTAMPTZ(6) | NOT NULL | V1 |
| `RECEIVED_AT` | TIMESTAMPTZ(6) | | V2 |
| `BACKFILL_AT` | TIMESTAMPTZ(6) | | V2 |
| `BACKFILL_NEXT_AT` | TIMESTAMPTZ(6) | | V2 |
| `BACKFILL_ATTEMPTS` | INT | NOT NULL DEFAULT 0 | V2 |
| `BACKFILL_ERROR` | VARCHAR(512) | | V2 |
| `BACKFILL_ABANDONED_AT` | TIMESTAMPTZ(6) | | V4 |
| `BACKFILL_ABANDONED_REASON` | VARCHAR(64) | | V4 |
| `ENQUEUED_AT` | TIMESTAMPTZ(6) | NOT NULL DEFAULT now() | V6 |
> V7 删除了 `PROCESSING_STARTED_AT`。当前终态值集:`PENDING / FAILED / SUCCEEDED / SKIPPED / DEAD`G-PROC-HST 新增 `ARCHIVED`。
**MSG_EVENT**(自有 PGoutbox):
| 列 | 类型 | 约束 | 加入迁移 |
|---|---|---|---|
| `EVENT_ID` | BIGSERIAL | PK | V1 |
| `TARGET` | VARCHAR(30) | NOT NULL | V1 |
| `PARTITION_KEY` | VARCHAR(32) | NOT NULL | V1 |
| `EVENT_TYPE` | VARCHAR(16) | NOT NULL DEFAULT 'UPSERT' | V1 |
| `STATE_VERSION` | BIGINT | NOT NULL | V1 |
| `PAYLOAD_JSON` | TEXT | NOT NULL | V1 |
| `STATE` | VARCHAR(16) | NOT NULL | V1 |
| `ATTEMPTS` | INT | NOT NULL DEFAULT 0 | V1 |
| `NEXT_ATTEMPT_AT` | TIMESTAMPTZ(6) | | V1 |
| `ERROR_CLASS` | VARCHAR(20) | | V1 |
| `LAST_ERROR` | VARCHAR(1000) | | V1 |
| `CREATED_AT` | TIMESTAMPTZ(6) | NOT NULL | V1 |
> **无 `UPDATED_AT`、无 `SENT_AT`**。G-EVENT-RETENTION 需 V10 迁移新增 `SENT_AT`。
**PROC_STATE_HST**:尚未建表 `[G-PROC-HST]`
### 时间常数排序(引自 design.md「生命周期与清除」)
1. `R ≤ R_keep``C-7`
2. 去重记忆期 ≥ `R_keep``INV-9`)——否则「归档后重复」不成立
3. 回填放弃清单可见期 ≥ `R_keep``C-16`)——否则库方清除缺 `C-8` 依据
4. 归档阈值计的是**终局之后**的时间,不是入队之后:终态行未了结回填时不进入候选
### 待确认事项(Q-x,引自 contracts.md §C,仅列本 spec 涉及项)
| 编号 | 事项 | 当前假定 | 阻塞 |
|---|---|---|---|
| Q2 | 信箱 ID 单调、ID 分配→事务可见时延上界、ID 空间不复位 | 时延按 5 分钟 `max-commit-delay`(**缺少依据的占位值**) | 发现完整性声明、空洞老化阈值 |
| Q6 | 重放期限与人工处置期限的取值 | `R` = 30 天;重放/处置期限未定 | `R_keep` 取值 |
| Q7 | 处理标记值集与写权限、原文保留期 | 写入 `PROCESSED` | 回填值集、保留期下界 |
| Q8 | 逐类覆盖清单(积压摸底的类型分布依据) | — | 积压处置与逐类矩阵(**暂缓**) |
| Q9 | 清除执行方与 DDL 授权、方案 A/B 选型 | 首选方案 A | `R_keep` 与清除边界 |
| Q11 | 上游 `SEQN` 重置周期与业务身份的日期边界 | 不含日期边界 | 身份算法 |
| Q13 | 日计划缺失可选字段的删除语义 | 保留未携带字段(**暂缓**) | 快照合并 |
---
## User Stories
1. As a **系统运维**, I want Kafka producer `max-in-flight=1`, so that 同一分区内事件投递顺序与写入顺序一致,满足 architecture `D3`
2. As a **系统运维**, I want LDM/REGN/RSTA/EROR 消息被显式标记为 SKIPPED, so that 监控告警能区分「已知忽略类型」和「真正的异常消息」
3. As a **系统运维**, I want MSG_EVENT 已投递行按保留期自动清理, so that outbox 表不会无限增长导致查询退化
4. As a **系统运维**, I want 回填退避参数可通过配置调整, so that 不同负载下无需改代码即可优化回填节奏
5. As a **系统运维**, I want PROC_STATE 终态行归档到 PROC_STATE_HST 并在主表留下去重影子行, so that PROC_STATE 表有生命周期终点且不破坏业务去重(`INV-9`
6. As a **领域模型维护者**, I want FLOP 幂等规则按实际到达类型增量补齐, so that 不为未到达的类型做无用设计
---
## Implementation Decisions
### G-KAFKA-D3
-`PARAM:kafka.producers.default.max-in-flight-requests-per-connection` 从当前值 `5` 收敛到 `D3` 要求的 `1`
- 影响 `KAFKA:msg``KAFKA:schd` 两个投递目标
- 吞吐影响可接受:单分区有序是 `INV-10` 的隐含前提,`D3` 明确要求该键取有序值
- 配置落点:`application.yml``kafka.producers.default.max-in-flight-requests-per-connection`(现由 `MSGX_KAFKA_MAX_IN_FLIGHT` 覆盖);`.env.example` 注释中的简写改为全键名
- `README.md``max.in.flight=1` 简写统一为全键;保留「严禁非幂等降级」的契约口径
- 验收:以配置断言(或启动自检)钉住生效值满足 `D3`,并在 `G-KAFKA-D3` 缺口索引中更新验证证据映射
### G-IGNORE
- codec 层按 `TYPE`+`STYP` 组合识别 `LDM`/`REGN`/`RSTA`/`EROR`(大小写不敏感,基线见 `US-04`
- **不新增 `MsgKind` 变体**`MsgKind` 的契约是 `Schd(RESP/DNLD/ADFT)`/`Flop`/`Fdel`/`Unsupported`;忽略是解码后路由边界的策略,与 `MsgKind` 正交
- 判定位置:解码成功之后、身份绑定之前、`MsgKind` 分派之前(`US-04`);忽略报文不写 `IDENTITY_KEY`
- 分派路径:不走航班决策逻辑,直接走非业务型终态(`INV-8`
- 单语句写 `PROC_STATE(SKIPPED)` + 回填意图同语句落库
- 不取 `PIPELINE_LOCK`、不触航班表/事件
-`UNSUPPORTED` 区分:忽略类不进入重试、不占队头退避
- 命中优先于 `MsgKind.Unsupported` 分支:不得再落到 `FAILED(UNSUPPORTED)` 的退避路径
- 规则未实现前的回退行为:忽略类报文按 `UNSUPPORTED` 可重试处理(现状),**不得按 `MALFORMED` 判死**
- `design.md` 明确:「类型未覆盖不等于报文非法:忽略类报文在规则实现前不按 `MALFORMED` 处理」
- 计数:`US-04` 要求计数;若新增指标(建议 `msgx.pipeline.codec.ignored.total`),须同 PR 登记 `reference.md` 指标表,不新增未登记指标
### G-EVENT-RETENTION
- `MSG_EVENT` 新增 `SENT_AT` 列(可空,`NULL` = 未投递):V10 Flyway 迁移,只增不改;**写入必须在条件确认 `SENT` 的同一 UPDATE 内完成**(原子性保证,不存在先写状态再补时间的两步路径);`KAFKA:schd` 代次重置为 `PENDING` 时清空;已有行 `SENT_AT``NULL`,视为未投递,不会被清理谓词命中
- jobs 模块新增清理作业,扫描 `MSG_EVENT``STATE='SENT'``SENT_AT IS NOT NULL``SENT_AT < now() PARAM:msgx.pipeline.event-retention` 的行,批量删除(`ORDER BY EVENT_ID ASC` + 批次上限,幂等可重跑)
- 删除必须带 `STATE='SENT'` 条件:`KAFKA:schd``FLID` 出现新代次时该行被重置为 `PENDING`,两种执行顺序下都不会被误删
- 清理先删 → upsert 新建行(新代次 `PENDING`,正确)
- upsert 先重置为 `PENDING` → 同一 DELETE 因谓词不匹配而跳过(正确)
- `KAFKA:msg``DEAD` 行保留作 DLQ,人工处置后再清理
- `KAFKA:schd``DEAD` 行在同 FLID 出现新的可接受状态代次时被重置
- 作业不触航班表,**不需要 `PIPELINE_LOCK`**`INV-18` 约束的是航班表写者互斥(「主泵处理器」与「历史清理」),`MSG_EVENT` 不是航班表
- 默认保留期在 reference.md 登记(建议起点 7 天,需生产数据校准)
### G-BACKFILL-BACKOFF
- 从 BackfillService 提取两个配置参数(reference.md 参数注册表已登记为目标参数):
- `PARAM:msgx.pipeline.backfill-backoff-ms`(当前硬编码 30 秒起步)
- `PARAM:msgx.pipeline.backfill-backoff-cap-ms`(当前硬编码 15 分钟封顶)
- 不改变现有退避逻辑,仅将代码常量替换为配置注入
- 实现后更新 reference.md 状态从「目标参数(未实现)」为实际默认值
### G-PROC-HST
- 新建 migration`PROC_STATE_HST` 表,结构与 `PROC_STATE` 相同,增加 `ARCHIVED_AT` 时间戳
- 归档候选条件:
- 终态(`STATE IN (SUCCEEDED, SKIPPED, DEAD)`
- 回填已了结:`BACKFILL_AT` 非空,或已放弃且经人工对账(放弃行不写标记,是 `C-8` 清除授权证据,未对账前不得归档)
- 终局后经过 `PARAM:msgx.proc-state.archive-after` 阈值;基准是 `UPDATED_AT`(终态与了结都推进它,了结后不再更新),**不是** `ENQUEUED_AT`
- 归档操作**不取 `PIPELINE_LOCK`**:人工重放走 `MessageLifecycleGate`、不取该锁,锁是假保护;批量归档持锁会阻塞主泵 FIFO。互斥由条件写入 + `MessageLifecycleGate` 提供:同一事务内先 `INSERT INTO PROC_STATE_HST … SELECT`,再按候选时的 `STATE` 条件写主表;影响 0 行即整体回滚、该行跳过(`DEAD` 已被重放改回 `PENDING` 时谓词不匹配)
- 主表**不删除**归档行,而是置 `STATE='ARCHIVED'`(新增状态值)并清空尝试/错误/回填/时间列,仅保留 `MSG_ID``IDENTITY_KEY``ARCHIVED_AT`
- **去重记忆约束**:归档后 `IDENTITY_KEY` 的业务去重能力必须保留(`INV-9`),去重记忆期 ≥ `R_keep`(时间常数排序)。实现取「主表保留去重影子行」:影子行即 `STATE='ARCHIVED'` 行,`IDENTITY_KEY` 唯一约束留在主表不动;**所有非归档谓词必须显式排除 `ARCHIVED`**——队头推进(`INV-3` 只计 `PENDING`/`FAILED`)、`backlog()` 聚合、回填扫描均不靠状态包含列表隐式过滤,而是显式 `STATE <> 'ARCHIVED'` 或等价的排除条件
### G-FLOP-IDEMPOTENT 收窄
- 分析生产实际到达的 FLOP 类型分布
- 仅对实际到达类型补齐幂等规则
- 文档记录:「未到达类型在首次到达时补齐」
- `INV-20` / `CLM-3` 在矩阵完整前仍标记为不可声明
---
## Testing Decisions
- **G-KAFKA-D3**:配置级变更,通过 application-test.yml 断言 producer 生效值满足 `D3`
- **G-IGNORE**
- codec 单元测试:给定 LDM/REGN/RSTA/EROR XML,断言分派为 SKIPPED 路径(不走 FlightStateEngine
- processing 集成测试:给定忽略类型消息,断言 PROC_STATE 写入 SKIPPED、不触航班表/事件、回填意图同语句落库
- 断言 `INV-8`:非业务型终态不触碰航班表 / `MSG_EVENT`,只写 `PROC_STATE`,且终态与回填意图同语句生效
- 断言 `INV-7`:回填四分支对 `ignored:<rule>` 行成立(尤其 `MISSING_ROW` 立即放弃并告警)
- 断言 `INV-3`:忽略报文到达终态后不占队头、不阻塞后续消息
- 不回退既有 `duplicate-of` 行为
- **G-EVENT-RETENTION**
- 使用 TestClock 推进时间,断言超过保留期的 `SENT_AT` 行被清除
- 断言未超期的 SENT 行、`SENT_AT IS NULL` 的行保留
- 断言非 SENT 状态行(PENDING/DEAD)不受影响
- 断言 `KAFKA:msg` DEAD 行不被清理(DLQ 保留)
- 断言 `KAFKA:schd` 新代次重置出的 `PENDING` 行在同一轮清理中不被删(含两种执行顺序)
- 断言清理幂等可重跑;批次执行不改变 `KAFKA:msg` 的投递顺序与队头(`INV-3` / `INV-10`
- **G-BACKFILL-BACKOFF**:断言配置参数注入生效,退避行为与硬编码时一致
- **G-PROC-HST**
- 使用 TestClock + 内存适配器
- 断言归档候选条件:终态 + 回填了结 + `UPDATED_AT` 阈值过期;未了结与未到阈值均不进入候选
- 断言放弃行未对账前不被归档
- 断言条件写入复查:DEAD 行被重放为 `PENDING` 后,归档事务整体回滚、该行跳过
- 断言归档原子性:HST 有对应行,且主表该行置 `ARCHIVED`
- 断言 `DELETE` 影响 0 行时整体回滚,源记录保留并报告计数
- 断言去重记忆:归档后同身份新消息仍被识别为 `duplicate-of:<msgId>`
- 断言 `ARCHIVED` 行不占队头(`INV-3`)、不参与 `backlog()` 聚合、不被回填扫描选中
- 迁移与删除用 `PgTestSupport` / `FlywayMigrationTest` 本地验证
- 所有测试使用 `TestClocks.kt` 与内存适配器,禁止 Thread.sleep 和真实外部基础设施
- 涉及排序/重试/幂等/投递必须补 `invariants.md` 验证映射回归
---
## Out of Scope
以下内容经 grill 审查后判定为当前迭代过度工程化,推至 phase B:
| 项 | 缺口 ID | 理由 |
|---|---|---|
| REQ_TRACK 运行时协调器 | `G-REQ-TRACK` | 当前迭代无 outbound request 用户故事(US-08 未进入迭代) |
| RESP 应答守卫 | `G-RESP-GUARD` | 依赖 REQ_TRACK,同上 |
| REQ_TRACK 开放态唯一索引运行时 | `G-REQ-OPEN-UNIQUE` | 索引已存在(V8 migration),运行时依赖 REQ_TRACK 协调器 |
| MAFL 派生投影实现 | `G-MAFL` | 无下游消费者读取主航班投影;`INV-21`/`INV-22` 保留为设计约束 |
| 原始报文留存通道 | `G-REPLAY-CHANNEL` | replay 能力本身未建,mark-means-purge 语义未确认(`Q9` |
| 归档保留期 | `G-HST-RETENTION` | PROC_STATE_HST 尚未建成,保留期在建成时一并定义 |
| 航班历史存储保留期 | `G-FLIGHT-HIST-RETENTION` | 历史存储未接入 |
| REQ_TRACK 关闭态保留期 | `G-REQ-TRACK-RETENTION` | REQ_TRACK 关闭行不存在 |
| FLOP 29 类全矩阵 | `G-FLOP-IDEMPOTENT` | 收窄为按到达类型增量补齐 |
| Compat HTTP 完整 ResponseDto | `G-COMPAT-HTTP` | 等 Q3 确认 |
| 非阻断 Q 项 | Q4, Q6, Q8, Q10, Q12, Q13, Q14, Q15, Q16 | 不阻塞当前核心路径;Q8/Q13 已标暂缓 |
---
## Further Notes
### 脆弱边界风险
G1 移除后(commit `b873855`),水位线正确性完全依赖 `PRE-2`/`PRE-3`(库方承诺 ID 单调递增)。Q2 未确认 = 系统在此假设上裸奔。建议将 Q2 确认作为当前迭代的阻断性前置条件。
### 时间常数序
design.md「生命周期与清除」的时间常数排序定义的 `R ≤ R_keep ≤ 去重记忆 ≥ 放弃清单可见期` 约束在实现 G-EVENT-RETENTION 和 G-PROC-HST 时必须遵守:
1. `R ≤ R_keep``C-7`
2. 去重记忆期 ≥ `R_keep``INV-9`
3. 回填放弃清单可见期 ≥ `R_keep``C-16`
4. 归档阈值计的是终局之后的时间,不是入队之后
### C-16 回填放弃清单
`C-16` 要求放弃清单在信箱清除前保持可查。G-PROC-HST 归档作业必须确保:已放弃行的记录不会因归档而消失——放弃行未对账前不得归档(已在 Testing Decisions 中断言)。
### 通用约束
- 依据:`docs/` 为唯一设计依据;`docs/legacy/` 仅用于 `Q` 项对拍。
- 参数:新增或改名 `msgx.*` / 基础设施键,同 PR 登记 `reference.md`;本文只引 `PARAM:` 键。
- 存储:共享 MySQL 零 Schema 变更(`C-14`);Flyway 只动自有 PG,且只增不改(改列一律新迁移)。
- 测试:用 `TestClocks.kt` 与内存适配器;涉及排序/重试/幂等/投递必须补 `invariants.md` 验证映射回归;禁 `Thread.sleep`、禁真实外部基础设施。真实 PG 用例走 `PgTestSupport` / `FlywayMigrationTest`
- 交付:Conventional Commits + 小写 Plane scope,一单只关一个 work item。
### 文档修正(实施前完成)
以下文档段落与已裁定决策不一致,须在对应实施 PR 合入前修正:
- **design.md**「处理终态归档」段落:把「必须在 `PIPELINE_LOCK` 内复查 `STATE` 未变再删」改为条件 DELETE 语义——同一事务内 `INSERT…SELECT` + 按候选时 `STATE` 条件 `DELETE`,0 行即回滚;互斥由条件写入提供,不取锁
- **user-stories.md `US-11`**
- AC1:阈值基准从 `ENQUEUED_AT` 改为 `UPDATED_AT`(与 `design.md` 时间常数排序一致,`UPDATED_AT` 是「终局后且已了结」的保守时刻)
- AC2:删除「未完成投递」判据(`INV-1` 明确投递只依赖 `MSG_EVENT``PROC_STATE` 不承担投递证据;`MSG_EVENT` 清理归 `G-EVENT-RETENTION` 独立管)
### 文档同步(实施后完成)
实现完成后需更新:
- reference.md 参数注册表:`PARAM:msgx.pipeline.backfill-backoff-ms` / `-cap-ms` / `PARAM:msgx.pipeline.event-retention` 从「目标参数(未实现)」改为实际默认值
- reference.md 参数注册表:`PARAM:kafka.producers.default.max-in-flight-requests-per-connection` 的「依据」列从「与 D3 不一致」改为契约一致
- reference.md 参数注册表:`PARAM:msgx.proc-state.archive-after` 从「未实现」改为实际默认值
- invariants.md 缺口索引:闭合的 `G-x` 从表中移除