247 lines
6.3 KiB
Go
247 lines
6.3 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"go.uber.org/zap"
|
|
|
|
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
)
|
|
|
|
const (
|
|
defaultAIPointUsageCleanupBatch = 5000
|
|
defaultAIPointUsageCleanupBatches = 100
|
|
)
|
|
|
|
type AIPointUsageCleanupWorker struct {
|
|
pool *pgxpool.Pool
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func NewAIPointUsageCleanupWorker(pool *pgxpool.Pool, logger *zap.Logger) *AIPointUsageCleanupWorker {
|
|
return &AIPointUsageCleanupWorker{pool: pool, logger: logger}
|
|
}
|
|
|
|
func (w *AIPointUsageCleanupWorker) RunOnce(ctx context.Context, run JobRunContext) (map[string]any, error) {
|
|
if w == nil || w.pool == nil {
|
|
return map[string]any{"skipped": true, "reason": "worker_not_ready"}, nil
|
|
}
|
|
|
|
startedAt := time.Now()
|
|
batchSize := AsInt(run.Config, "batch_size", defaultAIPointUsageCleanupBatch)
|
|
if run.Job != nil && run.Job.BatchSize != nil {
|
|
batchSize = *run.Job.BatchSize
|
|
}
|
|
if batchSize <= 0 {
|
|
batchSize = defaultAIPointUsageCleanupBatch
|
|
}
|
|
maxBatches := AsInt(run.Config, "max_batches_per_run", defaultAIPointUsageCleanupBatches)
|
|
if maxBatches <= 0 {
|
|
maxBatches = defaultAIPointUsageCleanupBatches
|
|
}
|
|
statementTimeout := AsDuration(run.Config, "statement_timeout", 5*time.Second)
|
|
dryRun := run.DryRun
|
|
now := time.Now().UTC()
|
|
|
|
stats := map[string]any{
|
|
"batch_size": batchSize,
|
|
"max_batches_per_run": maxBatches,
|
|
"dry_run": dryRun,
|
|
}
|
|
|
|
conn, err := w.pool.Acquire(ctx)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("acquire tenant connection: %w", err)
|
|
}
|
|
defer conn.Release()
|
|
|
|
if statementTimeout > 0 {
|
|
if _, err := conn.Exec(ctx, `SELECT set_config('statement_timeout', $1, false)`, fmt.Sprintf("%dms", statementTimeout.Milliseconds())); err != nil {
|
|
return stats, fmt.Errorf("set statement timeout: %w", err)
|
|
}
|
|
defer conn.Exec(context.Background(), `RESET statement_timeout`)
|
|
}
|
|
|
|
tenantIDs, err := w.loadTenantIDs(ctx, conn)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("load ai point usage tenants: %w", err)
|
|
}
|
|
|
|
planRepo := tenantrepo.NewTenantPlanRepository(conn)
|
|
totalDeleted := int64(0)
|
|
totalCandidateProbe := int64(0)
|
|
tenantCount := 0
|
|
cleanedTenantCount := 0
|
|
skippedNoPlan := 0
|
|
batches := 0
|
|
batchLimitReached := false
|
|
|
|
for _, tenantID := range tenantIDs {
|
|
if ctx.Err() != nil {
|
|
return stats, ErrorFromContext(ctx)
|
|
}
|
|
if !dryRun && batches >= maxBatches {
|
|
batchLimitReached = true
|
|
break
|
|
}
|
|
|
|
tenantCount++
|
|
access, err := planRepo.GetTenantPlanAccess(ctx, tenantID, now)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("load tenant %d plan access: %w", tenantID, err)
|
|
}
|
|
cutoff, ok := aiPointUsageCleanupCutoff(access, now)
|
|
if !ok {
|
|
skippedNoPlan++
|
|
continue
|
|
}
|
|
|
|
if dryRun {
|
|
candidates, err := w.probeTenant(ctx, conn, tenantID, cutoff, batchSize)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("probe tenant %d ai point usage: %w", tenantID, err)
|
|
}
|
|
if candidates > 0 {
|
|
cleanedTenantCount++
|
|
totalCandidateProbe += candidates
|
|
}
|
|
continue
|
|
}
|
|
|
|
deleted, tenantBatches, err := w.deleteTenant(ctx, conn, tenantID, cutoff, batchSize, maxBatches-batches)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("delete tenant %d ai point usage: %w", tenantID, err)
|
|
}
|
|
if deleted > 0 {
|
|
cleanedTenantCount++
|
|
totalDeleted += deleted
|
|
batches += tenantBatches
|
|
}
|
|
}
|
|
|
|
stats["tenant_count"] = tenantCount
|
|
stats["cleaned_tenant_count"] = cleanedTenantCount
|
|
stats["skipped_no_plan_count"] = skippedNoPlan
|
|
stats["deleted_count"] = totalDeleted
|
|
stats["batch_count"] = batches
|
|
stats["batch_limit_reached"] = batchLimitReached
|
|
if dryRun {
|
|
stats["candidate_probe_count"] = totalCandidateProbe
|
|
}
|
|
stats["duration_ms"] = time.Since(startedAt).Milliseconds()
|
|
return stats, nil
|
|
}
|
|
|
|
func (w *AIPointUsageCleanupWorker) loadTenantIDs(ctx context.Context, conn *pgxpool.Conn) ([]int64, error) {
|
|
rows, err := conn.Query(ctx, `
|
|
SELECT DISTINCT tenant_id
|
|
FROM ai_point_usage_logs
|
|
ORDER BY tenant_id
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tenantIDs []int64
|
|
for rows.Next() {
|
|
var tenantID int64
|
|
if err := rows.Scan(&tenantID); err != nil {
|
|
return nil, err
|
|
}
|
|
tenantIDs = append(tenantIDs, tenantID)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return tenantIDs, nil
|
|
}
|
|
|
|
func (w *AIPointUsageCleanupWorker) probeTenant(ctx context.Context, conn *pgxpool.Conn, tenantID int64, cutoff time.Time, limit int) (int64, error) {
|
|
if limit <= 0 {
|
|
limit = defaultAIPointUsageCleanupBatch
|
|
}
|
|
var count int64
|
|
if err := conn.QueryRow(ctx, aiPointUsageCleanupProbeSQL(), tenantID, cutoff.UTC(), limit).Scan(&count); err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (w *AIPointUsageCleanupWorker) deleteTenant(ctx context.Context, conn *pgxpool.Conn, tenantID int64, cutoff time.Time, batchSize, maxBatches int) (int64, int, error) {
|
|
total := int64(0)
|
|
batches := 0
|
|
for batches < maxBatches {
|
|
tag, err := conn.Exec(ctx, aiPointUsageCleanupDeleteSQL(), tenantID, cutoff.UTC(), batchSize)
|
|
if err != nil {
|
|
return total, batches, err
|
|
}
|
|
affected := tag.RowsAffected()
|
|
if affected == 0 {
|
|
break
|
|
}
|
|
total += affected
|
|
batches++
|
|
if affected < int64(batchSize) {
|
|
break
|
|
}
|
|
}
|
|
return total, batches, nil
|
|
}
|
|
|
|
func aiPointUsageCleanupCutoff(access *tenantrepo.TenantPlanAccess, now time.Time) (time.Time, bool) {
|
|
if access == nil {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
referenceTime := now.UTC()
|
|
if !access.EndAt.IsZero() && !access.EndAt.After(referenceTime) && access.EndAt.After(access.StartAt) {
|
|
referenceTime = access.EndAt.Add(-time.Nanosecond)
|
|
}
|
|
if referenceTime.Before(access.StartAt) {
|
|
referenceTime = access.StartAt
|
|
}
|
|
|
|
windowStart, _, _ := access.AIPointsWindow(referenceTime)
|
|
if windowStart.IsZero() {
|
|
return time.Time{}, false
|
|
}
|
|
return windowStart.UTC(), true
|
|
}
|
|
|
|
func aiPointUsageCleanupProbeSQL() string {
|
|
return `
|
|
SELECT COUNT(*)::bigint
|
|
FROM (
|
|
SELECT id
|
|
FROM ai_point_usage_logs
|
|
WHERE tenant_id = $1
|
|
AND created_at < $2
|
|
AND status IN ('completed', 'refunded', 'failed')
|
|
ORDER BY created_at ASC, id ASC
|
|
LIMIT $3
|
|
) candidates
|
|
`
|
|
}
|
|
|
|
func aiPointUsageCleanupDeleteSQL() string {
|
|
return `
|
|
WITH doomed AS (
|
|
SELECT id
|
|
FROM ai_point_usage_logs
|
|
WHERE tenant_id = $1
|
|
AND created_at < $2
|
|
AND status IN ('completed', 'refunded', 'failed')
|
|
ORDER BY created_at ASC, id ASC
|
|
LIMIT $3
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
DELETE FROM ai_point_usage_logs l
|
|
USING doomed
|
|
WHERE l.id = doomed.id
|
|
`
|
|
}
|