Files
silo-server/internal/notifications/webhook_repo.go
QuickandClaude Fable 5 e5b210589d fix(notifications): address PR #136 review findings
Codex + CodeRabbit review fixes, all verified against current behavior:

- Web Push: single-writer VAPID provisioning via a new conditional
  SetIfAbsent settings write (no split-brain identity across nodes), and
  read/decode failures now surface instead of silently rotating the
  keypair; the eager-provisioning goroutine joins the shutdown WaitGroup
- Web Push: endpoint reassignment purges the previous owner's pending
  attempts inside the upsert transaction, with an ownership re-check at
  send time
- Webhooks: per-profile cap enforced atomically (advisory-locked
  count+insert), typed pgconn unique-violation mapping, create-time
  type/URL mismatch rejection, send-time HTTPS re-check, and Retry-After
  HTTP-date support (shared, clamped parser also used by web push)
- Delivery workers: transient delivery-row lookup errors leave the claim
  to lease expiry instead of permanently failing the attempt
- Interest: history-only imports now feed the index (userstore history
  hooks + completed-history folding in recompute/rebuild), rebuild also
  recomputes existing interest rows so removed sources get cleaned up,
  and failed flush mutations requeue (bounded) instead of dropping
- Retention: read notifications age from read_at, not created_at
- Startup: scan queue workers start only after the availability detector
  is wired, so resumed scans cannot skip availability recording
- mail: settings-store read failures propagate instead of reading as
  "not configured"
- DB: new migration adds episode ordinal/key CHECK constraints
- Web: service worker restricts notification clicks to same-origin URLs,
  preferences popover gets an error+retry state, and the realtime
  profile-rebind backoff grows to 5 minutes to keep shared channels
  stable through notifications-only outages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:16:27 -04:00

399 lines
14 KiB
Go

package notifications
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// WebhookRepository owns notification_webhooks and webhook_delivery_attempts.
type WebhookRepository struct {
pool *pgxpool.Pool
}
// NewWebhookRepository creates a WebhookRepository.
func NewWebhookRepository(pool *pgxpool.Pool) *WebhookRepository {
return &WebhookRepository{pool: pool}
}
const webhookColumns = `
id, user_id, profile_id, name, type, url_ciphertext, url_host,
signing_secret_ciphertext, enabled,
notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up,
notify_requests,
consecutive_failures, disabled_reason,
last_success_at, last_failure_at, last_failure_status, last_failure_message,
created_at, updated_at`
func scanWebhook(row pgx.Row) (*Webhook, error) {
var hook Webhook
err := row.Scan(
&hook.ID, &hook.UserID, &hook.ProfileID, &hook.Name, &hook.Type,
&hook.URLCiphertext, &hook.URLHost, &hook.SigningSecretCiphertext, &hook.Enabled,
&hook.NotifyFavorites, &hook.NotifyWatchlist, &hook.NotifyContinueWatching, &hook.NotifyNextUp,
&hook.NotifyRequests,
&hook.ConsecutiveFailures, &hook.DisabledReason,
&hook.LastSuccessAt, &hook.LastFailureAt, &hook.LastFailureStatus, &hook.LastFailureMessage,
&hook.CreatedAt, &hook.UpdatedAt,
)
if err != nil {
return nil, err
}
return &hook, nil
}
func scanWebhooks(rows pgx.Rows) ([]Webhook, error) {
defer rows.Close()
hooks := make([]Webhook, 0, 4)
for rows.Next() {
hook, err := scanWebhook(rows)
if err != nil {
return nil, fmt.Errorf("scan webhook: %w", err)
}
hooks = append(hooks, *hook)
}
return hooks, rows.Err()
}
// ListByProfile returns all of a profile's webhooks for the listing endpoint.
func (r *WebhookRepository) ListByProfile(ctx context.Context, profileID string) ([]Webhook, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+webhookColumns+` FROM notification_webhooks WHERE profile_id = $1 ORDER BY created_at`,
profileID)
if err != nil {
return nil, fmt.Errorf("list webhooks: %w", err)
}
return scanWebhooks(rows)
}
// ListEnabledByProfiles loads enabled webhooks for a set of profiles, keyed
// by profile. Used by the fanout outbox enqueue inside its transaction.
func (r *WebhookRepository) ListEnabledByProfiles(ctx context.Context, tx pgx.Tx, profileIDs []string) (map[string][]Webhook, error) {
out := make(map[string][]Webhook, len(profileIDs))
if len(profileIDs) == 0 {
return out, nil
}
rows, err := tx.Query(ctx,
`SELECT `+webhookColumns+` FROM notification_webhooks WHERE profile_id = ANY($1) AND enabled`,
profileIDs)
if err != nil {
return nil, fmt.Errorf("list enabled webhooks: %w", err)
}
hooks, err := scanWebhooks(rows)
if err != nil {
return nil, err
}
for _, hook := range hooks {
out[hook.ProfileID] = append(out[hook.ProfileID], hook)
}
return out, nil
}
// GetByID returns one webhook scoped to the profile; (nil, nil) when absent.
func (r *WebhookRepository) GetByID(ctx context.Context, profileID, id string) (*Webhook, error) {
hook, err := scanWebhook(r.pool.QueryRow(ctx,
`SELECT `+webhookColumns+` FROM notification_webhooks WHERE profile_id = $1 AND id = $2`,
profileID, id))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get webhook: %w", err)
}
return hook, nil
}
// getByIDUnscoped loads a webhook for internal delivery paths.
func (r *WebhookRepository) getByIDUnscoped(ctx context.Context, id string) (*Webhook, error) {
hook, err := scanWebhook(r.pool.QueryRow(ctx,
`SELECT `+webhookColumns+` FROM notification_webhooks WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get webhook: %w", err)
}
return hook, nil
}
// ErrWebhookNameTaken is returned when a profile already has a webhook with
// the requested name.
var ErrWebhookNameTaken = errors.New("a webhook with this name already exists")
// isWebhookNameViolation reports whether err is the unique violation on the
// per-profile webhook name constraint, via the typed pgx error (string
// matching on error text would break if message formatting changes).
func isWebhookNameViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) &&
pgErr.Code == "23505" &&
pgErr.ConstraintName == "notification_webhooks_profile_name_key"
}
// InsertWithLimit persists a new webhook unless the profile is already at
// maxPerProfile. The count and insert run under a per-profile advisory
// transaction lock so concurrent creates cannot both pass the check and push
// the profile past its cap.
func (r *WebhookRepository) InsertWithLimit(ctx context.Context, hook Webhook, maxPerProfile int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin webhook insert: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx,
`SELECT pg_advisory_xact_lock(hashtextextended('notification_webhooks:' || $1, 0))`,
hook.ProfileID); err != nil {
return fmt.Errorf("lock webhook quota: %w", err)
}
var count int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM notification_webhooks WHERE profile_id = $1`, hook.ProfileID,
).Scan(&count); err != nil {
return fmt.Errorf("count webhooks: %w", err)
}
if count >= maxPerProfile {
return ErrWebhookLimit
}
if _, err := tx.Exec(ctx, `
INSERT INTO notification_webhooks
(id, user_id, profile_id, name, type, url_ciphertext, url_host,
signing_secret_ciphertext, enabled,
notify_favorites, notify_watchlist, notify_continue_watching, notify_next_up,
notify_requests)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
hook.ID, hook.UserID, hook.ProfileID, hook.Name, hook.Type,
hook.URLCiphertext, hook.URLHost, hook.SigningSecretCiphertext, hook.Enabled,
hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp,
hook.NotifyRequests); err != nil {
if isWebhookNameViolation(err) {
return ErrWebhookNameTaken
}
return fmt.Errorf("insert webhook: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit webhook insert: %w", err)
}
return nil
}
// Update persists the mutable fields of a webhook (name, URL, flags,
// enabled state, secret) and bumps updated_at.
func (r *WebhookRepository) Update(ctx context.Context, hook Webhook) error {
_, err := r.pool.Exec(ctx, `
UPDATE notification_webhooks SET
name = $2, type = $3, url_ciphertext = $4, url_host = $5,
signing_secret_ciphertext = $6, enabled = $7,
notify_favorites = $8, notify_watchlist = $9,
notify_continue_watching = $10, notify_next_up = $11,
notify_requests = $12,
consecutive_failures = $13, disabled_reason = $14,
updated_at = now()
WHERE id = $1`,
hook.ID, hook.Name, hook.Type, hook.URLCiphertext, hook.URLHost,
hook.SigningSecretCiphertext, hook.Enabled,
hook.NotifyFavorites, hook.NotifyWatchlist, hook.NotifyContinueWatching, hook.NotifyNextUp,
hook.NotifyRequests,
hook.ConsecutiveFailures, hook.DisabledReason)
if err != nil {
if isWebhookNameViolation(err) {
return ErrWebhookNameTaken
}
return fmt.Errorf("update webhook: %w", err)
}
return nil
}
// Delete removes a profile's webhook; attempts cascade. Idempotent.
func (r *WebhookRepository) Delete(ctx context.Context, profileID, id string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM notification_webhooks WHERE profile_id = $1 AND id = $2`, profileID, id)
return err
}
// DeleteAllForProfile removes a deleted profile's webhooks.
func (r *WebhookRepository) DeleteAllForProfile(ctx context.Context, profileID string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM notification_webhooks WHERE profile_id = $1`, profileID)
return err
}
// RecordSuccess resets the failure streak after a delivered attempt.
func (r *WebhookRepository) RecordSuccess(ctx context.Context, webhookID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notification_webhooks
SET last_success_at = now(), consecutive_failures = 0, updated_at = now()
WHERE id = $1`, webhookID)
return err
}
// RecordFailure increments the failure streak and stores the diagnostic.
func (r *WebhookRepository) RecordFailure(ctx context.Context, webhookID string, httpStatus *int, message string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notification_webhooks
SET last_failure_at = now(),
last_failure_status = $2,
last_failure_message = left($3, 256),
consecutive_failures = consecutive_failures + 1,
updated_at = now()
WHERE id = $1`, webhookID, httpStatus, message)
return err
}
// Disable auto-disables a webhook with a profile-visible reason.
func (r *WebhookRepository) Disable(ctx context.Context, webhookID, reason string) error {
_, err := r.pool.Exec(ctx, `
UPDATE notification_webhooks
SET enabled = false, disabled_reason = left($2, 256), updated_at = now()
WHERE id = $1`, webhookID, reason)
return err
}
// RecentFinalOutcomes returns the most recent terminal attempt outcomes
// (delivered/failed/auto_disabled) for a webhook, newest first. Used for the
// 3-consecutive-non-retryable-4xx auto-disable rule.
func (r *WebhookRepository) RecentFinalOutcomes(ctx context.Context, webhookID string, limit int) ([]DeliveryAttempt, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, notification_delivery_id, webhook_id, attempt_number, attempted_at,
next_retry_at, http_status, outcome, failure_message
FROM webhook_delivery_attempts
WHERE webhook_id = $1 AND outcome IN ('delivered', 'failed', 'auto_disabled')
ORDER BY attempted_at DESC
LIMIT $2`, webhookID, limit)
if err != nil {
return nil, fmt.Errorf("list final webhook outcomes: %w", err)
}
return scanDeliveryAttempts(rows)
}
func scanDeliveryAttempts(rows pgx.Rows) ([]DeliveryAttempt, error) {
defer rows.Close()
attempts := make([]DeliveryAttempt, 0, 8)
for rows.Next() {
var attempt DeliveryAttempt
if err := rows.Scan(
&attempt.ID, &attempt.NotificationDeliveryID, &attempt.TargetID,
&attempt.AttemptNumber, &attempt.AttemptedAt, &attempt.NextRetryAt,
&attempt.HTTPStatus, &attempt.Outcome, &attempt.FailureMessage,
); err != nil {
return nil, fmt.Errorf("scan webhook attempt: %w", err)
}
attempts = append(attempts, attempt)
}
return attempts, rows.Err()
}
// EnqueueAttempts inserts `pending` outbox rows inside the fanout
// transaction. attempt_number starts at 0 (no send tried yet).
func (r *WebhookRepository) EnqueueAttempts(ctx context.Context, tx pgx.Tx, attempts []DeliveryAttempt) error {
if len(attempts) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString(`
INSERT INTO webhook_delivery_attempts
(id, notification_delivery_id, webhook_id, attempt_number, outcome)
VALUES `)
args := make([]any, 0, len(attempts)*5)
for i, attempt := range attempts {
if i > 0 {
sb.WriteString(", ")
}
base := len(args)
sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d)", base+1, base+2, base+3, base+4, base+5))
args = append(args, attempt.ID, attempt.NotificationDeliveryID, attempt.TargetID, 0, WebhookOutcomePending)
}
sb.WriteString(" ON CONFLICT DO NOTHING")
if _, err := tx.Exec(ctx, sb.String(), args...); err != nil {
return fmt.Errorf("enqueue webhook attempts: %w", err)
}
return nil
}
// claimLease is how long a claimed attempt is invisible to other claimers. A
// crash mid-send surfaces the attempt to the retry worker after the lease.
const webhookClaimLease = 90 * time.Second
// ClaimPendingForDelivery claims a delivery's pending attempts for immediate
// post-commit dispatch. Claiming flips the row to `retrying` with a short
// lease instead of holding row locks across the HTTP send.
func (r *WebhookRepository) ClaimPendingForDelivery(ctx context.Context, deliveryID string) ([]DeliveryAttempt, error) {
return r.claim(ctx, `
UPDATE webhook_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2
WHERE id IN (
SELECT id FROM webhook_delivery_attempts
WHERE notification_delivery_id = $1 AND outcome = 'pending'
FOR UPDATE SKIP LOCKED
)
RETURNING id, notification_delivery_id, webhook_id, attempt_number, attempted_at,
next_retry_at, http_status, outcome, failure_message`,
deliveryID, webhookClaimLease)
}
// ClaimDue claims attempts whose retry is due, plus stale pending rows whose
// post-commit dispatch never ran (outbox recovery after a crash).
func (r *WebhookRepository) ClaimDue(ctx context.Context, limit int) ([]DeliveryAttempt, error) {
return r.claim(ctx, `
UPDATE webhook_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2
WHERE id IN (
SELECT id FROM webhook_delivery_attempts
WHERE (outcome = 'retrying' AND next_retry_at <= now())
OR (outcome = 'pending' AND attempted_at <= now() - interval '60 seconds')
ORDER BY next_retry_at NULLS FIRST
LIMIT $1
FOR UPDATE SKIP LOCKED
)
RETURNING id, notification_delivery_id, webhook_id, attempt_number, attempted_at,
next_retry_at, http_status, outcome, failure_message`,
limit, webhookClaimLease)
}
func (r *WebhookRepository) claim(ctx context.Context, query string, args ...any) ([]DeliveryAttempt, error) {
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("claim webhook attempts: %w", err)
}
return scanDeliveryAttempts(rows)
}
// FinalizeAttempt records a send result: the new outcome, the attempt number
// just consumed, and the optional next retry time.
func (r *WebhookRepository) FinalizeAttempt(ctx context.Context, attemptID, outcome string, attemptNumber int, httpStatus *int, failureMessage string, nextRetryAt *time.Time) error {
var message *string
if failureMessage != "" {
message = &failureMessage
}
_, err := r.pool.Exec(ctx, `
UPDATE webhook_delivery_attempts
SET outcome = $2, attempt_number = $3, attempted_at = now(),
http_status = $4, failure_message = left($5, 256), next_retry_at = $6
WHERE id = $1`,
attemptID, outcome, attemptNumber, httpStatus, message, nextRetryAt)
if err != nil {
return fmt.Errorf("finalize webhook attempt: %w", err)
}
return nil
}
// DeleteOldAttempts applies attempt retention: delivered rows past 7 days,
// failed/auto_disabled rows past 30 days. Pending/retrying rows are kept
// until they resolve.
func (r *WebhookRepository) DeleteOldAttempts(ctx context.Context, now time.Time) (int64, error) {
tag, err := r.pool.Exec(ctx, `
DELETE FROM webhook_delivery_attempts
WHERE (outcome = 'delivered' AND attempted_at < $1)
OR (outcome IN ('failed', 'auto_disabled') AND attempted_at < $2)`,
now.AddDate(0, 0, -7), now.AddDate(0, 0, -30))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}