Public source validation / validate (push) Failing after 3m8s
464 lines
17 KiB
Go
464 lines
17 KiB
Go
package notification
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const maxClaimLimit = 100
|
|
|
|
const (
|
|
StatusPending = "pending"
|
|
StatusDelivering = "delivering"
|
|
StatusRetry = "retry"
|
|
StatusDelivered = "delivered"
|
|
StatusFailed = "failed"
|
|
)
|
|
|
|
// Store is the deliberately small persistence boundary used by the dispatcher.
|
|
// Enqueue, claim and completion are the only operations that can advance an
|
|
// outbox item, which keeps retry and delivery audit changes in one transaction.
|
|
type Store interface {
|
|
Enqueue(context.Context, Outbox) (Outbox, bool, error)
|
|
GetOutbox(context.Context, string) (Outbox, error)
|
|
ClaimDue(context.Context, time.Time, int) ([]Outbox, error)
|
|
Complete(context.Context, string, int, bool, error, time.Time) (Outbox, error)
|
|
}
|
|
|
|
type ChannelStore interface {
|
|
CreateChannel(context.Context, Channel) (Channel, error)
|
|
GetChannel(context.Context, string) (Channel, error)
|
|
ListChannels(context.Context, int) ([]Channel, error)
|
|
UpdateChannel(context.Context, Channel, int64) (Channel, error)
|
|
DeleteChannel(context.Context, string) error
|
|
}
|
|
|
|
type Repository struct{ Pool *pgxpool.Pool }
|
|
|
|
func NewRepository(pool *pgxpool.Pool) (*Repository, error) {
|
|
if pool == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
return &Repository{Pool: pool}, nil
|
|
}
|
|
|
|
func (r *Repository) Enqueue(ctx context.Context, item Outbox) (Outbox, bool, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return Outbox{}, false, ErrUnavailable
|
|
}
|
|
if err := validateOutbox(item); err != nil {
|
|
return Outbox{}, false, err
|
|
}
|
|
if item.ID == "" {
|
|
item.ID = newID()
|
|
}
|
|
if item.NextAttemptAt.IsZero() {
|
|
item.NextAttemptAt = time.Now().UTC()
|
|
}
|
|
if item.Status == "" {
|
|
item.Status = StatusPending
|
|
}
|
|
if item.CreatedAt.IsZero() {
|
|
item.CreatedAt = time.Now().UTC()
|
|
}
|
|
if item.UpdatedAt.IsZero() {
|
|
item.UpdatedAt = item.CreatedAt
|
|
}
|
|
|
|
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return Outbox{}, false, fmt.Errorf("begin notification enqueue: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
var inserted Outbox
|
|
err = scanOutbox(tx.QueryRow(ctx, `
|
|
INSERT INTO notification_outbox
|
|
(id,idempotency_key,channel_id,event_type,subject,body,status,attempts,next_attempt_at,created_at,updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
ON CONFLICT (idempotency_key) DO NOTHING
|
|
RETURNING id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body`,
|
|
item.ID, item.IdempotencyKey, item.ChannelID, item.EventType, item.Subject, item.Body, item.Status,
|
|
item.Attempts, item.NextAttemptAt.UTC(), item.CreatedAt.UTC(), item.UpdatedAt.UTC()), &inserted)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
if err := scanOutbox(tx.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE idempotency_key=$1`, item.IdempotencyKey), &inserted); err != nil {
|
|
return Outbox{}, false, mapError("select existing notification", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Outbox{}, false, fmt.Errorf("commit duplicate notification: %w", err)
|
|
}
|
|
return inserted, true, nil
|
|
}
|
|
if err != nil {
|
|
return Outbox{}, false, mapError("enqueue notification", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Outbox{}, false, fmt.Errorf("commit notification enqueue: %w", err)
|
|
}
|
|
return inserted, false, nil
|
|
}
|
|
|
|
func (r *Repository) GetOutbox(ctx context.Context, id string) (Outbox, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return Outbox{}, ErrUnavailable
|
|
}
|
|
if id == "" {
|
|
return Outbox{}, fmt.Errorf("%w: outbox id is required", ErrInvalid)
|
|
}
|
|
var item Outbox
|
|
err := scanOutbox(r.Pool.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE id=$1`, id), &item)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Outbox{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Outbox{}, fmt.Errorf("get notification outbox: %w", err)
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (r *Repository) ClaimDue(ctx context.Context, now time.Time, limit int) ([]Outbox, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if limit < 1 || limit > maxClaimLimit {
|
|
return nil, fmt.Errorf("%w: claim limit must be between 1 and %d", ErrInvalid, maxClaimLimit)
|
|
}
|
|
now = now.UTC()
|
|
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin notification claim: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
rows, err := tx.Query(ctx, `
|
|
WITH due AS (
|
|
SELECT id
|
|
FROM notification_outbox
|
|
WHERE attempts < $2 AND (
|
|
(status IN ('pending','retry') AND next_attempt_at <= $1)
|
|
OR (status='delivering' AND locked_until IS NOT NULL AND locked_until <= $1)
|
|
)
|
|
ORDER BY id ASC
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT $3
|
|
)
|
|
UPDATE notification_outbox AS item
|
|
SET status='delivering', attempts=item.attempts+1, locked_until=$1 + interval '5 minutes', updated_at=$1
|
|
FROM due
|
|
WHERE item.id=due.id
|
|
RETURNING item.id::text,item.idempotency_key,item.channel_id::text,item.event_type,item.status,item.attempts,item.next_attempt_at,COALESCE(item.last_error,''),item.created_at,item.updated_at,item.delivered_at,item.subject,item.body`, now, MaxAttempts, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claim due notifications: %w", err)
|
|
}
|
|
claimed := make([]Outbox, 0, limit)
|
|
for rows.Next() {
|
|
var item Outbox
|
|
if err := scanOutbox(rows, &item); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("scan claimed notification: %w", err)
|
|
}
|
|
claimed = append(claimed, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("claim notification rows: %w", err)
|
|
}
|
|
rows.Close()
|
|
for _, item := range claimed {
|
|
if _, err := tx.Exec(ctx, `INSERT INTO notification_deliveries (id,outbox_id,attempt,status,occurred_at) VALUES ($1,$2,$3,'delivering',$4) ON CONFLICT (outbox_id,attempt) DO NOTHING`, newID(), item.ID, item.Attempts, now); err != nil {
|
|
return nil, mapError("create notification delivery audit", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit notification claim: %w", err)
|
|
}
|
|
return claimed, nil
|
|
}
|
|
|
|
func (r *Repository) Complete(ctx context.Context, id string, attempt int, success bool, deliveryErr error, now time.Time) (Outbox, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return Outbox{}, ErrUnavailable
|
|
}
|
|
if id == "" || attempt < 1 || attempt > MaxAttempts {
|
|
return Outbox{}, fmt.Errorf("%w: completion identity is invalid", ErrInvalid)
|
|
}
|
|
now = now.UTC()
|
|
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return Outbox{}, fmt.Errorf("begin notification completion: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
var current Outbox
|
|
if err := scanOutbox(tx.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE id=$1 FOR UPDATE`, id), ¤t); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Outbox{}, ErrNotFound
|
|
}
|
|
return Outbox{}, err
|
|
}
|
|
if current.Status != StatusDelivering || current.Attempts != attempt {
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Outbox{}, fmt.Errorf("commit idempotent notification completion: %w", err)
|
|
}
|
|
return current, nil
|
|
}
|
|
if success {
|
|
if _, err := tx.Exec(ctx, `UPDATE notification_outbox SET status='delivered',locked_until=NULL,last_error=NULL,delivered_at=$1,updated_at=$1 WHERE id=$2`, now, id); err != nil {
|
|
return Outbox{}, fmt.Errorf("mark notification delivered: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE notification_deliveries SET status='delivered',error=NULL,occurred_at=$1 WHERE outbox_id=$2 AND attempt=$3`, now, id, attempt); err != nil {
|
|
return Outbox{}, fmt.Errorf("audit notification delivery: %w", err)
|
|
}
|
|
} else {
|
|
message := RedactError(deliveryErr)
|
|
status := StatusRetry
|
|
next := now.Add(RetryDelay(attempt))
|
|
if attempt >= MaxAttempts {
|
|
status = StatusFailed
|
|
next = now
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE notification_outbox SET status=$1,locked_until=NULL,last_error=$2,next_attempt_at=$3,updated_at=$4 WHERE id=$5`, status, nullableString(message), next, now, id); err != nil {
|
|
return Outbox{}, fmt.Errorf("record notification failure: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE notification_deliveries SET status='failed',error=$1,occurred_at=$2 WHERE outbox_id=$3 AND attempt=$4`, nullableString(message), now, id, attempt); err != nil {
|
|
return Outbox{}, fmt.Errorf("audit notification failure: %w", err)
|
|
}
|
|
}
|
|
if err := scanOutbox(tx.QueryRow(ctx, `SELECT id::text,idempotency_key,channel_id::text,event_type,status,attempts,next_attempt_at,COALESCE(last_error,''),created_at,updated_at,delivered_at,subject,body FROM notification_outbox WHERE id=$1`, id), ¤t); err != nil {
|
|
return Outbox{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Outbox{}, fmt.Errorf("commit notification completion: %w", err)
|
|
}
|
|
return current, nil
|
|
}
|
|
|
|
func (r *Repository) CreateChannel(ctx context.Context, channel Channel) (Channel, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return Channel{}, ErrUnavailable
|
|
}
|
|
if channel.ID == "" {
|
|
channel.ID = newID()
|
|
}
|
|
if channel.Revision == 0 {
|
|
channel.Revision = 1
|
|
}
|
|
if err := validateChannel(channel); err != nil {
|
|
return Channel{}, err
|
|
}
|
|
if channel.CreatedAt.IsZero() {
|
|
channel.CreatedAt = time.Now().UTC()
|
|
}
|
|
if channel.UpdatedAt.IsZero() {
|
|
channel.UpdatedAt = channel.CreatedAt
|
|
}
|
|
config, err := safeConfiguration(channel.Configuration)
|
|
if err != nil {
|
|
return Channel{}, err
|
|
}
|
|
var created Channel
|
|
err = scanChannel(r.Pool.QueryRow(ctx, `INSERT INTO notification_channels (id,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) RETURNING id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at`, channel.ID, channel.Name, channel.Type, channel.Enabled, channel.SecretRef.ID, config, channel.Revision, channel.CreatedAt.UTC(), channel.UpdatedAt.UTC()), &created)
|
|
if err != nil {
|
|
return Channel{}, mapError("create notification channel", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (r *Repository) GetChannel(ctx context.Context, id string) (Channel, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return Channel{}, ErrUnavailable
|
|
}
|
|
var channel Channel
|
|
err := scanChannel(r.Pool.QueryRow(ctx, `SELECT id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at FROM notification_channels WHERE id=$1`, id), &channel)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Channel{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Channel{}, fmt.Errorf("get notification channel: %w", err)
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
func (r *Repository) ListChannels(ctx context.Context, limit int) ([]Channel, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if limit < 1 || limit > 100 {
|
|
return nil, fmt.Errorf("%w: channel limit must be between 1 and 100", ErrInvalid)
|
|
}
|
|
rows, err := r.Pool.Query(ctx, `SELECT id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at FROM notification_channels ORDER BY name ASC,id ASC LIMIT $1`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list notification channels: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
channels := make([]Channel, 0, limit)
|
|
for rows.Next() {
|
|
var channel Channel
|
|
if err := scanChannel(rows, &channel); err != nil {
|
|
return nil, err
|
|
}
|
|
channels = append(channels, channel)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list notification channel rows: %w", err)
|
|
}
|
|
return channels, nil
|
|
}
|
|
|
|
func (r *Repository) UpdateChannel(ctx context.Context, channel Channel, expectedRevision int64) (Channel, error) {
|
|
if r == nil || r.Pool == nil {
|
|
return Channel{}, ErrUnavailable
|
|
}
|
|
if expectedRevision < 1 {
|
|
return Channel{}, fmt.Errorf("%w: channel revision is invalid", ErrInvalid)
|
|
}
|
|
if err := validateChannel(channel); err != nil {
|
|
return Channel{}, err
|
|
}
|
|
config, err := safeConfiguration(channel.Configuration)
|
|
if err != nil {
|
|
return Channel{}, err
|
|
}
|
|
var updated Channel
|
|
err = scanChannel(r.Pool.QueryRow(ctx, `UPDATE notification_channels SET name=$1,channel_type=$2,enabled=$3,secret_ref=$4,configuration=$5::jsonb,revision=revision+1,updated_at=now() WHERE id=$6 AND revision=$7 RETURNING id::text,name,channel_type,enabled,secret_ref,configuration,revision,created_at,updated_at`, channel.Name, channel.Type, channel.Enabled, channel.SecretRef.ID, config, channel.ID, expectedRevision), &updated)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
if _, getErr := r.GetChannel(ctx, channel.ID); errors.Is(getErr, ErrNotFound) {
|
|
return Channel{}, ErrNotFound
|
|
}
|
|
return Channel{}, ErrConflict
|
|
}
|
|
if err != nil {
|
|
return Channel{}, mapError("update notification channel", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func (r *Repository) DeleteChannel(ctx context.Context, id string) error {
|
|
if r == nil || r.Pool == nil {
|
|
return ErrUnavailable
|
|
}
|
|
command, err := r.Pool.Exec(ctx, `DELETE FROM notification_channels WHERE id=$1`, id)
|
|
if err != nil {
|
|
return mapError("delete notification channel", err)
|
|
}
|
|
if command.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateOutbox(item Outbox) error {
|
|
if item.ID != "" && len(item.ID) > 64 || item.IdempotencyKey == "" || len(item.IdempotencyKey) > 255 || item.ChannelID == "" || item.EventType == "" || (item.EventType != EventFiring && item.EventType != EventRecovery && item.EventType != EventUnknown) || len(item.Subject) < 1 || len(item.Subject) > MaxSubject || len(item.Body) < 1 || len(item.Body) > MaxBody || item.Attempts < 0 || item.Attempts > MaxAttempts {
|
|
return fmt.Errorf("%w: outbox fields are invalid", ErrInvalid)
|
|
}
|
|
if item.Status != "" && item.Status != StatusPending {
|
|
return fmt.Errorf("%w: new outbox status must be pending", ErrInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateChannel(channel Channel) error {
|
|
if err := channel.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if channel.Revision < 1 {
|
|
return fmt.Errorf("%w: channel revision is invalid", ErrInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func safeConfiguration(config map[string]any) ([]byte, error) {
|
|
if config == nil {
|
|
config = map[string]any{}
|
|
}
|
|
if containsSecretKey(config) {
|
|
return nil, fmt.Errorf("%w: secret values must use secret references", ErrInvalid)
|
|
}
|
|
encoded, err := json.Marshal(config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: invalid channel configuration", ErrInvalid)
|
|
}
|
|
if len(encoded) > 16<<10 {
|
|
return nil, fmt.Errorf("%w: channel configuration is too large", ErrInvalid)
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
func containsSecretKey(value any) bool {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range typed {
|
|
key = strings.ToLower(key)
|
|
if strings.Contains(key, "secret") || strings.Contains(key, "token") || strings.Contains(key, "password") || strings.Contains(key, "authorization") {
|
|
return true
|
|
}
|
|
if containsSecretKey(child) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range typed {
|
|
if containsSecretKey(child) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func scanOutbox(row interface{ Scan(...any) error }, item *Outbox) error {
|
|
return row.Scan(&item.ID, &item.IdempotencyKey, &item.ChannelID, &item.EventType, &item.Status, &item.Attempts, &item.NextAttemptAt, &item.LastError, &item.CreatedAt, &item.UpdatedAt, &item.DeliveredAt, &item.Subject, &item.Body)
|
|
}
|
|
|
|
func scanChannel(row interface{ Scan(...any) error }, channel *Channel) error {
|
|
var config []byte
|
|
if err := row.Scan(&channel.ID, &channel.Name, &channel.Type, &channel.Enabled, &channel.SecretRef.ID, &config, &channel.Revision, &channel.CreatedAt, &channel.UpdatedAt); err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(config, &channel.Configuration); err != nil {
|
|
return fmt.Errorf("decode notification channel configuration: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nullableString(value string) any {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func mapError(operation string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
switch pgErr.Code {
|
|
case "23503", "23505":
|
|
return fmt.Errorf("%w: %s", ErrConflict, operation)
|
|
case "23514", "22P02":
|
|
return fmt.Errorf("%w: %s", ErrInvalid, operation)
|
|
}
|
|
}
|
|
return fmt.Errorf("%s: %w", operation, err)
|
|
}
|
|
|
|
func newID() string {
|
|
bytes := make([]byte, 16)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return fmt.Sprintf("00000000-0000-4000-8000-%012d", time.Now().UnixNano()%1_000_000_000_000)
|
|
}
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
encoded := hex.EncodeToString(bytes)
|
|
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:]
|
|
}
|