Files
silo-server/internal/notifications/fanout_worker.go
a196b0844e feat(notifications): add Android FCM push delivery via the relay (#409)
Extend the push pipeline to Android devices through the Silo push
relay's /v1/fcm/send endpoint. push_devices gains platform-conditional
FCM token columns (encrypted at rest with row AAD, hashed like APNs
tokens), the generic POST /notifications/push/devices endpoint the
Android client already calls registers FCM tokens, and fanout,
operational dispatch, retries, and terminal UNREGISTERED device
disabling all reuse the existing Apple machinery. Delivery is gated by
a new notifications.android_push_delivery_enabled setting, advertised
through the capability endpoint's android_push block, and testable via
POST /admin/notifications/push/fcm/test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:44:39 -04:00

508 lines
16 KiB
Go

package notifications
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/oklog/ulid/v2"
)
const (
fanoutPollInterval = 15 * time.Second
fanoutClaimLimit = 100
)
// FanoutWorker consumes unprocessed release_events and materializes
// notification_deliveries plus realtime dispatch. It is safe to run on
// multiple nodes: claiming uses FOR UPDATE SKIP LOCKED, delivery inserts are
// idempotent, and the last-notified cursor update is guarded.
type FanoutWorker struct {
pool *pgxpool.Pool
releases *ReleaseRepository
interests *InterestRepository
deliveries *DeliveryRepository
preferences *PreferencesRepository
settings *Settings
dispatcher Dispatcher
logger *slog.Logger
nudge chan struct{}
// Per-target outboxes: when set, the fanout transaction also enqueues
// `pending` attempt rows for each newly inserted delivery — one per
// enabled, reason-matching webhook and one per enabled web push
// subscription.
webhooks *WebhookRepository
rateLimiter *profileRateLimiter
webPush *WebPushRepository
pushDevices *PushDeviceRepository
}
// SetWebhookOutbox wires durable webhook attempt enqueueing into the fanout
// transaction.
func (w *FanoutWorker) SetWebhookOutbox(webhooks *WebhookRepository, limiter *profileRateLimiter) {
w.webhooks = webhooks
w.rateLimiter = limiter
}
// SetWebPushOutbox wires durable web push attempt enqueueing into the fanout
// transaction.
func (w *FanoutWorker) SetWebPushOutbox(webPush *WebPushRepository) {
w.webPush = webPush
}
// SetPushOutbox wires durable Apple push attempt enqueueing into the fanout
// transaction.
func (w *FanoutWorker) SetPushOutbox(pushDevices *PushDeviceRepository) {
w.pushDevices = pushDevices
}
// NewFanoutWorker creates a FanoutWorker.
func NewFanoutWorker(
pool *pgxpool.Pool,
releases *ReleaseRepository,
interests *InterestRepository,
deliveries *DeliveryRepository,
preferences *PreferencesRepository,
settings *Settings,
dispatcher Dispatcher,
) *FanoutWorker {
return &FanoutWorker{
pool: pool,
releases: releases,
interests: interests,
deliveries: deliveries,
preferences: preferences,
settings: settings,
dispatcher: dispatcher,
logger: slog.Default().With("component", "notifications.fanout"),
nudge: make(chan struct{}, 1),
}
}
// Nudge schedules a near-term claim pass (after the settle delay) so
// notifications feel realtime without tight polling. Non-blocking.
func (w *FanoutWorker) Nudge() {
if w == nil {
return
}
select {
case w.nudge <- struct{}{}:
default:
}
}
// Run processes release events until ctx is canceled.
func (w *FanoutWorker) Run(ctx context.Context) {
ticker := time.NewTicker(fanoutPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-w.nudge:
// Wait out the settle delay so one scan's burst for a series
// lands in a single claim batch instead of trickling through.
settle := w.settings.SettleDelay(ctx)
select {
case <-ctx.Done():
return
case <-time.After(settle + time.Second):
}
}
if !w.settings.FanoutEnabled(ctx) {
continue
}
for {
processed, err := w.processBatch(ctx)
if err != nil {
if ctx.Err() == nil {
w.logger.ErrorContext(ctx, "fanout batch failed", "error", err)
}
break
}
if processed == 0 {
break
}
}
}
}
// processBatch claims one batch of release events and fans them out. The
// claim, delivery inserts, cursor updates, and processed marks share one
// transaction so an event is never marked processed without durable
// deliveries; reprocessing after a crash is harmless because delivery inserts
// dedupe. Returns the number of events handled (fanned out + suppressed).
func (w *FanoutWorker) processBatch(ctx context.Context) (int, error) {
started := time.Now()
settle := w.settings.SettleDelay(ctx)
maxBurst := w.settings.MaxSeriesBurst(ctx)
tx, err := w.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin fanout tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
claimed, err := w.releases.ClaimUnprocessed(ctx, tx, settle, fanoutClaimLimit)
if err != nil {
return 0, err
}
if len(claimed) == 0 {
return 0, nil
}
// Non-episode kinds (movies, audiobooks, ebooks) have no per-profile
// interest and never fan out; mark them processed immediately so retention
// reclaims them. This must happen before the burst cap: flat item events
// have no series_id, and ApplyBurstCap groups by (library_id, series_id). The server-channel
// sweep reads events by cursor regardless of processed state.
events, others := PartitionEventsByKind(claimed)
if err := w.releases.MarkProcessed(ctx, tx, eventIDs(others), nil); err != nil {
return 0, err
}
// Suppress events that aged past the staleness horizon before fanout
// could run (fanout disabled for a stretch, extended downtime): users
// should not receive a backlog of long-stale "new episode" notifications
// when the worker comes back.
staleCutoff := time.Now().Add(-w.settings.MaxEventAge(ctx))
fresh := make([]ReleaseEvent, 0, len(events))
stale := make([]ReleaseEvent, 0)
for _, event := range events {
if event.CreatedAt.Before(staleCutoff) {
stale = append(stale, event)
} else {
fresh = append(fresh, event)
}
}
if len(stale) > 0 {
reason := SuppressedReasonStale
if err := w.releases.MarkProcessed(ctx, tx, eventIDs(stale), &reason); err != nil {
return 0, err
}
}
fanout, suppressed := ApplyBurstCap(fresh, maxBurst)
if len(suppressed) > 0 {
reason := SuppressedReasonSeriesBurst
if err := w.releases.MarkProcessed(ctx, tx, eventIDs(suppressed), &reason); err != nil {
return 0, err
}
}
totalRecipients := 0
totalInserted := 0
dispatchRows := make([]DeliveryRow, 0, 32)
for _, event := range fanout {
rows, recipients, err := w.fanOutEvent(ctx, tx, event)
if err != nil {
return 0, fmt.Errorf("fan out event %s: %w", event.ID, err)
}
totalRecipients += recipients
totalInserted += len(rows)
dispatchRows = append(dispatchRows, rows...)
}
if err := w.releases.MarkProcessed(ctx, tx, eventIDs(fanout), nil); err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit fanout tx: %w", err)
}
// Publish realtime events only after the delivery transaction commits.
// Failures here are tolerable: the durable rows appear on reconnect.
for _, row := range dispatchRows {
if err := w.dispatcher.Dispatch(ctx, row); err != nil {
w.logger.WarnContext(ctx, "notification dispatch failed",
"delivery_id", row.ID, "profile_id", row.ProfileID, "error", err)
}
}
w.logger.InfoContext(ctx, "fanout batch processed",
"claimed", len(claimed),
"non_episode", len(others),
"fanned_out", len(fanout),
"suppressed", len(suppressed),
"stale", len(stale),
"recipient_count", totalRecipients,
"inserted_count", totalInserted,
"deduped_count", totalRecipients-totalInserted,
"duration_ms", time.Since(started).Milliseconds(),
)
return len(claimed), nil
}
// fanOutEvent resolves recipients for one release event and inserts
// deliveries. Returns dispatch payloads for the rows actually inserted and
// the candidate recipient count.
func (w *FanoutWorker) fanOutEvent(ctx context.Context, tx pgx.Tx, event ReleaseEvent) ([]DeliveryRow, int, error) {
candidates, err := w.interests.ListActiveBySeries(ctx, tx, event.LibraryID, event.SeriesID)
if err != nil {
return nil, 0, err
}
if len(candidates) == 0 {
return nil, 0, nil
}
profileIDs := make([]string, 0, len(candidates))
for _, candidate := range candidates {
profileIDs = append(profileIDs, candidate.ProfileID)
}
prefs, err := w.preferences.GetMany(ctx, tx, profileIDs)
if err != nil {
return nil, 0, err
}
pending := make(map[string]pendingDelivery, len(candidates))
toInsert := make([]Delivery, 0, len(candidates))
for _, candidate := range candidates {
flags, eligible := EvaluateRecipient(candidate, prefs[candidate.ProfileID], event.EpisodeKey)
if !eligible {
continue
}
flagsJSON, err := json.Marshal(flags)
if err != nil {
return nil, 0, fmt.Errorf("marshal reason flags: %w", err)
}
eventID := event.ID
libraryID := event.LibraryID
seriesID := event.SeriesID
episodeID := event.EpisodeID
delivery := Delivery{
ID: ulid.Make().String(),
ReleaseEventID: &eventID,
UserID: candidate.UserID,
ProfileID: candidate.ProfileID,
LibraryID: &libraryID,
SeriesID: &seriesID,
EpisodeID: &episodeID,
Type: DeliveryTypeEpisodeAvailable,
ReasonFlags: flagsJSON,
}
pending[candidate.ProfileID] = pendingDelivery{delivery: delivery, flags: flags}
toInsert = append(toInsert, delivery)
}
if len(toInsert) == 0 {
return nil, len(candidates), nil
}
inserted, err := w.deliveries.BulkInsert(ctx, tx, toInsert)
if err != nil {
return nil, 0, err
}
// Durable dispatch outbox: enqueue per-target attempt rows in the same
// transaction so a crash after commit delays webhooks instead of silently
// dropping them. Push channels (specs 02-03) plug in here the same way.
if err := w.enqueueWebhookOutbox(ctx, tx, inserted, pending); err != nil {
return nil, 0, err
}
if err := w.enqueueWebPushOutbox(ctx, tx, inserted); err != nil {
return nil, 0, err
}
if err := w.enqueuePushOutbox(ctx, tx, inserted); err != nil {
return nil, 0, err
}
notifiedProfiles := make([]string, 0, len(inserted))
for _, row := range inserted {
notifiedProfiles = append(notifiedProfiles, row.ProfileID)
}
if err := w.interests.GuardedSetLastNotified(ctx, tx, event.LibraryID, event.SeriesID, event.EpisodeKey, notifiedProfiles); err != nil {
return nil, 0, err
}
display, err := loadEventDisplay(ctx, tx, event.EpisodeID)
if err != nil {
return nil, 0, err
}
dispatchRows := make([]DeliveryRow, 0, len(inserted))
for _, row := range inserted {
entry, ok := pending[row.ProfileID]
if !ok {
continue
}
delivery := entry.delivery
delivery.ID = row.ID
delivery.CreatedAt = row.CreatedAt
seasonNumber := event.SeasonNumber
episodeNumber := event.EpisodeNumber
dispatchRows = append(dispatchRows, DeliveryRow{
Delivery: delivery,
SeriesTitle: display.seriesTitle,
EpisodeTitle: display.episodeTitle,
SeasonNumber: &seasonNumber,
EpisodeNumber: &episodeNumber,
PosterPath: display.posterPath,
PosterThumbhash: display.posterThumbhash,
PosterSourcePath: display.posterSourcePath,
})
}
return dispatchRows, len(candidates), nil
}
// pendingDelivery pairs a candidate delivery with its matched reason flags so
// the outbox enqueue can apply per-webhook reason filters.
type pendingDelivery struct {
delivery Delivery
flags ReasonFlags
}
// enqueuePushOutbox inserts pending push attempt rows for each newly inserted
// delivery and enabled private-push device on every admin-enabled platform
// (Apple and Android).
func (w *FanoutWorker) enqueuePushOutbox(ctx context.Context, tx pgx.Tx, inserted []InsertedDelivery) error {
if w.pushDevices == nil || len(inserted) == 0 {
return nil
}
platforms := w.settings.EnabledPushPlatforms(ctx)
if len(platforms) == 0 {
return nil
}
profileSet := make(map[string]struct{}, len(inserted))
profileIDs := make([]string, 0, len(inserted))
for _, row := range inserted {
if _, ok := profileSet[row.ProfileID]; !ok {
profileSet[row.ProfileID] = struct{}{}
profileIDs = append(profileIDs, row.ProfileID)
}
}
devicesByProfile, err := w.pushDevices.ListEnabledPushByProfiles(ctx, tx, profileIDs, platforms)
if err != nil {
return err
}
attempts := make([]PushDeliveryAttempt, 0, len(inserted))
for _, row := range inserted {
attempts = append(attempts, newPushDeliveryAttempts(row.ID, devicesByProfile[row.ProfileID])...)
}
return w.pushDevices.EnqueuePushAttempts(ctx, tx, attempts)
}
// enqueueWebPushOutbox inserts `pending` web push attempt rows for each newly
// inserted delivery, inside the fanout transaction. Subscriptions have no
// per-reason filters: profile preferences already gated delivery creation,
// and push volume is bounded by the per-series burst cap.
func (w *FanoutWorker) enqueueWebPushOutbox(ctx context.Context, tx pgx.Tx, inserted []InsertedDelivery) error {
if w.webPush == nil || len(inserted) == 0 || !w.settings.WebPushEnabled(ctx) {
return nil
}
profileSet := make(map[string]struct{}, len(inserted))
profileIDs := make([]string, 0, len(inserted))
for _, row := range inserted {
if _, ok := profileSet[row.ProfileID]; !ok {
profileSet[row.ProfileID] = struct{}{}
profileIDs = append(profileIDs, row.ProfileID)
}
}
subsByProfile, err := w.webPush.ListEnabledByProfiles(ctx, tx, profileIDs)
if err != nil {
return err
}
attempts := make([]DeliveryAttempt, 0, len(inserted))
for _, row := range inserted {
for _, sub := range subsByProfile[row.ProfileID] {
attempts = append(attempts, DeliveryAttempt{
ID: ulid.Make().String(),
NotificationDeliveryID: row.ID,
TargetID: sub.ID,
})
}
}
return w.webPush.EnqueueAttempts(ctx, tx, attempts)
}
// enqueueWebhookOutbox inserts `pending` webhook attempt rows for each newly
// inserted delivery, inside the fanout transaction. Two filters apply:
// per-webhook reason flags, and the per-profile delivery rate limit
// (over-limit notifications stay in the inbox; webhooks just don't fire).
func (w *FanoutWorker) enqueueWebhookOutbox(ctx context.Context, tx pgx.Tx, inserted []InsertedDelivery, pending map[string]pendingDelivery) error {
if w.webhooks == nil || len(inserted) == 0 || !w.settings.WebhooksEnabled(ctx) {
return nil
}
profileSet := make(map[string]struct{}, len(inserted))
profileIDs := make([]string, 0, len(inserted))
for _, row := range inserted {
if _, ok := profileSet[row.ProfileID]; !ok {
profileSet[row.ProfileID] = struct{}{}
profileIDs = append(profileIDs, row.ProfileID)
}
}
hooksByProfile, err := w.webhooks.ListEnabledByProfiles(ctx, tx, profileIDs)
if err != nil {
return err
}
rateLimit := w.settings.WebhooksDeliveriesPerMinute(ctx)
attempts := make([]DeliveryAttempt, 0, len(inserted))
rateLimited := 0
for _, row := range inserted {
entry, ok := pending[row.ProfileID]
if !ok {
continue
}
for _, hook := range hooksByProfile[row.ProfileID] {
if !hook.MatchesReasons(entry.flags) {
continue
}
if w.rateLimiter != nil && !w.rateLimiter.Allow(row.ProfileID, rateLimit) {
rateLimited++
continue
}
attempts = append(attempts, DeliveryAttempt{
ID: ulid.Make().String(),
NotificationDeliveryID: row.ID,
TargetID: hook.ID,
})
}
}
if rateLimited > 0 {
w.logger.WarnContext(ctx, "webhook deliveries rate limited", "skipped", rateLimited)
}
return w.webhooks.EnqueueAttempts(ctx, tx, attempts)
}
type eventDisplay struct {
seriesTitle string
episodeTitle string
posterPath string
posterThumbhash string
posterSourcePath string
}
// loadEventDisplay fetches per-event display metadata once and reuses it for
// every recipient's dispatch payload.
func loadEventDisplay(ctx context.Context, tx pgx.Tx, episodeID string) (eventDisplay, error) {
var display eventDisplay
err := tx.QueryRow(ctx, `
SELECT COALESCE(e.title, ''), COALESCE(s.title, ''),
COALESCE(s.poster_path, ''), COALESCE(s.poster_thumbhash, ''),
COALESCE(s.poster_source_path, '')
FROM episodes e
LEFT JOIN media_items s ON s.content_id = e.series_id
WHERE e.content_id = $1`,
episodeID,
).Scan(&display.episodeTitle, &display.seriesTitle, &display.posterPath,
&display.posterThumbhash, &display.posterSourcePath)
if errors.Is(err, pgx.ErrNoRows) {
return eventDisplay{}, nil
}
if err != nil {
return display, fmt.Errorf("load event display metadata: %w", err)
}
return display, nil
}
func eventIDs(events []ReleaseEvent) []string {
ids := make([]string, 0, len(events))
for _, event := range events {
ids = append(ids, event.ID)
}
return ids
}