| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- package service
- import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "strings"
- "sync"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/google/uuid"
- "github.com/redis/go-redis/v9"
- )
- const (
- opsAggHourlyJobName = "ops_preaggregation_hourly"
- opsAggDailyJobName = "ops_preaggregation_daily"
- opsAggHourlyInterval = 10 * time.Minute
- opsAggDailyInterval = 1 * time.Hour
- // Keep in sync with ops retention target (vNext default 30d).
- opsAggBackfillWindow = 1 * time.Hour
- // Recompute overlap to absorb late-arriving rows near boundaries.
- opsAggHourlyOverlap = 2 * time.Hour
- opsAggDailyOverlap = 48 * time.Hour
- opsAggHourlyChunk = 24 * time.Hour
- opsAggDailyChunk = 7 * 24 * time.Hour
- // Delay around boundaries (e.g. 10:00..10:05) to avoid aggregating buckets
- // that may still receive late inserts.
- opsAggSafeDelay = 5 * time.Minute
- opsAggMaxQueryTimeout = 5 * time.Second
- opsAggHourlyTimeout = 5 * time.Minute
- opsAggDailyTimeout = 2 * time.Minute
- opsAggHourlyLeaderLockKey = "ops:aggregation:hourly:leader"
- opsAggDailyLeaderLockKey = "ops:aggregation:daily:leader"
- opsAggHourlyLeaderLockTTL = 15 * time.Minute
- opsAggDailyLeaderLockTTL = 10 * time.Minute
- )
- // OpsAggregationService periodically backfills ops_metrics_hourly / ops_metrics_daily
- // for stable long-window dashboard queries.
- //
- // It is safe to run in multi-replica deployments when Redis is available (leader lock).
- type OpsAggregationService struct {
- opsRepo OpsRepository
- settingRepo SettingRepository
- cfg *config.Config
- db *sql.DB
- redisClient *redis.Client
- instanceID string
- stopCh chan struct{}
- startOnce sync.Once
- stopOnce sync.Once
- hourlyMu sync.Mutex
- dailyMu sync.Mutex
- skipLogMu sync.Mutex
- skipLogAt time.Time
- }
- func NewOpsAggregationService(
- opsRepo OpsRepository,
- settingRepo SettingRepository,
- db *sql.DB,
- redisClient *redis.Client,
- cfg *config.Config,
- ) *OpsAggregationService {
- return &OpsAggregationService{
- opsRepo: opsRepo,
- settingRepo: settingRepo,
- cfg: cfg,
- db: db,
- redisClient: redisClient,
- instanceID: uuid.NewString(),
- }
- }
- func (s *OpsAggregationService) Start() {
- if s == nil {
- return
- }
- s.startOnce.Do(func() {
- if s.stopCh == nil {
- s.stopCh = make(chan struct{})
- }
- go s.hourlyLoop()
- go s.dailyLoop()
- })
- }
- func (s *OpsAggregationService) Stop() {
- if s == nil {
- return
- }
- s.stopOnce.Do(func() {
- if s.stopCh != nil {
- close(s.stopCh)
- }
- })
- }
- func (s *OpsAggregationService) hourlyLoop() {
- // First run immediately.
- s.aggregateHourly()
- ticker := time.NewTicker(opsAggHourlyInterval)
- defer ticker.Stop()
- for {
- select {
- case <-ticker.C:
- s.aggregateHourly()
- case <-s.stopCh:
- return
- }
- }
- }
- func (s *OpsAggregationService) dailyLoop() {
- // First run immediately.
- s.aggregateDaily()
- ticker := time.NewTicker(opsAggDailyInterval)
- defer ticker.Stop()
- for {
- select {
- case <-ticker.C:
- s.aggregateDaily()
- case <-s.stopCh:
- return
- }
- }
- }
- func (s *OpsAggregationService) aggregateHourly() {
- if s == nil || s.opsRepo == nil {
- return
- }
- if s.cfg != nil {
- if !s.cfg.Ops.Enabled {
- return
- }
- if !s.cfg.Ops.Aggregation.Enabled {
- return
- }
- }
- ctx, cancel := context.WithTimeout(context.Background(), opsAggHourlyTimeout)
- defer cancel()
- if !s.isMonitoringEnabled(ctx) {
- return
- }
- release, ok := s.tryAcquireLeaderLock(ctx, opsAggHourlyLeaderLockKey, opsAggHourlyLeaderLockTTL, "[OpsAggregation][hourly]")
- if !ok {
- return
- }
- if release != nil {
- defer release()
- }
- s.hourlyMu.Lock()
- defer s.hourlyMu.Unlock()
- startedAt := time.Now().UTC()
- runAt := startedAt
- // Aggregate stable full hours only.
- end := utcFloorToHour(time.Now().UTC().Add(-opsAggSafeDelay))
- start := end.Add(-opsAggBackfillWindow)
- // Resume from the latest bucket with overlap.
- {
- ctxMax, cancelMax := context.WithTimeout(context.Background(), opsAggMaxQueryTimeout)
- latest, ok, err := s.opsRepo.GetLatestHourlyBucketStart(ctxMax)
- cancelMax()
- if err != nil {
- logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][hourly] failed to read latest bucket: %v", err)
- } else if ok {
- candidate := latest.Add(-opsAggHourlyOverlap)
- if candidate.After(start) {
- start = candidate
- }
- }
- }
- start = utcFloorToHour(start)
- if !start.Before(end) {
- return
- }
- var aggErr error
- for cursor := start; cursor.Before(end); cursor = cursor.Add(opsAggHourlyChunk) {
- chunkEnd := minTime(cursor.Add(opsAggHourlyChunk), end)
- if err := s.opsRepo.UpsertHourlyMetrics(ctx, cursor, chunkEnd); err != nil {
- aggErr = err
- logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][hourly] upsert failed (%s..%s): %v", cursor.Format(time.RFC3339), chunkEnd.Format(time.RFC3339), err)
- break
- }
- }
- finishedAt := time.Now().UTC()
- durationMs := finishedAt.Sub(startedAt).Milliseconds()
- dur := durationMs
- if aggErr != nil {
- msg := truncateString(aggErr.Error(), 2048)
- errAt := finishedAt
- hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer hbCancel()
- _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
- JobName: opsAggHourlyJobName,
- LastRunAt: &runAt,
- LastErrorAt: &errAt,
- LastError: &msg,
- LastDurationMs: &dur,
- })
- return
- }
- successAt := finishedAt
- hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer hbCancel()
- result := truncateString(fmt.Sprintf("window=%s..%s", start.Format(time.RFC3339), end.Format(time.RFC3339)), 2048)
- _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
- JobName: opsAggHourlyJobName,
- LastRunAt: &runAt,
- LastSuccessAt: &successAt,
- LastDurationMs: &dur,
- LastResult: &result,
- })
- }
- func (s *OpsAggregationService) aggregateDaily() {
- if s == nil || s.opsRepo == nil {
- return
- }
- if s.cfg != nil {
- if !s.cfg.Ops.Enabled {
- return
- }
- if !s.cfg.Ops.Aggregation.Enabled {
- return
- }
- }
- ctx, cancel := context.WithTimeout(context.Background(), opsAggDailyTimeout)
- defer cancel()
- if !s.isMonitoringEnabled(ctx) {
- return
- }
- release, ok := s.tryAcquireLeaderLock(ctx, opsAggDailyLeaderLockKey, opsAggDailyLeaderLockTTL, "[OpsAggregation][daily]")
- if !ok {
- return
- }
- if release != nil {
- defer release()
- }
- s.dailyMu.Lock()
- defer s.dailyMu.Unlock()
- startedAt := time.Now().UTC()
- runAt := startedAt
- end := utcFloorToDay(time.Now().UTC())
- start := end.Add(-opsAggBackfillWindow)
- {
- ctxMax, cancelMax := context.WithTimeout(context.Background(), opsAggMaxQueryTimeout)
- latest, ok, err := s.opsRepo.GetLatestDailyBucketDate(ctxMax)
- cancelMax()
- if err != nil {
- logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][daily] failed to read latest bucket: %v", err)
- } else if ok {
- candidate := latest.Add(-opsAggDailyOverlap)
- if candidate.After(start) {
- start = candidate
- }
- }
- }
- start = utcFloorToDay(start)
- if !start.Before(end) {
- return
- }
- var aggErr error
- for cursor := start; cursor.Before(end); cursor = cursor.Add(opsAggDailyChunk) {
- chunkEnd := minTime(cursor.Add(opsAggDailyChunk), end)
- if err := s.opsRepo.UpsertDailyMetrics(ctx, cursor, chunkEnd); err != nil {
- aggErr = err
- logger.LegacyPrintf("service.ops_aggregation", "[OpsAggregation][daily] upsert failed (%s..%s): %v", cursor.Format("2006-01-02"), chunkEnd.Format("2006-01-02"), err)
- break
- }
- }
- finishedAt := time.Now().UTC()
- durationMs := finishedAt.Sub(startedAt).Milliseconds()
- dur := durationMs
- if aggErr != nil {
- msg := truncateString(aggErr.Error(), 2048)
- errAt := finishedAt
- hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer hbCancel()
- _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
- JobName: opsAggDailyJobName,
- LastRunAt: &runAt,
- LastErrorAt: &errAt,
- LastError: &msg,
- LastDurationMs: &dur,
- })
- return
- }
- successAt := finishedAt
- hbCtx, hbCancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer hbCancel()
- result := truncateString(fmt.Sprintf("window=%s..%s", start.Format(time.RFC3339), end.Format(time.RFC3339)), 2048)
- _ = s.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
- JobName: opsAggDailyJobName,
- LastRunAt: &runAt,
- LastSuccessAt: &successAt,
- LastDurationMs: &dur,
- LastResult: &result,
- })
- }
- func (s *OpsAggregationService) isMonitoringEnabled(ctx context.Context) bool {
- if s == nil {
- return false
- }
- if s.cfg != nil && !s.cfg.Ops.Enabled {
- return false
- }
- if s.settingRepo == nil {
- return true
- }
- if ctx == nil {
- ctx = context.Background()
- }
- value, err := s.settingRepo.GetValue(ctx, SettingKeyOpsMonitoringEnabled)
- if err != nil {
- if errors.Is(err, ErrSettingNotFound) {
- return true
- }
- return true
- }
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "false", "0", "off", "disabled":
- return false
- default:
- return true
- }
- }
- var opsAggReleaseScript = redis.NewScript(`
- if redis.call("GET", KEYS[1]) == ARGV[1] then
- return redis.call("DEL", KEYS[1])
- end
- return 0
- `)
- func (s *OpsAggregationService) tryAcquireLeaderLock(ctx context.Context, key string, ttl time.Duration, logPrefix string) (func(), bool) {
- if s == nil {
- return nil, false
- }
- if ctx == nil {
- ctx = context.Background()
- }
- // Prefer Redis leader lock when available (multi-instance), but avoid stampeding
- // the DB when Redis is flaky by falling back to a DB advisory lock.
- if s.redisClient != nil {
- ok, err := s.redisClient.SetNX(ctx, key, s.instanceID, ttl).Result()
- if err == nil {
- if !ok {
- s.maybeLogSkip(logPrefix)
- return nil, false
- }
- release := func() {
- ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- _, _ = opsAggReleaseScript.Run(ctx2, s.redisClient, []string{key}, s.instanceID).Result()
- }
- return release, true
- }
- // Redis error: fall through to DB advisory lock.
- }
- release, ok := tryAcquireDBAdvisoryLock(ctx, s.db, hashAdvisoryLockID(key))
- if !ok {
- s.maybeLogSkip(logPrefix)
- return nil, false
- }
- return release, true
- }
- func (s *OpsAggregationService) maybeLogSkip(prefix string) {
- s.skipLogMu.Lock()
- defer s.skipLogMu.Unlock()
- now := time.Now()
- if !s.skipLogAt.IsZero() && now.Sub(s.skipLogAt) < time.Minute {
- return
- }
- s.skipLogAt = now
- if prefix == "" {
- prefix = "[OpsAggregation]"
- }
- logger.LegacyPrintf("service.ops_aggregation", "%s leader lock held by another instance; skipping", prefix)
- }
- func utcFloorToHour(t time.Time) time.Time {
- return t.UTC().Truncate(time.Hour)
- }
- func utcFloorToDay(t time.Time) time.Time {
- u := t.UTC()
- y, m, d := u.Date()
- return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
- }
- func minTime(a, b time.Time) time.Time {
- if a.Before(b) {
- return a
- }
- return b
- }
|