perf(feed): collapse count + facets into one /feed/summary pass (E2 S4, PERF-13)
The FE fired /feed/count (1 pass) + /facets (3 passes) on every filter change =
4 full scans of the subscribed set. New /api/feed/summary does ONE grouped query
(the base filter with tags stripped, GROUP BY channel_id -> per-channel video
counts) and derives the total count + every tag's distinct-channel facet count in
Python on the ~few-hundred-row channel map, faithfully mirroring filtered_query's
channel-based tag logic + the /facets drill-down (own category dropped, unless a
conjunctive topic-AND). Returns {count, facets} — the same numbers the two old
endpoints returned.
FE: a single shared query (Feed reads .count, Sidebar reads .facets); same key +
filters, so React Query dedupes to ONE request. Invalidations repointed to
qk.feedSummary. The old /feed/count + /facets endpoints (and their now-unused FE
api/qk) are kept as the equivalence-test oracle + safety net; a later cleanup can
drop them.
New equivalence test (test_feed_views, 8 filter combos incl. cross-category,
disjunctive/conjunctive topic modes, search): /feed/summary count == /feed/count
and facets == /facets, byte-identical. Structural win: 2 requests / 4-6 queries
-> 1 request / 2-3 queries (the review measured 530 -> 144 ms). Gate green:
344 pytest, frontend tsc/eslint/knip/prettier/222 vitest.
This commit is contained in:
@@ -115,7 +115,7 @@ export default function Channels() {
|
||||
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
|
||||
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
||||
@@ -131,7 +131,7 @@ export default function Channels() {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
},
|
||||
});
|
||||
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
|
||||
|
||||
@@ -226,9 +226,11 @@ export default function Feed({
|
||||
}
|
||||
}, [query.dataUpdatedAt]);
|
||||
|
||||
const countQuery = useQuery({
|
||||
queryKey: qk.feedCount(queryFilters),
|
||||
queryFn: () => api.feedCount(queryFilters),
|
||||
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
||||
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: qk.feedSummary(queryFilters),
|
||||
queryFn: () => api.feedSummary(queryFilters),
|
||||
staleTime: 30_000,
|
||||
enabled: !ytActive,
|
||||
placeholderData: keepPreviousData,
|
||||
@@ -358,8 +360,7 @@ export default function Feed({
|
||||
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
||||
const dropFeedCaches = () => {
|
||||
qc.removeQueries({ queryKey: qk.feed() });
|
||||
qc.removeQueries({ queryKey: qk.feedCount() });
|
||||
qc.removeQueries({ queryKey: qk.facets() });
|
||||
qc.removeQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
// --- Live YouTube search mode -------------------------------------------------------------
|
||||
@@ -622,10 +623,10 @@ export default function Feed({
|
||||
)}
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
||||
{countQuery.data
|
||||
{summaryQuery.data
|
||||
? t("feed.videoCount", {
|
||||
count: countQuery.data.count,
|
||||
formattedCount: countQuery.data.count.toLocaleString(),
|
||||
count: summaryQuery.data.count,
|
||||
formattedCount: summaryQuery.data.count.toLocaleString(),
|
||||
})
|
||||
: " "}
|
||||
</span>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function NotificationsPanel() {
|
||||
.setState(meta.videoId, "new")
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
|
||||
resolveVideo(meta.videoId);
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
onSuccess: () => {
|
||||
// Feed/channels/status now have content — refresh the app behind the wizard.
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
},
|
||||
|
||||
@@ -87,14 +87,16 @@ export default function Sidebar({
|
||||
// Live per-tag channel counts for the current filter context. Debounce the search term so
|
||||
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
|
||||
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
|
||||
const facetsQuery = useQuery({
|
||||
queryKey: qk.facets(facetFilters),
|
||||
queryFn: () => api.facets(facetFilters),
|
||||
// Shared with the feed's count (same key + filters -> React Query dedupes to one request): the feed
|
||||
// reads `.count`, this reads `.facets` (PERF-13, replaces the separate facets query).
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: qk.feedSummary(facetFilters),
|
||||
queryFn: () => api.feedSummary(facetFilters),
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const facetsReady = !!facetsQuery.data;
|
||||
const facetCounts = facetsQuery.data?.counts ?? {};
|
||||
const facetsReady = !!summaryQuery.data;
|
||||
const facetCounts = summaryQuery.data?.facets ?? {};
|
||||
const chipCount = (tag: Tag): number =>
|
||||
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
|
||||
const visibleChips = (list: Tag[]): Tag[] => {
|
||||
|
||||
@@ -131,6 +131,10 @@ export const feedApi = {
|
||||
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
|
||||
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||
req(`/api/facets?${filterParams(f).toString()}`),
|
||||
// The sidebar's total count + per-tag channel facets in ONE request + ONE grouped query, replacing
|
||||
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
|
||||
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
|
||||
req(`/api/feed/summary?${filterParams(f).toString()}`),
|
||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
|
||||
setState: (id: string, status: string) =>
|
||||
|
||||
@@ -16,6 +16,8 @@ describe("qk — bare vs parameterized branch", () => {
|
||||
expect(qk.feedCount(FILTERS)).toEqual(["feed-count", FILTERS]);
|
||||
expect(qk.facets()).toEqual(["facets"]);
|
||||
expect(qk.facets(FILTERS)).toEqual(["facets", FILTERS]);
|
||||
expect(qk.feedSummary()).toEqual(["feed-summary"]);
|
||||
expect(qk.feedSummary(FILTERS)).toEqual(["feed-summary", FILTERS]);
|
||||
expect(qk.playlist()).toEqual(["playlist"]);
|
||||
expect(qk.plexShow()).toEqual(["plex-show"]);
|
||||
expect(qk.plexShow("s1")).toEqual(["plex-show", "s1"]);
|
||||
|
||||
@@ -27,6 +27,8 @@ export const qk = {
|
||||
filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const),
|
||||
facets: (filters?: FeedFilters) =>
|
||||
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
|
||||
feedSummary: (filters?: FeedFilters) =>
|
||||
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
|
||||
ytSearch: (q?: string | null, count?: number) =>
|
||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
|
||||
Reference in New Issue
Block a user