Один человек спроектировал и довёл до продакшена целый онлайн-кинотеатр: и сайт, и сервер, и нейросеть рекомендаций, и инфраструктуру. Сам себя называет фронтендером, но по факту закрывает full-stack + ML. 206+ коммитов, длительная последовательная работа, продукт живёт в проде на primetime.su. Самостоятельный, доводит сложное до результата, документирует решения. Уровень — Senior.
Системное мышление, а не «фичеделие»:
единый 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) | Senior | gRPC-интерсептор с 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 Engineering | Senior | Стриминг 678 МБ CSV + самописный бинарный формат — № 3, № 8 |
| Системный дизайн / абстракции | Senior | Бинарный кодек, мета-Nuxt-модуль, одна политика в 3 формах — № 8 + «ещё примеры» |
| Distributed systems / надёжность | Senior | Stale-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-консьюмер), но не сквозное покрытие |
7 фрагментов, демонстрирующих инженерный уровень
Оптимизация на уровне планировщика 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); });
Stale-while-revalidate + single-flight на Redis
Главная не блокируется на дорогом агрегате «Сейчас смотрят»: отдаёт stale немедленно, пересчитывает максимум один процесс (NX-lock), деградирует при недоступности Redis.
core-api-grpc/src/modules/grpc/services/shelves.service.tsprivate 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); } }
finally. Тонкость, которую обычно набивают на инцидентах.Стриминг 678 МБ с константной памятью
Эксплуатирует то, что ratings.csv отсортирован по userId →
группирует пользователей «на лету», держа в памяти максимум одного.
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; // несматченное выбрасываем }
Кастомный train-loop: in-batch sampled-softmax + logQ
Не model.fit(), а ручной цикл обучения уровня индустриальных
рекомендеров (YouTube/Netflix). Обучение в той же косинусной геометрии, что и инференс в pgvector.
// 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]));
Reconcile с защитой от усечённого ответа провайдера
При обновлении каста сверяет свежий payload с существующим и отказывается удалять данные, если внешний API вернул подозрительно мало — защита от деградации источника.
core-api-grpc/.../cache/movie-staff-cache.consumer.tsconst 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; // не вычищаем каст на «тонком» ответе }
updatedAt → cron переиндексирует в ES). Это про данные, которые нельзя терять.gRPC-интерсептор: single-flight refresh + одноразовый ретрай
10 параллельных 401 не вызывают 10 обновлений токена (дедуп в один промис), а
WeakMap гарантирует ровно один повтор на запрос — без рекурсии рефреша.
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; } };
UNAUTHENTICATED, аккуратная работа с SSR-контекстом.
Это сильный фронтенд, а не «навесил axios-интерсептор».Шаблонный SEO-движок с динамическим JSON-LD + liquid-вёрстка
SEO — не метатеги, а движок: schema.org (Movie/TVSeries, BreadcrumbList,
динамический ItemList) на SSR. Плюс фирменная вёрстка, где 1rem = 1px макета.
// 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 по макету
Самописный бинарный формат выборки под свой train-loop
Главный аргумент «сделал, а не скачал»: спроектировал бинарный контейнер
PTTD ровно под нужды своего sampled-softmax. JSON дублировал 817-мерные векторы
в каждой строке → десятки ГБ; он применил нормализацию как в БД — каждый вектор
хранится один раз, взаимодействия = пары индексов.
// Раскладка (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; }
Находит, что нужно пользователю — не просто «красивый код»
Самое ценное: почти каждое решение отвечает на вопрос «что на самом деле нужно?», а не «как сделать эффектно». Это редкость даже у сильных технарей.
🏷️ Заголовок полки несёт причину
«Раз вам зашёл „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-дисциплина), которые быстро добираются в зрелой инженерной культуре.