# Room Cell State Machines 本文档定义 `room-service` 在微服务多机部署下必须遵守的 Room Cell 状态机。Room Cell 的目标是让同一个房间在同一时间只有一个执行节点提交状态,并且在节点重启、发布、RTC 回调延迟和 outbox 重试时不丢状态、不双写、不重复扣费。 ## Room Room 是房间生命周期的主状态。只有 `active` 房间允许进房、麦位、管理和送礼命令。 ```mermaid stateDiagram-v2 [*] --> creating creating --> active: CreateRoom 持久化成功 active --> closing: CloseRoom 开始收尾 closing --> closed: command log/outbox/meta 提交 active --> closed: CloseRoom 当前实现一次提交完成 closed --> [*] ``` 状态语义: | State | Meaning | | ---------- | ------------------------------------------ | | `creating` | 正在创建房间外部依赖,不对客户端开放 | | `active` | 可进房、可管理、可上麦、可送礼 | | `closing` | 正在关闭,不再接收新业务命令 | | `closed` | 已关闭,`JoinRoom` 和 guard 查询都必须拒绝 | `CloseRoom` 必须作为 Room Cell 命令提交,不能只从后台直接更新 `rooms.status`。关闭房间时必须清空业务 presence、释放麦位、写 command log、写 `RoomClosed` outbox、更新 `rooms` 和 `room_list_entries` 状态。 ## Presence Presence 是 room-service 自己维护的业务进房状态,不等同于腾讯云 IM 或 RTC 的真实连接。 ```mermaid stateDiagram-v2 [*] --> absent absent --> present: JoinRoom present --> present: JoinRoom 重试或 RoomHeartbeat 刷新 last_seen_at_ms present --> left: LeaveRoom present --> stale_left: stale worker 超时清理 present --> kicked: KickUser present --> closed_left: CloseRoom left --> present: JoinRoom stale_left --> present: JoinRoom kicked --> present: UnbanUser 后重新 JoinRoom closed_left --> [*] ``` 规则: - `RoomHeartbeat` 只能刷新已有 presence,不能创建 presence。 - `LeaveRoom` 要同步释放用户占用的麦位。 - RTC `room_exited` 只释放当前麦位,不清理业务 presence;用户可能仍停留在房间页等待重连。 - `KickUser` 写入 ban 集合,用户必须先 `UnbanUser` 才能重新 `JoinRoom`。 ## Mic Mic 是麦位和 RTC 发流确认状态。业务上麦不等于已经在 RTC 发流。 ```mermaid stateDiagram-v2 [*] --> idle idle --> locked: SetMicSeatLock(true) locked --> idle: SetMicSeatLock(false) idle --> pending_publish: MicUp pending_publish --> publishing: ConfirmMicPublishing 或 RTC audio_started pending_publish --> idle: publish_timeout 或 MicDown 或 LeaveRoom publishing --> idle: MicDown 或 RTC audio_stopped 或 LeaveRoom publishing --> pending_publish: ChangeMicSeat 保留 mic_session pending_publish --> pending_publish: ChangeMicSeat 保留 mic_session ``` 规则: - `mic_session_id` 是单次上麦会话 ID,旧会话的确认、停止和超时任务不能清掉新会话。 - `mic_session_room_version` 和 `last_publish_event_time_ms` 用于丢弃旧 RTC 事件。 - `pending_publish` 超过 `publish_deadline_ms` 后由 worker 自动下麦。 - `LeaveRoom` 与 RTC `room_exited` 并发时以业务 presence 的 `last_seen_at_ms` 和当前 `mic_session_id` 为准;旧的 RTC 退房事件不能释放用户重进房后的新麦位会话。 - `CloseRoom` 必须释放全部麦位,但保留麦位编号和锁定结构。 ## Lease Lease 是多机部署下的房间执行权。Redis 只保存热路由,不是恢复来源;MySQL snapshot + command log 才是恢复来源。 ```mermaid stateDiagram-v2 [*] --> unowned unowned --> owned_by_node: EnsureOwner owned_by_node --> owned_by_node: 同节点续约并生成新 lease_token owned_by_node --> expired: TTL 到期 expired --> owned_by_other_node: 新节点 EnsureOwner owned_by_node --> fenced_out: 提交前 VerifyOwner 失败 fenced_out --> [*] ``` 规则: - Redis runtime route key 统一使用 `app_code + "\0" + room_id`,包括默认 App;不能再使用裸 `room_id` 作为兼容 key。 - `room:node:{node_id}` 保存当前 room-service 实例的 `advertise_addr`,只用于 owner 转发;它不是服务发现的全局配置中心。 - gateway 可以连接 room-service 内网负载均衡;命中非 owner 节点时,gRPC 边界必须按 Redis owner route 转发到当前 owner 实例。 - `advertise_addr` 必须是实例私网地址,不能是 room-service 内网 CLB 地址,否则转发会命中任意节点并触发递归保护。 - 命令开始前通过 `EnsureOwner` 获得执行权。 - 命令真正写 command log/outbox 前必须用 `lease_token` 再做一次 `VerifyOwner`。 - 如果提交前 lease 已经过期或 token 已变化,旧节点必须返回 `UNAVAILABLE`,不能写房间状态。 - `room_command_log` 保存 `owner_node_id` 和 `lease_token`,用于排查谁在什么 lease 下提交了命令。 - stale presence worker 和 mic publish timeout worker 只能扫描本节点已加载 Cell;清理前必须确认当前 Redis lease 仍属于本节点,并在读完快照后再次用相同 `lease_token` 校验,最后由命令提交链路再做 fencing。 - 节点进入 draining 后不能再接收新的 Room Cell 命令,也不能恢复或创建新的 Cell;gRPC drain 完成后应主动释放本节点已加载房间的 owner lease,降低滚动发布等待 TTL 的窗口。 ## Outbox Outbox 是房间事件对外投递状态机。它允许重复投递,但多 worker 不应该同时抢同一批。 ```mermaid stateDiagram-v2 [*] --> pending pending --> delivering: ClaimPendingOutbox retryable --> delivering: ClaimPendingOutbox delivering --> delivered: 投递成功并确认 delivering --> retryable: 投递失败或状态未知 delivering --> failed: 明确不可重试 retryable --> failed: 超过策略上限或人工判定 ``` 规则: - `ClaimPendingOutbox` 必须把事件从 `pending/retryable` 原子改成 `delivering`,并写入 `worker_id` 和 `lock_until_ms`。 - 其他 worker 不能抢到未过期的 `delivering` 事件。 - 投递成功后才能标记 `delivered`。 - 投递失败必须保留 envelope 和 last_error,转入 `retryable` 等待下一轮抢占。 - outbox 事件必须带 `app_code`,消费者按 App 隔离幂等。