Prometheus: runs, duration, rows_deleted, config_changes; инкремент при CRUD и Execute. Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
5.1 KiB
Go
196 lines
5.1 KiB
Go
package maintenance
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"strings"
|
|
"time"
|
|
|
|
"evobgp/internal/observability"
|
|
"evobgp/internal/pgmonitor"
|
|
"evobgp/internal/store"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// PolicyExecutor runs maintenance policies against PostgreSQL.
|
|
type PolicyExecutor struct {
|
|
Store store.Backend
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
// Execute runs cleanup and/or vacuum steps for a policy.
|
|
func (e *PolicyExecutor) Execute(ctx context.Context, policy *store.MaintenancePolicy, dryRun bool) (map[string]any, error) {
|
|
start := time.Now()
|
|
if policy == nil {
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
action := policyAction(policy)
|
|
record := func(status string, detail map[string]any) {
|
|
observability.RecordMaintenancePolicyRun(policy.ID, action, status, dryRun, time.Since(start), rowsDeletedFromDetail(detail))
|
|
}
|
|
|
|
if e == nil || e.Pool == nil {
|
|
record("failed", nil)
|
|
return nil, fmt.Errorf("maintenance: postgres not configured")
|
|
}
|
|
if err := ValidateTableName(policy.TableName); err != nil {
|
|
record("failed", nil)
|
|
return nil, err
|
|
}
|
|
if err := ValidateCondition(policy.Condition); err != nil {
|
|
record("failed", nil)
|
|
return nil, err
|
|
}
|
|
if !store.ValidVacuumStrategy(policy.VacuumStrategy) {
|
|
record("failed", nil)
|
|
return nil, store.ErrInvalidInput
|
|
}
|
|
|
|
detail := map[string]any{
|
|
"policy_id": policy.ID,
|
|
"table": policy.TableName,
|
|
"dry_run": dryRun,
|
|
}
|
|
|
|
if policy.RetentionPeriodSec != nil || policy.MaxRows != nil {
|
|
cleanupDetail, err := e.runCleanup(ctx, policy, dryRun)
|
|
for k, v := range cleanupDetail {
|
|
detail[k] = v
|
|
}
|
|
if err != nil {
|
|
record("failed", detail)
|
|
return detail, err
|
|
}
|
|
}
|
|
|
|
if policy.VacuumStrategy != store.VacuumStrategyNone {
|
|
kind := vacuumKind(policy.VacuumStrategy)
|
|
vacDetail, err := pgmonitor.ExecMaintenance(ctx, e.Pool, kind, policy.TableName, dryRun)
|
|
if vacDetail != nil {
|
|
detail["vacuum"] = vacDetail
|
|
}
|
|
if err != nil {
|
|
record("failed", detail)
|
|
return detail, err
|
|
}
|
|
}
|
|
|
|
if !dryRun && e.Store != nil {
|
|
_ = e.Store.TouchMaintenancePolicyRun(policy.ID, "succeeded", "")
|
|
}
|
|
record("succeeded", detail)
|
|
return detail, nil
|
|
}
|
|
|
|
func policyAction(p *store.MaintenancePolicy) string {
|
|
if p == nil {
|
|
return "run"
|
|
}
|
|
if p.RetentionPeriodSec != nil || p.MaxRows != nil {
|
|
if p.VacuumStrategy != store.VacuumStrategyNone {
|
|
return "cleanup_vacuum"
|
|
}
|
|
return "cleanup"
|
|
}
|
|
if p.VacuumStrategy != store.VacuumStrategyNone {
|
|
return p.VacuumStrategy
|
|
}
|
|
return "run"
|
|
}
|
|
|
|
func rowsDeletedFromDetail(detail map[string]any) int64 {
|
|
if detail == nil {
|
|
return 0
|
|
}
|
|
switch v := detail["deleted"].(type) {
|
|
case int64:
|
|
return v
|
|
case int:
|
|
return int64(v)
|
|
case float64:
|
|
return int64(v)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (e *PolicyExecutor) runCleanup(ctx context.Context, policy *store.MaintenancePolicy, dryRun bool) (map[string]any, error) {
|
|
detail := map[string]any{"cleanup": true}
|
|
limit := NormalizeBatchLimit(policy.MaxRows)
|
|
qualTable := pgx.Identifier{policy.TableName}.Sanitize()
|
|
cond := store.NormalizeMaintenancePolicyCondition(policy.Condition)
|
|
|
|
tx, err := e.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return detail, fmt.Errorf("maintenance: begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
lockKey := advisoryKey(policy.ID)
|
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, lockKey); err != nil {
|
|
return detail, fmt.Errorf("maintenance: advisory lock: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, fmt.Sprintf(`SET LOCAL statement_timeout = '%ds'`, DefaultStatementTimeoutSec)); err != nil {
|
|
return detail, fmt.Errorf("maintenance: statement_timeout: %w", err)
|
|
}
|
|
|
|
var args []any
|
|
where := cond
|
|
argN := 1
|
|
if policy.RetentionPeriodSec != nil && *policy.RetentionPeriodSec > 0 {
|
|
cutoff := time.Now().UTC().Add(-time.Duration(*policy.RetentionPeriodSec) * time.Second)
|
|
where = fmt.Sprintf("(%s) AND created_at < $%d", cond, argN)
|
|
args = append(args, cutoff)
|
|
argN++
|
|
}
|
|
|
|
countSQL := fmt.Sprintf(`SELECT count(*) FROM %s WHERE %s`, qualTable, where)
|
|
var wouldDelete int64
|
|
if err := tx.QueryRow(ctx, countSQL, args...).Scan(&wouldDelete); err != nil {
|
|
return detail, fmt.Errorf("maintenance: count: %w", err)
|
|
}
|
|
detail["would_delete"] = wouldDelete
|
|
if dryRun {
|
|
return detail, nil
|
|
}
|
|
|
|
deleteSQL := fmt.Sprintf(`
|
|
DELETE FROM %s WHERE ctid IN (
|
|
SELECT ctid FROM %s WHERE %s LIMIT $%d
|
|
)`, qualTable, qualTable, where, argN)
|
|
args = append(args, limit)
|
|
tag, err := tx.Exec(ctx, deleteSQL, args...)
|
|
if err != nil {
|
|
return detail, fmt.Errorf("maintenance: delete: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return detail, fmt.Errorf("maintenance: commit: %w", err)
|
|
}
|
|
detail["deleted"] = tag.RowsAffected()
|
|
return detail, nil
|
|
}
|
|
|
|
func vacuumKind(strategy string) string {
|
|
switch strings.TrimSpace(strategy) {
|
|
case store.VacuumStrategyVacuum:
|
|
return "vacuum"
|
|
case store.VacuumStrategyAnalyze:
|
|
return "analyze"
|
|
case store.VacuumStrategyVacuumAnalyze:
|
|
return "vacuum_analyze"
|
|
case store.VacuumStrategyReindex:
|
|
return "reindex"
|
|
default:
|
|
return "vacuum"
|
|
}
|
|
}
|
|
|
|
func advisoryKey(policyID string) int64 {
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte("maint:" + policyID))
|
|
return int64(h.Sum64())
|
|
}
|