Files
ITWorx-Pulse-Public/internal/notification/repository.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

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), &current); 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), &current); 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:]
}