scheduler_snapshot_hydration_test.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. )
  9. type snapshotHydrationCache struct {
  10. snapshot []*Account
  11. accounts map[int64]*Account
  12. }
  13. func (c *snapshotHydrationCache) GetSnapshot(ctx context.Context, bucket SchedulerBucket) ([]*Account, bool, error) {
  14. return c.snapshot, true, nil
  15. }
  16. func (c *snapshotHydrationCache) CaptureBucketWriteToken(ctx context.Context, bucket SchedulerBucket) (SchedulerBucketWriteToken, error) {
  17. return SchedulerBucketWriteToken{Bucket: bucket, Epoch: 1}, nil
  18. }
  19. func (c *snapshotHydrationCache) SetSnapshot(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accounts []Account) error {
  20. return nil
  21. }
  22. func (c *snapshotHydrationCache) RetireBucket(ctx context.Context, bucket SchedulerBucket) error {
  23. return nil
  24. }
  25. func (c *snapshotHydrationCache) ReopenBucket(ctx context.Context, bucket SchedulerBucket) (SchedulerBucketWriteToken, error) {
  26. return SchedulerBucketWriteToken{Bucket: bucket, Epoch: 1}, nil
  27. }
  28. func (c *snapshotHydrationCache) TryAcquireGroupLifecycleLease(context.Context, int64, time.Duration) (SchedulerGroupLifecycleLease, bool, error) {
  29. return SchedulerGroupLifecycleLease{}, false, nil
  30. }
  31. func (c *snapshotHydrationCache) ReleaseGroupLifecycleLease(context.Context, SchedulerGroupLifecycleLease) error {
  32. return nil
  33. }
  34. func (c *snapshotHydrationCache) GetAccount(ctx context.Context, accountID int64) (*Account, error) {
  35. if c.accounts == nil {
  36. return nil, nil
  37. }
  38. return c.accounts[accountID], nil
  39. }
  40. func (c *snapshotHydrationCache) SetAccount(ctx context.Context, account *Account) error {
  41. return nil
  42. }
  43. func (c *snapshotHydrationCache) DeleteAccount(ctx context.Context, accountID int64) error {
  44. return nil
  45. }
  46. func (c *snapshotHydrationCache) UpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error {
  47. return nil
  48. }
  49. func (c *snapshotHydrationCache) TryLockBucket(ctx context.Context, bucket SchedulerBucket, ttl time.Duration) (bool, error) {
  50. return true, nil
  51. }
  52. func (c *snapshotHydrationCache) UnlockBucket(ctx context.Context, bucket SchedulerBucket) error {
  53. return nil
  54. }
  55. func (c *snapshotHydrationCache) ListBuckets(ctx context.Context) ([]SchedulerBucket, error) {
  56. return nil, nil
  57. }
  58. func (c *snapshotHydrationCache) GetOutboxWatermark(ctx context.Context) (int64, error) {
  59. return 0, nil
  60. }
  61. func (c *snapshotHydrationCache) SetOutboxWatermark(ctx context.Context, id int64) error {
  62. return nil
  63. }
  64. func TestOpenAISelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedulerSnapshot(t *testing.T) {
  65. cache := &snapshotHydrationCache{
  66. snapshot: []*Account{
  67. {
  68. ID: 1,
  69. Platform: PlatformOpenAI,
  70. Type: AccountTypeAPIKey,
  71. Status: StatusActive,
  72. Schedulable: true,
  73. Concurrency: 1,
  74. Priority: 1,
  75. Credentials: map[string]any{
  76. "model_mapping": map[string]any{
  77. "gpt-4": "gpt-4",
  78. },
  79. },
  80. },
  81. },
  82. accounts: map[int64]*Account{
  83. 1: {
  84. ID: 1,
  85. Platform: PlatformOpenAI,
  86. Type: AccountTypeAPIKey,
  87. Status: StatusActive,
  88. Schedulable: true,
  89. Concurrency: 1,
  90. Priority: 1,
  91. Credentials: map[string]any{
  92. "api_key": "sk-live",
  93. "model_mapping": map[string]any{"gpt-4": "gpt-4"},
  94. },
  95. },
  96. },
  97. }
  98. schedulerSnapshot := NewSchedulerSnapshotService(cache, nil, nil, nil, nil)
  99. groupID := int64(2)
  100. svc := &OpenAIGatewayService{
  101. schedulerSnapshot: schedulerSnapshot,
  102. cache: &stubGatewayCache{},
  103. }
  104. selection, err := svc.SelectAccountWithLoadAwareness(context.Background(), &groupID, "", "gpt-4", nil)
  105. if err != nil {
  106. t.Fatalf("SelectAccountWithLoadAwareness error: %v", err)
  107. }
  108. if selection == nil || selection.Account == nil {
  109. t.Fatalf("expected selected account")
  110. }
  111. if got := selection.Account.GetOpenAIApiKey(); got != "sk-live" {
  112. t.Fatalf("expected hydrated api key, got %q", got)
  113. }
  114. }
  115. func TestOpenAINewAcquiredSelectionResult_ReleasesSlotWhenHydrationFails(t *testing.T) {
  116. cache := &snapshotHydrationCache{
  117. accounts: map[int64]*Account{},
  118. }
  119. schedulerSnapshot := NewSchedulerSnapshotService(cache, nil, stubOpenAIAccountRepo{}, nil, nil)
  120. svc := &OpenAIGatewayService{
  121. schedulerSnapshot: schedulerSnapshot,
  122. }
  123. releaseCalls := 0
  124. selection, err := svc.newAcquiredSelectionResult(context.Background(), &Account{ID: 1001}, func() {
  125. releaseCalls++
  126. })
  127. if err == nil {
  128. t.Fatalf("expected hydration error")
  129. }
  130. if selection != nil {
  131. t.Fatalf("expected nil selection on hydration error")
  132. }
  133. if releaseCalls != 1 {
  134. t.Fatalf("expected release to be called once, got %d", releaseCalls)
  135. }
  136. }
  137. func TestGatewaySelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedulerSnapshot(t *testing.T) {
  138. cache := &snapshotHydrationCache{
  139. snapshot: []*Account{
  140. {
  141. ID: 9,
  142. Platform: PlatformAnthropic,
  143. Type: AccountTypeAPIKey,
  144. Status: StatusActive,
  145. Schedulable: true,
  146. Concurrency: 1,
  147. Priority: 1,
  148. },
  149. },
  150. accounts: map[int64]*Account{
  151. 9: {
  152. ID: 9,
  153. Platform: PlatformAnthropic,
  154. Type: AccountTypeAPIKey,
  155. Status: StatusActive,
  156. Schedulable: true,
  157. Concurrency: 1,
  158. Priority: 1,
  159. Credentials: map[string]any{
  160. "api_key": "anthropic-live-key",
  161. },
  162. },
  163. },
  164. }
  165. schedulerSnapshot := NewSchedulerSnapshotService(cache, nil, nil, nil, nil)
  166. svc := &GatewayService{
  167. schedulerSnapshot: schedulerSnapshot,
  168. cache: &mockGatewayCacheForPlatform{},
  169. cfg: testConfig(),
  170. }
  171. result, err := svc.SelectAccountWithLoadAwareness(context.Background(), nil, "", "claude-3-5-sonnet-20241022", nil, "", 0)
  172. if err != nil {
  173. t.Fatalf("SelectAccountWithLoadAwareness error: %v", err)
  174. }
  175. if result == nil || result.Account == nil {
  176. t.Fatalf("expected selected account")
  177. }
  178. if got := result.Account.GetCredential("api_key"); got != "anthropic-live-key" {
  179. t.Fatalf("expected hydrated api key, got %q", got)
  180. }
  181. }
  182. func TestGatewaySelectAccountWithLoadAwareness_SkipsAntigravityGeminiFamilyRateLimitedSnapshot(t *testing.T) {
  183. resetAt := time.Now().Add(10 * time.Minute).Format(time.RFC3339)
  184. cache := &snapshotHydrationCache{
  185. snapshot: []*Account{
  186. {
  187. ID: 1,
  188. Platform: PlatformAntigravity,
  189. Type: AccountTypeOAuth,
  190. Status: StatusActive,
  191. Schedulable: true,
  192. Concurrency: 1,
  193. Priority: 1,
  194. AccountGroups: []AccountGroup{
  195. {AccountID: 1, GroupID: 22},
  196. },
  197. GroupIDs: []int64{22},
  198. Extra: map[string]any{
  199. "mixed_scheduling": true,
  200. modelRateLimitsKey: map[string]any{
  201. antigravityGeminiModelRateLimitKey: map[string]any{
  202. "rate_limit_reset_at": resetAt,
  203. },
  204. },
  205. },
  206. },
  207. {
  208. ID: 2,
  209. Platform: PlatformAntigravity,
  210. Type: AccountTypeOAuth,
  211. Status: StatusActive,
  212. Schedulable: true,
  213. Concurrency: 1,
  214. Priority: 2,
  215. AccountGroups: []AccountGroup{
  216. {AccountID: 2, GroupID: 22},
  217. },
  218. GroupIDs: []int64{22},
  219. Extra: map[string]any{
  220. "mixed_scheduling": true,
  221. },
  222. },
  223. },
  224. accounts: map[int64]*Account{
  225. 1: {ID: 1, Platform: PlatformAntigravity, Type: AccountTypeOAuth},
  226. 2: {ID: 2, Platform: PlatformAntigravity, Type: AccountTypeOAuth},
  227. },
  228. }
  229. groupID := int64(22)
  230. svc := &GatewayService{
  231. schedulerSnapshot: NewSchedulerSnapshotService(cache, nil, nil, nil, nil),
  232. groupRepo: &mockGroupRepoForGateway{
  233. groups: map[int64]*Group{
  234. groupID: {
  235. ID: groupID,
  236. Platform: PlatformGemini,
  237. Status: StatusActive,
  238. Hydrated: true,
  239. },
  240. },
  241. },
  242. concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}),
  243. cfg: &config.Config{
  244. Gateway: config.GatewayConfig{
  245. Scheduling: config.GatewaySchedulingConfig{
  246. LoadBatchEnabled: true,
  247. StickySessionMaxWaiting: 3,
  248. StickySessionWaitTimeout: time.Second,
  249. FallbackWaitTimeout: time.Second,
  250. FallbackMaxWaiting: 10,
  251. },
  252. },
  253. },
  254. }
  255. result, err := svc.SelectAccountWithLoadAwareness(context.Background(), &groupID, "", "gemini-3-flash-preview", nil, "", 0)
  256. if err != nil {
  257. t.Fatalf("SelectAccountWithLoadAwareness error: %v", err)
  258. }
  259. if result == nil || result.Account == nil {
  260. t.Fatalf("expected selected account")
  261. }
  262. if result.Account.ID != 2 {
  263. t.Fatalf("expected scheduler to skip Gemini-family limited antigravity account 1, got %d", result.Account.ID)
  264. }
  265. }