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 // notification store; the interface keeps the worker decoupled from the // store package. type SQLRetentionStore interface { // DeleteRecordsOlderThan removes records rows whose accepted_at predates // cutoff. Cascading FKs drop routes and dead_letters owned by the deleted // rows. DeleteRecordsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) // DeleteMalformedIntentsOlderThan removes malformed-intent rows whose // recorded_at predates cutoff. DeleteMalformedIntentsOlderThan(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 // RecordRetention bounds how long records (and their cascaded routes and // dead_letters) survive after acceptance. RecordRetention time.Duration // MalformedIntentRetention bounds how long malformed-intent rows survive // after recorded_at. MalformedIntentRetention 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 records and malformed-intent rows // whose retention window has expired. The worker replaces the per-key // Redis EXPIRE eviction that maintained TTLs on the previous Redis-backed // notification keyspace. type SQLRetentionWorker struct { store SQLRetentionStore recordRetention time.Duration malformedIntentRetention 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.RecordRetention <= 0: return nil, errors.New("new sql retention worker: non-positive record retention") case cfg.MalformedIntentRetention <= 0: return nil, errors.New("new sql retention worker: non-positive malformed intent 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, recordRetention: cfg.RecordRetention, malformedIntentRetention: cfg.MalformedIntentRetention, 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", "record_retention", worker.recordRetention.String(), "malformed_intent_retention", worker.malformedIntentRetention.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() recordCutoff := now.Add(-worker.recordRetention) if deleted, err := worker.store.DeleteRecordsOlderThan(ctx, recordCutoff); err != nil { worker.logger.Warn("delete expired records failed", "cutoff", recordCutoff, "error", fmt.Sprintf("%v", err), ) } else if deleted > 0 { worker.logger.Info("expired records deleted", "cutoff", recordCutoff, "deleted", deleted, ) } malformedCutoff := now.Add(-worker.malformedIntentRetention) if deleted, err := worker.store.DeleteMalformedIntentsOlderThan(ctx, malformedCutoff); err != nil { worker.logger.Warn("delete expired malformed intents failed", "cutoff", malformedCutoff, "error", fmt.Sprintf("%v", err), ) } else if deleted > 0 { worker.logger.Info("expired malformed intents deleted", "cutoff", malformedCutoff, "deleted", deleted, ) } }