# Room Outbox Worker

本文档记录当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 outbox；腾讯云 IM、activity-service 等外部投递全部由 outbox worker 异步执行。

## 边界

| 项目 | 当前规则 |
| --- | --- |
| 主链路 | 不同步调用腾讯云 IM，不同步投递 activity-service |
| 持久化 | 命令变更、command log、room_outbox 在同一提交链路内完成 |
| worker | 读取 `room_outbox`，投递到配置的 outbox publisher fanout |
| 腾讯云 IM | `RoomCreated` 由 worker 确保群组存在，其他房间事件由 worker 发送群系统消息 |
| activity-service | 由 room outbox worker 异步投递，失败进入 room_outbox 重试 |
| 幂等 | 房间命令靠 `command_id`，outbox 投递靠 `event_id` |

不做的事：

| 不做 | 原因 |
| --- | --- |
| 在 HTTP/gRPC 请求里等待 IM REST | 外部慢调用会拖住房间串行命令队列 |
| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox，不属于房间权威状态 |
| 直接用 Redis 做 outbox | MySQL 是恢复和补偿的持久来源 |
| 精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once，消费端必须按 `event_id` 去重 |

## 写命令语义

```text
gateway -> room-service command
  -> idempotency check
  -> ensure Redis lease and Room Cell
  -> mutate cloned RoomState
  -> optional wallet debit before room gift state is committed
  -> SaveMutation(command log + room_outbox + room meta)
  -> snapshot when version hits snapshot_every_n
  -> update list/presence projections best effort
  -> return response

outbox worker
  -> claim pending/retryable rows
  -> publish to composite outbox publisher
  -> delivered / retryable / failed
```

主链路成功只表示房间状态已经落库；不表示腾讯云 IM 或 activity-service 已经完成投递。客户端实时表现应以房间响应、房间 snapshot 和 IM 系统消息共同补偿。

## 数据状态

`room_outbox.status` 使用：

| 状态 | 含义 |
| --- | --- |
| `pending` | 已落库，尚未被 worker 处理 |
| `delivering` | 已被 worker 抢占，`lock_until_ms` 前其他 worker 不抢 |
| `delivered` | 所有配置的 outbox publisher 已处理成功 |
| `retryable` | 上次投递失败，等待 `next_retry_at_ms` 后重试 |
| `failed` | 达到最大重试次数，进入死信，需要人工处理或后台工具重放 |

旧数据里可能存在历史 `published` 状态。当前 worker 不再写这个状态，新链路只写上表状态。

## 配置

`services/room-service/configs/*.yaml` 当前配置：

```yaml
snapshot_every_n: 10

outbox_worker:
  enabled: true
  poll_interval: "1s"
  batch_size: 100
  publish_timeout: "3s"
  retry_strategy: "exponential_backoff"
  max_retry_count: 10
  initial_backoff: "5s"
  max_backoff: "5m"
```

配置规则：

| 字段 | 规则 |
| --- | --- |
| `snapshot_every_n` | 开发和生产默认 `10`；命令日志仍保证恢复，不能依赖每条命令快照 |
| `poll_interval` | worker 空轮询间隔，必须大于 0，避免 busy loop |
| `batch_size` | 每轮最大抢占条数，必须大于 0，避免全表扫描 |
| `publish_timeout` | 单条 outbox 投递 deadline，避免外部系统拖死 worker |
| `retry_strategy` | 当前为 `exponential_backoff` |
| `max_retry_count` | 达到次数后标记 `failed`，防止 poison event 永久污染日志 |
| `initial_backoff` | 第一次失败后的最小退避 |
| `max_backoff` | 指数退避上限 |

## 重试和死信

失败处理：

```text
publish failed
  -> retry_count + 1
  -> if retry_count >= max_retry_count: status=failed
  -> else: status=retryable, next_retry_at_ms=now+backoff
```

`failed` 不再自动重试。后台应提供人工查看和重放工具，重放时必须保留原 `event_id` 或生成明确的新修复事件，避免消费端无法去重。

## 投递对象

当前 `CompositeOutboxPublisher` fanout 到多个 publisher：

| Publisher | 行为 |
| --- | --- |
| Tencent IM | `RoomCreated` 确保群存在；房间系统事件发送腾讯云 IM 群自定义消息 |
| Activity | 将 room outbox 事件投递给 `activity-service` 的消费入口 |

fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都会进入重试，因此消费端必须幂等处理重复事件。

## 阶段耗时日志

每条房间写命令都会输出 `room_command_timing`：

| 字段 | 含义 |
| --- | --- |
| `idempotency_ms` | command log 幂等检查耗时 |
| `lease_ms` | Redis lease / Room Cell 装载耗时 |
| `wallet_debit_ms` | 送礼扣费耗时；非送礼为 0 |
| `save_mutation_ms` | command log + outbox + 房间 meta 提交耗时 |
| `snapshot_ms` | 快照持久化耗时；未触发为 0 |
| `projection_ms` | 列表和 presence 投影耗时 |
| `activity_ms` | 主链路 activity 同步耗时；当前固定为 0 |
| `tencent_im_ms` | 主链路腾讯 IM 同步耗时；当前固定为 0 |
| `activity_async_outbox` | 本命令是否产生异步 activity/outbox 投递 |
| `tencent_im_async_outbox` | 本命令是否产生异步 IM/outbox 投递 |

排查接口卡顿时先看 `total_ms`，再看各阶段字段。送礼慢通常优先看 `wallet_debit_ms`，普通房间命令慢优先看 `save_mutation_ms`、`snapshot_ms` 和 `projection_ms`。

## 验收

必须满足：

| 场景 | 验收 |
| --- | --- |
| IM 不可用 | 房间写命令仍成功，outbox 进入 `retryable` 或最终 `failed` |
| activity 不可用 | 房间写命令仍成功，outbox 进入重试 |
| poison event | 达到 `max_retry_count` 后进入 `failed`，不再每秒刷日志 |
| 正常投递 | 同房间事件最终全部 `delivered` |
| 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` |
