From af5744f8ab5fe3800ecd34d7dfe014ccda71e1a9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 18:26:20 -0400 Subject: [PATCH] perf(home): aggregate-first rewrites for two scan-the-world queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleGetHome itself is well-architected (5 sections in parallel via goroutines, latency-bound by the slowest single query). The cold- start lag is two of those queries doing wider scans than necessary. ListLastPlayedArtistsForUser was iterating FROM artists a with a LATERAL play_events join per row — O(total_artists in library) plan even for users who've only played a handful. Inverted: aggregate the user's plays by artist_id first via the play_events → tracks join (uses play_events_user_track_idx + tracks pkey), then attach the artist row and lateral cover/count subqueries only for the artists that actually appear. Cost now bounded by play history, not library size. ListMostPlayedTracksForUser was joining tracks/albums/artists for every play_event row before grouping — O(total plays) work for joins. Pre-aggregated play_events into a CTE keyed by track_id + count(*), then joined to tracks/albums/artists only for the distinct-tracks survivors. Order-by uses the pre-computed count. No handler or generated-Go signature changes — both queries return the same rowset shape, just much faster on libraries where total artists/plays >> distinct-played-artists/distinct-played-tracks. --- internal/db/dbq/recommendation.sql.go | 66 ++++++++++++++++---------- internal/db/queries/recommendation.sql | 66 ++++++++++++++++---------- 2 files changed, 82 insertions(+), 50 deletions(-) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index e5ca6ba6..fc653a78 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -12,17 +12,19 @@ import ( ) const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many -SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version, - cov.id AS cover_album_id, - cnt.album_count::bigint AS album_count, - max_started.started_at::timestamptz AS last_played_at -FROM artists a -JOIN LATERAL ( - SELECT max(pe.started_at) AS started_at +WITH user_plays AS ( + SELECT t.artist_id, max(pe.started_at) AS last_started FROM play_events pe JOIN tracks t ON t.id = pe.track_id - WHERE pe.user_id = $1 AND t.artist_id = a.id -) max_started ON max_started.started_at IS NOT NULL + WHERE pe.user_id = $1 + GROUP BY t.artist_id +) +SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + up.last_started::timestamptz AS last_played_at +FROM user_plays up +JOIN artists a ON a.id = up.artist_id LEFT JOIN LATERAL ( SELECT id FROM albums WHERE artist_id = a.id AND cover_art_path IS NOT NULL @@ -32,7 +34,7 @@ LEFT JOIN LATERAL ( SELECT count(*) AS album_count FROM albums WHERE artist_id = a.id ) cnt ON true -ORDER BY max_started.started_at DESC, a.id +ORDER BY up.last_started DESC, a.id LIMIT $2 ` @@ -52,6 +54,15 @@ type ListLastPlayedArtistsForUserRow struct { // a derived cover_album_id via a representative-album lateral join (most // recent album that has cover_art_path set). album_count joined for the // ArtistRef wire shape. +// +// Earlier shape iterated `FROM artists a` and ran a LATERAL play_events +// subquery per artist — O(total_artists) plan even for users with a +// handful of plays. New shape aggregates the user's plays by artist +// via the play_events → tracks join up front (uses +// play_events_user_track_idx + tracks pkey lookups), then attaches +// the artist row and lateral cover/count subqueries only for the +// artists that actually appear. Distinct-artists set is small for a +// typical user, so cost is bounded by play history not library size. func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) { rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit) if err != nil { @@ -87,24 +98,24 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast } const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many +WITH plays AS ( + SELECT track_id, count(*) AS cnt + FROM play_events + WHERE user_id = $1 AND was_skipped = false + GROUP BY track_id +) SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, albums.title AS album_title, artists.name AS artist_name -FROM tracks t -JOIN albums ON albums.id = t.album_id -JOIN artists ON artists.id = t.artist_id -JOIN play_events pe ON pe.track_id = t.id -WHERE pe.user_id = $1 - AND pe.was_skipped = false - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 AND q.track_id = t.id - ) -GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, - t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, - t.mbid, t.genre, t.added_at, t.updated_at, - albums.title, artists.name -ORDER BY count(*) DESC, t.id +FROM plays p +JOIN tracks t ON t.id = p.track_id +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id +) +ORDER BY p.cnt DESC, t.id LIMIT $2 ` @@ -122,6 +133,11 @@ type ListMostPlayedTracksForUserRow struct { // M6a: top-N tracks by completed-play count for the user. was_skipped // excludes skips so a user spamming next doesn't fabricate a top track. // Quarantined tracks (per-user soft-hide from M5b) are filtered out. +// +// Aggregate play_events first (uses play_events_user_track_idx) and +// join through tracks/albums/artists only for the survivors. Earlier +// shape did the join across every play_event row before grouping — +// O(plays) instead of O(distinct tracks). func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) { rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit) if err != nil { diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 34e3cd4e..b4cec84b 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -206,24 +206,29 @@ LIMIT $3; -- M6a: top-N tracks by completed-play count for the user. was_skipped -- excludes skips so a user spamming next doesn't fabricate a top track. -- Quarantined tracks (per-user soft-hide from M5b) are filtered out. +-- +-- Aggregate play_events first (uses play_events_user_track_idx) and +-- join through tracks/albums/artists only for the survivors. Earlier +-- shape did the join across every play_event row before grouping — +-- O(plays) instead of O(distinct tracks). +WITH plays AS ( + SELECT track_id, count(*) AS cnt + FROM play_events + WHERE user_id = $1 AND was_skipped = false + GROUP BY track_id +) SELECT sqlc.embed(t), albums.title AS album_title, artists.name AS artist_name -FROM tracks t -JOIN albums ON albums.id = t.album_id -JOIN artists ON artists.id = t.artist_id -JOIN play_events pe ON pe.track_id = t.id -WHERE pe.user_id = $1 - AND pe.was_skipped = false - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 AND q.track_id = t.id - ) -GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, - t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, - t.mbid, t.genre, t.added_at, t.updated_at, - albums.title, artists.name -ORDER BY count(*) DESC, t.id +FROM plays p +JOIN tracks t ON t.id = p.track_id +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id +) +ORDER BY p.cnt DESC, t.id LIMIT $2; -- name: ListLastPlayedArtistsForUser :many @@ -231,17 +236,28 @@ LIMIT $2; -- a derived cover_album_id via a representative-album lateral join (most -- recent album that has cover_art_path set). album_count joined for the -- ArtistRef wire shape. -SELECT sqlc.embed(a), - cov.id AS cover_album_id, - cnt.album_count::bigint AS album_count, - max_started.started_at::timestamptz AS last_played_at -FROM artists a -JOIN LATERAL ( - SELECT max(pe.started_at) AS started_at +-- +-- Earlier shape iterated `FROM artists a` and ran a LATERAL play_events +-- subquery per artist — O(total_artists) plan even for users with a +-- handful of plays. New shape aggregates the user's plays by artist +-- via the play_events → tracks join up front (uses +-- play_events_user_track_idx + tracks pkey lookups), then attaches +-- the artist row and lateral cover/count subqueries only for the +-- artists that actually appear. Distinct-artists set is small for a +-- typical user, so cost is bounded by play history not library size. +WITH user_plays AS ( + SELECT t.artist_id, max(pe.started_at) AS last_started FROM play_events pe JOIN tracks t ON t.id = pe.track_id - WHERE pe.user_id = $1 AND t.artist_id = a.id -) max_started ON max_started.started_at IS NOT NULL + WHERE pe.user_id = $1 + GROUP BY t.artist_id +) +SELECT sqlc.embed(a), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + up.last_started::timestamptz AS last_played_at +FROM user_plays up +JOIN artists a ON a.id = up.artist_id LEFT JOIN LATERAL ( SELECT id FROM albums WHERE artist_id = a.id AND cover_art_path IS NOT NULL @@ -251,7 +267,7 @@ LEFT JOIN LATERAL ( SELECT count(*) AS album_count FROM albums WHERE artist_id = a.id ) cnt ON true -ORDER BY max_started.started_at DESC, a.id +ORDER BY up.last_started DESC, a.id LIMIT $2; -- name: ListRediscoverAlbumsForUser :many