Files
silo-server/internal/notifications/interest_hooks.go
T
edd919c5f7 fix(notifications): restore store capabilities and batch series resolution (#647)
* fix(notifications): restore store capabilities and batch series resolution

Marking or unmarking a large series spent minutes in the interest-tracking
layer. Two independent defects, both in internal/notifications.

The decorator embeds the userstore.UserStore *interface*, which promotes only
that interface's methods. Every optional capability the backing store
implements was therefore invisible through the wrapper, and because callers
reach these by type assertion with a working fallback, the loss was silent:
no error, no test failure, just the slow path. cmd/silo wraps the provider
unconditionally when notifications are enabled, so in production
userstore.MarkWatchedBatch's assertion failed and #645's transactional batch
write never ran. AddVisibleHistory, VisibleHistoryTimestamps, and the
jellycompat series rollup were degraded the same way.

Forward all four capabilities explicitly, and add compile-time assertions so a
future capability is a build error rather than a silent slowdown.

Separately, the interest flush resolved each queued item to its series with one
query apiece, then deduped the results. A whole-series mark queues one mutation
per episode, so thousands of lookups collapsed to a single series after paying
for all of them. Resolve the batch in one query and dedupe from that; the
single-item resolveSeriesID had no other callers and is removed rather than
left to drift.

Measured on the dev server against a 6,375-episode series:

  mark    5.2s  -> ~0.9s
  unmark  116s  -> ~8.6s
  episodes index scans  18.5M -> 19,298

Fixes #646.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(notifications): queue interest recomputes on the batch fallback path

Review catch (CodeRabbit on #647). When the backing store lacks
WatchedBatchWriter, the forward handed the work to the generic helper against
s.UserStore — the inner store — so this decorator's own MarkWatched hook never
fired and nothing queued an interest recompute. Marking a series watched on
such a backend updated progress and history but left profile_series_interest
stale until an unrelated mutation or the rebuild task touched the series.

Queue by requested target on that path, and do so even when the helper returns
an error: the fallback is a per-target loop, so a mid-loop failure still leaves
earlier targets written. A redundant queue costs one recompute; a missing one
is silent staleness. The transactional path keeps queuing from written entries
only on success, because on error nothing landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(notifications): advertise the series rollup only when the store has it

Review catch (Codex on #647). Forwarding SeriesEpisodeWatchCounts
unconditionally made the wrapper always satisfy SeriesEpisodeRollupStore, even
over the per-user SQLite backend, which has no catalog tables and cannot answer
the query. Callers read "implements the interface" as "can do this", so every
jellycompat series detail and browse would enter the fast path, take the error,
log "series watch rollup query failed", and only then fall back — turning an
expected capability absence into recurring warning noise on requests that
succeeded.

Make the capability conditional on the backing store, the way DeviceRegistry
already is, via wrapper types composed in ForUser. The remaining capabilities
stay unconditional: those have real generic fallbacks and every store can
perform them.

Tests cover both directions — a SQLite-backed wrapper must not advertise the
rollup, and a rollup-capable store must keep it (and reach it) through the
wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 00:00:02 -04:00

457 lines
19 KiB
Go

package notifications
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// WrapUserStoreProvider decorates the shared user-store provider so every
// favorites, watchlist, watch-progress, and watch-history mutation —
// regardless of which path performed it (REST handlers, jellycompat, history
// imports, playback stop, watch sync) — queues an interest recompute. Hooking
// the lowest shared layer keeps the seven-plus mutation call sites hook-free
// and drift-free.
//
// Progress writes queue only on state *transitions* (a row appearing, the
// in-progress flag flipping, completion crossing, rows being cleared):
// progress sync ticks fire continuously during playback on a busy server, and
// recomputing interest on every tick would be a pointless hot write path.
func WrapUserStoreProvider(inner userstore.UserStoreProvider, system *System) userstore.UserStoreProvider {
if inner == nil || system == nil {
return inner
}
return &interestTrackingProvider{inner: inner, system: system}
}
type interestTrackingProvider struct {
inner userstore.UserStoreProvider
system *System
}
func (p *interestTrackingProvider) ForUser(ctx context.Context, userID int) (userstore.UserStore, error) {
store, err := p.inner.ForUser(ctx, userID)
if err != nil || store == nil {
return store, err
}
tracked := &interestTrackingStore{UserStore: store, userID: userID, system: p.system, updater: p.system.Interest}
// Preserve the interface upgrades callers probe for. Both are conditional
// on the backing store: advertising a capability it does not have would
// send callers down a fast path that can only fail.
registry, hasDevices := store.(userstore.DeviceRegistry)
rollup, hasRollup := store.(userstore.SeriesEpisodeRollupStore)
switch {
case hasDevices && hasRollup:
return &interestTrackingStoreWithDevicesAndRollup{
interestTrackingStore: tracked,
DeviceRegistry: registry,
SeriesEpisodeRollupStore: rollup,
}, nil
case hasDevices:
return &interestTrackingStoreWithDevices{
interestTrackingStore: tracked,
DeviceRegistry: registry,
}, nil
case hasRollup:
return &interestTrackingStoreWithRollup{
interestTrackingStore: tracked,
SeriesEpisodeRollupStore: rollup,
}, nil
}
return tracked, nil
}
func (p *interestTrackingProvider) Close() error {
return p.inner.Close()
}
type interestTrackingStore struct {
userstore.UserStore
userID int
system *System
updater *InterestUpdater
}
type interestTrackingStoreWithDevices struct {
*interestTrackingStore
userstore.DeviceRegistry
}
// interestTrackingStoreWithRollup adds the series-rollup capability only when
// the backing store actually has it. Unlike the other capabilities, this one
// cannot be forwarded unconditionally: the per-user SQLite backend has no
// catalog tables and genuinely cannot answer the query, and callers treat
// "implements the interface" as "can do this". Advertising it anyway would
// send every jellycompat series request down the fast path to fail, logging a
// warning each time before falling back — turning an expected capability
// absence into recurring noise on successful requests.
type interestTrackingStoreWithRollup struct {
*interestTrackingStore
userstore.SeriesEpisodeRollupStore
}
type interestTrackingStoreWithDevicesAndRollup struct {
*interestTrackingStore
userstore.DeviceRegistry
userstore.SeriesEpisodeRollupStore
}
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStore)(nil)
var _ userstore.SettingMutationTransactioner = (*interestTrackingStore)(nil)
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.SettingMutationTransactioner = (*interestTrackingStoreWithDevices)(nil)
// Optional store capabilities must survive the decorator. Embedding the
// UserStore interface promotes only that interface's methods, so each of these
// needs an explicit forward below; the assertions make a missing one a compile
// error instead of a silent production slowdown.
//
// SeriesEpisodeRollupStore is deliberately absent here: it is conditional on
// the backing store, so it lives on the wrapper types above rather than being
// forwarded unconditionally.
var _ userstore.WatchedBatchWriter = (*interestTrackingStore)(nil)
var _ userstore.VisibleHistoryAdder = (*interestTrackingStore)(nil)
var _ userstore.HistoryVisibilityStore = (*interestTrackingStore)(nil)
var _ userstore.WatchedBatchWriter = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.VisibleHistoryAdder = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.HistoryVisibilityStore = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.SeriesEpisodeRollupStore = (*interestTrackingStoreWithRollup)(nil)
var _ userstore.SeriesEpisodeRollupStore = (*interestTrackingStoreWithDevicesAndRollup)(nil)
var _ userstore.DeviceRegistry = (*interestTrackingStoreWithDevicesAndRollup)(nil)
// WithPreferenceSettingsTransaction preserves the optional atomic-settings
// capability of the wrapped store. Preference writes do not affect interest
// signals, so the transaction can pass through unchanged; keeping the method
// on the decorator is what lets settings handlers reach the real backend's
// transaction boundary in production.
func (s *interestTrackingStore) WithPreferenceSettingsTransaction(
ctx context.Context,
fn func(userstore.PreferenceSettingsWriter) error,
) error {
transactioner, ok := s.UserStore.(userstore.PreferenceSettingsTransactioner)
if !ok {
return fmt.Errorf("wrapped user store does not support atomic preference settings synchronization")
}
return transactioner.WithPreferenceSettingsTransaction(ctx, fn)
}
// CompareAndSetSettingValue preserves the semantic-document CAS capability of
// the wrapped store. Settings writes do not affect notification interests, so
// this decorator must not intercept or downgrade the backend primitive.
func (s *interestTrackingStore) CompareAndSetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
cas, ok := s.UserStore.(userstore.SettingValueCompareAndSetter)
if !ok {
return nil, fmt.Errorf("wrapped user store does not support atomic setting updates")
}
return cas.CompareAndSetSettingValue(ctx, id, value, expectedRevision)
}
// WithSettingMutationTransaction preserves the durable setting+receipt
// transaction used by mutation IDs. Passing the transaction-scoped writer
// through unchanged keeps both operations on the concrete backend transaction.
func (s *interestTrackingStore) WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(userstore.SettingMutationWriter) error,
) error {
transactioner, ok := s.UserStore.(userstore.SettingMutationTransactioner)
if !ok {
return fmt.Errorf("wrapped user store does not support atomic idempotent setting mutations")
}
return transactioner.WithSettingMutationTransaction(ctx, mutationID, fn)
}
// progressState is the transition-relevant projection of a progress row.
type progressState struct {
exists bool
inProgress bool
completed bool
}
func (s *interestTrackingStore) currentProgressState(ctx context.Context, profileID, mediaItemID string) progressState {
entry, err := s.GetProgress(ctx, profileID, mediaItemID)
if err != nil || entry == nil {
return progressState{}
}
return progressState{
exists: true,
inProgress: !entry.Completed && entry.PositionSeconds > 0,
completed: entry.Completed,
}
}
func progressStateFromValues(position, duration float64, thresholds userstore.ProgressThresholds) progressState {
completed := duration > 0 && position/duration > userstore.WatchedFraction(thresholds.WatchedPct)
return progressState{
exists: true,
inProgress: !completed && position > 0,
completed: completed,
}
}
func (s *interestTrackingStore) queueOnTransition(profileID, mediaItemID string, before, after progressState) {
if before != after {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
}
// --- Favorites & watchlist: every mutation queues (user-action frequency).
func (s *interestTrackingStore) AddFavorite(ctx context.Context, profileID, mediaItemID string) error {
err := s.UserStore.AddFavorite(ctx, profileID, mediaItemID)
if err == nil {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
return err
}
func (s *interestTrackingStore) AddFavoriteAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error) {
inserted, err := s.UserStore.AddFavoriteAt(ctx, profileID, mediaItemID, addedAt)
if err == nil && inserted {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
return inserted, err
}
func (s *interestTrackingStore) RemoveFavorite(ctx context.Context, profileID, mediaItemID string) error {
err := s.UserStore.RemoveFavorite(ctx, profileID, mediaItemID)
if err == nil {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
return err
}
func (s *interestTrackingStore) AddToWatchlist(ctx context.Context, profileID, mediaItemID string) error {
err := s.UserStore.AddToWatchlist(ctx, profileID, mediaItemID)
if err == nil {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
return err
}
func (s *interestTrackingStore) RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error {
err := s.UserStore.RemoveFromWatchlist(ctx, profileID, mediaItemID)
if err == nil {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
return err
}
// --- Progress: queue on transitions only.
func (s *interestTrackingStore) UpdateProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds userstore.ProgressThresholds) error {
before := s.currentProgressState(ctx, profileID, mediaItemID)
err := s.UserStore.UpdateProgress(ctx, profileID, mediaItemID, position, duration, thresholds)
if err == nil {
s.queueOnTransition(profileID, mediaItemID, before, progressStateFromValues(position, duration, thresholds))
}
return err
}
func (s *interestTrackingStore) SetProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds userstore.ProgressThresholds) error {
before := s.currentProgressState(ctx, profileID, mediaItemID)
err := s.UserStore.SetProgress(ctx, profileID, mediaItemID, position, duration, thresholds)
if err == nil {
s.queueOnTransition(profileID, mediaItemID, before, progressStateFromValues(position, duration, thresholds))
}
return err
}
func (s *interestTrackingStore) SetProgressAt(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) error {
before := s.currentProgressState(ctx, profileID, mediaItemID)
err := s.UserStore.SetProgressAt(ctx, profileID, mediaItemID, position, duration, completed, updatedAt)
if err == nil {
after := progressState{exists: true, inProgress: !completed && position > 0, completed: completed}
s.queueOnTransition(profileID, mediaItemID, before, after)
}
return err
}
func (s *interestTrackingStore) SetProgressIfNewer(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) (bool, error) {
before := s.currentProgressState(ctx, profileID, mediaItemID)
applied, err := s.UserStore.SetProgressIfNewer(ctx, profileID, mediaItemID, position, duration, completed, updatedAt)
if err == nil && applied {
after := progressState{exists: true, inProgress: !completed && position > 0, completed: completed}
s.queueOnTransition(profileID, mediaItemID, before, after)
}
return applied, err
}
func (s *interestTrackingStore) MarkWatched(ctx context.Context, profileID, mediaItemID string, duration float64) error {
before := s.currentProgressState(ctx, profileID, mediaItemID)
err := s.UserStore.MarkWatched(ctx, profileID, mediaItemID, duration)
if err == nil {
s.queueOnTransition(profileID, mediaItemID, before, progressState{exists: true, completed: true})
}
return err
}
func (s *interestTrackingStore) MarkProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error {
beforeStates, _ := s.ListProgressByMediaItems(ctx, profileID, mediaItemIDs)
err := s.UserStore.MarkProgressBatch(ctx, profileID, mediaItemIDs, updatedAt)
if err == nil {
for _, mediaItemID := range mediaItemIDs {
if entry, ok := beforeStates[mediaItemID]; ok && entry.Completed {
continue // already completed: no transition
}
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
}
return err
}
func (s *interestTrackingStore) ClearProgress(ctx context.Context, profileID, mediaItemID string) error {
err := s.UserStore.ClearProgress(ctx, profileID, mediaItemID)
if err == nil {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
return err
}
func (s *interestTrackingStore) ClearProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error {
err := s.UserStore.ClearProgressBatch(ctx, profileID, mediaItemIDs, updatedAt)
if err == nil {
for _, mediaItemID := range mediaItemIDs {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
}
return err
}
// --- Watch history: history imports and watch-provider syncs may record a
// completed watch without any progress write, so the progress hooks alone
// would never see them. AddHistory (the live playback path) is deliberately
// not hooked: playback always writes progress alongside it, and those writes
// already queue on transitions.
func (s *interestTrackingStore) AddHistoryIfMissing(ctx context.Context, entry userstore.WatchHistoryEntry) (bool, error) {
created, err := s.UserStore.AddHistoryIfMissing(ctx, entry)
if err == nil && created && entry.Completed {
s.updater.QueueItemMutation(s.userID, entry.ProfileID, entry.MediaItemID)
}
return created, err
}
func (s *interestTrackingStore) RemoveHistoryItems(ctx context.Context, profileID string, mediaItemIDs []string, removedAt time.Time) error {
err := s.UserStore.RemoveHistoryItems(ctx, profileID, mediaItemIDs, removedAt)
if err == nil {
for _, mediaItemID := range mediaItemIDs {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
}
return err
}
// --- Optional store capabilities.
//
// This decorator embeds the userstore.UserStore *interface*, which promotes
// only the methods declared on that interface. Any optional capability the
// backing store implements is invisible through the wrapper unless it is
// forwarded explicitly here — and because callers reach these via type
// assertion with a working fallback, a missing forward is silent: no error, no
// test failure, just a much slower path in production. Marking a series
// watched regressed exactly this way. When adding a new optional capability to
// userstore, forward it here and extend the compile-time assertions below.
// MarkWatchedBatch forwards the transactional batch mark-watched write, whose
// whole purpose is that a large series commits as one unit. Falling through to
// the per-target loop would restore the partial-write window it removes.
func (s *interestTrackingStore) MarkWatchedBatch(
ctx context.Context,
profileID string,
targets []userstore.MarkWatchedTarget,
entries []userstore.WatchHistoryEntry,
) ([]userstore.WatchHistoryEntry, error) {
writer, ok := s.UserStore.(userstore.WatchedBatchWriter)
if !ok {
// The helper runs against the inner store, so this decorator's own
// MarkWatched hook never fires; queue here or the recompute is lost.
// A mid-loop error still leaves earlier targets written, so queue
// regardless of err — a redundant queue only costs one recompute,
// while a missing one leaves profile_series_interest stale until some
// unrelated mutation or the rebuild task touches the series.
written, err := userstore.MarkWatchedBatch(ctx, s.UserStore, profileID, targets, entries)
s.queueTargetMutations(profileID, targets)
return written, err
}
written, err := writer.MarkWatchedBatch(ctx, profileID, targets, entries)
// The batch write is one transaction: on error nothing landed, so there is
// nothing to recompute.
if err == nil {
s.queueItemMutations(profileID, written)
}
return written, err
}
// queueItemMutations records one interest mutation per written entry, matching
// what the per-target path queues through MarkWatched.
func (s *interestTrackingStore) queueItemMutations(profileID string, entries []userstore.WatchHistoryEntry) {
for _, entry := range entries {
s.updater.QueueItemMutation(s.userID, profileID, entry.MediaItemID)
}
}
// queueTargetMutations queues by requested target rather than written entry,
// for the non-transactional path where a partial write may have occurred.
func (s *interestTrackingStore) queueTargetMutations(profileID string, targets []userstore.MarkWatchedTarget) {
for _, target := range targets {
s.updater.QueueItemMutation(s.userID, profileID, target.MediaItemID)
}
}
// AddVisibleHistory forwards the watermark-aware history insert. The generic
// fallback needs two round-trips (timestamp lookup, then insert) to do the
// same job.
func (s *interestTrackingStore) AddVisibleHistory(ctx context.Context, entry userstore.WatchHistoryEntry) (userstore.WatchHistoryEntry, error) {
adder, ok := s.UserStore.(userstore.VisibleHistoryAdder)
if !ok {
return userstore.AddVisibleHistory(ctx, s.UserStore, entry)
}
return adder.AddVisibleHistory(ctx, entry)
}
// VisibleHistoryTimestamps forwards the batched watermark lookup; without it
// callers assume a single wall-clock timestamp for every item.
func (s *interestTrackingStore) VisibleHistoryTimestamps(ctx context.Context, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error) {
visibility, ok := s.UserStore.(userstore.HistoryVisibilityStore)
if !ok {
return userstore.VisibleHistoryTimestamps(ctx, s.UserStore, profileID, mediaItemIDs, at)
}
return visibility.VisibleHistoryTimestamps(ctx, profileID, mediaItemIDs, at)
}
func (s *interestTrackingStore) DeleteHistoryBySource(ctx context.Context, profileID string, mediaItemIDs []string, source userstore.WatchHistorySource) error {
err := s.UserStore.DeleteHistoryBySource(ctx, profileID, mediaItemIDs, source)
if err == nil {
for _, mediaItemID := range mediaItemIDs {
s.updater.QueueItemMutation(s.userID, profileID, mediaItemID)
}
}
return err
}
// DeleteProfile purges notification state alongside the profile itself;
// profiles may live outside Postgres, so no cascade covers these tables.
// The purge is best-effort: a failure is logged, never surfaced as a
// profile-deletion failure (the retention task prunes leftovers).
func (s *interestTrackingStore) DeleteProfile(ctx context.Context, id string) error {
err := s.UserStore.DeleteProfile(ctx, id)
if err == nil {
purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
if purgeErr := s.system.PurgeProfile(purgeCtx, id); purgeErr != nil {
slog.WarnContext(ctx, "notifications: profile purge failed", "component", "notifications", "profile_id", id, "error", purgeErr)
}
}
return err
}