Public source validation / validate (push) Failing after 3m8s
117 lines
4.1 KiB
Go
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: ¬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
|