ops_aggregation_service.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "strings"
  8. "sync"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. "github.com/google/uuid"
  13. "github.com/redis/go-redis/v9"
  14. )
  15. const (
  16. opsAggHourlyJobName = "ops_preaggregation_hourly"
  17. opsAggDailyJobName = "ops_preaggregation_daily"
  18. opsAggHourlyInterval = 10 * time.Minute
  19. opsAggDailyInterval = 1 * time.Hour
  20. // Keep in sync with ops retention target (vNext default 30d).
  21. opsAggBackfillWindow = 1 * time.Hour
  22. // Recompute overlap to absorb late-arriving rows near boundaries.
  23. opsAggHourlyOverlap = 2 * time.Hour
  24. opsAggDailyOverlap = 48 * time.Hour
  25. opsAggHourlyChunk = 24 * time.Hour
  26. opsAggDailyChunk = 7 * 24 * time.Hour
  27. // Delay around boundaries (e.g. 10:00..10:05) to avoid aggregating buckets
  28. // that may still receive late inserts.
  29. opsAggSafeDelay = 5 * time.Minute
  30. opsAggMaxQueryTimeout = 5 * time.Second
  31. opsAggHourlyTimeout = 5 * time.Minute
  32. opsAggDailyTimeout = 2 * time.Minute
  33. opsAggHourlyLeaderLockKey = "ops:aggregation:hourly:leader"
  34. opsAggDailyLeaderLockKey = "ops:aggregation:daily:leader"
  35. opsAggHourlyLeaderLockTTL = 15 * time.Minute
  36. opsAggDailyLeaderLockTTL = 10 * time.Minute
  37. )
  38. // OpsAggregationService periodically backfills ops_metrics_hourly / ops_metrics_daily
  39. // for stable long-window dashboard queries.
  40. //
  41. // It is safe to run in multi-replica deployments when Redis is available (leader lock).
  42. type OpsAggregationService struct {
  43. opsRepo OpsRepository
  44. settingRepo SettingRepository
  45. cfg *config.Config
  46. db *sql.DB
  47. redisClient *redis.Client
  48. instanceID string
  49. stopCh chan struct{}
  50. startOnce sync.Once
  51. stopOnce sync.Once
  52. hourlyMu sync.Mutex
  53. dailyMu sync.Mutex
  54. skipLogMu sync.Mutex
  55. skipLogAt time.Time
  56. }
  57. func NewOpsAggregationService(
  58. opsRepo OpsRepository,
  59. settingRepo SettingRepository,
  60. db *sql.DB,
  61. redisClient *redis.Client,
  62. cfg *config.Config,
  63. ) *OpsAggregationService {
  64. return &OpsAggregationService{
  65. opsRepo: opsRepo,
  66. settingRepo: settingRepo,
  67. cfg: cfg,
  68. db: db,
  69. redisClient: redisClient,
  70. instanceID: uuid.NewString(),
  71. }
  72. }
  73. func (s *OpsAggregationService) Start() {
  74. if s == nil {
  75. return
  76. }
  77. s.startOnce.Do(func() {
  78. if s.stopCh == nil {
  79. s.stopCh = make(chan struct{})
  80. }
  81. go s.hourlyLoop()
  82. go s.dailyLoop()
  83. })
  84. }
  85. func (s *OpsAggregationService) Stop() {
  86. if s == nil {
  87. return
  88. }
  89. s.stopOnce.Do(func() {
  90. if s.stopCh != nil {
  91. close(s.stopCh)
  92. }
  93. })
  94. }
  95. func (s *OpsAggregationService) hourlyLoop() {
  96. // First run immediately.
  97. s.aggregateHourly()
  98. ticker := time.NewTicker(opsAggHourlyInterval)
  99. defer ticker.Stop()
  100. for {
  101. select {
  102. case <-ticker.C:
  103. s.aggregateHourly()
  104. case <-s.stopCh:
  105. return
  106. }
  107. }
  108. }
  109. func (s *OpsAggregationService) dailyLoop() {
  110. // First run immediately.
  111. s.aggregateDaily()
  112. ticker := time.NewTicker(opsAggDailyInterval)
  113. defer ticker.Stop()
  114. for {
  115. select {
  116. case <-ticker.C:
  117. s.aggregateDaily()
  118. case <-s.stopCh:
  119. return
  120. }
  121. }
  122. }
  123. func (s *OpsAggregationService) aggregateHourly() {
  124. if s == nil || s.opsRepo == nil {
  125. return
  126. }
  127. if s.cfg != nil {
  128. if !s.cfg.Ops.Enabled {
  129. return
  130. }
  131. if !s.cfg.Ops.Aggregation.Enabled {
  132. return
  133. }
  134. }
  135. ctx, cancel := context.WithTimeout(context.Background(), opsAggHourlyTimeout)
  136. defer cancel()
  137. if !s.isMonitoringEnabled(ctx) {
  138. return
  139. }
  140. release, ok := s.tryAcquireLeaderLock(ctx, opsAggHourlyLeaderLockKey, opsAggHourlyLeaderLockTTL, "[OpsAggregation][hourly]")
  141. if !ok {
  142. return
  143. }
  144. if release != nil {
  145. defer release()
  146. }
  147. s.hourlyMu.Lock()
  148. defer s.hourlyMu.Unlock()
  149. startedAt := time.Now().UTC()
  150. runAt := startedAt
  151. // Aggregate stable full hours only.
  152. end := utcFloorToHour(time.Now().UTC().Add(-opsAggSafeDelay))
  153. start := end.Add(-opsAggBackfillWindow)
  154. // Resume from the latest bucket with overlap.
  155. {
  156. ctxMax, cancelMax := context.WithTimeout(context.Background(), opsAggMaxQueryTimeout)
  157. latest, ok, err := s.opsRepo.GetLatestHourlyBucketStart(ctxMax)
  158. cancelMax()
  159. if err != nil {
  160. logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][hourly] failed to read latest bucket: %v", err)
  161. } else if ok {
  162. candidate := latest.Add(-opsAggHourlyOverlap)
  163. if candidate.After(start) {
  164. start = candidate
  165. }
  166. }
  167. }
  168. start = utcFloorToHour(start)
  169. if !start.Before(end) {
  170. return
  171. }
  172. var aggErr error
  173. for cursor := start; cursor.Before(end); cursor = cursor.Add(opsAggHourlyChunk) {
  174. chunkEnd := minTime(cursor.Add(opsAggHourlyChunk), end)
  175. if err := s.opsRepo.UpsertHourlyMetrics(ctx, cursor, chunkEnd); err != nil {
  176. aggErr = err
  177. logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][hourly] upsert failed (%s..%s): %v", cursor.Format(time.RFC3339), chunkEnd.Format(time.RFC3339), err)
  178. break
  179. }
  180. }
  181. finishedAt := time.Now().UTC()
  182. durationMs := finishedAt.Sub(startedAt).Milliseconds()
  183. dur := durationMs
  184. if aggErr != nil {
  185. msg := truncateString(aggErr.Error(), 2048)
  186. errAt := finishedAt
  187. hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
  188. defer hbCancel()
  189. _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
  190. JobName: opsAggHourlyJobName,
  191. LastRunAt: &runAt,
  192. LastErrorAt: &errAt,
  193. LastError: &msg,
  194. LastDurationMs: &dur,
  195. })
  196. return
  197. }
  198. successAt := finishedAt
  199. hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
  200. defer hbCancel()
  201. result := truncateString(fmt.Sprintf("window=%s..%s", start.Format(time.RFC3339), end.Format(time.RFC3339)), 2048)
  202. _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
  203. JobName: opsAggHourlyJobName,
  204. LastRunAt: &runAt,
  205. LastSuccessAt: &successAt,
  206. LastDurationMs: &dur,
  207. LastResult: &result,
  208. })
  209. }
  210. func (s *OpsAggregationService) aggregateDaily() {
  211. if s == nil || s.opsRepo == nil {
  212. return
  213. }
  214. if s.cfg != nil {
  215. if !s.cfg.Ops.Enabled {
  216. return
  217. }
  218. if !s.cfg.Ops.Aggregation.Enabled {
  219. return
  220. }
  221. }
  222. ctx, cancel := context.WithTimeout(context.Background(), opsAggDailyTimeout)
  223. defer cancel()
  224. if !s.isMonitoringEnabled(ctx) {
  225. return
  226. }
  227. release, ok := s.tryAcquireLeaderLock(ctx, opsAggDailyLeaderLockKey, opsAggDailyLeaderLockTTL, "[OpsAggregation][daily]")
  228. if !ok {
  229. return
  230. }
  231. if release != nil {
  232. defer release()
  233. }
  234. s.dailyMu.Lock()
  235. defer s.dailyMu.Unlock()
  236. startedAt := time.Now().UTC()
  237. runAt := startedAt
  238. end := utcFloorToDay(time.Now().UTC())
  239. start := end.Add(-opsAggBackfillWindow)
  240. {
  241. ctxMax, cancelMax := context.WithTimeout(context.Background(), opsAggMaxQueryTimeout)
  242. latest, ok, err := s.opsRepo.GetLatestDailyBucketDate(ctxMax)
  243. cancelMax()
  244. if err != nil {
  245. logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][daily] failed to read latest bucket: %v", err)
  246. } else if ok {
  247. candidate := latest.Add(-opsAggDailyOverlap)
  248. if candidate.After(start) {
  249. start = candidate
  250. }
  251. }
  252. }
  253. start = utcFloorToDay(start)
  254. if !start.Before(end) {
  255. return
  256. }
  257. var aggErr error
  258. for cursor := start; cursor.Before(end); cursor = cursor.Add(opsAggDailyChunk) {
  259. chunkEnd := minTime(cursor.Add(opsAggDailyChunk), end)
  260. if err := s.opsRepo.UpsertDailyMetrics(ctx, cursor, chunkEnd); err != nil {
  261. aggErr = err
  262. logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][daily] upsert failed (%s..%s): %v", cursor.Format("2006-01-02"), chunkEnd.Format("2006-01-02"), err)
  263. break
  264. }
  265. }
  266. finishedAt := time.Now().UTC()
  267. durationMs := finishedAt.Sub(startedAt).Milliseconds()
  268. dur := durationMs
  269. if aggErr != nil {
  270. msg := truncateString(aggErr.Error(), 2048)
  271. errAt := finishedAt
  272. hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
  273. defer hbCancel()
  274. _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
  275. JobName: opsAggDailyJobName,
  276. LastRunAt: &runAt,
  277. LastErrorAt: &errAt,
  278. LastError: &msg,
  279. LastDurationMs: &dur,
  280. })
  281. return
  282. }
  283. successAt := finishedAt
  284. hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
  285. defer hbCancel()
  286. result := truncateString(fmt.Sprintf("window=%s..%s", start.Format(time.RFC3339), end.Format(time.RFC3339)), 2048)
  287. _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
  288. JobName: opsAggDailyJobName,
  289. LastRunAt: &runAt,
  290. LastSuccessAt: &successAt,
  291. LastDurationMs: &dur,
  292. LastResult: &result,
  293. })
  294. }
  295. func (s *OpsAggregationService) isMonitoringEnabled(ctx context.Context) bool {
  296. if s == nil {
  297. return false
  298. }
  299. if s.cfg != nil && !s.cfg.Ops.Enabled {
  300. return false
  301. }
  302. if s.settingRepo == nil {
  303. return true
  304. }
  305. if ctx == nil {
  306. ctx = context.Background()
  307. }
  308. value, err := s.settingRepo.GetValue(ctx, SettingKeyOpsMonitoringEnabled)
  309. if err != nil {
  310. if errors.Is(err, ErrSettingNotFound) {
  311. return true
  312. }
  313. return true
  314. }
  315. switch strings.ToLower(strings.TrimSpace(value)) {
  316. case "false", "0", "off", "disabled":
  317. return false
  318. default:
  319. return true
  320. }
  321. }
  322. var opsAggReleaseScript = redis.NewScript(`
  323. if redis.call("GET", KEYS[1]) == ARGV[1] then
  324. return redis.call("DEL", KEYS[1])
  325. end
  326. return 0
  327. `)
  328. func (s *OpsAggregationService) tryAcquireLeaderLock(ctx context.Context, key string, ttl time.Duration, logPrefix string) (func(), bool) {
  329. if s == nil {
  330. return nil, false
  331. }
  332. if ctx == nil {
  333. ctx = context.Background()
  334. }
  335. // Prefer Redis leader lock when available (multi-instance), but avoid stampeding
  336. // the DB when Redis is flaky by falling back to a DB advisory lock.
  337. if s.redisClient != nil {
  338. ok, err := s.redisClient.SetNX(ctx, key, s.instanceID, ttl).Result()
  339. if err == nil {
  340. if !ok {
  341. s.maybeLogSkip(logPrefix)
  342. return nil, false
  343. }
  344. release := func() {
  345. ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  346. defer cancel()
  347. _, _ = opsAggReleaseScript.Run(ctx2, s.redisClient, []string{key}, s.instanceID).Result()
  348. }
  349. return release, true
  350. }
  351. // Redis error: fall through to DB advisory lock.
  352. }
  353. release, ok := tryAcquireDBAdvisoryLock(ctx, s.db, hashAdvisoryLockID(key))
  354. if !ok {
  355. s.maybeLogSkip(logPrefix)
  356. return nil, false
  357. }
  358. return release, true
  359. }
  360. func (s *OpsAggregationService) maybeLogSkip(prefix string) {
  361. s.skipLogMu.Lock()
  362. defer s.skipLogMu.Unlock()
  363. now := time.Now()
  364. if !s.skipLogAt.IsZero() && now.Sub(s.skipLogAt) < time.Minute {
  365. return
  366. }
  367. s.skipLogAt = now
  368. if prefix == "" {
  369. prefix = "[OpsAggregation]"
  370. }
  371. logger.LegacyPrintf("service.ops_aggregation", "%s leader lock held by another instance; skipping", prefix)
  372. }
  373. func utcFloorToHour(t time.Time) time.Time {
  374. return t.UTC().Truncate(time.Hour)
  375. }
  376. func utcFloorToDay(t time.Time) time.Time {
  377. u := t.UTC()
  378. y, m, d := u.Date()
  379. return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
  380. }
  381. func minTime(a, b time.Time) time.Time {
  382. if a.Before(b) {
  383. return a
  384. }
  385. return b
  386. }