cleanup.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"time"
"github.com/go-co-op/gocron/v2"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
// cleanOldCheckHistory removes check history records older than 1 year
func cleanOldCheckHistory() {
oneYearAgo := time.Now().Add(-365 * 24 * time.Hour)
log.Info().Time("cutoff", oneYearAgo).Msg("[Cleanup] Starting cleanup of check history")
var deletedCount int64
result := db.Where("created_at < ?", oneYearAgo).Delete(&CheckHistory{})
if result.Error != nil {
log.Error().Err(result.Error).Msg("[Cleanup] Failed to clean old check history")
return
}
deletedCount = result.RowsAffected
log.Info().Int64("deleted", deletedCount).Msg("[Cleanup] Successfully deleted check history records")
}
// bucketOldCheckHistory aggregates CheckHistory records older than 24 hours into hourly buckets
// Uses SQL aggregation for maximum efficiency instead of loading data into Go
func bucketOldCheckHistory() {
cutoffTime := time.Now().Add(-24 * time.Hour)
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour)
log.Info().Time("cutoff", cutoffTime).Msg("[Bucketing] Starting check history bucketing")
// Use SQL to aggregate all checks into buckets in a single query
// This is much more efficient than loading all checks into memory
// SQLite's strftime can truncate datetime to hour: strftime('%Y-%m-%d %H:00:00', datetime)
// Then convert to unix timestamp: unixepoch(strftime('%Y-%m-%d %H:00:00', created_at))
// Aggregate checks into buckets using SQL
// This single query replaces all the Go-based grouping and aggregation
var aggregatedBuckets []struct {
MonitorID uint
BucketHour int64
TotalChecks int
UpChecks int
AvgResponseTime float64
MinResponseTime int
MaxResponseTime int
}
// GORM stores datetime as text with format: "2026-01-12 05:29:47.500629789 +0000 UTC..."
// Extract date and hour (first 13 chars: "2026-01-12 05"), then convert to unix timestamp
// Use substr to get "YYYY-MM-DD HH" format, then use datetime() to parse and convert
err := db.Raw(`
SELECT
monitor_id,
CAST(unixepoch(substr(created_at, 1, 13) || ':00:00') AS INTEGER) as bucket_hour,
COUNT(*) as total_checks,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as up_checks,
AVG(CASE WHEN response_time > 0 THEN response_time ELSE NULL END) as avg_response_time,
MIN(CASE WHEN response_time > 0 THEN response_time ELSE NULL END) as min_response_time,
MAX(CASE WHEN response_time > 0 THEN response_time ELSE NULL END) as max_response_time
FROM check_histories
WHERE created_at < ? AND created_at >= ?
GROUP BY monitor_id, bucket_hour
ORDER BY monitor_id, bucket_hour
`, cutoffTime, sevenDaysAgo).Scan(&aggregatedBuckets).Error
if err != nil {
log.Error().Err(err).Msg("[Bucketing] Failed to aggregate checks into buckets")
return
}
if len(aggregatedBuckets) == 0 {
log.Info().Msg("[Bucketing] No old checks to bucket")
// Still try to delete old raw records
result := db.Where("created_at < ?", sevenDaysAgo).Delete(&CheckHistory{})
if result.Error != nil {
log.Error().Err(result.Error).Msg("[Bucketing] Failed to delete old raw records")
} else {
log.Debug().Int64("deleted", result.RowsAffected).Msg("[Bucketing] Deleted old raw records")
}
return
}
log.Info().Int("buckets", len(aggregatedBuckets)).Msg("[Bucketing] Aggregated checks into buckets")
// Batch upsert buckets in transactions (500 per transaction for better SQLite performance)
const batchSize = 500
totalBucketed := 0
for i := 0; i < len(aggregatedBuckets); i += batchSize {
end := i + batchSize
if end > len(aggregatedBuckets) {
end = len(aggregatedBuckets)
}
batch := aggregatedBuckets[i:end]
// Upsert batch in transaction using INSERT ... ON CONFLICT
err := db.Transaction(func(tx *gorm.DB) error {
for _, agg := range batch {
// Handle NULL values from SQL (MIN/MAX can be NULL if no response_time > 0)
minRT := agg.MinResponseTime
maxRT := agg.MaxResponseTime
if minRT == 0 {
minRT = 0 // Keep as 0 if NULL
}
if maxRT == 0 {
maxRT = 0 // Keep as 0 if NULL
}
// Use INSERT ... ON CONFLICT UPDATE (UPSERT) for SQLite
// This is more efficient than separate SELECT + UPDATE/INSERT
upsertSQL := `
INSERT INTO check_history_buckets
(monitor_id, bucket_hour, total_checks, up_checks, avg_response_time, min_response_time, max_response_time, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(monitor_id, bucket_hour) DO UPDATE SET
total_checks = excluded.total_checks,
up_checks = excluded.up_checks,
avg_response_time = excluded.avg_response_time,
min_response_time = excluded.min_response_time,
max_response_time = excluded.max_response_time
`
if err := tx.Exec(upsertSQL,
agg.MonitorID,
agg.BucketHour,
agg.TotalChecks,
agg.UpChecks,
agg.AvgResponseTime,
minRT,
maxRT,
time.Now(),
).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
log.Error().Err(err).Int("batch_start", i).Int("batch_end", end).
Msg("[Bucketing] Failed to upsert bucket batch")
continue
}
totalBucketed += len(batch)
}
// Delete old raw records after bucketing (keep last 7 days raw for detailed charts)
result := db.Where("created_at < ?", sevenDaysAgo).Delete(&CheckHistory{})
if result.Error != nil {
log.Error().Err(result.Error).Msg("[Bucketing] Failed to delete old raw records")
} else {
log.Debug().Int64("deleted", result.RowsAffected).Msg("[Bucketing] Deleted old raw records")
}
log.Info().Int("buckets_created", totalBucketed).Msg("[Bucketing] Completed check history bucketing")
}
var cleanupScheduler gocron.Scheduler
// startCleanupScheduler starts a background job that runs cleanup daily at midnight using gocron
func startCleanupScheduler() {
// Create a new scheduler for cleanup jobs
sched, err := gocron.NewScheduler()
if err != nil {
log.Fatal().Err(err).Msg("[Cleanup] Failed to create cleanup scheduler")
}
cleanupScheduler = sched
// Schedule cleanup job to run daily at midnight (00:00)
// Cron expression: "0 0 * * *" means: minute=0, hour=0, every day, every month, every weekday
_, err = cleanupScheduler.NewJob(
gocron.CronJob("0 0 * * *", false),
gocron.NewTask(func() {
log.Info().Msg("[Cleanup] Running scheduled cleanup and bucketing")
cleanOldCheckHistory()
bucketOldCheckHistory()
}),
gocron.WithName("daily-cleanup"),
)
if err != nil {
log.Fatal().Err(err).Msg("[Cleanup] Failed to schedule cleanup job")
}
// Start the scheduler
cleanupScheduler.Start()
log.Info().Msg("[Cleanup] Cleanup scheduler started - will run daily at midnight")
}