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 补条件回滚语义)。
This commit is contained in:
windyboy
2026-09-13 12:52:21 +08:00
parent 729e102b8e
commit 30dc8adf00
3 changed files with 376 additions and 9 deletions
+6 -6
View File
@@ -23,8 +23,8 @@
| 记录 | 用途 | 关键约束 | | 记录 | 用途 | 关键约束 |
|---|---|---| |---|---|---|
| `PROC_STATE` | 入站消息的处理状态、身份、尝试次数、错误原因与回填事实 | `MSG_ID = CMINMSGS_ID` 主键防重复入队;`IDENTITY_KEY` 唯一约束防业务重复;按最小未完成 `MSG_ID` 取队头;`BACKFILL_NEXT_AT` 非空 = 还欠一次回填,`BACKFILL_AT` 非空 = 标记已确认,`BACKFILL_ABANDONED_AT/REASON` 非空 = 已停止自动重试(**不等于**标记已确认);`RECEIVED_AT` 复制自信箱接收时间、**可能为 NULL**、仅用于对账与展示;`ENQUEUED_AT` 是本地入队时间、非空、是超期判据的唯一依据。 | | `PROC_STATE` | 入站消息的处理状态、身份、尝试次数、错误原因与回填事实 | `MSG_ID = CMINMSGS_ID` 主键防重复入队;`IDENTITY_KEY` 唯一约束防业务重复;按最小未完成 `MSG_ID` 取队头;`BACKFILL_NEXT_AT` 非空 = 还欠一次回填,`BACKFILL_AT` 非空 = 标记已确认,`BACKFILL_ABANDONED_AT/REASON` 非空 = 已停止自动重试(**不等于**标记已确认);`RECEIVED_AT` 复制自信箱接收时间、**可能为 NULL**、仅用于对账与展示;`ENQUEUED_AT` 是本地入队时间、非空、是超期判据的唯一依据;归档后的去重影子行置 `STATE='ARCHIVED'`、只保留 `IDENTITY_KEY``MSG_ID`,不占队头、不触发回填、不参与积压聚合(`[G-PROC-HST]`。 |
| `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`。 | | `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``SENT_AT` 在投递确认的同一条 UPDATE 内写入,是保留期判定的唯一基准(`[G-EVENT-RETENTION]`。 |
| `REQ_TRACK` | 上游请求及应答关联 | 状态 `PENDING / SENT / DONE / EXPIRED`;保存请求类型、覆盖运营日、发送方、出站信箱 ID 与发送/完成时间;**「同类只允许一个开放请求」的唯一键 = `(请求类型, 覆盖运营日, 发送方)`,且仅对开放状态生效**。登记、超时与应答匹配尚未实现 `[G-REQ-TRACK]`。 | | `REQ_TRACK` | 上游请求及应答关联 | 状态 `PENDING / SENT / DONE / EXPIRED`;保存请求类型、覆盖运营日、发送方、出站信箱 ID 与发送/完成时间;**「同类只允许一个开放请求」的唯一键 = `(请求类型, 覆盖运营日, 发送方)`,且仅对开放状态生效**。登记、超时与应答匹配尚未实现 `[G-REQ-TRACK]`。 |
| `REF_MASTER` | 静态参考数据(目标表) | `(RTYPE, RKEY)` 唯一;尚未建表,客户端与刷新流程见 user-stories US-13/US-14US-14 两类映射的存储落点未定。 | | `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。 | | `FLIGHT_SCHD` | 航班标量及单值异常字段 | `FLID` 主键;`OPERATION_DAY` 一经确定不可变;版本与最近消息 ID 用于追踪。现有变长集合存于 8 张资源明细表与 `FLIGHT_ROUTE_POINT``SRVT`/`VIPF` 专用明细尚未实现 `[G-SRVT-VIPF]`,规则见 flight-state.md。 |
@@ -57,7 +57,7 @@
→ DEAD(重试耗尽,记录保留作 DLQ) → DEAD(重试耗尽,记录保留作 DLQ)
``` ```
`SUCCEEDED / SKIPPED / DEAD` 是处理终态,不再阻塞后续消息;`FAILED` 不是终态,仍占据队头。`DEAD` 表示需要处置,不等于业务成功。 `SUCCEEDED / SKIPPED / DEAD` 是处理终态,不再阻塞后续消息;`FAILED` 不是终态,仍占据队头。`DEAD` 表示需要处置,不等于业务成功。`ARCHIVED` 不是处理态:它是终态行归档后留在主表的去重影子行,不占队头、不触发回填、不参与积压聚合(见「生命周期与清除」)。
| 错误类别 | 处理方式 | | 错误类别 | 处理方式 |
|---|---| |---|---|
@@ -344,12 +344,12 @@ PENDING → SENT → DONE
**处理终态归档**`PROC_STATE``PROC_STATE_HST` **处理终态归档**`PROC_STATE``PROC_STATE_HST`
候选 = 终态 **且** 回填已了结 **且** 终局后经过归档阈值。两处不可省: 候选 = 终态 **且** 回填已了结 **且** 终局后经过归档阈值(基准是 `UPDATED_AT`:终态与了结都推进它,了结后不再更新)。两处不可省:
- **回填已了结** = `BACKFILL_AT` 非空,或已放弃 **且经人工对账**。放弃行不写标记,是 `C-8` 的清除授权证据,未对账前不得归档。 - **回填已了结** = `BACKFILL_AT` 非空,或已放弃 **且经人工对账**。放弃行不写标记,是 `C-8` 的清除授权证据,未对账前不得归档。
- **删除前复查** = `DEAD` 可被人工重放改回 `PENDING`,故必须在 `PIPELINE_LOCK` 内复查 `STATE` 未变再删,与航班历史清理同形(`INV-18` - **写入前复查** = `DEAD` 可被人工重放改回 `PENDING`。人工重放走 `MessageLifecycleGate`、不取 `PIPELINE_LOCK`,因此该锁不构成复查依据:归档在同一事务内先 `INSERT INTO PROC_STATE_HST … SELECT`,再按候选时的 `STATE` 条件写主表;影响 0 行即整体回滚、该行跳过。重放先一步改回 `PENDING` 时谓词不匹配,天然互斥。批量归档不得持 `PIPELINE_LOCK`——那会阻塞主泵 FIFO,与「作业不使到期消息饥饿」冲突
归档范围只含终态;归档后仍须保留业务去重能力(`INV-9`)——去重记忆期长于工作状态在线期,两者同行时只能二选一 归档范围只含终态;归档后仍须保留业务去重能力(`INV-9`)——去重记忆期长于工作状态在线期,实现取「主表保留去重影子行」:主行置 `STATE='ARCHIVED'`、只留 `IDENTITY_KEY``MSG_ID``IDENTITY_KEY` 唯一约束留在主表不动。队头推进、`backlog()` 与回填扫描的谓词显式排除 `ARCHIVED`,不靠状态包含列表隐式过滤
**时间常数排序**(取值见 contracts.md **时间常数排序**(取值见 contracts.md
+367
View File
@@ -0,0 +1,367 @@
# 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` 从表中移除
+3 -3
View File
@@ -216,9 +216,9 @@
**验收标准** **验收标准**
1. 终态记录在入队后经过的时间(`ENQUEUED_AT`)达到 `PARAM:msgx.proc-state.archive-after` 时列为归档候选;PENDING/FAILED 禁止归档。 1. 终态记录在**了结后**经过的时间(`UPDATED_AT`)达到 `PARAM:msgx.proc-state.archive-after` 时列为归档候选;`PENDING`/`FAILED` 禁止归档,回填未了结的终态行不进入候选
2. 归档到自有 PG `PROC_STATE_HST`关联 `MSG_EVENT` 的历史目标保留规则一并设计。仍有未完成投递、回填或恢复依赖时,不移除所需记录 2. 归档到自有 PG `PROC_STATE_HST`,主表保留 `STATE='ARCHIVED'` 的去重影子行(仅 `IDENTITY_KEY``MSG_ID`),使归档后同业务身份再次到达仍可去重`MSG_EVENT` 的历史目标保留规则`G-EVENT-RETENTION` 独立处理,不构成归档判据
3. 迁移与删除在自有库事务内完成,重复执行幂等失败保留源记录并报告计数。归档后同信箱 ID/业务身份再次到达,仍能按约定去重。 3. 归档写入与主行置 `ARCHIVED` 在同一自有库事务内完成,按候选时的状态条件复查,影响 0 行即整体回滚;重复执行幂等失败保留源记录并报告计数。
4. 本系统不写共享 MySQL `CMINMSGS_HST`、不清理外部信箱;由库方按 `Q9` 执行的清除与历史归档见 [contracts.md](contracts.md)「保留与清除」。原文可用性与重放保留期由 Q6/Q7 关联确认。 4. 本系统不写共享 MySQL `CMINMSGS_HST`、不清理外部信箱;由库方按 `Q9` 执行的清除与历史归档见 [contracts.md](contracts.md)「保留与清除」。原文可用性与重放保留期由 Q6/Q7 关联确认。
**当前基础与落点**`PROC_STATE_HST` 未建表,也没有归档处理记录的作业;先确定去重记录保留与关联策略,再补迁移与归档中断测试。航班历史清理(`HistorySweepJob`,属 US-15 红线范围)与本文档处理记录归档不是同一件事,不能混为一谈。 **当前基础与落点**`PROC_STATE_HST` 未建表,也没有归档处理记录的作业;先确定去重记录保留与关联策略,再补迁移与归档中断测试。航班历史清理(`HistorySweepJob`,属 US-15 红线范围)与本文档处理记录归档不是同一件事,不能混为一谈。