hyapp-server/services/room-service/internal/room/service/pipeline_v2_internal_test.go
2026-07-12 00:47:20 +08:00

205 lines
8.3 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 (
"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 projectionsCell 本身仍正常提交。
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)
}
}