Consumer: DevStatsPropertyObjectViewEndedConsumer
Пишущий процесс главного факта dev-stats: принимает завершённый просмотр объекта недвижимости из user-activity,
обогащает его иерархией застройщика из dim_property_object и добавляет closed-строку факта
property_object_view_facts в ClickHouse pin_dev_stats батчами.
Реализует ADR-0057: трёхлетний массив просмотров с произвольными агрегациями по иерархии
проект→здание→группа планировок→планировка→объект недвижимости и по типу зрителя живёт в ClickHouse, а не в операционном
PostgreSQL user-activity (ADR-0056 п.2–3).
Назначение
Consumer DevStatsPropertyObjectViewEndedConsumer слушает событие property-object-view-ended (топик
pin.user-activity.property-object-view-ended.v1) и добавляет (никогда не обновляет и не удаляет) строку
closed-факта просмотра. Событие несёт PropertyObjectId, SessionId, AnonymousId, UserId?,
ViewSource, ViewedAt, LastActivityAt, DurationSeconds, но не несёт вышестоящую иерархию
застройщика — консюмер резолвит DeveloperId → ProjectId → BuildingId → LayoutGroupId → LayoutId из
dim_property_object перед вставкой, чтобы факт лёг в property_object_view_facts уже
денормализованным (JOIN-free аналитика по любому уровню). Он же классифицирует ViewerKind
(Guest/Agent/Investor) и переносит ViewSource.
Closed-строка (реальная DurationSeconds, IngestionTime позже open-факта из action-tracked) склеивается
с open-строкой того же просмотра по PropertyObjectViewId при чтении (argMax(*, IngestionTime)) — см. «Deduplication»
в property-object-view-facts.md. Owned-состояния в PostgreSQL консюмер не меняет: пишет
только в ClickHouse.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | dev-stats (проект DevStats.Background) |
| Consumer | DevStatsPropertyObjectViewEndedConsumer |
| Обрабатываемое событие | property-object-view-ended |
| Topic | pin.user-activity.property-object-view-ended.v1 |
| ContractVersion | 1; при несовпадении — warn-and-proceed |
| Consumer group | dev-stats-property-object-view-ended |
| Producer | user-activity — worker activity-closer (закрытие просмотра по неактивности / форс-закрытие при закрытии родительской сессии) |
| Эффект (событие → что меняется) | append closed-строки в pin_dev_stats.property_object_view_facts (ReplacingMergeTree) |
Контекст события
- Из метаданных события доступны идентификатор источника (
AggregateId = PropertyObjectViewId), тип агрегата (PropertyObjectView), время события (CreatedAt), версия snapshot-а (Version). - Сквозной трейс восстанавливается из метаданных события; идентификатор сообщения (
MessageId) прописывается в факт какSourceEventId(сквозная трассировка событие → строка CH). - Бизнес-идемпотентность — декларативная, на стороне ClickHouse:
FactId = SourceEventId— последний компонентORDER BYReplacingMergeTree, повторная доставка (at-least-once) схлопывается при merge, а читающие запросы дубль-устойчивы (argMax(*, IngestionTime)поPropertyObjectViewId;FINALзапрещён — правило SDK). Дополнительно консюмер отсекает дубль того жеSourceEventIdв батче, чтобы rollupproperty_object_view_daily/funnel_views_mvне получил повтор. Отдельная таблица идемпотентности не используется: у консюмера нет PG-транзакции.
Входные данные / payload события
Контракт события: event-contract: property-object-view-ended. Дом колонок факта — property-object-view-facts.md («Columns»); ниже — только правила маппинга.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
PropertyObjectViewId | Guid | Да | → PropertyObjectViewId (бизнес-ключ склейки open/closed); Guid.Empty → poison (DLQ) |
PropertyObjectId | Guid | Да | → PropertyObjectId; ключ обогащения из dim_property_object; Guid.Empty → poison |
SessionId | Guid | Да | → SessionId (связь с session_facts) |
AnonymousId | Guid | Да | → AnonymousId (идентификатор гостя; вход uniqViewers = coalesce(UserId, AnonymousId)) |
UserId | Guid? | Нет | → UserId (NULL для гостя); вход классификации ViewerKind |
ViewSource | string (enum ViewSource) | Да | → ViewSource; unknown-значение → Unknown + warning (fallback контракта) |
ViewedAt | DateTimeOffset | Да | → EventTime/ViewedAt; партиция toYYYYMM; default/> now + 1 ч → poison |
LastActivityAt | DateTimeOffset | Да | → LastActivityAt |
DurationSeconds | int | Да | → DurationSeconds; < 0 → poison (INV-LV: >= 0) |
FilterSnapshot | AppliedFilterModel? | Нет | в факт не маппится (воронку каталога строит action-tracked); в логах не разворачивается |
Колонки, которые консюмер заполняет сам: FactId = SourceEventId (идентификатор сообщения); IngestionTime
= момент вставки; TenantId = DeveloperId (после обогащения); BrandId = NULL;
иерархия (DeveloperId/ProjectId/BuildingId/LayoutGroupId/LayoutId) — из dim_property_object;
ViewerKind — из UserId + роль (см. ниже).
Обогащение иерархией из dim_property_object
Lookup WHERE PropertyObjectId = ? в dim_property_object, последняя версия — argMax(col, UpsertedAt)
с GROUP BY PropertyObjectId (без FINAL); из строки берётся весь путь вверх
(DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId). Lookup'ы батчатся: для батча
сообщений — один WHERE PropertyObjectId IN (…) вместо запроса на строку.
Классификация ViewerKind (зависимость от контракта / assumption)
ViewerKind ∈ Guest/Agent/Investor. UserId == null → Guest (полностью выводится из payload).
Для аутентифицированного зрителя разделение Agent/Investor требует роли зрителя, которой в v1 нет ни
в payload, ни в измерениях dev-stats (реплики dim_user нет по ADR-0056). Простой faithful-вариант v1:
producer добавляет в событие optional-поле роли актора (ViewerRole, additive-only — bump ContractVersion
не нужен) либо заводится реплика dim_user из identity. До этого аутентифицированный зритель пишется
как Investor (доминирующий тип аудитории каталога), а Agent детектируется по мере появления сигнала роли.
Требование зафиксировано в «Известных ограничениях» README.
Кросс-сервисный контракт
Ожидает от user-activity (входной контракт property-object-view-ended):
- Поля payload:
PropertyObjectViewId,PropertyObjectId,SessionId,AnonymousId,UserId?,ViewSource,ViewedAt,LastActivityAt,DurationSeconds,FilterSnapshot?;MessageIdв метаданных →SourceEventId. - Гарантии продюсера: событие публикуется worker-ом
activity-closerатомарно с закрытием просмотра;DurationSecondsуже посчитан сервером по polling-delta (INV-LV≥ 0); доставка at-least-once (дубли снимаются dedup поFactId); одинPropertyObjectViewIdразделяется с open-фактомaction-tracked(ViewPropertyObject) для склейки open/closed поPropertyObjectViewIdпри чтении. - Событие не несёт иерархию застройщика — её обязан долить сам консюмер из
dim_property_object.
Ожидает от dim_property_object (внутри dev-stats): строку с полным путём вверх по PropertyObjectId
(гарантия консюмера property-object-upserted); lookup argMax(…, UpsertedAt) GROUP BY PropertyObjectId без FINAL. Отсутствие строки → late-dimension (park/deferred re-enrich/DLQ).
Предоставляет downstream (в пределах dev-stats, без публикации событий):
- Append-only closed-строку
property_object_view_factsуже денормализованную по иерархии — источник всех метрик внимания и вход rollup-овproperty_object_view_daily/funnel_views_mv(стадияViews) и этапа воронки. Гарантия дубль-устойчивости чтения:argMax(*, IngestionTime)поPropertyObjectViewId,uniq*поPropertyObjectViewId; окно свежести факта ≤ 5 мин.
Алгоритм
Консюмер батчевый: шаги 1–4 — на сообщение, шаги 5–6 — на батч (flush по порогу ≤ 1000 строк или 5 секунд — freshness SLA ≤ 5 мин с запасом).
- Проверить контекст события: сверить
ContractVersion(=1; при несовпадении — warn-and-proceed). Дубль поSourceEventIdна входе не отсеивается (dedup декларативен), но повтор в текущем батче отбрасывается. - Проверить payload (по колонке «Обязательность»): ошибка десериализации → DLQ (poison), партиция
продолжается;
PropertyObjectViewId/PropertyObjectId == Guid.Empty→ DLQ + error-лог;DurationSeconds < 0(нарушение INV-LV:>= 0),ViewedAt == defaultилиViewedAt > now() + 1 ч→ DLQ; unknown-enumViewSource→Unknown+ warning (не DLQ). - Обогащение иерархией: batch-lookup
dim_property_objectпоPropertyObjectId(argMax(…, UpsertedAt), безFINAL). Late-dimension (гонка: факт раньшеproperty-object-upserted): строки нет → сообщение паркуется (deferred re-enrich): отложенный ретрай с backoff (напр. 30 c → 5 мин, ≤ N попыток); при устойчивом промахе → DLQ + алерт «dimension gap». Просмотр не теряется (см. dim_property_object.md). - Маппинг поле-в-поле: построить строку факта
property_object_view_facts— скалярные поля из payload; иерархия изdim_property_object;ViewerKindизUserId+ роль;FactId = SourceEventId;TenantId = DeveloperId;IngestionTime— момент вставки. Переходов состояний нет. Добавить строку в буфер батча. - Пакетно вставить (append-only) буфер строк в
pin_dev_stats.property_object_view_facts(батч ≤ 1000 строк / 5 с); точечное обновление/удаление/upsert запрещены — append-only факт; в CH пишет только этот background-консюмер. Смещение (offset) фиксируется строго после подтверждённой вставки; сбой между вставкой и commit → повторная доставка → дубли схлопываетReplacingMergeTree(at-least-once без потерь). - Записать метрики/логи; успех → ack; ошибка вставки → retry всего батча (идемпотентно), offset не двигается.
Диаграмма
Предусловия и постусловия
Предусловия (до обработки батча): таблица property_object_view_facts и её rollup-MV созданы миграцией;
consumer group dev-stats-property-object-view-ended зарегистрирована; измерение dim_property_object
наполнено (backfill при вводе) — иначе просмотры массово паркуются на late-dimension.
Постусловие (после успешного батча): для каждого валидного, обогащённого сообщения в
property_object_view_facts появилась closed-строка с реальной DurationSeconds, денормализованной иерархией
(DeveloperId = TenantId), FactId = SourceEventId, IngestionTime = момент вставки; open/closed того же
PropertyObjectViewId склеиваются при чтении (побеждает closed по IngestionTime); rollup-ы получили строку
без задвоения (in-batch dedup по SourceEventId); offset сдвинут. Спарканные сообщения не потеряны; poison — в
DLQ. PostgreSQL не меняется.
Acceptance criteria
- Happy closed-факт. Given валидное
property-object-view-endedи существующая строкаdim_property_object, When консюмер обработал батч, Then вproperty_object_view_factsесть closed-строка с денормализованной иерархией и реальнойDurationSeconds; чтение (argMax/uniqбезFINAL) отдаёт один просмотр. - Склейка open/closed. Given ранее записан open-факт (
action-tracked,DurationSeconds = 0) с тем жеPropertyObjectViewId, When пришёл closed, Then при чтении склейка поPropertyObjectViewIdвыбирает closed-строку (argMax(*, IngestionTime)); просмотр не задваивается. - Late-dimension. Given строки
dim_property_objectещё нет, When обрабатывается просмотр, Then сообщение паркуется (deferred re-enrich с backoff), при устойчивом промахе → DLQ + алерт; после backfill измерения replay из DLQ дописывает факт;dev_stats_dim_property_object_miss_totalрастёт. - Идемпотентный повтор. Given то же событие доставлено дважды, When оба обработаны, Then дубль по
FactId = SourceEventIdсхлопываетсяReplacingMergeTree, in-batch dedup не даёт rollup-повтора. - Граничные значения. Given
DurationSeconds < 0(нарушение INV-LV) илиViewedAt == defaultилиViewedAt > now() + 1 чили пустойPropertyObjectViewId/PropertyObjectId, When обработан, Then сообщение в DLQ (poison) + error-лог, партиция продолжается. - Unknown ViewSource. Given
ViewSourceвне множества контракта, When обработан, ThenUnknown+ warning (не DLQ), строка пишется. - Гость vs аутентифицированный. Given
UserId == null, ThenViewerKind = Guest; GivenUserId != nullбез сигнала роли, ThenViewerKind = Investor(v1-допущение),Agent— по мере появления сигнала роли.
Идемпотентность и replay
- Business key:
FactId = SourceEventId(идентификатор сообщения, уникален на событие). Дубль: строка вставляется повторно и схлопываетсяReplacingMergeTreeпри merge; до merge читатели дубль-устойчивы (argMax(*, IngestionTime)поPropertyObjectViewId; open/closed одной сессии просмотра склеиваются поPropertyObjectViewId). - Replay: да — переигрывание топика группой
dev-stats-property-object-view-endedс произвольного offset безопасно; «дырки» после инцидента закрываются повтором диапазона без ручной очистки CH. - Пересечение с backfill фактов исключено схемой ключей: у backfill-строк
SourceEventId— детерминированныйbackfill:{PropertyObjectViewId}(см. facts-backfill), у консюмера — идентификатор сообщения события.
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| ClickHouse недоступен / timeout вставки | Да: батч с backoff (1с → 60с), offset не commit-ится | Нет (сбой инфраструктуры) | Алерт по росту ingest-lag > 15 мин; восстановить CH — консюмер догонит |
dim_property_object не найден (late-dimension) | Да: park + deferred re-enrich (backoff, ≤ N попыток) | Да при устойчивом промахе + алерт «dimension gap» | Backfill dim_property_object, затем replay сообщения из DLQ |
| Ошибка десериализации / невалидный payload (шаг 2) | Нет | Да (poison, per-message) + error-лог | Проверить контракт producer-а user-activity; replay после исправления |
Версия события ≠ ContractVersion | Не сбой: warning-лог, продолжить | Нет | Сверить график миграции контракта |
| Сбой между вставкой батча и commit offset | Повторная доставка (at-least-once) | Нет | Не требуется — дубли схлопывает ReplacingMergeTree |
Наблюдаемость и безопасность
- Logs: consumed (offset-диапазон, размер батча), enriched/parked (late-dimension), skipped-poison (причина
SourceEventId), flushed (строк, длительность вставки), failed (ошибка CH). Число дублей не логируется — dedup на стороне CH.
- Metrics:
dev_stats_property_object_view_facts_ingested_total,dev_stats_ingest_lag_seconds(ViewedAt → IngestionTime),dev_stats_ingest_batch_size,dev_stats_dim_property_object_miss_total, счётчики retry/DLQ. - Traces: consume span + dim-lookup span + insert span; корреляция из метаданных события;
SourceEventIdв строке связывает CH-данные с событием. - PII/masking: факт несёт только псевдонимные
UserId/AnonymousId/SessionId(Sensitive на уровне строк); ФИО/контакты не хранятся; наружу API отдаёт агрегаты (типы зрителей, счётчики).FilterSnapshotв логах не разворачивается. - Actor/permissions: пишет только system-actor консюмера; прямой записи в CH из API/других сервисов нет.
Tenant:
TenantId = DeveloperIdберётся изdim_property_object(обогащение замыкает tenant-scope на источнике данных); scope-фильтрация — на чтении (DeveloperId = актор, fail-closed).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Классификация зрителя и источника (ViewerKind / ViewSource)
- События (2): События dev-stats, Event Contract: pin.user-activity.property-object-view-ended (PropertyObjectViewEndedKafkaEvent)
- Консюмеры (3): Consumer: DevStatsActionTrackedConsumer, Consumer: DevStatsPropertyObjectUpsertedConsumer, Consumer: DevStatsSessionEndedConsumer
- Фоновые задачи (1): Background Job: FactsBackfillJob
- Дашборды (1): Ops-борд: dev-stats-ingest-overview (здоровье ingestion)
- index.md (1): DevStats Service (Pin.DevStats)
- dimensions (1): dim_property_object (ClickHouse dimension)
- facts (1): PropertyObjectViewFact (ClickHouse)
Связанные документы
- Эталон CH-консюмера: invest-analytics/consumers/chessboard-price-changed.md.
- Контракт события: ../../user-activity/events/property-object-view-ended.md.
- Дом факта: property-object-view-facts.md; rollup: property-object-view-daily-rollup.md; воронка (этап views): funnel-daily-rollup.md.
- Измерение обогащения: ../dimensions/property-object.md; соседний open-факт: action-tracked.md; сессии: session-ended.md.
- Границы контекста: ../README.md. ADR-0057 (dev-stats), ADR-0056 (границы CH, simplicity), ADR-0052 (tenancy).