diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 0000000..3ac4a56 --- /dev/null +++ b/PLANS.md @@ -0,0 +1,20 @@ +# muzick — overnight fix plan + +## tasks +1. **fix N+1 queries** in generators.service.ts (#110, prio:4) +2. **fix SSRF guard** in images.routes.ts (#111, prio:3) +3. **clean & re-enrich images** (#124, prio:0 — operational) +4. **ethos UI migration** (#34, prio:5 — dispatched to ethos-ui agent) + +## 1. N+1 queries + +### comfortGenerator (lines 57-87) +current: loops 20 artists, `SELECT ... WHERE object_id = $1` per artist +fix: single query with `WHERE cf.object_id = ANY($1::uuid[])` + `unnest` to get per-artist rows back + +### deepDiveGenerator (lines 248-274) +current: loops 20 albums, `SELECT ... WHERE t.album_id = $1` per album +fix: single query with `WHERE t.album_id = ANY($1::uuid[])` — already batched on artist query + +### verification +`comfortGenerator` and `deepDiveGenerator` are tested in `generators.test.ts` diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts index 4f6f295..fef14a4 100644 --- a/backend/src/services/generators.service.ts +++ b/backend/src/services/generators.service.ts @@ -52,41 +52,46 @@ async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise b.value - a.value) .slice(0, 20); - const candidates: Candidate[] = []; + if (topArtists.length === 0) return []; - for (const belief of topArtists) { - const res = await db.pgClient.query( - `SELECT t.id + const artistValueMap = new Map(topArtists.map(b => [b.entity_id, b.value])); + const artistIds = topArtists.map(b => b.entity_id); + + const res = await db.pgClient.query( + `SELECT sub.id, sub.artist_id + FROM ( + SELECT t.id, cf.object_id AS artist_id, + ROW_NUMBER() OVER (PARTITION BY cf.object_id ORDER BY cf.fused_value DESC) AS rn FROM tracks t JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id AND cf.predicate IN ('credited_main_on', 'featured_on') - AND cf.object_type = 'artist' AND cf.object_id = $1 + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) AND (cf.user_id = $2 OR cf.user_id = $3) WHERE t.state = 'LIBRARY' AND NOT (t.id = ANY($4::uuid[])) - ORDER BY cf.fused_value DESC - LIMIT 2`, - [belief.entity_id, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] - ); + ) sub + WHERE sub.rn <= 2 + ORDER BY sub.artist_id, sub.rn`, + [artistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); - for (const row of res.rows as { id: string }[]) { - candidates.push({ - trackId: row.id, - generatorId: 'comfort', - explanation: [{ - subjectType: 'artist', - subjectId: belief.entity_id, - predicate: 'credited_main_on', - objectType: 'track', - objectId: row.id, - fusedValue: belief.value, - }], - relevance: belief.value, - }); - } - } - - return candidates; + return (res.rows as { id: string; artist_id: string }[]).map(row => { + const value = artistValueMap.get(row.artist_id) ?? 0.5; + return { + trackId: row.id, + generatorId: 'comfort', + explanation: [{ + subjectType: 'artist', + subjectId: row.artist_id, + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: value, + }], + relevance: value, + }; + }); } // --------------------------------------------------------------------------- @@ -243,37 +248,40 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise< [obsessedIds] ); - const candidates: Candidate[] = []; + const albumRows = albumRes.rows as { album_id: string; artist_id: string }[]; + if (albumRows.length === 0) return []; - for (const album of albumRes.rows as { album_id: string; artist_id: string }[]) { - const trackRes = await db.pgClient.query( - `SELECT t.id + const albumIds = albumRows.map(a => a.album_id); + const albumArtistMap = new Map(albumRows.map(a => [a.album_id, a.artist_id])); + + const trackRes = await db.pgClient.query( + `SELECT sub.id, sub.album_id + FROM ( + SELECT t.id, t.album_id, + ROW_NUMBER() OVER (PARTITION BY t.album_id ORDER BY t.title ASC) AS rn FROM tracks t - WHERE t.album_id = $1 AND t.state = 'LIBRARY' + WHERE t.album_id = ANY($1::uuid[]) + AND t.state = 'LIBRARY' AND NOT (t.id = ANY($2::uuid[])) - ORDER BY t.title ASC - LIMIT 5`, - [album.album_id, ctx.recentExclusions] - ); + ) sub + WHERE sub.rn <= 5 + ORDER BY sub.album_id, sub.rn`, + [albumIds, ctx.recentExclusions] + ); - for (const row of trackRes.rows as { id: string }[]) { - candidates.push({ - trackId: row.id, - generatorId: 'deep-dive', - explanation: [{ - subjectType: 'artist', - subjectId: album.artist_id, - predicate: 'credited_main_on', - objectType: 'track', - objectId: row.id, - fusedValue: 0.7, - }], - relevance: 0.7, - }); - } - } - - return candidates; + return (trackRes.rows as { id: string; album_id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'deep-dive', + explanation: [{ + subjectType: 'artist', + subjectId: albumArtistMap.get(row.album_id) ?? 'unknown', + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.7, + }], + relevance: 0.7, + })); } // ---------------------------------------------------------------------------