dashboard_aggregation_service_test.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/stretchr/testify/require"
  9. )
  10. type dashboardAggregationRepoTestStub struct {
  11. aggregateCalls int
  12. recomputeCalls int
  13. cleanupUsageCalls int
  14. cleanupDedupCalls int
  15. ensurePartitionCalls int
  16. lastStart time.Time
  17. lastEnd time.Time
  18. watermark time.Time
  19. aggregateErr error
  20. cleanupAggregatesErr error
  21. cleanupUsageErr error
  22. cleanupDedupErr error
  23. ensurePartitionErr error
  24. aggregateCtx context.Context
  25. events *[]string
  26. }
  27. type dashboardAggregationRollupRepoTestStub struct {
  28. *dashboardAggregationRepoTestStub
  29. groupRollupCalls int
  30. groupRollupAt time.Time
  31. groupRollupErr error
  32. groupRollupCtx context.Context
  33. }
  34. func (s *dashboardAggregationRollupRepoTestStub) SyncGroupUsageRollups(ctx context.Context, todayStart time.Time) error {
  35. s.groupRollupCalls++
  36. s.groupRollupAt = todayStart
  37. s.groupRollupCtx = ctx
  38. if s.events != nil {
  39. *s.events = append(*s.events, "group_rollup")
  40. }
  41. return s.groupRollupErr
  42. }
  43. func (s *dashboardAggregationRepoTestStub) AggregateRange(ctx context.Context, start, end time.Time) error {
  44. s.aggregateCalls++
  45. s.aggregateCtx = ctx
  46. s.lastStart = start
  47. s.lastEnd = end
  48. if s.events != nil {
  49. *s.events = append(*s.events, "dashboard_aggregation")
  50. }
  51. return s.aggregateErr
  52. }
  53. func (s *dashboardAggregationRepoTestStub) RecomputeRange(ctx context.Context, start, end time.Time) error {
  54. s.recomputeCalls++
  55. return s.AggregateRange(ctx, start, end)
  56. }
  57. func (s *dashboardAggregationRepoTestStub) GetAggregationWatermark(ctx context.Context) (time.Time, error) {
  58. return s.watermark, nil
  59. }
  60. func (s *dashboardAggregationRepoTestStub) UpdateAggregationWatermark(ctx context.Context, aggregatedAt time.Time) error {
  61. return nil
  62. }
  63. func (s *dashboardAggregationRepoTestStub) CleanupAggregates(ctx context.Context, hourlyCutoff, dailyCutoff time.Time) error {
  64. return s.cleanupAggregatesErr
  65. }
  66. func (s *dashboardAggregationRepoTestStub) CleanupUsageLogs(ctx context.Context, cutoff time.Time) error {
  67. s.cleanupUsageCalls++
  68. return s.cleanupUsageErr
  69. }
  70. func (s *dashboardAggregationRepoTestStub) CleanupUsageBillingDedup(ctx context.Context, cutoff time.Time) error {
  71. s.cleanupDedupCalls++
  72. return s.cleanupDedupErr
  73. }
  74. func (s *dashboardAggregationRepoTestStub) EnsureUsageLogsPartitions(ctx context.Context, now time.Time) error {
  75. s.ensurePartitionCalls++
  76. return s.ensurePartitionErr
  77. }
  78. func TestDashboardAggregationService_RunScheduledAggregation_EpochUsesRetentionStart(t *testing.T) {
  79. repo := &dashboardAggregationRepoTestStub{watermark: time.Unix(0, 0).UTC()}
  80. svc := &DashboardAggregationService{
  81. repo: repo,
  82. cfg: config.DashboardAggregationConfig{
  83. Enabled: true,
  84. IntervalSeconds: 60,
  85. LookbackSeconds: 120,
  86. Retention: config.DashboardAggregationRetentionConfig{
  87. UsageLogsDays: 1,
  88. HourlyDays: 1,
  89. DailyDays: 1,
  90. },
  91. },
  92. }
  93. svc.runScheduledAggregation()
  94. require.Equal(t, 1, repo.aggregateCalls)
  95. require.False(t, repo.lastEnd.IsZero())
  96. require.Equal(t, truncateToDayUTC(repo.lastEnd.AddDate(0, 0, -1)), repo.lastStart)
  97. }
  98. func TestDashboardAggregationService_RunScheduledAggregationSyncsGroupUsageRollups(t *testing.T) {
  99. baseRepo := &dashboardAggregationRepoTestStub{watermark: time.Now().UTC()}
  100. repo := &dashboardAggregationRollupRepoTestStub{dashboardAggregationRepoTestStub: baseRepo}
  101. svc := &DashboardAggregationService{
  102. repo: repo,
  103. cfg: config.DashboardAggregationConfig{
  104. Enabled: true,
  105. IntervalSeconds: 60,
  106. LookbackSeconds: 120,
  107. Retention: config.DashboardAggregationRetentionConfig{
  108. UsageLogsDays: 1,
  109. UsageBillingDedupDays: 2,
  110. HourlyDays: 1,
  111. DailyDays: 1,
  112. },
  113. },
  114. }
  115. before := GroupUsageTodayStart(time.Now())
  116. svc.runScheduledAggregation()
  117. after := GroupUsageTodayStart(time.Now())
  118. require.Equal(t, 1, repo.groupRollupCalls)
  119. require.Contains(t, []time.Time{before, after}, repo.groupRollupAt)
  120. }
  121. func TestDashboardAggregationService_RunScheduledAggregationSyncsGroupAfterDashboardEarlyReturn(t *testing.T) {
  122. events := make([]string, 0, 2)
  123. baseRepo := &dashboardAggregationRepoTestStub{
  124. watermark: time.Now().UTC(),
  125. aggregateErr: errors.New("dashboard aggregation failed"),
  126. events: &events,
  127. }
  128. repo := &dashboardAggregationRollupRepoTestStub{
  129. dashboardAggregationRepoTestStub: baseRepo,
  130. groupRollupErr: errors.New("group rollup failed"),
  131. }
  132. svc := &DashboardAggregationService{
  133. repo: repo,
  134. cfg: config.DashboardAggregationConfig{
  135. LookbackSeconds: 120,
  136. Retention: config.DashboardAggregationRetentionConfig{
  137. UsageLogsDays: 1,
  138. },
  139. },
  140. }
  141. svc.runScheduledAggregation()
  142. require.Equal(t, []string{"dashboard_aggregation", "group_rollup"}, events)
  143. require.NotNil(t, repo.aggregateCtx)
  144. require.NotNil(t, repo.groupRollupCtx)
  145. if repo.aggregateCtx == repo.groupRollupCtx {
  146. t.Fatal("分组日汇总必须使用独立于 dashboard 聚合的 context")
  147. }
  148. groupDeadline, ok := repo.groupRollupCtx.Deadline()
  149. require.True(t, ok, "group rollup context must be bounded")
  150. require.LessOrEqual(t, time.Until(groupDeadline), defaultDashboardAggregationTimeout)
  151. }
  152. type dashboardAggregationLeaderLockRecordingCache struct {
  153. delegate *fakeLeaderLockCache
  154. acquireKeys []string
  155. acquireTTLs []time.Duration
  156. }
  157. func (c *dashboardAggregationLeaderLockRecordingCache) TryAcquireLeaderLock(ctx context.Context, key, owner string, ttl time.Duration) (bool, error) {
  158. c.acquireKeys = append(c.acquireKeys, key)
  159. c.acquireTTLs = append(c.acquireTTLs, ttl)
  160. return c.delegate.TryAcquireLeaderLock(ctx, key, owner, ttl)
  161. }
  162. func (c *dashboardAggregationLeaderLockRecordingCache) ReleaseLeaderLock(ctx context.Context, key, owner string) error {
  163. return c.delegate.ReleaseLeaderLock(ctx, key, owner)
  164. }
  165. func TestDashboardAggregationService_StartupGroupSyncUsesIndependentLongLivedLeaderLock(t *testing.T) {
  166. delegate := &fakeLeaderLockCache{}
  167. _, err := delegate.TryAcquireLeaderLock(context.Background(), dashboardAggregationLeaderLockKey, "periodic-peer", time.Hour)
  168. require.NoError(t, err)
  169. cache := &dashboardAggregationLeaderLockRecordingCache{delegate: delegate}
  170. repo := &dashboardAggregationRollupRepoTestStub{dashboardAggregationRepoTestStub: &dashboardAggregationRepoTestStub{}}
  171. svc := &DashboardAggregationService{
  172. repo: repo,
  173. lockCache: cache,
  174. instanceID: "startup-instance",
  175. }
  176. svc.runStartupGroupUsageSync()
  177. require.Len(t, cache.acquireKeys, 1)
  178. require.NotEqual(t, dashboardAggregationLeaderLockKey, cache.acquireKeys[0])
  179. require.Len(t, cache.acquireTTLs, 1)
  180. require.Greater(t, cache.acquireTTLs[0], defaultDashboardAggregationBackfillTimeout)
  181. require.Equal(t, 1, repo.groupRollupCalls)
  182. }
  183. func TestDashboardAggregationService_CleanupRetentionFailure_DoesNotRecord(t *testing.T) {
  184. repo := &dashboardAggregationRepoTestStub{cleanupAggregatesErr: errors.New("清理失败")}
  185. svc := &DashboardAggregationService{
  186. repo: repo,
  187. cfg: config.DashboardAggregationConfig{
  188. Retention: config.DashboardAggregationRetentionConfig{
  189. UsageLogsDays: 1,
  190. HourlyDays: 1,
  191. DailyDays: 1,
  192. },
  193. },
  194. }
  195. svc.maybeCleanupRetention(context.Background(), time.Now().UTC())
  196. require.Nil(t, svc.lastRetentionCleanup.Load())
  197. require.Equal(t, 1, repo.cleanupUsageCalls)
  198. require.Equal(t, 1, repo.cleanupDedupCalls)
  199. }
  200. func TestDashboardAggregationService_CleanupDedupFailure_DoesNotRecord(t *testing.T) {
  201. repo := &dashboardAggregationRepoTestStub{cleanupDedupErr: errors.New("dedup cleanup failed")}
  202. svc := &DashboardAggregationService{
  203. repo: repo,
  204. cfg: config.DashboardAggregationConfig{
  205. Retention: config.DashboardAggregationRetentionConfig{
  206. UsageLogsDays: 1,
  207. HourlyDays: 1,
  208. DailyDays: 1,
  209. },
  210. },
  211. }
  212. svc.maybeCleanupRetention(context.Background(), time.Now().UTC())
  213. require.Nil(t, svc.lastRetentionCleanup.Load())
  214. require.Equal(t, 1, repo.cleanupDedupCalls)
  215. }
  216. func TestDashboardAggregationService_PartitionFailure_DoesNotAggregate(t *testing.T) {
  217. repo := &dashboardAggregationRepoTestStub{ensurePartitionErr: errors.New("partition failed")}
  218. svc := &DashboardAggregationService{
  219. repo: repo,
  220. cfg: config.DashboardAggregationConfig{
  221. Enabled: true,
  222. IntervalSeconds: 60,
  223. LookbackSeconds: 120,
  224. Retention: config.DashboardAggregationRetentionConfig{
  225. UsageLogsDays: 1,
  226. UsageBillingDedupDays: 2,
  227. HourlyDays: 1,
  228. DailyDays: 1,
  229. },
  230. },
  231. }
  232. svc.runScheduledAggregation()
  233. require.Equal(t, 1, repo.ensurePartitionCalls)
  234. require.Equal(t, 1, repo.aggregateCalls)
  235. }
  236. func TestDashboardAggregationService_TriggerBackfill_TooLarge(t *testing.T) {
  237. repo := &dashboardAggregationRepoTestStub{}
  238. svc := &DashboardAggregationService{
  239. repo: repo,
  240. cfg: config.DashboardAggregationConfig{
  241. BackfillEnabled: true,
  242. BackfillMaxDays: 1,
  243. },
  244. }
  245. start := time.Now().AddDate(0, 0, -3)
  246. end := time.Now()
  247. err := svc.TriggerBackfill(start, end)
  248. require.ErrorIs(t, err, ErrDashboardBackfillTooLarge)
  249. require.Equal(t, 0, repo.aggregateCalls)
  250. }