account_traffic_cache_test.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. package repository
  2. import (
  3. "context"
  4. "fmt"
  5. "sync"
  6. "sync/atomic"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/alicebob/miniredis/v2"
  11. "github.com/redis/go-redis/v9"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func trafficFixture(t *testing.T) (*miniredis.Miniredis, *accountTrafficCache, service.AccountTrafficPlan) {
  15. t.Helper()
  16. server := miniredis.RunT(t)
  17. server.SetTime(time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC))
  18. client := redis.NewClient(&redis.Options{Addr: server.Addr()})
  19. t.Cleanup(func() { client.Close() })
  20. p := service.DefaultAccountTrafficPolicy()
  21. p.StrictRPMEnabled = true
  22. p.RPM = 3
  23. p.Burst = 3
  24. return server, &accountTrafficCache{rdb: client}, service.AccountTrafficPlan{AccountID: 42, Revision: 1, Signature: "policy-1", HardLimit: 8, Policy: p}
  25. }
  26. func TestAccountTrafficAtomicRPMAndBurst(t *testing.T) {
  27. server, cache, plan := trafficFixture(t)
  28. ctx := context.Background()
  29. for i := 0; i < 3; i++ {
  30. a, err := cache.Acquire(ctx, plan, fmt.Sprint(i))
  31. require.NoError(t, err)
  32. require.True(t, a.Allowed)
  33. require.NoError(t, cache.Finish(ctx, plan, fmt.Sprint(i), 200, 100))
  34. }
  35. blocked, err := cache.Acquire(ctx, plan, "fourth")
  36. require.NoError(t, err)
  37. require.False(t, blocked.Allowed)
  38. require.Equal(t, time.Minute, blocked.RetryAfter)
  39. server.SetTime(time.Date(2026, 9, 14, 12, 0, 20, 0, time.UTC))
  40. blocked, err = cache.Acquire(ctx, plan, "still-full")
  41. require.NoError(t, err)
  42. require.False(t, blocked.Allowed)
  43. require.Equal(t, 40*time.Second, blocked.RetryAfter)
  44. server.SetTime(time.Date(2026, 9, 14, 12, 1, 1, 0, time.UTC))
  45. accepted, err := cache.Acquire(ctx, plan, "new-window")
  46. require.NoError(t, err)
  47. require.True(t, accepted.Allowed)
  48. state, err := cache.Snapshot(ctx, plan)
  49. require.NoError(t, err)
  50. require.Equal(t, 1, state.RequestsLastMinute)
  51. require.EqualValues(t, 4, state.Accepted)
  52. // A burst smaller than RPM stops a simultaneous spike before the minute cap.
  53. plan.AccountID = 43
  54. plan.Policy.RPM = 60
  55. plan.Policy.Burst = 2
  56. for i := 0; i < 2; i++ {
  57. a, err := cache.Acquire(ctx, plan, fmt.Sprint(i))
  58. require.NoError(t, err)
  59. require.True(t, a.Allowed)
  60. }
  61. blocked, err = cache.Acquire(ctx, plan, "burst-full")
  62. require.NoError(t, err)
  63. require.False(t, blocked.Allowed)
  64. require.Equal(t, time.Second, blocked.RetryAfter)
  65. }
  66. func TestAccountTrafficConcurrentInstancesCannotExceedCeiling(t *testing.T) {
  67. server, cache, plan := trafficFixture(t)
  68. plan.Policy.RPM = 1
  69. plan.Policy.Burst = 1
  70. otherClient := redis.NewClient(&redis.Options{Addr: server.Addr()})
  71. defer otherClient.Close()
  72. other := &accountTrafficCache{rdb: otherClient}
  73. var admitted atomic.Int32
  74. var wg sync.WaitGroup
  75. failures := make(chan error, 40)
  76. for i := 0; i < 40; i++ {
  77. wg.Add(1)
  78. go func(i int) {
  79. defer wg.Done()
  80. chosen := cache
  81. if i%2 == 1 {
  82. chosen = other
  83. }
  84. a, err := chosen.Acquire(context.Background(), plan, fmt.Sprint(i))
  85. if err != nil {
  86. failures <- err
  87. }
  88. if a.Allowed {
  89. admitted.Add(1)
  90. }
  91. }(i)
  92. }
  93. wg.Wait()
  94. close(failures)
  95. for err := range failures {
  96. require.NoError(t, err)
  97. }
  98. require.EqualValues(t, 1, admitted.Load())
  99. }
  100. func TestAccountTrafficAdaptiveSuggestionAndSlowRecovery(t *testing.T) {
  101. server, cache, plan := trafficFixture(t)
  102. ctx := context.Background()
  103. plan.Policy.StrictRPMEnabled = false
  104. plan.Policy.AdaptiveEnabled = true
  105. plan.Policy.FailureThreshold = 2
  106. for i := 0; i < 2; i++ {
  107. id := fmt.Sprint(i)
  108. a, err := cache.Acquire(ctx, plan, id)
  109. require.NoError(t, err)
  110. require.True(t, a.Allowed)
  111. require.NoError(t, cache.Finish(ctx, plan, id, 429, 100))
  112. }
  113. state, err := cache.Snapshot(ctx, plan)
  114. require.NoError(t, err)
  115. require.Equal(t, 4, state.RecommendedConcurrency)
  116. require.Equal(t, 8, state.EffectiveConcurrency, "observe mode never lowers the actual cap")
  117. plan.Revision = 2
  118. plan.Signature = "automatic"
  119. plan.Policy.AdaptiveMode = "automatic"
  120. require.NoError(t, cache.Sync(ctx, plan))
  121. for i := 0; i < 4; i++ {
  122. a, err := cache.Acquire(ctx, plan, fmt.Sprint("active-", i))
  123. require.NoError(t, err)
  124. require.True(t, a.Allowed)
  125. }
  126. blocked, err := cache.Acquire(ctx, plan, "fifth")
  127. require.NoError(t, err)
  128. require.False(t, blocked.Allowed)
  129. require.Equal(t, 4, blocked.State.EffectiveConcurrency)
  130. for i := 0; i < 3; i++ {
  131. require.NoError(t, cache.Finish(ctx, plan, fmt.Sprint("active-", i), 200, 100))
  132. }
  133. state, err = cache.Snapshot(ctx, plan)
  134. require.NoError(t, err)
  135. require.Equal(t, 4, state.EffectiveConcurrency)
  136. server.SetTime(time.Date(2026, 9, 14, 12, 1, 1, 0, time.UTC))
  137. require.NoError(t, cache.Finish(ctx, plan, "active-3", 200, 100))
  138. state, err = cache.Snapshot(ctx, plan)
  139. require.NoError(t, err)
  140. require.Equal(t, 5, state.EffectiveConcurrency)
  141. require.Equal(t, 8, plan.HardLimit, "the saved administrator cap is not mutated")
  142. plan.Policy.AdaptiveEnabled = false
  143. plan.Revision = 3
  144. plan.Signature = "disabled"
  145. require.NoError(t, cache.Sync(ctx, plan))
  146. state, err = cache.Snapshot(ctx, plan)
  147. require.NoError(t, err)
  148. require.Equal(t, 8, state.EffectiveConcurrency)
  149. }
  150. func TestAccountTrafficStalePolicyAndOldCompletionCannotUndoChanges(t *testing.T) {
  151. _, cache, old := trafficFixture(t)
  152. ctx := context.Background()
  153. old.Policy.StrictRPMEnabled = false
  154. old.Policy.AdaptiveEnabled = true
  155. old.Policy.FailureThreshold = 1
  156. a, err := cache.Acquire(ctx, old, "old-request")
  157. require.NoError(t, err)
  158. require.True(t, a.Allowed)
  159. updated := old
  160. updated.Revision = 2
  161. updated.Signature = "turned-off"
  162. updated.Policy.AdaptiveEnabled = false
  163. require.NoError(t, cache.Sync(ctx, updated))
  164. blocked, err := cache.Acquire(ctx, old, "stale")
  165. require.NoError(t, err)
  166. require.True(t, blocked.Allowed)
  167. require.True(t, blocked.SkipObservation, "turning off observation must not block an old session")
  168. require.NoError(t, cache.Finish(ctx, old, "old-request", 429, 50))
  169. state, err := cache.Snapshot(ctx, updated)
  170. require.NoError(t, err)
  171. require.Equal(t, 8, state.EffectiveConcurrency)
  172. require.Equal(t, 8, state.RecommendedConcurrency)
  173. require.Zero(t, state.InFlight)
  174. }