perf(api): collapse N+1 in /api/artists/{id} + 1 round-trip in /api/albums/{id}

Two new sqlc queries replace three sequential per-album round trips
that were dominating detail-screen latency.

GetAlbumWithArtist: handleGetAlbum was doing GetAlbumByID then
GetArtistByID — separate round trips for one logical lookup. The new
query joins albums + artists with sqlc.embed and returns both in one
SELECT. Detail-page DB cost: 3 trips → 2.

ListAlbumsByArtistWithTrackCount: handleGetArtist was loading the
artist's album list, then issuing one CountTracksByAlbum per album to
populate track_count. On a 30-album artist that's 32 sequential
queries — each ~5ms over a local DB, ~30ms over a remote one. The
new query embeds the album row + a correlated count(*) subquery, so
every album's track count comes back in one SELECT regardless of
album count. Detail-page DB cost: 1 + N → 1 + 1.

Together these account for the bulk of cold-cache navigation latency
on the Flutter client. Combined with the existing SWR + nav
hydration on the client side, detail screens should render their
header instantly and the body within one round trip instead of
N+constant.
This commit is contained in:
2026-05-11 18:13:27 -04:00
parent 9cac664679
commit c7549bbe48
3 changed files with 132 additions and 20 deletions
+85
View File
@@ -168,6 +168,41 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR
return i, err
}
const getAlbumWithArtist = `-- name: GetAlbumWithArtist :one
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.id = $1
`
type GetAlbumWithArtistRow struct {
Album Album
ArtistName string
}
// Combined fetch for /api/albums/{id}: returns the album row + the
// joined artist name in a single round trip. Replaces a sequential
// GetAlbumByID + GetArtistByID pair on the hot detail-page path.
func (q *Queries) GetAlbumWithArtist(ctx context.Context, id pgtype.UUID) (GetAlbumWithArtistRow, error) {
row := q.db.QueryRow(ctx, getAlbumWithArtist, id)
var i GetAlbumWithArtistRow
err := row.Scan(
&i.Album.ID,
&i.Album.Title,
&i.Album.SortTitle,
&i.Album.ArtistID,
&i.Album.ReleaseDate,
&i.Album.Mbid,
&i.Album.CoverArtPath,
&i.Album.CreatedAt,
&i.Album.UpdatedAt,
&i.Album.CoverArtSource,
&i.Album.CoverArtSourcesVersion,
&i.ArtistName,
)
return i, err
}
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
`
@@ -389,6 +424,56 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID)
return items, nil
}
const listAlbumsByArtistWithTrackCount = `-- name: ListAlbumsByArtistWithTrackCount :many
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version,
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
AS track_count
FROM albums
WHERE albums.artist_id = $1
ORDER BY release_date NULLS LAST, sort_title
`
type ListAlbumsByArtistWithTrackCountRow struct {
Album Album
TrackCount int64
}
// Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
// per album). Returns each album joined with its track count via a
// correlated subquery — single round trip regardless of album count.
func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID pgtype.UUID) ([]ListAlbumsByArtistWithTrackCountRow, error) {
rows, err := q.db.Query(ctx, listAlbumsByArtistWithTrackCount, artistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAlbumsByArtistWithTrackCountRow
for rows.Next() {
var i ListAlbumsByArtistWithTrackCountRow
if err := rows.Scan(
&i.Album.ID,
&i.Album.Title,
&i.Album.SortTitle,
&i.Album.ArtistID,
&i.Album.ReleaseDate,
&i.Album.Mbid,
&i.Album.CoverArtPath,
&i.Album.CreatedAt,
&i.Album.UpdatedAt,
&i.Album.CoverArtSource,
&i.Album.CoverArtSourcesVersion,
&i.TrackCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
FROM albums
+20
View File
@@ -14,6 +14,26 @@ RETURNING *;
-- name: GetAlbumByID :one
SELECT * FROM albums WHERE id = $1;
-- name: GetAlbumWithArtist :one
-- Combined fetch for /api/albums/{id}: returns the album row + the
-- joined artist name in a single round trip. Replaces a sequential
-- GetAlbumByID + GetArtistByID pair on the hot detail-page path.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.id = $1;
-- name: ListAlbumsByArtistWithTrackCount :many
-- Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
-- per album). Returns each album joined with its track count via a
-- correlated subquery — single round trip regardless of album count.
SELECT sqlc.embed(albums),
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
AS track_count
FROM albums
WHERE albums.artist_id = $1
ORDER BY release_date NULLS LAST, sort_title;
-- name: GetAlbumByArtistAndTitle :one
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create.
SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;