perf(backend): ретенция track_plays, прун сирот-треков, проекция now-playing, пул БД
- 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 конкурентных поллеров).
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ChartsController } from './charts.controller';
|
import { ChartsController } from './charts.controller';
|
||||||
import { ChartsService } from './charts.service';
|
import { ChartsService } from './charts.service';
|
||||||
|
import { MaintenanceService } from './maintenance.service';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { EnrichModule } from '../enrich/enrich.module';
|
import { EnrichModule } from '../enrich/enrich.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, EnrichModule],
|
imports: [AuthModule, EnrichModule],
|
||||||
controllers: [ChartsController],
|
controllers: [ChartsController],
|
||||||
providers: [ChartsService],
|
providers: [ChartsService, MaintenanceService],
|
||||||
exports: [ChartsService],
|
exports: [ChartsService],
|
||||||
})
|
})
|
||||||
export class ChartsModule {}
|
export class ChartsModule {}
|
||||||
|
|||||||
87
src/charts/maintenance.service.ts
Normal file
87
src/charts/maintenance.service.ts
Normal file
@@ -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<void> {
|
||||||
|
await this.pruneOldPlays();
|
||||||
|
await this.pruneOrphanTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет проигрывания старше RETENTION_DAYS чанками. */
|
||||||
|
private async pruneOldPlays(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,8 +217,15 @@ export class NowPlayingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAllNowPlaying() {
|
async getAllNowPlaying() {
|
||||||
|
// Проекция: контроллеру нужны только stationId+name станции и трек —
|
||||||
|
// не тянем всю строку Station (streamUrl, tags[], даты и т.д.) на каждый ряд.
|
||||||
const list = await this.prisma.nowPlaying.findMany({
|
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) подтягиваем обложку
|
// Для записей без своей обложки (ICY-станции типа DFM) подтягиваем обложку
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
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()
|
@Injectable()
|
||||||
export class PrismaService
|
export class PrismaService
|
||||||
extends PrismaClient
|
extends PrismaClient
|
||||||
implements OnModuleInit, OnModuleDestroy
|
implements OnModuleInit, OnModuleDestroy
|
||||||
{
|
{
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
datasources: { db: { url: withConnectionLimit(process.env.DATABASE_URL) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user