scheduler_snapshot_group_lifecycle_test.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "fmt"
  7. "sync"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/stretchr/testify/require"
  12. )
  13. type groupLifecycleTestCache struct {
  14. *retirementRaceCache
  15. stateMu sync.Mutex
  16. leaseHeld bool
  17. lease SchedulerGroupLifecycleLease
  18. leaseSequence int
  19. leaseBusy bool
  20. leaseAcquireErr error
  21. leaseReleaseErr error
  22. acquireCalls int
  23. releaseCalls int
  24. acquireTTL time.Duration
  25. acquireDeadline bool
  26. releaseDeadline bool
  27. releaseCtxErr error
  28. listErr error
  29. listCalls int
  30. retireCalls []SchedulerBucket
  31. reopenTokens []SchedulerBucketWriteToken
  32. retireHeld []bool
  33. reopenHeld []bool
  34. retireErr error
  35. retireErrAt int
  36. reopenErr error
  37. reopenErrAt int
  38. bucketLockBusy bool
  39. bucketLockErr error
  40. bucketLockTTLs []time.Duration
  41. unlockCalls int
  42. setErr error
  43. }
  44. func newGroupLifecycleTestCache(buckets ...SchedulerBucket) *groupLifecycleTestCache {
  45. return &groupLifecycleTestCache{retirementRaceCache: newRetirementRaceCache(buckets...)}
  46. }
  47. func (c *groupLifecycleTestCache) TryAcquireGroupLifecycleLease(ctx context.Context, groupID int64, ttl time.Duration) (SchedulerGroupLifecycleLease, bool, error) {
  48. c.stateMu.Lock()
  49. defer c.stateMu.Unlock()
  50. c.acquireCalls++
  51. c.acquireTTL = ttl
  52. _, c.acquireDeadline = ctx.Deadline()
  53. if c.leaseAcquireErr != nil {
  54. return SchedulerGroupLifecycleLease{}, false, c.leaseAcquireErr
  55. }
  56. if c.leaseBusy || c.leaseHeld {
  57. return SchedulerGroupLifecycleLease{}, false, nil
  58. }
  59. c.leaseSequence++
  60. c.lease = SchedulerGroupLifecycleLease{GroupID: groupID, OwnerToken: fmt.Sprintf("owner-%d", c.leaseSequence)}
  61. c.leaseHeld = true
  62. return c.lease, true, nil
  63. }
  64. func (c *groupLifecycleTestCache) ReleaseGroupLifecycleLease(ctx context.Context, lease SchedulerGroupLifecycleLease) error {
  65. c.stateMu.Lock()
  66. defer c.stateMu.Unlock()
  67. c.releaseCalls++
  68. _, c.releaseDeadline = ctx.Deadline()
  69. c.releaseCtxErr = ctx.Err()
  70. if c.leaseReleaseErr != nil {
  71. return c.leaseReleaseErr
  72. }
  73. if !c.leaseHeld || lease != c.lease {
  74. return ErrSchedulerGroupLifecycleLeaseLost
  75. }
  76. c.leaseHeld = false
  77. return nil
  78. }
  79. func (c *groupLifecycleTestCache) RetireBucket(ctx context.Context, bucket SchedulerBucket) error {
  80. c.stateMu.Lock()
  81. c.retireCalls = append(c.retireCalls, bucket)
  82. c.retireHeld = append(c.retireHeld, c.leaseHeld)
  83. held := c.leaseHeld
  84. call := len(c.retireCalls)
  85. err := c.retireErr
  86. errAt := c.retireErrAt
  87. c.stateMu.Unlock()
  88. if !held {
  89. return errors.New("retire called outside group lifecycle lease")
  90. }
  91. if err != nil && (errAt <= 0 || call == errAt) {
  92. return err
  93. }
  94. return c.retirementRaceCache.RetireBucket(ctx, bucket)
  95. }
  96. func (c *groupLifecycleTestCache) ReopenBucket(ctx context.Context, bucket SchedulerBucket) (SchedulerBucketWriteToken, error) {
  97. if err := ctx.Err(); err != nil {
  98. return SchedulerBucketWriteToken{}, err
  99. }
  100. c.stateMu.Lock()
  101. c.reopenHeld = append(c.reopenHeld, c.leaseHeld)
  102. held := c.leaseHeld
  103. call := len(c.reopenHeld)
  104. reopenErr := c.reopenErr
  105. reopenErrAt := c.reopenErrAt
  106. c.stateMu.Unlock()
  107. if !held {
  108. return SchedulerBucketWriteToken{}, errors.New("reopen called outside group lifecycle lease")
  109. }
  110. if reopenErr != nil && (reopenErrAt <= 0 || call == reopenErrAt) {
  111. return SchedulerBucketWriteToken{}, reopenErr
  112. }
  113. token, err := c.retirementRaceCache.ReopenBucket(ctx, bucket)
  114. if err != nil {
  115. return SchedulerBucketWriteToken{}, err
  116. }
  117. c.stateMu.Lock()
  118. c.reopenTokens = append(c.reopenTokens, token)
  119. c.stateMu.Unlock()
  120. return token, nil
  121. }
  122. func (c *groupLifecycleTestCache) ListBuckets(ctx context.Context) ([]SchedulerBucket, error) {
  123. c.stateMu.Lock()
  124. c.listCalls++
  125. err := c.listErr
  126. c.stateMu.Unlock()
  127. if err != nil {
  128. return nil, err
  129. }
  130. return c.retirementRaceCache.ListBuckets(ctx)
  131. }
  132. func (c *groupLifecycleTestCache) TryLockBucket(_ context.Context, _ SchedulerBucket, ttl time.Duration) (bool, error) {
  133. c.stateMu.Lock()
  134. defer c.stateMu.Unlock()
  135. c.bucketLockTTLs = append(c.bucketLockTTLs, ttl)
  136. if c.bucketLockErr != nil {
  137. return false, c.bucketLockErr
  138. }
  139. return !c.bucketLockBusy, nil
  140. }
  141. func (c *groupLifecycleTestCache) UnlockBucket(context.Context, SchedulerBucket) error {
  142. c.stateMu.Lock()
  143. c.unlockCalls++
  144. c.stateMu.Unlock()
  145. return nil
  146. }
  147. func (c *groupLifecycleTestCache) SetSnapshot(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accounts []Account) error {
  148. c.stateMu.Lock()
  149. err := c.setErr
  150. c.stateMu.Unlock()
  151. if err != nil {
  152. return err
  153. }
  154. return c.retirementRaceCache.SetSnapshot(ctx, bucket, token, accounts)
  155. }
  156. func (c *groupLifecycleTestCache) lifecycleCounts() (acquires, releases, listCalls int) {
  157. c.stateMu.Lock()
  158. defer c.stateMu.Unlock()
  159. return c.acquireCalls, c.releaseCalls, c.listCalls
  160. }
  161. func (c *groupLifecycleTestCache) retiredBuckets() []SchedulerBucket {
  162. c.stateMu.Lock()
  163. defer c.stateMu.Unlock()
  164. return append([]SchedulerBucket(nil), c.retireCalls...)
  165. }
  166. func (c *groupLifecycleTestCache) tokens() []SchedulerBucketWriteToken {
  167. c.stateMu.Lock()
  168. defer c.stateMu.Unlock()
  169. return append([]SchedulerBucketWriteToken(nil), c.reopenTokens...)
  170. }
  171. func (c *groupLifecycleTestCache) leaseHeldAndTokenCount() (bool, int) {
  172. c.stateMu.Lock()
  173. defer c.stateMu.Unlock()
  174. return c.leaseHeld, len(c.reopenTokens)
  175. }
  176. func (c *groupLifecycleTestCache) lockStats() ([]time.Duration, int) {
  177. c.stateMu.Lock()
  178. defer c.stateMu.Unlock()
  179. return append([]time.Duration(nil), c.bucketLockTTLs...), c.unlockCalls
  180. }
  181. func (c *groupLifecycleTestCache) lifecycleMutationLeaseStates() (retire, reopen []bool) {
  182. c.stateMu.Lock()
  183. defer c.stateMu.Unlock()
  184. return append([]bool(nil), c.retireHeld...), append([]bool(nil), c.reopenHeld...)
  185. }
  186. type groupLifecycleTestGroupRepo struct {
  187. GroupRepository
  188. mu sync.Mutex
  189. group *Group
  190. err error
  191. calls int
  192. afterGet func()
  193. }
  194. func (r *groupLifecycleTestGroupRepo) GetByIDLite(context.Context, int64) (*Group, error) {
  195. r.mu.Lock()
  196. r.calls++
  197. if r.err != nil {
  198. err := r.err
  199. r.mu.Unlock()
  200. return nil, err
  201. }
  202. if r.group == nil {
  203. r.mu.Unlock()
  204. return nil, ErrGroupNotFound
  205. }
  206. copyGroup := *r.group
  207. afterGet := r.afterGet
  208. r.mu.Unlock()
  209. if afterGet != nil {
  210. afterGet()
  211. }
  212. return &copyGroup, nil
  213. }
  214. func (r *groupLifecycleTestGroupRepo) set(group *Group, err error) {
  215. r.mu.Lock()
  216. defer r.mu.Unlock()
  217. r.group = group
  218. r.err = err
  219. }
  220. func (r *groupLifecycleTestGroupRepo) callCount() int {
  221. r.mu.Lock()
  222. defer r.mu.Unlock()
  223. return r.calls
  224. }
  225. type groupLifecycleTestAccountRepo struct {
  226. AccountRepository
  227. mu sync.Mutex
  228. calls int
  229. callsByPlatform map[string]int
  230. err error
  231. started chan struct{}
  232. release chan struct{}
  233. once sync.Once
  234. beforeLoad func()
  235. beforeLoadOnce sync.Once
  236. }
  237. func (r *groupLifecycleTestAccountRepo) load(ctx context.Context, platform string) ([]Account, error) {
  238. r.mu.Lock()
  239. r.calls++
  240. if r.callsByPlatform == nil {
  241. r.callsByPlatform = make(map[string]int)
  242. }
  243. r.callsByPlatform[platform]++
  244. err := r.err
  245. started := r.started
  246. release := r.release
  247. r.mu.Unlock()
  248. if started != nil {
  249. r.once.Do(func() { close(started) })
  250. }
  251. if r.beforeLoad != nil {
  252. r.beforeLoadOnce.Do(r.beforeLoad)
  253. }
  254. if release != nil {
  255. select {
  256. case <-release:
  257. case <-ctx.Done():
  258. return nil, ctx.Err()
  259. }
  260. }
  261. if err != nil {
  262. return nil, err
  263. }
  264. return []Account{{ID: 9001, Platform: platform, Status: StatusActive, Schedulable: true}}, nil
  265. }
  266. func (r *groupLifecycleTestAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, _ int64, platform string) ([]Account, error) {
  267. return r.load(ctx, platform)
  268. }
  269. func (r *groupLifecycleTestAccountRepo) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, _ int64, platforms []string) ([]Account, error) {
  270. platform := "mixed"
  271. if len(platforms) > 0 {
  272. platform = platforms[0]
  273. }
  274. return r.load(ctx, platform)
  275. }
  276. func (r *groupLifecycleTestAccountRepo) callCount() int {
  277. r.mu.Lock()
  278. defer r.mu.Unlock()
  279. return r.calls
  280. }
  281. func (r *groupLifecycleTestAccountRepo) platformCallCount(platform string) int {
  282. r.mu.Lock()
  283. defer r.mu.Unlock()
  284. return r.callsByPlatform[platform]
  285. }
  286. func newGroupLifecycleTestService(cache SchedulerCache, accounts AccountRepository, groups GroupRepository, runMode string) *SchedulerSnapshotService {
  287. return NewSchedulerSnapshotService(cache, nil, accounts, groups, &config.Config{RunMode: runMode})
  288. }
  289. func expectedGroupLifecycleBuckets(groupID int64) []SchedulerBucket {
  290. return schedulerCanonicalBuckets(groupID)
  291. }
  292. func schedulerCanonicalAccountQueryCount() int {
  293. count := 0
  294. for _, platform := range schedulerSnapshotPlatforms() {
  295. count++
  296. if platform == PlatformAnthropic || platform == PlatformGemini {
  297. count++
  298. }
  299. }
  300. return count
  301. }
  302. func bucketStrings(buckets []SchedulerBucket) map[string]struct{} {
  303. out := make(map[string]struct{}, len(buckets))
  304. for _, bucket := range buckets {
  305. out[bucket.String()] = struct{}{}
  306. }
  307. return out
  308. }
  309. func requireLifecycleSeen(t *testing.T, seen map[batchSeenKey]struct{}, groupID int64) {
  310. t.Helper()
  311. _, ok := seen[batchSeenKey{groupID: groupID, lifecycle: true}]
  312. require.True(t, ok)
  313. for _, platform := range schedulerSnapshotPlatforms() {
  314. _, ok = seen[batchSeenKey{groupID: groupID, platform: platform}]
  315. require.True(t, ok)
  316. }
  317. }
  318. func requireLifecycleNotSeen(t *testing.T, seen map[batchSeenKey]struct{}, groupID int64) {
  319. t.Helper()
  320. _, ok := seen[batchSeenKey{groupID: groupID, lifecycle: true}]
  321. require.False(t, ok)
  322. for _, platform := range schedulerSnapshotPlatforms() {
  323. _, ok = seen[batchSeenKey{groupID: groupID, platform: platform}]
  324. require.False(t, ok)
  325. }
  326. }
  327. func TestSchedulerGroupLifecycleInactiveAndMissingRetireAllHistoricalBucketsWithoutAccountReads(t *testing.T) {
  328. for _, tc := range []struct {
  329. name string
  330. group *Group
  331. err error
  332. }{
  333. {name: "inactive", group: &Group{ID: 81, Status: StatusDisabled, Hydrated: true}},
  334. {name: "missing", err: ErrGroupNotFound},
  335. } {
  336. t.Run(tc.name, func(t *testing.T) {
  337. const groupID int64 = 81
  338. current := expectedGroupLifecycleBuckets(groupID)
  339. historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "obsolete"}
  340. other := SchedulerBucket{GroupID: groupID + 1, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
  341. groupZero := SchedulerBucket{GroupID: 0, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
  342. cache := newGroupLifecycleTestCache(current[0], historical, other, groupZero)
  343. groups := &groupLifecycleTestGroupRepo{group: tc.group, err: tc.err}
  344. accounts := &groupLifecycleTestAccountRepo{}
  345. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  346. seen := make(map[batchSeenKey]struct{})
  347. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen))
  348. expected := bucketStrings(append(current, historical))
  349. got := bucketStrings(cache.retiredBuckets())
  350. require.Equal(t, expected, got)
  351. retireHeld, _ := cache.lifecycleMutationLeaseStates()
  352. require.Len(t, retireHeld, len(expected))
  353. for _, held := range retireHeld {
  354. require.True(t, held)
  355. }
  356. require.NotContains(t, got, other.String())
  357. require.NotContains(t, got, groupZero.String())
  358. require.Zero(t, accounts.callCount())
  359. require.Equal(t, 1, groups.callCount())
  360. _, _, listCalls := cache.lifecycleCounts()
  361. require.Equal(t, 1, listCalls)
  362. requireLifecycleSeen(t, seen, groupID)
  363. })
  364. }
  365. }
  366. func TestSchedulerPrepareGroupLifecycleUsesKnownHistoricalBucketsWithoutListingRegistry(t *testing.T) {
  367. const groupID int64 = 811
  368. historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "obsolete"}
  369. cache := newGroupLifecycleTestCache()
  370. cache.listErr = errors.New("registry must not be listed")
  371. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: groupID, Status: StatusDisabled, Hydrated: true}}
  372. accounts := &groupLifecycleTestAccountRepo{}
  373. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  374. plan, err := svc.prepareGroupLifecycle(context.Background(), groupID, []SchedulerBucket{historical})
  375. require.NoError(t, err)
  376. require.False(t, plan.active)
  377. require.Empty(t, plan.tasks)
  378. _, _, listCalls := cache.lifecycleCounts()
  379. require.Zero(t, listCalls)
  380. require.Contains(t, bucketStrings(cache.retiredBuckets()), historical.String())
  381. require.Zero(t, accounts.callCount())
  382. }
  383. func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *testing.T) {
  384. const groupID int64 = 82
  385. current := expectedGroupLifecycleBuckets(groupID)
  386. historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "obsolete"}
  387. cache := newGroupLifecycleTestCache(historical)
  388. for _, bucket := range current {
  389. require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), bucket))
  390. }
  391. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: groupID, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true}}
  392. accounts := &groupLifecycleTestAccountRepo{}
  393. accounts.beforeLoad = func() {
  394. held, tokenCount := cache.leaseHeldAndTokenCount()
  395. require.False(t, held, "the group lifecycle lease must be released before the first account query")
  396. require.Equal(t, len(current), tokenCount, "all reopen tokens must be prepared before the first account query")
  397. }
  398. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  399. seen := make(map[batchSeenKey]struct{})
  400. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen))
  401. require.Equal(t, bucketStrings(current), bucketStrings(cache.reopens))
  402. require.Empty(t, cache.retiredBuckets())
  403. registered, err := cache.retirementRaceCache.ListBuckets(context.Background())
  404. require.NoError(t, err)
  405. require.Contains(t, bucketStrings(registered), historical.String())
  406. require.Len(t, cache.tokens(), len(current))
  407. require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
  408. require.Equal(t, 1, accounts.platformCallCount(PlatformOpenAI))
  409. for _, bucket := range current {
  410. _, published := cache.counts(bucket)
  411. require.Equal(t, 1, published, bucket.String())
  412. }
  413. require.Contains(t, bucketStrings(current), SchedulerBucket{GroupID: groupID, Platform: PlatformAntigravity, Mode: SchedulerModeForced}.String())
  414. require.Contains(t, bucketStrings(current), SchedulerBucket{GroupID: groupID, Platform: PlatformAnthropic, Mode: SchedulerModeMixed}.String())
  415. require.Contains(t, bucketStrings(current), SchedulerBucket{GroupID: groupID, Platform: PlatformGemini, Mode: SchedulerModeMixed}.String())
  416. acquires, releases, listCalls := cache.lifecycleCounts()
  417. require.Equal(t, 1, acquires)
  418. require.Equal(t, 1, releases)
  419. require.Zero(t, listCalls)
  420. require.Equal(t, schedulerGroupLifecycleLeaseTTL, cache.acquireTTL)
  421. require.True(t, cache.acquireDeadline)
  422. require.True(t, cache.releaseDeadline)
  423. require.NoError(t, cache.releaseCtxErr)
  424. _, reopenHeld := cache.lifecycleMutationLeaseStates()
  425. require.Len(t, reopenHeld, len(current))
  426. for _, held := range reopenHeld {
  427. require.True(t, held)
  428. }
  429. lockTTLs, unlockCalls := cache.lockStats()
  430. require.Len(t, lockTTLs, len(current))
  431. for _, ttl := range lockTTLs {
  432. require.Equal(t, 30*time.Second, ttl)
  433. }
  434. require.Equal(t, len(current), unlockCalls)
  435. requireLifecycleSeen(t, seen, groupID)
  436. }
  437. func TestSchedulerGroupLifecycleInactiveThenActiveAuthoritativelyReopens(t *testing.T) {
  438. const groupID int64 = 83
  439. cache := newGroupLifecycleTestCache()
  440. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: groupID, Status: StatusDisabled, Hydrated: true}}
  441. accounts := &groupLifecycleTestAccountRepo{}
  442. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  443. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
  444. require.Zero(t, accounts.callCount())
  445. groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil)
  446. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
  447. require.Len(t, cache.tokens(), schedulerCanonicalBucketCount())
  448. require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
  449. for _, bucket := range expectedGroupLifecycleBuckets(groupID) {
  450. _, published := cache.counts(bucket)
  451. require.Equal(t, 1, published, bucket.String())
  452. }
  453. }
  454. func TestSchedulerGroupLifecycleLaterInactiveFencesLongActiveRebuild(t *testing.T) {
  455. const groupID int64 = 84
  456. cache := newGroupLifecycleTestCache()
  457. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: groupID, Status: StatusActive, Hydrated: true}}
  458. started := make(chan struct{})
  459. release := make(chan struct{})
  460. accounts := &groupLifecycleTestAccountRepo{started: started, release: release}
  461. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  462. activeSeen := make(map[batchSeenKey]struct{})
  463. inactiveSeen := make(map[batchSeenKey]struct{})
  464. activeResult := make(chan error, 1)
  465. go func() {
  466. activeResult <- svc.handleGroupEvent(context.Background(), ptrInt64(groupID), activeSeen)
  467. }()
  468. select {
  469. case <-started:
  470. case <-time.After(time.Second):
  471. t.Fatal("active rebuild did not reach the account load")
  472. }
  473. groups.set(&Group{ID: groupID, Status: StatusDisabled, Hydrated: true}, nil)
  474. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), inactiveSeen))
  475. close(release)
  476. err := <-activeResult
  477. require.ErrorIs(t, err, ErrSchedulerBucketRetired)
  478. requireLifecycleNotSeen(t, activeSeen, groupID)
  479. requireLifecycleSeen(t, inactiveSeen, groupID)
  480. }
  481. func TestSchedulerGroupLifecycleEpochPreventsABA(t *testing.T) {
  482. const groupID int64 = 85
  483. cache := newGroupLifecycleTestCache()
  484. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: groupID, Status: StatusDisabled, Hydrated: true}}
  485. accounts := &groupLifecycleTestAccountRepo{}
  486. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  487. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
  488. groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil)
  489. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
  490. firstActiveTokens := cache.tokens()
  491. canonicalCount := schedulerCanonicalBucketCount()
  492. require.Len(t, firstActiveTokens, canonicalCount)
  493. groups.set(&Group{ID: groupID, Status: StatusDisabled, Hydrated: true}, nil)
  494. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
  495. groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil)
  496. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{})))
  497. allTokens := cache.tokens()
  498. require.Len(t, allTokens, 2*canonicalCount)
  499. require.Greater(t, allTokens[canonicalCount].Epoch, firstActiveTokens[0].Epoch)
  500. require.ErrorIs(t, cache.SetSnapshot(context.Background(), firstActiveTokens[0].Bucket, firstActiveTokens[0], nil), ErrSchedulerBucketWriteFenced)
  501. }
  502. func TestSchedulerGroupLifecycleSeenIsIndependentAndDeduplicatesGroupEvents(t *testing.T) {
  503. const groupID int64 = 86
  504. cache := newGroupLifecycleTestCache()
  505. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: groupID, Status: StatusActive, Hydrated: true}}
  506. accounts := &groupLifecycleTestAccountRepo{}
  507. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  508. seen := make(map[batchSeenKey]struct{})
  509. for _, platform := range schedulerSnapshotPlatforms() {
  510. seen[batchSeenKey{groupID: groupID, platform: platform}] = struct{}{}
  511. }
  512. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen))
  513. require.Equal(t, 1, groups.callCount())
  514. require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
  515. requireLifecycleSeen(t, seen, groupID)
  516. require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen))
  517. require.Equal(t, 1, groups.callCount())
  518. require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
  519. }
  520. func TestSchedulerGroupLifecycleFailuresDoNotMarkSeen(t *testing.T) {
  521. tests := []struct {
  522. name string
  523. prepare func(*groupLifecycleTestCache, *groupLifecycleTestGroupRepo, *groupLifecycleTestAccountRepo)
  524. check func(*testing.T, error)
  525. }{
  526. {
  527. name: "lease busy",
  528. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  529. cache.leaseBusy = true
  530. },
  531. check: func(t *testing.T, err error) { require.ErrorIs(t, err, ErrSchedulerGroupLifecycleLeaseBusy) },
  532. },
  533. {
  534. name: "lease error",
  535. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  536. cache.leaseAcquireErr = errors.New("lease failed")
  537. },
  538. check: func(t *testing.T, err error) { require.EqualError(t, err, "lease failed") },
  539. },
  540. {
  541. name: "release lost",
  542. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  543. cache.leaseReleaseErr = ErrSchedulerGroupLifecycleLeaseLost
  544. },
  545. check: func(t *testing.T, err error) { require.ErrorIs(t, err, ErrSchedulerGroupLifecycleLeaseLost) },
  546. },
  547. {
  548. name: "release error",
  549. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  550. cache.leaseReleaseErr = errors.New("release failed")
  551. },
  552. check: func(t *testing.T, err error) { require.EqualError(t, err, "release failed") },
  553. },
  554. {
  555. name: "group query error",
  556. prepare: func(_ *groupLifecycleTestCache, groups *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  557. groups.err = errors.New("group query failed")
  558. },
  559. check: func(t *testing.T, err error) { require.EqualError(t, err, "group query failed") },
  560. },
  561. {
  562. name: "list buckets error",
  563. prepare: func(cache *groupLifecycleTestCache, groups *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  564. groups.group.Status = StatusDisabled
  565. cache.listErr = errors.New("list buckets failed")
  566. },
  567. check: func(t *testing.T, err error) { require.EqualError(t, err, "list buckets failed") },
  568. },
  569. {
  570. name: "retire bucket error",
  571. prepare: func(cache *groupLifecycleTestCache, groups *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  572. groups.group.Status = StatusDisabled
  573. cache.retireErr = errors.New("retire bucket failed")
  574. cache.retireErrAt = 2
  575. },
  576. check: func(t *testing.T, err error) { require.EqualError(t, err, "retire bucket failed") },
  577. },
  578. {
  579. name: "reopen bucket error",
  580. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  581. cache.reopenErr = errors.New("reopen bucket failed")
  582. cache.reopenErrAt = 2
  583. },
  584. check: func(t *testing.T, err error) { require.EqualError(t, err, "reopen bucket failed") },
  585. },
  586. {
  587. name: "account rebuild error",
  588. prepare: func(_ *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, accounts *groupLifecycleTestAccountRepo) {
  589. accounts.err = errors.New("account load failed")
  590. },
  591. check: func(t *testing.T, err error) { require.EqualError(t, err, "account load failed") },
  592. },
  593. {
  594. name: "bucket lock busy",
  595. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  596. cache.bucketLockBusy = true
  597. },
  598. check: func(t *testing.T, err error) { require.ErrorIs(t, err, ErrSchedulerBucketRebuildBusy) },
  599. },
  600. {
  601. name: "bucket lock error",
  602. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  603. cache.bucketLockErr = errors.New("bucket lock failed")
  604. },
  605. check: func(t *testing.T, err error) { require.EqualError(t, err, "bucket lock failed") },
  606. },
  607. {
  608. name: "set snapshot error",
  609. prepare: func(cache *groupLifecycleTestCache, _ *groupLifecycleTestGroupRepo, _ *groupLifecycleTestAccountRepo) {
  610. cache.setErr = errors.New("set snapshot failed")
  611. },
  612. check: func(t *testing.T, err error) { require.EqualError(t, err, "set snapshot failed") },
  613. },
  614. }
  615. for index, tc := range tests {
  616. t.Run(tc.name, func(t *testing.T) {
  617. groupID := int64(870 + index)
  618. cache := newGroupLifecycleTestCache()
  619. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: groupID, Status: StatusActive, Hydrated: true}}
  620. accounts := &groupLifecycleTestAccountRepo{}
  621. tc.prepare(cache, groups, accounts)
  622. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  623. seen := make(map[batchSeenKey]struct{})
  624. err := svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)
  625. tc.check(t, err)
  626. requireLifecycleNotSeen(t, seen, groupID)
  627. if tc.name == "release lost" || tc.name == "release error" {
  628. require.Zero(t, accounts.callCount())
  629. }
  630. if tc.name == "retire bucket error" || tc.name == "reopen bucket error" {
  631. _, releases, _ := cache.lifecycleCounts()
  632. require.Equal(t, 1, releases)
  633. require.Zero(t, accounts.callCount())
  634. }
  635. if tc.name == "account rebuild error" || tc.name == "set snapshot error" {
  636. lockTTLs, unlockCalls := cache.lockStats()
  637. require.Len(t, lockTTLs, 1)
  638. require.Equal(t, 1, unlockCalls)
  639. require.Equal(t, 1, accounts.callCount())
  640. }
  641. })
  642. }
  643. }
  644. func TestSchedulerGroupLifecycleOperationAndReleaseErrorsPreserveBothCauses(t *testing.T) {
  645. const groupID int64 = 880
  646. operationErr := errors.New("group query failed")
  647. cache := newGroupLifecycleTestCache()
  648. cache.leaseReleaseErr = ErrSchedulerGroupLifecycleLeaseLost
  649. groups := &groupLifecycleTestGroupRepo{err: operationErr}
  650. accounts := &groupLifecycleTestAccountRepo{}
  651. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  652. seen := make(map[batchSeenKey]struct{})
  653. err := svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)
  654. require.ErrorIs(t, err, operationErr)
  655. require.ErrorIs(t, err, ErrSchedulerGroupLifecycleLeaseLost)
  656. requireLifecycleNotSeen(t, seen, groupID)
  657. require.Zero(t, accounts.callCount())
  658. }
  659. func TestSchedulerGroupLifecycleUntrustedGroupStateFailsClosed(t *testing.T) {
  660. for _, tc := range []struct {
  661. name string
  662. group *Group
  663. }{
  664. {name: "not hydrated", group: &Group{ID: 88, Status: StatusActive}},
  665. {name: "mismatched id", group: &Group{ID: 89, Status: StatusActive, Hydrated: true}},
  666. } {
  667. t.Run(tc.name, func(t *testing.T) {
  668. const eventGroupID int64 = 88
  669. cache := newGroupLifecycleTestCache()
  670. groups := &groupLifecycleTestGroupRepo{group: tc.group}
  671. accounts := &groupLifecycleTestAccountRepo{}
  672. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  673. seen := make(map[batchSeenKey]struct{})
  674. err := svc.handleGroupEvent(context.Background(), ptrInt64(eventGroupID), seen)
  675. require.Error(t, err)
  676. require.Empty(t, cache.retiredBuckets())
  677. require.Empty(t, cache.tokens())
  678. require.Zero(t, accounts.callCount())
  679. requireLifecycleNotSeen(t, seen, eventGroupID)
  680. acquires, releases, listCalls := cache.lifecycleCounts()
  681. require.Equal(t, 1, acquires)
  682. require.Equal(t, 1, releases)
  683. require.Zero(t, listCalls)
  684. })
  685. }
  686. }
  687. func TestSchedulerGroupLifecycleCanceledAfterFreshQueryUsesIndependentReleaseContext(t *testing.T) {
  688. const groupID int64 = 89
  689. ctx, cancel := context.WithCancel(context.Background())
  690. cache := newGroupLifecycleTestCache()
  691. groups := &groupLifecycleTestGroupRepo{
  692. group: &Group{ID: groupID, Status: StatusActive, Hydrated: true},
  693. afterGet: cancel,
  694. }
  695. accounts := &groupLifecycleTestAccountRepo{}
  696. svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  697. seen := make(map[batchSeenKey]struct{})
  698. err := svc.handleGroupEvent(ctx, ptrInt64(groupID), seen)
  699. require.ErrorIs(t, err, context.Canceled)
  700. requireLifecycleNotSeen(t, seen, groupID)
  701. require.Empty(t, cache.tokens())
  702. require.Zero(t, accounts.callCount())
  703. acquires, releases, _ := cache.lifecycleCounts()
  704. require.Equal(t, 1, acquires)
  705. require.Equal(t, 1, releases)
  706. require.True(t, cache.releaseDeadline)
  707. require.NoError(t, cache.releaseCtxErr)
  708. }
  709. func TestSchedulerGroupLifecycleGroupZeroAndSimpleModeAreNoOps(t *testing.T) {
  710. cache := newGroupLifecycleTestCache()
  711. groups := &groupLifecycleTestGroupRepo{group: &Group{ID: 88, Status: StatusActive, Hydrated: true}}
  712. accounts := &groupLifecycleTestAccountRepo{}
  713. standard := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard)
  714. simple := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeSimple)
  715. require.NoError(t, standard.handleGroupEvent(context.Background(), nil, make(map[batchSeenKey]struct{})))
  716. require.NoError(t, standard.handleGroupEvent(context.Background(), ptrInt64(0), make(map[batchSeenKey]struct{})))
  717. require.NoError(t, simple.handleGroupEvent(context.Background(), ptrInt64(88), make(map[batchSeenKey]struct{})))
  718. acquires, releases, listCalls := cache.lifecycleCounts()
  719. require.Zero(t, acquires)
  720. require.Zero(t, releases)
  721. require.Zero(t, listCalls)
  722. require.Zero(t, groups.callCount())
  723. require.Zero(t, accounts.callCount())
  724. }