Files
msgexchange-v2/docs/flight-state-design-v2.md
T

185 lines
14 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.
# 运营航班状态设计
状态:PostgreSQL 实现基线,2026-09-08 核对。未完成项在各节明确标注;文件名沿用 v2,兼容历史引用。
配套文档:完成度证据见 [ACM2-29 核查与简化计划](acm2-29-audit-and-simplification.md),字段级行为见 [语义矩阵](flight-state-semantics.md),决策理由见 [决策摘要](decision-flight-state.md)。
## 1. 系统做什么
系统从共享 MySQL 信箱接收 SIS 报文,把报文合并成本系统的"航班当前状态",再把变化以事件发出。
三条底线:
- **一套权威状态。** 读写都指向本地数据库;共享 MySQL 和 Kafka 是提交之后的外部边界,失败各自重试,不会让已提交的状态回滚或重做。
- **一个活动主泵。** 生产同时只有一个实例在处理;事务行锁只保护本地提交,不是多实例协调机制。
- **一条提交事务。** 状态、outbox、处理终态在同一个本地事务里落库,要么全成功要么全回滚。
处理顺序严格按信箱 FIFO,按业务身份去重,失败按退避重试。
数据库:现场提供库时目标是 Oracle 11g;当前实际接通的只有 PostgreSQL,本文描述 PG 实现。
## 2. 存储:一张主表加九张明细表
### 为什么不用固定槽位
航班资源是变长集合:登机门通常一两个、值机柜台一到三个、转盘一个,报文规范理论上限 99。ACM2-29 曾定过"全标量宽表、零子表"方案(GATE1/GATE2 式固定槽位),当天撤销——槽位会截断数据,而设计要求保留重复资源的全部属性、输入顺序和源序号。结论:标量进主表,集合进明细表。
### 表清单
| 表 | 作用 |
|---|---|
| `FLIGHT_SCHD` | 主表,一行一个 FLID:标量字段、异常前缀列、SRVT/VIPF/MAFL_TEXT、FDAY、STATE_VERSION、LAST_MESSAGE_ID、时间戳。 |
| 8 张明细表 | gate、checkin、belt、stand_plan、chute、delay、bridge_op、chock_op;主键 (FLID, ORDINAL)。 |
| `FLIGHT_ROUTE_POINT` | 第 9 张明细表,ROUT/ERUT 两类路线共用,ROUTE_KIND 区分。 |
| `SCHD_GEN` / `SCHD_GEN_FLID` | 日代版本、最后消息身份、当日成员集合;成员表带外键级联删除。 |
| `PIPELINE_LOCK` | 单写者行锁,每个处理事务先锁 FLIGHT_SCHD_WRITER 行。 |
| `BACKFILL_TODO` | 回填待办,见第 4 节。 |
| `FLIGHT_SCHD_DISPLAY` | PG 展示视图(前两个登机门/柜台、首个转盘/延误、资源数),只是投影,不是状态来源。 |
9 张明细表承载 10 类集合——路线表一张顶两类。
三个要知道的事实:
1. **明细表没有声明外键**,清理由仓储显式执行,不靠数据库级联;SOURCE_SEQ 可空且不唯一,只是记录——逐条定位更新的 Apply 协议因此不铺开(无生产调用方,定位键不可靠)。
2. **差删按日期圈定范围**:先选出目标 FDAY 的实际成员,再删这些成员的明细和主行;跨日迁移和 FDAY 为空的航班不动。(这里曾有 bug:先删明细再圈主行,导致保留航班的集合被误删;已修复并有真实 PG 回归。)
3. **演进纪律**:不为尚无消费者的查询加类型化时间列、摘要、额外载荷表或索引;时间先保留原始字符串,字段与长度以已应用迁移为准。
### 已知待修
路线表主键仍是 (FLID, ORDINAL):同一航班 ROUT 和 ERUT 各自从序号 1 写起,共存即冲突。计划用新迁移改成 (FLID, ROUTE_KIND, ORDINAL),见 ACM2-30。
## 3. 航班状态的两条来路:请求/下载与动态数据
状态只有两个来源,各成一条线:
- **请求/下载线(基准)**:建立某天航班的基准全量。本系统向 AODB 发日计划请求,AODB 回 SCHD 报文(一整天全部航班),快照整体落库;当天没再出现的航班被差删。
- **动态线(运行事件)**:跟踪运行中的持续变化——实际时间、登机门开关、值机柜台、行李转盘、延误、靠撤桥、轮挡等。FLOP 类事件规范有 29 类,当前已接 GTDT 一类;一条报文只改一个航班的一类资源,其余字段不动。
两条线共用同一个入口和同一套事务/失败规则:进事务后都是"锁内读态 → 合并 → 写库 → 事件入 outbox → 终态 → 提交 → 回填"。
```text
信箱队头 → 解码 → 绑定身份(去重)→ 按 Handler 路由
├─ SCHD-DNLD(已接通)────────→ 请求/下载线
├─ SCHD-RESP / ADFT(未接通)─→ 请求/下载线
└─ FLOP 事件(已接 GTDT)────→ 动态线
```
### 公共入口(所有消息都走)
1. **排队**:信箱消息和定时任务共用一个 FIFO 队列,主泵只处理队头;队头失败按退避等待,重试超限或队头滞留超过时限 → DEAD。
2. **解码**:报文非法 → DEAD 不重试;解码器缺陷类错误 → FAILED 退避重试。
3. **绑定身份**:幂等键 = 发送方|类型|子类型|序号,仅首次处理时绑定;已被其他消息占用 → SKIPPED(重复),自身照常回填共享信箱。
4. **路由**:按报文类型找 Handler;没有注册的 Handler → FAILED(可重放,不写终态)。
### 请求/下载线:基准全量怎么来
SIS 循环:本系统发日计划请求 → AODB 回 SCHD。SCHD 三个子类型载荷结构相同:
- **DNLD**:AODB 定时下发的时刻表下载(当前唯一接通的)。
- **RESP**:应答本系统的日计划请求。
- **ADFT**:临时/加班航班,单条航班记录。
请求登记在 REQ_TRACK(同类并发=1,新请求把旧请求强制置 EXPIRED),出站经 COUTMSGS outbox——发送与超时重发尚未接线。
收到下载后的落库步骤:
1. 解析整包并校验,单包上限 10000 个航班(超限 DEAD);真实解析未接通,测试靠注入 staging 数据。
2. 进事务(先锁 PIPELINE_LOCK 行):
- **重放短路**:该消息已提交过(日代 LAST_MESSAGE_ID 相同)→ 直接 SUCCEEDED,不加版本、不发事件。
- **算成员差**:新快照里消失的航班进差删名单。
- **合并写入**:锁内逐航班读当前态 → 合并 → 整集合替换,FDAY 置为快照日期。
- **差删**:删差删名单航班的明细和主行(规则见第 2 节)。
- **CAS 推进日代版本**:版本不符回滚 → FAILED(INFRA) 退避。
- **应答匹配**:有待答的 SCHD 请求则标记完成。
- 每个航班一条 KAFKA_SCHD 事件入 outboxPROC_STATE → SUCCEEDED;提交。
3. 提交后回填共享信箱(失败落 BACKFILL_TODO,见第 4 节);重放短路同样要回填。
现状:只有 DNLD 被路由进这条流程;RESP 和 ADFT 还没有 Handler,进来会记 FAILED(UNSUPPORTED)——"请求 → 应答 → 标记完成"的闭环待接通。参考数据请求(静态主数据 21 类)用同一张 REQ_TRACK,但应答落参考库、由 RequestCoordinator 处理,与航班状态无关。
### 动态线:运行事件怎么改状态(以 GTDT 为例)
1. **Handler 纯函数决策**:输入 = 受影响航班的当前态 + 报文,输出 = 字段变更和待发事件;这一步不算账、不落库。每类资源的语义在 Handler 里固定,如 GTDT 是登机门集合整体替换(GTNO=0 表示清除)。
2. 进事务(先锁 PIPELINE_LOCK 行):
- 每个受影响航班:锁内重读最新态 → 合并字段变更 → 整集合替换写入,保留已有 FDAY。
- 事件(KAFKA_MSG 通知 + KAFKA_SCHD 状态推送)入 outboxPROC_STATE → SUCCEEDED;提交。
3. 提交后回填共享信箱(同上)。
### 状态变更的实现(两条线共用)
所有状态变更都走同一套加工链:**报文 → 命令 → 合并出新状态 → 写库**。
#### 数据的统一形态
内存里一个航班的状态就是一个模型(FlightNextState):一组标量键值 + 10 类集合(每类是一组条目,每条目一组键值)+ 版本/消息追踪字段。主表存标量,明细表存集合,列映射固定(如 GTDT 的 GATE/GOTM/GCTM → flight_gate 对应列);读回时按 ORDINAL 组装集合,DB 权威态 = 主行 + 明细行。
#### 第一步:报文变成命令
`commandsFromFields` 把 Handler 解出的字段集翻成命令,规则:
- **字段没出现 = 不动**。增量报文只带变化的部分,其余不碰。
- **标量出现 = Set**(覆盖)。异常键(FDIV/FRET/FLAB)载荷为空串、null、空对象时 = Clear。
- **集合出现 = Replace**(整组替换为报文给的条目);整组条目序号属性为 0(如 GTNO=0)= Clear(显式清除);清除标记和正常条目混在一组直接拒绝,不猜。
- DELY 没有协议序号属性,不支持逐条 Apply;清除走空数组替换。
#### 第二步:命令合并进当前态(纯函数)
`FlightStateEngine.apply` 从当前态拷贝一份,逐条应用命令:Set 覆盖键值,Clear 删键并记入 clearedKeysReplace 整组换,Clear 删组。然后版本 +1、记 LAST_MESSAGE_ID,产出 FlightNextState。纯函数:不碰库、不发事件,同样输入永远同样输出。
#### 第三步:新状态写进库(唯一写入口 persistNextStates
- **主表标量**:快照线整行替换(FDAY=快照日期);动态线只 UPDATE 本次出现的列,clearedKeys 对应的物理列显式置 NULL,新航班先插一行(FDAY 为空)。
- **明细集合**:每次写航班,9 张明细表对该 FLID 先 DELETE 再按合并后的集合整组 INSERT——即使某个集合本次没变也重写,保证库里与内存状态严格一致。这就是"整集合替换"的落地:ORDINAL 按输入顺序 1..NSOURCE_SEQ 存协议序号,RECORD_VERSION 记写入时的版本。
- last_message_id / state_version / updated_at 一并更新。
#### 事件从状态序列化而来
KAFKA_SCHD 载荷 = FlightNextState 序列化(FlightFieldsJson):键按字典序输出保证同状态字节级稳定;集合键输出为真 JSON 数组/对象,不做双重字符串编码。快照线的事件与落库用同一份事务内状态;动态线的事件目前来自 Handler 锁外预览(见已知偏差)。
### 失败规则(两条线共用)
事务内任何一步失败 → 整体回滚,消息记 FAILED 退避重试。已提交的成功不受后续失败影响:回填和投递失败只落补偿待办,不降级 SUCCEEDED,不重放业务。
### 不变量(两条线共用)
- **状态、事件、终态同事务**:库里能看到的必是完整提交结果。
- **计算是合并不是覆盖**:报文没提的字段一律不动(加工链见上节)。快照更新 FDAY,动态线保留 FDAY,新行为空。
- **快照重放只有一道防线**LAST_MESSAGE_ID 识别最近一次快照的重放;更早的历史快照重入是否安全,靠人工重放的版本/业务日期策略。
- 字段缺失、清除、空数组、异常对象的精确行为见语义矩阵。
### 已知偏差(待收敛)
GTDT Handler 在事务外算一遍预览并构造事件,主泵在事务里再算一遍。目标是 Handler 只表达业务变化,事务内算出唯一一份 nextState,落库和 KAFKA_SCHD 共用——尚未实现。
## 4. 提交之后:回填与事件
提交成功后有两件外部事:回填共享 MySQL 信箱、异步投递 Kafka,各自独立重试。
回填恢复靠一条持久待办:
- 现状:只在回填失败后写 `BACKFILL_TODO`;提交后立即崩溃的窗口没有待办,恢复不了。
- 目标:在业务终态同一事务里登记回填意图,提交后回填成功再删待办。崩溃自然有账可查,不需要额外的"重启对账框架"。
- 红线:登记待办再失败,不能把已提交的 SUCCEEDED 降级为 FAILED。
## 5. 怎么读
现在读一个航班 = 主行 1 次 + 10 类集合各 1 次,全量约 1+10N 次查询;多次读不在同一数据库快照,主子状态不保证一致。
后续在仓储边界按 FLID 批次加载明细,并明确一致性读事务;不引入缓存权威或通用查询框架。`GET /all/flights` 仍是 Controller TODO——仓储方法和展示视图存在不等于接口已交付。
KAFKA_SCHD 输出结构化 JSON,集合禁止双重编码成字符串。清除的 wire 表达、空数组、未知属性、异常局部替换仍有验收缺口,见核查报告;一个全字段样例通过不等于整个 SIS 协议无损。
## 6. Oracle:一整个工作项,不是"等环境"
PG 是唯一实现:SqlDialect 只覆盖两条主行 upsertOracle11gDialect 是没接线的模板(绑定顺序不兼容、MERGE 列数和占位符对不上);迁移目录只有 README,驱动、其余 SQL、视图、目标库测试都没写。
口径因此是"完整适配并验收":以一条跑通的仓储纵向链定接口,处理 DDL、绑定、空字符串、长文本,再上目标 11.2 实测。入口见 [Oracle 迁移说明](../src/main/resources/db/migration/oracle11g/README.md)。
## 7. 老数据升级:脚本能跑不等于数据无损
V1.1→V1.4V1.2 删 *_TXTV1.3 建空明细表,V1.4 删旧槽位列——旧集合数据没有任何自动恢复链路。带数据的实例升级前必须:导出/备份 → 选完整 DNLD 或可信原文恢复 → 逐字段核对后再切换。已发布迁移不改(不靠改历史隐藏损失),也别指望从已删槽位找回数据。
## 8. 现状
已验证:PG 上的事务、部分集合往返、CAS、行锁、脚本升级,101 个测试全绿。
未完成:真实 DNLD/RESP 解析与生产路由、`/all/flights`、全字段与空值无损往返、提交后崩溃恢复、带数据无损迁移、查询规模证据、Oracle 实测、现场对拍。分项证据在核查报告与 Plane,本文不复述工单流水账。