0.2.79 - Slow api fixes

This commit is contained in:
ponzischeme89
2026-08-19 18:08:00 +12:00
parent 590e069366
commit 0782545013
41 changed files with 1820 additions and 317 deletions
+232 -59
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
@@ -40,32 +41,60 @@ func (s *Server) filterRecommendationPermissions(
if len(ids) == 0 {
return rows
}
// The batches are independent questions about disjoint sets of ids, and there can be
// several of them on a launcher full of recommendations. Asking Emby them one after
// another put the whole of that latency on the tail of the home response; asking
// together costs Emby the same work and the viewer one batch's wait.
//
// There is deliberately no cap on how many run at once. The count is a hundredth of
// the recommendation candidate pool, which is itself bounded, and the Emby client's
// transport already keeps a warm pool of connections for them to share.
allowed := map[string]bool{}
cred := credentials(sess)
var (
mu sync.Mutex
failed bool
wg sync.WaitGroup
)
for start := 0; start < len(ids); start += 100 {
end := min(start+100, len(ids))
result, err := s.emby.Items(ctx, cred, rowParams(url.Values{
"Ids": {strings.Join(ids[start:end], ",")},
"Recursive": {"true"},
"IncludeItemTypes": {"Movie,Series"},
"Limit": {itoa(end - start)},
}, fieldsRow))
if err != nil {
s.log.Warn("recommendation permission check failed; hiding candidates",
"user", sess.EmbyUserID, "error", err)
for index := range rows {
if recommendationRow(rows[index].ID) {
rows[index].Items = []json.RawMessage{}
batch := ids[start:end]
wg.Add(1)
go func() {
defer wg.Done()
result, err := s.emby.Items(ctx, cred, rowParams(url.Values{
"Ids": {strings.Join(batch, ",")},
"Recursive": {"true"},
"IncludeItemTypes": {"Movie,Series"},
"Limit": {itoa(len(batch))},
}, fieldsRow))
mu.Lock()
defer mu.Unlock()
if err != nil {
s.loggerFor(ctx).Warn("recommendation permission check failed; hiding candidates",
"user", sess.EmbyUserID, "error", err)
failed = true
return
}
for _, raw := range result.Items {
decoded := recommend.Decode([]json.RawMessage{raw})
if len(decoded) == 1 {
allowed[decoded[0].ID] = true
}
}
return rows
}
for _, raw := range result.Items {
decoded := recommend.Decode([]json.RawMessage{raw})
if len(decoded) == 1 {
allowed[decoded[0].ID] = true
}()
}
wg.Wait()
// One failed batch hides every candidate, exactly as it did when the loop returned
// early: this is Emby being the final authority on what a viewer may see, and a
// partial answer is not evidence that the rest is permitted.
if failed {
for index := range rows {
if recommendationRow(rows[index].ID) {
rows[index].Items = []json.RawMessage{}
}
}
return rows
}
for index := range rows {
if !recommendationRow(rows[index].ID) {
@@ -82,6 +111,22 @@ func (s *Server) filterRecommendationPermissions(
return rows
}
// rankingContext gathers everything the weighted ranker needs about one viewer: their
// learned profile, what they have already been shown, and how the household as a whole
// has been getting on with the library.
//
// It is seven Postgres reads and it used to make all seven one after another, on the tail
// of the home response — after the Emby fan-out had finished, so nothing else was in
// flight and every one of them was pure added latency. Six of the seven depend on nothing
// but the viewer's id. They are now issued together, which turns the sum of seven round
// trips into roughly the slowest one.
//
// The two that are genuinely a chain stay a chain — an onboarding document names the
// items its ratings refer to, and the actions name theirs — but the two chains run beside
// each other, and their results are applied to the profile afterwards in the order they
// were applied before. Ordering the *writes* rather than the reads is the whole trick:
// ApplyOnboarding and ApplyExplicitPreference are not commutative and this must not
// become a place where the ranking depends on which query answered first.
func (s *Server) rankingContext(
ctx context.Context,
userID string,
@@ -90,10 +135,90 @@ func (s *Server) rankingContext(
if s.store == nil {
return profile, nil, nil
}
if raw, err := s.store.WeightedRecommendationProfile(ctx, userID); err == nil {
_ = json.Unmarshal(raw, &profile)
} else {
s.log.Warn("weighted profile unavailable", "user", userID, "error", err)
var (
wg sync.WaitGroup
profileRaw json.RawMessage
onboarding recommend.OnboardingPreferences
onboardingOK bool
onboardingItems []json.RawMessage
actions []store.RecommendationAction
actionItems []json.RawMessage
exposureValues []store.ItemExposureStat
household map[string]float64
)
run := func(fn func()) {
wg.Add(1)
go func() {
defer wg.Done()
fn()
}()
}
run(func() {
raw, err := s.store.WeightedRecommendationProfile(ctx, userID)
if err != nil {
s.loggerFor(ctx).Warn("weighted profile unavailable", "user", userID, "error", err)
return
}
profileRaw = raw
})
run(func() {
raw, err := s.store.RecommendationOnboarding(ctx, userID)
if err != nil {
return
}
var preferences recommend.OnboardingPreferences
if json.Unmarshal(raw, &preferences) != nil {
return
}
onboarding, onboardingOK = preferences, true
ids := make([]string, 0, len(preferences.Ratings))
for id, rating := range preferences.Ratings {
if strings.TrimSpace(id) != "" && rating >= 1 && rating <= 5 {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return
}
if raws, err := s.store.LibraryItemsByID(ctx, ids); err == nil {
onboardingItems = raws
}
})
run(func() {
values, err := s.store.RecommendationActions(ctx, userID)
if err != nil {
return
}
actions = values
ids := make([]string, 0, len(values))
for _, action := range values {
ids = append(ids, action.ItemID)
}
if len(ids) == 0 {
return
}
if raws, err := s.store.LibraryItemsByID(ctx, ids); err == nil {
actionItems = raws
}
})
run(func() {
values, err := s.store.UserItemExposures(ctx, userID, time.Now().Add(-45*24*time.Hour))
if err == nil {
exposureValues = values
}
})
run(func() { household = s.householdCompletionScores(ctx) })
wg.Wait()
if len(profileRaw) > 0 {
_ = json.Unmarshal(profileRaw, &profile)
}
if profile.ExplicitPositive == nil {
profile.ExplicitPositive = map[string]bool{}
@@ -101,31 +226,16 @@ func (s *Server) rankingContext(
if profile.ExplicitNegative == nil {
profile.ExplicitNegative = map[string]bool{}
}
if raw, err := s.store.RecommendationOnboarding(ctx, userID); err == nil {
var preferences recommend.OnboardingPreferences
if json.Unmarshal(raw, &preferences) == nil {
profile.ApplyOnboarding(preferences, s.weightedConfig().MinimumEvidence)
ids := make([]string, 0, len(preferences.Ratings))
for id, rating := range preferences.Ratings {
if strings.TrimSpace(id) != "" && rating >= 1 && rating <= 5 {
ids = append(ids, id)
}
}
if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil {
for _, item := range recommend.Decode(raws) {
profile.ApplyOnboardingRating(
item, preferences.Ratings[item.ID],
s.weightedConfig().MinimumEvidence,
)
}
}
if onboardingOK {
evidence := s.weightedConfig().MinimumEvidence
profile.ApplyOnboarding(onboarding, evidence)
for _, item := range recommend.Decode(onboardingItems) {
profile.ApplyOnboardingRating(item, onboarding.Ratings[item.ID], evidence)
}
}
if actions, err := s.store.RecommendationActions(ctx, userID); err == nil {
ids := make([]string, 0, len(actions))
if len(actions) > 0 {
byID := make(map[string]string, len(actions))
for _, action := range actions {
ids = append(ids, action.ItemID)
byID[action.ItemID] = action.Action
switch action.Action {
case "more_like_this":
@@ -134,30 +244,82 @@ func (s *Server) rankingContext(
profile.ExplicitNegative[action.ItemID] = true
}
}
if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil {
for _, item := range recommend.Decode(raws) {
profile.ApplyExplicitPreference(item, byID[item.ID] == "more_like_this")
}
for _, item := range recommend.Decode(actionItems) {
profile.ApplyExplicitPreference(item, byID[item.ID] == "more_like_this")
}
}
exposures := map[string]recommend.ItemExposure{}
if values, err := s.store.UserItemExposures(
ctx, userID, time.Now().Add(-45*24*time.Hour),
); err == nil {
for _, value := range values {
exposures[value.ItemID] = recommend.ItemExposure{
Impressions: value.Impressions, Focuses: value.Focuses,
Selects: value.Selects, LastShown: value.LastShown,
}
exposures := make(map[string]recommend.ItemExposure, len(exposureValues))
for _, value := range exposureValues {
exposures[value.ItemID] = recommend.ItemExposure{
Impressions: value.Impressions, Focuses: value.Focuses,
Selects: value.Selects, LastShown: value.LastShown,
}
}
household, err := s.store.HouseholdCompletionScores(ctx, time.Now().Add(-180*24*time.Hour))
if err != nil {
if household == nil {
household = map[string]float64{}
}
return profile, exposures, household
}
// householdCompletionScoreTTL is how long one reading of the household's completion
// scores is reused.
//
// The query aggregates six months of viewing across everybody, it is the heaviest read
// in rankingContext, and its answer is *the same for every viewer in the house* — so
// four televisions refreshing their launchers were running four copies of one
// six-month aggregate, none of which could have produced a different number. A minute
// is chosen against what it measures: this moves when somebody finishes something, and
// nothing on a launcher is different for having learned that a minute sooner.
const householdCompletionScoreTTL = time.Minute
// householdScores caches that reading. Coalesced as well as cached, so the request that
// finds it expired is the only one that pays for the refresh rather than the first of
// several that all do.
type householdScores struct {
mu sync.Mutex
value map[string]float64
fetched time.Time
}
func (s *Server) householdCompletionScores(ctx context.Context) map[string]float64 {
s.household.mu.Lock()
defer s.household.mu.Unlock()
if s.household.value != nil && time.Since(s.household.fetched) < householdCompletionScoreTTL {
return s.household.value
}
scores, err := s.store.HouseholdCompletionScores(ctx, time.Now().Add(-180*24*time.Hour))
if err != nil {
// A failed read must not be cached as an empty household: that would suppress
// the signal for a whole minute on the strength of one timeout. The previous
// reading is the better answer where there is one.
if s.household.value != nil {
return s.household.value
}
return map[string]float64{}
}
s.household.value = scores
s.household.fetched = time.Now()
return scores
}
// rankingInputs is one viewer's ranking evidence, gathered.
//
// It exists so the gathering can happen somewhere other than immediately before the
// ranking. On the home path the evidence depends on nothing but the viewer's id, while
// the rows it will be applied to take seconds to arrive from Emby — so it is read
// beside them rather than after them, and by the time there is anything to rank it is
// already in hand. See handleHome.
type rankingInputs struct {
profile recommend.WeightedProfile
exposures map[string]recommend.ItemExposure
household map[string]float64
}
func (s *Server) rankingInputs(ctx context.Context, userID string) rankingInputs {
profile, exposures, household := s.rankingContext(ctx, userID)
return rankingInputs{profile: profile, exposures: exposures, household: household}
}
func (s *Server) personalizeTitles(
ctx context.Context,
sess store.Session,
@@ -166,7 +328,18 @@ func (s *Server) personalizeTitles(
if len(rows) == 0 {
return rows
}
profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
return s.personalizeTitlesWith(rows, s.rankingInputs(ctx, sess.EmbyUserID))
}
// personalizeTitlesWith is the ranking itself, over evidence somebody else gathered.
func (s *Server) personalizeTitlesWith(
rows []recommend.Row,
inputs rankingInputs,
) []recommend.Row {
if len(rows) == 0 {
return rows
}
profile, exposures, household := inputs.profile, inputs.exposures, inputs.household
cfg := s.weightedConfig()
now := time.Now()
location := s.householdLocation()