scheduler_snapshot_retirement_test.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "sync"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/stretchr/testify/require"
  10. )
  11. type retirementRaceCache struct {
  12. SchedulerCache
  13. mu sync.Mutex
  14. epochs map[string]int64
  15. retired map[string]bool
  16. listBuckets []SchedulerBucket
  17. captures []SchedulerBucket
  18. reopens []SchedulerBucket
  19. setAttempts map[string]int
  20. published map[string]int
  21. versions map[string]int
  22. beforeSet func()
  23. }
  24. func newRetirementRaceCache(buckets ...SchedulerBucket) *retirementRaceCache {
  25. return &retirementRaceCache{
  26. epochs: make(map[string]int64),
  27. retired: make(map[string]bool),
  28. listBuckets: buckets,
  29. setAttempts: make(map[string]int),
  30. published: make(map[string]int),
  31. versions: make(map[string]int),
  32. }
  33. }
  34. func (c *retirementRaceCache) GetSnapshot(context.Context, SchedulerBucket) ([]*Account, bool, error) {
  35. return nil, false, nil
  36. }
  37. func (c *retirementRaceCache) CaptureBucketWriteToken(_ context.Context, bucket SchedulerBucket) (SchedulerBucketWriteToken, error) {
  38. c.mu.Lock()
  39. defer c.mu.Unlock()
  40. key := bucket.String()
  41. c.captures = append(c.captures, bucket)
  42. if c.retired[key] {
  43. return SchedulerBucketWriteToken{}, ErrSchedulerBucketRetired
  44. }
  45. if c.epochs[key] == 0 {
  46. c.epochs[key] = 1
  47. }
  48. return SchedulerBucketWriteToken{Bucket: bucket, Epoch: c.epochs[key]}, nil
  49. }
  50. func (c *retirementRaceCache) SetSnapshot(_ context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, _ []Account) error {
  51. if c.beforeSet != nil {
  52. c.beforeSet()
  53. }
  54. c.mu.Lock()
  55. defer c.mu.Unlock()
  56. key := bucket.String()
  57. c.setAttempts[key]++
  58. if !token.ValidFor(bucket) {
  59. return ErrSchedulerBucketWriteFenced
  60. }
  61. if c.retired[key] {
  62. return ErrSchedulerBucketRetired
  63. }
  64. if c.epochs[key] != token.Epoch {
  65. return ErrSchedulerBucketWriteFenced
  66. }
  67. c.versions[key]++
  68. c.published[key]++
  69. return nil
  70. }
  71. func (c *retirementRaceCache) RetireBucket(_ context.Context, bucket SchedulerBucket) error {
  72. c.mu.Lock()
  73. defer c.mu.Unlock()
  74. key := bucket.String()
  75. if !c.retired[key] {
  76. c.epochs[key]++
  77. if c.epochs[key] < 1 {
  78. c.epochs[key] = 1
  79. }
  80. c.retired[key] = true
  81. }
  82. return nil
  83. }
  84. func (c *retirementRaceCache) ReopenBucket(_ context.Context, bucket SchedulerBucket) (SchedulerBucketWriteToken, error) {
  85. c.mu.Lock()
  86. defer c.mu.Unlock()
  87. key := bucket.String()
  88. if c.epochs[key] == 0 {
  89. c.epochs[key] = 1
  90. }
  91. delete(c.retired, key)
  92. c.reopens = append(c.reopens, bucket)
  93. return SchedulerBucketWriteToken{Bucket: bucket, Epoch: c.epochs[key]}, nil
  94. }
  95. func (c *retirementRaceCache) TryLockBucket(context.Context, SchedulerBucket, time.Duration) (bool, error) {
  96. return true, nil
  97. }
  98. func (c *retirementRaceCache) UnlockBucket(context.Context, SchedulerBucket) error {
  99. return nil
  100. }
  101. func (c *retirementRaceCache) ListBuckets(context.Context) ([]SchedulerBucket, error) {
  102. return append([]SchedulerBucket(nil), c.listBuckets...), nil
  103. }
  104. func (c *retirementRaceCache) counts(bucket SchedulerBucket) (setAttempts, published int) {
  105. c.mu.Lock()
  106. defer c.mu.Unlock()
  107. return c.setAttempts[bucket.String()], c.published[bucket.String()]
  108. }
  109. func (c *retirementRaceCache) captureAndReopenCounts() (captures, reopens int) {
  110. c.mu.Lock()
  111. defer c.mu.Unlock()
  112. return len(c.captures), len(c.reopens)
  113. }
  114. func (c *retirementRaceCache) version(bucket SchedulerBucket) int {
  115. c.mu.Lock()
  116. defer c.mu.Unlock()
  117. return c.versions[bucket.String()]
  118. }
  119. type retirementGroupRepo struct {
  120. GroupRepository
  121. groups []Group
  122. err error
  123. }
  124. func (r *retirementGroupRepo) ListActive(context.Context) ([]Group, error) {
  125. return r.groups, r.err
  126. }
  127. func TestSchedulerFullRebuildCapturesAllRegistryTokensBeforeDBLoad(t *testing.T) {
  128. first := SchedulerBucket{GroupID: 61, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
  129. queued := SchedulerBucket{GroupID: 61, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
  130. cache := newRetirementRaceCache(first, queued)
  131. dbStarted := make(chan struct{})
  132. releaseDB := make(chan struct{})
  133. var firstDB sync.Once
  134. repo := &mockAccountRepoForPlatform{
  135. listPlatformFunc: func(context.Context, string) ([]Account, error) {
  136. firstDB.Do(func() {
  137. close(dbStarted)
  138. <-releaseDB
  139. })
  140. return []Account{{ID: 6101, Platform: PlatformOpenAI, Status: StatusActive, Schedulable: true}}, nil
  141. },
  142. }
  143. svc := NewSchedulerSnapshotService(cache, nil, repo, &retirementGroupRepo{groups: []Group{{ID: 61, Status: StatusActive}}}, &config.Config{
  144. RunMode: config.RunModeStandard,
  145. Gateway: config.GatewayConfig{Scheduling: config.GatewaySchedulingConfig{
  146. DbFallbackEnabled: true,
  147. }},
  148. })
  149. result := make(chan error, 1)
  150. go func() { result <- svc.triggerFullRebuild("retirement_race_a") }()
  151. select {
  152. case <-dbStarted:
  153. case <-time.After(time.Second):
  154. t.Fatal("first DB load did not start")
  155. }
  156. captures, reopens := cache.captureAndReopenCounts()
  157. require.Equal(t, 2*schedulerCanonicalBucketCount(), captures, "group0 and active-group canonical tokens must be captured before the first DB load")
  158. require.Zero(t, reopens)
  159. require.NoError(t, cache.RetireBucket(context.Background(), queued))
  160. _, err := cache.ReopenBucket(context.Background(), queued)
  161. require.NoError(t, err)
  162. close(releaseDB)
  163. require.NoError(t, <-result)
  164. _, firstPublished := cache.counts(first)
  165. queuedAttempts, queuedPublished := cache.counts(queued)
  166. require.Equal(t, 1, firstPublished)
  167. require.Equal(t, 1, queuedAttempts)
  168. require.Zero(t, queuedPublished, "queued registry task must not adopt the reopened epoch")
  169. }
  170. func TestSchedulerRebuildRetireAfterDBLoadFencesPublish(t *testing.T) {
  171. bucket := SchedulerBucket{GroupID: 62, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
  172. cache := newRetirementRaceCache()
  173. dbReturned := make(chan struct{})
  174. setEntered := make(chan struct{})
  175. releaseSet := make(chan struct{})
  176. cache.beforeSet = func() {
  177. close(setEntered)
  178. <-releaseSet
  179. }
  180. repo := &mockAccountRepoForPlatform{
  181. listPlatformFunc: func(context.Context, string) ([]Account, error) {
  182. close(dbReturned)
  183. return []Account{{ID: 6201, Platform: PlatformOpenAI, Status: StatusActive, Schedulable: true}}, nil
  184. },
  185. }
  186. svc := NewSchedulerSnapshotService(cache, nil, repo, nil, &config.Config{
  187. RunMode: config.RunModeStandard,
  188. Gateway: config.GatewayConfig{Scheduling: config.GatewaySchedulingConfig{
  189. DbFallbackEnabled: true,
  190. }},
  191. })
  192. result := make(chan error, 1)
  193. go func() {
  194. result <- svc.rebuildBuckets(context.Background(), []SchedulerBucket{bucket}, "retirement_race_b")
  195. }()
  196. select {
  197. case <-dbReturned:
  198. case <-time.After(time.Second):
  199. t.Fatal("DB load did not return")
  200. }
  201. select {
  202. case <-setEntered:
  203. case <-time.After(time.Second):
  204. t.Fatal("snapshot writer did not reach allocation boundary")
  205. }
  206. require.NoError(t, cache.RetireBucket(context.Background(), bucket))
  207. close(releaseSet)
  208. require.NoError(t, <-result)
  209. setAttempts, published := cache.counts(bucket)
  210. require.Equal(t, 1, setAttempts)
  211. require.Zero(t, published)
  212. require.Zero(t, cache.version(bucket), "retirement before allocation must not advance the snapshot version")
  213. }
  214. func TestSchedulerFallbackReturnsDBAccountsWhenBucketRetired(t *testing.T) {
  215. bucket := SchedulerBucket{GroupID: 63, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
  216. cache := newRetirementRaceCache()
  217. require.NoError(t, cache.RetireBucket(context.Background(), bucket))
  218. repo := &mockAccountRepoForPlatform{
  219. accounts: []Account{{ID: 6301, Platform: PlatformOpenAI, Status: StatusActive, Schedulable: true}},
  220. }
  221. svc := NewSchedulerSnapshotService(cache, nil, repo, nil, &config.Config{
  222. RunMode: config.RunModeStandard,
  223. Gateway: config.GatewayConfig{Scheduling: config.GatewaySchedulingConfig{
  224. DbFallbackEnabled: true,
  225. }},
  226. })
  227. groupID := bucket.GroupID
  228. accounts, useMixed, err := svc.ListSchedulableAccounts(context.Background(), &groupID, bucket.Platform, false)
  229. require.NoError(t, err)
  230. require.False(t, useMixed)
  231. require.Len(t, accounts, 1)
  232. setAttempts, published := cache.counts(bucket)
  233. require.Zero(t, setAttempts)
  234. require.Zero(t, published)
  235. }