mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
add command for cleanup relesed stock allocations
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user