dashboard_aggregation_service.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "log/slog"
  7. "sync/atomic"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/google/uuid"
  12. )
  13. const (
  14. defaultDashboardAggregationTimeout = 2 * time.Minute
  15. defaultDashboardAggregationBackfillTimeout = 30 * time.Minute
  16. dashboardAggregationRetentionInterval = 6 * time.Hour
  17. // dashboardAggregationLeaderLockKey 保证多副本部署中每个周期只有一个实例执行聚合。
  18. dashboardAggregationLeaderLockKey = "dashboard:aggregation:leader"
  19. // TTL 必须覆盖 dashboard 聚合与分组日汇总两个有界阶段,避免任务中途失锁。
  20. dashboardAggregationLeaderLockTTL = 5 * time.Minute
  21. // 启动回填耗时可能远长于周期聚合,因此使用独立锁并让 TTL 严格大于回填超时。
  22. dashboardAggregationGroupUsageBackfillLeaderLockKey = "dashboard:aggregation:group-usage-backfill:leader"
  23. dashboardAggregationGroupUsageBackfillLeaderLockTTL = defaultDashboardAggregationBackfillTimeout + time.Minute
  24. )
  25. var (
  26. // ErrDashboardBackfillDisabled 当配置禁用回填时返回。
  27. ErrDashboardBackfillDisabled = errors.New("仪表盘聚合回填已禁用")
  28. // ErrDashboardBackfillTooLarge 当回填跨度超过限制时返回。
  29. ErrDashboardBackfillTooLarge = errors.New("回填时间跨度过大")
  30. errDashboardAggregationRunning = errors.New("聚合作业正在运行")
  31. )
  32. // DashboardAggregationRepository 定义仪表盘预聚合仓储接口。
  33. type DashboardAggregationRepository interface {
  34. AggregateRange(ctx context.Context, start, end time.Time) error
  35. // RecomputeRange 重新计算指定时间范围内的聚合数据(包含活跃用户等派生表)。
  36. // 设计目的:当 usage_logs 被批量删除/回滚后,确保聚合表可恢复一致性。
  37. RecomputeRange(ctx context.Context, start, end time.Time) error
  38. GetAggregationWatermark(ctx context.Context) (time.Time, error)
  39. UpdateAggregationWatermark(ctx context.Context, aggregatedAt time.Time) error
  40. CleanupAggregates(ctx context.Context, hourlyCutoff, dailyCutoff time.Time) error
  41. CleanupUsageLogs(ctx context.Context, cutoff time.Time) error
  42. CleanupUsageBillingDedup(ctx context.Context, cutoff time.Time) error
  43. EnsureUsageLogsPartitions(ctx context.Context, now time.Time) error
  44. }
  45. // DashboardAggregationService 负责定时聚合与回填。
  46. type DashboardAggregationService struct {
  47. repo DashboardAggregationRepository
  48. timingWheel *TimingWheelService
  49. cfg config.DashboardAggregationConfig
  50. running int32
  51. lastRetentionCleanup atomic.Value // time.Time
  52. lockCache LeaderLockCache
  53. db *sql.DB
  54. instanceID string
  55. }
  56. // NewDashboardAggregationService 创建聚合服务。
  57. func NewDashboardAggregationService(repo DashboardAggregationRepository, timingWheel *TimingWheelService, cfg *config.Config) *DashboardAggregationService {
  58. var aggCfg config.DashboardAggregationConfig
  59. if cfg != nil {
  60. aggCfg = cfg.DashboardAgg
  61. }
  62. return &DashboardAggregationService{
  63. repo: repo,
  64. timingWheel: timingWheel,
  65. cfg: aggCfg,
  66. instanceID: uuid.NewString(),
  67. }
  68. }
  69. // SetLeaderLock injects the leader-lock cache and DB used to elect a single
  70. // instance for the periodic scheduled aggregation. When both are nil the job runs
  71. // ungated (single-instance / test behavior).
  72. func (s *DashboardAggregationService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) {
  73. if s == nil {
  74. return
  75. }
  76. s.lockCache = lockCache
  77. s.db = db
  78. }
  79. // Start 启动定时聚合作业(重启生效配置)。
  80. func (s *DashboardAggregationService) Start() {
  81. if s == nil || s.repo == nil || s.timingWheel == nil {
  82. return
  83. }
  84. if !s.cfg.Enabled {
  85. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合作业已禁用")
  86. return
  87. }
  88. go s.runStartupGroupUsageSync()
  89. interval := time.Duration(s.cfg.IntervalSeconds) * time.Second
  90. if interval <= 0 {
  91. interval = time.Minute
  92. }
  93. if s.cfg.RecomputeDays > 0 {
  94. go s.recomputeRecentDays()
  95. }
  96. s.timingWheel.ScheduleRecurring("dashboard:aggregation", interval, func() {
  97. s.runScheduledAggregation()
  98. })
  99. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合作业启动 (interval=%v, lookback=%ds)", interval, s.cfg.LookbackSeconds)
  100. if !s.cfg.BackfillEnabled {
  101. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填已禁用,如需补齐保留窗口以外历史数据请手动回填")
  102. }
  103. }
  104. // TriggerBackfill 触发回填(异步)。
  105. func (s *DashboardAggregationService) TriggerBackfill(start, end time.Time) error {
  106. if s == nil || s.repo == nil {
  107. return errors.New("聚合服务未初始化")
  108. }
  109. if !s.cfg.BackfillEnabled {
  110. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填被拒绝: backfill_enabled=false")
  111. return ErrDashboardBackfillDisabled
  112. }
  113. if !end.After(start) {
  114. return errors.New("回填时间范围无效")
  115. }
  116. if s.cfg.BackfillMaxDays > 0 {
  117. maxRange := time.Duration(s.cfg.BackfillMaxDays) * 24 * time.Hour
  118. if end.Sub(start) > maxRange {
  119. return ErrDashboardBackfillTooLarge
  120. }
  121. }
  122. go func() {
  123. ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationBackfillTimeout)
  124. defer cancel()
  125. if err := s.backfillRange(ctx, start, end); err != nil {
  126. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填失败: %v", err)
  127. }
  128. }()
  129. return nil
  130. }
  131. // TriggerRecomputeRange 触发指定范围的重新计算(异步)。
  132. // 与 TriggerBackfill 不同:
  133. // - 不依赖 backfill_enabled(这是内部一致性修复)
  134. // - 不更新 watermark(避免影响正常增量聚合游标)
  135. func (s *DashboardAggregationService) TriggerRecomputeRange(start, end time.Time) error {
  136. if s == nil || s.repo == nil {
  137. return errors.New("聚合服务未初始化")
  138. }
  139. if !s.cfg.Enabled {
  140. return errors.New("聚合服务已禁用")
  141. }
  142. if !end.After(start) {
  143. return errors.New("重新计算时间范围无效")
  144. }
  145. go func() {
  146. const maxRetries = 3
  147. for i := 0; i < maxRetries; i++ {
  148. ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationBackfillTimeout)
  149. err := s.recomputeRange(ctx, start, end)
  150. cancel()
  151. if err == nil {
  152. return
  153. }
  154. if !errors.Is(err, errDashboardAggregationRunning) {
  155. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 重新计算失败: %v", err)
  156. return
  157. }
  158. time.Sleep(5 * time.Second)
  159. }
  160. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 重新计算放弃: 聚合作业持续占用")
  161. }()
  162. return nil
  163. }
  164. func (s *DashboardAggregationService) recomputeRecentDays() {
  165. days := s.cfg.RecomputeDays
  166. if days <= 0 {
  167. return
  168. }
  169. now := time.Now().UTC()
  170. start := now.AddDate(0, 0, -days)
  171. ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationBackfillTimeout)
  172. defer cancel()
  173. if err := s.backfillRange(ctx, start, now); err != nil {
  174. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 启动重算失败: %v", err)
  175. return
  176. }
  177. }
  178. func (s *DashboardAggregationService) recomputeRange(ctx context.Context, start, end time.Time) error {
  179. if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
  180. return errDashboardAggregationRunning
  181. }
  182. defer atomic.StoreInt32(&s.running, 0)
  183. jobStart := time.Now().UTC()
  184. if err := s.repo.RecomputeRange(ctx, start, end); err != nil {
  185. return err
  186. }
  187. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 重新计算完成 (start=%s end=%s duration=%s)",
  188. start.UTC().Format(time.RFC3339),
  189. end.UTC().Format(time.RFC3339),
  190. time.Since(jobStart).String(),
  191. )
  192. return nil
  193. }
  194. func (s *DashboardAggregationService) runScheduledAggregation() {
  195. if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
  196. return
  197. }
  198. defer atomic.StoreInt32(&s.running, 0)
  199. jobStart := time.Now().UTC()
  200. ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationTimeout)
  201. defer cancel()
  202. // Multi-instance guard: only the leader runs the periodic aggregation; peers
  203. // skip this cycle to avoid N× redundant GROUP BY queries and watermark races.
  204. release, ok := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, dashboardAggregationLeaderLockKey, s.instanceID, dashboardAggregationLeaderLockTTL)
  205. if !ok {
  206. return
  207. }
  208. defer release()
  209. defer s.runScheduledGroupUsageSync()
  210. now := time.Now().UTC()
  211. last, err := s.repo.GetAggregationWatermark(ctx)
  212. if err != nil {
  213. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 读取水位失败: %v", err)
  214. last = time.Unix(0, 0).UTC()
  215. }
  216. lookback := time.Duration(s.cfg.LookbackSeconds) * time.Second
  217. epoch := time.Unix(0, 0).UTC()
  218. start := last.Add(-lookback)
  219. if !last.After(epoch) {
  220. retentionDays := s.cfg.Retention.UsageLogsDays
  221. if retentionDays <= 0 {
  222. retentionDays = 1
  223. }
  224. start = truncateToDayUTC(now.AddDate(0, 0, -retentionDays))
  225. } else if start.After(now) {
  226. start = now.Add(-lookback)
  227. }
  228. if err := s.aggregateRange(ctx, start, now); err != nil {
  229. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合失败: %v", err)
  230. return
  231. }
  232. updateErr := s.repo.UpdateAggregationWatermark(ctx, now)
  233. if updateErr != nil {
  234. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 更新水位失败: %v", updateErr)
  235. }
  236. slog.Debug("[DashboardAggregation] 聚合完成",
  237. "start", start.Format(time.RFC3339),
  238. "end", now.Format(time.RFC3339),
  239. "duration", time.Since(jobStart).String(),
  240. "watermark_updated", updateErr == nil,
  241. )
  242. s.maybeCleanupRetention(ctx, now)
  243. }
  244. func (s *DashboardAggregationService) runScheduledGroupUsageSync() {
  245. ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationTimeout)
  246. defer cancel()
  247. if err := s.syncGroupUsageRollups(ctx, time.Now().UTC()); err != nil {
  248. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 分组用量日汇总失败: %v", err)
  249. }
  250. }
  251. func (s *DashboardAggregationService) runStartupGroupUsageSync() {
  252. ctx, cancel := context.WithTimeout(context.Background(), defaultDashboardAggregationBackfillTimeout)
  253. defer cancel()
  254. release, ok := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, dashboardAggregationGroupUsageBackfillLeaderLockKey, s.instanceID, dashboardAggregationGroupUsageBackfillLeaderLockTTL)
  255. if !ok {
  256. return
  257. }
  258. defer release()
  259. if err := s.syncGroupUsageRollups(ctx, time.Now().UTC()); err != nil {
  260. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 启动分组用量回填失败: %v", err)
  261. }
  262. }
  263. func (s *DashboardAggregationService) syncGroupUsageRollups(ctx context.Context, now time.Time) error {
  264. repo, ok := s.repo.(GroupUsageRollupRepository)
  265. if !ok {
  266. return nil
  267. }
  268. return repo.SyncGroupUsageRollups(ctx, GroupUsageTodayStart(now))
  269. }
  270. func (s *DashboardAggregationService) backfillRange(ctx context.Context, start, end time.Time) error {
  271. if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
  272. return errDashboardAggregationRunning
  273. }
  274. defer atomic.StoreInt32(&s.running, 0)
  275. jobStart := time.Now().UTC()
  276. startUTC := start.UTC()
  277. endUTC := end.UTC()
  278. if !endUTC.After(startUTC) {
  279. return errors.New("回填时间范围无效")
  280. }
  281. cursor := truncateToDayUTC(startUTC)
  282. for cursor.Before(endUTC) {
  283. windowEnd := cursor.Add(24 * time.Hour)
  284. if windowEnd.After(endUTC) {
  285. windowEnd = endUTC
  286. }
  287. if err := s.aggregateRange(ctx, cursor, windowEnd); err != nil {
  288. return err
  289. }
  290. cursor = windowEnd
  291. }
  292. updateErr := s.repo.UpdateAggregationWatermark(ctx, endUTC)
  293. if updateErr != nil {
  294. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 更新水位失败: %v", updateErr)
  295. }
  296. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 回填聚合完成 (start=%s end=%s duration=%s watermark_updated=%t)",
  297. startUTC.Format(time.RFC3339),
  298. endUTC.Format(time.RFC3339),
  299. time.Since(jobStart).String(),
  300. updateErr == nil,
  301. )
  302. s.maybeCleanupRetention(ctx, endUTC)
  303. return nil
  304. }
  305. func (s *DashboardAggregationService) aggregateRange(ctx context.Context, start, end time.Time) error {
  306. if !end.After(start) {
  307. return nil
  308. }
  309. if err := s.repo.EnsureUsageLogsPartitions(ctx, end); err != nil {
  310. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 分区检查失败: %v", err)
  311. }
  312. return s.repo.AggregateRange(ctx, start, end)
  313. }
  314. func (s *DashboardAggregationService) maybeCleanupRetention(ctx context.Context, now time.Time) {
  315. lastAny := s.lastRetentionCleanup.Load()
  316. if lastAny != nil {
  317. if last, ok := lastAny.(time.Time); ok && now.Sub(last) < dashboardAggregationRetentionInterval {
  318. return
  319. }
  320. }
  321. hourlyCutoff := now.AddDate(0, 0, -s.cfg.Retention.HourlyDays)
  322. dailyCutoff := now.AddDate(0, 0, -s.cfg.Retention.DailyDays)
  323. usageCutoff := now.AddDate(0, 0, -s.cfg.Retention.UsageLogsDays)
  324. dedupCutoff := now.AddDate(0, 0, -s.cfg.Retention.UsageBillingDedupDays)
  325. aggErr := s.repo.CleanupAggregates(ctx, hourlyCutoff, dailyCutoff)
  326. if aggErr != nil {
  327. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] 聚合保留清理失败: %v", aggErr)
  328. }
  329. usageErr := s.repo.CleanupUsageLogs(ctx, usageCutoff)
  330. if usageErr != nil {
  331. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] usage_logs 保留清理失败: %v", usageErr)
  332. }
  333. dedupErr := s.repo.CleanupUsageBillingDedup(ctx, dedupCutoff)
  334. if dedupErr != nil {
  335. logger.LegacyPrintf("service.dashboard_aggregation", "[DashboardAggregation] usage_billing_dedup 保留清理失败: %v", dedupErr)
  336. }
  337. if aggErr == nil && usageErr == nil && dedupErr == nil {
  338. s.lastRetentionCleanup.Store(now)
  339. }
  340. }
  341. func truncateToDayUTC(t time.Time) time.Time {
  342. t = t.UTC()
  343. return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
  344. }