package taskcenter import ( "context" "errors" "net/http" "strconv" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( archiveGCControlID = uint8(1) defaultArchiveGCBatch = 200 minArchiveGCBatch = 200 maxArchiveGCBatch = 500 minimumArchiveGCAge = 30 * 24 * time.Hour archiveGCClaimIndex = "idx_task_center_archive_claim_create" ) // ArchiveGCStatus 返回唯一控制行;总开关关闭时不要求迁移已经执行,便于新版本先安全上线。 func (s *Service) ArchiveGCStatus(ctx context.Context) (*ArchiveGCStatusView, error) { row, err := s.loadArchiveGCRun(ctx) if err != nil { // 总开关关闭的新环境可能尚未执行 060;此时返回安全的 IDLE。若控制行已经存在, // 即使关闭开关也展示真实 RUNNING/PAUSED 状态,避免运维误以为历史 run 不存在。 if !s.cfg.TaskCenter.Archive.GC.Enabled { return &ArchiveGCStatusView{Enabled: false, Status: ArchiveGCStatusIdle, BatchSize: defaultArchiveGCBatch}, nil } return nil, err } view := s.archiveGCStatusView(row) return &view, nil } // ArchiveGCDryRun 对索引支持的候选范围做有界预检,并把 cutoff 固定进控制行。 // 这里不做 COUNT(*),因为在大表上为了展示总数触发长扫描本身就违背清理降载目标。 func (s *Service) ArchiveGCDryRun(ctx context.Context, request ArchiveGCDryRunRequest) (*ArchiveGCDryRunResponse, error) { if err := s.requireArchiveGCReady(ctx); err != nil { return nil, err } current, err := s.loadArchiveGCRun(ctx) if err != nil { return nil, err } if current.Status == ArchiveGCStatusRunning { return nil, NewAppError(http.StatusConflict, "archive_gc_running", "pause the running archive GC before preparing another cutoff") } cutoff := time.UnixMilli(request.CutoffTime) if request.CutoffTime <= 0 || cutoff.After(time.Now().Add(-minimumArchiveGCAge)) { return nil, NewAppError(http.StatusBadRequest, "archive_gc_cutoff_too_recent", "cutoffTime must retain at least 30 days of hot data") } batchSize, err := normalizeArchiveGCBatch(request.BatchSize) if err != nil { return nil, err } preview, err := s.findArchiveGCCandidates(ctx, cutoff, batchSize+1) if err != nil { return nil, err } hasMore := len(preview) > batchSize if hasMore { preview = preview[:batchSize] } runSnowflake, err := utils.NextID() if err != nil { return nil, err } runID := strconv.FormatInt(runSnowflake, 10) if runID == "" { return nil, NewAppError(http.StatusInternalServerError, "archive_gc_run_id_failed", "cannot create archive GC run id") } now := time.Now() var updated model.TaskCenterArchiveGCRun err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.TaskCenterArchiveGCRun if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil { return archiveGCMigrationError(err) } if locked.Status == ArchiveGCStatusRunning { return NewAppError(http.StatusConflict, "archive_gc_running", "pause the running archive GC before preparing another cutoff") } updates := map[string]any{ "run_id": runID, "status": ArchiveGCStatusDryRun, "cutoff_time": cutoff, "batch_size": batchSize, "scanned_rows": 0, "eligible_rows": 0, "protected_rows": 0, "deleted_rows": 0, "recovery_object_count": 0, "cursor_create_time": nil, "cursor_id": 0, "lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now, } if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(updates).Error; err != nil { return err } return tx.Where("id = ?", archiveGCControlID).First(&updated).Error }) if err != nil { return nil, err } view := s.archiveGCStatusView(updated) return &ArchiveGCDryRunResponse{ArchiveGCStatusView: view, PreviewRows: len(preview), HasMore: hasMore}, nil } // StartArchiveGC 只允许启动刚刚预检过的控制行,确保真实删除沿用完全相同的固定 cutoff。 func (s *Service) StartArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) { if err := s.requireArchiveGCReady(ctx); err != nil { return nil, err } return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusDryRun}, ArchiveGCStatusRunning) } func (s *Service) PauseArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) { if err := s.requireArchiveGCEnabled(); err != nil { return nil, err } return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusRunning}, ArchiveGCStatusPaused) } func (s *Service) ResumeArchiveGC(ctx context.Context) (*ArchiveGCStatusView, error) { if err := s.requireArchiveGCReady(ctx); err != nil { return nil, err } return s.transitionArchiveGC(ctx, []string{ArchiveGCStatusPaused, ArchiveGCStatusFailed}, ArchiveGCStatusRunning) } func (s *Service) transitionArchiveGC(ctx context.Context, from []string, to string) (*ArchiveGCStatusView, error) { var updated model.TaskCenterArchiveGCRun err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.TaskCenterArchiveGCRun if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", archiveGCControlID).First(&locked).Error; err != nil { return archiveGCMigrationError(err) } allowed := false for _, status := range from { if locked.Status == status { allowed = true break } } if !allowed { return NewAppError(http.StatusConflict, "archive_gc_state_conflict", "archive GC state does not allow this operation") } if locked.CutoffTime == nil { return NewAppError(http.StatusConflict, "archive_gc_cutoff_missing", "run dry-run before starting archive GC") } now := time.Now() if err := tx.Model(&model.TaskCenterArchiveGCRun{}).Where("id = ?", archiveGCControlID).Updates(map[string]any{ "status": to, "lease_owner": "", "lease_until": nil, "last_error": "", "update_time": now, }).Error; err != nil { return err } return tx.Where("id = ?", archiveGCControlID).First(&updated).Error }) if err != nil { return nil, err } view := s.archiveGCStatusView(updated) return &view, nil } func (s *Service) loadArchiveGCRun(ctx context.Context) (model.TaskCenterArchiveGCRun, error) { var row model.TaskCenterArchiveGCRun if err := s.db.WithContext(ctx).Where("id = ?", archiveGCControlID).First(&row).Error; err != nil { return row, archiveGCMigrationError(err) } return row, nil } func (s *Service) requireArchiveGCReady(ctx context.Context) error { if err := s.requireArchiveGCEnabled(); err != nil { return err } archive := s.cfg.TaskCenter.Archive if strings.TrimSpace(archive.Bucket) == "" || strings.TrimSpace(archive.Region) == "" || strings.TrimSpace(archive.SecretID) == "" || strings.TrimSpace(archive.SecretKey) == "" { return NewAppError(http.StatusServiceUnavailable, "archive_gc_cos_not_configured", "task center archive COS is not configured") } return s.requireArchiveGCClaimIndex(ctx) } func (s *Service) requireArchiveGCEnabled() error { if !s.cfg.TaskCenter.Archive.GC.Enabled { return NewAppError(http.StatusServiceUnavailable, "archive_gc_disabled", "task center archive GC is disabled") } return nil } func (s *Service) requireArchiveGCClaimIndex(ctx context.Context) error { type indexColumn struct { ColumnName string `gorm:"column:column_name"` Seq int `gorm:"column:seq_in_index"` SubPart *int64 `gorm:"column:sub_part"` IndexType string `gorm:"column:index_type"` IsVisible string `gorm:"column:is_visible"` } var columns []indexColumn if err := s.db.WithContext(ctx).Raw(` -- MySQL 会把 information_schema 列标签原样返回为大写;显式使用与 GORM tag -- 一致的小写别名,避免查询有三行但结构体字段全为零值而误判索引缺失。 SELECT COLUMN_NAME AS column_name, SEQ_IN_INDEX AS seq_in_index, SUB_PART AS sub_part, INDEX_TYPE AS index_type, IS_VISIBLE AS is_visible FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'task_center_event_archive_outbox' AND INDEX_NAME = ? ORDER BY SEQ_IN_INDEX`, archiveGCClaimIndex).Scan(&columns).Error; err != nil { return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_check_failed", "cannot verify the archive GC claim index") } required := [...]string{"status", "create_time", "id"} if len(columns) != len(required) { return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_required", "migration 056 claim index must be online before archive GC") } for index, expected := range required { column := columns[index] if column.Seq != index+1 || !strings.EqualFold(column.ColumnName, expected) || column.SubPart != nil || !strings.EqualFold(column.IndexType, "BTREE") || !strings.EqualFold(column.IsVisible, "YES") { return NewAppError(http.StatusServiceUnavailable, "archive_gc_index_required", "migration 056 claim index must be online before archive GC") } } return nil } func normalizeArchiveGCBatch(value int) (int, error) { if value == 0 { return defaultArchiveGCBatch, nil } if value < minArchiveGCBatch || value > maxArchiveGCBatch { return 0, NewAppError(http.StatusBadRequest, "archive_gc_batch_invalid", "batchSize must be between 200 and 500") } return value, nil } func archiveGCMigrationError(err error) error { if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusServiceUnavailable, "archive_gc_migration_required", "migration 060 archive GC control row is required") } return err } func (s *Service) archiveGCStatusView(row model.TaskCenterArchiveGCRun) ArchiveGCStatusView { view := ArchiveGCStatusView{ Enabled: s.cfg.TaskCenter.Archive.GC.Enabled, RunID: row.RunID, Status: row.Status, BatchSize: row.BatchSize, ScannedRows: row.ScannedRows, EligibleRows: row.EligibleRows, ProtectedRows: row.ProtectedRows, DeletedRows: row.DeletedRows, RecoveryObjectCount: row.RecoveryObjectCount, CursorID: row.CursorID, LastError: row.LastError, CreateTime: row.CreateTime.UnixMilli(), UpdateTime: row.UpdateTime.UnixMilli(), } if row.CutoffTime != nil { view.CutoffTime = row.CutoffTime.UnixMilli() } if row.CursorCreateTime != nil { view.CursorCreateTime = row.CursorCreateTime.UnixMilli() } if row.LeaseUntil != nil { view.LeaseUntil = row.LeaseUntil.UnixMilli() } return view }