Consumer: User-Activity Popularity (pin-catalog-popularity)
Kafka-consumer завершённых просмотров объектов недвижимости из контекста user-activity: инкрементит
счётчик популярности витрины PopularityViews карточки объекта недвижимости и агрегатно — карточки её
проекта. Дом полей целевых счётчиков — catalog-property-object-card.md
(PopularityViews, INV-7 DelistedFrozen) и catalog-project-card.md
(PopularityViews, агрегация по ProjectId); дом контракта события — производящий контекст
property-object-view-ended.md; эталон
алгоритма инкремента — property-object-view-stats.md
(тот же топик, та же семантика счётчика).
Metadata
| Поле | Значение |
|---|---|
| Статус | draft |
| Owning team | PIN Marketplace (контекст catalog) |
| Область действия | Счётчик PopularityViews объекта недвижимости/проекта для сортировки «по популярности» |
| Feature flag | нет — счётчик всегда включён; потеря отдельного просмотра не критична (fail-open, README «Известные ограничения») |
Назначение
Consumer обрабатывает событие PropertyObjectViewEnded (pin.user-activity.property-object-view-ended.v1)
и инкрементит на 1 счётчик завершённых просмотров: CatalogPropertyObjectCard.PopularityViews по
PropertyObjectId события и агрегатно CatalogProjectCard.PopularityViews по проекту этого объекта
недвижимости (card.ProjectId — событие ProjectId не несёт, см.
DSC-CAT-POP-1). Инкремент — атомарное «слепое» увеличение колонки
(popularity_views = popularity_views + 1), а не read-modify-write в памяти: гонок значений нет,
конфликта оптимистической блокировки с параллельными консюмерами chessboard/invest-analytics/rewards
(пишут другие колонки той же строки) не возникает (эталон
property-object-view-stats.md, INV-LVS-002).
Пересчёт PopularityScore (сглаженная популярность за окно 30 дней с экспоненциальным затуханием) — это
ночной job popularity-recompute по временным рядам
просмотров; здесь только сырой монотонный счётчик-tiebreaker. Собственных интеграционных и доменных
событий консюмер не публикует: счётчик не влияет ни на видимость (INV-1), ни на ценовые агрегаты
проекта (MinPrice/MaxPrice/PropertyObjectsAvailable/Payback), поэтому пересчёт агрегатов проекта
не поднимается (витрина — конечная точка данных). dev-stats и crm слушают тот же топик
независимо.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | catalog |
| Обрабатываемое событие | PropertyObjectViewEnded |
| Topic | pin.user-activity.property-object-view-ended.v1, 6 партиций, key = PropertyObjectViewId (= AggregateId, Guid v7) |
| ContractVersion | = 1; при несовпадении версии события — warn-and-proceed (метрика catalog_popularity_contract_mismatch_total) |
| Consumer group | pin-catalog-popularity |
| Producer | user-activity — worker activity-closer: закрытие просмотра polling-ом по неактивности 10–15 мин (ADR-0054/0056 п.3, INV-LV-004) |
Событие → эффект
| Событие | Эффект в витрине каталога |
|---|---|
PropertyObjectViewEnded | +1 к PopularityViews карточки объекта недвижимости (по PropertyObjectId) и агрегатно +1 к PopularityViews карточки её проекта; Delisted-карточка пропускается (INV-7), дубль по PropertyObjectViewId не задваивает счётчик |
Контекст события
- Из метаданных доступны: идентификатор сообщения,
PropertyObjectViewId(AggregateId, Guid v7), тип агрегата, время события. Корреляции/actor в метаданных нет. - Актор — системный (не пользовательский контекст). Карточки не tenanted (платформенная витрина);
DeveloperId/ProjectId— обычные колонки, не tenant → cross-tenant-запись невозможна по построению. - Идемпотентность — по бизнес-ключу
PropertyObjectViewId(ключcatalog-user-activity:property-object-view-ended:{PropertyObjectViewId}); для счётчика дедуп — по семантическому бизнес-ключу просмотра, не по идентификатору сообщения (см. «Идемпотентность и replay» и DSC-CAT-POP-2).
Входные данные / payload события
Контракт события: event-contract: pin.user-activity.property-object-view-ended
(дом payload — property-object-view.md). Ниже — только
поля, которые consumer реально читает; PII нет (одни идентификаторы + длительность). Событие не
содержит ProjectId — проект берётся из загруженной карточки объекта недвижимости
(DSC-CAT-POP-1).
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
PropertyObjectViewId | Guid | Да | Бизнес-ключ идемпотентности (catalog-user-activity:property-object-view-ended:{PropertyObjectViewId}); = AggregateId/Kafka key |
PropertyObjectId | Guid | Да | Адресация карточки объекта недвижимости; цель инкремента CatalogPropertyObjectCard.PopularityViews |
SessionId | Guid | Да | Только диагностика (лог), в мутации не участвует |
AnonymousId | Guid | Да | Диагностика; в логах не разворачивается (квази-идентификатор устройства) |
UserId | Guid? | Нет | Диагностика; в логах не разворачивается |
ViewSource | string (enum ViewSource) | Да | Разрез метрики (Catalog/Search/Selection/…); unknown → Unknown + warn |
ViewedAt | DateTimeOffset | Да | Диагностика (момент открытия карточки) |
LastActivityAt | DateTimeOffset | Да | Лаг-метрика (now − LastActivityAt); диагностика |
DurationSeconds | int | Да | Только лог/метрика (длительность просмотра); на инкремент не влияет; < 0 → warn + принимается |
FilterSnapshot | AppliedFilterModel? | Нет | Не используется каталогом (счётчик) — игнорируется |
Алгоритм
- Контекст события. Восстановить метаданные (идентификатор сообщения,
PropertyObjectViewId); при несовпаденииContractVersion— warn-and-proceed; идемпотентность (критична — счётчик): по ключуcatalog-user-activity:property-object-view-ended:{PropertyObjectViewId}— ключ уже есть → логskipped-duplicate, метрикаcatalog_popularity_duplicates_total, ack, инкремента нет (повтор доставки не задваивает счётчик). - Проверка входа.
PropertyObjectViewId != Guid.Empty;PropertyObjectId != Guid.Empty;DurationSeconds >= 0— при< 0warn (контракт-аномалия) + принимается (длительность лишь логируется, инкремент — на 1). Существование объекта недвижимости проверяется на загрузке (шаг 3) — отсутствие карточки штатная out-of-order-ситуация с retriable-исходом, а не poison. - Что читаем. Карточка объекта недвижимости по
PropertyObjectId; нет карточки →CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND— retriable (out-of-order:PropertyObjectUpsertedизchessboardещё не применён; топики разные, порядок между ними не гарантирован).card.ProjectId— цель агрегатного инкремента проекта (карточку проекта не читаем: инкремент — атомарный по id; её отсутствие обрабатывается fail-open на шаге 5). - Бизнес-правила и переходы.
- Guard INV-7 DelistedFrozen:
Status == Delisted→ объект недвижимости удалён в источнике, инкремента нет: логskipped-delisted, метрикаcatalog_popularity_delisted_skipped_total, к согласованной фиксации (только отметка идемпотентности), ack. СтатусыPublished/Hiddenсчитаются нормально (просмотр состоялся; скрытая карточка копит счётчик — при возврате вPublishedон актуален). - Маппинг: дельта =
+1.CatalogPropertyObjectCard.PopularityViews += 1(поPropertyObjectId);CatalogProjectCard.PopularityViews += 1(поcard.ProjectId). Другие поля (PopularityScore,Status, эффективные значения,UpdatedAt) не трогаются — счётчик ортогонален проекции;PopularityScoreпересчитает ночной job.
- Guard INV-7 DelistedFrozen:
- Что меняется. Атомарный инкремент без read-modify-write (эталон
property-object-view-stats.md):
+1кPopularityViewsкарточки объекта недвижимости, затем+1кPopularityViewsкарточки проекта. Порядок не важен (независимы). Инкремент проекта затронул0строк (карточка проекта ещё не создана, out-of-order INV-2) → fail-open: warn + метрикаcatalog_popularity_project_missing_total, событие не падает (потеря единичного просмотра проекта приемлема — сортировка приблизительна). - Согласованность. Инкремент объекта недвижимости + инкремент проекта + отметка идемпотентности
(
PropertyObjectViewId) — в одном согласованном изменении; offset подтверждается только после успешной фиксации. Доменных/интеграционных событий консюмер не порождает (счётчик не влияет на видимость/агрегаты цен, обоснование — «Назначение»). - Результат. Успех → ack.
CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND(retriable) → retry по политике → DLQ после исчерпания. Инфраструктурный сбой → бесконечный backoff (событие не теряется). Freshness: популярность отстаёт на лаг закрытия просмотра (10–15 мин) + лаг топика — приемлемо (README, «Известные ограничения»).
Диаграмма
Идемпотентность и replay
- Дедупликация (критична для счётчика): по бизнес-ключу
PropertyObjectViewId(catalog-user-activity:property-object-view-ended:{PropertyObjectViewId}), отметка ключа — в одном согласованном изменении с инкрементом (шаг 6). Событие по одномуPropertyObjectViewIdпубликуется ровно один раз (Inactive-просмотр иммутабелен, INV-LV-008), а идентификатор сообщения стабилен → ключ закрывает и redelivery, и replay топика; выборPropertyObjectViewIdдополнительно защищает от гипотетического re-publish с новым идентификатором сообщения (DSC-CAT-POP-2; эталон property-object-view-stats.md INV-LVS-003). - Бизнес-guard: отдельного version-guard нет — дельта всегда
+1, монотонность обеспечивает отметка идемпотентности. - Параллельная повторная доставка. Если два инстанса группы одновременно получат одно и то же
PropertyObjectViewId(перебалансировка партиций либо дубль в топике), отметка идемпотентности в согласованном изменении сериализует их: чей инкремент фиксируется первым — тот и единственный; второй упирается в уже занятый бизнес-ключ и завершается какskipped-duplicate(инкремента нет). Счётчик остаётся точным —+1ровно один раз на просмотр. - Replay: осторожно. В отличие от snapshot-консюмеров (
chessboard/rewards— replay идемпотентен), повторный проход по топику для счётчика безопасен только при сохранённых ключах идемпотентности. Для «холодного» пересбора счётчика —POST /admin/projections/rebuild(сбрасываетPopularityViews = 0и переигрывает топик с известного offset при интактном idempotency-store) либо принять расхождение как приблизительное (сортировка популярности не требует точности;PopularityScoreвсё равно пересчитывается ночным job-ом из временных рядов).
Retry и DLQ
DLQ-топик: pin.catalog.popularity-dlq.v1 (исходное событие + заголовки x-dlq-reason,
x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — | Бесконечный exponential backoff 1s→60s с паузой партиции (событие не теряется) | Нет | Runbook catalog-overview: восстановить зависимость |
Карточка объекта недвижимости не найдена (out-of-order против chessboard upsert) | CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND | 6 попыток, backoff 30s → 1m → 2m → 4m → 8m → 16m | Да, после исчерпания | Проверить лаг pin.chessboard.property-object-upserted.v1/пришёл ли PropertyObjectUpserted; replay из DLQ |
| Карточка проекта не найдена (агрегатный инкремент, INV-2) | — | Нет — fail-open (warn + метрика, событие обрабатывается) | Нет | Не требуется: инкремент проекта доедет неявно; сортировка приблизительна |
Карточка Delisted (INV-7) | — | Нет — skip + warn, ack | Нет | Не ошибка (штатный пропуск) |
Неизвестный ContractVersion события | — | Нет — warn-and-proceed | Нет | Согласовать версию с user-activity при breaking-изменении |
Конфликта оптимистической блокировки в таблице нет: инкремент — «слепое» атомарное увеличение колонки без
version-предиката, поэтому сам консюмер на concurrency не падает; бремя retry по конфликту несут
read-modify-write-консюмеры той же строки (chessboard/invest-analytics), а не популярность.
Наблюдаемость и безопасность
- Logs (structured, по событию):
consumed/skipped-duplicate/incremented/skipped-delisted/property-object-not-found/dead-letteredс полямиPropertyObjectViewId,PropertyObjectId,ProjectId,ViewSource,DurationSeconds.UserId/AnonymousId/SessionId— квази-идентификаторы: в логах не разворачиваются (пишется только наличие). PII нет. - Metrics:
catalog_popularity_lag_seconds(now − CreatedAtсобытия),catalog_popularity_consume_duration_ms,catalog_popularity_incremented_total(разрезViewSource),catalog_popularity_duplicates_total,catalog_popularity_delisted_skipped_total,catalog_popularity_property_object_not_found_total,catalog_popularity_project_missing_total,catalog_popularity_retries_total,catalog_popularity_dlq_total,catalog_popularity_contract_mismatch_total; лаг топика (consumer-group offset lag). - Traces: span приёма события → span обработки → 2 span хранилища (инкремент объекта недвижимости, инкремент проекта); correlation из метаданных.
- Alerts: лаг топика > 15 мин (низкий приоритет — популярность не критична); DLQ > 0 (warn, не
paging — fail-open); устойчивый рост
catalog_popularity_property_object_not_found_total(сигнал лагаchessboard-синка). - Dashboard / Runbook: catalog-overview (лаг консюмеров по топикам, DLQ); runbooks «replay DLQ консюмеров», «rebuild проекций по id».
- Безопасность: системный актор; trusted-граница (внутренний Kafka, m2m ACL); карточки не tenanted,
PropertyObjectId/ProjectIdсобытия используются только как адрес инкремента — cross-tenant-запись невозможна по построению; PII в payload и логах нет; на любой единичный сбой — fail-open (потеря просмотра приемлема), а не блокировка витрины.
Расхождения контрактов (реестр)
| # | Расхождение | Статус | Действие |
|---|---|---|---|
| DSC-CAT-POP-1 | Исходная assumption ТЗ/README перечисляла в payload ProjectId (Guid?), EndedAt, ViewId, ViewDurationSeconds; реальный контракт property-object-view-ended.md ProjectId не несёт (резолвится из card.ProjectId), вместо EndedAt — LastActivityAt/ViewedAt, ключ — PropertyObjectViewId, длительность — DurationSeconds | закрыто (контракт зафиксирован владельцем user-activity) | ProjectId резолвится из карточки объекта недвижимости; синхронного запроса в user-activity нет (INV-LV-006) |
| DSC-CAT-POP-2 | Первичная спецификация предлагала идемпотентность по идентификатору сообщения; для счётчика корректный ключ дедупликации — бизнес-ключ PropertyObjectViewId (эталон property-object-view-stats.md INV-LVS-003; контракт: «PropertyObjectViewId — бизнес-ключ идемпотентности consumer-ов») | resolved | Ключ catalog-user-activity:property-object-view-ended:{PropertyObjectViewId}; идентификатор сообщения 1:1 с PropertyObjectViewId для этого события, поэтому поведение при redelivery эквивалентно |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: CatalogProjectCard
- События (2): События dev-stats, Event Contract: pin.user-activity.property-object-view-ended (PropertyObjectViewEndedKafkaEvent)
- Фоновые задачи (1): Background Job: PopularityRecomputeJob
- index.md (1): Catalog Service (Pin.Catalog)
Связанные документы
- Целевые счётчики: catalog-property-object-card.md
(
PopularityViews,PopularityScore, INV-7 DelistedFrozen), catalog-project-card.md (PopularityViews, агрегация поProjectId). - Источник события и эталон алгоритма: property-object-view-ended.md
(контракт), property-object-view.md (дом payload),
property-object-view-stats.md (эталон инкремента),
activity-closer(producer). - Соседние консюмеры каталога: chessboard-projection-sync.md (upsert
карточек/INV-2), invest-analytics-enrichment.md,
rewards-commission.md; ночной пересчёт
PopularityScore— job popularity-recompute. - ADR-0052 (tenancy), ADR-0054/0056 (просмотры → user-activity, polling-закрытие, инкрементальный счётчик).