Files
silo-server/internal/activitylog/cleanup.go
T

136 lines
3.6 KiB
Go

package activitylog
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
keyRetentionDays = "activitylog.retention_days"
defaultRetentionStr = "90"
defaultRetention = 90
cleanupBatchSize = 10000
)
// SettingsStore is satisfied by *catalog.ServerSettingsRepo.
type SettingsStore interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key, value string) error
}
type PartitionManager interface {
EnsureFuturePartitions(ctx context.Context) error
DropExpiredPartitions(ctx context.Context, cutoff time.Time) ([]string, error)
DeleteExpiredRowsFromDefault(ctx context.Context, cutoff time.Time) (int64, error)
}
// SeedDefaults writes default activity log settings if not already set.
func SeedDefaults(ctx context.Context, store SettingsStore) error {
existing, err := store.Get(ctx, keyRetentionDays)
if err != nil {
return fmt.Errorf("seed activitylog defaults: %w", err)
}
if existing != "" {
return nil
}
return store.Set(ctx, keyRetentionDays, defaultRetentionStr)
}
// RunCleanup starts a background goroutine that runs batched deletes daily.
// Blocks until ctx is cancelled.
func RunCleanup(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, pm PartitionManager) {
// Run once at startup, then every 24 hours
CleanupOnce(ctx, pool, store, pm)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
CleanupOnce(ctx, pool, store, pm)
}
}
}
// CleanupOnce runs a single activity log retention pass.
func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, pm PartitionManager) int64 {
days := defaultRetention
if raw, err := store.Get(ctx, keyRetentionDays); err == nil && raw != "" {
if d := parseInt(raw); d > 0 {
days = d
}
}
cutoff := time.Now().UTC().AddDate(0, 0, -days)
if pm != nil {
if err := pm.EnsureFuturePartitions(ctx); err != nil {
slog.Warn("activitylog ensure future partitions error", "error", err)
}
partitionCleanupFailed := false
totalDeleted := int64(0)
if dropped, err := pm.DropExpiredPartitions(ctx, cutoff); err != nil {
slog.Warn("activitylog partition cleanup error", "error", err)
partitionCleanupFailed = true
} else if len(dropped) > 0 {
slog.Info("activitylog dropped expired partitions", "partitions", dropped)
}
if deleted, err := pm.DeleteExpiredRowsFromDefault(ctx, cutoff); err != nil {
slog.Warn("activitylog default partition cleanup error", "error", err)
partitionCleanupFailed = true
} else if deleted > 0 {
totalDeleted += deleted
slog.Info("activitylog default partition cleanup completed", "deleted", deleted, "retention_days", days)
}
if !partitionCleanupFailed {
return totalDeleted
}
slog.Warn("activitylog partition cleanup degraded, falling back to row deletes", "retention_days", days)
}
total := deleteExpiredRowsBefore(ctx, pool, cutoff)
if total > 0 {
slog.Info("activitylog cleanup completed", "deleted", total, "retention_days", days)
}
return total
}
func deleteExpiredRowsBefore(ctx context.Context, pool *pgxpool.Pool, cutoff time.Time) int64 {
total := int64(0)
for {
result, err := pool.Exec(ctx, `
DELETE FROM activity_log
WHERE id IN (
SELECT id FROM activity_log
WHERE timestamp < $1
LIMIT $2
)
`, cutoff, cleanupBatchSize)
if err != nil {
slog.Warn("activitylog cleanup error", "error", err)
return total
}
deleted := result.RowsAffected()
total += deleted
if deleted < int64(cleanupBatchSize) {
break
}
}
return total
}
func parseInt(s string) int {
var v int
fmt.Sscanf(s, "%d", &v)
return v
}