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:
@@ -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';
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Детальная страница трека
|
// Детальная страница трека
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user