0.2.79 - Slow api fixes
This commit is contained in:
+232
-59
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user