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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user