259 lines
9.9 KiB
Go
259 lines
9.9 KiB
Go
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(`
|
||
SELECT COLUMN_NAME, SEQ_IN_INDEX, SUB_PART, INDEX_TYPE, 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
|
||
}
|