| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672 |
- //go:build unit
- package service
- import (
- "context"
- "errors"
- "sync"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/require"
- )
- type fullRebuildLifecycleCache struct {
- *groupLifecycleTestCache
- mu sync.Mutex
- captureAttempts []SchedulerBucket
- captureErrors map[string]error
- lockBusyOnce map[string]bool
- watermark int64
- watermarkWrites []int64
- }
- func newFullRebuildLifecycleCache(buckets ...SchedulerBucket) *fullRebuildLifecycleCache {
- return &fullRebuildLifecycleCache{
- groupLifecycleTestCache: newGroupLifecycleTestCache(buckets...),
- captureErrors: make(map[string]error),
- lockBusyOnce: make(map[string]bool),
- }
- }
- func (c *fullRebuildLifecycleCache) CaptureBucketWriteToken(ctx context.Context, bucket SchedulerBucket) (SchedulerBucketWriteToken, error) {
- c.mu.Lock()
- c.captureAttempts = append(c.captureAttempts, bucket)
- err := c.captureErrors[bucket.String()]
- c.mu.Unlock()
- if err != nil {
- return SchedulerBucketWriteToken{}, err
- }
- return c.retirementRaceCache.CaptureBucketWriteToken(ctx, bucket)
- }
- func (c *fullRebuildLifecycleCache) ListBuckets(ctx context.Context) ([]SchedulerBucket, error) {
- buckets, err := c.groupLifecycleTestCache.ListBuckets(ctx)
- if err != nil {
- return nil, err
- }
- c.retirementRaceCache.mu.Lock()
- defer c.retirementRaceCache.mu.Unlock()
- registered := make([]SchedulerBucket, 0, len(buckets))
- for _, bucket := range buckets {
- if !c.retirementRaceCache.retired[bucket.String()] {
- registered = append(registered, bucket)
- }
- }
- return registered, nil
- }
- func (c *fullRebuildLifecycleCache) TryLockBucket(ctx context.Context, bucket SchedulerBucket, ttl time.Duration) (bool, error) {
- c.mu.Lock()
- busy := c.lockBusyOnce[bucket.String()]
- if busy {
- delete(c.lockBusyOnce, bucket.String())
- }
- c.mu.Unlock()
- if busy {
- return false, nil
- }
- return c.groupLifecycleTestCache.TryLockBucket(ctx, bucket, ttl)
- }
- func (c *fullRebuildLifecycleCache) GetOutboxWatermark(context.Context) (int64, error) {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.watermark, nil
- }
- func (c *fullRebuildLifecycleCache) SetOutboxWatermark(_ context.Context, id int64) error {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.watermark = id
- c.watermarkWrites = append(c.watermarkWrites, id)
- return nil
- }
- func (c *fullRebuildLifecycleCache) captureAttemptCount() int {
- c.mu.Lock()
- defer c.mu.Unlock()
- return len(c.captureAttempts)
- }
- func (c *fullRebuildLifecycleCache) currentWatermark() int64 {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.watermark
- }
- func (c *fullRebuildLifecycleCache) totalSetAttempts() int {
- c.retirementRaceCache.mu.Lock()
- defer c.retirementRaceCache.mu.Unlock()
- var total int
- for _, attempts := range c.retirementRaceCache.setAttempts {
- total += attempts
- }
- return total
- }
- type fullRebuildLifecycleGroupRepo struct {
- GroupRepository
- mu sync.Mutex
- activeIDs []int64
- activeIDsErr error
- listActiveErr error
- fresh map[int64]*Group
- freshErr map[int64]error
- activeIDCalls int
- listActiveCalls int
- freshCalls []int64
- }
- func (r *fullRebuildLifecycleGroupRepo) ListActiveIDs(context.Context) ([]int64, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.activeIDCalls++
- return append([]int64(nil), r.activeIDs...), r.activeIDsErr
- }
- func (r *fullRebuildLifecycleGroupRepo) ListActive(context.Context) ([]Group, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.listActiveCalls++
- return nil, r.listActiveErr
- }
- func (r *fullRebuildLifecycleGroupRepo) GetByIDLite(_ context.Context, id int64) (*Group, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.freshCalls = append(r.freshCalls, id)
- if err := r.freshErr[id]; err != nil {
- return nil, err
- }
- group := r.fresh[id]
- if group == nil {
- return nil, ErrGroupNotFound
- }
- copyGroup := *group
- return ©Group, nil
- }
- func (r *fullRebuildLifecycleGroupRepo) stats() (activeIDs, listActive int, fresh []int64) {
- r.mu.Lock()
- defer r.mu.Unlock()
- return r.activeIDCalls, r.listActiveCalls, append([]int64(nil), r.freshCalls...)
- }
- type fullRebuildFallbackGroupRepo struct {
- GroupRepository
- mu sync.Mutex
- groups []Group
- err error
- listCalls int
- }
- func (r *fullRebuildFallbackGroupRepo) ListActive(context.Context) ([]Group, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.listCalls++
- return append([]Group(nil), r.groups...), r.err
- }
- type fullRebuildAccountCall struct {
- groupID int64
- platform string
- }
- type fullRebuildAccountRepo struct {
- AccountRepository
- mu sync.Mutex
- calls []fullRebuildAccountCall
- beforeFirst func()
- once sync.Once
- }
- func (r *fullRebuildAccountRepo) record(groupID int64, platform string) ([]Account, error) {
- r.mu.Lock()
- r.calls = append(r.calls, fullRebuildAccountCall{groupID: groupID, platform: platform})
- beforeFirst := r.beforeFirst
- r.mu.Unlock()
- if beforeFirst != nil {
- r.once.Do(beforeFirst)
- }
- return []Account{{ID: 1, Platform: platform, Status: StatusActive, Schedulable: true}}, nil
- }
- func (r *fullRebuildAccountRepo) ListSchedulableByGroupIDAndPlatform(_ context.Context, groupID int64, platform string) ([]Account, error) {
- return r.record(groupID, platform)
- }
- func (r *fullRebuildAccountRepo) ListSchedulableByGroupIDAndPlatforms(_ context.Context, groupID int64, platforms []string) ([]Account, error) {
- return r.record(groupID, firstPlatform(platforms))
- }
- func (r *fullRebuildAccountRepo) ListSchedulableUngroupedByPlatform(_ context.Context, platform string) ([]Account, error) {
- return r.record(0, platform)
- }
- func (r *fullRebuildAccountRepo) ListSchedulableUngroupedByPlatforms(_ context.Context, platforms []string) ([]Account, error) {
- return r.record(0, firstPlatform(platforms))
- }
- func (r *fullRebuildAccountRepo) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]Account, error) {
- panic("unexpected ListModelAvailabilityCandidates call")
- }
- func (r *fullRebuildAccountRepo) ListSchedulableByPlatform(_ context.Context, platform string) ([]Account, error) {
- return r.record(0, platform)
- }
- func (r *fullRebuildAccountRepo) ListSchedulableByPlatforms(_ context.Context, platforms []string) ([]Account, error) {
- return r.record(0, firstPlatform(platforms))
- }
- func (r *fullRebuildAccountRepo) callCount() int {
- r.mu.Lock()
- defer r.mu.Unlock()
- return len(r.calls)
- }
- func (r *fullRebuildAccountRepo) groupCallCount(groupID int64) int {
- r.mu.Lock()
- defer r.mu.Unlock()
- var count int
- for _, call := range r.calls {
- if call.groupID == groupID {
- count++
- }
- }
- return count
- }
- func firstPlatform(platforms []string) string {
- if len(platforms) == 0 {
- return ""
- }
- return platforms[0]
- }
- func newFullRebuildLifecycleService(
- cache SchedulerCache,
- outbox SchedulerOutboxRepository,
- accounts AccountRepository,
- groups GroupRepository,
- runMode string,
- ) *SchedulerSnapshotService {
- return NewSchedulerSnapshotService(cache, outbox, accounts, groups, &config.Config{RunMode: runMode})
- }
- func TestSchedulerFullRebuildActiveTombstoneDoesNotBlockFollowingGroupEvent(t *testing.T) {
- const groupID int64 = 101
- canonical := schedulerBucketsForGroup(groupID)
- cache := newFullRebuildLifecycleCache()
- require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[0]))
- groups := &fullRebuildLifecycleGroupRepo{
- activeIDs: []int64{groupID},
- fresh: map[int64]*Group{
- groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
- },
- freshErr: make(map[int64]error),
- }
- accounts := &fullRebuildAccountRepo{}
- outbox := &outboxCleanupRepo{events: []SchedulerOutboxEvent{
- {ID: 1, EventType: SchedulerOutboxEventFullRebuild},
- {ID: 2, EventType: SchedulerOutboxEventGroupChanged, GroupID: ptrInt64(groupID)},
- }}
- svc := newFullRebuildLifecycleService(cache, outbox, accounts, groups, config.RunModeStandard)
- svc.pollOutbox()
- require.Equal(t, int64(2), cache.currentWatermark())
- cache.mu.Lock()
- require.Equal(t, []int64{2}, cache.watermarkWrites)
- cache.mu.Unlock()
- activeCalls, fallbackCalls, freshCalls := groups.stats()
- require.Equal(t, 1, activeCalls)
- require.Zero(t, fallbackCalls)
- require.Equal(t, []int64{groupID, groupID}, freshCalls)
- canonicalCount := len(canonical)
- require.Len(t, cache.tokens(), 2*canonicalCount, "full rebuild and the following group event must each run fresh authority")
- _, reopenHeld := cache.lifecycleMutationLeaseStates()
- require.Len(t, reopenHeld, 2*canonicalCount)
- for _, held := range reopenHeld {
- require.True(t, held)
- }
- require.Equal(t, 3*schedulerCanonicalAccountQueryCount(), accounts.callCount())
- }
- func TestSchedulerFullRebuildGlobalReadErrorsFailBeforeMutationOrDB(t *testing.T) {
- t.Run("list buckets", func(t *testing.T) {
- cache := newFullRebuildLifecycleCache()
- cache.listErr = errors.New("registry failed")
- groups := &fullRebuildLifecycleGroupRepo{fresh: make(map[int64]*Group), freshErr: make(map[int64]error)}
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- err := svc.rebuildFullSnapshot(context.Background(), "test")
- require.ErrorIs(t, err, cache.listErr)
- activeCalls, fallbackCalls, freshCalls := groups.stats()
- require.Zero(t, activeCalls)
- require.Zero(t, fallbackCalls)
- require.Empty(t, freshCalls)
- requireFullRebuildNoMutationOrDB(t, cache, accounts)
- })
- t.Run("list active ids without fallback", func(t *testing.T) {
- cache := newFullRebuildLifecycleCache()
- groups := &fullRebuildLifecycleGroupRepo{
- activeIDsErr: errors.New("active ids failed"),
- listActiveErr: errors.New("fallback must not run"),
- fresh: make(map[int64]*Group),
- freshErr: make(map[int64]error),
- }
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- err := svc.rebuildFullSnapshot(context.Background(), "test")
- require.ErrorIs(t, err, groups.activeIDsErr)
- activeCalls, fallbackCalls, freshCalls := groups.stats()
- require.Equal(t, 1, activeCalls)
- require.Zero(t, fallbackCalls)
- require.Empty(t, freshCalls)
- requireFullRebuildNoMutationOrDB(t, cache, accounts)
- })
- t.Run("list active fallback", func(t *testing.T) {
- cache := newFullRebuildLifecycleCache()
- groups := &fullRebuildFallbackGroupRepo{err: errors.New("active groups failed")}
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- err := svc.rebuildFullSnapshot(context.Background(), "test")
- require.ErrorIs(t, err, groups.err)
- groups.mu.Lock()
- require.Equal(t, 1, groups.listCalls)
- groups.mu.Unlock()
- requireFullRebuildNoMutationOrDB(t, cache, accounts)
- })
- }
- func TestSchedulerFullRebuildFreshActivePreparesEveryTokenBeforeFirstDB(t *testing.T) {
- const groupID int64 = 102
- historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "unknown"}
- cache := newFullRebuildLifecycleCache(historical)
- groups := &fullRebuildLifecycleGroupRepo{
- fresh: map[int64]*Group{
- groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
- },
- freshErr: make(map[int64]error),
- }
- accounts := &fullRebuildAccountRepo{}
- var capturesAtFirstDB int
- accounts.beforeFirst = func() {
- capturesAtFirstDB = cache.captureAttemptCount()
- held, reopenCount := cache.leaseHeldAndTokenCount()
- require.False(t, held)
- require.Equal(t, schedulerCanonicalBucketCount(), reopenCount)
- require.Equal(t, schedulerCanonicalBucketCount()+1, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB")
- }
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
- require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount())
- require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount())
- require.Equal(t, schedulerCanonicalAccountQueryCount()+1, accounts.groupCallCount(groupID))
- _, historicalPublished := cache.counts(historical)
- require.Equal(t, 1, historicalPublished)
- activeCalls, fallbackCalls, freshCalls := groups.stats()
- require.Equal(t, 1, activeCalls)
- require.Zero(t, fallbackCalls)
- require.Equal(t, []int64{groupID}, freshCalls)
- _, _, listCalls := cache.lifecycleCounts()
- require.Equal(t, 1, listCalls, "known Rg must prevent a second registry read")
- }
- func TestSchedulerFullRebuildOrdinaryCaptureErrorReturnsBeforeFirstDB(t *testing.T) {
- first := SchedulerBucket{GroupID: 0, Platform: "legacy-a", Mode: "unknown"}
- last := SchedulerBucket{GroupID: 0, Platform: "legacy-b", Mode: "unknown"}
- cache := newFullRebuildLifecycleCache(first, last)
- wantErr := errors.New("capture failed")
- cache.captureErrors[last.String()] = wantErr
- groups := &fullRebuildLifecycleGroupRepo{fresh: make(map[int64]*Group), freshErr: make(map[int64]error)}
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- err := svc.rebuildFullSnapshot(context.Background(), "test")
- require.ErrorIs(t, err, wantErr)
- require.Equal(t, schedulerCanonicalBucketCount()+2, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning")
- require.Zero(t, accounts.callCount())
- require.Zero(t, cache.totalSetAttempts())
- }
- func TestSchedulerFullRebuildPreservesGroupZeroActiveHistoricalAndInvalidRegistryBuckets(t *testing.T) {
- const groupID int64 = 103
- groupZeroHistorical := SchedulerBucket{GroupID: 0, Platform: "legacy-zero", Mode: "unknown"}
- activeHistorical := SchedulerBucket{GroupID: groupID, Platform: "legacy-active", Mode: "unknown"}
- activeForced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
- activeMixed := SchedulerBucket{GroupID: groupID, Platform: PlatformAnthropic, Mode: SchedulerModeMixed}
- invalidHistorical := SchedulerBucket{GroupID: -7, Platform: "legacy-invalid", Mode: "unknown"}
- cache := newFullRebuildLifecycleCache(groupZeroHistorical, activeHistorical, activeForced, activeMixed, invalidHistorical)
- groups := &fullRebuildFallbackGroupRepo{groups: []Group{{ID: groupID, Status: StatusActive}}}
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
- require.Equal(t, 2*schedulerCanonicalBucketCount()+3, cache.captureAttemptCount())
- require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+3, accounts.callCount())
- groups.mu.Lock()
- require.Equal(t, 1, groups.listCalls)
- groups.mu.Unlock()
- require.Empty(t, cache.retiredBuckets())
- require.Empty(t, cache.tokens())
- for _, bucket := range []SchedulerBucket{groupZeroHistorical, activeHistorical, activeForced, activeMixed, invalidHistorical} {
- _, published := cache.counts(bucket)
- require.Equal(t, 1, published, bucket.String())
- }
- }
- func TestSchedulerFullRebuildActiveTombstoneFreshInactiveOrMissingFiltersAllGroupTasks(t *testing.T) {
- for _, tc := range []struct {
- name string
- group *Group
- }{
- {name: "inactive", group: &Group{ID: 104, Status: StatusDisabled, Hydrated: true}},
- {name: "missing"},
- } {
- t.Run(tc.name, func(t *testing.T) {
- const groupID int64 = 104
- canonical := schedulerBucketsForGroup(groupID)
- historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "unknown"}
- cache := newFullRebuildLifecycleCache(historical)
- require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[5]))
- groups := &fullRebuildLifecycleGroupRepo{
- activeIDs: []int64{groupID},
- fresh: make(map[int64]*Group),
- freshErr: make(map[int64]error),
- }
- if tc.group != nil {
- groups.fresh[groupID] = tc.group
- }
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
- require.Zero(t, accounts.groupCallCount(groupID))
- require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.groupCallCount(0))
- require.Empty(t, cache.tokens())
- require.Equal(t, bucketStrings(append(canonical, historical)), bucketStrings(cache.retiredBuckets()))
- for _, bucket := range append(canonical, historical) {
- attempts, published := cache.counts(bucket)
- require.Zero(t, attempts, bucket.String())
- require.Zero(t, published, bucket.String())
- }
- _, _, listCalls := cache.lifecycleCounts()
- require.Equal(t, 1, listCalls)
- })
- }
- }
- func TestSchedulerFullRebuildStaleCandidatesAreSortedAndNeverReopenedAcrossRounds(t *testing.T) {
- historical := []SchedulerBucket{
- {GroupID: 3, Platform: "legacy", Mode: "unknown"},
- {GroupID: 1, Platform: "legacy", Mode: "unknown"},
- {GroupID: 2, Platform: "legacy", Mode: "unknown"},
- }
- cache := newFullRebuildLifecycleCache(historical...)
- groups := &fullRebuildLifecycleGroupRepo{
- fresh: map[int64]*Group{
- 1: {ID: 1, Status: StatusDisabled, Hydrated: true},
- 3: {ID: 3, Status: StatusDisabled, Hydrated: true},
- },
- freshErr: make(map[int64]error),
- }
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "first"))
- retireCallsAfterFirst := len(cache.retiredBuckets())
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "second"))
- _, _, freshCalls := groups.stats()
- require.Equal(t, []int64{1, 2, 3}, freshCalls)
- require.Equal(t, retireCallsAfterFirst, len(cache.retiredBuckets()))
- require.Empty(t, cache.tokens())
- require.Zero(t, accounts.groupCallCount(1))
- require.Zero(t, accounts.groupCallCount(2))
- require.Zero(t, accounts.groupCallCount(3))
- _, _, listCalls := cache.lifecycleCounts()
- require.Equal(t, 2, listCalls, "each round must use only its one global registry snapshot")
- for _, bucket := range historical {
- require.Contains(t, bucketStrings(cache.retiredBuckets()), bucket.String())
- }
- }
- func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t *testing.T) {
- historical := []SchedulerBucket{
- {GroupID: 1, Platform: "legacy", Mode: "unknown"},
- {GroupID: 2, Platform: "legacy", Mode: "unknown"},
- {GroupID: 3, Platform: "legacy", Mode: "unknown"},
- }
- wantErr := errors.New("fresh group query failed")
- cache := newFullRebuildLifecycleCache(historical...)
- groups := &fullRebuildLifecycleGroupRepo{
- fresh: map[int64]*Group{
- 1: {ID: 1, Status: StatusDisabled, Hydrated: true},
- 3: {ID: 3, Status: StatusDisabled, Hydrated: true},
- },
- freshErr: map[int64]error{2: wantErr},
- }
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- err := svc.triggerFullRebuild("first")
- require.ErrorIs(t, err, wantErr)
- _, _, freshCalls := groups.stats()
- require.Equal(t, []int64{1, 2}, freshCalls)
- require.Zero(t, accounts.callCount())
- require.Zero(t, cache.totalSetAttempts())
- require.Equal(t, schedulerCanonicalBucketCount()+1, len(cache.retiredBuckets()))
- groups.mu.Lock()
- delete(groups.freshErr, 2)
- groups.fresh[2] = &Group{ID: 2, Status: StatusDisabled, Hydrated: true}
- groups.mu.Unlock()
- require.NoError(t, svc.triggerFullRebuild("retry"))
- _, _, freshCalls = groups.stats()
- require.Equal(t, []int64{1, 2, 2, 3}, freshCalls)
- require.Equal(t, 3*(schedulerCanonicalBucketCount()+1), len(cache.retiredBuckets()))
- require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
- require.Empty(t, cache.tokens())
- }
- func TestSchedulerFullRebuildActiveTombstoneLazyRecoveryDiscardsPartialCaptureTasks(t *testing.T) {
- const groupID int64 = 105
- canonical := schedulerBucketsForGroup(groupID)
- historical := SchedulerBucket{GroupID: groupID, Platform: "legacy", Mode: "unknown"}
- cache := newFullRebuildLifecycleCache(canonical[0], canonical[4], historical)
- require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[5]))
- groups := &fullRebuildLifecycleGroupRepo{
- activeIDs: []int64{groupID},
- fresh: map[int64]*Group{
- groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
- },
- freshErr: make(map[int64]error),
- }
- accounts := &fullRebuildAccountRepo{}
- var capturesAtFirstDB int
- accounts.beforeFirst = func() {
- capturesAtFirstDB = cache.captureAttemptCount()
- held, reopenCount := cache.leaseHeldAndTokenCount()
- require.False(t, held)
- require.Equal(t, len(canonical), reopenCount)
- // C(0) + captures through the retired canonical index (inclusive) + historical ordinary bucket.
- require.Equal(t, schedulerCanonicalBucketCount()+6+1, capturesAtFirstDB)
- }
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
- require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount())
- require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount())
- for _, bucket := range canonical {
- attempts, published := cache.counts(bucket)
- require.Equal(t, 1, attempts, "discarded pre-recovery tokens must never publish: %s", bucket.String())
- require.Equal(t, 1, published, bucket.String())
- }
- _, published := cache.counts(historical)
- require.Equal(t, 1, published)
- _, _, listCalls := cache.lifecycleCounts()
- require.Equal(t, 1, listCalls)
- }
- func TestSchedulerFullRebuildSimpleModePreservesRegistryWithoutLifecycleAuthority(t *testing.T) {
- registered := []SchedulerBucket{
- {GroupID: 0, Platform: "legacy-zero", Mode: "unknown"},
- {GroupID: 106, Platform: "legacy-positive", Mode: "unknown"},
- {GroupID: -8, Platform: "legacy-negative", Mode: "unknown"},
- }
- cache := newFullRebuildLifecycleCache(registered...)
- groups := &fullRebuildLifecycleGroupRepo{
- activeIDsErr: errors.New("simple mode must not query groups"),
- fresh: make(map[int64]*Group),
- freshErr: make(map[int64]error),
- }
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeSimple)
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
- activeCalls, fallbackCalls, freshCalls := groups.stats()
- require.Zero(t, activeCalls)
- require.Zero(t, fallbackCalls)
- require.Empty(t, freshCalls)
- require.Equal(t, schedulerCanonicalBucketCount()+len(registered), cache.captureAttemptCount())
- require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.callCount())
- require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.groupCallCount(0))
- require.Empty(t, cache.retiredBuckets())
- require.Empty(t, cache.tokens())
- for _, bucket := range registered {
- _, published := cache.counts(bucket)
- require.Equal(t, 1, published, bucket.String())
- }
- }
- func TestSchedulerFullRebuildFreshReopenLockBusyRetriesWithoutBlockingOrdinaryTasks(t *testing.T) {
- const groupID int64 = 107
- canonical := schedulerBucketsForGroup(groupID)
- cache := newFullRebuildLifecycleCache()
- require.NoError(t, cache.retirementRaceCache.RetireBucket(context.Background(), canonical[0]))
- cache.lockBusyOnce[canonical[0].String()] = true
- groups := &fullRebuildLifecycleGroupRepo{
- activeIDs: []int64{groupID},
- fresh: map[int64]*Group{
- groupID: {ID: groupID, Status: StatusActive, Hydrated: true},
- },
- freshErr: make(map[int64]error),
- }
- accounts := &fullRebuildAccountRepo{}
- outbox := &outboxCleanupRepo{events: []SchedulerOutboxEvent{{ID: 1, EventType: SchedulerOutboxEventFullRebuild}}}
- svc := newFullRebuildLifecycleService(cache, outbox, accounts, groups, config.RunModeStandard)
- svc.pollOutbox()
- require.Zero(t, cache.currentWatermark())
- _, groupZeroPublished := cache.counts(schedulerCanonicalBuckets(0)[0])
- require.Equal(t, 1, groupZeroPublished, "ordinary tasks must still run when one strict Reopen task is busy")
- require.Equal(t, 2*schedulerCanonicalAccountQueryCount(), accounts.callCount())
- svc.pollOutbox()
- require.Equal(t, int64(1), cache.currentWatermark())
- require.Equal(t, 4*schedulerCanonicalAccountQueryCount(), accounts.callCount())
- _, busyBucketPublished := cache.counts(canonical[0])
- require.Equal(t, 1, busyBucketPublished)
- activeCalls, fallbackCalls, freshCalls := groups.stats()
- require.Equal(t, 2, activeCalls)
- require.Zero(t, fallbackCalls)
- require.Equal(t, []int64{groupID}, freshCalls)
- }
- func TestSchedulerFullRebuildOrdinaryLockBusyKeepsExistingSkipSemantics(t *testing.T) {
- busyBucket := schedulerCanonicalBuckets(0)[0]
- cache := newFullRebuildLifecycleCache()
- cache.lockBusyOnce[busyBucket.String()] = true
- groups := &fullRebuildLifecycleGroupRepo{fresh: make(map[int64]*Group), freshErr: make(map[int64]error)}
- accounts := &fullRebuildAccountRepo{}
- svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard)
- require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test"))
- require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount())
- attempts, published := cache.counts(busyBucket)
- require.Zero(t, attempts)
- require.Zero(t, published)
- }
- func requireFullRebuildNoMutationOrDB(t *testing.T, cache *fullRebuildLifecycleCache, accounts *fullRebuildAccountRepo) {
- t.Helper()
- require.Zero(t, cache.captureAttemptCount())
- require.Empty(t, cache.retiredBuckets())
- require.Empty(t, cache.tokens())
- require.Zero(t, accounts.callCount())
- require.Zero(t, cache.totalSetAttempts())
- }
|