Перейти к основному содержимому

Background Job: PopularityRecomputeJob

Назначение

PopularityRecomputeJob — ночной фоновый расчёт контекста catalog, который пересчитывает сглаженную популярность PopularityScore (decimal(12,4)) карточек CatalogPropertyObjectCard и CatalogProjectCard по завершённым просмотрам за окно 30 дней с экспоненциальным затуханием. PopularityScore — единственный ключ сортировки «по популярности» (индексы ix_property_object_cards_popularity / ix_project_cards_popularity).

Почему нужна собственная таблица срезов

Вход — накопительный счётчик PopularityViews (long, только растёт), который инкрементирует консюмер user-activity-popularity по событию pin.user-activity.property-object-view-ended.v1 (объект недвижимости — по PropertyObjectId; проект — агрегируя по ProjectId). Из одного накопительного счётчика нельзя получить «просмотры за 30 дней» — нужна дневная гранулярность, поэтому job ведёт собственную таблицу дневных срезов накопителя (PopularityDailySnapshot, ниже) и берёт дневные инкременты как разности соседних срезов. Механизм — PostgreSQL-only, без Redis.

ПолеЗначение
OwnerPIN Marketplace (Catalog squad)
Статус документаdraft
Область действиятаблицы catalog.property_object_cards / catalog.project_cards / catalog.popularity_daily_views
Assumptions / ограниченияпопулярность отстаёт от реального времени (лаг закрытия просмотра 10–15 мин + суточный цикл) — приемлемо для сортировки; целевой объём — сотни тысяч объектов недвижимости + тысячи проектных карточек, партиционирования нет
ЗависимостиPostgreSQL (catalog: карточки, popularity_daily_views); вход PopularityViews пишет консюмер user-activity-popularity; событий не публикует

Классификация

ПолеЗначение
Сервисcatalog
Тип jobScheduled (ночной, один прогон в сутки)
Triggercron 0 3 * * * (UTC, ночной прогон — минимальный трафик витрины); следующий тик не стартует, пока идёт предыдущий прогон
Критичностьlow (свежесть сортировки «по популярности» вне денежного пути; деградация лишь задерживает переранжирование)

Расписание и locking

АспектПравило
Scheduleраз в сутки, 0 3 * * * UTC; полный детерминированный пересчёт по данным окна (не инкрементальный)
Координация параллелизмаглобального распределённого lock нет (без Redis). Параллельные инстансы координируются построчной блокировкой батча (FOR UPDATE SKIP LOCKED) — каждый батч карточек достаётся одному инстансу. Опциональный строгий single-flight — PostgreSQL advisory-lock Pin:lock:job:scheduled:catalog-popularity-recompute (по построению не обязателен — батчи дизъюнктны)
Batch sizeBatchSize карточек/батч (default 1000), keyset по Id; отдельный проход по объектам недвижимости, затем по проектам
Max durationцель < 10 мин; предохранитель MaxRunDuration (default 30 мин) — при превышении прогон завершается штатно, остаток берёт следующий ночной цикл
Остановкапри остановке воркера текущий батч докоммичивается или откатывается целиком, блокировки освобождаются (graceful stop)

Входная модель PopularityRecomputeInput

Внешнего runtime-входа нет — параметры из конфигурации, проверяются при старте.

ПолеТипОбязательностьИсточникОписание
AsOfDateDateOnlyДасистемное время на старте прогонаДата прогона D — ключ дневного среза и правая граница окна.
WindowDaysintДаconfig (default 30)Ширина окна W.
DecayHalfLifeDaysintДаconfig (default 7)Период полураспада H веса: каждые H суток вклад просмотров падает вдвое.
BatchSizeintДаconfig (default 1000, > 0)Верхняя граница карточек на батч/транзакцию.
TenantIdНетне применяетсяПлатформенная витрина: обработка вне tenant, сквозной скан всех карточек.

Модель хранения дневных срезов PopularityDailySnapshot

Вспомогательная служебная таблица catalog.popularity_daily_views — дом здесь (единственный писатель/читатель — этот job; доменным концептом не является). Хранит снимок накопителя PopularityViews на конец каждого прогона; дневной инкремент = разность соседних снимков.

ПолеТипОбязательностьОграниченияОписание
IdGuidДаPKИдентификатор строки среза.
CardKindPopularityCardKindДахранится как text, varchar(16)PropertyObject / Project — к какой проекции относится срез.
CardIdGuidДалогическая ссылка на property_object_cards.id / project_cards.id (без FK — две таблицы)Карточка.
RunDateDateOnlyДачасть unique-ключаДата прогона D (ключ «job-дня»).
CumulativeViewslongДа>= 0Снимок PopularityViews карточки на момент прогона.
CreatedAtDateTimeOffsetДаМомент записи среза.

Индексы: unique ix_popularity_daily_unique (card_kind, card_id, run_date) (один срез на карточку в сутки, идемпотентность прогона); чтение окна — ix_popularity_daily_window (card_kind, card_id, run_date desc). Retention: срезы старше D − (WindowDays + 2) удаляются в конце прогона — ≈ 32 строки/карточку.

Enum PopularityCardKind (хранится как text)

ЗначениеОписание
PropertyObjectСрез относится к CatalogPropertyObjectCard.
ProjectСрез относится к CatalogProjectCard.

Формула сглаживания и затухания

  • Период полураспада H = DecayHalfLifeDays → база затухания λ = 2^(−1/H) (при H = 7λ ≈ 0.9057).

  • «Job-день k» — интервал между срезами прогонов k−1 и k; дневной инкремент Δ_k = max(0, S_k − S_{k−1}), где S_k = CumulativeViews среза (счётчик монотонен — PopularityViews только растёт; max(0, …) — guard от replay/переинициализации).

  • Пусть r — текущий прогон (RunDate = D), Δ_r — только что закрытый job-день (вес λ^0 = 1). Тогда

    PopularityScore = round( Σ_{i=0}^{W−1} Δ_{r−i} · λ^i , 4 )decimal(12,4).

  • Отсутствующий срез дня (карточка создана позже начала окна или пропущенный прогон) → Δ = 0; при пропуске ночи весь накопленный за простой прирост попадает в первый после простоя job-день (документированное упрощение, согласуется с «приемлемым отставанием»).

Пересчёт детерминирован по хранимым срезам → повтор прогона за ту же дату идемпотентен. Мини-пример (H = 7, λ ≈ 0.9057): три свежих job-дня Δ = [10, 0, 25]10·1 + 0·λ + 25·λ² = 30.5073.

Алгоритм

Каждый ночной прогон повторяет шаги 3–6 по батчам сначала для объектов недвижимости (property_object_cards), затем для проектов (project_cards).

  1. Контекст и доступ. Актор — системный, обработка вне tenant (сквозной скан всех карточек); AsOfDate = системная дата на старте. Идемпотентность обеспечивается детерминированным пересчётом по срезам (см. Формула, шаг 4).
  2. Проверка входа. Внешнего ввода нет; конфигурация проверена при старте (WindowDays > 0, DecayHalfLifeDays > 0, BatchSize > 0) — иначе воркер не стартует. Батч-скан сам отбирает карточки Status ∈ {Published, Hidden}; Delisted пропускаются.
  3. Что читаем (под блокировкой). Keyset-пагинация по Id: батч карточек Status != Delisted под построчной блокировкой (FOR UPDATE SKIP LOCKED) — проекция (Id, PopularityViews, PopularityScore). Затем окно срезов: срезы этих карточек с RunDate >= AsOfDate − WindowDays (одним запросом, без N+1).
  4. Расчёт. Для каждой карточки батча: записать срез дня (S_r = PopularityViews, ключ (CardKind, CardId, RunDate = AsOfDate)); по срезам окна вычислить дневные инкременты Δ_i и новый PopularityScore (Формула). Guard эффективности: если newScore == PopularityScore (типично для давно неактивных — оба 0), карточка не обновляется (не переписываем индекс популярности зря); срез всё равно пишется.
  5. Что меняется. В одном согласованном изменении на батч: срезы дня (upsert по ключу (card_kind, card_id, run_date)) и PopularityScore/UpdatedAt изменившихся карточек (точечное обновление — популярность вне инвариантов карточки). Конкурентный доступ — оптимистическая блокировка: конфликт с консюмером-инкрементом ночью крайне маловероятен, при конфликте — повтор с перечиткой батча (шаг 6); построчные блокировки держатся до коммита батча. После двух проходов — удаление устаревших срезов (retention) отдельной транзакцией.
  6. Результат. Сводка прогона (PropertyObjectCardsUpdated, ProjectCardsUpdated, SnapshotsWritten, SnapshotsPruned, ItemErrors, DurationMs) — для метрик/логов. Poison-строка исключается, логируется, остальной батч коммитится; инфраструктурный сбой — откат батча целиком, ретрай следующим ночным прогоном (пересчёт детерминирован).

Граница согласованности

ЭлементПравило
Единица измененияна батч — срезы + обновление карточек в одном согласованном изменении; retention-удаление — отдельной транзакцией в конце
Сохраняемое состояниеpopularity_daily_views (upsert срезов), property_object_cards/project_cards (PopularityScore, UpdatedAt изменившихся)
External callsнет — целиком PostgreSQL; событий/Kafka job не порождает
Частичная ошибкаcontinue — poison-карточка исключается из батча и логируется, остальной батч коммитится; инфраструктурный сбой — откат батча, ретрай следующим ночным прогоном

Идемпотентность и retry

СценарийПравило
Повторный прогон за ту же датуСрез дня upsert-ится по ключу (CardKind, CardId, RunDate); newScore — чистая функция от хранимых срезов окна → идентичный результат
Job restartCheckpoint не нужен — пересчёт полный и детерминированный; повтор с любого места безопасен
Параллельные инстансы / партициипострочная блокировка (FOR UPDATE SKIP LOCKED) разводит батчи по инстансам; одну карточку обновляет ровно один инстанс
Гонка с консюмером user-activity-popularityКонсюмер атомарно инкрементирует PopularityViews; job читает актуальное значение как S_r. Конфликт оптимистической блокировки на сохранении → повтор перечиткой батча (новое S_r лишь уточняет скор)
Пропущенная ночьСледующий прогон видит разрыв дат в срезах: инкремент за простой атрибутируется первому job-дню после разрыва; окно самокорректируется за WindowDays
Poison itemИсключается из батча, ERROR-лог с CardKind/CardId; при систематике — алерт errors_total, разбор по runbook

Наблюдаемость

ТипПравило
Logsrun started (runId, AsOfDate, W, H); batch processed (CardKind, rows, updated); run completed (PropertyObjectCardsUpdated, ProjectCardsUpdated, SnapshotsWritten, SnapshotsPruned, DurationMs); item failed (CardKind, CardId, exception)
Metricscatalog_popularity_recompute_duration_seconds (histogram); catalog_popularity_recompute_cards_total{card_kind} (counter); `catalog_popularity_recompute_snapshots_total{op=written
TracescorrelationId = jobRunId на span прогона; атрибуты card_kind, batch_size, updated_count на span батчей
DashboardGrafana catalog-overview (панель Background jobs — длительность, число карточек, ошибки пересчёта)
Alertsнет успешного прогона > 26 ч; catalog_popularity_recompute_errors_total rate > 0 устойчиво; catalog_popularity_score_staleness_hours > 30

SLO цикла

ПоказательЦельОбоснование
Свежесть популярностиPopularityScore отражает просмотры не старше ≈ (вчерашний job-день) + лаг закрытия просмотра 10–15 минсуточный цикл + polling-закрытие property-object-view-ended; отставание приемлемо для сортировки
Длительность прогонаp95 < 10 мин при целевом объёме карточекнижняя нагрузка ночью; при превышении — MaxRunDuration, добор следующей ночью
Идемпотентность0 расхождений скора при повторе прогона/рестарте/N инстансахдетерминированная формула по хранимым срезам + FOR UPDATE SKIP LOCKED
Полнотавсе Published/Hidden-карточки получают актуальный (в т.ч. затухший до 0) скор за прогон; Delisted исключены осознаннополный проход обеих таблиц

Кросс-сервисный контракт и деградация источника

НаправлениеКонтрактПоведение при отсутствии/устаревании
Ожидает (вход)Накопительный счётчик PopularityViews (монотонный, только растёт), который инкрементирует консюмер user-activity-popularity по pin.user-activity.property-object-view-ended.v1. Job читает снапшот счётчика как S_r под построчной блокировкойКонсюмер лагирует/остановлен → дневные инкременты Δ занижены (часть просмотров ещё не в счётчике); PopularityScore занижен, но валиден. После догона консюмера пропущенный прирост атрибутируется первому job-дню — окно самокорректируется за WindowDays. Job не ждёт и не падает
Предоставляет (выход)Поле PopularityScore (decimal(12,4)) карточек — единственный ключ сортировки «по популярности» лент (синхронное чтение витрины: get-projects.md, get-property-objects.md); один из ключей дефолта «Рекомендованные» (ranking-rule.md). Интеграционных событий нет — потребление только внутри витриныМежду прогонами скор «застывший» (лаг ≤ суточный цикл + MaxRunDuration); лента отдаёт последний рассчитанный порядок. Пропущенная ночь → скор не обновлён сутки, ранжирование деградирует по свежести, не по корректности

Связанный runbook

СценарийRunbook
Прогон не отработал / рост errors_total../runbooks/popularity-recompute.md (будущий путь по конвенции)
Расхождение скора (ручная сверка)принудительный пересчёт карточек по id — POST /admin/projections/rebuild (README «Runbooks»)

Тесты

ТестОжидание
Empty (нет карточек/просмотров)no-op success; срезы с нулевым накопителем не меняют скор (newScore == 0, обновление пропущено)
Экспоненциальное затуханиепри Δ = [10,0,25], H=7PopularityScore = 30.5073 (decimal(12,4))
Затухание без новых просмотровкарточка без новых просмотров N ночей подряд: скор монотонно убывает по λ и достигает 0 за пределами окна
Идемпотентность / повтор прогонаповтор за ту же дату не меняет PopularityScore; срез upsert-ится (Δ=30 из соседних S=100,130 входит с весом λ^0), не дублируется
Партиции / параллельные инстансыFOR UPDATE SKIP LOCKED — каждая карточка пересчитана один раз
Гонка с консюмероминкремент PopularityViews во время прогона → либо учтён в S_r, либо конфликт блокировки → повтор без порчи данных
Retention / Delistedсрезы старше WindowDays + 2 удалены (окно прогона не затронуто); Delisted-карточка не пересчитывается
Остановка воркераостановка в прогоне → текущий батч докоммичен/откачен целиком, блокировки сняты, graceful stop

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы