feat(idempotency): 为关键写接口接入幂等并完善并发容错
This commit is contained in:
805
backend/internal/service/idempotency_test.go
Normal file
805
backend/internal/service/idempotency_test.go
Normal file
@@ -0,0 +1,805 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type inMemoryIdempotencyRepo struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
data map[string]*IdempotencyRecord
|
||||
}
|
||||
|
||||
func newInMemoryIdempotencyRepo() *inMemoryIdempotencyRepo {
|
||||
return &inMemoryIdempotencyRepo{
|
||||
nextID: 1,
|
||||
data: make(map[string]*IdempotencyRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) key(scope, hash string) string {
|
||||
return scope + "|" + hash
|
||||
}
|
||||
|
||||
func cloneRecord(in *IdempotencyRecord) *IdempotencyRecord {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
if in.ResponseStatus != nil {
|
||||
v := *in.ResponseStatus
|
||||
out.ResponseStatus = &v
|
||||
}
|
||||
if in.ResponseBody != nil {
|
||||
v := *in.ResponseBody
|
||||
out.ResponseBody = &v
|
||||
}
|
||||
if in.ErrorReason != nil {
|
||||
v := *in.ErrorReason
|
||||
out.ErrorReason = &v
|
||||
}
|
||||
if in.LockedUntil != nil {
|
||||
v := *in.LockedUntil
|
||||
out.LockedUntil = &v
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) CreateProcessing(_ context.Context, record *IdempotencyRecord) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
k := r.key(record.Scope, record.IdempotencyKeyHash)
|
||||
if _, ok := r.data[k]; ok {
|
||||
return false, nil
|
||||
}
|
||||
rec := cloneRecord(record)
|
||||
rec.ID = r.nextID
|
||||
rec.CreatedAt = time.Now()
|
||||
rec.UpdatedAt = rec.CreatedAt
|
||||
r.nextID++
|
||||
r.data[k] = rec
|
||||
record.ID = rec.ID
|
||||
record.CreatedAt = rec.CreatedAt
|
||||
record.UpdatedAt = rec.UpdatedAt
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) GetByScopeAndKeyHash(_ context.Context, scope, keyHash string) (*IdempotencyRecord, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return cloneRecord(r.data[r.key(scope, keyHash)]), nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) TryReclaim(_ context.Context, id int64, fromStatus string, now, newLockedUntil, newExpiresAt time.Time) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
if rec.Status != fromStatus {
|
||||
return false, nil
|
||||
}
|
||||
if rec.LockedUntil != nil && rec.LockedUntil.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
rec.Status = IdempotencyStatusProcessing
|
||||
rec.LockedUntil = &newLockedUntil
|
||||
rec.ExpiresAt = newExpiresAt
|
||||
rec.ErrorReason = nil
|
||||
rec.UpdatedAt = time.Now()
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) ExtendProcessingLock(_ context.Context, id int64, requestFingerprint string, newLockedUntil, newExpiresAt time.Time) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
if rec.Status != IdempotencyStatusProcessing || rec.RequestFingerprint != requestFingerprint {
|
||||
return false, nil
|
||||
}
|
||||
rec.LockedUntil = &newLockedUntil
|
||||
rec.ExpiresAt = newExpiresAt
|
||||
rec.UpdatedAt = time.Now()
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) MarkSucceeded(_ context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
rec.Status = IdempotencyStatusSucceeded
|
||||
rec.LockedUntil = nil
|
||||
rec.ExpiresAt = expiresAt
|
||||
rec.UpdatedAt = time.Now()
|
||||
rec.ErrorReason = nil
|
||||
rec.ResponseStatus = &responseStatus
|
||||
rec.ResponseBody = &responseBody
|
||||
return nil
|
||||
}
|
||||
return errors.New("record not found")
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) MarkFailedRetryable(_ context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, rec := range r.data {
|
||||
if rec.ID != id {
|
||||
continue
|
||||
}
|
||||
rec.Status = IdempotencyStatusFailedRetryable
|
||||
rec.LockedUntil = &lockedUntil
|
||||
rec.ExpiresAt = expiresAt
|
||||
rec.UpdatedAt = time.Now()
|
||||
rec.ErrorReason = &errorReason
|
||||
return nil
|
||||
}
|
||||
return errors.New("record not found")
|
||||
}
|
||||
|
||||
func (r *inMemoryIdempotencyRepo) DeleteExpired(_ context.Context, now time.Time, _ int) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var deleted int64
|
||||
for k, rec := range r.data {
|
||||
if !rec.ExpiresAt.After(now) {
|
||||
delete(r.data, k)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_RequireKey(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
cfg.ObserveOnly = false
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "admin:1",
|
||||
RequireKey: true,
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyRequired))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ReplaySucceededResult(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
execCount := 0
|
||||
exec := func(ctx context.Context) (any, error) {
|
||||
execCount++
|
||||
return map[string]any{"count": execCount}, nil
|
||||
}
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}
|
||||
|
||||
first, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.False(t, first.Replayed)
|
||||
|
||||
second, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.True(t, second.Replayed)
|
||||
require.Equal(t, 1, execCount, "second request should replay without executing business logic")
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.Equal(t, uint64(1), metrics.ClaimTotal)
|
||||
require.Equal(t, uint64(1), metrics.ReplayTotal)
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ReclaimExpiredSucceededRecord(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope.expired",
|
||||
Method: "POST",
|
||||
Route: "/test/expired",
|
||||
ActorScope: "user:99",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "expired-case",
|
||||
Payload: map[string]any{"k": "v"},
|
||||
}
|
||||
|
||||
execCount := 0
|
||||
exec := func(ctx context.Context) (any, error) {
|
||||
execCount++
|
||||
return map[string]any{"count": execCount}, nil
|
||||
}
|
||||
|
||||
first, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
require.False(t, first.Replayed)
|
||||
require.Equal(t, 1, execCount)
|
||||
|
||||
keyHash := HashIdempotencyKey(opts.IdempotencyKey)
|
||||
repo.mu.Lock()
|
||||
existing := repo.data[repo.key(opts.Scope, keyHash)]
|
||||
require.NotNil(t, existing)
|
||||
existing.ExpiresAt = time.Now().Add(-time.Second)
|
||||
repo.mu.Unlock()
|
||||
|
||||
second, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, second)
|
||||
require.False(t, second.Replayed, "expired record should be reclaimed and execute business logic again")
|
||||
require.Equal(t, 2, execCount)
|
||||
|
||||
third, err := coordinator.Execute(context.Background(), opts, exec)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, third)
|
||||
require.True(t, third.Replayed)
|
||||
payload, ok := third.Data.(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, float64(2), payload["count"])
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.GreaterOrEqual(t, metrics.ClaimTotal, uint64(2))
|
||||
require.GreaterOrEqual(t, metrics.ReplayTotal, uint64(1))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_SameKeyDifferentPayloadConflict(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-2",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-2",
|
||||
Payload: map[string]any{"a": 2},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyConflict))
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.Equal(t, uint64(1), metrics.ConflictTotal)
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_BackoffAfterRetryableFailure(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
cfg.FailedRetryBackoff = 2 * time.Second
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope",
|
||||
Method: "POST",
|
||||
Route: "/test",
|
||||
ActorScope: "user:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-3",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
return nil, infraerrors.InternalServer("UPSTREAM_ERROR", "upstream error")
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyRetryBackoff))
|
||||
require.Greater(t, RetryAfterSecondsFromError(err), 0)
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.GreaterOrEqual(t, metrics.RetryBackoffTotal, uint64(2))
|
||||
require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
|
||||
require.GreaterOrEqual(t, metrics.ProcessingDurationCount, uint64(1))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ConcurrentSameKeySingleSideEffect(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
cfg.ProcessingTimeout = 2 * time.Second
|
||||
coordinator := NewIdempotencyCoordinator(repo, cfg)
|
||||
|
||||
opts := IdempotencyExecuteOptions{
|
||||
Scope: "test.scope.concurrent",
|
||||
Method: "POST",
|
||||
Route: "/test/concurrent",
|
||||
ActorScope: "user:7",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "concurrent-case",
|
||||
Payload: map[string]any{"v": 1},
|
||||
}
|
||||
|
||||
var execCount int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
atomic.AddInt32(&execCount, 1)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
replayed, err := coordinator.Execute(context.Background(), opts, func(ctx context.Context) (any, error) {
|
||||
atomic.AddInt32(&execCount, 1)
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, replayed.Replayed)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&execCount), "concurrent same-key requests should execute business side-effect once")
|
||||
|
||||
metrics := GetIdempotencyMetricsSnapshot()
|
||||
require.Equal(t, uint64(1), metrics.ClaimTotal)
|
||||
require.Equal(t, uint64(1), metrics.ReplayTotal)
|
||||
require.GreaterOrEqual(t, metrics.ConflictTotal, uint64(1))
|
||||
}
|
||||
|
||||
type failingIdempotencyRepo struct{}
|
||||
|
||||
func (failingIdempotencyRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
|
||||
return false, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
|
||||
return nil, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
|
||||
return false, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
|
||||
return false, errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
|
||||
return errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
|
||||
return errors.New("store unavailable")
|
||||
}
|
||||
func (failingIdempotencyRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
|
||||
return 0, errors.New("store unavailable")
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_StoreUnavailableMetrics(t *testing.T) {
|
||||
resetIdempotencyMetricsForTest()
|
||||
coordinator := NewIdempotencyCoordinator(failingIdempotencyRepo{}, DefaultIdempotencyConfig())
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "test.scope.unavailable",
|
||||
Method: "POST",
|
||||
Route: "/test/unavailable",
|
||||
ActorScope: "admin:1",
|
||||
RequireKey: true,
|
||||
IdempotencyKey: "case-unavailable",
|
||||
Payload: map[string]any{"v": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
require.GreaterOrEqual(t, GetIdempotencyMetricsSnapshot().StoreUnavailableTotal, uint64(1))
|
||||
}
|
||||
|
||||
func TestDefaultIdempotencyCoordinatorAndTTLs(t *testing.T) {
|
||||
SetDefaultIdempotencyCoordinator(nil)
|
||||
require.Nil(t, DefaultIdempotencyCoordinator())
|
||||
require.Equal(t, DefaultIdempotencyConfig().DefaultTTL, DefaultWriteIdempotencyTTL())
|
||||
require.Equal(t, DefaultIdempotencyConfig().SystemOperationTTL, DefaultSystemOperationIdempotencyTTL())
|
||||
|
||||
coordinator := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
|
||||
DefaultTTL: 2 * time.Hour,
|
||||
SystemOperationTTL: 15 * time.Minute,
|
||||
ProcessingTimeout: 10 * time.Second,
|
||||
FailedRetryBackoff: 3 * time.Second,
|
||||
ObserveOnly: false,
|
||||
})
|
||||
SetDefaultIdempotencyCoordinator(coordinator)
|
||||
t.Cleanup(func() {
|
||||
SetDefaultIdempotencyCoordinator(nil)
|
||||
})
|
||||
|
||||
require.Same(t, coordinator, DefaultIdempotencyCoordinator())
|
||||
require.Equal(t, 2*time.Hour, DefaultWriteIdempotencyTTL())
|
||||
require.Equal(t, 15*time.Minute, DefaultSystemOperationIdempotencyTTL())
|
||||
}
|
||||
|
||||
func TestNormalizeIdempotencyKeyAndFingerprint(t *testing.T) {
|
||||
key, err := NormalizeIdempotencyKey(" abc-123 ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "abc-123", key)
|
||||
|
||||
key, err = NormalizeIdempotencyKey("")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", key)
|
||||
|
||||
_, err = NormalizeIdempotencyKey(string(make([]byte, 129)))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = NormalizeIdempotencyKey("bad\nkey")
|
||||
require.Error(t, err)
|
||||
|
||||
fp1, err := BuildIdempotencyFingerprint("", "", "", map[string]any{"a": 1})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, fp1)
|
||||
fp2, err := BuildIdempotencyFingerprint("POST", "/", "anonymous", map[string]any{"a": 1})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fp1, fp2)
|
||||
|
||||
_, err = BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"bad": make(chan int)})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyInvalidPayload), infraerrors.Code(err))
|
||||
}
|
||||
|
||||
func TestRetryAfterSecondsFromErrorBranches(t *testing.T) {
|
||||
require.Equal(t, 0, RetryAfterSecondsFromError(nil))
|
||||
require.Equal(t, 0, RetryAfterSecondsFromError(errors.New("plain")))
|
||||
|
||||
err := ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "12"})
|
||||
require.Equal(t, 12, RetryAfterSecondsFromError(err))
|
||||
|
||||
err = ErrIdempotencyInProgress.WithMetadata(map[string]string{"retry_after": "bad"})
|
||||
require.Equal(t, 0, RetryAfterSecondsFromError(err))
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ExecuteNilExecutorAndNoKeyPassThrough(t *testing.T) {
|
||||
repo := newInMemoryIdempotencyRepo()
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "IDEMPOTENCY_EXECUTOR_NIL", infraerrors.Reason(err))
|
||||
|
||||
called := 0
|
||||
result, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
RequireKey: true,
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
called++
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, called)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.Replayed)
|
||||
}
|
||||
|
||||
type noIDOwnerRepo struct{}
|
||||
|
||||
func (noIDOwnerRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (noIDOwnerRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (noIDOwnerRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (noIDOwnerRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (noIDOwnerRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error { return nil }
|
||||
func (noIDOwnerRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (noIDOwnerRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) { return 0, nil }
|
||||
|
||||
func TestIdempotencyCoordinator_RepoNilScopeRequiredAndRecordIDMissing(t *testing.T) {
|
||||
cfg := DefaultIdempotencyConfig()
|
||||
coordinator := NewIdempotencyCoordinator(nil, cfg)
|
||||
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
coordinator = NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), cfg)
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
IdempotencyKey: "k2",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "IDEMPOTENCY_SCOPE_REQUIRED", infraerrors.Reason(err))
|
||||
|
||||
coordinator = NewIdempotencyCoordinator(noIDOwnerRepo{}, cfg)
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-no-id",
|
||||
IdempotencyKey: "k3",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
}
|
||||
|
||||
type conflictBranchRepo struct {
|
||||
existing *IdempotencyRecord
|
||||
tryReclaimErr error
|
||||
tryReclaimOK bool
|
||||
}
|
||||
|
||||
func (r *conflictBranchRepo) CreateProcessing(context.Context, *IdempotencyRecord) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *conflictBranchRepo) GetByScopeAndKeyHash(context.Context, string, string) (*IdempotencyRecord, error) {
|
||||
return cloneRecord(r.existing), nil
|
||||
}
|
||||
func (r *conflictBranchRepo) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
|
||||
if r.tryReclaimErr != nil {
|
||||
return false, r.tryReclaimErr
|
||||
}
|
||||
return r.tryReclaimOK, nil
|
||||
}
|
||||
func (r *conflictBranchRepo) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *conflictBranchRepo) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *conflictBranchRepo) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *conflictBranchRepo) DeleteExpired(context.Context, time.Time, int) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_ConflictBranchesAndDecodeError(t *testing.T) {
|
||||
now := time.Now()
|
||||
fp, err := BuildIdempotencyFingerprint("POST", "/x", "u:1", map[string]any{"a": 1})
|
||||
require.NoError(t, err)
|
||||
badBody := "{bad-json"
|
||||
repo := &conflictBranchRepo{
|
||||
existing: &IdempotencyRecord{
|
||||
ID: 1,
|
||||
Scope: "scope",
|
||||
IdempotencyKeyHash: HashIdempotencyKey("k"),
|
||||
RequestFingerprint: fp,
|
||||
Status: IdempotencyStatusSucceeded,
|
||||
ResponseBody: &badBody,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
},
|
||||
}
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.existing = &IdempotencyRecord{
|
||||
ID: 2,
|
||||
Scope: "scope",
|
||||
IdempotencyKeyHash: HashIdempotencyKey("k"),
|
||||
RequestFingerprint: fp,
|
||||
Status: "unknown",
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyKeyConflict), infraerrors.Code(err))
|
||||
|
||||
repo.existing = &IdempotencyRecord{
|
||||
ID: 3,
|
||||
Scope: "scope",
|
||||
IdempotencyKeyHash: HashIdempotencyKey("k"),
|
||||
RequestFingerprint: fp,
|
||||
Status: IdempotencyStatusFailedRetryable,
|
||||
LockedUntil: ptrTime(now.Add(-time.Second)),
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
repo.tryReclaimErr = errors.New("reclaim down")
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.tryReclaimErr = nil
|
||||
repo.tryReclaimOK = false
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope",
|
||||
IdempotencyKey: "k",
|
||||
Method: "POST",
|
||||
Route: "/x",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyInProgress), infraerrors.Code(err))
|
||||
}
|
||||
|
||||
type markBehaviorRepo struct {
|
||||
inMemoryIdempotencyRepo
|
||||
failMarkSucceeded bool
|
||||
failMarkFailed bool
|
||||
}
|
||||
|
||||
func (r *markBehaviorRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
|
||||
if r.failMarkSucceeded {
|
||||
return errors.New("mark succeeded failed")
|
||||
}
|
||||
return r.inMemoryIdempotencyRepo.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
|
||||
}
|
||||
|
||||
func (r *markBehaviorRepo) MarkFailedRetryable(ctx context.Context, id int64, errorReason string, lockedUntil, expiresAt time.Time) error {
|
||||
if r.failMarkFailed {
|
||||
return errors.New("mark failed retryable failed")
|
||||
}
|
||||
return r.inMemoryIdempotencyRepo.MarkFailedRetryable(ctx, id, errorReason, lockedUntil, expiresAt)
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_MarkAndMarshalBranches(t *testing.T) {
|
||||
repo := &markBehaviorRepo{inMemoryIdempotencyRepo: *newInMemoryIdempotencyRepo()}
|
||||
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
|
||||
|
||||
repo.failMarkSucceeded = true
|
||||
_, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-success",
|
||||
IdempotencyKey: "k1",
|
||||
Method: "POST",
|
||||
Route: "/ok",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.failMarkSucceeded = false
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-marshal",
|
||||
IdempotencyKey: "k2",
|
||||
Method: "POST",
|
||||
Route: "/bad",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return map[string]any{"bad": make(chan int)}, nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, infraerrors.Code(ErrIdempotencyStoreUnavail), infraerrors.Code(err))
|
||||
|
||||
repo.failMarkFailed = true
|
||||
_, err = coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
|
||||
Scope: "scope-fail",
|
||||
IdempotencyKey: "k3",
|
||||
Method: "POST",
|
||||
Route: "/fail",
|
||||
ActorScope: "u:1",
|
||||
Payload: map[string]any{"a": 1},
|
||||
}, func(ctx context.Context) (any, error) {
|
||||
return nil, errors.New("plain failure")
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "plain failure", err.Error())
|
||||
}
|
||||
|
||||
func TestIdempotencyCoordinator_HelperBranches(t *testing.T) {
|
||||
c := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), IdempotencyConfig{
|
||||
DefaultTTL: time.Hour,
|
||||
SystemOperationTTL: time.Hour,
|
||||
ProcessingTimeout: time.Second,
|
||||
FailedRetryBackoff: time.Second,
|
||||
MaxStoredResponseLen: 12,
|
||||
ObserveOnly: false,
|
||||
})
|
||||
|
||||
// conflictWithRetryAfter without locked_until should return base error.
|
||||
base := ErrIdempotencyInProgress
|
||||
err := c.conflictWithRetryAfter(base, nil, time.Now())
|
||||
require.Equal(t, infraerrors.Code(base), infraerrors.Code(err))
|
||||
|
||||
// marshalStoredResponse should truncate.
|
||||
body, err := c.marshalStoredResponse(map[string]any{"long": "abcdefghijklmnopqrstuvwxyz"})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, body, "...(truncated)")
|
||||
|
||||
// decodeStoredResponse empty and invalid json.
|
||||
out, err := c.decodeStoredResponse(nil)
|
||||
require.NoError(t, err)
|
||||
_, ok := out.(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
invalid := "{invalid"
|
||||
_, err = c.decodeStoredResponse(&invalid)
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user