perf(home): aggregate-first rewrites for two scan-the-world queries

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.
This commit is contained in:
2026-05-11 18:26:20 -04:00
parent c7549bbe48
commit af5744f8ab
2 changed files with 82 additions and 50 deletions
+41 -25
View File
@@ -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 {
+41 -25
View File
@@ -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