package service import ( "context" "errors" "sync" "sync/atomic" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/room/cell" "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" ) // pipelineRaceRepository embeds the wide production boundary so this unit test can focus on the // command-log contract. Any unexpected repository call still panics through the nil embedded interface. type pipelineRaceRepository struct { Repository mu sync.Mutex commands map[string]CommandRecord getCalls atomic.Int32 saveCalls atomic.Int32 thirdGet chan struct{} // loseCommitAck simulates MySQL committing the transaction and then losing the COMMIT response. loseCommitAck atomic.Bool } func (r *pipelineRaceRepository) GetCommand(_ context.Context, roomID string, commandID string) (CommandRecord, bool, error) { if r.getCalls.Add(1) == 3 && r.thirdGet != nil { close(r.thirdGet) } r.mu.Lock() defer r.mu.Unlock() record, exists := r.commands[roomID+"\x00"+commandID] return record, exists, nil } func (r *pipelineRaceRepository) SaveMutation(_ context.Context, commit MutationCommit) error { r.saveCalls.Add(1) r.mu.Lock() key := commit.Command.RoomID + "\x00" + commit.Command.CommandID if _, exists := r.commands[key]; exists { r.mu.Unlock() return ErrCommandAlreadyCommitted } record := commit.Command record.Payload = append([]byte(nil), record.Payload...) record.ResultPayload = append([]byte(nil), record.ResultPayload...) r.commands[key] = record r.mu.Unlock() if r.loseCommitAck.CompareAndSwap(true, false) { return errors.New("mysql commit acknowledgement lost") } return nil } func TestMutateRoomRechecksCommandInsideCellAndReplaysTypedResult(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := &pipelineRaceRepository{commands: make(map[string]CommandRecord), thirdGet: make(chan struct{})} directory := router.NewMemoryDirectory() svc := New(Config{NodeID: "node-pipeline-v2", LeaseTTL: 10 * time.Second, SnapshotEveryN: 1}, directory, repository, nil, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-pipeline-v2" initial := state.NewRoomState(roomID, 101, 8, "voice") initial.Version = 1 lease, err := directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), svc.nodeID, svc.clock.Now(), svc.leaseTTL) if err != nil { t.Fatalf("acquire test lease: %v", err) } svc.installCell(ctx, roomID, cell.New(initial, rank.New(20)), lease.LeaseToken) cmd := command.SendGift{Base: command.Base{AppCode: appcode.Default, CommandID: "cmd-pipeline-v2", ActorID: 101, Room: roomID}, TargetType: "user", TargetUserID: 202, TargetUserIDs: []int64{202}, RequestedTargetUserIDs: []int64{202}, GiftID: "rose", GiftCount: 1} started := make(chan struct{}) release := make(chan struct{}) var mutateCalls atomic.Int32 mutate := func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if mutateCalls.Add(1) == 1 { close(started) <-release } current.Heat += 100 current.Version++ return mutationResult{ snapshot: current.ToProto(), deleteRoom: true, // 跳过与本测试无关的 snapshot/list projections;Cell 本身仍正常提交。 giftV2: &roomv1.SendGiftResultV2{ ApiVersion: 2, CommandId: cmd.ID(), BillingReceiptId: "receipt-once", CoinBalanceAfter: 900, SettledGiftCount: 1, SettledTargetUserIds: []int64{202}, CommittedRoomVersion: current.Version, RoomHeat: current.Heat, OperationStatus: GiftOperationStatusCommitted, }, }, nil, nil } results := make(chan mutationResult, 2) errors := make(chan error, 2) go func() { result, err := svc.mutateRoom(ctx, cmd, true, mutate) results <- result errors <- err }() <-started go func() { result, err := svc.mutateRoom(ctx, cmd, true, mutate) results <- result errors <- err }() // 第三次读取是第二个请求的 Cell 外预检;确认它已经 miss 后再放开首请求,稳定复现历史竞态窗口。 <-repository.thirdGet close(release) replays := 0 for range 2 { if err := <-errors; err != nil { t.Fatalf("mutateRoom failed: %v", err) } result := <-results if result.giftV2 == nil || result.giftV2.GetBillingReceiptId() != "receipt-once" || result.giftV2.GetCoinBalanceAfter() != 900 { t.Fatalf("typed result was not preserved: %+v", result.giftV2) } if result.giftV2.GetIdempotentReplay() { replays++ } } if mutateCalls.Load() != 1 || replays != 1 { t.Fatalf("same command must mutate once and replay once: mutate_calls=%d replays=%d", mutateCalls.Load(), replays) } roomState, _, err := svc.cells[runtimeRoomKeyFromContext(ctx, roomID)].roomCell.Snapshot(ctx) if err != nil { t.Fatalf("read room state: %v", err) } if roomState.Heat != 100 || roomState.Version != 2 { t.Fatalf("duplicate command changed Cell twice: heat=%d version=%d", roomState.Heat, roomState.Version) } } func TestMutateRoomConvergesCellAfterCommittedTransactionLosesAck(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := &pipelineRaceRepository{commands: make(map[string]CommandRecord)} repository.loseCommitAck.Store(true) directory := router.NewMemoryDirectory() svc := New(Config{NodeID: "node-pipeline-commit-unknown", LeaseTTL: 10 * time.Second, SnapshotEveryN: 1}, directory, repository, nil, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-pipeline-commit-unknown" initial := state.NewRoomState(roomID, 101, 8, "voice") initial.Version = 1 lease, err := directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), svc.nodeID, svc.clock.Now(), svc.leaseTTL) if err != nil { t.Fatalf("acquire test lease: %v", err) } svc.installCell(ctx, roomID, cell.New(initial, rank.New(20)), lease.LeaseToken) cmd := command.SendGift{Base: command.Base{AppCode: appcode.Default, CommandID: "cmd-commit-unknown", ActorID: 101, Room: roomID}, TargetType: "user", TargetUserID: 202, TargetUserIDs: []int64{202}, RequestedTargetUserIDs: []int64{202}, GiftID: "rose", GiftCount: 1} var debitAndMutationCalls atomic.Int32 mutate := func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { debitAndMutationCalls.Add(1) current.Heat += 100 current.Version++ return mutationResult{ snapshot: current.ToProto(), deleteRoom: true, // Isolate the Cell/command-log contract from snapshot and list projections. giftV2: &roomv1.SendGiftResultV2{ ApiVersion: 2, CommandId: cmd.ID(), BillingReceiptId: "receipt-commit-unknown", CoinBalanceAfter: 900, BalanceVersion: 17, SettledGiftCount: 1, SettledTargetUserIds: []int64{202}, CommittedRoomVersion: current.Version, RoomHeat: current.Heat, OperationStatus: GiftOperationStatusCommitted, }, }, nil, nil } first, err := svc.mutateRoom(ctx, cmd, true, mutate) if err != nil { t.Fatalf("lost COMMIT acknowledgement should converge from the stored command: %v", err) } if !first.applied || first.giftV2 == nil || first.giftV2.GetBalanceVersion() != 17 { t.Fatalf("first typed result was not retained after commit confirmation: %+v", first.giftV2) } replay, err := svc.mutateRoom(ctx, cmd, true, mutate) if err != nil { t.Fatalf("retry should replay the confirmed typed result: %v", err) } if replay.giftV2 == nil || !replay.giftV2.GetIdempotentReplay() || replay.giftV2.GetBillingReceiptId() != "receipt-commit-unknown" || replay.giftV2.GetBalanceVersion() != 17 { t.Fatalf("typed replay drifted after unknown commit: %+v", replay.giftV2) } if debitAndMutationCalls.Load() != 1 || repository.saveCalls.Load() != 1 { t.Fatalf("unknown COMMIT must not debit or persist twice: mutate=%d save=%d", debitAndMutationCalls.Load(), repository.saveCalls.Load()) } roomState, _, err := svc.cells[runtimeRoomKeyFromContext(ctx, roomID)].roomCell.Snapshot(ctx) if err != nil { t.Fatalf("read converged room state: %v", err) } if roomState.Heat != 100 || roomState.Version != 2 { t.Fatalf("Cell did not converge exactly once: heat=%d version=%d", roomState.Heat, roomState.Version) } }