perf(backend): индексы, кэш чартов, пропуск upsert, фикс N+1 обогащения

- track_plays(played_at,track_id,station_id) покрывающий + tracks(first_seen_at) WHERE
  pending частичный (применены CONCURRENTLY на проде + миграция idempotent)
- ChartsService.getTopTracks: in-memory TTL-кэш 90с по (period,genre,limit) → детальная
  страница и параллельные запросы не пересчитывают тяжёлые агрегации
- NowPlayingService.ingest: не пишем now_playing и не шлём сокет, если трек не изменился
  (было ~20k бесполезных upsert/час)
- enrichNowPlaying: вместо N+1 (300 upsert/мин) — один batched findMany по normKey

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-06 16:20:25 +03:00
parent 924a4a0ab1
commit a3434ed894
5 changed files with 72 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
-- Покрывающий индекс под агрегацию чартов (идемпотентно — на проде уже создан CONCURRENTLY).
CREATE INDEX IF NOT EXISTS "track_plays_window_idx" ON "track_plays" ("played_at", "track_id", "station_id");
-- Частичный индекс под бэкфилл обогащения (Prisma не выражает partial WHERE в схеме).
CREATE INDEX IF NOT EXISTS "tracks_enrich_pending_idx" ON "tracks" ("first_seen_at" DESC) WHERE "enrich_status" = 'pending';

View File

@@ -139,6 +139,8 @@ model Track {
likes TrackLike[] likes TrackLike[]
@@index([genre]) @@index([genre])
// Частичный индекс под бэкфилл обогащения создаётся миграцией (Prisma не умеет
// partial WHERE): tracks_enrich_pending_idx (first_seen_at DESC) WHERE enrich_status='pending'.
@@map("tracks") @@map("tracks")
} }
@@ -154,6 +156,8 @@ model TrackPlay {
@@index([trackId, playedAt]) @@index([trackId, playedAt])
@@index([playedAt]) @@index([playedAt])
@@index([stationId]) @@index([stationId])
// Покрывающий индекс под агрегацию чартов (WHERE played_at>=X → GROUP BY track_id, COUNT DISTINCT station_id)
@@index([playedAt, trackId, stationId], map: "track_plays_window_idx")
@@map("track_plays") @@map("track_plays")
} }

View File

@@ -214,12 +214,23 @@ export class ChartsService {
} }
} }
// In-memory TTL-кэш чартов: чарт меняется медленно, а агрегации тяжёлые.
// Один пересчёт на (period, genre, limit) раз в CHART_TTL.
private chartCache = new Map<string, { at: number; data: { items: ChartEntry[] } }>();
private static readonly CHART_TTL = 90_000;
// Чарт треков за период (с опциональным фильтром по жанру) // Чарт треков за период (с опциональным фильтром по жанру)
async getTopTracks( async getTopTracks(
period: ChartPeriod, period: ChartPeriod,
limit: number, limit: number,
genre?: string, genre?: string,
): Promise<{ items: ChartEntry[] }> { ): Promise<{ items: ChartEntry[] }> {
const cacheKey = `${period}|${genre ?? ''}|${limit}`;
const cached = this.chartCache.get(cacheKey);
if (cached && Date.now() - cached.at < ChartsService.CHART_TTL) {
return cached.data;
}
const since = this.periodStart(period); const since = this.periodStart(period);
const duration = this.periodDuration(period); const duration = this.periodDuration(period);
const prevSince = new Date(since.getTime() - duration); const prevSince = new Date(since.getTime() - duration);
@@ -232,7 +243,11 @@ export class ChartsService {
select: { id: true }, select: { id: true },
}); });
genreTrackIds = matched.map((t) => t.id); genreTrackIds = matched.map((t) => t.id);
if (genreTrackIds.length === 0) return { items: [] }; if (genreTrackIds.length === 0) {
const empty = { items: [] };
this.chartCache.set(cacheKey, { at: Date.now(), data: empty });
return empty;
}
} }
// Топ текущего периода: группировка по trackId // Топ текущего периода: группировка по trackId
@@ -248,7 +263,9 @@ export class ChartsService {
}); });
if (currentGroups.length === 0) { if (currentGroups.length === 0) {
return { items: [] }; const empty = { items: [] };
this.chartCache.set(cacheKey, { at: Date.now(), data: empty });
return empty;
} }
const trackIds = currentGroups.map((g) => g.trackId); const trackIds = currentGroups.map((g) => g.trackId);
@@ -347,7 +364,9 @@ export class ChartsService {
}; };
}); });
return { items }; const result = { items };
this.chartCache.set(cacheKey, { at: Date.now(), data: result });
return result;
} }
// Детальная страница трека // Детальная страница трека

View File

@@ -92,19 +92,39 @@ export class EnrichmentService {
const rows = await this.prisma.nowPlaying.findMany({ const rows = await this.prisma.nowPlaying.findMany({
select: { artist: true, song: true }, select: { artist: true, song: true },
}); });
const todo: { id: string; artist: string; song: string; normKey: string }[] = []; // Треки уже созданы в ChartsService.recordPlay — не upsert'им построчно (был
// N+1 на ~300 строк/мин), а читаем пачкой по normKey. Мусор/исключённые
// станции трек не создавали → их и не обогащаем (это правильно).
const normKeys = new Set<string>();
for (const r of rows) { for (const r of rows) {
const artist = (r.artist ?? '').trim(); const artist = (r.artist ?? '').trim();
const song = (r.song ?? '').trim(); const song = (r.song ?? '').trim();
if (!artist || !song) continue; if (artist && song) normKeys.add(this.buildNormKey(artist, song));
const normKey = this.buildNormKey(artist, song); }
const track = await this.prisma.track.upsert({ if (normKeys.size === 0) return;
where: { normKey },
create: { normKey, artist, song }, const tracks = await this.prisma.track.findMany({
update: {}, where: { normKey: { in: [...normKeys] } },
select: { id: true, coverUrl: true, enrichStatus: true }, select: {
id: true,
artist: true,
song: true,
normKey: true,
coverUrl: true,
enrichStatus: true,
},
}); });
if (!track.coverUrl) todo.push({ id: track.id, artist, song, normKey });
const todo: { id: string; artist: string; song: string; normKey: string }[] = [];
for (const track of tracks) {
if (!track.coverUrl) {
todo.push({
id: track.id,
artist: track.artist,
song: track.song,
normKey: track.normKey,
});
}
// полное обогащение (жанр) — в общую очередь, если ещё не сделано // полное обогащение (жанр) — в общую очередь, если ещё не сделано
if (track.enrichStatus !== 'done') this.enqueue(track.id); if (track.enrichStatus !== 'done') this.enqueue(track.id);
} }

View File

@@ -149,6 +149,17 @@ export class NowPlayingService {
where: { stationId: stationDbId }, where: { stationId: stationDbId },
}); });
// Ничего не изменилось (станцию опросили, трек тот же) — не пишем в БД и не
// шлём сокет: иначе ~20k бесполезных upsert/час и лишний churn индексов.
if (
prev &&
prev.song === song &&
prev.artist === artist &&
prev.coverUrl === coverUrl
) {
return;
}
const updated = await this.prisma.nowPlaying.upsert({ const updated = await this.prisma.nowPlaying.upsert({
where: { stationId: stationDbId }, where: { stationId: stationDbId },
create: { stationId: stationDbId, song, artist, coverUrl }, create: { stationId: stationDbId, song, artist, coverUrl },