ADR-0021:群成员可见区间与会话集合权威(一期实现)¶
- 状态:已接受(2026-09-26,由项目负责人按推荐方案批准:入群、退群、踢人全套;退群后历史读到退群点为止)
- 相关:
docs/PLAN.md§7.5(visible(u,c))、§10.1 第 3/9 步、§10.1.1、§12.5(定义式未读)、§12.10(退群)、 §19.1 边界 5、附录 A.3/A.4、附录 B.6(membership_event_broadcast_max_members);ADR-0018、ADR-0020
背景¶
- 群成员只有“当前成员集合 + 建群 initial cohort”:后加入的成员读不了任何历史(失败闭合),
remove_member没有任何入口,没有人能退群;会话列表的未读与会话头不看成员区间。 - 发送路径先冻结成员版本、后分配
conversation_seq,与 §10.1 第 3 步(分配之后冻结)相反。 序号计数器在 MessageStore 的分片实例上,群成员却固定在 core 实例 0,二者无法原子。 UserConversationState(会话集合的持久权威)未实现:qsession 只有convs:{user}投影, dispatch 日志保留期(1 天)之外或投影全失时无法重建。
决策¶
1. 群成员与会话序号同实例、同原子区¶
- GroupMembership 的全部键改用该群会话的提交桶标签
{bNNN}(commit_bucket(tenant, group_conversation_id)), 与msgcseq/提交状态落在同一 Redis 实例;分桶函数移到qim-common由两方共用,连接路由与 MessageStore 相同(QIM_REDIS_ADDR+QIM_MESSAGE_STORE_ADDRS,同一顺序)。该实例按 ADR-0018 已是appendfsync always。 - 发送:writer 仍先读当前成员版本
V以计算冻结受众,但提交脚本在分配序号的同一 Lua 内校验grpmeta.version == V;不相等返回“成员已变更”,writer 重新冻结并重试(有界 3 次)。效果等价于 “分配之后冻结”:序号S的消息恰好投给分配S那一刻的成员。 - 一期无生产数据,旧 core 实例 0 上的群键不迁移(开发环境重建即可);上线前必须清空旧键。
2. 成员事件是会话内的控制消息,占用一个 conversation_seq¶
- 入群(管理面
GroupAdd)、退群(客户端LEAVE_GROUP,只能退自己)、移除(管理面GroupRemove) 各提交一条MessageType::CONTROL消息。一个 Lua 在同一原子区内:校验版本 → 分配序号J→ 写新成员集合与版本V+1快照 → 写成员区间 → 预留并记录该控制消息的提交(与reserve_and_record相同的 RECORDED 状态),之后走普通提交流程(Outbox → COMMITTED)。 - 区间(
visible(u,c) = (joined_at, left_at),下界开区间,§7.5): - 建群的 initial members:
joined_at = 0,不产生控制消息(§14.4 同口径); - 入群:
joined_at = J − 1(入群事件本身可见),清除left_at;重新入群以新区间替换旧区间; - 退群/移除:
left_at = J,状态记为LEFT/REMOVED;J及之后的消息对其不可见。 - 控制消息的受众显式列出(
DeliveryAudience::Direct):成员数 ≤membership_event_broadcast_max_members(500)时为变更后成员 ∪ 受影响用户,否则只有受影响用户(§10.1.3:超阈值不广播)。受影响用户 总能收到自己的入群/退群事件。控制消息不计未读(定义式已排除Control)。 - 载荷:
custom_type = "qim.membership.v1",正文为op:u8(1 入群、2 退群、3 移除)+actor:u128be+n:u16be+n × user:u128be。 - 记录的发送者:退群为退群者本人;管理面操作没有终端用户身份,记为群 owner,正文
actor = 0标明来源(一期 user/device 合一,设备取同值)。 - 幂等:
client_message_id = blake3("qim.membership-event.v1\0" || tenant || group || op || actor || 规划版本 V || 对象集合)[0..16]。同一变更重试命中既有提交续做;已生效后再重试时对象已被规划 过滤为空,直接返回当前人数,不产生第二条事件。 - 只有群键与提交事实同实例的后端能提供该原子区:
redis与tiered实现;对照用scyllaMessageStore 后端对成员变更返回Unsupported,群发送的版本校验在该后端不生效(非生产)。 - 新增协议帧(附录 A.3,本 ADR 起属契约):
LEAVE_GROUP(0x0A04,上行,group_id),应答复用MEMBER_LIST_BATCH(request_id关联);MEMBER_LIST_BATCH.members[].joined_at_conversation_seq自本 ADR 起填真实值。移除只开放管理面(mTLS Admin,qimctl membership remove),一期不开放群主踢人帧。
3. 区间的执行点¶
- 投递:不变。冻结版本
V等于分配S时的成员,已离开者不在V内,入群者只出现在入群事件 之后的版本里;控制消息按显式受众投递。 - 历史:
HistoryAuthorizer返回可见区间而非布尔;history_page只返回区间内记录,earliest_available_conversation_seq取max(全局最早, joined_at + 1)。离开者仍可读到left_at之前的历史(§12.10 缺省策略);从未成为成员者拒绝(PERMISSION_DENIED,不区分“无权”与“不存在”)。 initial cohort 集合grphist0由区间取代。 - 会话列表与未读:群会话的
base = max(read_conversation_seq, joined_at);已离开者上界为left_at − 1,会话头取上界内最新一条,未读只计上界内。离开者的会话列表不得出现离开后的消息预览。 - 发送:发送者必须在冻结版本内(离开者发送被拒绝,现有逻辑)。成员列表:仅当前成员可拉取。
4. UserConversationState 权威与 qsession 兜底重建¶
- ScyllaDB 表
user_conversation_state((tenant_id, user_id), conversation_id):kind、membership_state、joined_at_conversation_seq、left_at_conversation_seq、first_seen_at、updated_at;无表级 TTL (会话集合是权威,不随消息到期),retention_class到期只影响消息本身。 - 写入者是 qsession 的 durable dispatch 消费者,只在会话首次进入某用户集合时写一次——不违反
“禁每条消息同步更新全部成员持久会话记录”:普通消息不产生写入。
redis开发后端无 ScyllaDB,权威 退化为同实例 Redis hash(仅开发,不作为权威保证)。 - 实现修订(2026-09-26):表只存会话集合(
kind, first_seen_at),不复制成员状态与区间。 区间的权威是决策 1 的 GroupMembership 区间(与提交事实同一原子区写入);在 qsession 里再写一份 只会多一个漂移源,而且大群成员事件的正文不内联进 dispatch,qsession 读不到变更对象。 - 写路径:投影用一个 Lua 同时
ZADD convs GT与“ZSCORE为空则把(user, cid, created_at)追加到session_projection:ucs_pending队列”;独立登记任务把队列批量写入 ScyllaDB 后,按原样逐项出队 (只弹出与已写入项相等的队首,多实例重复写无害)。投影消费不等待 ScyllaDB,吞吐不变;队列与投影 在同一实例、同一次原子写入,崩溃不漏登记。 - 两条投影路径共用同一登记 Lua(2026-09-27 修复):mailbox 的低延迟投影通道此前直接
ZADD GT,它通常先于 durable 投影到达;由它新增成员后,durable 侧看到成员已存在就不登记—— 180 s 实测 250 万条投影只登记了 15 个会话。改为两条路径都经project_entries(ZSCORE为空才登记,与ZADD同一原子步骤),谁先加入谁登记、只登记一次;低延迟通道一批一次调用, 摊薄 Lua 固定开销。不能改为“低延迟通道只更新已有成员”:持续负载下 durable 投影落后, 集合的新鲜度靠的正是低延迟通道(该方案实测 T-HEAVY 30 秒内只见 730/5000 个会话)。 回归:低延迟通道先到也只登记一次且权威表可读回。 - 重建:qsession 启动时若 checkpoint 越过 dispatch 保留窗口、或 checkpoint 缺失而日志低水位非零
(投影全失),不再失败闭合,而是递增投影纪元(
session_projection:epoch)并从日志当前低水位继续 消费;checkpoint 越过高水位(broker 丢了已确认记录)与身份不符仍失败闭合。每个用户首次拉会话列表 时,若其纪元标记落后,从user_conversation_state读回会话集合、以ZADD GT(分值取登记时刻, 列表排序仍按摘要的last_activity_id)合并回convs:{user},再写纪元标记。按用户惰性重建,不做 全表扫描。 - 已知缺口:越窗期间首次出现、且之后再无消息的会话从未被投影,也就从未登记,重建后仍缺;此后任一 新消息会把它带回列表。邮箱与客户端本地库不受影响。启动日志记录越窗的 partition 与区间。
验收(全部为真实 Redis + 三节点 Scylla 门禁)¶
- 26.3.5 入群边界:入群者收到且只收到入群事件及之后的消息;
PULL_HISTORY最早可见为入群事件; 入群前的消息不计未读。 - 26.3.4 退群边界:
seq ≥ left_at的消息零投递、历史不可见、会话头不越界;重放确定。 - 重新入群:新区间替换旧区间,旧区间之后、新入群之前的消息不可见。
- 发送与成员变更并发:任一消息的受众恰为分配其序号时的成员(版本校验重试路径有测试)。
- qsession 在投影清空、checkpoint 越窗后,用户拉列表得到与清空前相同的会话集合与定义式未读。
实现与验收映射(2026-09-26)¶
| 验收 | 覆盖 |
|---|---|
| 1 入群边界 | qim-store/tests/redis_group_membership.rs::入群_退群_移除_重新入群的历史边界;qim-writer members::tests::加群_退群经控制消息提交且成员页回带入群点(joined_at_conversation_seq) |
| 2 退群边界 | 同上(历史);qim-loadgen e2e 第 16 项“退群边界”(零投递、邮箱无该条、历史与会话头止于退群点) |
| 3 重新入群 | 入群_退群_移除_重新入群的历史边界(新区间替换旧区间) |
| 4 发送与成员变更并发 | 群发送的冻结版本过期即拒绝且不消耗序号、成员变更规划过期被拒_重试幂等返回既有提交;writer 发送路径有界重冻结 3 次 |
| 5 投影清空重建 | qim-session conversation_state::tests::投影清空且纪元递增后按用户从权威表重建会话集合;durable::tests::越过保留窗口触发重建_越过高水位仍拒绝;ScyllaDB 表 tiered_message_store.rs::会话集合权威表登记幂等且可按用户读回 |
新增指标:qim_writer_membership_events_total、qim_writer_membership_retry_total、
qim_session_group_interval_missing_total(恒零)、qim_session_conversation_state_{pending,written,failures}_total、
qim_session_projection_user_rebuilt_total。客户端:SDK Command::LeaveGroup / TrackedCommand::LeaveGroup、
C qim_leave_group_v2(member_operation = 3)、UniFFI leave_group_v2、桌面 /leave;运维 qimctl membership add|remove。
GUI 未加退群入口。
后果¶
- 群成员随会话分片,消除“GroupMembership 固定实例 0”的扩展缺陷。
- 成员变更成本:一次 Lua + 一次普通提交流程;成员变更频率远低于消息,不在热路径上。
- 发送路径增加一次版本比较(同一 Lua 内,几乎零成本);成员变更与发送并发时发送侧重试。
- 实测代价(2026-09-27,本机 hybrid、tmpfs 模拟服务器盘、4+4 Redis、10,000 msg/s × 180 s,各一次):
对照 ADR-0021 之前同配置基线,SEND_ACK P99 25.1 → 30.0 ms、端到端 P99 114.6 → 122.2 ms,
到达率、T-HEAVY(0.7 → 0.9 ms)、e2e、孤儿审计均通过。代价来自会话投影登记 Lua:单次约
5.7~6.1 µs,而原
ZADD为 0.9 µs,落在已达单线程上限(1.0 核)的默认 core 实例上。 可削减方向:durable 投影把登记与 checkpoint CAS 合并为一次调用;或把 qsession 的会话集合、 已读与待登记队列移出默认 core 实例。 - 新增一个上行帧与一张 ScyllaDB 表;SDK 增加
LeaveGroup命令。