package workerruntime import ( "context" "fmt" "log/slog" "time" "github.com/itworx/pulse/internal/notification" ) // MaxNotificationBatch bounds one outbox drain. const MaxNotificationBatch = 50 // ChannelLister lists configured notification channels. type ChannelLister interface { ListChannels(ctx context.Context, limit int) ([]notification.Channel, error) } // NotificationDrainJob delivers due outbox items. // // Delivery semantics come entirely from internal/notification: ClaimDue moves a // due item to "delivering", increments its attempt counter and takes a bounded // lock in one transaction (FOR UPDATE SKIP LOCKED), and Complete only applies // to the exact attempt it claimed. That is what makes retry safe: a second // worker cannot claim a locked item, a crashed attempt is retried only after // its lock expires, and a completion for a stale attempt is ignored instead of // delivering twice. type NotificationDrainJob struct { Store notification.Store Channels ChannelLister // Senders maps a channel type onto its transport. A configured channel // whose type has no registered transport is reported, never silently // dropped and never retried into failure. Senders map[string]notification.ChannelSender Factories map[string]notification.ChannelSenderFactory BatchSize int Logger *slog.Logger Now func() time.Time } func (j NotificationDrainJob) now() time.Time { if j.Now != nil { return j.Now().UTC() } return time.Now().UTC() } func (j NotificationDrainJob) logger() *slog.Logger { if j.Logger != nil { return j.Logger } return slog.Default() } // Run drains one bounded batch of due notifications. func (j NotificationDrainJob) Run(ctx context.Context) (Outcome, error) { if j.Store == nil || j.Channels == nil { return Outcome{Disabled: true, Reason: "notifications_not_configured"}, nil } channels, err := j.Channels.ListChannels(ctx, 100) if err != nil { return Outcome{}, fmt.Errorf("list notification channels: %w", err) } senders := make(map[string]notification.ChannelSender, len(channels)) counts := map[string]int64{} unsupported := 0 for _, channel := range channels { if !channel.Enabled { continue } counts["channels"]++ sender, ok := j.Senders[channel.Type] if (!ok || sender == nil) && j.Factories[channel.Type] != nil { sender, err = j.Factories[channel.Type].Sender(ctx, channel) ok = err == nil && sender != nil if err != nil { j.logger().Error("notification channel transport configuration failed", "channel", channel.ID, "type", channel.Type, "error", notification.RedactError(err)) } } if !ok || sender == nil { unsupported++ j.logger().Error("notification channel has no delivery transport", "channel", channel.ID, "type", channel.Type) continue } senders[channel.ID] = sender } counts["unsupported_channels"] = int64(unsupported) if counts["channels"] == 0 { return Outcome{Disabled: true, Reason: "no_enabled_notification_channels", Counts: counts}, nil } if len(senders) == 0 { // Claiming items we cannot deliver would burn their bounded attempts, // so the drain stops and the failure stays visible instead. return Outcome{Counts: counts}, fmt.Errorf("no delivery transport is registered for %d enabled notification channel(s)", unsupported) } batch := j.BatchSize if batch < 1 || batch > MaxNotificationBatch { batch = MaxNotificationBatch } dispatcher := notification.Dispatcher{Store: j.Store, Channels: senders, Limiter: ¬ification.RateLimiter{}} delivered, err := dispatcher.DispatchDue(ctx, j.now(), batch) counts["delivered"] = int64(delivered) if err != nil { return Outcome{Counts: counts}, fmt.Errorf("drain notification outbox: %w", err) } if unsupported > 0 { return Outcome{Counts: counts}, fmt.Errorf("%d enabled notification channel(s) have no delivery transport", unsupported) } return Outcome{Counts: counts}, nil } // DrainDeadline is the longest a single drain may take. It is exported so the // job schedule and the outbox lock window stay visibly related: the outbox lock // is five minutes, comfortably above this bound. const DrainDeadline = 30 * time.Second