233 lines
8.1 KiB
Go
233 lines
8.1 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/logx"
|
||
"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) {
|
||
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")
|
||
}
|
||
|
||
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
|
||
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 时完成恢复。
|
||
roomCell, lease, err := s.ensureCell(ctx, cmd.RoomID())
|
||
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
|
||
}
|
||
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
|
||
}
|
||
// 保存已提交 outbox 记录,出 Cell 锁后再通知 activity-service;外部服务慢调用不能占住房间串行执行队列。
|
||
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
|
||
}
|
||
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
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, err
|
||
}
|
||
|
||
result := value.(mutationResult)
|
||
|
||
if result.applied && shouldSnapshot(result.snapshot, s.snapshotEveryN) {
|
||
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
|
||
if err := s.persistSnapshot(ctx, result.snapshot); err != nil {
|
||
return mutationResult{}, err
|
||
}
|
||
}
|
||
if result.applied {
|
||
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
||
s.projectRoomListBestEffort(ctx, result.snapshot)
|
||
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
|
||
s.projectRoomPresenceBestEffort(ctx, result.snapshot)
|
||
// activity-service 消费的是已经落库的 room outbox 事实,失败只记日志;outbox 补偿链路仍保留房间 IM 投递能力。
|
||
s.publishRoomEventsBestEffort(ctx, result.outboxRecords)
|
||
}
|
||
|
||
if result.applied && result.syncEvent != nil {
|
||
// 同步腾讯云 IM 失败不回滚房间状态;outbox 后续负责补偿投递。
|
||
if err := s.syncPublisher.PublishRoomEvent(ctx, *result.syncEvent); err == nil {
|
||
// 同步投递成功后 best-effort 标记 delivered,避免补偿 worker 对同一 event_id 重复补投。
|
||
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(ctx)), defaultOutboxWorkerOptions().PublishTimeout)
|
||
markErr := s.repository.MarkOutboxDelivered(markCtx, result.syncEvent.EventID)
|
||
cancel()
|
||
if markErr != nil {
|
||
logx.Error(ctx, "outbox_mark_delivered_failed", markErr,
|
||
slog.String("worker", outboxWorkerName),
|
||
slog.String("node_id", s.nodeID),
|
||
slog.String("event_id", result.syncEvent.EventID),
|
||
slog.String("room_id", result.syncEvent.RoomID),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) publishRoomEventsBestEffort(ctx context.Context, records []outbox.Record) {
|
||
if s == nil || s.eventPublisher == nil || len(records) == 0 {
|
||
return
|
||
}
|
||
for _, record := range records {
|
||
if record.Envelope == nil {
|
||
// 只有 protobuf envelope 能跨服务消费;没有 envelope 的历史/本地事件仍只参与原 outbox 投递。
|
||
continue
|
||
}
|
||
if err := s.eventPublisher.PublishOutboxEvent(ctx, record.Envelope); err != nil {
|
||
logx.Warn(ctx, "room_event_consumer_publish_failed",
|
||
slog.String("node_id", s.nodeID),
|
||
slog.String("event_id", record.EventID),
|
||
slog.String("event_type", record.EventType),
|
||
slog.String("room_id", record.RoomID),
|
||
slog.String("error", err.Error()),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|