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

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 teamPIN 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
Topicpin.user-activity.property-object-view-ended.v1, 6 партиций, key = PropertyObjectViewId (= AggregateId, Guid v7)
ContractVersion= 1; при несовпадении версии события — warn-and-proceed (метрика catalog_popularity_contract_mismatch_total)
Consumer grouppin-catalog-popularity
Produceruser-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ТипОбязательностьКак используется (маппинг)
PropertyObjectViewIdGuidДаБизнес-ключ идемпотентности (catalog-user-activity:property-object-view-ended:{PropertyObjectViewId}); = AggregateId/Kafka key
PropertyObjectIdGuidДаАдресация карточки объекта недвижимости; цель инкремента CatalogPropertyObjectCard.PopularityViews
SessionIdGuidДаТолько диагностика (лог), в мутации не участвует
AnonymousIdGuidДаДиагностика; в логах не разворачивается (квази-идентификатор устройства)
UserIdGuid?НетДиагностика; в логах не разворачивается
ViewSourcestring (enum ViewSource)ДаРазрез метрики (Catalog/Search/Selection/…); unknown → Unknown + warn
ViewedAtDateTimeOffsetДаДиагностика (момент открытия карточки)
LastActivityAtDateTimeOffsetДаЛаг-метрика (now − LastActivityAt); диагностика
DurationSecondsintДаТолько лог/метрика (длительность просмотра); на инкремент не влияет; < 0 → warn + принимается
FilterSnapshotAppliedFilterModel?НетНе используется каталогом (счётчик) — игнорируется

Алгоритм

  1. Контекст события. Восстановить метаданные (идентификатор сообщения, PropertyObjectViewId); при несовпадении ContractVersion — warn-and-proceed; идемпотентность (критична — счётчик): по ключу catalog-user-activity:property-object-view-ended:{PropertyObjectViewId} — ключ уже есть → лог skipped-duplicate, метрика catalog_popularity_duplicates_total, ack, инкремента нет (повтор доставки не задваивает счётчик).
  2. Проверка входа. PropertyObjectViewId != Guid.Empty; PropertyObjectId != Guid.Empty; DurationSeconds >= 0 — при < 0 warn (контракт-аномалия) + принимается (длительность лишь логируется, инкремент — на 1). Существование объекта недвижимости проверяется на загрузке (шаг 3) — отсутствие карточки штатная out-of-order-ситуация с retriable-исходом, а не poison.
  3. Что читаем. Карточка объекта недвижимости по PropertyObjectId; нет карточки → CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUNDretriable (out-of-order: PropertyObjectUpserted из chessboard ещё не применён; топики разные, порядок между ними не гарантирован). card.ProjectId — цель агрегатного инкремента проекта (карточку проекта не читаем: инкремент — атомарный по id; её отсутствие обрабатывается fail-open на шаге 5).
  4. Бизнес-правила и переходы.
    • 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.
  5. Что меняется. Атомарный инкремент без 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, событие не падает (потеря единичного просмотра проекта приемлема — сортировка приблизительна).
  6. Согласованность. Инкремент объекта недвижимости + инкремент проекта + отметка идемпотентности (PropertyObjectViewId) — в одном согласованном изменении; offset подтверждается только после успешной фиксации. Доменных/интеграционных событий консюмер не порождает (счётчик не влияет на видимость/агрегаты цен, обоснование — «Назначение»).
  7. Результат. Успех → 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).

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)Бесконечный exponential backoff 1s→60s с паузой партиции (событие не теряется)НетRunbook catalog-overview: восстановить зависимость
Карточка объекта недвижимости не найдена (out-of-order против chessboard upsert)CATALOG_SYNC_PROPERTY_OBJECT_NOT_FOUND6 попыток, 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), вместо EndedAtLastActivityAt/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 эквивалентно

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

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

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