channel_monitor_v2_aggregator.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "sync"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  8. "github.com/google/uuid"
  9. )
  10. const (
  11. channelMonitorV2AggregatorLockKey = "channel-monitor-v2-aggregator"
  12. // Retention walks back to the longest stored tier (1d rollup = 90d). Per-tier
  13. // prune in the repository drops short-lived 1m/user/hist facts earlier.
  14. channelMonitorV2RetentionMax = 90 * 24 * time.Hour
  15. // First tick after upgrade prioritizes the default 90m view (with small padding).
  16. channelMonitorV2BootstrapFirst = 2 * time.Hour
  17. // Always refresh a small trailing window so late writes land without
  18. // re-aggregating large history every tick.
  19. channelMonitorV2RecentOverlap = 10 * time.Minute
  20. // Gentle backfill: small adaptive chunks, never default 24h hammering.
  21. // Initial historical chunk after the 2h seed.
  22. channelMonitorV2BackfillChunkInit = time.Hour
  23. channelMonitorV2MinBackfillChunk = 15 * time.Minute
  24. // Depth-based ceilings (product phases 90m → 1d → 7d → 30d → 90d).
  25. channelMonitorV2MaxChunkNear1d = 2 * time.Hour
  26. channelMonitorV2MaxChunkNear7d = 4 * time.Hour
  27. channelMonitorV2MaxChunkFar = 6 * time.Hour
  28. // Soft adaptive timing: grow only when a recompute is clearly cheap.
  29. channelMonitorV2GrowChunkUnder = 15 * time.Second
  30. channelMonitorV2MaxBackoff = 10 * time.Minute
  31. )
  32. // channelMonitorRuntimeSubscriber is the optional settings hook that lets the
  33. // aggregator wake immediately when channel_monitor_enabled / mode flips.
  34. type channelMonitorRuntimeSubscriber interface {
  35. SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func())
  36. }
  37. type ChannelMonitorV2Aggregator struct {
  38. repo ChannelMonitorV2Repository
  39. db *sql.DB
  40. settings channelMonitorRuntimeReader
  41. instanceID string
  42. stopCh chan struct{}
  43. // kickCh wakes the loop early after a settings change (buffered 1).
  44. kickCh chan struct{}
  45. startOnce sync.Once
  46. stopOnce sync.Once
  47. mu sync.Mutex
  48. // backfillAt is the earliest minute already recomputed (mirrors DB cursor).
  49. // Zero means "not yet loaded from durable watermark this process".
  50. backfillAt time.Time
  51. backfillChunk time.Duration
  52. backfillFailures int
  53. // nextWaitFloor is applied after runOnce when failures require backoff.
  54. nextWaitFloor time.Duration
  55. // cursorLoaded is true after the first successful watermark read (or init).
  56. cursorLoaded bool
  57. // hasAggregated is true once any recompute in this process (or durable data) exists.
  58. hasAggregated bool
  59. unsub func()
  60. ctx context.Context
  61. cancel context.CancelFunc
  62. }
  63. func NewChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.DB, settings channelMonitorRuntimeReader) *ChannelMonitorV2Aggregator {
  64. return &ChannelMonitorV2Aggregator{
  65. repo: repo,
  66. db: db,
  67. settings: settings,
  68. instanceID: uuid.NewString(),
  69. stopCh: make(chan struct{}),
  70. kickCh: make(chan struct{}, 1),
  71. backfillChunk: channelMonitorV2BackfillChunkInit,
  72. }
  73. }
  74. func (s *ChannelMonitorV2Aggregator) Start() {
  75. if s == nil || s.repo == nil {
  76. return
  77. }
  78. s.startOnce.Do(func() {
  79. s.mu.Lock()
  80. s.ctx, s.cancel = context.WithCancel(context.Background())
  81. s.mu.Unlock()
  82. if sub, ok := s.settings.(channelMonitorRuntimeSubscriber); ok && sub != nil {
  83. unsub := sub.SubscribeChannelMonitorRuntime(func() {
  84. s.kick()
  85. })
  86. s.mu.Lock()
  87. stopped := s.ctx == nil
  88. if !stopped {
  89. select {
  90. case <-s.ctx.Done():
  91. stopped = true
  92. default:
  93. }
  94. }
  95. if !stopped {
  96. s.unsub = unsub
  97. }
  98. s.mu.Unlock()
  99. if stopped && unsub != nil {
  100. unsub()
  101. }
  102. }
  103. go s.loop()
  104. })
  105. }
  106. func (s *ChannelMonitorV2Aggregator) Stop() {
  107. if s == nil {
  108. return
  109. }
  110. s.stopOnce.Do(func() {
  111. s.mu.Lock()
  112. cancel := s.cancel
  113. unsub := s.unsub
  114. s.cancel = nil
  115. s.unsub = nil
  116. s.mu.Unlock()
  117. if cancel != nil {
  118. cancel()
  119. }
  120. if unsub != nil {
  121. unsub()
  122. }
  123. close(s.stopCh)
  124. })
  125. }
  126. // kick wakes the aggregation loop so mode flips take effect without waiting
  127. // for the next refresh interval.
  128. func (s *ChannelMonitorV2Aggregator) kick() {
  129. if s == nil {
  130. return
  131. }
  132. select {
  133. case s.kickCh <- struct{}{}:
  134. default:
  135. }
  136. }
  137. func (s *ChannelMonitorV2Aggregator) loop() {
  138. for {
  139. interval := time.Minute
  140. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  141. if !s.passiveAggregationAllowed(ctx) {
  142. cancel()
  143. if !s.wait(interval) {
  144. return
  145. }
  146. continue
  147. }
  148. if cfg, err := s.repo.GetConfig(ctx); err == nil {
  149. if !cfg.Enabled {
  150. cancel()
  151. if !s.wait(interval) {
  152. return
  153. }
  154. continue
  155. }
  156. if cfg.RefreshIntervalSeconds > 0 {
  157. interval = time.Duration(cfg.RefreshIntervalSeconds) * time.Second
  158. }
  159. }
  160. cancel()
  161. s.runOnce()
  162. // Hard gate: never compress bootstrap to multi-Hz ticks. Soft gate: on
  163. // repeated failures raise the wait floor (exponential backoff).
  164. s.mu.Lock()
  165. floor := s.nextWaitFloor
  166. s.mu.Unlock()
  167. if floor > interval {
  168. interval = floor
  169. }
  170. if !s.wait(interval) {
  171. return
  172. }
  173. }
  174. }
  175. func (s *ChannelMonitorV2Aggregator) passiveAggregationAllowed(ctx context.Context) bool {
  176. if s == nil || s.settings == nil {
  177. // Fail closed without settings: do not aggregate under ambiguous mode.
  178. return false
  179. }
  180. return s.settings.GetChannelMonitorRuntime(ctx).PassiveAggregationAllowed()
  181. }
  182. func (s *ChannelMonitorV2Aggregator) wait(interval time.Duration) bool {
  183. timer := time.NewTimer(interval)
  184. defer timer.Stop()
  185. select {
  186. case <-timer.C:
  187. return true
  188. case <-s.kickCh:
  189. // Drain any coalesced kicks so a burst of settings writes only wakes once.
  190. for {
  191. select {
  192. case <-s.kickCh:
  193. default:
  194. return true
  195. }
  196. }
  197. case <-s.stopCh:
  198. return false
  199. }
  200. }
  201. func (s *ChannelMonitorV2Aggregator) runOnce() {
  202. s.mu.Lock()
  203. parent := s.ctx
  204. s.mu.Unlock()
  205. if parent == nil {
  206. parent = context.Background()
  207. }
  208. ctx, cancel := context.WithTimeout(parent, 55*time.Second)
  209. defer cancel()
  210. release, acquired := tryAcquireSingletonLeaderLock(ctx, nil, s.db, channelMonitorV2AggregatorLockKey, s.instanceID, 2*time.Minute)
  211. if !acquired {
  212. return
  213. }
  214. if release != nil {
  215. defer release()
  216. }
  217. now := time.Now().UTC().Truncate(time.Minute)
  218. if err := s.ensureCursor(ctx, now); err != nil {
  219. logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] load watermark failed: %v", err)
  220. return
  221. }
  222. s.mu.Lock()
  223. cursor := s.backfillAt
  224. hasData := s.hasAggregated
  225. s.mu.Unlock()
  226. // Phase 1 (first upgrade / empty): seed the default 90m UI window quickly.
  227. if !hasData || cursor.IsZero() {
  228. start := now.Add(-channelMonitorV2BootstrapFirst)
  229. started := time.Now()
  230. if err := s.repo.RecomputeRange(ctx, start, now); err != nil {
  231. logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] bootstrap recent aggregation failed: %v", err)
  232. s.recordBackfillFailure(now, cursor)
  233. return
  234. }
  235. s.recordBackfillSuccess(start, time.Since(started), now)
  236. return
  237. }
  238. // Always refresh the trailing overlap so late usage/error writes land in 1m facts.
  239. if err := s.repo.RecomputeRange(ctx, now.Add(-channelMonitorV2RecentOverlap), now); err != nil {
  240. logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] overlap aggregation failed: %v", err)
  241. return
  242. }
  243. // Phase 2: walk history backward at most one chunk per tick until retention max (90d).
  244. // Product UI (30d) fills first; remaining 30–90d continues silently.
  245. retentionCutoff := now.Add(-channelMonitorV2RetentionMax)
  246. if !cursor.After(retentionCutoff) {
  247. return
  248. }
  249. end := cursor
  250. s.mu.Lock()
  251. chunk := s.backfillChunk
  252. s.mu.Unlock()
  253. if chunk <= 0 {
  254. chunk = channelMonitorV2BackfillChunkInit
  255. }
  256. maxChunk := channelMonitorV2MaxChunkForDepth(now, end)
  257. if chunk > maxChunk {
  258. chunk = maxChunk
  259. }
  260. if chunk < channelMonitorV2MinBackfillChunk {
  261. chunk = channelMonitorV2MinBackfillChunk
  262. }
  263. start := end.Add(-chunk)
  264. // Once bootstrap reaches historical data, keep chunks on day boundaries so
  265. // daily rollups never depend on 1m rows from two independently pruned chunks.
  266. if end.Before(now.Add(-7 * 24 * time.Hour)) {
  267. aligned := end.Add(-chunk).Truncate(24 * time.Hour)
  268. if aligned.Before(end) {
  269. start = aligned
  270. }
  271. }
  272. if start.Before(retentionCutoff) {
  273. start = retentionCutoff
  274. }
  275. if !start.Before(end) {
  276. return
  277. }
  278. started := time.Now()
  279. if err := s.repo.RecomputeRange(ctx, start, end); err != nil {
  280. logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] backfill failed %s..%s: %v", start, end, err)
  281. s.recordBackfillFailure(now, end)
  282. return
  283. }
  284. s.recordBackfillSuccess(start, time.Since(started), now)
  285. }
  286. // channelMonitorV2MaxChunkForDepth returns the hard ceiling for a historical
  287. // chunk ending at `end` (earliest already covered / next walk end).
  288. func channelMonitorV2MaxChunkForDepth(now, end time.Time) time.Duration {
  289. age := now.Sub(end)
  290. switch {
  291. case age < 24*time.Hour:
  292. return channelMonitorV2MaxChunkNear1d
  293. case age < 7*24*time.Hour:
  294. return channelMonitorV2MaxChunkNear7d
  295. default:
  296. return channelMonitorV2MaxChunkFar
  297. }
  298. }
  299. func (s *ChannelMonitorV2Aggregator) recordBackfillSuccess(coveredFrom time.Time, elapsed time.Duration, now time.Time) {
  300. s.mu.Lock()
  301. defer s.mu.Unlock()
  302. s.backfillAt = coveredFrom
  303. s.hasAggregated = true
  304. s.backfillFailures = 0
  305. s.nextWaitFloor = 0
  306. maxChunk := channelMonitorV2MaxChunkForDepth(now, coveredFrom)
  307. // Grow slowly only when the recompute was clearly cheap.
  308. if elapsed > 0 && elapsed < channelMonitorV2GrowChunkUnder {
  309. next := s.backfillChunk
  310. if next <= 0 {
  311. next = channelMonitorV2BackfillChunkInit
  312. }
  313. next = time.Duration(float64(next) * 1.5)
  314. if next > maxChunk {
  315. next = maxChunk
  316. }
  317. if next < channelMonitorV2MinBackfillChunk {
  318. next = channelMonitorV2MinBackfillChunk
  319. }
  320. s.backfillChunk = next
  321. return
  322. }
  323. // Keep a healthy chunk within the depth ceiling after success.
  324. if s.backfillChunk <= 0 || s.backfillChunk > maxChunk {
  325. s.backfillChunk = channelMonitorV2BackfillChunkInit
  326. if s.backfillChunk > maxChunk {
  327. s.backfillChunk = maxChunk
  328. }
  329. }
  330. }
  331. func (s *ChannelMonitorV2Aggregator) recordBackfillFailure(now, end time.Time) {
  332. s.mu.Lock()
  333. defer s.mu.Unlock()
  334. s.backfillFailures++
  335. // Shrink chunk toward the minimum so large DBs self-throttle.
  336. if s.backfillChunk <= 0 {
  337. s.backfillChunk = channelMonitorV2BackfillChunkInit
  338. }
  339. s.backfillChunk /= 2
  340. if s.backfillChunk < channelMonitorV2MinBackfillChunk {
  341. s.backfillChunk = channelMonitorV2MinBackfillChunk
  342. }
  343. maxChunk := channelMonitorV2MaxChunkForDepth(now, end)
  344. if s.backfillChunk > maxChunk {
  345. s.backfillChunk = maxChunk
  346. }
  347. // Exponential backoff on wait floor: 1m, 2m, 4m… capped at 10m.
  348. floor := time.Minute << uint(s.backfillFailures-1)
  349. if floor > channelMonitorV2MaxBackoff {
  350. floor = channelMonitorV2MaxBackoff
  351. }
  352. if floor < time.Minute {
  353. floor = time.Minute
  354. }
  355. s.nextWaitFloor = floor
  356. }
  357. // ensureCursor restores durable backfill_cursor after process restart so progress
  358. // and historical walk continue instead of re-seeding only the last 2h.
  359. func (s *ChannelMonitorV2Aggregator) ensureCursor(ctx context.Context, now time.Time) error {
  360. s.mu.Lock()
  361. loaded := s.cursorLoaded
  362. s.mu.Unlock()
  363. if loaded {
  364. return nil
  365. }
  366. wm, err := s.repo.GetAggregationWatermark(ctx)
  367. if err != nil {
  368. return err
  369. }
  370. s.mu.Lock()
  371. defer s.mu.Unlock()
  372. if s.cursorLoaded {
  373. return nil
  374. }
  375. if wm != nil {
  376. if !wm.BackfillCursor.IsZero() {
  377. s.backfillAt = wm.BackfillCursor.UTC().Truncate(time.Minute)
  378. }
  379. if wm.HasData || !wm.DataThrough.IsZero() {
  380. s.hasAggregated = true
  381. // Legacy rows may have data_through but null backfill_cursor (older workers).
  382. // Infer cursor from data_through − initial window so we do not re-bootstrap
  383. // only 2h and claim zero progress forever.
  384. if s.backfillAt.IsZero() && !wm.DataThrough.IsZero() {
  385. inferred := wm.DataThrough.UTC().Truncate(time.Minute).Add(-channelMonitorV2BootstrapFirst)
  386. if inferred.After(now) {
  387. inferred = now.Add(-channelMonitorV2BootstrapFirst)
  388. }
  389. s.backfillAt = inferred
  390. }
  391. }
  392. }
  393. s.cursorLoaded = true
  394. return nil
  395. }