// 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) }