diff options
Diffstat (limited to 'server')
| -rw-r--r-- | server/cmd/listend/main.go | 2 | ||||
| -rw-r--r-- | server/internal/api/api.go | 6 | ||||
| -rw-r--r-- | server/internal/api/api_test.go | 14 | ||||
| -rw-r--r-- | server/internal/store/store.go | 2 | ||||
| -rw-r--r-- | server/internal/store/tier.go | 86 | ||||
| -rw-r--r-- | server/internal/store/tier_test.go | 34 | ||||
| -rw-r--r-- | server/internal/words/pipeline.go | 18 | ||||
| -rw-r--r-- | server/internal/words/pipeline_test.go | 8 |
8 files changed, 94 insertions, 76 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index a165feb..79290c1 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -295,7 +295,7 @@ func (w *worker) save() { func (w *worker) pollAll(ctx context.Context) { // Topical shows first and archive last, so a five-hour back-catalogue episode cannot take // a cycle's recognition budget before the morning news has been looked at. - feeds := w.state.ByTier(w.state.Feeds()) + feeds := w.state.ByCategory(w.state.Feeds()) if len(feeds) == 0 { log.Printf("no subscriptions yet; waiting for the phone to send its list") return diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 2d2e279..5a11c92 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -181,10 +181,10 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) { } var body struct { Feeds []string `json:"feeds"` - // Tiers says how soon each feed's work is worth doing, keyed by URL. Optional and + // Categories says what sort of listening each feed is, keyed by URL, which is also // separate from the list rather than folded into it, so a phone that knows nothing // about tiers keeps working unchanged. - Tiers map[string]string `json:"tiers,omitempty"` + Categories map[string]string `json:"categories,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"}) @@ -200,7 +200,7 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) { return } // After the list, so a tier for a feed that was just removed is dropped with it. - s.state.SetTiers(body.Tiers) + s.state.SetCategories(body.Categories) if s.opts.OnSubscriptions != nil { s.opts.OnSubscriptions(s.state.Feeds()) } diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index b50454b..5ae4435 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -379,21 +379,21 @@ Tiers arrive beside the list rather than folded into it, so a phone that knows n them keeps working unchanged -- which every phone did until this week. */ -func TestTakesTheTiersThePhoneSends(t *testing.T) { +func TestTakesTheCategoriesThePhoneSends(t *testing.T) { s, st, _, _ := newServer(t) post(t, s, "/api/subscriptions", - `{"feeds":["https://a","https://b"],"tiers":{"https://a":"topical"}}`) + `{"feeds":["https://a","https://b"],"categories":{"https://a":"news"}}`) - if got := st.Tier("https://a"); got != store.TierTopical { - t.Errorf("got %q, want %q", got, store.TierTopical) + if got := st.Category("https://a"); got != store.CategoryNews { + t.Errorf("got %q, want %q", got, store.CategoryNews) } - if got := st.Tier("https://b"); got != store.TierOrdinary { + if got := st.Category("https://b"); got != store.CategoryDefault { t.Errorf("got %q, want the default", got) } } -func TestAListWithNoTiersStillWorks(t *testing.T) { +func TestAListWithNoCategoriesStillWorks(t *testing.T) { s, st, _, _ := newServer(t) rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a"]}`) @@ -414,7 +414,7 @@ func TestATierForARemovedFeedIsDropped(t *testing.T) { post(t, s, "/api/subscriptions", `{"feeds":["https://b"],"tiers":{"https://a":"archive"}}`) - if got := st.Tier("https://a"); got != store.TierOrdinary { + if got := st.Category("https://a"); got != store.CategoryDefault { t.Errorf("got %q, want it dropped with the feed", got) } } diff --git a/server/internal/store/store.go b/server/internal/store/store.go index de763ed..caf5365 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -469,7 +469,7 @@ func (s *State) SetFeeds(feeds []string) (removed []string) { } for url := range s.tiers { if !wanted[url] { - s.forgetTier(url) + s.forgetCategory(url) } } sort.Strings(removed) diff --git a/server/internal/store/tier.go b/server/internal/store/tier.go index 94d2634..eb46aca 100644 --- a/server/internal/store/tier.go +++ b/server/internal/store/tier.go @@ -5,7 +5,7 @@ import "sort" /* How soon a show's work is worth doing. -Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps +Peter's framing: a few shows are news and stop being worth hearing within a day, a category he keeps up with, and a long tail that is not topical at all. It matters because recognition costs about five minutes of CPU an episode, so a five-hour Dan Carlin episode landing the same morning as Up First takes most of a cycle's budget before the news is touched. @@ -14,31 +14,55 @@ Three and no more. A number would invite fiddling and would not mean anything; t three distinctions he actually described. */ const ( - // TierTopical is news: worth hearing today and worthless by Thursday. - TierTopical = "topical" - // TierOrdinary is a show kept up with. The default, because it is what most of a library - // is and because a show nobody has placed should not be starved. - TierOrdinary = "ordinary" - // TierArchive is a back catalogue, a slow drip, anything heard whenever. Its work waits - // for the time nothing else wants. - TierArchive = "archive" + // The categories, in the order the work is done. The phone sends these; see + // docs/show-kinds.md in the app for what each means and why the order is what it is. + // + // They replaced three values that were priority alone -- topical, ordinary, archive -- + // which could not tell the daily news from a daily horror serial. A category says what a + // show *is*, and the order of the list is the priority, so there is one thing to set + // rather than two that can disagree. + CategoryNews = "news" + CategoryAnalysis = "analysis" + CategoryCuriosity = "curiosity" + CategorySailing = "sailing" + CategoryTrue = "truestory" + CategoryFiction = "fiction" + CategoryDeep = "deep" ) -// tierRank orders the work. Lower goes first. -var tierRank = map[string]int{TierTopical: 0, TierOrdinary: 1, TierArchive: 2} +// categoryRank orders the work. Lower goes first, and the order is the declaration order above. +// +// A category this server does not know -- sent by a newer phone -- ranks with the default +// rather than first or last, so an unrecognised value neither jumps the queue nor starves. +var categoryRank = map[string]int{ + CategoryNews: 0, CategoryAnalysis: 1, CategoryCuriosity: 2, CategorySailing: 3, + CategoryTrue: 4, CategoryFiction: 5, CategoryDeep: 6, +} + +// CategoryDefault is where an unplaced or unrecognised show sits: the middle of the order. +const CategoryDefault = CategoryTrue + +// CategoryRank is where a category sits in the work order, for callers outside this package. +// An unknown one ranks with the default rather than first or last. +func CategoryRank(category string) int { + if rank, ok := categoryRank[category]; ok { + return rank + } + return categoryRank[CategoryDefault] +} /* -SetTiers records where the phone placed each feed, replacing what was there. +SetCategories records where the phone placed each feed, replacing what was there. -Only for feeds that are actually subscribed. The phone sends its list and its tiers in one -push, and a tier naming a feed the same push dropped would otherwise outlive it and be +Only for feeds that are actually subscribed. The phone sends its list and its categories in one +push, and a category naming a feed the same push dropped would otherwise outlive it and be inherited by a resubscription months later. -A tier this server does not understand is dropped rather than stored: a newer phone inventing a +A category this server does not understand is dropped rather than stored: a newer phone inventing a fourth must not quietly stop a show being polled, which is the rule the label store already follows for a verdict it does not recognise. */ -func (s *State) SetTiers(tiers map[string]string) { +func (s *State) SetCategories(categories map[string]string) { s.mu.Lock() defer s.mu.Unlock() if s.tiers == nil { @@ -48,38 +72,38 @@ func (s *State) SetTiers(tiers map[string]string) { for _, url := range s.feeds { subscribed[url] = true } - for url, tier := range tiers { - _, known := tierRank[tier] - if !subscribed[url] || !known || tier == TierOrdinary { + for url, category := range categories { + _, known := categoryRank[category] + if !subscribed[url] || !known || category == CategoryDefault { delete(s.tiers, url) continue } - s.tiers[url] = tier + s.tiers[url] = category } } -// Tier is where a feed sits, or [TierOrdinary]. -func (s *State) Tier(feedURL string) string { +// Category is where a feed sits, or [CategoryDefault]. +func (s *State) Category(feedURL string) string { s.mu.RLock() defer s.mu.RUnlock() - if tier, ok := s.tiers[feedURL]; ok { - return tier + if category, ok := s.tiers[feedURL]; ok { + return category } - return TierOrdinary + return CategoryDefault } -// ByTier orders feed URLs so the topical ones are worked first and the archive last. +// ByCategory orders feed URLs so the news is worked first and the deep dives last. // -// Stable, so an ordering within a tier -- newest first, usually -- survives being grouped. A +// Stable, so an ordering within a category -- newest first, usually -- survives being grouped. A // sort that reshuffled equals would make the queue unpredictable between cycles for no gain. -func (s *State) ByTier(feedURLs []string) []string { +func (s *State) ByCategory(feedURLs []string) []string { out := append([]string(nil), feedURLs...) sort.SliceStable(out, func(i, j int) bool { - return tierRank[s.Tier(out[i])] < tierRank[s.Tier(out[j])] + return categoryRank[s.Category(out[i])] < categoryRank[s.Category(out[j])] }) return out } -// forgetTier drops a feed's placement. Called when the feed goes, or a feed resubscribed months +// forgetCategory drops a feed's placement. Called when the feed goes, or a feed resubscribed months // later would arrive carrying a placement nobody chose. -func (s *State) forgetTier(feedURL string) { delete(s.tiers, feedURL) } +func (s *State) forgetCategory(feedURL string) { delete(s.tiers, feedURL) } diff --git a/server/internal/store/tier_test.go b/server/internal/store/tier_test.go index 971ee51..025f323 100644 --- a/server/internal/store/tier_test.go +++ b/server/internal/store/tier_test.go @@ -17,10 +17,10 @@ the three distinctions he actually described. func TestATierIsRememberedForAFeed(t *testing.T) { s := New() s.SetFeeds([]string{"https://a", "https://b"}) - s.SetTiers(map[string]string{"https://a": TierTopical}) + s.SetCategories(map[string]string{"https://a": CategoryNews}) - if got := s.Tier("https://a"); got != TierTopical { - t.Errorf("got %q, want %q", got, TierTopical) + if got := s.Category("https://a"); got != CategoryNews { + t.Errorf("got %q, want %q", got, CategoryNews) } } @@ -29,8 +29,8 @@ func TestAShowWithNoTierIsOrdinary(t *testing.T) { s := New() s.SetFeeds([]string{"https://a"}) - if got := s.Tier("https://a"); got != TierOrdinary { - t.Errorf("got %q, want %q", got, TierOrdinary) + if got := s.Category("https://a"); got != CategoryDefault { + t.Errorf("got %q, want %q", got, CategoryDefault) } } @@ -43,9 +43,9 @@ same rule the label store already follows for a verdict it does not recognise. func TestAnUnknownTierIsIgnored(t *testing.T) { s := New() s.SetFeeds([]string{"https://a"}) - s.SetTiers(map[string]string{"https://a": "urgent-ish"}) + s.SetCategories(map[string]string{"https://a": "urgent-ish"}) - if got := s.Tier("https://a"); got != TierOrdinary { + if got := s.Category("https://a"); got != CategoryDefault { t.Errorf("got %q, want the ordinary tier", got) } } @@ -55,10 +55,10 @@ func TestAnUnknownTierIsIgnored(t *testing.T) { func TestForgettingAFeedForgetsItsTier(t *testing.T) { s := New() s.SetFeeds([]string{"https://a"}) - s.SetTiers(map[string]string{"https://a": TierTopical}) + s.SetCategories(map[string]string{"https://a": CategoryNews}) s.SetFeeds([]string{"https://b"}) - if got := s.Tier("https://a"); got != TierOrdinary { + if got := s.Category("https://a"); got != CategoryDefault { t.Errorf("got %q, want the tier forgotten with the feed", got) } } @@ -69,16 +69,16 @@ Ordering runs topical first and archive last, and keeps the given order within a Stable so that "newest first" survives being grouped: an ordering that reshuffled equals would make the queue unpredictable from one cycle to the next for no gain. */ -func TestWorkIsOrderedByTier(t *testing.T) { +func TestWorkIsOrderedByCategory(t *testing.T) { s := New() s.SetFeeds([]string{"https://news", "https://usual", "https://archive", "https://news2"}) - s.SetTiers(map[string]string{ - "https://news": TierTopical, - "https://news2": TierTopical, - "https://archive": TierArchive, + s.SetCategories(map[string]string{ + "https://news": CategoryNews, + "https://news2": CategoryNews, + "https://archive": CategoryDeep, }) - got := s.ByTier([]string{"https://usual", "https://archive", "https://news", "https://news2"}) + got := s.ByCategory([]string{"https://usual", "https://archive", "https://news", "https://news2"}) want := []string{"https://news", "https://news2", "https://usual", "https://archive"} for i := range want { if got[i] != want[i] { @@ -96,9 +96,9 @@ dropped would outlive it and be inherited by a resubscription months later. func TestATierForAFeedNotSubscribedIsDropped(t *testing.T) { s := New() s.SetFeeds([]string{"https://b"}) - s.SetTiers(map[string]string{"https://a": TierArchive}) + s.SetCategories(map[string]string{"https://a": CategoryDeep}) - if got := s.Tier("https://a"); got != TierOrdinary { + if got := s.Category("https://a"); got != CategoryDefault { t.Errorf("got %q, want nothing kept for an unsubscribed feed", got) } } diff --git a/server/internal/words/pipeline.go b/server/internal/words/pipeline.go index 8cd18ed..d8de8d8 100644 --- a/server/internal/words/pipeline.go +++ b/server/internal/words/pipeline.go @@ -102,17 +102,11 @@ func (p *Pipeline) Forget(e *store.Episode) { e.WordBreaks, e.WordAdsRefused = nil, "" } -// tierRankOf orders the tiers for work: topical first, archive last. -func tierRankOf(tier string) int { - switch tier { - case store.TierTopical: - return 0 - case store.TierArchive: - return 2 - default: - return 1 - } -} +// categoryRankOf orders the categories for work, news first and deep dives last. +// +// Delegates rather than keeping its own table: the order is the category list's declaration +// order, and two copies of it would drift the first time one is added. +func categoryRankOf(category string) int { return store.CategoryRank(category) } // clock renders a duration the way every readout in this project does. func clock(ms int64) string { @@ -725,7 +719,7 @@ func (p *Pipeline) pendingRecognition() []*store.Episode { rank := map[string]int{} for _, show := range p.State.Shows() { for _, e := range show.Episodes { - rank[e.ID] = tierRankOf(p.State.Tier(show.FeedURL)) + rank[e.ID] = categoryRankOf(p.State.Category(show.FeedURL)) } } sort.SliceStable(pending, func(i, j int) bool { diff --git a/server/internal/words/pipeline_test.go b/server/internal/words/pipeline_test.go index 9982a10..cf23d8f 100644 --- a/server/internal/words/pipeline_test.go +++ b/server/internal/words/pipeline_test.go @@ -76,13 +76,13 @@ A five-hour back-catalogue episode arriving the same morning as the news would o most of a cycle's recognition budget before the news was looked at. */ func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) { - if tierRankOf(store.TierTopical) >= tierRankOf(store.TierOrdinary) { + if categoryRankOf(store.CategoryNews) >= categoryRankOf(store.CategoryDefault) { t.Error("a topical show does not come first") } - if tierRankOf(store.TierArchive) <= tierRankOf(store.TierOrdinary) { + if categoryRankOf(store.CategoryDeep) <= categoryRankOf(store.CategoryDefault) { t.Error("an archive show does not come last") } - if tierRankOf("") != tierRankOf(store.TierOrdinary) { + if categoryRankOf("") != categoryRankOf(store.CategoryDefault) { t.Error("a show nobody placed is not treated as ordinary, so it could be starved") } } @@ -90,7 +90,7 @@ func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) { func TestRecognitionIsOrderedByTierThenByWhatChecksItself(t *testing.T) { state := store.New() state.SetFeeds([]string{"https://news", "https://usual"}) - state.SetTiers(map[string]string{"https://news": store.TierTopical}) + state.SetCategories(map[string]string{"https://news": store.CategoryNews}) state.Merge("https://usual", "Usual", nil) state.Merge("https://news", "News", nil) |
