2026-05-12 19:34:47 +08:00

214 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"context"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
"hyapp/services/room-service/internal/router"
)
func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) {
startedAt := time.Now()
var idempotencyMS int64
var leaseMS int64
var saveMutationMS int64
var snapshotMS int64
var projectionMS int64
if cmd.RoomID() == "" || cmd.ID() == "" || cmd.ActorUserID() == 0 {
// command meta 是幂等、路由、审计和权限判断的共同基础,缺一不可。
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
}
if s.isDraining() {
// 下线中的节点只让已经进入执行链路的命令收尾,不再接收新的 Room Cell 命令。
return mutationResult{}, xerr.New(xerr.Unavailable, "room service is draining")
}
idempotencyStartedAt := time.Now()
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
idempotencyMS = elapsedMS(idempotencyStartedAt)
if err != nil {
return mutationResult{}, err
}
if seen {
// 幂等命令不重新执行 mutate也不会再次调用 wallet 或写 outbox。
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
if err != nil {
return mutationResult{}, err
}
return mutationResult{snapshot: snapshot}, nil
}
// 确保当前节点持有房间 lease并在没有内存 Cell 时完成恢复。
leaseStartedAt := time.Now()
roomCell, lease, err := s.ensureCell(ctx, cmd.RoomID())
leaseMS = elapsedMS(leaseStartedAt)
if err != nil {
return mutationResult{}, err
}
value, err := roomCell.Do(ctx, func(current *state.RoomState, currentRank *rank.LocalRank) (any, error) {
// 在副本上执行命令,只有 command log/outbox 保存成功后才整体替换当前状态。
nextState := current.Clone()
nextRank := currentRank.Clone()
now := s.clock.Now()
result, outboxRecords, err := mutate(now, nextState, nextRank)
if err != nil {
// 校验、钱包或业务失败时不提交状态,不写 command log。
return nil, err
}
if result.snapshot == nil {
// 无特殊返回需求的命令默认返回变更后的快照。
result.snapshot = nextState.ToProto()
}
result.snapshot = snapshotWithApp(ctx, result.snapshot)
if result.snapshot.GetVersion() != current.Version {
// 版本变化代表本命令产生实际状态变更,需要持久化命令和事件。
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err
}
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: nextState.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: commandPayloadForRecord(payload, result),
Replayable: replayable,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
OutboxRecords: outboxRecords,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
}); err != nil {
return nil, err
}
saveMutationMS += elapsedMS(saveStartedAt)
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity避免外部慢调用占住房间串行执行队列。
result.outboxRecords = outboxRecords
// 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。
current.ReplaceWith(nextState)
currentRank.ReplaceWith(nextRank)
result.snapshot = snapshotWithApp(ctx, current.ToProto())
result.applied = true
} else {
// no-op 命令仍写入 command log保证相同 command_id 重试可命中幂等,
// 不同 payload 复用 command_id 会被后续入口拒绝为 CONFLICT。
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err
}
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: current.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: payload,
Replayable: false,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
}); err != nil {
return nil, err
}
saveMutationMS += elapsedMS(saveStartedAt)
}
return result, nil
})
if err != nil {
return mutationResult{}, err
}
result := value.(mutationResult)
if result.applied && shouldSnapshot(result.snapshot, s.snapshotEveryN) {
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
snapshotStartedAt := time.Now()
if err := s.persistSnapshot(ctx, result.snapshot); err != nil {
return mutationResult{}, err
}
snapshotMS = elapsedMS(snapshotStartedAt)
}
if result.applied {
projectionStartedAt := time.Now()
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
s.projectRoomListBestEffort(ctx, result.snapshot)
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
s.projectRoomPresenceBestEffort(ctx, result.snapshot)
projectionMS = elapsedMS(projectionStartedAt)
}
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), result.applied, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, result.walletDebitMS, 0, 0, len(result.outboxRecords) > 0, result.syncEvent != nil)
return result, nil
}
func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) {
payload, err := command.Serialize(cmd)
if err != nil {
return nil, false, err
}
record, exists, err := s.repository.GetCommand(ctx, cmd.RoomID(), cmd.ID())
if err != nil {
return nil, false, err
}
if !exists {
return payload, false, nil
}
currentIDPayload, err := command.IdempotencyPayload(payload)
if err != nil {
return nil, true, err
}
storedIDPayload, err := command.IdempotencyPayload(record.Payload)
if err != nil {
return nil, true, err
}
if record.CommandType != cmd.Type() || !bytes.Equal(storedIDPayload, currentIDPayload) {
return nil, true, xerr.New(xerr.Conflict, "command_id payload conflict")
}
return payload, true, nil
}
func commandPayloadForRecord(defaultPayload []byte, result mutationResult) []byte {
if len(result.commandPayload) > 0 {
return result.commandPayload
}
return defaultPayload
}
func (s *Service) ensureLeaseStillOwned(ctx context.Context, roomID string, lease router.Lease) error {
ok, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, lease.LeaseToken, s.clock.Now())
if err != nil {
return err
}
if !ok {
return xerr.New(xerr.Unavailable, "room lease is no longer owned by this node")
}
return nil
}
func elapsedMS(start time.Time) int64 {
return time.Since(start).Milliseconds()
}