scheduler_snapshot_full_rebuild_lifecycle_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/stretchr/testify/require"
  11. )
  12. type fullRebuildLifecycleCache struct {
  13. *groupLifecycleTestCache
  14. mu sync.Mutex
  15. captureAttempts []SchedulerBucket
  16. captureErrors map[string]error
  17. lockBusyOnce map[string]bool
  18. watermark int64
  19. watermarkWrites []int64
  20. }
  21. func newFullRebuildLifecycleCache(buckets ...SchedulerBucket) *fullRebuildLifecycleCache {
  22. return &fullRebuildLifecycleCache{
  23. groupLifecycleTestCache: newGroupLifecycleTestCache(buckets...),
  24. captureErrors: make(map[string]error),
  25. lockBusyOnce: make(map[string]bool),
  26. }
  27. }
  28. func (c *fullRebuildLifecycleCache) CaptureBucketWriteToken(ctx context.Context, bucket SchedulerBucket) (SchedulerBucketWriteToken, error) {
  29. c.mu.Lock()
  30. c.captureAttempts = append(c.captureAttempts, bucket)
  31. err := c.captureErrors[bucket.String()]
  32. c.mu.Unlock()
  33. if err != nil {
  34. return SchedulerBucketWriteToken{}, err
  35. }
  36. return c.retirementRaceCache.CaptureBucketWriteToken(ctx, bucket)
  37. }
  38. func (c *fullRebuildLifecycleCache) ListBuckets(ctx context.Context) ([]SchedulerBucket, error) {
  39. buckets, err := c.groupLifecycleTestCache.ListBuckets(ctx)
  40. if err != nil {
  41. return nil, err
  42. }
  43. c.retirementRaceCache.mu.Lock()
  44. defer c.retirementRaceCache.mu.Unlock()
  45. registered := make([]SchedulerBucket, 0, len(buckets))
  46. for _, bucket := range buckets {
  47. if !c.retirementRaceCache.retired[bucket.String()] {
  48. registered = append(registered, bucket)
  49. }
  50. }
  51. return registered, nil
  52. }
  53. func (c *fullRebuildLifecycleCache) TryLockBucket(ctx context.Context, bucket SchedulerBucket, ttl time.Duration) (bool, error) {
  54. c.mu.Lock()
  55. busy := c.lockBusyOnce[bucket.String()]
  56. if busy {
  57. delete(c.lockBusyOnce, bucket.String())
  58. }
  59. c.mu.Unlock()
  60. if busy {
  61. return false, nil
  62. }
  63. return c.groupLifecycleTestCache.TryLockBucket(ctx, bucket, ttl)
  64. }
  65. func (c *fullRebuildLifecycleCache) GetOutboxWatermark(context.Context) (int64, error) {
  66. c.mu.Lock()
  67. defer c.mu.Unlock()
  68. return c.watermark, nil
  69. }
  70. func (c *fullRebuildLifecycleCache) SetOutboxWatermark(_ context.Context, id int64) error {
  71. c.mu.Lock()
  72. defer c.mu.Unlock()
  73. c.watermark = id
  74. c.watermarkWrites = append(c.watermarkWrites, id)
  75. return nil
  76. }
  77. func (c *fullRebuildLifecycleCache) captureAttemptCount() int {
  78. c.mu.Lock()
  79. defer c.mu.Unlock()
  80. return len(c.captureAttempts)
  81. }
  82. func (c *fullRebuildLifecycleCache) currentWatermark() int64 {
  83. c.mu.Lock()
  84. defer c.mu.Unlock()
  85. return c.watermark
  86. }
  87. func (c *fullRebuildLifecycleCache) totalSetAttempts() int {
  88. c.retirementRaceCache.mu.Lock()
  89. defer c.retirementRaceCache.mu.Unlock()
  90. var total int
  91. for _, attempts := range c.retirementRaceCache.setAttempts {
  92. total += attempts
  93. }
  94. return total
  95. }
  96. type fullRebuildLifecycleGroupRepo struct {
  97. GroupRepository
  98. mu sync.Mutex
  99. activeIDs []int64
  100. activeIDsErr error
  101. listActiveErr error
  102. fresh map[int64]*Group
  103. freshErr map[int64]error
  104. activeIDCalls int
  105. listActiveCalls int
  106. freshCalls []int64
  107. }
  108. func (r *fullRebuildLifecycleGroupRepo) ListActiveIDs(context.Context) ([]int64, error) {
  109. r.mu.Lock()
  110. defer r.mu.Unlock()
  111. r.activeIDCalls++
  112. return append([]int64(nil), r.activeIDs...), r.activeIDsErr
  113. }
  114. func (r *fullRebuildLifecycleGroupRepo) ListActive(context.Context) ([]Group, error) {
  115. r.mu.Lock()
  116. defer r.mu.Unlock()
  117. r.listActiveCalls++
  118. return nil, r.listActiveErr
  119. }
  120. func (r *fullRebuildLifecycleGroupRepo) GetByIDLite(_ context.Context, id int64) (*Group, error) {
  121. r.mu.Lock()
  122. defer r.mu.Unlock()
  123. r.freshCalls = append(r.freshCalls, id)
  124. if err := r.freshErr[id]; err != nil {
  125. return nil, err
  126. }
  127. group := r.fresh[id]
  128. if group == nil {
  129. return nil, ErrGroupNotFound
  130. }
  131. copyGroup := *group
  132. return &copyGroup, nil
  133. }
  134. func (r *fullRebuildLifecycleGroupRepo) stats() (activeIDs, listActive int, fresh []int64) {
  135. r.mu.Lock()
  136. defer r.mu.Unlock()
  137. return r.activeIDCalls, r.listActiveCalls, append([]int64(nil), r.freshCalls...)
  138. }
  139. type fullRebuildFallbackGroupRepo struct {
  140. GroupRepository
  141. mu sync.Mutex
  142. groups []Group
  143. err error
  144. listCalls int
  145. }
  146. func (r *fullRebuildFallbackGroupRepo) ListActive(context.Context) ([]Group, error) {
  147. r.mu.Lock()
  148. defer r.mu.Unlock()
  149. r.listCalls++
  150. return append([]Group(nil), r.groups...), r.err
  151. }
  152. type fullRebuildAccountCall struct {
  153. groupID int64
  154. platform string
  155. }
  156. type fullRebuildAccountRepo struct {
  157. AccountRepository
  158. mu sync.Mutex
  159. calls []fullRebuildAccountCall
  160. beforeFirst func()
  161. once sync.Once
  162. }
  163. func (r *fullRebuildAccountRepo) record(groupID int64, platform string) ([]Account, error) {
  164. r.mu.Lock()
  165. r.calls = append(r.calls, fullRebuildAccountCall{groupID: groupID, platform: platform})
  166. beforeFirst := r.beforeFirst
  167. r.mu.Unlock()
  168. if beforeFirst != nil {
  169. r.once.Do(beforeFirst)
  170. }
  171. return []Account{{ID: 1, Platform: platform, Status: StatusActive, Schedulable: true}}, nil
  172. }
  173. func (r *fullRebuildAccountRepo) ListSchedulableByGroupIDAndPlatform(_ context.Context, groupID int64, platform string) ([]Account, error) {
  174. return r.record(groupID, platform)
  175. }
  176. func (r *fullRebuildAccountRepo) ListSchedulableByGroupIDAndPlatforms(_ context.Context, groupID int64, platforms []string) ([]Account, error) {
  177. return r.record(groupID, firstPlatform(platforms))
  178. }
  179. func (r *fullRebuildAccountRepo) ListSchedulableUngroupedByPlatform(_ context.Context, platform string) ([]Account, error) {
  180. return r.record(0, platform)
  181. }
  182. func (r *fullRebuildAccountRepo) ListSchedulableUngroupedByPlatforms(_ context.Context, platforms []string) ([]Account, error) {
  183. return r.record(0, firstPlatform(platforms))
  184. }
  185. func (r *fullRebuildAccountRepo) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]Account, error) {
  186. panic("unexpected ListModelAvailabilityCandidates call")
  187. }
  188. func (r *fullRebuildAccountRepo) ListSchedulableByPlatform(_ context.Context, platform string) ([]Account, error) {
  189. return r.record(0, platform)
  190. }
  191. func (r *fullRebuildAccountRepo) ListSchedulableByPlatforms(_ context.Context, platforms []string) ([]Account, error) {
  192. return r.record(0, firstPlatform(platforms))
  193. }
  194. func (r *fullRebuildAccountRepo) callCount() int {
  195. r.mu.Lock()
  196. defer r.mu.Unlock()
  197. return len(r.calls)
  198. }
  199. func (r *fullRebuildAccountRepo) groupCallCount(groupID int64) int {
  200. r.mu.Lock()
  201. defer r.mu.Unlock()
  202. var count int
  203. for _, call := range r.calls {
  204. if call.groupID == groupID {
  205. count++
  206. }
  207. }
  208. return count
  209. }
  210. func firstPlatform(platforms []string) string {
  211. if len(platforms) == 0 {
  212. return ""
  213. }
  214. return platforms[0]
  215. }
  216. func newFullRebuildLifecycleService(
  217. cache SchedulerCache,
  218. outbox SchedulerOutboxRepository,
  219. accounts AccountRepository,
  220. groups GroupRepository,
  221. runMode string,
  222. ) *SchedulerSnapshotService {
  223. return NewSchedulerSnapshotService(cache, outbox, accounts, groups, &config.Config{RunMode: runMode})
  224. }
  225. func TestSchedulerFullRebuildActiveTombstoneDoesNotBlockFollowingGroupEvent(t *testing.T) {
  226. const groupID int64 = 101
  227. canonical := schedulerBucketsForGroup(groupID)
  228. cache := newFullRebuildLifecycleCache()
  229. require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[0]))
  230. groups := &fullRebuildLifecycleGroupRepo{
  231. activeIDs: []int64{groupID},
  232. fresh: map[int64]*Group{
  233. groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
  234. },
  235. freshErr: make(map[int64]error),
  236. }
  237. accounts := &fullRebuildAccountRepo{}
  238. outbox := &outboxCleanupRepo{events: []SchedulerOutboxEvent{
  239. {ID: 1, EventType: SchedulerOutboxEventFullRebuild},
  240. {ID: 2, EventType: SchedulerOutboxEventGroupChanged, GroupID: ptrInt64(groupID)},
  241. }}
  242. svc := newFullRebuildLifecycleService(cache, outbox, accounts, groups, config.RunModeStandard)
  243. svc.pollOutbox()
  244. require.Equal(t, int64(2), cache.currentWatermark())
  245. cache.mu.Lock()
  246. require.Equal(t, []int64{2}, cache.watermarkWrites)
  247. cache.mu.Unlock()
  248. activeCalls, fallbackCalls, freshCalls := groups.stats()
  249. require.Equal(t, 1, activeCalls)
  250. require.Zero(t, fallbackCalls)
  251. require.Equal(t, []int64{groupID, groupID}, freshCalls)
  252. canonicalCount := len(canonical)
  253. require.Len(t, cache.tokens(), 2*canonicalCount, "full rebuild and the following group event must each run fresh authority")
  254. _, reopenHeld := cache.lifecycleMutationLeaseStates()
  255. require.Len(t, reopenHeld, 2*canonicalCount)
  256. for _, held := range reopenHeld {
  257. require.True(t, held)
  258. }
  259. require.Equal(t, 3*schedulerCanonicalAccountQueryCount(), accounts.callCount())
  260. }
  261. func TestSchedulerFullRebuildGlobalReadErrorsFailBeforeMutationOrDB(t *testing.T) {
  262. t.Run("list buckets", func(t *testing.T) {
  263. cache := newFullRebuildLifecycleCache()
  264. cache.listErr = errors.New("registry failed")
  265. groups := &fullRebuildLifecycleGroupRepo{fresh: make(map[int64]*Group), freshErr: make(map[int64]error)}
  266. accounts := &fullRebuildAccountRepo{}
  267. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  268. err := svc.rebuildFullSnapshot(context.Background(), "test")
  269. require.ErrorIs(t, err, cache.listErr)
  270. activeCalls, fallbackCalls, freshCalls := groups.stats()
  271. require.Zero(t, activeCalls)
  272. require.Zero(t, fallbackCalls)
  273. require.Empty(t, freshCalls)
  274. requireFullRebuildNoMutationOrDB(t, cache, accounts)
  275. })
  276. t.Run("list active ids without fallback", func(t *testing.T) {
  277. cache := newFullRebuildLifecycleCache()
  278. groups := &fullRebuildLifecycleGroupRepo{
  279. activeIDsErr: errors.New("active ids failed"),
  280. listActiveErr: errors.New("fallback must not run"),
  281. fresh: make(map[int64]*Group),
  282. freshErr: make(map[int64]error),
  283. }
  284. accounts := &fullRebuildAccountRepo{}
  285. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  286. err := svc.rebuildFullSnapshot(context.Background(), "test")
  287. require.ErrorIs(t, err, groups.activeIDsErr)
  288. activeCalls, fallbackCalls, freshCalls := groups.stats()
  289. require.Equal(t, 1, activeCalls)
  290. require.Zero(t, fallbackCalls)
  291. require.Empty(t, freshCalls)
  292. requireFullRebuildNoMutationOrDB(t, cache, accounts)
  293. })
  294. t.Run("list active fallback", func(t *testing.T) {
  295. cache := newFullRebuildLifecycleCache()
  296. groups := &fullRebuildFallbackGroupRepo{err: errors.New("active groups failed")}
  297. accounts := &fullRebuildAccountRepo{}
  298. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  299. err := svc.rebuildFullSnapshot(context.Background(), "test")
  300. require.ErrorIs(t, err, groups.err)
  301. groups.mu.Lock()
  302. require.Equal(t, 1, groups.listCalls)
  303. groups.mu.Unlock()
  304. requireFullRebuildNoMutationOrDB(t, cache, accounts)
  305. })
  306. }
  307. func TestSchedulerFullRebuildFreshActivePreparesEveryTokenBeforeFirstDB(t *testing.T) {
  308. const groupID int64 = 102
  309. historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "unknown"}
  310. cache := newFullRebuildLifecycleCache(historical)
  311. groups := &fullRebuildLifecycleGroupRepo{
  312. fresh: map[int64]*Group{
  313. groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
  314. },
  315. freshErr: make(map[int64]error),
  316. }
  317. accounts := &fullRebuildAccountRepo{}
  318. var capturesAtFirstDB int
  319. accounts.beforeFirst = func() {
  320. capturesAtFirstDB = cache.captureAttemptCount()
  321. held, reopenCount := cache.leaseHeldAndTokenCount()
  322. require.False(t, held)
  323. require.Equal(t, schedulerCanonicalBucketCount(), reopenCount)
  324. require.Equal(t, schedulerCanonicalBucketCount()+1, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB")
  325. }
  326. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  327. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
  328. require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount())
  329. require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount())
  330. require.Equal(t, schedulerCanonicalAccountQueryCount()+1, accounts.groupCallCount(groupID))
  331. _, historicalPublished := cache.counts(historical)
  332. require.Equal(t, 1, historicalPublished)
  333. activeCalls, fallbackCalls, freshCalls := groups.stats()
  334. require.Equal(t, 1, activeCalls)
  335. require.Zero(t, fallbackCalls)
  336. require.Equal(t, []int64{groupID}, freshCalls)
  337. _, _, listCalls := cache.lifecycleCounts()
  338. require.Equal(t, 1, listCalls, "known Rg must prevent a second registry read")
  339. }
  340. func TestSchedulerFullRebuildOrdinaryCaptureErrorReturnsBeforeFirstDB(t *testing.T) {
  341. first := SchedulerBucket{GroupID: 0, Platform: "legacy-a", Mode: "unknown"}
  342. last := SchedulerBucket{GroupID: 0, Platform: "legacy-b", Mode: "unknown"}
  343. cache := newFullRebuildLifecycleCache(first, last)
  344. wantErr := errors.New("capture failed")
  345. cache.captureErrors[last.String()] = wantErr
  346. groups := &fullRebuildLifecycleGroupRepo{fresh: make(map[int64]*Group), freshErr: make(map[int64]error)}
  347. accounts := &fullRebuildAccountRepo{}
  348. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  349. err := svc.rebuildFullSnapshot(context.Background(), "test")
  350. require.ErrorIs(t, err, wantErr)
  351. require.Equal(t, schedulerCanonicalBucketCount()+2, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning")
  352. require.Zero(t, accounts.callCount())
  353. require.Zero(t, cache.totalSetAttempts())
  354. }
  355. func TestSchedulerFullRebuildPreservesGroupZeroActiveHistoricalAndInvalidRegistryBuckets(t *testing.T) {
  356. const groupID int64 = 103
  357. groupZeroHistorical := SchedulerBucket{GroupID: 0, Platform: "legacy-zero", Mode: "unknown"}
  358. activeHistorical := SchedulerBucket{GroupID: groupID, Platform: "legacy-active", Mode: "unknown"}
  359. activeForced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
  360. activeMixed := SchedulerBucket{GroupID: groupID, Platform: PlatformAnthropic, Mode: SchedulerModeMixed}
  361. invalidHistorical := SchedulerBucket{GroupID: -7, Platform: "legacy-invalid", Mode: "unknown"}
  362. cache := newFullRebuildLifecycleCache(groupZeroHistorical, activeHistorical, activeForced, activeMixed, invalidHistorical)
  363. groups := &fullRebuildFallbackGroupRepo{groups: []Group{{ID: groupID, Status: StatusActive}}}
  364. accounts := &fullRebuildAccountRepo{}
  365. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  366. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
  367. require.Equal(t, 2*schedulerCanonicalBucketCount()+3, cache.captureAttemptCount())
  368. require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+3, accounts.callCount())
  369. groups.mu.Lock()
  370. require.Equal(t, 1, groups.listCalls)
  371. groups.mu.Unlock()
  372. require.Empty(t, cache.retiredBuckets())
  373. require.Empty(t, cache.tokens())
  374. for _, bucket := range []SchedulerBucket{groupZeroHistorical, activeHistorical, activeForced, activeMixed, invalidHistorical} {
  375. _, published := cache.counts(bucket)
  376. require.Equal(t, 1, published, bucket.String())
  377. }
  378. }
  379. func TestSchedulerFullRebuildActiveTombstoneFreshInactiveOrMissingFiltersAllGroupTasks(t *testing.T) {
  380. for _, tc := range []struct {
  381. name string
  382. group *Group
  383. }{
  384. {name: "inactive", group: &Group{ID: 104, Status: StatusDisabled, Hydrated: true}},
  385. {name: "missing"},
  386. } {
  387. t.Run(tc.name, func(t *testing.T) {
  388. const groupID int64 = 104
  389. canonical := schedulerBucketsForGroup(groupID)
  390. historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "unknown"}
  391. cache := newFullRebuildLifecycleCache(historical)
  392. require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[5]))
  393. groups := &fullRebuildLifecycleGroupRepo{
  394. activeIDs: []int64{groupID},
  395. fresh: make(map[int64]*Group),
  396. freshErr: make(map[int64]error),
  397. }
  398. if tc.group != nil {
  399. groups.fresh[groupID] = tc.group
  400. }
  401. accounts := &fullRebuildAccountRepo{}
  402. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  403. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
  404. require.Zero(t, accounts.groupCallCount(groupID))
  405. require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.groupCallCount(0))
  406. require.Empty(t, cache.tokens())
  407. require.Equal(t, bucketStrings(append(canonical, historical)), bucketStrings(cache.retiredBuckets()))
  408. for _, bucket := range append(canonical, historical) {
  409. attempts, published := cache.counts(bucket)
  410. require.Zero(t, attempts, bucket.String())
  411. require.Zero(t, published, bucket.String())
  412. }
  413. _, _, listCalls := cache.lifecycleCounts()
  414. require.Equal(t, 1, listCalls)
  415. })
  416. }
  417. }
  418. func TestSchedulerFullRebuildStaleCandidatesAreSortedAndNeverReopenedAcrossRounds(t *testing.T) {
  419. historical := []SchedulerBucket{
  420. {GroupID: 3, Platform: "legacy", Mode: "unknown"},
  421. {GroupID: 1, Platform: "legacy", Mode: "unknown"},
  422. {GroupID: 2, Platform: "legacy", Mode: "unknown"},
  423. }
  424. cache := newFullRebuildLifecycleCache(historical...)
  425. groups := &fullRebuildLifecycleGroupRepo{
  426. fresh: map[int64]*Group{
  427. 1: {ID: 1, Status: StatusDisabled, Hydrated: true},
  428. 3: {ID: 3, Status: StatusDisabled, Hydrated: true},
  429. },
  430. freshErr: make(map[int64]error),
  431. }
  432. accounts := &fullRebuildAccountRepo{}
  433. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  434. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "first"))
  435. retireCallsAfterFirst := len(cache.retiredBuckets())
  436. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "second"))
  437. _, _, freshCalls := groups.stats()
  438. require.Equal(t, []int64{1, 2, 3}, freshCalls)
  439. require.Equal(t, retireCallsAfterFirst, len(cache.retiredBuckets()))
  440. require.Empty(t, cache.tokens())
  441. require.Zero(t, accounts.groupCallCount(1))
  442. require.Zero(t, accounts.groupCallCount(2))
  443. require.Zero(t, accounts.groupCallCount(3))
  444. _, _, listCalls := cache.lifecycleCounts()
  445. require.Equal(t, 2, listCalls, "each round must use only its one global registry snapshot")
  446. for _, bucket := range historical {
  447. require.Contains(t, bucketStrings(cache.retiredBuckets()), bucket.String())
  448. }
  449. }
  450. func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t *testing.T) {
  451. historical := []SchedulerBucket{
  452. {GroupID: 1, Platform: "legacy", Mode: "unknown"},
  453. {GroupID: 2, Platform: "legacy", Mode: "unknown"},
  454. {GroupID: 3, Platform: "legacy", Mode: "unknown"},
  455. }
  456. wantErr := errors.New("fresh group query failed")
  457. cache := newFullRebuildLifecycleCache(historical...)
  458. groups := &fullRebuildLifecycleGroupRepo{
  459. fresh: map[int64]*Group{
  460. 1: {ID: 1, Status: StatusDisabled, Hydrated: true},
  461. 3: {ID: 3, Status: StatusDisabled, Hydrated: true},
  462. },
  463. freshErr: map[int64]error{2: wantErr},
  464. }
  465. accounts := &fullRebuildAccountRepo{}
  466. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  467. err := svc.triggerFullRebuild("first")
  468. require.ErrorIs(t, err, wantErr)
  469. _, _, freshCalls := groups.stats()
  470. require.Equal(t, []int64{1, 2}, freshCalls)
  471. require.Zero(t, accounts.callCount())
  472. require.Zero(t, cache.totalSetAttempts())
  473. require.Equal(t, schedulerCanonicalBucketCount()+1, len(cache.retiredBuckets()))
  474. groups.mu.Lock()
  475. delete(groups.freshErr, 2)
  476. groups.fresh[2] = &Group{ID: 2, Status: StatusDisabled, Hydrated: true}
  477. groups.mu.Unlock()
  478. require.NoError(t, svc.triggerFullRebuild("retry"))
  479. _, _, freshCalls = groups.stats()
  480. require.Equal(t, []int64{1, 2, 2, 3}, freshCalls)
  481. require.Equal(t, 3*(schedulerCanonicalBucketCount()+1), len(cache.retiredBuckets()))
  482. require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
  483. require.Empty(t, cache.tokens())
  484. }
  485. func TestSchedulerFullRebuildActiveTombstoneLazyRecoveryDiscardsPartialCaptureTasks(t *testing.T) {
  486. const groupID int64 = 105
  487. canonical := schedulerBucketsForGroup(groupID)
  488. historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "unknown"}
  489. cache := newFullRebuildLifecycleCache(canonical[0], canonical[4], historical)
  490. require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[5]))
  491. groups := &fullRebuildLifecycleGroupRepo{
  492. activeIDs: []int64{groupID},
  493. fresh: map[int64]*Group{
  494. groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
  495. },
  496. freshErr: make(map[int64]error),
  497. }
  498. accounts := &fullRebuildAccountRepo{}
  499. var capturesAtFirstDB int
  500. accounts.beforeFirst = func() {
  501. capturesAtFirstDB = cache.captureAttemptCount()
  502. held, reopenCount := cache.leaseHeldAndTokenCount()
  503. require.False(t, held)
  504. require.Equal(t, len(canonical), reopenCount)
  505. // C(0) + captures through the retired canonical index (inclusive) + historical ordinary bucket.
  506. require.Equal(t, schedulerCanonicalBucketCount()+6+1, capturesAtFirstDB)
  507. }
  508. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  509. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
  510. require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount())
  511. require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount())
  512. for _, bucket := range canonical {
  513. attempts, published := cache.counts(bucket)
  514. require.Equal(t, 1, attempts, "discarded pre-recovery tokens must never publish: %s", bucket.String())
  515. require.Equal(t, 1, published, bucket.String())
  516. }
  517. _, published := cache.counts(historical)
  518. require.Equal(t, 1, published)
  519. _, _, listCalls := cache.lifecycleCounts()
  520. require.Equal(t, 1, listCalls)
  521. }
  522. func TestSchedulerFullRebuildSimpleModePreservesRegistryWithoutLifecycleAuthority(t *testing.T) {
  523. registered := []SchedulerBucket{
  524. {GroupID: 0, Platform: "legacy-zero", Mode: "unknown"},
  525. {GroupID: 106, Platform: "legacy-positive", Mode: "unknown"},
  526. {GroupID: -8, Platform: "legacy-negative", Mode: "unknown"},
  527. }
  528. cache := newFullRebuildLifecycleCache(registered...)
  529. groups := &fullRebuildLifecycleGroupRepo{
  530. activeIDsErr: errors.New("simple mode must not query groups"),
  531. fresh: make(map[int64]*Group),
  532. freshErr: make(map[int64]error),
  533. }
  534. accounts := &fullRebuildAccountRepo{}
  535. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeSimple)
  536. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
  537. activeCalls, fallbackCalls, freshCalls := groups.stats()
  538. require.Zero(t, activeCalls)
  539. require.Zero(t, fallbackCalls)
  540. require.Empty(t, freshCalls)
  541. require.Equal(t, schedulerCanonicalBucketCount()+len(registered), cache.captureAttemptCount())
  542. require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.callCount())
  543. require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.groupCallCount(0))
  544. require.Empty(t, cache.retiredBuckets())
  545. require.Empty(t, cache.tokens())
  546. for _, bucket := range registered {
  547. _, published := cache.counts(bucket)
  548. require.Equal(t, 1, published, bucket.String())
  549. }
  550. }
  551. func TestSchedulerFullRebuildFreshReopenLockBusyRetriesWithoutBlockingOrdinaryTasks(t *testing.T) {
  552. const groupID int64 = 107
  553. canonical := schedulerBucketsForGroup(groupID)
  554. cache := newFullRebuildLifecycleCache()
  555. require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[0]))
  556. cache.lockBusyOnce[canonical[0].String()] = true
  557. groups := &fullRebuildLifecycleGroupRepo{
  558. activeIDs: []int64{groupID},
  559. fresh: map[int64]*Group{
  560. groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
  561. },
  562. freshErr: make(map[int64]error),
  563. }
  564. accounts := &fullRebuildAccountRepo{}
  565. outbox := &outboxCleanupRepo{events: []SchedulerOutboxEvent{{ID: 1, EventType: SchedulerOutboxEventFullRebuild}}}
  566. svc := newFullRebuildLifecycleService(cache, outbox, accounts, groups, config.RunModeStandard)
  567. svc.pollOutbox()
  568. require.Zero(t, cache.currentWatermark())
  569. _, groupZeroPublished := cache.counts(schedulerCanonicalBuckets(0)[0])
  570. require.Equal(t, 1, groupZeroPublished, "ordinary tasks must still run when one strict Reopen task is busy")
  571. require.Equal(t, 2*schedulerCanonicalAccountQueryCount(), accounts.callCount())
  572. svc.pollOutbox()
  573. require.Equal(t, int64(1), cache.currentWatermark())
  574. require.Equal(t, 4*schedulerCanonicalAccountQueryCount(), accounts.callCount())
  575. _, busyBucketPublished := cache.counts(canonical[0])
  576. require.Equal(t, 1, busyBucketPublished)
  577. activeCalls, fallbackCalls, freshCalls := groups.stats()
  578. require.Equal(t, 2, activeCalls)
  579. require.Zero(t, fallbackCalls)
  580. require.Equal(t, []int64{groupID}, freshCalls)
  581. }
  582. func TestSchedulerFullRebuildOrdinaryLockBusyKeepsExistingSkipSemantics(t *testing.T) {
  583. busyBucket := schedulerCanonicalBuckets(0)[0]
  584. cache := newFullRebuildLifecycleCache()
  585. cache.lockBusyOnce[busyBucket.String()] = true
  586. groups := &fullRebuildLifecycleGroupRepo{fresh: make(map[int64]*Group), freshErr: make(map[int64]error)}
  587. accounts := &fullRebuildAccountRepo{}
  588. svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
  589. require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
  590. require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
  591. attempts, published := cache.counts(busyBucket)
  592. require.Zero(t, attempts)
  593. require.Zero(t, published)
  594. }
  595. func requireFullRebuildNoMutationOrDB(t *testing.T, cache *fullRebuildLifecycleCache, accounts *fullRebuildAccountRepo) {
  596. t.Helper()
  597. require.Zero(t, cache.captureAttemptCount())
  598. require.Empty(t, cache.retiredBuckets())
  599. require.Empty(t, cache.tokens())
  600. require.Zero(t, accounts.callCount())
  601. require.Zero(t, cache.totalSetAttempts())
  602. }