package service import ( "context" "log/slog" "time" "hyapp/pkg/logx" ) // outboxRetentionBatchPause 是同一轮内两批 DELETE 之间的让路间隔,避免清理积压时长时间占用行锁和 IO。 const outboxRetentionBatchPause = 100 * time.Millisecond // OutboxRetentionOptions 控制 delivered 历史事件的定期清理循环。 type OutboxRetentionOptions struct { // ScanInterval 是清空一轮可删事件后到下一轮扫描的间隔;非正数会被归一到安全默认值。 ScanInterval time.Duration // MaxAge 是 delivered 事件按 updated_at_ms 计算的保留时长。 MaxAge time.Duration // BatchSize 是单条 DELETE 的行数上限。 BatchSize int } func defaultOutboxRetentionOptions() OutboxRetentionOptions { // 默认值和 config.defaultOutboxWorkerConfig 保持一致,防止手动构造绕过配置归一化后误删新事件。 return OutboxRetentionOptions{ ScanInterval: 5 * time.Minute, MaxAge: 30 * 24 * time.Hour, BatchSize: 500, } } func normalizeOutboxRetentionOptions(options OutboxRetentionOptions) OutboxRetentionOptions { defaults := defaultOutboxRetentionOptions() if options.ScanInterval <= 0 { options.ScanInterval = defaults.ScanInterval } if options.MaxAge <= 0 { options.MaxAge = defaults.MaxAge } if options.BatchSize <= 0 { options.BatchSize = defaults.BatchSize } return options } // RunOutboxRetentionWorker 周期性删除 delivered 且超过保留窗口的 room_outbox 历史事件。 // 它只做数据保留治理,不参与投递;多节点同时运行时 DELETE 靠 MySQL 行锁串行,语义幂等。 func (s *Service) RunOutboxRetentionWorker(ctx context.Context, options OutboxRetentionOptions) { if s == nil || s.repository == nil { return } options = normalizeOutboxRetentionOptions(options) // 启动先执行一轮,让积压清理不用等第一个 scan interval。 s.runOutboxRetentionRound(ctx, options) ticker := time.NewTicker(options.ScanInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.runOutboxRetentionRound(ctx, options) } } } func (s *Service) runOutboxRetentionRound(ctx context.Context, options OutboxRetentionOptions) { if _, err := s.ProcessOutboxRetention(ctx, options); err != nil && ctx.Err() == nil { // 单轮失败只影响清理进度,下一轮扫描会继续;不升级为 Error 避免和投递故障混在告警里。 logx.Warn(ctx, "outbox_retention_failed", slog.String("error", err.Error())) } } // ProcessOutboxRetention 执行一轮清理:按批删除到没有可删事件为止,返回本轮总删除行数。 func (s *Service) ProcessOutboxRetention(ctx context.Context, options OutboxRetentionOptions) (int64, error) { options = normalizeOutboxRetentionOptions(options) cutoffMS := s.clock.Now().UTC().Add(-options.MaxAge).UnixMilli() var total int64 for { deleted, err := s.repository.DeleteDeliveredOutboxBefore(ctx, cutoffMS, options.BatchSize) total += deleted if err != nil { return total, err } if deleted < int64(options.BatchSize) { break } // 整批删满说明还有积压,稍作停顿继续,直到删空;退出信号优先于继续清理。 select { case <-ctx.Done(): return total, ctx.Err() case <-time.After(outboxRetentionBatchPause): } } if total > 0 { logx.Info(ctx, "outbox_retention_purged", slog.Int64("rows_deleted", total), slog.Int64("cutoff_ms", cutoffMS), slog.String("node_id", s.nodeID), ) } return total, nil }