Files
ITWorx-Pulse-Public/internal/workerruntime/notificationjob.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

117 lines
4.1 KiB
Go

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: &notification.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