跳转至

ADR-0020:GroupDispatch.fencing_epoch 在一期形态不适用,切换到会话租约序号窗口时强制启用

  • 状态:已接受(2026-09-26,由项目负责人选择方案 B)
  • 相关:docs/PLAN.md §19.1 边界 1、§19.2(fencing token 的载体与两个校验点)、ADR-0007(一期简化形态)、 ADR-0012(Fanout 不用 Kafka 事务)

背景

§19.2 规定:fencing_epoch 由 ShardRegistry 在每次授予会话写入租约时递增,ConversationWriter 把它写进 每条 GroupDispatch,MailboxNode 按 (tenant, conversation) 维护“已见最大 epoch”过滤器,丢弃旧 owner 的迟到追加。它防的是会话写入权脑裂:旧 writer 在租约失效后仍凭本地持有的序号预留窗口与 ConversationHead 写入资格继续提交,与新 writer 分配出重叠的 conversation_seq。

此前该字段被列为发布阻断。实现它需要 ShardRegistry(etcd)与会话写入租约,二者一期均未实现。

一期形态下,fencing 所防的脑裂不存在

  • writer 不持有任何正确性状态:conversation_seq 分配、幂等(ClientDedup)与提交状态机都在 MessageStore 的 Redis Lua 中原子完成,按会话提交桶线性化;writer 没有本地序号窗口,任何 writer 处理 任何会话都正确(gateway 的会合哈希只是亲和性优化)。不存在“旧 owner 凭本地窗口继续发号”的路径。
  • 暂停后恢复的 writer:它手中的在途提交已在 Redis 固化坐标,恢复后发布的 Outbox 记录与恢复器 可能发布的记录同值,下游以确定性 dispatch_id + payload_digest 收敛,不产生新序号。
  • writer_id 被他人接管:HLC 窗口按 (region_id, writer_id) 在 Redis 原子领取,新窗口起点 严格大于已登记的上一窗口终点,同一 writer_id 的两个进程拿到的毫秒区间互不重叠,message_id 不会重复(identity::RESERVE_HLC_SCRIPT)。
  • 序号回退:宿主崩溃导致的 Redis 回退已由 ADR-0018 的 appendfsync always 强制消除。

回归测试 qim-store/tests/redis_message_store.rs::两个_writer_并发提交同一会话_序号不重复且连续 以两个独立存储实例并发提交 200 条,断言序号恰为 1..=200。

决策

  1. 一期形态(Redis Lua 原子分配序号、无会话写入租约)不实现 GroupDispatch.fencing_epoch, 它不再是一期发布阻断项。§19.1 边界 1 的保证机制在一期由“Redis 原子分配 + appendfsync always” 承担,检测信号(同一 (conversation_id, conversation_seq) 出现两个不同 message_id)由 ADR-0018 归档层的冲突拒绝与 qimctl audit dispatch 的一致性检查覆盖。
  2. 强制启用条件:一旦引入以下任一机制,fencing_epoch 与 §19.2 的两个校验点必须先于该机制上线:
  3. ConversationWriter 在本地持有序号预留窗口(按段预取 conversation_seq);
  4. 会话写入改由 ShardRegistry 租约授予(包括阶段一迁移到 ScyllaDB 分配器后的任何租约化形态);
  5. ConversationHead 改为由 writer 条件更新。
  6. §19.2 的契约文本保持不变(目标形态);docs/PLAN.md §0.2 记录本 ADR 的适用范围。

后果

  • 一期少一个发布阻断项,不引入 ShardRegistry。
  • 代价:writer 的线性化点完全依赖 MessageStore Redis 的原子性与持久性;该实例不可用即无法提交 (与 ADR-0007 的一期可用性取舍一致)。
  • 任何把序号分配移出 Redis 原子脚本的改动,都必须先回到本 ADR 的“强制启用条件”。