修复房间问题

This commit is contained in:
zhx 2026-07-07 18:28:50 +08:00
parent b8cbb93183
commit a328b2bfef
12 changed files with 424 additions and 27 deletions

View File

@ -261,7 +261,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
projectionMS = elapsedMS(projectionStartedAt) projectionMS = elapsedMS(projectionStartedAt)
// 持久化成功后再安装内存 Cell避免内存有房间但恢复来源不完整。 // 持久化成功后再安装内存 Cell避免内存有房间但恢复来源不完整。
s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit))) s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)), lease.LeaseToken)
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, tencentIMMS, true, true, logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, tencentIMMS, true, true,
slog.Int64("save_room_meta_ms", saveRoomMetaMS), slog.Int64("save_room_meta_ms", saveRoomMetaMS),
) )

View File

@ -23,12 +23,12 @@ type loadedRoomRef struct {
RoomID string RoomID string
} }
func (s *Service) installCell(ctx context.Context, roomID string, roomCell *cell.RoomCell) { func (s *Service) installCell(ctx context.Context, roomID string, roomCell *cell.RoomCell, leaseToken string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
// 注册表只保存当前进程内已装载 Cell,不代表 Redis lease 的全局 owner // 注册表只保存当前进程内已装载 CellleaseToken 决定它能否继续承载写路径
s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] = roomCell s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] = &loadedCell{roomCell: roomCell, leaseToken: leaseToken}
} }
func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell { func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell {
@ -36,7 +36,44 @@ func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell {
defer s.mu.Unlock() defer s.mu.Unlock()
// 返回指针后由 RoomCell 自己的 mailbox 保证状态串行化。 // 返回指针后由 RoomCell 自己的 mailbox 保证状态串行化。
return s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] loaded := s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)]
if loaded == nil {
return nil
}
return loaded.roomCell
}
func (s *Service) loadCellForLease(ctx context.Context, roomID string, lease router.Lease) *cell.RoomCell {
key := runtimeRoomKey(appcode.FromContext(ctx), roomID)
s.mu.Lock()
defer s.mu.Unlock()
loaded := s.cells[key]
if loaded == nil {
return nil
}
if loaded.leaseToken == "" || loaded.leaseToken != lease.LeaseToken {
// lease token 变化代表当前节点经历过执行权断档;旧 Cell 可能落后于新 owner 已提交的 command log必须丢弃后走恢复。
delete(s.cells, key)
return nil
}
return loaded.roomCell
}
func (s *Service) loadCellForCurrentOwner(ctx context.Context, roomID string) (*cell.RoomCell, error) {
if s.directory == nil {
// 测试中的极简 Service 可能没有装配 directory生产 HealthCheck 会拒绝这种配置。
return s.loadCell(ctx, roomID), nil
}
lease, exists, err := s.directory.Lookup(ctx, runtimeRoomKeyFromContext(ctx, roomID))
if err != nil {
return nil, err
}
if !exists || lease.NodeID != s.nodeID || !lease.ValidAt(s.clock.Now()) {
// 只读路径不能主动接管 lease没有当前 owner 身份时必须走持久化恢复,避免旧 Cell 放行过期状态。
return nil, nil
}
return s.loadCellForLease(ctx, roomID, lease), nil
} }
func (s *Service) forgetLoadedRoom(ctx context.Context, roomID string) { func (s *Service) forgetLoadedRoom(ctx context.Context, roomID string) {
@ -103,6 +140,10 @@ func (s *Service) loadedRoomLease(ctx context.Context, roomRef loadedRoomRef, no
// 如果其他节点已经接管并持有有效 lease当前节点必须跳过避免旧内存态覆盖新 owner。 // 如果其他节点已经接管并持有有效 lease当前节点必须跳过避免旧内存态覆盖新 owner。
return router.Lease{}, false, nil return router.Lease{}, false, nil
} }
if roomCell := s.loadCellForLease(ctx, roomRef.RoomID, lease); roomCell == nil {
// 旧 Cell 的 token 不连续时,后台 worker 不能靠重新抢到的 lease 继续写旧内存分支。
return router.Lease{}, false, nil
}
return lease, true, nil return lease, true, nil
} }

View File

@ -0,0 +1,99 @@
package service
import (
"context"
"testing"
"time"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/cell"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
"hyapp/services/room-service/internal/router"
)
func TestLoadedRoomLeaseDropsCellWhenLeaseTokenChanges(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
roomID := "room-token-gap"
now := time.Date(2026, 7, 7, 9, 0, 0, 0, time.UTC)
directory := router.NewMemoryDirectory()
svc := &Service{
nodeID: "room-node-a",
leaseTTL: time.Second,
directory: directory,
cells: make(map[string]*loadedCell),
}
lease, err := directory.EnsureOwner(ctx, runtimeRoomKey("lalu", roomID), svc.nodeID, now, svc.leaseTTL)
if err != nil {
t.Fatalf("ensure first lease: %v", err)
}
roomState := state.NewRoomState(roomID, 1001, 8, "voice")
roomState.Version = 7
svc.installCell(ctx, roomID, cell.New(roomState, rank.FromState(roomState.GiftRank, 20)), lease.LeaseToken)
if got := svc.loadCellForLease(ctx, roomID, lease); got == nil {
t.Fatal("freshly installed cell should be reusable with the original lease token")
}
if _, owned, err := svc.loadedRoomLease(ctx, loadedRoomRef{AppCode: "lalu", RoomID: roomID}, now.Add(2*time.Second)); err != nil {
t.Fatalf("load expired lease: %v", err)
} else if owned {
t.Fatal("expired lease re-acquisition must not keep using the old in-memory cell")
}
if got := svc.loadCell(ctx, roomID); got != nil {
t.Fatal("old in-memory cell must be forgotten after a lease-token discontinuity")
}
currentLease, exists, err := directory.Lookup(ctx, runtimeRoomKey("lalu", roomID))
if err != nil {
t.Fatalf("lookup current lease: %v", err)
}
if !exists || currentLease.LeaseToken == lease.LeaseToken {
t.Fatal("test setup expected the directory to issue a new lease token after expiry")
}
}
func TestLoadCellForCurrentOwnerRejectsReadOnlyAndExpiredCells(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
roomID := "room-read-current-owner"
now := time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC)
directory := router.NewMemoryDirectory()
svc := &Service{
nodeID: "room-node-a",
leaseTTL: time.Second,
clock: fixedRoomTestClock{now: now},
directory: directory,
cells: make(map[string]*loadedCell),
}
roomState := state.NewRoomState(roomID, 1001, 8, "voice")
lease, err := directory.EnsureOwner(ctx, runtimeRoomKey("lalu", roomID), svc.nodeID, now, svc.leaseTTL)
if err != nil {
t.Fatalf("ensure lease: %v", err)
}
svc.installCell(ctx, roomID, cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, 20)), "")
if got, err := svc.loadCellForCurrentOwner(ctx, roomID); err != nil {
t.Fatalf("load read-only token cell: %v", err)
} else if got != nil {
t.Fatal("read-only recovered cell must not be reused as current-owner state")
}
if got := svc.loadCell(ctx, roomID); got != nil {
t.Fatal("read-only cell should be forgotten after current-owner token check")
}
svc.installCell(ctx, roomID, cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, 20)), lease.LeaseToken)
svc.clock = fixedRoomTestClock{now: now.Add(2 * time.Second)}
if got, err := svc.loadCellForCurrentOwner(ctx, roomID); err != nil {
t.Fatalf("load expired token cell: %v", err)
} else if got != nil {
t.Fatal("expired owner lease must not authorize reading the old in-memory cell")
}
}
type fixedRoomTestClock struct {
now time.Time
}
func (c fixedRoomTestClock) Now() time.Time {
return c.now
}

View File

@ -48,7 +48,7 @@ func (s *Service) SweepMicPublishTimeouts(ctx context.Context) error {
continue continue
} }
roomCell := s.loadCell(roomCtx, roomRef.RoomID) roomCell := s.loadCellForLease(roomCtx, roomRef.RoomID, lease)
if roomCell == nil { if roomCell == nil {
// loadedRoomRefs 和 loadCell 之间允许并发变化,缺失时跳过即可。 // loadedRoomRefs 和 loadCell 之间允许并发变化,缺失时跳过即可。
continue continue

View File

@ -3,6 +3,7 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -74,7 +75,13 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
} }
result.snapshot = snapshotWithApp(ctx, result.snapshot) result.snapshot = snapshotWithApp(ctx, result.snapshot)
if result.snapshot.GetVersion() != current.Version { stateChanged := nextState.Version != current.Version
if stateChanged && nextState.Version != current.Version+1 {
// Room Cell 命令日志按每次状态变更推进一个版本恢复;跳版本或复用旧版本都说明命令实现破坏了恢复语义。
return nil, xerr.New(xerr.Internal, fmt.Sprintf("room version advanced from %d to %d", current.Version, nextState.Version))
}
if stateChanged {
// 版本变化代表本命令产生实际状态变更,需要持久化命令和事件。 // 版本变化代表本命令产生实际状态变更,需要持久化命令和事件。
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil { if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err return nil, err

View File

@ -461,7 +461,7 @@ func (s *Service) SweepStalePresence(ctx context.Context) error {
continue continue
} }
roomCell := s.loadCell(roomCtx, roomRef.RoomID) roomCell := s.loadCellForLease(roomCtx, roomRef.RoomID, lease)
if roomCell == nil { if roomCell == nil {
continue continue
} }

View File

@ -27,8 +27,8 @@ func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell
return nil, router.Lease{}, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID)) return nil, router.Lease{}, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID))
} }
if roomCell := s.loadCell(ctx, roomID); roomCell != nil { if roomCell := s.loadCellForLease(ctx, roomID, lease); roomCell != nil {
// 本节点已经装载该房间,直接复用已有 Room Cell。 // 本节点已经装载该房间且 lease token 连续才能直接复用已有 Room Cell。
return roomCell, lease, nil return roomCell, lease, nil
} }
@ -55,7 +55,7 @@ func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell
// 恢复完成后创建新的 Room Cell并从恢复后的 GiftRank 重建 LocalRank 索引。 // 恢复完成后创建新的 Room Cell并从恢复后的 GiftRank 重建 LocalRank 索引。
roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit)) roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit))
s.installCell(ctx, roomID, roomCell) s.installCell(ctx, roomID, roomCell, lease.LeaseToken)
return roomCell, lease, nil return roomCell, lease, nil
} }

View File

@ -834,7 +834,7 @@ func (s *Service) SweepRoomRocketLaunchings(ctx context.Context) error {
if !owned { if !owned {
continue continue
} }
roomCell := s.loadCell(roomCtx, roomRef.RoomID) roomCell := s.loadCellForLease(roomCtx, roomRef.RoomID, lease)
if roomCell == nil { if roomCell == nil {
continue continue
} }

View File

@ -110,8 +110,8 @@ type Service struct {
// mu 保护本进程内 room_id -> RoomCell 注册表。 // mu 保护本进程内 room_id -> RoomCell 注册表。
mu sync.Mutex mu sync.Mutex
// cells 保存当前节点已经装载的单房间执行单元 // cells 保存当前节点已经装载的单房间执行单元;写路径必须同时校验 lease token避免 lease 断档后复用旧内存分支
cells map[string]*cell.RoomCell cells map[string]*loadedCell
// robotRuntimeMu 保护后台机器人房间运行循环;配置变更时要取消旧循环后重建。 // robotRuntimeMu 保护后台机器人房间运行循环;配置变更时要取消旧循环后重建。
robotRuntimeMu sync.Mutex robotRuntimeMu sync.Mutex
@ -134,6 +134,13 @@ type robotRoomRuntime struct {
configUpdatedAtMS int64 configUpdatedAtMS int64
} }
type loadedCell struct {
// roomCell 是当前进程内的 Room Cell 状态副本,只有 leaseToken 连续匹配时才能继续承载写命令。
roomCell *cell.RoomCell
// leaseToken 记录装载该 Cell 时持有的 Redis lease 实例;空值只允许读路径使用,不能参与写路径复用。
leaseToken string
}
// mutationResult 是命令在 Room Cell 内执行后的统一结果包。 // mutationResult 是命令在 Room Cell 内执行后的统一结果包。
type mutationResult struct { type mutationResult struct {
// applied 表示本次命令确实改变并提交了房间状态。 // applied 表示本次命令确实改变并提交了房间状态。
@ -272,7 +279,7 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
luckyGiftSendLocker: cfg.LuckyGiftSendLocker, luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
luckyGiftSendLockTTL: luckyGiftSendLockTTL, luckyGiftSendLockTTL: luckyGiftSendLockTTL,
rtcUserRemover: cfg.RTCUserRemover, rtcUserRemover: cfg.RTCUserRemover,
cells: make(map[string]*cell.RoomCell), cells: make(map[string]*loadedCell),
robotRuntimes: make(map[string]robotRoomRuntime), robotRuntimes: make(map[string]robotRoomRuntime),
humanRobotRuntimes: make(map[string]robotRoomRuntime), humanRobotRuntimes: make(map[string]robotRoomRuntime),
} }

View File

@ -6,14 +6,14 @@ import (
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/cell"
"hyapp/services/room-service/internal/room/rank"
) )
// currentSnapshot 返回当前房间的最新快照;没有内存 Cell 时会从持久化恢复后再返回。 // currentSnapshot 返回当前房间的最新快照;没有内存 Cell 时会从持久化恢复后再返回。
func (s *Service) currentSnapshot(ctx context.Context, roomID string) (*roomv1.RoomSnapshot, error) { func (s *Service) currentSnapshot(ctx context.Context, roomID string) (*roomv1.RoomSnapshot, error) {
if roomCell := s.loadCell(ctx, roomID); roomCell != nil { if roomCell, err := s.loadCellForCurrentOwner(ctx, roomID); err != nil {
// 内存 Cell 是最新状态来源guard 查询优先读它。 return nil, err
} else if roomCell != nil {
// 只有当前节点仍持有匹配 lease token 时,内存 Cell 才是最新状态来源。
currentState, _, err := roomCell.Snapshot(ctx) currentState, _, err := roomCell.Snapshot(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@ -38,9 +38,6 @@ func (s *Service) currentSnapshot(ctx context.Context, roomID string) (*roomv1.R
return snapshotWithApp(ctx, restoredState.ToProto()), nil return snapshotWithApp(ctx, restoredState.ToProto()), nil
} }
roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit))
s.installCell(ctx, roomID, roomCell)
return snapshotWithApp(ctx, restoredState.ToProto()), nil return snapshotWithApp(ctx, restoredState.ToProto()), nil
} }

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strings" "strings"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -70,6 +71,35 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
return err return err
} }
if err := r.lockRoomMutation(ctx, tx, appCode, commit.Command.RoomID); err != nil {
_ = tx.Rollback()
return err
}
if commit.Command.Replayable {
var existingCommandID string
err := tx.QueryRowContext(ctx,
`SELECT command_id
FROM room_command_log FORCE INDEX (idx_room_command_replay)
WHERE app_code = ? AND room_id = ? AND room_version = ? AND replayable = TRUE AND command_id <> ?
LIMIT 1
FOR UPDATE`,
appCode,
commit.Command.RoomID,
commit.Command.RoomVersion,
commit.Command.CommandID,
).Scan(&existingCommandID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return err
}
if existingCommandID != "" {
_ = tx.Rollback()
// 同房间同版本只能有一个可回放状态变更;这是恢复链路的最后一道持久化防线。
return fmt.Errorf("room command version conflict: room_id=%s room_version=%d existing_command_id=%s new_command_id=%s", commit.Command.RoomID, commit.Command.RoomVersion, existingCommandID, commit.Command.CommandID)
}
}
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms) `INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -317,6 +347,26 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
return tx.Commit() return tx.Commit()
} }
func (r *Repository) lockRoomMutation(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
var lockedRoomID string
err := tx.QueryRowContext(ctx,
`SELECT room_id
FROM rooms
WHERE app_code = ? AND room_id = ?
FOR UPDATE`,
appCode,
roomID,
).Scan(&lockedRoomID)
if errors.Is(err, sql.ErrNoRows) {
// CreateRoom 的测试级别 command-log 写入可能没有预置 rooms 行;生产命令在 SaveMutation 前已有 room meta。
return nil
}
if err != nil {
return err
}
return nil
}
func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error { func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
// 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。 // 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。
// room_outbox 也一起清理,避免已删除房间继续补偿投递旧的展示事件。 // room_outbox 也一起清理,避免已删除房间继续补偿投递旧的展示事件。
@ -418,16 +468,16 @@ func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomV
// SaveSnapshot 保存房间最新快照。 // SaveSnapshot 保存房间最新快照。
func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error { func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error {
// 快照按 room_id 保留最新一条,版本更旧的写入不会覆盖较新的恢复来源。 // 快照按 room_id 保留最新一条;同版本后写可能来自旧 owner 分支,不能覆盖已经存在的恢复来源。
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode) appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms) `INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
room_version = IF(VALUES(room_version) >= room_version, VALUES(room_version), room_version), payload = IF(VALUES(room_version) > room_version, VALUES(payload), payload),
payload = IF(VALUES(room_version) >= room_version, VALUES(payload), payload), created_at_ms = IF(VALUES(room_version) > room_version, VALUES(created_at_ms), created_at_ms),
created_at_ms = IF(VALUES(room_version) >= room_version, VALUES(created_at_ms), created_at_ms), updated_at_ms = IF(VALUES(room_version) > room_version, VALUES(updated_at_ms), updated_at_ms),
updated_at_ms = IF(VALUES(room_version) >= room_version, VALUES(updated_at_ms), updated_at_ms)`, room_version = IF(VALUES(room_version) > room_version, VALUES(room_version), room_version)`,
appCode, appCode,
snapshot.RoomID, snapshot.RoomID,
snapshot.RoomVersion, snapshot.RoomVersion,

View File

@ -0,0 +1,196 @@
package mysql
import (
"bytes"
"context"
"sync"
"testing"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
func TestSaveSnapshotDoesNotOverwriteSameVersion(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repo := newCommandLogTestRepository(t, ctx)
goodPayload := []byte("snapshot-good-v10")
if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{
AppCode: appcode.Default,
RoomID: "room-snapshot-version",
RoomVersion: 10,
Payload: goodPayload,
CreatedAtMS: 1000,
}); err != nil {
t.Fatalf("save initial snapshot: %v", err)
}
if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{
AppCode: appcode.Default,
RoomID: "room-snapshot-version",
RoomVersion: 10,
Payload: []byte("snapshot-bad-same-version"),
CreatedAtMS: 2000,
}); err != nil {
t.Fatalf("save same-version snapshot: %v", err)
}
got, exists, err := repo.GetLatestSnapshot(ctx, "room-snapshot-version")
if err != nil {
t.Fatalf("get same-version snapshot: %v", err)
}
if !exists || got.RoomVersion != 10 || !bytes.Equal(got.Payload, goodPayload) || got.CreatedAtMS != 1000 {
t.Fatalf("same-version snapshot overwrote recovery source: got version=%d created=%d payload=%q", got.RoomVersion, got.CreatedAtMS, got.Payload)
}
if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{
AppCode: appcode.Default,
RoomID: "room-snapshot-version",
RoomVersion: 11,
Payload: []byte("snapshot-new-v11"),
CreatedAtMS: 3000,
}); err != nil {
t.Fatalf("save newer snapshot: %v", err)
}
got, exists, err = repo.GetLatestSnapshot(ctx, "room-snapshot-version")
if err != nil {
t.Fatalf("get newer snapshot: %v", err)
}
if !exists || got.RoomVersion != 11 || !bytes.Equal(got.Payload, []byte("snapshot-new-v11")) {
t.Fatalf("newer snapshot should replace old one: got version=%d payload=%q", got.RoomVersion, got.Payload)
}
}
func TestSaveMutationRejectsDuplicateReplayableRoomVersion(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repo := newCommandLogTestRepository(t, ctx)
roomID := "room-command-version"
if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
AppCode: appcode.Default,
RoomID: roomID,
RoomVersion: 20,
CommandID: "cmd-first",
CommandType: "join_room",
Payload: []byte(`{"command_id":"cmd-first"}`),
Replayable: true,
OwnerNodeID: "node-a",
LeaseToken: "lease-a",
CreatedAtMS: 1000,
}}); err != nil {
t.Fatalf("save first replayable command: %v", err)
}
if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
AppCode: appcode.Default,
RoomID: roomID,
RoomVersion: 20,
CommandID: "cmd-second",
CommandType: "leave_room",
Payload: []byte(`{"command_id":"cmd-second"}`),
Replayable: true,
OwnerNodeID: "node-b",
LeaseToken: "lease-b",
CreatedAtMS: 2000,
}}); err == nil {
t.Fatal("duplicate replayable command version should be rejected")
}
if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
AppCode: appcode.Default,
RoomID: roomID,
RoomVersion: 20,
CommandID: "cmd-noop",
CommandType: "join_room",
Payload: []byte(`{"command_id":"cmd-noop"}`),
Replayable: false,
OwnerNodeID: "node-a",
LeaseToken: "lease-a",
CreatedAtMS: 3000,
}}); err != nil {
t.Fatalf("same-version non-replayable idempotency record should still be saved: %v", err)
}
}
func TestSaveMutationSerializesConcurrentReplayableRoomVersion(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repo := newCommandLogTestRepository(t, ctx)
roomID := "room-command-version-race"
insertCommandLogTestRoom(t, ctx, repo, roomID)
start := make(chan struct{})
errs := make(chan error, 2)
var wg sync.WaitGroup
for _, commandID := range []string{"cmd-race-a", "cmd-race-b"} {
wg.Add(1)
go func(commandID string) {
defer wg.Done()
<-start
errs <- repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
AppCode: appcode.Default,
RoomID: roomID,
RoomVersion: 30,
CommandID: commandID,
CommandType: "room_heartbeat",
Payload: []byte(`{"command_id":"` + commandID + `"}`),
Replayable: true,
OwnerNodeID: "node-a",
LeaseToken: "lease-a",
CreatedAtMS: 4000,
}})
}(commandID)
}
close(start)
wg.Wait()
close(errs)
successes := 0
failures := 0
for err := range errs {
if err == nil {
successes++
continue
}
failures++
}
if successes != 1 || failures != 1 {
t.Fatalf("concurrent same-version replayable writes should produce one success and one conflict, got successes=%d failures=%d", successes, failures)
}
}
func newCommandLogTestRepository(t *testing.T, ctx context.Context) *Repository {
t.Helper()
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ROOM_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_room_service.sql"),
DatabasePrefix: "hyapp_room_command_test",
})
repo, err := Open(ctx, schema.DSN, 4, 4)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
})
return repo
}
func insertCommandLogTestRoom(t *testing.T, ctx context.Context, repo *Repository, roomID string) {
t.Helper()
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO rooms (
app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, visible_region_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Default,
roomID,
roomID+"-short",
1001,
8,
"voice",
"active",
10,
1000,
1000,
); err != nil {
t.Fatalf("insert command log test room: %v", err)
}
}