ops_cleanup_service.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  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. "github.com/robfig/cron/v3"
  15. "go.uber.org/zap"
  16. )
  17. const (
  18. opsCleanupJobName = "ops_cleanup"
  19. opsCleanupLeaderLockKeyDefault = "ops:cleanup:leader"
  20. opsCleanupLeaderLockTTLDefault = 30 * time.Minute
  21. )
  22. var opsCleanupCronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
  23. var opsCleanupReleaseScript = redis.NewScript(`
  24. if redis.call("GET", KEYS[1]) == ARGV[1] then
  25. return redis.call("DEL", KEYS[1])
  26. end
  27. return 0
  28. `)
  29. // OpsCleanupService periodically deletes old ops data to prevent unbounded DB growth.
  30. //
  31. // - Scheduling: 5-field cron spec (minute hour dom month dow).
  32. // - Multi-instance: best-effort Redis leader lock so only one node runs cleanup.
  33. // - Safety: deletes in batches to avoid long transactions.
  34. //
  35. // 附带:在 runCleanupOnce 末尾调用 ChannelMonitorService.RunDailyMaintenance,
  36. // 统一共享 cron schedule + leader lock + heartbeat,避免再引一套调度。
  37. type OpsCleanupService struct {
  38. opsRepo OpsRepository
  39. db *sql.DB
  40. redisClient *redis.Client
  41. cfg *config.Config
  42. channelMonitorSvc *ChannelMonitorService
  43. settingRepo SettingRepository
  44. instanceID string
  45. // mu 守护 cron 实例切换 + effective 配置切换。
  46. // 这里不再用 startOnce/stopOnce,是因为 Reload 需要"停旧 cron 重启新 cron",
  47. // 而 Once 一旦触发就无法再次执行;改为 started/stopped 布尔配合 mu。
  48. mu sync.Mutex
  49. cron *cron.Cron
  50. started bool
  51. stopped bool
  52. effective opsCleanupEffectiveConfig
  53. warnNoRedisOnce sync.Once
  54. }
  55. type opsCleanupEffectiveConfig struct {
  56. config.OpsCleanupConfig
  57. SystemLogRetentionDays int
  58. }
  59. func NewOpsCleanupService(
  60. opsRepo OpsRepository,
  61. db *sql.DB,
  62. redisClient *redis.Client,
  63. cfg *config.Config,
  64. channelMonitorSvc *ChannelMonitorService,
  65. settingRepo SettingRepository,
  66. ) *OpsCleanupService {
  67. return &OpsCleanupService{
  68. opsRepo: opsRepo,
  69. db: db,
  70. redisClient: redisClient,
  71. cfg: cfg,
  72. channelMonitorSvc: channelMonitorSvc,
  73. settingRepo: settingRepo,
  74. instanceID: uuid.NewString(),
  75. }
  76. }
  77. // Start 首次启动 cron 调度。Enabled / Schedule 由 effective 配置决定(settings 优先 cfg)。
  78. // 重复调用幂等。
  79. func (s *OpsCleanupService) Start() {
  80. if s == nil {
  81. return
  82. }
  83. if s.cfg != nil && !s.cfg.Ops.Enabled {
  84. return
  85. }
  86. if s.opsRepo == nil || s.db == nil {
  87. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started (missing deps)")
  88. return
  89. }
  90. s.mu.Lock()
  91. defer s.mu.Unlock()
  92. if s.started || s.stopped {
  93. return
  94. }
  95. s.started = true
  96. if err := s.applyScheduleLocked(context.Background()); err != nil {
  97. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started: %v", err)
  98. }
  99. }
  100. // Stop 关闭 cron。幂等。
  101. func (s *OpsCleanupService) Stop() {
  102. if s == nil {
  103. return
  104. }
  105. s.mu.Lock()
  106. defer s.mu.Unlock()
  107. if s.stopped {
  108. return
  109. }
  110. s.stopped = true
  111. s.stopCronLocked()
  112. }
  113. // stopCronLocked 停掉当前 cron 实例(带 3s 超时)。调用方持锁。
  114. func (s *OpsCleanupService) stopCronLocked() {
  115. if s.cron == nil {
  116. return
  117. }
  118. ctx := s.cron.Stop()
  119. select {
  120. case <-ctx.Done():
  121. case <-time.After(opsCleanupCronStopTimeout):
  122. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron stop timed out")
  123. }
  124. s.cron = nil
  125. }
  126. // applyScheduleLocked 重新计算 effective 配置并按其 schedule 重建 cron。调用方持锁。
  127. // 若 effective.Enabled=false(用户在 UI 关闭清理),停旧 cron 后直接返回,不创建新 cron。
  128. func (s *OpsCleanupService) applyScheduleLocked(ctx context.Context) error {
  129. s.computeEffectiveLocked(ctx)
  130. s.stopCronLocked()
  131. if !s.effective.Enabled {
  132. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron disabled by settings")
  133. return nil
  134. }
  135. schedule := strings.TrimSpace(s.effective.Schedule)
  136. if schedule == "" {
  137. schedule = opsCleanupDefaultSchedule
  138. }
  139. loc := time.Local
  140. if s.cfg != nil && strings.TrimSpace(s.cfg.Timezone) != "" {
  141. if parsed, err := time.LoadLocation(strings.TrimSpace(s.cfg.Timezone)); err == nil && parsed != nil {
  142. loc = parsed
  143. }
  144. }
  145. c := cron.New(cron.WithParser(opsCleanupCronParser), cron.WithLocation(loc))
  146. if _, err := c.AddFunc(schedule, func() { s.runScheduled() }); err != nil {
  147. return fmt.Errorf("invalid schedule %q: %w", schedule, err)
  148. }
  149. c.Start()
  150. s.cron = c
  151. logger.LegacyPrintf("service.ops_cleanup",
  152. "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/system:%d/min:%d/hour:%d)",
  153. schedule, loc.String(),
  154. s.effective.ErrorLogRetentionDays,
  155. s.effective.SystemLogRetentionDays,
  156. s.effective.MinuteMetricsRetentionDays,
  157. s.effective.HourlyMetricsRetentionDays,
  158. )
  159. return nil
  160. }
  161. // Reload 重新读取 ops_advanced_settings.data_retention 并按新配置重建 cron。
  162. // 适用于 admin 在 UI 修改清理设置后立即生效(schedule / enabled 改动需要 Reload;
  163. // retention 改动 runScheduled 顶部也会刷新,下一次触发即生效)。
  164. // 若 service 还未 Start 或已 Stop,Reload 不做任何事。
  165. func (s *OpsCleanupService) Reload(ctx context.Context) error {
  166. if s == nil {
  167. return nil
  168. }
  169. s.mu.Lock()
  170. defer s.mu.Unlock()
  171. if !s.started || s.stopped {
  172. return nil
  173. }
  174. return s.applyScheduleLocked(ctx)
  175. }
  176. // computeEffectiveLocked 计算"生效配置"并写入 s.effective。调用方持锁。
  177. //
  178. // 优先级:UI 写入的 settings.ops_advanced_settings.data_retention(权威)覆盖 cfg.Ops.Cleanup 的副本。
  179. // - Enabled:settings 直接覆盖
  180. // - Schedule:settings 非空时覆盖,否则保留 cfg
  181. // - *RetentionDays:settings >=0 时覆盖(包括 0=TRUNCATE),<0 沿用 cfg
  182. //
  183. // System log retention has a separate owner: settings.ops_runtime_log_config.retention_days.
  184. // Missing or invalid settings fall back independently instead of disabling all cleanup overlays.
  185. func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) {
  186. base := opsCleanupEffectiveConfig{
  187. SystemLogRetentionDays: defaultOpsRuntimeLogConfig(s.cfg).RetentionDays,
  188. }
  189. if s.cfg != nil {
  190. base.OpsCleanupConfig = s.cfg.Ops.Cleanup
  191. }
  192. defer func() { s.effective = base }()
  193. if s.settingRepo == nil {
  194. return
  195. }
  196. if ctx == nil {
  197. ctx = context.Background()
  198. }
  199. values, err := s.settingRepo.GetMultiple(ctx, []string{
  200. SettingKeyOpsAdvancedSettings,
  201. SettingKeyOpsRuntimeLogConfig,
  202. })
  203. if err != nil {
  204. logger.LegacyPrintf("service.ops_cleanup",
  205. "[OpsCleanup] read cleanup settings failed, using defaults: %v", err)
  206. return
  207. }
  208. if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok {
  209. adv := defaultOpsAdvancedSettingsForConfig(s.cfg)
  210. if err := json.Unmarshal([]byte(raw), adv); err != nil {
  211. logger.LegacyPrintf("service.ops_cleanup",
  212. "[OpsCleanup] parse advanced settings failed, using cfg: %v", err)
  213. } else {
  214. dr := adv.DataRetention
  215. base.Enabled = dr.CleanupEnabled
  216. if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" {
  217. base.Schedule = sched
  218. }
  219. if dr.ErrorLogRetentionDays >= 0 {
  220. base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays
  221. }
  222. if dr.MinuteMetricsRetentionDays >= 0 {
  223. base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays
  224. }
  225. if dr.HourlyMetricsRetentionDays >= 0 {
  226. base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays
  227. }
  228. }
  229. }
  230. if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok {
  231. defaults := defaultOpsRuntimeLogConfig(s.cfg)
  232. runtimeCfg := *defaults
  233. if err := json.Unmarshal([]byte(raw), &runtimeCfg); err != nil {
  234. logger.LegacyPrintf("service.ops_cleanup",
  235. "[OpsCleanup] parse runtime log config failed, using default system-log retention: %v", err)
  236. } else {
  237. normalizeOpsRuntimeLogConfig(&runtimeCfg, defaults)
  238. if runtimeCfg.RetentionDays >= 1 && runtimeCfg.RetentionDays <= 3650 {
  239. base.SystemLogRetentionDays = runtimeCfg.RetentionDays
  240. }
  241. }
  242. }
  243. }
  244. // snapshotEffective 取一份 effective 副本(runCleanupOnce 等读路径使用)。
  245. func (s *OpsCleanupService) snapshotEffective() opsCleanupEffectiveConfig {
  246. s.mu.Lock()
  247. defer s.mu.Unlock()
  248. return s.effective
  249. }
  250. // refreshEffectiveBeforeRun 在 cron 触发时刷新 effective,让 retention 改动当次即生效。
  251. // schedule 改动不影响当次(cron 调度由库管理,需要 Reload 才换 schedule)。
  252. func (s *OpsCleanupService) refreshEffectiveBeforeRun(ctx context.Context) {
  253. s.mu.Lock()
  254. defer s.mu.Unlock()
  255. s.computeEffectiveLocked(ctx)
  256. }
  257. func (s *OpsCleanupService) runScheduled() {
  258. if s == nil || s.db == nil || s.opsRepo == nil {
  259. return
  260. }
  261. ctx, cancel := context.WithTimeout(context.Background(), opsCleanupRunTimeout)
  262. defer cancel()
  263. // 让 retention 改动当次生效(schedule/enabled 改动需要 Reload)。
  264. s.refreshEffectiveBeforeRun(ctx)
  265. release, ok := s.tryAcquireLeaderLock(ctx)
  266. if !ok {
  267. return
  268. }
  269. if release != nil {
  270. defer release()
  271. }
  272. startedAt := time.Now().UTC()
  273. runAt := startedAt
  274. counts, err := s.runCleanupOnce(ctx)
  275. if err != nil {
  276. s.recordHeartbeatError(runAt, time.Since(startedAt), err)
  277. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cleanup failed: %v", err)
  278. return
  279. }
  280. s.recordHeartbeatSuccess(runAt, time.Since(startedAt), counts)
  281. logger.L().Info("[OpsCleanup] cleanup complete",
  282. zap.String("component", "service.ops_cleanup"),
  283. zap.String("deleted_counts", counts.String()),
  284. )
  285. }
  286. func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDeletedCounts, error) {
  287. out := opsCleanupDeletedCounts{}
  288. if s == nil || s.db == nil || s.cfg == nil {
  289. return out, nil
  290. }
  291. effective := s.snapshotEffective()
  292. now := time.Now().UTC()
  293. targets := []opsCleanupTarget{
  294. {effective.ErrorLogRetentionDays, "ops_error_logs", "created_at", false, &out.errorLogs},
  295. {effective.ErrorLogRetentionDays, "ops_ingress_reject_aggregates", "bucket_start", false, &out.ingressRejects},
  296. {effective.ErrorLogRetentionDays, "ops_alert_events", "created_at", false, &out.alertEvents},
  297. {effective.SystemLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs},
  298. {effective.SystemLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits},
  299. {effective.MinuteMetricsRetentionDays, "ops_system_metrics", "created_at", false, &out.systemMetrics},
  300. {effective.HourlyMetricsRetentionDays, "ops_metrics_hourly", "bucket_start", false, &out.hourlyPreagg},
  301. {effective.HourlyMetricsRetentionDays, "ops_metrics_daily", "bucket_date", true, &out.dailyPreagg},
  302. }
  303. for _, t := range targets {
  304. cutoff, truncate, ok := opsCleanupPlan(now, t.retentionDays)
  305. if !ok {
  306. continue
  307. }
  308. n, err := opsCleanupRunOne(ctx, s.db, truncate, cutoff, t.table, t.timeCol, t.castDate, opsCleanupBatchSize)
  309. if err != nil {
  310. return out, err
  311. }
  312. *t.counter = n
  313. }
  314. // Channel monitor 每日维护(聚合昨日明细 + 软删过期明细/聚合)。
  315. // 失败只记日志,不影响 ops 清理的成功状态(与 ops 各步骤风格一致);
  316. // 维护本身已经把每步错误打到 slog,heartbeat result 不再分项记录。
  317. if s.channelMonitorSvc != nil {
  318. if err := s.channelMonitorSvc.RunDailyMaintenance(ctx); err != nil {
  319. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] channel monitor maintenance failed: %v", err)
  320. }
  321. }
  322. return out, nil
  323. }
  324. func (s *OpsCleanupService) tryAcquireLeaderLock(ctx context.Context) (func(), bool) {
  325. if s == nil {
  326. return nil, false
  327. }
  328. // In simple run mode, assume single instance.
  329. if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
  330. return nil, true
  331. }
  332. key := opsCleanupLeaderLockKeyDefault
  333. ttl := opsCleanupLeaderLockTTLDefault
  334. // Prefer Redis leader lock when available, but avoid stampeding the DB when Redis is flaky by
  335. // falling back to a DB advisory lock.
  336. if s.redisClient != nil {
  337. ok, err := s.redisClient.SetNX(ctx, key, s.instanceID, ttl).Result()
  338. if err == nil {
  339. if !ok {
  340. return nil, false
  341. }
  342. return func() {
  343. _, _ = opsCleanupReleaseScript.Run(ctx, s.redisClient, []string{key}, s.instanceID).Result()
  344. }, true
  345. }
  346. // Redis error: fall back to DB advisory lock.
  347. s.warnNoRedisOnce.Do(func() {
  348. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] leader lock SetNX failed; falling back to DB advisory lock: %v", err)
  349. })
  350. } else {
  351. s.warnNoRedisOnce.Do(func() {
  352. logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] redis not configured; using DB advisory lock")
  353. })
  354. }
  355. release, ok := tryAcquireDBAdvisoryLock(ctx, s.db, hashAdvisoryLockID(key))
  356. if !ok {
  357. return nil, false
  358. }
  359. return release, true
  360. }
  361. func (s *OpsCleanupService) recordHeartbeatSuccess(runAt time.Time, duration time.Duration, counts opsCleanupDeletedCounts) {
  362. if s == nil || s.opsRepo == nil {
  363. return
  364. }
  365. now := time.Now().UTC()
  366. durMs := duration.Milliseconds()
  367. result := truncateString(counts.String(), 2048)
  368. ctx, cancel := context.WithTimeout(context.Background(), opsCleanupHeartbeatTimeout)
  369. defer cancel()
  370. _ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
  371. JobName: opsCleanupJobName,
  372. LastRunAt: &runAt,
  373. LastSuccessAt: &now,
  374. LastDurationMs: &durMs,
  375. LastResult: &result,
  376. })
  377. }
  378. func (s *OpsCleanupService) recordHeartbeatError(runAt time.Time, duration time.Duration, err error) {
  379. if s == nil || s.opsRepo == nil || err == nil {
  380. return
  381. }
  382. now := time.Now().UTC()
  383. durMs := duration.Milliseconds()
  384. msg := truncateString(err.Error(), 2048)
  385. ctx, cancel := context.WithTimeout(context.Background(), opsCleanupHeartbeatTimeout)
  386. defer cancel()
  387. _ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
  388. JobName: opsCleanupJobName,
  389. LastRunAt: &runAt,
  390. LastErrorAt: &now,
  391. LastError: &msg,
  392. LastDurationMs: &durMs,
  393. })
  394. }