Инженерный профиль · демонстрация разработки

Фёдор Гаранин

Full-Stack Engineer (Frontend-focus) + ML · автор PrimeTime

Оценка инженерного уровня: Senior Full-Stack · уверенно, по доказательствам ниже
Кратко для HR

Один человек спроектировал и довёл до продакшена целый онлайн-кинотеатр: и сайт, и сервер, и нейросеть рекомендаций, и инфраструктуру. Сам себя называет фронтендером, но по факту закрывает full-stack + ML. 206+ коммитов, длительная последовательная работа, продукт живёт в проде на primetime.su. Самостоятельный, доводит сложное до результата, документирует решения. Уровень — Senior.

Кратко для Techlead

Системное мышление, а не «фичеделие»: единый proto-контракт FE↔BE, бинарный gRPC-Web через Envoy, разделение хранилищ PG/ES/pgvector/Redis. Оптимизирует на уровне планировщика БД (разбор EXPLAIN, двухстадийный HNSW, SET LOCAL ef_search). ML из первоисточников (in-batch sampled-softmax + logQ). Distributed-паттерны: circuit breaker, single-flight, stale-while-revalidate. Чистые read-пути без N+1, транзакционный ingest с reconcile-guard.

Матрица компетенций

Что доказано кодом

Каждая оценка подкреплена конкретным примером в разделе «Доказательства» ниже.

КомпетенцияУровеньДоказательство
Frontend / SSR (Nuxt, Vue)SeniorgRPC-интерсептор с single-flight refresh, server-null сторы — № 6
SEO-инженерияSeniorШаблонный движок JSON-LD, server-side sitemap из gRPC — № 7
Backend / API-дизайнSeniorЕдиный proto-контракт, 18 контроллеров, модульность NestJS — backend
Базы данных / производительностьSeniorДвухстадийный HNSW с разбором EXPLAIN, индексы под запросы — № 1
ML / рекомендательные системыSeniorКастомный train-loop, sampled-softmax + logQ, train/serve consistency — № 4
Data EngineeringSeniorСтриминг 678 МБ CSV + самописный бинарный формат — № 3, № 8
Системный дизайн / абстракцииSeniorБинарный кодек, мета-Nuxt-модуль, одна политика в 3 формах — № 8 + «ещё примеры»
Distributed systems / надёжностьSeniorStale-while-revalidate + single-flight, circuit breaker — № 2
Консистентность данныхSeniorТранзакционный ingest, reconcile partial-payload guard — № 5
Продуктовое мышлениеSeniorОбъяснимые полки, семантический гейт, чистка данных под ML — раздел «Продуктовое мышление»
Безопасность / авторизацияSenior−JWT с ревокацией; минус — секреты в .env, per-method guard — backend §12
ТестированиеMiddleТочечные тесты (ML-кодек, staff-консьюмер), но не сквозное покрытие
Доказательства · show, don't tell

7 фрагментов, демонстрирующих инженерный уровень

№1 · DB perf

Оптимизация на уровне планировщика PostgreSQL

Не «написал SQL», а профилировал, нашёл seq-scan на 291k строк и переписал под двухстадийный HNSW-поиск. Комментарий объясняет почему.

core-api-grpc/src/modules/grpc/services/recommendation.service.ts
// 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 в транзакции → не протекает в пул соединений
await this.prisma.$transaction(async (tx) => {
  await tx.$executeRawUnsafe(`SET LOCAL hnsw.ef_search = ${candidatePool}`);
  return tx.$queryRawUnsafe(sqlQuery, ...queryArgs);
});
Доказывает: понимание индексов pgvector/HNSW, профилирование запросов, изоляцию сессионных настроек в транзакции. Это уровень инженера, который читает план запроса, а не подбирает наугад.
pgvectorHNSWEXPLAINtransactions
№2 · Distributed

Stale-while-revalidate + single-flight на Redis

Главная не блокируется на дорогом агрегате «Сейчас смотрят»: отдаёт stale немедленно, пересчитывает максимум один процесс (NX-lock), деградирует при недоступности Redis.

core-api-grpc/src/modules/grpc/services/shelves.service.ts
private async refreshTrendingCache(computeIfNoLock: boolean): Promise<number[]> {
  const gotLock = await this.redis.safeExecute(
    () => this.redis.set(LOCK_KEY, '1', 'EX', LOCK_TTL, 'NX'), null);
  // нет лока и это фоновый путь — другой процесс уже считает, выходим
  if (!gotLock && !computeIfNoLock) return [];
  try { /* пересчёт + запись в кэш */ }
  finally { if (gotLock) await this.redis.safeExecute(() => this.redis.del(LOCK_KEY), null); }
}
Доказывает: владение паттернами кэширования под нагрузкой (cache stampede prevention), graceful degradation, и аккуратное освобождение lock в finally. Тонкость, которую обычно набивают на инцидентах.
Redissingle-flightstale-while-revalidateresilience
№3 · Data Eng

Стриминг 678 МБ с константной памятью

Эксплуатирует то, что ratings.csv отсортирован по userId → группирует пользователей «на лету», держа в памяти максимум одного.

ai-recommendation/src/build-movielens-users.ts
for await (const line of rl) {            // readline по 678 МБ, без загрузки в RAM
  const userId = parseInt(line.slice(0, c1), 10);
  if (userId !== currentUserId) {        // сменился юзер → финализируем предыдущего
    if (currentUserId !== -1) await flushUser();
    currentUserId = userId;
    currentRatings = new Map();           // O(1) памяти: один юзер за раз
  }
  const kp = movieIdToKp.get(movieId);   // мэтч датасетов по imdb_id
  if (kp === undefined) continue;         // несматченное выбрасываем
}
Доказывает: data-engineering мышление — обработка гигабайтов без OOM, backpressure на записи, осознанный выбор ручного парсинга ради скорости. Не «прочитал файл в массив».
streamingconstant-memoryMovieLens 25Mbackpressure
№4 · ML

Кастомный train-loop: in-batch sampled-softmax + logQ

Не model.fit(), а ручной цикл обучения уровня индустриальных рекомендеров (YouTube/Netflix). Обучение в той же косинусной геометрии, что и инференс в pgvector.

ai-recommendation/src/train.ts
// L2-норма → косинус (ровно та геометрия, что на серве в pgvector <=>)
uE = tf.div(uE, tf.norm(uE, 'euclidean', 1, true));
iE = tf.div(iE, tf.norm(iE, 'euclidean', 1, true));
let logits = tf.div(tf.matMul(uE, iE, false, true), tf.scalar(TEMPERATURE)); // [B,B]

if (USE_LOGQ_CORRECTION) logits = tf.sub(logits, qRow);   // вычитаем log P(item) — sampling-bias
if (dupIdx)                                              // sparse-маска дублей через scatterND
  logits = tf.add(logits, tf.scatterND(idx, tf.fill([numDup], -1e9), [B, B]));
Доказывает: ML-инженер, читавший первоисточники (YouTube «Sampling-Bias-Corrected»), а не туториалы. Понимает train/serve consistency, температуру, регуляризацию, и оптимизирует горячий цикл (sparse-маска вместо B×B).
Two-Towersampled-softmaxlogQTensorFlow.js
№5 · Consistency

Reconcile с защитой от усечённого ответа провайдера

При обновлении каста сверяет свежий payload с существующим и отказывается удалять данные, если внешний API вернул подозрительно мало — защита от деградации источника.

core-api-grpc/.../cache/movie-staff-cache.consumer.ts
const changed = await this.prisma.$transaction(async (tx) => {
  const created = await tx.movieStaff.createMany({ data: freshRows, skipDuplicates: true });
  const removed = await this.reconcileRemovals(tx, movie.id, freshRows, kpId);
  return created.count > 0 || removed > 0;
}, { timeout: 30000 });

// внутри reconcile — partial-payload guard:
if (existing.length >= GUARD_MIN && freshRows.length < existing.length * 0.5) {
  this.logger.warn('Skipping reconcile: suspected partial payload');
  return 0;   // не вычищаем каст на «тонком» ответе
}
Доказывает: защитное программирование на стыке с ненадёжным внешним источником, атомарность через транзакцию, и cross-store sync (после изменения «трогает» updatedAt → cron переиндексирует в ES). Это про данные, которые нельзя терять.
Bull queueidempotent upsertreconcile guardcross-store sync
№6 · Frontend / SSR

gRPC-интерсептор: single-flight refresh + одноразовый ретрай

10 параллельных 401 не вызывают 10 обновлений токена (дедуп в один промис), а WeakMap гарантирует ровно один повтор на запрос — без рекурсии рефреша.

core-web/app/plugins/4.grpcInstance.ts
const retryInterceptor: Interceptor = next => async (req) => {
  try { return await next(req); }
  catch (error) {
    const e = ConnectError.from(error);
    if (e.code === Code.Unauthenticated) {
      if (req.service === AuthService && req.method.name === 'Revoke') throw error;
      if (!refreshedRequests.has(req)) {        // WeakMap → ровно один ретрай на запрос
        refreshedRequests.set(req, true);
        const newToken = await refreshManager.runRefreshFlow(); // single-flight
        if (newToken) { req.header.set('Authorization', `Bearer ${newToken}`); return await next(req); }
      }
      throw error;
    }
    if (e.message.includes('A403')) { await deviceStore.getDeviceClientId(); return await next(req); }
    throw error;
  }
};
Доказывает: глубокое понимание auth-флоу и гонок на клиенте, согласованность с backend по коду ошибки UNAUTHENTICATED, аккуратная работа с SSR-контекстом. Это сильный фронтенд, а не «навесил axios-интерсептор».
gRPC-Websingle-flightWeakMapSSR
№7 · SEO / Architecture

Шаблонный SEO-движок с динамическим JSON-LD + liquid-вёрстка

SEO — не метатеги, а движок: schema.org (Movie/TVSeries, BreadcrumbList, динамический ItemList) на SSR. Плюс фирменная вёрстка, где 1rem = 1px макета.

core-web/app/composables/useSeoModule.ts · assets/stylesheets/base/_liquid.scss
// SEO: динамический ItemList «Рекомендуем» из массива похожих фильмов
seo.generateCompositeContextByItems('movie_items_block', relatedData.value);
state.value.useHead.script.push({ type: 'application/ld+json', innerHTML: JSON.stringify(jsonObject) });

// Вёрстка: на вьюпорте 360px → 1vw=3.6px → font-size=1px → 1rem=1px макета
html { --liquid-base: calc(1vw / 3.6); }         // mobile
@include bp-desktop { --liquid-base: calc(1vw / 19.2); } // 1920 макет
html { font-size: var(--liquid-base); }     // h1 { font-size: 36rem } = 36px по макету
Доказывает: продуктовое SEO-мышление медиа-уровня и нестандартные, но обоснованные архитектурные решения (флюидная сетка, снимающая класс проблем «макет↔код»). Изобретает абстракции, а не только потребляет фреймворк.
JSON-LDSSRfluid layoutNuxt module
№8 · Систем. дизайн

Самописный бинарный формат выборки под свой train-loop

Главный аргумент «сделал, а не скачал»: спроектировал бинарный контейнер PTTD ровно под нужды своего sampled-softmax. JSON дублировал 817-мерные векторы в каждой строке → десятки ГБ; он применил нормализацию как в БД — каждый вектор хранится один раз, взаимодействия = пары индексов.

ai-recommendation/src/training-codec.ts
// Раскладка (little-endian): Header[magic "PTTD"|version|dims|counts] + плотные секции
//   userTable float32[numUsers·userDim]   ← каждый вектор ОДИН раз (а не в каждой строке)
//   interUser/interItem uint32[N], interWeight float32[N]  ← взаимодействие = индексы

const takeF32 = (count) => {
  const bytes = count * 4, start = buf.byteOffset + off;
  const ab = buf.buffer.slice(start, start + bytes); // копия → ГАРАНТИРОВАННО выровнено
  off += bytes; return new Float32Array(ab);              // (raw view мог бы дать невыровненный offset)
};

// дедуп фильма + ЛЕНИВОЕ вычисление фич — только при первом появлении ключа
internItem(key, computeVec) {
  const e = this.itemKeyToIdx.get(key); if (e !== undefined) return e;
  const idx = this.itemVecs.push(computeVec()) - 1; this.itemKeyToIdx.set(key, idx); return idx;
}
Это копипастом не делается. Формат данных повторяет форму алгоритма (индексы под lookup в sampled-softmax); он знает про выравнивание памяти при чтении typed-array, валидирует magic/версию, и покрыл именно эту критичную часть тестом. Уровень работы с памятью, а не «прочитал JSON».
binary formatнормализация данныхmemory alignmenttested
Продуктовое мышление

Находит, что нужно пользователю — не просто «красивый код»

Самое ценное: почти каждое решение отвечает на вопрос «что на самом деле нужно?», а не «как сделать эффектно». Это редкость даже у сильных технарей.

🏷️ Заголовок полки несёт причину

«Раз вам зашёл „X“ — вот похожее». Понял, что люди доверяют рекомендациям, которые объяснены. Это продукт, а не алгоритм.

🎯 Семантика отключается на коротких запросах

Для имени/названия эмбеддинги вносят шум → useSemantic = words >= 3. Рассуждал о намерении пользователя, а не просто прикрутил kNN.

🗣️ LLM «переводит» язык пользователя

«кино где парень застрял во времени» → находит «День сурка». Увидел разрыв между тем, как люди формулируют, и тем, как работает поиск.

⏱️ Не доверяет «грязным» данным

duration помечен «не использовать», рейтинг — по IMDb, а не Кинопоиску. Заметил проблему качества данных и спроектировал вокруг неё.

📉 Чистит данные перед ML

Watch-сессии: «глухие» плееры нормализуются, порог хита 30с, лимит 900с. Думал о том, что в модель уходит реальный мир, а не идеальные данные.

🆕 Думает о тех, у кого нет истории

Cold-start: гостям — редакционные полки, модели — MovieLens-прайор. Продукт работает с первой секунды, а не после накопления данных.

Ещё примеры · разнообразие

Где он изобретает абстракции, а не потребляет фреймворк

🧬 Мета-программирование под себя

Свой Nuxt-модуль сканирует assets/icons/** и генерирует TypeScript-тип имён иконок → автодополнение и проверка на компиляции. Расширяет фреймворк, а не только пользуется.

🔀 RRF поверх двух несравнимых шкал

BM25 (Elasticsearch) и kNN (pgvector) живут в разных шкалах скоров → объединяет через Reciprocal Rank Fusion + байесовский вес популярности (формула IMDb Top-250).

🔗 Одна политика — три представления

Блэклист контента отдаётся как Prisma-фрагменты, raw-SQL и мягкий demotion-штраф — из одних констант. DRY поверх трёх разных движков запросов.

🌐 URL как источник истины фильтров

useCatalogFilters: фасеты живут в query-строке (shareable, SSR), draft/commit, preset/locked на страницу — одна вьюха питает 6+ страниц каталога.

🧩 k-means в рантайме против «усреднения вкуса»

Детерминированный k-means по векторам пользователя прямо на запрос → разные кластеры = разные полки, вместо одного усреднённого списка.

🔁 Sticky-провайдер + ротация токенов

Балансировщик запоминает последний успешный провайдер на тип операции, ротирует токены по rate-limit, блокирует упавшего по circuit breaker. 3 попытки с разными источниками.

Мягкие сигналы

Не только код

📐 Системное мышление

Единый proto-контракт FE↔BE, выбор бинарного gRPC-Web + Envoy, разделение хранилищ по ответственности. Думает архитектурой, а не файлами.

💬 Качество комментариев

Комментарии объясняют почему (разбор EXPLAIN, причины guard'ов), а не что. Признак инженера, думающего о читателе кода.

📚 Документирование решений

Каждая подсистема описана в docs/*.md с обоснованием. ТЗ на ML, эволюция модели (19-DNN → Two-Tower 768d).

🏗️ Доводит до прода

206+ коммитов, CI/CD, 10 живых контейнеров, реальные пользователи. Самостоятельность от идеи до эксплуатации.

Честно

Зоны роста

То, на что обратил бы внимание Techlead на онбординге — не блокеры, а вектор развития.

  • Секреты в .env в репозитории (пароли БД, токены Telegram/TMDB, ключи LLM) — для прода это уязвимость. Нужен secret-manager и ротация ключей.
  • Тестовое покрытие точечное (ML-кодек, staff-консьюмер). Для системы такого размера просятся юнит/интеграционные тесты, особенно на SEO-интерполяцию и ingest.
  • Авторизация per-method (явный guard + проверка userId) вместо глобального APP_GUARD + @Roles() — легко забыть проверку на новом методе.
  • Крупные сервисы 700+ строк (recommendation, elastic-search, shelves) — читабельны, но просятся на декомпозицию.
  • ContentSaverService.saveMovie — синхронная запись связей без транзакции (риск частичной записи). Тяжёлый ingest уже вынесен в транзакционные консьюмеры.

🎯 Рекомендация по найму

Уверенный Senior Full-Stack с сильным уклоном в Frontend и реальной ML-экспертизой. Подходит на роли Senior Frontend (где будет недозагружен по потолку), Senior Full-Stack (точное попадание) или Product/ML Engineer в продуктовой команде, где ценится сквозное владение — от Vue-гидрации до плана запроса PostgreSQL и train-loop нейросети.

Редкое сочетание широты и глубины: человек, который один спроектировал, написал и эксплуатирует систему, обычно требующую нескольких команд. Самостоятелен, доводит сложное до прода, документирует. Зоны роста — командные практики (тесты, секрет-менеджмент, code-review-дисциплина), которые быстро добираются в зрелой инженерной культуре.