From 4aa3b55b5e40c104f5d522570bfc5545dbffed8c Mon Sep 17 00:00:00 2001 From: nk Date: Sat, 6 Jun 2026 17:08:36 +0300 Subject: [PATCH] =?UTF-8?q?perf(backend):=20=D1=80=D0=B5=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20track=5Fplays,=20=D0=BF=D1=80=D1=83=D0=BD?= =?UTF-8?q?=20=D1=81=D0=B8=D1=80=D0=BE=D1=82-=D1=82=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20now-playing,=20=D0=BF=D1=83=D0=BB=20=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaintenanceService (@Cron daily 4:00): ретенция track_plays >180д чанками по 20k (без ретенции таблица растёт ~100k строк/сутки) + прун осиротевших треков (без проигрываний/лайков/обложки, >30д). Сейчас удаляет 0 (данным 4 дня) — только ограничивает будущий рост. ВНИМАНИЕ: 180д ограничивает и чарт period=all. - getAllNowPlaying: select-проекция (stationId+name) вместо include station:true — не тянем всю строку Station (streamUrl, tags[], даты) на каждый ряд now_playing. - PrismaService: connection_limit=20 в URL идемпотентно (дефолт ~5 мал под ~16 конкурентных поллеров). --- src/charts/charts.module.ts | 3 +- src/charts/maintenance.service.ts | 87 ++++++++++++++++++++++++++ src/now-playing/now-playing.service.ts | 9 ++- src/prisma/prisma.service.ts | 18 ++++++ 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/charts/maintenance.service.ts diff --git a/src/charts/charts.module.ts b/src/charts/charts.module.ts index 69629f3..55de6f0 100644 --- a/src/charts/charts.module.ts +++ b/src/charts/charts.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { ChartsController } from './charts.controller'; import { ChartsService } from './charts.service'; +import { MaintenanceService } from './maintenance.service'; import { AuthModule } from '../auth/auth.module'; import { EnrichModule } from '../enrich/enrich.module'; @Module({ imports: [AuthModule, EnrichModule], controllers: [ChartsController], - providers: [ChartsService], + providers: [ChartsService, MaintenanceService], exports: [ChartsService], }) export class ChartsModule {} diff --git a/src/charts/maintenance.service.ts b/src/charts/maintenance.service.ts new file mode 100644 index 0000000..7d9beca --- /dev/null +++ b/src/charts/maintenance.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Фоновое обслуживание данных чартов. track_plays растёт ~100k строк/сутки + * (≈700 станций × смены треков), поэтому без ретенции таблица разрастается + * безгранично. Раз в сутки: + * 1) удаляем проигрывания старше RETENTION_DAYS (чанками, чтобы не лочить таблицу); + * 2) подчищаем «осиротевшие» треки (без проигрываний/лайков/обложки, старые). + * + * ⚠️ RETENTION_DAYS ограничивает и чарт period='all' (он становится «за последние + * N дней») и totalPlays трека на детальной странице. 180 дней — компромисс между + * осмысленной историей и размером таблицы (~18M строк потолок). Меняется здесь. + */ +@Injectable() +export class MaintenanceService { + private readonly logger = new Logger(MaintenanceService.name); + + // Сколько дней храним факты проигрывания (см. предупреждение выше). + private static readonly RETENTION_DAYS = 180; + // Возраст «осиротевшего» трека для удаления. + private static readonly ORPHAN_DAYS = 30; + // Размер чанка удаления (чтобы один DELETE не держал долгий лок). + private static readonly CHUNK = 20_000; + + constructor(private readonly prisma: PrismaService) {} + + @Cron(CronExpression.EVERY_DAY_AT_4AM) + async runMaintenance(): Promise { + await this.pruneOldPlays(); + await this.pruneOrphanTracks(); + } + + /** Удаляет проигрывания старше RETENTION_DAYS чанками. */ + private async pruneOldPlays(): Promise { + const cutoff = new Date( + Date.now() - MaintenanceService.RETENTION_DAYS * 86_400_000, + ); + try { + let total = 0; + let removed: number; + do { + // ctid + LIMIT — удаляем порциями, не лочим всю таблицу разом. + removed = await this.prisma.$executeRaw` + DELETE FROM track_plays + WHERE ctid IN ( + SELECT ctid FROM track_plays + WHERE played_at < ${cutoff} + LIMIT ${MaintenanceService.CHUNK} + )`; + total += removed; + } while (removed > 0); + if (total > 0) { + this.logger.log( + `Ретенция: удалено ${total} track_plays старше ${MaintenanceService.RETENTION_DAYS}д`, + ); + } + } catch (e) { + this.logger.error(`Ретенция track_plays упала: ${(e as Error).message}`); + } + } + + /** + * Удаляет треки без следов использования: нет проигрываний, нет лайков, нет + * обложки и созданы давно (artefact-строки, которые иначе копятся навсегда). + * Каскад снимает зависимые plays (их и так нет). + */ + private async pruneOrphanTracks(): Promise { + const cutoff = new Date( + Date.now() - MaintenanceService.ORPHAN_DAYS * 86_400_000, + ); + try { + const removed = await this.prisma.$executeRaw` + DELETE FROM tracks t + WHERE t.first_seen_at < ${cutoff} + AND t.cover_url IS NULL + AND NOT EXISTS (SELECT 1 FROM track_plays p WHERE p.track_id = t.id) + AND NOT EXISTS (SELECT 1 FROM track_likes l WHERE l.track_id = t.id)`; + if (removed > 0) { + this.logger.log(`Прун сирот: удалено ${removed} tracks`); + } + } catch (e) { + this.logger.error(`Прун сирот упал: ${(e as Error).message}`); + } + } +} diff --git a/src/now-playing/now-playing.service.ts b/src/now-playing/now-playing.service.ts index 33d4720..bbc256e 100644 --- a/src/now-playing/now-playing.service.ts +++ b/src/now-playing/now-playing.service.ts @@ -217,8 +217,15 @@ export class NowPlayingService { } async getAllNowPlaying() { + // Проекция: контроллеру нужны только stationId+name станции и трек — + // не тянем всю строку Station (streamUrl, tags[], даты и т.д.) на каждый ряд. const list = await this.prisma.nowPlaying.findMany({ - include: { station: true }, + select: { + artist: true, + song: true, + coverUrl: true, + station: { select: { stationId: true, name: true } }, + }, }); // Для записей без своей обложки (ICY-станции типа DFM) подтягиваем обложку diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 7ffd32d..b0906ab 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,11 +1,29 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +/** + * Добавляет connection_limit в URL, если он не задан явно. По умолчанию Prisma + * берёт num_cpu*2+1 (на мелком VPS ~5), а у нас ~16 now-playing-поллеров + чарты + * шлют запросы конкурентно — пул из 20 устойчивее под всплески. Идемпотентно: + * если параметр уже есть в DATABASE_URL — не трогаем. + */ +function withConnectionLimit(url?: string): string | undefined { + if (!url || /[?&]connection_limit=/.test(url)) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}connection_limit=20`; +} + @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super({ + datasources: { db: { url: withConnectionLimit(process.env.DATABASE_URL) } }, + }); + } + async onModuleInit() { await this.$connect(); }