grok_import_probe_test.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. //go:build unit
  2. package admin
  3. import (
  4. "bytes"
  5. "context"
  6. "log/slog"
  7. "sync"
  8. "testing"
  9. "time"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type grokImportProbeStub struct {
  15. mu sync.Mutex
  16. calls map[int64]int
  17. failures map[int64]error
  18. active int
  19. maxActive int
  20. deadlineSeen bool
  21. block <-chan struct{}
  22. started chan int64
  23. done chan int64
  24. }
  25. func newGrokImportProbeStub(buffer int) *grokImportProbeStub {
  26. return &grokImportProbeStub{
  27. calls: make(map[int64]int),
  28. failures: make(map[int64]error),
  29. started: make(chan int64, buffer),
  30. done: make(chan int64, buffer),
  31. }
  32. }
  33. func (s *grokImportProbeStub) QueryQuota(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error) {
  34. _, deadlineSeen := ctx.Deadline()
  35. s.mu.Lock()
  36. s.calls[accountID]++
  37. s.active++
  38. if s.active > s.maxActive {
  39. s.maxActive = s.active
  40. }
  41. s.deadlineSeen = s.deadlineSeen || deadlineSeen
  42. s.mu.Unlock()
  43. s.started <- accountID
  44. var ctxErr error
  45. if s.block != nil {
  46. select {
  47. case <-s.block:
  48. case <-ctx.Done():
  49. ctxErr = ctx.Err()
  50. }
  51. }
  52. s.mu.Lock()
  53. s.active--
  54. failure := s.failures[accountID]
  55. s.mu.Unlock()
  56. s.done <- accountID
  57. if ctxErr != nil {
  58. return nil, ctxErr
  59. }
  60. if failure != nil {
  61. return nil, failure
  62. }
  63. return &service.GrokQuotaProbeResult{
  64. Source: "hybrid_probe",
  65. Model: "grok-4.5",
  66. StatusCode: 200,
  67. ResetSupported: false,
  68. }, nil
  69. }
  70. func (s *grokImportProbeStub) snapshot() (map[int64]int, int, bool) {
  71. s.mu.Lock()
  72. defer s.mu.Unlock()
  73. calls := make(map[int64]int, len(s.calls))
  74. for id, count := range s.calls {
  75. calls[id] = count
  76. }
  77. return calls, s.maxActive, s.deadlineSeen
  78. }
  79. type grokImportProbeSchedulerTestSnapshot struct {
  80. queued int
  81. workers int
  82. maxWorkers int
  83. }
  84. func snapshotGrokImportProbeScheduler(s *grokImportProbeScheduler) grokImportProbeSchedulerTestSnapshot {
  85. if s == nil {
  86. return grokImportProbeSchedulerTestSnapshot{}
  87. }
  88. s.mu.Lock()
  89. defer s.mu.Unlock()
  90. return grokImportProbeSchedulerTestSnapshot{
  91. queued: len(s.queue),
  92. workers: s.workers,
  93. maxWorkers: s.maxWorkers,
  94. }
  95. }
  96. func newGrokOAuthImportAccount(id int64) *service.Account {
  97. return &service.Account{
  98. ID: id,
  99. Platform: service.PlatformGrok,
  100. Type: service.AccountTypeOAuth,
  101. }
  102. }
  103. func awaitGrokProbeSignal(t *testing.T, signals <-chan int64) int64 {
  104. t.Helper()
  105. select {
  106. case id := <-signals:
  107. return id
  108. case <-time.After(time.Second):
  109. t.Fatal("timed out waiting for Grok import probe")
  110. return 0
  111. }
  112. }
  113. func TestGrokImportProbeSchedulerProbesSingleAccountOnce(t *testing.T) {
  114. scheduler := newGrokImportProbeScheduler(1, time.Second)
  115. prober := newGrokImportProbeStub(1)
  116. scheduler.schedule(prober, newGrokOAuthImportAccount(101))
  117. require.Equal(t, int64(101), awaitGrokProbeSignal(t, prober.done))
  118. calls, maxActive, deadlineSeen := prober.snapshot()
  119. require.Equal(t, map[int64]int{101: 1}, calls)
  120. require.Equal(t, 1, maxActive)
  121. require.True(t, deadlineSeen)
  122. require.Eventually(t, func() bool {
  123. snapshot := snapshotGrokImportProbeScheduler(scheduler)
  124. return snapshot.queued == 0 && snapshot.workers == 0
  125. }, time.Second, 10*time.Millisecond)
  126. }
  127. func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.T) {
  128. const taskCount = 50
  129. release := make(chan struct{})
  130. scheduler := newGrokImportProbeScheduler(3, time.Second)
  131. prober := newGrokImportProbeStub(taskCount)
  132. prober.block = release
  133. prober.failures[150] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "sensitive-upstream-body")
  134. for id := int64(101); id < 101+taskCount; id++ {
  135. scheduler.schedule(prober, newGrokOAuthImportAccount(id))
  136. }
  137. for i := 0; i < 3; i++ {
  138. awaitGrokProbeSignal(t, prober.started)
  139. }
  140. snapshot := snapshotGrokImportProbeScheduler(scheduler)
  141. require.Equal(t, taskCount-3, snapshot.queued)
  142. require.Equal(t, 3, snapshot.workers)
  143. require.Equal(t, 3, snapshot.maxWorkers)
  144. select {
  145. case id := <-prober.started:
  146. t.Fatalf("probe %d started before a concurrency slot was released", id)
  147. case <-time.After(75 * time.Millisecond):
  148. }
  149. close(release)
  150. for i := 0; i < taskCount; i++ {
  151. awaitGrokProbeSignal(t, prober.done)
  152. }
  153. calls, maxActive, _ := prober.snapshot()
  154. require.Len(t, calls, taskCount)
  155. for id := int64(101); id < 101+taskCount; id++ {
  156. require.Equal(t, 1, calls[id])
  157. }
  158. require.Equal(t, 3, maxActive)
  159. require.Eventually(t, func() bool {
  160. snapshot = snapshotGrokImportProbeScheduler(scheduler)
  161. return snapshot.queued == 0 && snapshot.workers == 0
  162. }, time.Second, 10*time.Millisecond)
  163. require.Equal(t, 3, snapshot.maxWorkers)
  164. }
  165. func TestGrokImportProbeSchedulerDeduplicatesPendingAndInFlightAccounts(t *testing.T) {
  166. scheduler := newGrokImportProbeScheduler(1, time.Second)
  167. prober := newGrokImportProbeStub(2)
  168. release := make(chan struct{})
  169. prober.block = release
  170. account := newGrokOAuthImportAccount(501)
  171. queued := newGrokOAuthImportAccount(502)
  172. scheduler.schedule(prober, account)
  173. require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.started))
  174. scheduler.schedule(prober, account)
  175. scheduler.schedule(prober, queued)
  176. scheduler.schedule(prober, queued)
  177. scheduler.mu.Lock()
  178. require.Len(t, scheduler.queue, 1)
  179. require.Contains(t, scheduler.inFlight, int64(501))
  180. require.Contains(t, scheduler.pending, int64(502))
  181. scheduler.mu.Unlock()
  182. close(release)
  183. require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.done))
  184. require.Equal(t, int64(502), awaitGrokProbeSignal(t, prober.done))
  185. calls, _, _ := prober.snapshot()
  186. require.Equal(t, 1, calls[501])
  187. require.Equal(t, 1, calls[502])
  188. }
  189. func TestGrokImportProbeSchedulerBoundsPendingQueue(t *testing.T) {
  190. scheduler := newGrokImportProbeScheduler(1, time.Second)
  191. prober := newGrokImportProbeStub(grokImportProbeQueueLimit + 1)
  192. release := make(chan struct{})
  193. prober.block = release
  194. scheduler.schedule(prober, newGrokOAuthImportAccount(600))
  195. require.Equal(t, int64(600), awaitGrokProbeSignal(t, prober.started))
  196. for id := int64(601); id < 601+grokImportProbeQueueLimit+10; id++ {
  197. scheduler.schedule(prober, newGrokOAuthImportAccount(id))
  198. }
  199. scheduler.mu.Lock()
  200. require.Len(t, scheduler.queue, grokImportProbeQueueLimit)
  201. scheduler.mu.Unlock()
  202. close(release)
  203. for i := 0; i < grokImportProbeQueueLimit+1; i++ {
  204. awaitGrokProbeSignal(t, prober.done)
  205. }
  206. }
  207. func TestGrokImportProbeSchedulerTimeoutCancelsProbe(t *testing.T) {
  208. neverRelease := make(chan struct{})
  209. scheduler := newGrokImportProbeScheduler(1, 20*time.Millisecond)
  210. prober := newGrokImportProbeStub(1)
  211. prober.block = neverRelease
  212. scheduler.schedule(prober, newGrokOAuthImportAccount(201))
  213. require.Equal(t, int64(201), awaitGrokProbeSignal(t, prober.done))
  214. calls, _, _ := prober.snapshot()
  215. require.Equal(t, 1, calls[201])
  216. }
  217. func TestGrokImportProbeSchedulerSkipsMissingServiceAndNonGrokAccounts(t *testing.T) {
  218. scheduler := newGrokImportProbeScheduler(1, time.Second)
  219. prober := newGrokImportProbeStub(1)
  220. scheduler.schedule(nil, newGrokOAuthImportAccount(301))
  221. scheduler.schedule(prober, &service.Account{ID: 302, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth})
  222. scheduler.schedule(prober, &service.Account{ID: 303, Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey})
  223. select {
  224. case id := <-prober.started:
  225. t.Fatalf("unexpected probe for account %d", id)
  226. case <-time.After(50 * time.Millisecond):
  227. }
  228. calls, _, _ := prober.snapshot()
  229. require.Empty(t, calls)
  230. }
  231. func TestGrokImportProbeFailureLogDoesNotIncludeErrorMessage(t *testing.T) {
  232. var logs bytes.Buffer
  233. previousLogger := slog.Default()
  234. slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
  235. defer slog.SetDefault(previousLogger)
  236. scheduler := newGrokImportProbeScheduler(1, time.Second)
  237. prober := newGrokImportProbeStub(1)
  238. prober.failures[401] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "refresh-token-secret")
  239. scheduler.schedule(prober, newGrokOAuthImportAccount(401))
  240. awaitGrokProbeSignal(t, prober.done)
  241. require.Eventually(t, func() bool {
  242. return bytes.Contains(logs.Bytes(), []byte("grok_import_active_probe_failed"))
  243. }, time.Second, 10*time.Millisecond)
  244. require.Contains(t, logs.String(), "GROK_TEST_PROBE_FAILED")
  245. require.NotContains(t, logs.String(), "refresh-token-secret")
  246. }