hyapp-server/services/room-service/internal/room/service/gift_operation_internal_test.go
zhx 02117bca4c fix: robot orchestrator 只运行本节点持有 lease 的机器人房
机器人房写命令间隔常大于 lease TTL(10s),双节点 orchestrator 无差别
起全量 runtime 导致互相抢占 ownership,跨节点命令全天 CONFLICT 刷屏,
礼物恢复 worker 在非 owner 节点把 pending saga 误标 compensated (~200/h)。

- startRobotRoomRuntime 启动前经 EnsureOwner 认领 lease,非 owner 跳过
- 每个 runtime 增加 lease keeper 按 TTL/3 主动续租,发现执行权被接管立即停止本地循环
- startActiveRobotRooms 改为 reconcile:lease 迁走或配置停用的房间停掉本地残留 runtime
- 礼物恢复 worker 先查房间归属,归他人时释放 claim 让 owner 节点结算
- 跨节点 ownership 冲突不再进入 compensated 终态,改走 retry(pending 不保证钱包未扣款)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 00:10:53 +08:00

252 lines
11 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"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/router"
)
type giftOperationOwnershipRepository struct {
Repository
retries []giftOperationRetryCall
compensated []giftOperationRetryCall
}
type giftOperationRetryCall struct {
roomID string
commandID string
lastError string
preserveLease bool
}
func (r *giftOperationOwnershipRepository) MarkGiftOperationRetry(_ context.Context, roomID string, commandID string, lastError string, _ int64, _ int64, preserveLease bool) error {
r.retries = append(r.retries, giftOperationRetryCall{roomID: roomID, commandID: commandID, lastError: lastError, preserveLease: preserveLease})
return nil
}
func (r *giftOperationOwnershipRepository) MarkGiftOperationCompensated(_ context.Context, roomID string, commandID string, lastError string, _ int64) error {
r.compensated = append(r.compensated, giftOperationRetryCall{roomID: roomID, commandID: commandID, lastError: lastError})
return nil
}
type compensatedGiftOperationRepository struct {
Repository
operation GiftOperation
reactivateCalls int
}
func (r *compensatedGiftOperationRepository) EnsureGiftOperation(_ context.Context, _ GiftOperation) (GiftOperation, bool, error) {
return r.operation, false, nil
}
func (r *compensatedGiftOperationRepository) ReactivateCompensatedGiftOperation(_ context.Context, _, _ string, updatedAtMS int64) (GiftOperation, bool, error) {
r.reactivateCalls++
if r.operation.Status == GiftOperationStatusCompensated {
r.operation.Status = GiftOperationStatusPending
r.operation.LastError = ""
r.operation.UpdatedAtMS = updatedAtMS
return r.operation, true, nil
}
return r.operation, false, nil
}
func TestGiftOperationRecoveryAttemptFitsInsideClaimLease(t *testing.T) {
// mutateRoom 在钱包可能已经扣费后允许一个独立的持久提交窗口,失败处理也会脱离过期请求落 retry
// claim 租约必须覆盖三段最坏边界,否则第二实例会在第一实例仍收尾时重复施压。
maxBoundedWork := giftOperationAttemptTimeout + roomMutationCommitTimeout + giftOperationPersistTimeout
if maxBoundedWork >= giftOperationClaimTTL {
t.Fatalf("recovery bounded work %s must be shorter than claim lease %s", maxBoundedWork, giftOperationClaimTTL)
}
}
func TestGiftOperationRecoveryDeadlineKeepsClaimUntilCellCommitBudgetEnds(t *testing.T) {
for _, err := range []error{
context.DeadlineExceeded,
context.Canceled,
status.Error(codes.DeadlineExceeded, "wallet timed out"),
status.Error(codes.Canceled, "attempt canceled"),
} {
if !giftOperationFailureMustKeepClaim(true, err) {
t.Fatalf("recovery timeout must preserve its claim: %v", err)
}
}
if giftOperationFailureMustKeepClaim(false, context.DeadlineExceeded) {
t.Fatal("foreground request has no recovery claim to preserve")
}
if giftOperationFailureMustKeepClaim(true, errors.New("wallet unavailable")) {
t.Fatal("completed retryable failures may release the claim for the next attempt")
}
}
func TestGiftOperationFailureOwnershipConflictRetriesInsteadOfCompensating(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
now := time.Date(2026, 7, 12, 9, 0, 0, 0, time.UTC)
req := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: "room-owner-conflict", CommandId: "cmd-owner-conflict", ActorUserId: 101},
TargetType: "user",
TargetUserId: 202,
GiftId: "rose",
GiftCount: 1,
}
repository := &giftOperationOwnershipRepository{}
flow := newGiftFlow(&Service{repository: repository, clock: fixedRoomTestClock{now: now}}, ctx, req, giftSendOptions{})
flow.operationStarted = true
flow.recovering = true
flow.handleGiftOperationFailure(roomOwnedByOtherNodeError("room-node-b"))
if len(repository.compensated) != 0 {
t.Fatalf("cross-node ownership conflict must not compensate a pending saga: %+v", repository.compensated)
}
if len(repository.retries) != 1 || repository.retries[0].preserveLease {
t.Fatalf("ownership conflict should release the claim for the owner node to retry: %+v", repository.retries)
}
repository = &giftOperationOwnershipRepository{}
flow = newGiftFlow(&Service{repository: repository, clock: fixedRoomTestClock{now: now}}, ctx, req, giftSendOptions{})
flow.operationStarted = true
flow.handleGiftOperationFailure(xerr.New(xerr.Conflict, "command_id payload conflict"))
if len(repository.compensated) != 1 || len(repository.retries) != 0 {
t.Fatalf("business conflict before debit must stay compensated: compensated=%+v retries=%+v", repository.compensated, repository.retries)
}
}
func TestRecoverGiftOperationDefersToOwnerNode(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 7, 12, 9, 0, 0, 0, time.UTC)
directory := router.NewMemoryDirectory()
repository := &giftOperationOwnershipRepository{}
svc := &Service{
nodeID: "room-node-a",
leaseTTL: 10 * time.Second,
clock: fixedRoomTestClock{now: now},
directory: directory,
repository: repository,
}
operation := GiftOperation{
AppCode: appcode.Default,
RoomID: "room-recovery-owner",
CommandID: "cmd-recovery-owner",
Status: GiftOperationStatusPending,
}
if _, err := directory.EnsureOwner(ctx, runtimeRoomKey(operation.AppCode, operation.RoomID), "room-node-b", now, 10*time.Second); err != nil {
t.Fatalf("seed other-node lease: %v", err)
}
// RequestPayload 故意留空:非 owner 分支必须在解码存量请求、进入 Room Cell 之前把任务让给 owner。
svc.recoverGiftOperation(ctx, operation)
if len(repository.retries) != 1 {
t.Fatalf("non-owner recovery must defer exactly once, got %+v", repository.retries)
}
if repository.retries[0].preserveLease || repository.retries[0].lastError != roomOwnedByOtherNodeMsgPrefix+"room-node-b" {
t.Fatalf("defer must release the claim and record the current owner: %+v", repository.retries[0])
}
if len(repository.compensated) != 0 {
t.Fatalf("defer must not compensate the operation: %+v", repository.compensated)
}
}
func TestIsRoomOwnedByOtherNodeError(t *testing.T) {
if !isRoomOwnedByOtherNodeError(roomOwnedByOtherNodeError("room-node-b")) {
t.Fatal("ownership conflict error must be recognized")
}
for _, err := range []error{
xerr.New(xerr.Conflict, "command_id payload conflict"),
xerr.New(xerr.Unavailable, roomOwnedByOtherNodeMsgPrefix+"room-node-b"),
errors.New(roomOwnedByOtherNodeMsgPrefix + "room-node-b"),
} {
if isRoomOwnedByOtherNodeError(err) {
t.Fatalf("must not treat %v as cross-node ownership conflict", err)
}
}
}
func TestCompensatedGiftOperationReactivatesOnlyAfterPayloadMatch(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
originalRequest := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: "room-reactivate", CommandId: "cmd-reactivate", ActorUserId: 101},
TargetType: "user", TargetUserId: 202, GiftId: "rose", GiftCount: 1, SenderRegionId: 10,
TargetIsHost: true, TargetHostRegionId: 20, TargetAgencyOwnerUserId: 303,
SenderDisplayProfile: &roomv1.SendGiftDisplayProfile{UserId: 101, Username: "first sender"},
}
// Build the stored command through the same normalizer used by production so this test exercises
// the semantic idempotency comparison instead of depending on hand-written JSON field defaults.
originalFlow := newGiftFlow(&Service{}, ctx, originalRequest, giftSendOptions{})
storedPayload, err := command.Serialize(originalFlow.cmd)
if err != nil {
t.Fatalf("serialize stored gift command: %v", err)
}
storedRequestPayload, err := proto.Marshal(originalRequest)
if err != nil {
t.Fatalf("serialize stored gift request: %v", err)
}
t.Run("conflicting payload stays compensated", func(t *testing.T) {
repository := &compensatedGiftOperationRepository{operation: GiftOperation{
AppCode: appcode.Default, RoomID: "room-reactivate", CommandID: "cmd-reactivate",
CommandPayload: storedPayload, RequestPayload: storedRequestPayload,
Status: GiftOperationStatusCompensated, LastError: "insufficient balance",
}}
service := &Service{repository: repository}
conflictingRequest := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: "room-reactivate", CommandId: "cmd-reactivate", ActorUserId: 101},
TargetType: "user", TargetUserId: 202, GiftId: "different-gift", GiftCount: 1,
}
flow := newGiftFlow(service, ctx, conflictingRequest, giftSendOptions{})
if err := flow.ensureGiftOperation(time.UnixMilli(2_000)); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("conflicting command must fail before reactivation: %v", err)
}
if repository.reactivateCalls != 0 || repository.operation.Status != GiftOperationStatusCompensated {
t.Fatalf("conflict exposed stored work to recovery: calls=%d operation=%+v", repository.reactivateCalls, repository.operation)
}
})
t.Run("identical payload CAS reactivates", func(t *testing.T) {
repository := &compensatedGiftOperationRepository{operation: GiftOperation{
AppCode: appcode.Default, RoomID: "room-reactivate", CommandID: "cmd-reactivate",
CommandPayload: storedPayload, RequestPayload: storedRequestPayload,
Status: GiftOperationStatusCompensated, LastError: "insufficient balance",
}}
service := &Service{repository: repository}
flow := newGiftFlow(service, ctx, originalRequest, giftSendOptions{})
if err := flow.ensureGiftOperation(time.UnixMilli(2_000)); err != nil {
t.Fatalf("identical command should reactivate: %v", err)
}
if repository.reactivateCalls != 1 || repository.operation.Status != GiftOperationStatusPending || !flow.operationStarted {
t.Fatalf("identical command did not enter pending exactly once: calls=%d operation=%+v", repository.reactivateCalls, repository.operation)
}
})
t.Run("load miss duplicate resumes first gateway snapshots", func(t *testing.T) {
repository := &compensatedGiftOperationRepository{operation: GiftOperation{
AppCode: appcode.Default, RoomID: "room-reactivate", CommandID: "cmd-reactivate",
CommandPayload: storedPayload, RequestPayload: storedRequestPayload, Status: GiftOperationStatusPending,
}}
service := &Service{repository: repository}
refreshedRequest := proto.Clone(originalRequest).(*roomv1.SendGiftRequest)
refreshedRequest.SenderRegionId = 99
refreshedRequest.TargetHostRegionId = 88
refreshedRequest.TargetAgencyOwnerUserId = 404
refreshedRequest.SenderDisplayProfile = &roomv1.SendGiftDisplayProfile{UserId: 101, Username: "renamed sender"}
flow := newGiftFlow(service, ctx, refreshedRequest, giftSendOptions{})
if err := flow.ensureGiftOperation(time.UnixMilli(2_000)); err != nil {
t.Fatalf("idempotency-equivalent duplicate should resume stored operation: %v", err)
}
if !flow.recovering || flow.cmd.SenderRegionID != 10 || flow.req.GetSenderRegionId() != 10 || flow.cmd.TargetHostRegionID != 20 || flow.req.GetTargetHostRegionId() != 20 || giftDisplayName(flow.cmd.SenderDisplayProfile) != "first sender" {
t.Fatalf("duplicate continued with refreshed gateway snapshots: command=%+v request=%+v", flow.cmd, flow.req)
}
if repository.reactivateCalls != 0 {
t.Fatalf("already-pending duplicate must not reactivate: %d", repository.reactivateCalls)
}
})
}