Files
silo-server/internal/notifications/system.go
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:53:52 -04:00

872 lines
29 KiB
Go

package notifications
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/discord"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/mail"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
// UserLister enumerates login accounts for the interest backfill. Satisfied
// by *auth.UserRepository.
type UserLister interface {
List(ctx context.Context) ([]*models.User, error)
}
// ImageURLResolver presigns stored image paths into client-fetchable URLs.
// Satisfied by *catalog.DetailService.
type ImageURLResolver interface {
PresignImageURL(ctx context.Context, path, imageType, size string) string
}
// System bundles the user-facing release-notification services: availability
// detection, interest maintenance, fanout, inbox repositories, and websocket
// tickets. It is distinct from the operational Hub in hub.go.
type System struct {
Settings *Settings
Releases *ReleaseRepository
Interests *InterestRepository
Deliveries *DeliveryRepository
Preferences *PreferencesRepository
Detector *AvailabilityDetector
Interest *InterestUpdater
Fanout *FanoutWorker
Tickets TicketStore
// Webhooks is nil when no at-rest cipher is configured (webhook URLs are
// credentials and must not be stored in plaintext).
Webhooks *WebhookService
// ServerChannels is nil when no at-rest cipher is configured (channel
// URLs are credentials, same rule as webhooks).
ServerChannels *ServerChannelService
// WebPush is nil when the settings store is not writable (VAPID keys
// could not be provisioned).
WebPush *WebPushService
// PushDevices is nil when no at-rest cipher is configured; APNs tokens are
// credentials and must not be stored in plaintext.
PushDevices *PushDeviceService
// EmailPrefs is nil when no mail sender was provided.
EmailPrefs *EmailPrefsRepository
// DiscordPrefs holds Discord DM link + mode state; the channel only
// delivers once an admin configures bot credentials in settings.
DiscordPrefs *DiscordPrefsRepository
mailSender mail.Sender
emailWorker *accountChannelWorker[string]
discordWorker *accountChannelWorker[int]
discordClient *discord.Client
// publicURL is the server's externally reachable base URL, used as the
// fallback for tokenized email links (see SetPublicURL).
publicURL string
webhookRepo *WebhookRepository
webhookDispatcher *WebhookDispatcher
webhookRetry *WebhookRetryWorker
webPushRepo *WebPushRepository
webPushDispatcher *WebPushDispatcher
pushDeviceRepo *PushDeviceRepository
pushDispatcher *PushDispatcher
pushSender *pushSender
// serverChannelWorker sweeps release_events into admin broadcast posts;
// nil without the at-rest cipher.
serverChannelWorker *serverChannelWorker
// dispatcher is the same MultiDispatcher the fanout worker uses; the
// operational dispatch path shares it so every delivery reaches every
// configured channel the same way.
dispatcher Dispatcher
pool *pgxpool.Pool
stores userstore.UserStoreProvider
users UserLister
images ImageURLResolver
logger *slog.Logger
wg sync.WaitGroup
}
// NewSystem wires the notification system. hub may be nil (no realtime
// publishing); redisClient may be nil (in-memory websocket tickets);
// mailSender may be nil (no email channel).
func NewSystem(
pool *pgxpool.Pool,
settingsReader SettingReader,
stores userstore.UserStoreProvider,
scopes ScopeResolver,
users UserLister,
hub *evt.Hub,
redisClient *redis.Client,
cipher *secret.Cipher,
mailSender mail.Sender,
) *System {
settings := NewSettings(settingsReader)
releases := NewReleaseRepository(pool)
interests := NewInterestRepository(pool)
deliveries := NewDeliveryRepository(pool)
preferences := NewPreferencesRepository(pool)
wsDispatcher := NewWebsocketDispatcher(hub)
dispatchers := []Dispatcher{wsDispatcher}
// Outbound webhooks require the at-rest cipher: destination URLs are
// bearer credentials (Discord) and must never be stored in plaintext.
var webhookRepo *WebhookRepository
var webhookService *WebhookService
var webhookDispatcher *WebhookDispatcher
var webhookRetry *WebhookRetryWorker
var sender *webhookSender
var pushDeviceService *PushDeviceService
var pushDeviceRepo *PushDeviceRepository
var pushSenderInst *pushSender
var pushDispatcher *PushDispatcher
if cipher != nil {
webhookRepo = NewWebhookRepository(pool)
sender = newWebhookSender(webhookRepo, deliveries, cipher, settings)
webhookService = newWebhookService(webhookRepo, cipher, settings, sender)
webhookDispatcher = newWebhookDispatcher(sender)
webhookRetry = newWebhookRetryWorker(sender)
dispatchers = append(dispatchers, webhookDispatcher)
pushDeviceRepo = NewPushDeviceRepository(pool)
pushDeviceService = NewPushDeviceService(pushDeviceRepo, cipher)
pushSenderInst = newPushSender(pushDeviceRepo, deliveries, cipher, settings)
pushDispatcher = newPushDispatcher(pushSenderInst)
dispatchers = append(dispatchers, pushDispatcher)
}
// Admin server channels (broadcast destinations) share the cipher
// requirement: their URLs are credentials too.
var serverChannelService *ServerChannelService
var serverChannelSweep *serverChannelWorker
if cipher != nil {
serverChannelRepo := NewServerChannelRepository(pool)
serverChannelSenderInst := newServerChannelSender(cipher, settings)
serverChannelService = newServerChannelService(serverChannelRepo, cipher, settings, serverChannelSenderInst)
serverChannelSweep = newServerChannelWorker(pool, serverChannelRepo, releases, serverChannelSenderInst, settings)
}
// Web push needs a writable settings store to self-provision its VAPID
// keypair. The reader main passes is the encrypted settings repo, which
// also writes; tests may pass a read-only stub.
var webPushRepo *WebPushRepository
var webPushService *WebPushService
var webPushDispatcher *WebPushDispatcher
var webPushSenderInst *webPushSender
if writer, ok := settingsReader.(SettingWriter); ok && writer != nil {
webPushRepo = NewWebPushRepository(pool)
webPushService = newWebPushService(webPushRepo, settings, writer)
webPushSenderInst = newWebPushSender(webPushRepo, deliveries, webPushService, settings)
webPushDispatcher = newWebPushDispatcher(webPushSenderInst)
dispatchers = append(dispatchers, webPushDispatcher)
}
// Email rides the shared SMTP core. Unlike the per-target channels it
// keeps no outbox: its dispatcher only nudges the watermark sweep.
var emailPrefs *EmailPrefsRepository
var emailChannelInst *emailChannel
var emailWorker *accountChannelWorker[string]
if mailSender != nil {
emailPrefs = NewEmailPrefsRepository(pool)
emailChannelInst = &emailChannel{
prefs: emailPrefs,
deliveries: deliveries,
settings: settings,
sender: mailSender,
}
emailWorker = newAccountChannelWorker(pool, emailChannelInst)
dispatchers = append(dispatchers, newNudgeDispatcher(emailWorker))
}
// Discord DMs ride the same account-watermark engine as email. The
// channel is always wired (its credentials live in settings and may be
// configured at runtime); enabled() gates each pass on the bot token.
discordPrefs := NewDiscordPrefsRepository(pool)
discordClient := discord.NewClient()
discordWorker, discordChannelInst := newDiscordWorker(pool, deliveries, discordPrefs, settings, discordClient)
dispatchers = append(dispatchers, newNudgeDispatcher(discordWorker))
multiDispatcher := NewMultiDispatcher(dispatchers...)
fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, multiDispatcher)
if webhookRepo != nil {
fanout.SetWebhookOutbox(webhookRepo, newProfileRateLimiter())
}
if webPushRepo != nil {
fanout.SetWebPushOutbox(webPushRepo)
}
if pushDeviceRepo != nil {
fanout.SetPushOutbox(pushDeviceRepo)
}
detector := NewAvailabilityDetector(releases, settings)
detector.SetFanoutNudge(func() {
fanout.Nudge()
serverChannelSweep.Nudge() // nil-safe
})
interest := NewInterestUpdater(pool, interests, stores, scopes)
system := &System{
Settings: settings,
Releases: releases,
Interests: interests,
Deliveries: deliveries,
Preferences: preferences,
Detector: detector,
Interest: interest,
Fanout: fanout,
Tickets: NewTicketStore(redisClient),
Webhooks: webhookService,
ServerChannels: serverChannelService,
WebPush: webPushService,
PushDevices: pushDeviceService,
EmailPrefs: emailPrefs,
DiscordPrefs: discordPrefs,
mailSender: mailSender,
emailWorker: emailWorker,
discordWorker: discordWorker,
discordClient: discordClient,
webhookRepo: webhookRepo,
webhookDispatcher: webhookDispatcher,
webhookRetry: webhookRetry,
webPushRepo: webPushRepo,
webPushDispatcher: webPushDispatcher,
pushDeviceRepo: pushDeviceRepo,
pushDispatcher: pushDispatcher,
pushSender: pushSenderInst,
serverChannelWorker: serverChannelSweep,
dispatcher: multiDispatcher,
pool: pool,
stores: stores,
users: users,
logger: slog.Default().With("component", "notifications.system"),
}
wsDispatcher.payload = system.PayloadForRow
if emailChannelInst != nil {
emailChannelInst.profileName = system.lookupProfileName
}
if sender != nil {
sender.operational = system.DispatchOperational
sender.posterURL = system.discordPosterURL
}
discordChannelInst.posterURL = system.discordPosterURL
if serverChannelSweep != nil {
serverChannelSweep.posterURL = system.discordPosterURL
serverChannelSweep.requesterDiscordID = system.requesterDiscordID
}
if webPushSenderInst != nil {
webPushSenderInst.payload = system.PayloadForRow
}
return system
}
// SetImageResolver wires presigned poster URLs into notification payloads.
// Optional; without it clients fall back to thumbhash placeholders.
func (s *System) SetImageResolver(resolver ImageURLResolver) {
if s != nil {
s.images = resolver
}
}
// discordPosterURL resolves a delivery's poster to a URL a Discord embed may
// carry, honoring the admin's poster mode: nothing when posters are off,
// public provider CDN URLs when derivable, and — only on the explicit
// "server" opt-in — a presigned URL from this server's own image storage.
// Wired into the Discord send paths as their posterURL hook.
func (s *System) discordPosterURL(ctx context.Context, posterPath, posterSourcePath string) string {
mode := s.Settings.DiscordPosterMode(ctx)
if mode == DiscordPostersOff {
return ""
}
if url := embedPosterURL(posterPath, posterSourcePath); url != "" {
return url
}
if mode != DiscordPostersServer || s.images == nil || posterPath == "" {
return ""
}
return s.images.PresignImageURL(ctx, posterPath, "poster", "")
}
// PayloadForRow converts a row to its wire shape, attaching a presigned
// poster URL when an image resolver is configured.
func (s *System) PayloadForRow(ctx context.Context, row DeliveryRow) DeliveryRowPayload {
payload := PayloadForRow(row)
if s != nil && s.images != nil && row.PosterPath != "" {
payload.PosterURL = s.images.PresignImageURL(ctx, row.PosterPath, "poster", "")
}
return payload
}
// PayloadsForRows converts rows to their wire shape with poster URLs.
func (s *System) PayloadsForRows(ctx context.Context, rows []DeliveryRow) []DeliveryRowPayload {
payloads := make([]DeliveryRowPayload, 0, len(rows))
for _, row := range rows {
payloads = append(payloads, s.PayloadForRow(ctx, row))
}
return payloads
}
// Start launches the fanout worker, interest updater, and (when configured)
// the webhook dispatch pool and retry worker under ctx.
func (s *System) Start(ctx context.Context) {
if s == nil {
return
}
s.wg.Add(2)
go func() {
defer s.wg.Done()
s.Fanout.Run(ctx)
}()
go func() {
defer s.wg.Done()
s.Interest.Run(ctx)
}()
if s.webhookDispatcher != nil {
s.wg.Add(2)
go func() {
defer s.wg.Done()
s.webhookDispatcher.Run(ctx)
}()
go func() {
defer s.wg.Done()
s.webhookRetry.Run(ctx)
}()
}
if s.serverChannelWorker != nil {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.serverChannelWorker.Run(ctx)
}()
}
if s.emailWorker != nil {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.emailWorker.Run(ctx)
}()
}
if s.discordWorker != nil {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.discordWorker.Run(ctx)
}()
}
if s.webPushDispatcher != nil {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.webPushDispatcher.Run(ctx)
}()
// Provision the VAPID keypair eagerly so a broken settings store
// surfaces at startup instead of on the first subscribe, and the
// capability endpoint never pays the generation latency.
s.wg.Add(1)
go func() {
defer s.wg.Done()
if _, err := s.WebPush.PublicKey(ctx); err != nil && ctx.Err() == nil {
s.logger.ErrorContext(ctx, "web push VAPID provisioning failed", "error", err)
}
}()
}
if s.pushDispatcher != nil {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.pushDispatcher.Run(ctx)
}()
}
}
// Wait blocks until the background loops exit (after their context is
// canceled), so shutdown can drain in-flight work.
func (s *System) Wait() {
if s != nil {
s.wg.Wait()
}
}
// PurgeProfile removes all notification state for a deleted profile.
// Profiles may live in per-user SQLite stores, so Postgres cascades cannot
// cover this.
func (s *System) PurgeProfile(ctx context.Context, profileID string) error {
if s == nil || profileID == "" {
return nil
}
if err := s.Interests.DeleteAllForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge interest rows: %w", err)
}
if err := s.Deliveries.DeleteAllForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge deliveries: %w", err)
}
if err := s.Preferences.DeleteForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge preferences: %w", err)
}
if s.webhookRepo != nil {
if err := s.webhookRepo.DeleteAllForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge webhooks: %w", err)
}
}
if s.webPushRepo != nil {
if err := s.webPushRepo.DeleteAllForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge web push subscriptions: %w", err)
}
}
if s.pushDeviceRepo != nil {
if err := s.pushDeviceRepo.DeleteAllForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge push devices: %w", err)
}
}
if s.EmailPrefs != nil {
if err := s.EmailPrefs.DeleteForProfile(ctx, profileID); err != nil {
return fmt.Errorf("purge email prefs: %w", err)
}
}
return nil
}
// SeedAvailability inserts episode_availability and movie_availability for
// every currently playable episode and movie without creating release events,
// then writes the per-library, per-kind seed markers. Idempotent and
// rerunnable; it exists for libraries that predate the notifications feature.
//
// Library selection is load-bearing for flood and loss safety:
// - already-seeded libraries are skipped: availability there is owned by
// the scan-end detector, and a silent (emitEvents=false) insert racing an
// in-flight scan would permanently suppress the claimed episode's release
// event;
// - never-fully-scanned libraries are skipped: their catalog is empty or
// partial, so seed-marking them now would make the first real scan emit
// release events for the entire back catalog. The detector writes their
// marker when that first full scan completes.
func (s *System) SeedAvailability(ctx context.Context, progress func(percent int, message string)) error {
report := func(percent int, message string) {
if progress != nil {
progress(percent, message)
}
}
// Each kind seeds independently with its own seed markers, because the
// episode pass historically marked every scanned library (movie libraries
// included) with zero movie rows. The flat item kinds come from the
// registry so a new kind cannot be forgotten here.
type seedPass struct {
kind string
seedCondition string
record func(ctx context.Context, libraryID int, emitEvents bool) (int, int, error)
}
passes := []seedPass{{
kind: EventKindEpisode,
seedCondition: `SELECT 1 FROM notification_library_seed_state seed WHERE seed.library_id = mf.id`,
record: s.Releases.RecordAvailabilityForLibrary,
}}
for _, k := range flatItemKinds {
passes = append(passes, seedPass{
kind: k.Kind,
seedCondition: `SELECT 1 FROM notification_content_seed_state seed
WHERE seed.library_id = mf.id AND seed.kind = '` + k.Kind + `'`,
record: func(ctx context.Context, libraryID int, emitEvents bool) (int, int, error) {
return s.Releases.RecordItemAvailabilityForLibrary(ctx, k, libraryID, emitEvents)
},
})
}
for passIdx, pass := range passes {
rows, err := s.pool.Query(ctx, `
SELECT mf.id
FROM media_folders mf
WHERE mf.last_scanned_at IS NOT NULL
AND NOT EXISTS (`+pass.seedCondition+`)
ORDER BY mf.id`)
if err != nil {
return fmt.Errorf("list libraries: %w", err)
}
libraryIDs := make([]int, 0, 8)
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
rows.Close()
return fmt.Errorf("scan library id: %w", err)
}
libraryIDs = append(libraryIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
totalSeeded := 0
for i, libraryID := range libraryIDs {
if ctx.Err() != nil {
return ctx.Err()
}
inserted, _, err := pass.record(ctx, libraryID, false)
if err != nil {
return fmt.Errorf("seed library %d (%s): %w", libraryID, pass.kind, err)
}
if err := s.Releases.MarkContentSeeded(ctx, libraryID, pass.kind); err != nil {
return fmt.Errorf("mark library %d seeded (%s): %w", libraryID, pass.kind, err)
}
totalSeeded += inserted
// Each pass owns an equal slice of the progress range.
passSpan := 100 / len(passes)
passBase := passIdx * passSpan
report(passBase+(i+1)*passSpan/max(len(libraryIDs), 1),
fmt.Sprintf("Seeded library %d %s availability (%d new rows)", libraryID, pass.kind, inserted))
}
s.logger.InfoContext(ctx, "availability seeding completed",
"kind", pass.kind, "libraries", len(libraryIDs), "availability_rows", totalSeeded)
}
report(100, "Availability seeding completed")
return nil
}
const interestRebuildTask = "interest_rebuild"
// RebuildInterest incrementally rebuilds profile_series_interest from
// favorites, watchlist, and watch progress for every profile. Checkpointed
// per profile so a crash resumes with at most one profile of repeated work;
// recomputes are idempotent upserts. Completed runs reset and start over
// (the task doubles as periodic drift repair).
func (s *System) RebuildInterest(ctx context.Context, progress func(percent int, message string)) error {
report := func(percent int, message string) {
if progress != nil {
progress(percent, message)
}
}
if s.users == nil {
return fmt.Errorf("interest rebuild requires a user lister")
}
checkpoint, completedAt, err := s.loadBackfillCheckpoint(ctx, interestRebuildTask)
if err != nil {
return err
}
if completedAt != nil {
// Start a fresh repair pass.
checkpoint = ""
if err := s.resetBackfillCheckpoint(ctx, interestRebuildTask); err != nil {
return err
}
}
users, err := s.users.List(ctx)
if err != nil {
return fmt.Errorf("list users: %w", err)
}
sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID })
processed := 0
for userIdx, user := range users {
if ctx.Err() != nil {
return ctx.Err()
}
store, err := s.stores.ForUser(ctx, user.ID)
if err != nil {
s.logger.WarnContext(ctx, "interest rebuild: open user store failed", "user_id", user.ID, "error", err)
continue
}
profiles, err := store.ListProfiles(ctx)
if err != nil {
s.logger.WarnContext(ctx, "interest rebuild: list profiles failed", "user_id", user.ID, "error", err)
continue
}
sort.Slice(profiles, func(i, j int) bool { return profiles[i].ID < profiles[j].ID })
for _, profile := range profiles {
key := backfillKey(user.ID, profile.ID)
if checkpoint != "" && key <= checkpoint {
continue
}
if err := s.rebuildProfileInterest(ctx, store, user.ID, profile.ID); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
s.logger.WarnContext(ctx, "interest rebuild: profile failed",
"user_id", user.ID, "profile_id", profile.ID, "error", err)
}
if err := s.saveBackfillCheckpoint(ctx, interestRebuildTask, key); err != nil {
return err
}
processed++
}
report((userIdx+1)*100/max(len(users), 1),
fmt.Sprintf("Rebuilt interest for %d profiles", processed))
}
if err := s.completeBackfillCheckpoint(ctx, interestRebuildTask); err != nil {
return err
}
s.logger.InfoContext(ctx, "interest rebuild completed", "profiles", processed)
return nil
}
// rebuildProfileInterest recomputes every series the profile has any
// relationship with (favorites, watchlist, progress, completed watch
// history), plus every series that already has interest rows in Postgres —
// rows whose sources were all removed must be recomputed too, or the
// drift-repair pass would keep notifying about unfollowed shows forever.
func (s *System) rebuildProfileInterest(ctx context.Context, store userstore.UserStore, userID int, profileID string) error {
const pageSize = 500
itemIDs := make(map[string]struct{}, 64)
for offset := 0; ; offset += pageSize {
favorites, err := store.ListFavorites(ctx, profileID, pageSize, offset)
if err != nil {
return fmt.Errorf("list favorites: %w", err)
}
for _, favorite := range favorites {
itemIDs[favorite.MediaItemID] = struct{}{}
}
if len(favorites) < pageSize {
break
}
}
for offset := 0; ; offset += pageSize {
watchlist, err := store.ListWatchlist(ctx, profileID, pageSize, offset)
if err != nil {
return fmt.Errorf("list watchlist: %w", err)
}
for _, entry := range watchlist {
itemIDs[entry.MediaItemID] = struct{}{}
}
if len(watchlist) < pageSize {
break
}
}
for offset := 0; ; offset += pageSize {
progress, err := store.ListProgress(ctx, profileID, "", pageSize, offset)
if err != nil {
return fmt.Errorf("list progress: %w", err)
}
for _, entry := range progress {
itemIDs[entry.MediaItemID] = struct{}{}
}
if len(progress) < pageSize {
break
}
}
// History imports (watch providers, history import runs) may record
// watched episodes without any progress row; they are watch relationships
// all the same.
for offset := 0; ; offset += pageSize {
history, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{
ProfileID: profileID,
Limit: pageSize,
Offset: offset,
})
if err != nil {
return fmt.Errorf("list completed history: %w", err)
}
for _, entry := range history {
itemIDs[entry.MediaItemID] = struct{}{}
}
if len(history) < pageSize {
break
}
}
seriesIDs, err := s.batchResolveSeries(ctx, itemIDs)
if err != nil {
return err
}
existing, err := s.Interests.ListSeriesForProfile(ctx, profileID)
if err != nil {
return err
}
for _, seriesID := range existing {
seriesIDs[seriesID] = struct{}{}
}
for seriesID := range seriesIDs {
if ctx.Err() != nil {
return ctx.Err()
}
if err := s.Interest.RecomputeSeries(ctx, userID, profileID, seriesID); err != nil {
s.logger.WarnContext(ctx, "interest rebuild: series recompute failed",
"user_id", userID, "profile_id", profileID, "series_id", seriesID, "error", err)
}
}
return nil
}
// batchResolveSeries maps item IDs (episodes, seasons, series) to their
// series IDs; movies resolve to nothing.
func (s *System) batchResolveSeries(ctx context.Context, itemIDs map[string]struct{}) (map[string]struct{}, error) {
ids := make([]string, 0, len(itemIDs))
for id := range itemIDs {
ids = append(ids, id)
}
seriesIDs := make(map[string]struct{}, len(ids))
const chunkSize = 500
for start := 0; start < len(ids); start += chunkSize {
end := min(start+chunkSize, len(ids))
rows, err := s.pool.Query(ctx, `
SELECT series_id FROM episodes WHERE content_id = ANY($1)
UNION
SELECT series_id FROM seasons WHERE content_id = ANY($1)
UNION
SELECT content_id FROM media_items WHERE content_id = ANY($1) AND type = 'series'`,
ids[start:end])
if err != nil {
return nil, fmt.Errorf("resolve series ids: %w", err)
}
for rows.Next() {
var seriesID string
if err := rows.Scan(&seriesID); err != nil {
rows.Close()
return nil, fmt.Errorf("scan series id: %w", err)
}
if seriesID != "" {
seriesIDs[seriesID] = struct{}{}
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
}
return seriesIDs, nil
}
// RetentionStats reports what a retention pass removed.
type RetentionStats struct {
DeliveriesDeleted int64
EventsDeleted int64
StaleEventsDeleted int64
InterestPruned int64
WebhookAttemptsDeleted int64
WebPushAttemptsDeleted int64
DiscordLinkStatesDeleted int64
}
// RunRetention applies the retention policy: read deliveries past the read
// window, unread past the unread window, processed release events past the
// debug window, unprocessed release events past the fanout staleness horizon,
// and inert interest rows.
func (s *System) RunRetention(ctx context.Context) (RetentionStats, error) {
var stats RetentionStats
now := time.Now().UTC()
readCutoff := now.AddDate(0, 0, -s.Settings.ReadRetentionDays(ctx))
unreadCutoff := now.AddDate(0, 0, -s.Settings.UnreadRetentionDays(ctx))
eventCutoff := now.AddDate(0, 0, -s.Settings.EventRetentionDays(ctx))
deleted, err := s.Deliveries.DeleteOld(ctx, readCutoff, unreadCutoff)
if err != nil {
return stats, fmt.Errorf("prune deliveries: %w", err)
}
stats.DeliveriesDeleted = deleted
events, err := s.Releases.DeleteProcessedBefore(ctx, eventCutoff)
if err != nil {
return stats, fmt.Errorf("prune release events: %w", err)
}
stats.EventsDeleted = events
// Unprocessed events accumulate without bound when fanout is disabled
// while availability detection keeps emitting; the fanout worker would
// suppress them as stale anyway, so retention reclaims them directly.
staleEvents, err := s.Releases.DeleteUnprocessedBefore(ctx, now.Add(-s.Settings.MaxEventAge(ctx)))
if err != nil {
return stats, fmt.Errorf("prune stale release events: %w", err)
}
stats.StaleEventsDeleted = staleEvents
pruned, err := s.Interests.PruneInert(ctx)
if err != nil {
return stats, fmt.Errorf("prune interest rows: %w", err)
}
stats.InterestPruned = pruned
if s.webhookRepo != nil {
attempts, err := s.webhookRepo.DeleteOldAttempts(ctx, now)
if err != nil {
return stats, fmt.Errorf("prune webhook attempts: %w", err)
}
stats.WebhookAttemptsDeleted = attempts
}
if s.webPushRepo != nil {
attempts, err := s.webPushRepo.DeleteOldAttempts(ctx, now)
if err != nil {
return stats, fmt.Errorf("prune web push attempts: %w", err)
}
stats.WebPushAttemptsDeleted = attempts
}
if s.DiscordPrefs != nil {
states, err := s.DiscordPrefs.DeleteExpiredLinkStates(ctx)
if err != nil {
return stats, fmt.Errorf("prune discord link states: %w", err)
}
stats.DiscordLinkStatesDeleted = states
}
return stats, nil
}
func backfillKey(userID int, profileID string) string {
return fmt.Sprintf("%010d|%s", userID, profileID)
}
func (s *System) loadBackfillCheckpoint(ctx context.Context, task string) (string, *time.Time, error) {
var key *string
var completedAt *time.Time
err := s.pool.QueryRow(ctx,
`SELECT last_processed_key, completed_at FROM notification_backfill_state WHERE task = $1`,
task,
).Scan(&key, &completedAt)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil, nil
}
if err != nil {
return "", nil, fmt.Errorf("load backfill checkpoint: %w", err)
}
checkpoint := ""
if key != nil {
checkpoint = *key
}
return checkpoint, completedAt, nil
}
func (s *System) saveBackfillCheckpoint(ctx context.Context, task, key string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO notification_backfill_state (task, last_processed_key, started_at, updated_at)
VALUES ($1, $2, now(), now())
ON CONFLICT (task) DO UPDATE SET
last_processed_key = EXCLUDED.last_processed_key,
updated_at = now()`,
task, key)
if err != nil {
return fmt.Errorf("save backfill checkpoint: %w", err)
}
return nil
}
func (s *System) resetBackfillCheckpoint(ctx context.Context, task string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO notification_backfill_state (task, last_processed_key, started_at, updated_at, completed_at)
VALUES ($1, NULL, now(), now(), NULL)
ON CONFLICT (task) DO UPDATE SET
last_processed_key = NULL,
started_at = now(),
updated_at = now(),
completed_at = NULL`,
task)
if err != nil {
return fmt.Errorf("reset backfill checkpoint: %w", err)
}
return nil
}
func (s *System) completeBackfillCheckpoint(ctx context.Context, task string) error {
_, err := s.pool.Exec(ctx, `
UPDATE notification_backfill_state SET completed_at = now(), updated_at = now() WHERE task = $1`,
task)
if err != nil {
return fmt.Errorf("complete backfill checkpoint: %w", err)
}
return nil
}