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:] }