package worker import ( "context" "errors" "fmt" "log/slog" "time" ) // SQLRetentionStore performs the durable DELETE statements applied by the // retention worker. Implementations are typically the umbrella PostgreSQL // mail store; the interface keeps the worker decoupled from the store // package. type SQLRetentionStore interface { // DeleteDeliveriesOlderThan removes deliveries whose created_at predates // cutoff. Cascading FKs drop attempts, dead_letters, delivery_payloads, // and delivery_recipients owned by the deleted rows. DeleteDeliveriesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) // DeleteMalformedCommandsOlderThan removes malformed-command rows whose // recorded_at predates cutoff. DeleteMalformedCommandsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) } // SQLRetentionConfig stores the dependencies and policy used by // SQLRetentionWorker. type SQLRetentionConfig struct { // Store applies the durable DELETE statements. Store SQLRetentionStore // DeliveryRetention bounds how long deliveries (and their cascaded // attempts/dead_letters/payloads/recipients) survive after creation. DeliveryRetention time.Duration // MalformedCommandRetention bounds how long malformed-command rows // survive after recorded_at. MalformedCommandRetention time.Duration // CleanupInterval stores the wall-clock period between two retention // passes. CleanupInterval time.Duration // Clock provides the wall-clock used to compute cutoff timestamps. Clock Clock } // SQLRetentionWorker periodically deletes deliveries and malformed-command // rows whose retention window has expired. The worker replaces the previous // Redis index_cleaner that maintained secondary index keys; PostgreSQL // indexes are maintained by the engine, so the worker only needs to enforce // retention. type SQLRetentionWorker struct { store SQLRetentionStore deliveryRetention time.Duration malformedCommandRetention time.Duration cleanupInterval time.Duration clock Clock logger *slog.Logger } // NewSQLRetentionWorker constructs the periodic retention worker. func NewSQLRetentionWorker(cfg SQLRetentionConfig, logger *slog.Logger) (*SQLRetentionWorker, error) { switch { case cfg.Store == nil: return nil, errors.New("new sql retention worker: nil store") case cfg.DeliveryRetention <= 0: return nil, errors.New("new sql retention worker: non-positive delivery retention") case cfg.MalformedCommandRetention <= 0: return nil, errors.New("new sql retention worker: non-positive malformed command retention") case cfg.CleanupInterval <= 0: return nil, errors.New("new sql retention worker: non-positive cleanup interval") case cfg.Clock == nil: return nil, errors.New("new sql retention worker: nil clock") } if logger == nil { logger = slog.Default() } return &SQLRetentionWorker{ store: cfg.Store, deliveryRetention: cfg.DeliveryRetention, malformedCommandRetention: cfg.MalformedCommandRetention, cleanupInterval: cfg.CleanupInterval, clock: cfg.Clock, logger: logger.With("component", "sql_retention_worker"), }, nil } // Run starts the retention loop and blocks until ctx is canceled. func (worker *SQLRetentionWorker) Run(ctx context.Context) error { if ctx == nil { return errors.New("run sql retention worker: nil context") } if err := ctx.Err(); err != nil { return err } if worker == nil { return errors.New("run sql retention worker: nil worker") } worker.logger.Info("sql retention worker started", "delivery_retention", worker.deliveryRetention.String(), "malformed_command_retention", worker.malformedCommandRetention.String(), "cleanup_interval", worker.cleanupInterval.String(), ) defer worker.logger.Info("sql retention worker stopped") // First pass runs immediately so a freshly started service does not wait // one full interval before evicting stale rows. worker.runOnce(ctx) ticker := time.NewTicker(worker.cleanupInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: worker.runOnce(ctx) } } } // Shutdown stops the retention worker within ctx. func (worker *SQLRetentionWorker) Shutdown(ctx context.Context) error { if ctx == nil { return errors.New("shutdown sql retention worker: nil context") } return nil } func (worker *SQLRetentionWorker) runOnce(ctx context.Context) { now := worker.clock.Now().UTC() deliveryCutoff := now.Add(-worker.deliveryRetention) if deleted, err := worker.store.DeleteDeliveriesOlderThan(ctx, deliveryCutoff); err != nil { worker.logger.Warn("delete expired deliveries failed", "cutoff", deliveryCutoff, "error", fmt.Sprintf("%v", err), ) } else if deleted > 0 { worker.logger.Info("expired deliveries deleted", "cutoff", deliveryCutoff, "deleted", deleted, ) } malformedCutoff := now.Add(-worker.malformedCommandRetention) if deleted, err := worker.store.DeleteMalformedCommandsOlderThan(ctx, malformedCutoff); err != nil { worker.logger.Warn("delete expired malformed commands failed", "cutoff", malformedCutoff, "error", fmt.Sprintf("%v", err), ) } else if deleted > 0 { worker.logger.Info("expired malformed commands deleted", "cutoff", malformedCutoff, "deleted", deleted, ) } }