This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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
|
||||
Reference in New Issue
Block a user