Backend · NestJS / gRPC

Backend — core-api-grpc

Детальный разбор серверной части PrimeTime. Обзор всей системы — в about.md; ML-составляющая — в ml.md.

01 Технологическое ядро

КатегорияВыбор
ФреймворкNestJS 10 как gRPC-микросервис (Transport.GRPC)
ТранспортgRPC (content.proto, 22 сервиса, 78 RPC), лимит сообщений 50 МБ
ORMPrisma 6, multi-schema, typedSql, raw-SQL для векторов
БДPostgreSQL 17 + pgvector, pg_trgm, btree_gin, uuid-ossp
ПоискElasticsearch 8.19 (@nestjs/elasticsearch)
Кэш/очередиRedis (ioredis), Bull (@nestjs/bull), cron (@nestjs/schedule)
AuthPassport JWT, nestjs-rbac, bcrypt
ML inference@tensorflow/tfjs-node (с фолбэком на чистый JS)
ВнешниеKinopoisk API, TMDB, Timeweb LLM, Telegram (nestjs-telegram)

Точка входа поднимает чистый микросервис без HTTP-обвязки:

// main.ts
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
  transport: Transport.GRPC,
  options: {
    url: '0.0.0.0:50051', package: 'content', protoPath,
    maxSendMessageLength: 1024 * 1024 * 50,
    maxReceiveMessageLength: 1024 * 1024 * 50,
  },
});

protoPath ищется по списку кандидатов (dev / dist / cwd) — устойчивость к разным окружениям сборки.

02 Модульная структура

src/ ├── common/ # инфраструктура: prisma, redis, config, logger, │ # interceptors, errors, validators ├── modules/ │ ├── grpc/ # gRPC-контроллеры (18) + сервисы (26) + mappers + catalog/ │ ├── balancer/ # отказоустойчивый роутинг внешних провайдеров │ │ ├── core/ # facade, registry, routing, token, policy │ │ ├── providers/ # kp-unofficial, kp-dev │ │ └── services/ # content-loader / merge / saver / movie-cache │ ├── auth/ # JWT-стратегии, guards, decorators │ └── device/ ├── search/ # вспомогательное ├── scripts/ # backfill-studios, backfill-staff, index-elastic, tmdb-smoke ├── shared/ └── proto/content.proto

Контроллеры тонкие (маппинг proto ↔ домен), бизнес-логика — в сервисах. Это канонический NestJS, выдержанный последовательно по всем 18 контроллерам.

03 Согласованность с фронтендом: единый proto-контракт

Это центральный принцип всей системы. content.proto (1497 строк) — единый источник истины для обоих концов:

  • На бэкенде NestJS реализует gRPC-сервисы по этому proto.
  • На фронте buf generate производит из того же файла TypeScript-клиент.

Фронтовый content.proto и бэкендовый идентичны (фронт держит копию для кодогенерации). 78 RPC-методов покрывают весь функционал: AuthService, MovieService, CatalogService, SearchService, RecommendationService, AnalyticsService, TelegramMailingService, StudioService, StaffService и т.д.

Эффект: изменение поля сообщения немедленно отражается в типах обеих сторон, а бинарная сериализация gRPC-Web компактнее и быстрее JSON. Браузерный трафик транслируется из gRPC-Web в gRPC через Envoy.

04 Работа с БД: Prisma + multi-schema + pgvector

Схема

schema.prisma (899 строк) описывает богатую доменную модель в двух схемах PostgreSQL:

  • content — фильмы, пользователи, плейлисты, персоны, студии, жанры, страны, факты, рейтинги, watch-сессии, статистика, Telegram-кампании, ML-реакции.
  • content-balancer — провайдеры и токены балансировщика (изоляция инфраструктуры от домена).

Включены расширения и preview-фичи:

generator client {
  previewFeatures = ["fullTextSearchPostgres", "multiSchema", "postgresqlExtensions", "typedSql"]
}
datasource db {
  extensions = [btree_gin, pg_trgm, uuid_ossp(map: "uuid-ossp"), vector]
  schemas    = ["content", "content-balancer"]
}

Векторные колонки

Ключевой нюанс — pgvector-колонки объявлены как Unsupported (Prisma их не умеет читать), поэтому над ними работают через raw SQL:

model watchContent {
  // ...
  semanticVector   Unsupported("vector(768)")? @map("semantic_vector")  // смысл описания
  itemTowerVector  Unsupported("vector(64)")?  @map("item_tower_vector") // выход Item Tower
}

Качество схемы

Схема демонстрирует продуманное индексирование под конкретные запросы:

  • GIN-индексы с text_ops для полнотекстового поиска по именам/email/username.
  • Хэш-индексы на kinopoiskId, slug, accessToken (точечный lookup).
  • Композитные индексы под реальные паттерны: idx_watch_sessions_user_kp_status, idx_movie_content_type_year, idx_playlist_items_list_order.
  • Каскады и onDelete-политики проставлены осознанно (Cascade / SetNull / NoAction).

11 ролей пользователей, 8 статусов сессий, аккуратные enum-ы — домен проработан глубоко, а не «на коленке».

Ключевой раздел

05 Гибридный поиск (Elasticsearch + pgvector + LLM)

elastic-search.service.ts (738 строк) — витрина инженерного мастерства. Поиск объединяет три независимых сигнала.

5.1. Два пула кандидатов

  1. BM25 в Elasticsearch — широкий пул (150 кандидатов) по полям names^3 / description / roles^2 с fuzziness: AUTO. Из _source берутся только kinopoiskId и предрассчитанные взвешенные рейтинги (без загрузки карточек).
  2. Семантический kNN в pgvector — запрос превращается в 768-мерный вектор и ищет ближайшие semantic_vector (60 кандидатов).

5.2. Reciprocal Rank Fusion (RRF)

Две несравнимые шкалы скоров сливаются через RRF — каждый кандидат получает Σ w / (k + rank):

esHits.forEach((h, i) => {
  rrf.set(h.id, (rrf.get(h.id) ?? 0) + this.SEARCH.wEs / (k + i + 1));
});
semanticIds.forEach((id, i) => rrf.set(id, (rrf.get(id) ?? 0) + this.SEARCH.wSem / (k + i + 1)));

5.3. Байесовское взвешивание популярности (формула IMDb Top-250)

Чтобы среди равно-релевантных побеждал известный тайтл, применяется взвешенный рейтинг WR = v/(v+m)·R + m/(v+m)·C, предрассчитанный и хранимый прямо в ES-индексе:

final = rrf · (1 + α · popularity)   // α = 2.0 (сильное влияние популярности)

Константы C и m выведены из данных (≈70-й перцентиль голосов) и переопределяемы через env.

5.4. Семантический «гейт»

Короткие запросы (< 3 слов) — это поиск по названию/имени/персонажу, где эмбеддинги целых документов вносят шум. Поэтому семантика для них отключается:

const useSemantic = wordCount >= this.SEARCH.semMinWords;

5.5. LLM-нормализация запроса

Если включён флаг, сырой запрос прогоняется через Timeweb LLM (deepseek-v4-flash), которая превращает «кино где парень застрял во времени»«день сурка groundhog day петля времени комедия». System-prompt жёстко требует «только теги через пробел, без знаков препинания и диалога», стоят лимиты по символам/словам и таймаут с фолбэком на исходный запрос.

5.6. Производительность как дисциплина

  • Полные карточки из PG грузятся только для финального top-N, а не для всего пула.
  • Для семантик-only кандидатов (нет ES-популярности) — ограниченный re-rank-пул.
  • Пагинация со 2-й страницы — простой BM25 (re-rank это забота 1-й страницы).
  • Инкрементальная синхронизация ES каждую минуту (@Cron(EVERY_MINUTE)) + батчевый full reindex по 5000 документов.

Это уровень поисковой команды, а не «прикрутил ES».

Ключевой раздел

06 Векторные рекомендации и pgvector

recommendation.service.ts (757 строк). Бэкенд не гоняет фильмы через нейросеть в рантайме — вместо этого:

  1. Загружает только User Tower при старте (onModuleInit), с фолбэком tfjs-node → tfjs и даже ручным IO-handler-ом для весов.
  2. Собирает 814-мерный профиль пользователя из истории/избранного.
  3. Прогоняет профиль через User Tower → 64-мерный вектор.
  4. Делает один SQL-запрос по item_tower_vector (предрассчитанные офлайн).

Двухстадийный HNSW-поиск (с разбором EXPLAIN в комментарии)

// EXPLAIN показал: одностадийный `<=> + random` шёл seq scan по ~291k строк = ~930мс.
// (1) HNSW берёт top-K ближайших ЧИСТЫМ `<=>` (индекс работает только на чистом
//     порядке); (2) ре-ранк этих кандидатов со штрафом + джиттером.
const sqlQuery = `
  WITH cand AS (
    SELECT wc.id, wc.kinopoisk_id, (wc.item_tower_vector <=> $1::vector) AS dist
    FROM content.movie_content wc
    WHERE wc.item_tower_vector IS NOT NULL ${candidateWhere}
    ORDER BY wc.item_tower_vector <=> $1::vector
    LIMIT ${candidatePool}
  )
  SELECT c.kinopoisk_id, 1.0 - c.dist AS score
  FROM cand c
  ORDER BY c.dist + (${demotionSql}) + (RANDOM() * 0.05)
  LIMIT ${limit}`;

Дополнительно — SET LOCAL hnsw.ef_search внутри транзакции, чтобы не протёк в пул соединений:

await this.prisma.$transaction(async (tx) => {
  await tx.$executeRawUnsafe(`SET LOCAL hnsw.ef_search = ${candidatePool}`);
  return tx.$queryRawUnsafe(sqlQuery, ...queryArgs);
});

Фильтрация прямо в SQL: исключаются оценённые (ml_user_to_movie), реально просмотренные (хит-сессии), и контент из плейлистов. + RANDOM()*0.05 — лёгкий exploration, чтобы лента не застывала.

Объяснимый «контент-визибилити»-слой

content-visibility.util.ts — единый блэклист (скрытые жанры/страны, мин. рейтинг, мин. число голосов), отдаваемый в трёх формах:

  • visibilityWhereAnd() — Prisma-фрагменты;
  • visibilitySql(alias) — raw-SQL AND-клаузы (там, где Prisma не годится);
  • visibilityDemotionSql(alias)мягкий штраф к дистанции (демоут, а не исключение) для ранжирования рекомендаций.

Одна доменная политика, три представления под разные движки запросов — отличный DRY.

Ключевой раздел

07 Полки главной страницы (shelves.service.ts)

774 строки. Главная — не один усреднённый список, а лента объяснимых «полок», где заголовок несёт причину: «Вам понравился „X“ — вот похожее», «Вы недавно смотрели „Y“», «Вы любите фантастику».

Сильные приёмы:

  • Runtime k-means прямо по векторам пользователя (детерминированный, без Math.random) — лекарство от «усреднения вкуса»: разные кластеры → разные полки.
  • Stale-while-revalidate + single-flight для «Сейчас смотрят»: главная не ждёт дорогой агрегат, пересчёт идёт максимум одним процессом через Redis NX-lock, при недоступности Redis — деградация на live-вычисление.
private async refreshTrendingCache(computeIfNoLock: boolean): Promise<number[]> {
  const gotLock = await this.redis.safeExecute(
    () => this.redis.set(this.TRENDING_LOCK_KEY, '1', 'EX', this.TRENDING_LOCK_TTL_S, 'NX'), null);
  if (!gotLock && !computeIfNoLock) return [];   // другой процесс уже считает
  try { /* ...пересчёт и запись в кэш... */ }
  finally { if (gotLock) await this.redis.safeExecute(() => this.redis.del(this.TRENDING_LOCK_KEY), null); }
}
  • Сквозной dedup между полками (фильм не якорит две полки и не повторяется), trimming до 12 карточек, отбрасывание полок короче MIN_RAIL, и резолв id→карточки одним батч-запросом в конце.
  • Качественный «пол» для всех выборок (есть жанры, IMDb ≥ 6, год ≥ 2005 + блэклист) с честным комментарием, почему нельзя использовать абсолютный порог косинуса (пространство эмбеддингов слишком сжато).
Ключевой раздел

08 Фасетный каталог (catalog/ модуль)

catalog.service.ts строит фасетный каталог поверх Elasticsearch-агрегаций:

  • Pipeline: normalize (валидация + резолв слагов/персон в id) → searchList (ES) → loadCards (PG, с сохранением порядка) → ответ.
  • Кэш фасетов отдельно от списка: ключ фасетов намеренно не зависит от paging/sort (фасет-счётчики от них не меняются) — экономия пересчётов.
  • Хэш-ключи кэша на sha1 от фильтра, версионирование префикса (catalog:v3).
  • Лейблы (жанры/страны) резолвятся из PG/Redis при рендере; в ES хранятся только id — чистое разделение «факты в ES / человекочитаемое в PG».
  • Дефолтная сортировка — по байесовскому взвешенному рейтингу с бонусом за свежесть.
Ключевой раздел

09 Балансировщик внешних провайдеров

balancer/core/balancer.facade.ts — отказоустойчивый слой доступа к внешним API (Kinopoisk Unofficial / Dev). Реализует сразу несколько паттернов надёжности:

  • До 3 попыток с разными провайдерами/токенами.
  • Sticky-провайдер: запоминается последний успешный провайдер на тип операции.
  • Ротация токенов с учётом rate-limit (Redis, burst 100 req/min на токен).
  • Circuit breaker: критические ошибки (ECONNREFUSED, timeout, 502/503/504, 402/quota) блокируют провайдера через ProviderPolicy.
  • Реестр провайдеров + duck-typing проверка реализации IOperations.
for (let attempt = 0; attempt < 3; attempt++) {
  const providerCode = await this.router.selectProvider({ operation, preferredProvider, excludedProviders });
  const token = await this.tokenManager.getAvailableToken(providerCode);
  const withinLimit = await this.rateLimitPolicy.checkRateLimit(providerCode, token, 100);
  if (!withinLimit) { await this.tokenManager.markTokenFailed(...); continue; }
  const result = await this.executeOperation(provider, operation, params);
  if (result.success) { this.stickyProviders.set(operation, providerCode); return ...; }
  if (this.isCriticalError(result.error)) this.providerPolicy.blockProvider(providerCode);
}

Сопутствующие сервисы (content-loader / content-merge / content-saver / movie-cache) загружают, объединяют и сохраняют контент от провайдеров — на уровне, который обычно делает отдельная команда интеграций.

10 Аналитика и фоновые задачи

  • Watch-сессии: AnalyticsService лимитирует время 900с, в агрегат шлёт только дельту (timeDelta), помечает хиты (≥30с). StatisticsService отдаёт realtime и детальную историю для админки.
  • Cron: инкрементальная ES-синхронизация каждую минуту.
  • Bull-очереди для миграционных/тяжёлых задач (runnable_tasks, error_handler).
  • Backfill-скрипты с настройкой concurrency/rps (backfill:studios, backfill:staff) — продуманный bulk-ingest с rate-control.
  • Telegram-рассылки: кампании, авто-отписка при 403/400 от Telegram API.
Ключевой раздел

11 Откуда берутся данные и как обеспечивается консистентность

Это ключевой вопрос для системы с каталогом ~290k единиц, и он решён продуманно.

Источники данных

ИсточникЧто даётКак попадает в систему
Kinopoisk Unofficial / Dev APIфильмы, сериалы, персоны, факты, связичерез балансировщик (§9) → нормализация → PostgreSQL
TMDBстудии и сети (компании-производители)TmdbService + backfill:studios (с HTTP-прокси, т.к. DNS отравлён в РФ)
MovieLens 25Mпрайор реальных оценок для MLофлайн-стриминг → movielens-users.jsonl (см. ml.md)
Timeweb LLM / Infinityоптимизация запросов / эмбеддингисинхронно при поиске

PostgreSQL — единственный источник истины. Elasticsearch, pgvector-колонки и Redis — это производные представления, которые пересобираются из PG.

Модель консистентности

  1. Идемпотентный ingest по натуральному ключу. Всё сохранение — upsert по kinopoiskId / slug / tmdbId, поэтому повторный прогон провайдера не плодит дубли (content-saver.service.ts).
  2. Асинхронный ingest тяжёлых связей через Bull-очереди. Стафф, факты, связи и спин-оффы грузятся не в синхронном пути запроса, а воркерами-консьюмерами (services/cache/*.consumer.ts), каждый из которых оборачивает запись в $transaction — атомарность связного графа.
  3. Reconcile с защитой от усечённого ответа провайдера. Самое сильное решение — MovieStaffCacheConsumer: он не просто перезаписывает каст, а сверяет свежий payload с существующим и отказывается удалять данные, если новый ответ покрывает < 50% уже сохранённых строк (подозрение на частичный/битый ответ API):
// Partial-payload guard: не вычищаем каст на «тонком» ответе провайдера.
if (existing.length >= RECONCILE_GUARD_MIN_EXISTING &&
    freshRows.length < existing.length * RECONCILE_MIN_COVERAGE) {
  this.logger.warn(`Skipping reconcile: suspected partial payload`);
  return 0;
}
// + пустой payload вообще не может удалить существующий каст

Это защита данных от деградации внешнего источника — то, о чём забывают даже в проде.

  1. Кросс-хранилищная синхронизация через updatedAt. Когда консьюмер меняет связи, он «трогает» watchContent.updatedAt; минутный cron (@Cron(EVERY_MINUTE)) подхватывает изменённые строки и переиндексирует их в Elasticsearch. Один сигнал (updatedAt) синхронизирует PG → ES без отдельной шины.
if (changed) {
  await this.prisma.watchContent.update({
    where: { id: movie.id }, data: { updatedAt: new Date() }, // → minute-cron reindex
  });
}
  1. Векторы пересобираются офлайн. semantic_vector и item_tower_vector — производные; обновляются скриптами generate-embeddings / cache-item-tower (идемпотентно, только для новых/пустых строк).
  2. Согласованные тоталы пагинации через $transaction([findMany, count]) — список и счётчик читаются в одной транзакции (playlist, user-playlist, admin, sitemap).
  3. Слияние нескольких провайдеров (content-merge.service.ts): приоритет non-null значений + дедуп массивов по ключу — консолидация данных из разных источников в одну запись.
  4. Восстановление после гонок на уникальных ключах: ловля P2002 с повторным findUnique (две параллельные вставки страны/жанра не валят ingest).

12 Авторизация и безопасность доступа

Полноценная JWT-авторизация (вопреки первому впечатлению — она есть и работает на каждом защищённом методе).

  • JWT-стратегия с проверкой ревокации (jwt.strategy.ts): мало проверить подпись — стратегия дополнительно проверяет, что токен не отозван (таблица token, isRevoked), и что пользователь существует:
async validate(payload: JwtPayload) {
  const user = await this.authService.getUserById(payload.id);
  if (!user) throw new UnauthorizedException('User not found');
  const tokenRecord = await this.prisma.token.findFirst({
    where: { userId: user.id, clientId: payload.clientId, isRevoked: false },
  });
  if (!tokenRecord) throw new UnauthorizedException('Token revoked');
  return { userId: user.id, clientId: payload.clientId, role: user.role, user };
}
  • gRPC-интерсептор (GrpcAuthInterceptor) достаёт токен из metadata (с учётом регистра ключей в HTTP/2), валидирует, прикрепляет user к контексту и возвращает UNAUTHENTICATED при протухшем токене — это ровно тот код, по которому фронтовый retry-интерсептор запускает refresh (см. frontend.md §6). Обе стороны согласованы по контракту ошибки.
  • @Public() и @CurrentUser() декораторы + явная проверка userId на защищённых методах (77 использований в 11 контроллерах):
@GrpcMethod('RecommendationService', 'GetRecommendations')
@UseInterceptors(GrpcAuthInterceptor)
async getRecommendations(@Payload() data, @CurrentUser() user: { userId: number }) {
  if (!user?.userId) throw new RpcException({ code: grpc.status.UNAUTHENTICATED, ... });
  // ...
}
  • Optional-auth паттерн: интерсептор пропускает анонима для методов, которые персонализируются при наличии userId (полки, фид) и работают как публичные без него.
  • Роли (ADMIN/MODERATOR/..., 11 значений) + nestjs-rbac; last_visited обновляется на каждом авторизованном вызове.
Замечание по безопасности: авторизация навешивается per-method (явный @UseInterceptors + проверка userId), а не глобальным guard'ом с декларативными role-декораторами. Работает и прозрачно читается, но легко забыть проверку на новом методе — глобальный APP_GUARD + @Roles() был бы надёжнее. Плюс секреты в .env (см. ниже).

13 N+1 и узкие места производительности

Проверено отдельно — в горячих read-путях N+1 нет:

  • Чтения идут одним запросом через Prisma include (JOIN), либо батчем findMany({ where: { id: { in: [...] } } }), либо raw-SQL ... IN (...) / = ANY($1::int[]). Карточки фильмов в полках/каталоге/поиске резолвятся одним батч-запросом и переставляются в нужный порядок in-memory (fetchCardsInOrder, loadCards, dedupeAndTrim) — классическое лекарство от N+1.
  • Семантические векторы истории пользователя тянутся одним IN (...)-запросом, а не по фильму.

Где циклы есть — это write/background-пути, и там это допустимо:

  • saveMovieGenres / buildFreshRows делают per-item upsert в цикле. Это ingest через очередь (не запрос пользователя), объём ограничен размером каста/жанров, и запись атомарна транзакцией. Здесь батч-createMany был бы оптимизацией, но не корректностью.

Прочие наблюдения:

  • RecommendationService.initializeMaps() грузит все страны со связями для подсчёта — тяжело, но выполняется один раз на старте (onModuleInit), не на запрос.
  • ContentSaverService.saveMovie (синхронный путь) — последовательность await без $transaction: при сбое на середине возможна частичная запись (фильм есть, жанры — нет). Тяжёлый ingest вынесен в транзакционные консьюмеры, но сам saveMovie стоит обернуть в транзакцию. (Зафиксировано как замечание.)

14 Оценка качества бэкенда

Сильные стороны

  • Образцовая модульность NestJS, тонкие контроллеры, сервисы с одной зоной ответственности.
  • Глубокая, проиндексированная под запросы доменная схема в двух PG-схемах.
  • Производительность как явная практика: разбор EXPLAIN в комментариях, двухстадийный HNSW, SET LOCAL ef_search, предрасчёт WR в ES, батч-загрузка карточек.
  • Зрелые distributed-паттерны: circuit breaker, sticky-провайдер, ротация токенов, rate-limit, single-flight, stale-while-revalidate.
  • Единая доменная политика видимости в трёх представлениях (DRY поверх разных движков).
  • Осознанное разделение хранилищ: PG (истина) / ES (текст+фасеты) / pgvector (смысл) / Redis (кэш+локи).
  • Зрелая консистентность ingest: идемпотентные upsert'ы, транзакционные Bull-консьюмеры, reconcile с защитой от усечённого ответа провайдера, кросс-store sync через updatedAt.
  • Чистые read-пути без N+1 (батч-загрузка карточек, IN/ANY, include).
  • Полноценная JWT-авторизация с проверкой ревокации токена и согласованным с фронтом контрактом ошибки UNAUTHENTICATED.

Замечания

  • Несколько сервисов перевалили за 700 строк — кандидаты на декомпозицию.
  • Секреты в .env в репозитории (пароли БД, токены Telegram/TMDB, ключи LLM) — для прод-системы это уязвимость; нужен secret-manager. (Зафиксировано как замечание; правки не вношу.)
  • Юнит/интеграционных тестов мало для системы такого размера.
  • Местами $queryRawUnsafe с интерполяцией — здесь безопасно (значения внутренние, есть явные комментарии про injection-safety), но требует дисциплины при развитии.

Уровень backend-исполнителя — Senior. Аргументация: системное проектирование data-платформы, профилирование и оптимизация на уровне планировщика БД, набор production-grade distributed-паттернов, end-to-end владение поиском и рекомендациями.