yumi-golang/internal/service/taskcenter/archive_gc_control.go
2026-07-18 19:38:00 +08:00

265 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}