Add notify package and durable notification outbox worker (Feature 19)
CI / ci (push) Successful in 5m54s
CI / ci (pull_request) Successful in 5m30s

Emit(ctx, Event) is the single decision point for who is told what: it
reads the event type's notification_prefs row and stores one pending
notification_outbox row per enabled channel, keyed by (event_key, channel)
with ON CONFLICT DO NOTHING, so a duplicate key never adds or resets a row.

The worker, started and stopped by cmd/server with the job runner, leases
due rows with a conditional update, delivers them through the Sender
registered for their channel, and marks them sent, or retries with a
backoff of 1 minute doubling to 1 hour until 24 hours after creation,
then marks them failed with last_error. A channel with no registered
sender fails its rows with an explicit error.

Maestro task addeb256-a4d4-4acf-8acd-6b6778d967ee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:23:39 -04:00
co-authored by Claude Opus 5.5
parent c8e5829a77
commit 615969cff2
7 changed files with 1512 additions and 5 deletions
+202
View File
@@ -0,0 +1,202 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: notifications.sql
package db
import (
"context"
"database/sql"
)
const claimDueNotification = `-- name: ClaimDueNotification :one
UPDATE notification_outbox
SET next_attempt_at = ?1, updated_at = ?2
WHERE id = (
SELECT id FROM notification_outbox
WHERE status = 'pending' AND next_attempt_at <= ?2
ORDER BY next_attempt_at, id
LIMIT 1
)
RETURNING id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
`
type ClaimDueNotificationParams struct {
LeaseUntil sql.NullString
Now string
}
// Leases the pending notification due longest ago by moving its
// next_attempt_at to lease_until, and returns it. A claimed row is not due
// again until the lease ends, so no other claim takes it while it is being
// delivered. Returns sql.ErrNoRows when none is due.
func (q *Queries) ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error) {
row := q.db.QueryRowContext(ctx, claimDueNotification, arg.LeaseUntil, arg.Now)
var i NotificationOutbox
err := row.Scan(
&i.ID,
&i.EventKey,
&i.Channel,
&i.Payload,
&i.Status,
&i.Attempts,
&i.NextAttemptAt,
&i.LastError,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const failNotification = `-- name: FailNotification :execrows
UPDATE notification_outbox
SET status = 'failed', attempts = attempts + ?1, next_attempt_at = NULL, last_error = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
`
type FailNotificationParams struct {
Attempted int64
LastError sql.NullString
Now string
ID string
LeaseUntil sql.NullString
}
// Gives up on a notification. attempts counts the delivery attempt that
// failed, if one was made. The lease must still be the claim's.
func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failNotification,
arg.Attempted,
arg.LastError,
arg.Now,
arg.ID,
arg.LeaseUntil,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getNotificationPref = `-- name: GetNotificationPref :one
SELECT event, push, chat, created_at, updated_at FROM notification_prefs
WHERE event = ?
`
func (q *Queries) GetNotificationPref(ctx context.Context, event string) (NotificationPref, error) {
row := q.db.QueryRowContext(ctx, getNotificationPref, event)
var i NotificationPref
err := row.Scan(
&i.Event,
&i.Push,
&i.Chat,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertNotification = `-- name: InsertNotification :execrows
INSERT INTO notification_outbox (
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
) VALUES (
?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?6
)
ON CONFLICT (event_key, channel) DO NOTHING
`
type InsertNotificationParams struct {
ID string
EventKey string
Channel string
Payload string
NextAttemptAt sql.NullString
Now string
}
// Stores a pending notification for one channel, due at once. A row already
// stored for the event key and channel, in any status, is left unchanged and
// no row is inserted.
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error) {
result, err := q.db.ExecContext(ctx, insertNotification,
arg.ID,
arg.EventKey,
arg.Channel,
arg.Payload,
arg.NextAttemptAt,
arg.Now,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const markNotificationSent = `-- name: MarkNotificationSent :execrows
UPDATE notification_outbox
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
WHERE id = ?2 AND status = 'pending' AND next_attempt_at = ?3
`
type MarkNotificationSentParams struct {
Now string
ID string
LeaseUntil sql.NullString
}
// Records a delivered notification; updated_at is when it was sent. The
// lease must still be the claim's.
func (q *Queries) MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markNotificationSent, arg.Now, arg.ID, arg.LeaseUntil)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const nextNotificationDue = `-- name: NextNotificationDue :one
SELECT next_attempt_at FROM notification_outbox
WHERE status = 'pending'
ORDER BY next_attempt_at
LIMIT 1
`
// Returns when the next pending notification is due. Returns sql.ErrNoRows
// when none is pending.
func (q *Queries) NextNotificationDue(ctx context.Context) (sql.NullString, error) {
row := q.db.QueryRowContext(ctx, nextNotificationDue)
var next_attempt_at sql.NullString
err := row.Scan(&next_attempt_at)
return next_attempt_at, err
}
const retryNotification = `-- name: RetryNotification :execrows
UPDATE notification_outbox
SET attempts = attempts + 1, next_attempt_at = ?1, last_error = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
`
type RetryNotificationParams struct {
NextAttemptAt sql.NullString
LastError sql.NullString
Now string
ID string
LeaseUntil sql.NullString
}
// Records a failed delivery and when to try again. The lease must still be
// the claim's.
func (q *Queries) RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error) {
result, err := q.db.ExecContext(ctx, retryNotification,
arg.NextAttemptAt,
arg.LastError,
arg.Now,
arg.ID,
arg.LeaseUntil,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}