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.
популярность отстаёт от реального времени (лаг закрытия просмотра 10–15 мин + суточный цикл) — приемлемо для сортировки; целевой объём — сотни тысяч объектов недвижимости + тысячи проектных карточек, партиционирования нет
Зависимости
PostgreSQL (catalog: карточки, popularity_daily_views); вход PopularityViews пишет консюмер user-activity-popularity; событий не публикует
Внешнего runtime-входа нет — параметры из конфигурации, проверяются при старте.
Поле
Тип
Обязательность
Источник
Описание
AsOfDate
DateOnly
Да
системное время на старте прогона
Дата прогона D — ключ дневного среза и правая граница окна.
WindowDays
int
Да
config (default 30)
Ширина окна W.
DecayHalfLifeDays
int
Да
config (default 7)
Период полураспада H веса: каждые H суток вклад просмотров падает вдвое.
BatchSize
int
Да
config (default 1000, > 0)
Верхняя граница карточек на батч/транзакцию.
TenantId
—
Нет
не применяется
Платформенная витрина: обработка вне tenant, сквозной скан всех карточек.
Модель хранения дневных срезов PopularityDailySnapshot
Вспомогательная служебная таблица catalog.popularity_daily_views — дом здесь (единственный
писатель/читатель — этот job; доменным концептом не является). Хранит снимок накопителя PopularityViews
на конец каждого прогона; дневной инкремент = разность соседних снимков.
Поле
Тип
Обязательность
Ограничения
Описание
Id
Guid
Да
PK
Идентификатор строки среза.
CardKind
PopularityCardKind
Да
хранится как text, varchar(16)
PropertyObject / Project — к какой проекции относится срез.
CardId
Guid
Да
логическая ссылка на property_object_cards.id / project_cards.id (без FK — две таблицы)
Карточка.
RunDate
DateOnly
Да
часть unique-ключа
Дата прогона D (ключ «job-дня»).
CumulativeViews
long
Да
>= 0
Снимок PopularityViews карточки на момент прогона.
CreatedAt
DateTimeOffset
Да
—
Момент записи среза.
Индексы: 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 строки/карточку.
Отсутствующий срез дня (карточка создана позже начала окна или пропущенный прогон) → Δ = 0; при
пропуске ночи весь накопленный за простой прирост попадает в первый после простоя job-день
(документированное упрощение, согласуется с «приемлемым отставанием»).
Пересчёт детерминирован по хранимым срезам → повтор прогона за ту же дату идемпотентен. Мини-пример
(H = 7, λ ≈ 0.9057): три свежих job-дня Δ = [10, 0, 25] → 10·1 + 0·λ + 25·λ² = 30.5073.
Каждый ночной прогон повторяет шаги 3–6 по батчам сначала для объектов недвижимости
(property_object_cards), затем для проектов (project_cards).
Контекст и доступ. Актор — системный, обработка вне tenant (сквозной скан всех карточек);
AsOfDate = системная дата на старте. Идемпотентность обеспечивается детерминированным пересчётом
по срезам (см. Формула, шаг 4).
Проверка входа. Внешнего ввода нет; конфигурация проверена при старте (WindowDays > 0,
DecayHalfLifeDays > 0, BatchSize > 0) — иначе воркер не стартует. Батч-скан сам отбирает
карточки Status ∈ {Published, Hidden}; Delisted пропускаются.
Что читаем (под блокировкой). Keyset-пагинация по Id: батч карточек Status != Delisted под
построчной блокировкой (FOR UPDATE SKIP LOCKED) — проекция (Id, PopularityViews, PopularityScore).
Затем окно срезов: срезы этих карточек с RunDate >= AsOfDate − WindowDays (одним запросом, без N+1).
Расчёт. Для каждой карточки батча: записать срез дня (S_r = PopularityViews, ключ (CardKind, CardId, RunDate = AsOfDate)); по срезам окна вычислить дневные инкременты Δ_i и новый
PopularityScore (Формула). Guard эффективности: если newScore == PopularityScore (типично для
давно неактивных — оба 0), карточка не обновляется (не переписываем индекс популярности зря); срез
всё равно пишется.
Что меняется. В одном согласованном изменении на батч: срезы дня (upsert по ключу (card_kind, card_id, run_date)) и PopularityScore/UpdatedAt изменившихся карточек (точечное обновление —
популярность вне инвариантов карточки). Конкурентный доступ — оптимистическая блокировка: конфликт с
консюмером-инкрементом ночью крайне маловероятен, при конфликте — повтор с перечиткой батча (шаг 6);
построчные блокировки держатся до коммита батча. После двух проходов — удаление устаревших срезов
(retention) отдельной транзакцией.
Срез дня upsert-ится по ключу (CardKind, CardId, RunDate); newScore — чистая функция от хранимых срезов окна → идентичный результат
Job restart
Checkpoint не нужен — пересчёт полный и детерминированный; повтор с любого места безопасен
Параллельные инстансы / партиции
построчная блокировка (FOR UPDATE SKIP LOCKED) разводит батчи по инстансам; одну карточку обновляет ровно один инстанс
Гонка с консюмером user-activity-popularity
Консюмер атомарно инкрементирует PopularityViews; job читает актуальное значение как S_r. Конфликт оптимистической блокировки на сохранении → повтор перечиткой батча (новое S_r лишь уточняет скор)
Пропущенная ночь
Следующий прогон видит разрыв дат в срезах: инкремент за простой атрибутируется первому job-дню после разрыва; окно самокорректируется за WindowDays
Poison item
Исключается из батча, ERROR-лог с CardKind/CardId; при систематике — алерт errors_total, разбор по runbook
Накопительный счётчик 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); лента отдаёт последний рассчитанный порядок. Пропущенная ночь → скор не обновлён сутки, ранжирование деградирует по свежести, не по корректности