diff --git a/cmd/cleanup-released-stock-allocations/main.go b/cmd/cleanup-released-stock-allocations/main.go new file mode 100644 index 00000000..d86bc7ca --- /dev/null +++ b/cmd/cleanup-released-stock-allocations/main.go @@ -0,0 +1,304 @@ +// Command cleanup-released-stock-allocations menghapus baris stock_allocations +// dengan status='RELEASED' yang sudah lewat masa retensi. +// +// Baris RELEASED muncul dari operasi Rollback / Reflow FIFO v2. Closing reports +// dan flow bisnis hanya membaca status='ACTIVE', sehingga RELEASED rows aman +// dihapus setelah masa retensi tertentu (default 90 hari). +// +// Cara pakai: +// +// go run ./cmd/cleanup-released-stock-allocations/ # dry-run +// go run ./cmd/cleanup-released-stock-allocations/ -apply # apply (90 hari) +// go run ./cmd/cleanup-released-stock-allocations/ -apply -retention-days=30 +// go run ./cmd/cleanup-released-stock-allocations/ -apply -batch-size=5000 +// go run ./cmd/cleanup-released-stock-allocations/ -apply -skip-vacuum +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + outputTable = "table" + outputJSON = "json" +) + +type options struct { + Apply bool + Output string + DBSSLMode string + RetentionDays int + BatchSize int + SkipVacuum bool +} + +type sizeStat struct { + TableSize string `json:"table_size" gorm:"column:table_size"` + TotalSize string `json:"total_size" gorm:"column:total_size"` + RowCount int64 `json:"row_count" gorm:"column:row_count"` +} + +type runSummary struct { + Mode string `json:"mode"` + RetentionDays int `json:"retention_days"` + CutoffTime string `json:"cutoff_time"` + BatchSize int `json:"batch_size"` + CandidateRows int64 `json:"candidate_rows"` + DeletedRows int64 `json:"deleted_rows,omitempty"` + BatchesExecuted int `json:"batches_executed,omitempty"` + BeforeSize sizeStat `json:"before_size"` + AfterSize sizeStat `json:"after_size,omitempty"` + DurationSeconds float64 `json:"duration_seconds"` + VacuumExecuted bool `json:"vacuum_executed"` + OverallStatus string `json:"overall_status"` +} + +func main() { + opts, err := parseFlags() + if err != nil { + log.Fatalf("invalid flags: %v", err) + } + + if opts.DBSSLMode != "" { + config.DBSSLMode = opts.DBSSLMode + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + start := time.Now() + cutoff := time.Now().Add(-time.Duration(opts.RetentionDays) * 24 * time.Hour) + + summary := runSummary{ + RetentionDays: opts.RetentionDays, + CutoffTime: cutoff.UTC().Format(time.RFC3339), + BatchSize: opts.BatchSize, + OverallStatus: "PASS", + } + + // Ambil ukuran tabel sebelum cleanup + before, err := fetchSizeStat(ctx, db) + if err != nil { + log.Fatalf("failed to fetch initial size: %v", err) + } + summary.BeforeSize = before + + // Hitung kandidat row + candidate, err := countCandidates(ctx, db, cutoff) + if err != nil { + log.Fatalf("failed to count candidates: %v", err) + } + summary.CandidateRows = candidate + + if candidate == 0 { + summary.Mode = modeLabel(opts.Apply) + summary.DurationSeconds = time.Since(start).Seconds() + fmt.Printf("No RELEASED rows older than %d days found. Nothing to do.\n", opts.RetentionDays) + render(opts.Output, summary) + return + } + + if !opts.Apply { + summary.Mode = "DRY_RUN" + summary.DurationSeconds = time.Since(start).Seconds() + render(opts.Output, summary) + fmt.Println() + fmt.Println("Re-run with -apply to actually delete the rows above.") + return + } + + summary.Mode = "APPLY" + + deleted, batches, err := applyCleanup(ctx, db, cutoff, opts.BatchSize) + summary.DeletedRows = deleted + summary.BatchesExecuted = batches + if err != nil { + summary.OverallStatus = "FAIL" + render(opts.Output, summary) + log.Fatalf("apply failed after %d batches (%d rows deleted): %v", batches, deleted, err) + } + + // VACUUM ANALYZE supaya space benar-benar dibebaskan ke OS + if !opts.SkipVacuum { + if err := runVacuum(ctx, db); err != nil { + // VACUUM gagal jangan-mengaborkan, log saja + log.Printf("WARN: VACUUM ANALYZE gagal: %v", err) + } else { + summary.VacuumExecuted = true + } + } + + after, err := fetchSizeStat(ctx, db) + if err != nil { + log.Printf("WARN: gagal ambil ukuran tabel setelah cleanup: %v", err) + } else { + summary.AfterSize = after + } + + summary.DurationSeconds = time.Since(start).Seconds() + render(opts.Output, summary) +} + +func parseFlags() (*options, error) { + var opts options + flag.BoolVar(&opts.Apply, "apply", false, "Apply deletion (omit for dry-run)") + flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json") + flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Database sslmode override") + flag.IntVar(&opts.RetentionDays, "retention-days", 90, "Keep RELEASED rows newer than N days") + flag.IntVar(&opts.BatchSize, "batch-size", 10000, "Rows deleted per transaction") + flag.BoolVar(&opts.SkipVacuum, "skip-vacuum", false, "Skip VACUUM ANALYZE after cleanup") + flag.Parse() + + opts.Output = strings.ToLower(strings.TrimSpace(opts.Output)) + opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode) + + if opts.Output == "" { + opts.Output = outputTable + } + if opts.Output != outputTable && opts.Output != outputJSON { + return nil, fmt.Errorf("unsupported --output=%s", opts.Output) + } + if opts.RetentionDays < 0 { + return nil, fmt.Errorf("retention-days must be >= 0, got %d", opts.RetentionDays) + } + if opts.BatchSize <= 0 { + return nil, fmt.Errorf("batch-size must be > 0, got %d", opts.BatchSize) + } + + return &opts, nil +} + +func countCandidates(ctx context.Context, db *gorm.DB, cutoff time.Time) (int64, error) { + var count int64 + err := db.WithContext(ctx). + Table("stock_allocations"). + Where("status = ?", entities.StockAllocationStatusReleased). + Where("released_at IS NOT NULL AND released_at < ?", cutoff). + Count(&count).Error + return count, err +} + +func applyCleanup(ctx context.Context, db *gorm.DB, cutoff time.Time, batchSize int) (int64, int, error) { + var totalDeleted int64 + batches := 0 + + for { + var affected int64 + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Pakai CTE supaya LIMIT bisa dipakai bersama DELETE di PostgreSQL. + // `released_at IS NOT NULL` defensif — rows lama dari migrasi mungkin + // NULL meski status=RELEASED. + res := tx.Exec(` +DELETE FROM stock_allocations +WHERE id IN ( + SELECT id FROM stock_allocations + WHERE status = ? + AND released_at IS NOT NULL + AND released_at < ? + ORDER BY id ASC + LIMIT ? +) +`, entities.StockAllocationStatusReleased, cutoff, batchSize) + if res.Error != nil { + return res.Error + } + affected = res.RowsAffected + return nil + }) + if err != nil { + return totalDeleted, batches, err + } + + if affected == 0 { + break + } + + totalDeleted += affected + batches++ + log.Printf("batch %d: deleted %d rows (running total: %d)", batches, affected, totalDeleted) + + if affected < int64(batchSize) { + break + } + } + + return totalDeleted, batches, nil +} + +func runVacuum(ctx context.Context, db *gorm.DB) error { + // VACUUM tidak bisa di-jalankan dalam transaksi. + // gorm SkipDefaultTransaction sudah true, tapi tetap aman menggunakan raw DB. + sqlDB, err := db.DB() + if err != nil { + return err + } + _, err = sqlDB.ExecContext(ctx, "VACUUM ANALYZE stock_allocations") + return err +} + +func fetchSizeStat(ctx context.Context, db *gorm.DB) (sizeStat, error) { + var stat sizeStat + err := db.WithContext(ctx).Raw(` +SELECT + pg_size_pretty(pg_relation_size('stock_allocations')) AS table_size, + pg_size_pretty(pg_total_relation_size('stock_allocations')) AS total_size, + (SELECT COUNT(*) FROM stock_allocations)::bigint AS row_count +`).Scan(&stat).Error + return stat, err +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY_RUN" +} + +func render(mode string, summary runSummary) { + if mode == outputJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(summary) + return + } + + fmt.Printf("=== Cleanup RELEASED stock_allocations ===\n") + fmt.Printf("Mode : %s\n", summary.Mode) + fmt.Printf("Retention days : %d (cutoff < %s)\n", summary.RetentionDays, summary.CutoffTime) + fmt.Printf("Batch size : %d\n", summary.BatchSize) + fmt.Printf("Candidate rows : %d\n", summary.CandidateRows) + + fmt.Printf("\n--- Before ---\n") + fmt.Printf("Total rows : %d\n", summary.BeforeSize.RowCount) + fmt.Printf("Table size : %s\n", summary.BeforeSize.TableSize) + fmt.Printf("Total size (idx) : %s\n", summary.BeforeSize.TotalSize) + + if summary.Mode == "APPLY" { + fmt.Printf("\n--- Apply ---\n") + fmt.Printf("Deleted rows : %d\n", summary.DeletedRows) + fmt.Printf("Batches executed : %d\n", summary.BatchesExecuted) + fmt.Printf("VACUUM executed : %v\n", summary.VacuumExecuted) + + if summary.AfterSize.RowCount > 0 || summary.AfterSize.TableSize != "" { + fmt.Printf("\n--- After ---\n") + fmt.Printf("Total rows : %d\n", summary.AfterSize.RowCount) + fmt.Printf("Table size : %s\n", summary.AfterSize.TableSize) + fmt.Printf("Total size (idx) : %s\n", summary.AfterSize.TotalSize) + } + } + + fmt.Printf("\nDuration : %.2fs\n", summary.DurationSeconds) + fmt.Printf("Overall status : %s\n", summary.OverallStatus) +}