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(); }