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

PropertyObjectViewFact (ClickHouse)

Дом полей append-only факта «просмотр объекта недвижимости застройщика». Главный факт контекста dev-stats (ADR-0057): источник всех метрик внимания к недвижимости застройщика — кто, сколько, как долго и как часто смотрит его проекты/здания/группы планировок/планировки/объекты. Онлайн-чтение — дубль-устойчивое (argMax по IngestionTime), без FINAL.

Назначение

PropertyObjectViewFact хранит по одной строке на завершённый просмотр объекта недвижимости из события pin.user-activity.property-object-view-ended.v1 (реальная длительность), плюс «открытый» факт из pin.user-activity.action-tracked.v1 (ActionType = ViewPropertyObject) для гарантии строки при прямых заходах и захвата ViewSource. Событие несёт PropertyObjectId, но не несёт иерархию застройщика — консюмер обогащает факт из измерений (dim_property_object → вверх) перед вставкой. ADR-0056: PostgreSQL-модель user-activity операционна (открытые просмотры, закрытие polling-delta), а трёхлетний массив фактов с произвольными агрегациями по иерархии живёт в ClickHouse.

Стандартные технические колонки факта: FactId (dedup), SourceEventId (трассировка), TenantId, BrandId? (в PIN всегда null), EventTime, IngestionTime.

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

ПолеЗначение
Контекстdev-stats (ADR-0057)
KindClickHouse fact (append-only)
Source of truthоперационный просмотр — user-activity; аналитическая лента — этот факт
ХранилищеClickHouse, database pin_dev_stats, table property_object_view_facts (dev-stats владеет своей схемой)
Tenant isolationTenantId = DeveloperId = CompanyEntity.Id (тип Developer); каждый запрос жёстко ограничен своим застройщиком

Table

ПолеЗначение
Databasepin_dev_stats
Tableproperty_object_view_facts
EngineReplacingMergeTree (dedup по ключу сортировки; версия — по порядку вставки)
Owner servicedev-stats (DevStats.Background пишет, DevStats.Api читает, DevStats.Migrator создаёт DDL)
Source eventspin.user-activity.property-object-view-ended.v1 (основной), pin.user-activity.action-tracked.v1 (ViewPropertyObject — open-факт)
Transform version1
Freshness SLA≤ 5 минут от события до видимости в API (batch ingestion, батч ≤ 1000 строк / 5 секунд)

Columns

ColumnTypeNullableSourcePartition/OrderPII
FactIdStringнет= SourceEventId (идентификатор события)order (последний компонент)нет
SourceEventIdStringнетидентификатор события (EventId)noneнет
TenantIdStringнет= DeveloperId (Guid как строка)noneнет
BrandIdNullable(String)дане используется в PIN, всегда NULLnoneнет
EventTimeDateTimeнет= ViewedAt (бизнес-время начала просмотра)PARTITION BY toYYYYMM(ViewedAt), order (предпоследний)нет
IngestionTimeDateTimeнетnow() при вставке консюмером; версия dedup при чтенииnoneнет
PropertyObjectViewIdUUIDнетid агрегата просмотра из user-activity; бизнес-ключ склейки open/closed строкnoneнет
PropertyObjectIdUUIDнетpayload события; ключ обогащения из dim_property_objectorder (6-й компонент)нет
SessionIdUUIDнетpayload события; связь с session_factsnoneнет
AnonymousIdUUIDнетpayload события; идентификатор гостяnoneнет
UserIdNullable(UUID)даpayload; NULL для гостяnoneкосвенно
ViewerKindLowCardinality(String)нетконсюмер из UserId + роль: Guest / Agent / Investororder-компонент rollupнет
ViewSourceLowCardinality(String)нетCatalog / Search / Selection / Favorites / News / DirectLink / Unknown (из метаданных action)noneнет
DeveloperIdUUIDнетобогащение из dim_property_objectdim_buildingdim_projectdim_developerorder (1-й компонент)нет
ProjectIdUUIDнетобогащение из dim_property_object/dim_projectorder (2-й компонент)нет
BuildingIdUUIDнетобогащение из dim_property_object/dim_buildingorder (3-й компонент)нет
LayoutGroupIdUUIDнетобогащение из dim_property_object/dim_layout_grouporder (4-й компонент)нет
LayoutIdUUIDнетобогащение из dim_property_object/dim_layoutorder (5-й компонент)нет
DurationSecondsInt32нетpayload property-object-view-ended; для open-факта action-tracked = 0noneнет
LastActivityAtDateTimeнетpayload; для open-факта = ViewedAtnoneнет

Персональные данные: только UserId/AnonymousId как псевдонимные идентификаторы; ФИО/контакты в факте не хранятся (агрегаты застройщику отдают типы зрителей и уникальные счётчики, не персоны — ADR-0057 §6).

Инварианты

КодИнвариантСмысл / где проверяется
INV-LV-1 AppendOnlyСтроки только добавляются; точечное обновление/удаление/upsert запрещеныКонсюмер пишет append-only; корректировки — новой строкой + dedup при чтении
INV-LV-2 NonNegativeDurationDurationSeconds >= 0 (open-факт = 0, closed — реальная длительность)Нарушение (< 0) → poison/DLQ на ingestion
INV-LV-3 HierarchyCompleteФакт вставляется только денормализованным полной иерархией DeveloperId → ProjectId → BuildingId → LayoutGroupId → LayoutId из dim_property_objectНет строки измерения → факт паркуется (late-dimension), не вставляется «без иерархии»
INV-LV-4 TenantEqualsDeveloperTenantId = DeveloperId (обогащение замыкает tenant-scope на источнике данных)Чтение фильтрует DeveloperId = актор (fail-closed)
INV-LV-5 DedupByFactIdFactId = SourceEventId — последний компонент ключа сортировки; повтор доставки схлопываетсяat-least-once без задвоения
INV-LV-6 ViewGluedByViewIdopen- и closed-строки одного просмотра несут один PropertyObjectViewId; при чтении склеиваются argMax по IngestionTimeОдин просмотр считается один раз, даже если closed не пришёл
INV-LV-7 Ttl3YearsХранение и TTL считаются от ViewedAt; факт живёт 3 годаADR-0057 «отслеживать 3 года»

Промежуточное состояние и что видит читатель

  • Окно freshness (≤ 5 мин). До подтверждённой batch-вставки просмотр в API ещё не виден; чтение всегда консистентно к тому, что уже вставлено (не «частично» внутри строки).
  • Open до closed. Пока пришёл только open-факт, просмотр уже посчитан (уникальный по PropertyObjectViewId), но его длительность = 0. После прихода closed средняя/сумма длительности дорастают до реальных (INV-LV-6). Прямой заход без closed остаётся с длительностью 0 — валидный исход.
  • Late-dimension. Пока иерархия не обогащена, просмотр отсутствует в метриках (паркуется), затем появляется в партиции своего ViewedAt — может ретроспективно увеличить счётчик прошедшего дня (идемпотентно, INV-LV-5). Устойчивый промах → DLQ + алерт «dimension gap».

Deduplication

  • Dedup key: ORDER BY c последним компонентом FactId = SourceEventId. Повторная доставка Kafka (at-least-once) даёт строку с идентичным ключом сортировки → ReplacingMergeTree схлопывает дубликат при merge. На стороне консюмера дубли того же FactId в батче также отсекаются (идемпотентность по SourceEventId), чтобы rollup-MV не получал повтор.
  • Open + closed одной сессии просмотра: action-tracked ViewPropertyObject (open, DurationSeconds = 0) и property-object-view-ended (closed, реальная длительность) несут разные FactId, но одинаковый PropertyObjectViewId. Физически это две строки; чтение склеивает их по PropertyObjectViewId через argMax(*, IngestionTime) — closed-строка (позже по IngestionTime, ненулевая длительность) побеждает. Если closed так и не пришёл (прямой заход, брошенная вкладка) — просмотр всё равно посчитан по open-строке.
  • Replay behavior: переигрывание топика идемпотентно; онлайн-запросы дубль-устойчивы (агрегации uniq* по PropertyObjectViewId и argMax по IngestionTime). FINAL в online-запросах запрещён (правило аналитического чтения).
  • Late event: факт вставляется в партицию своего ViewedAt независимо от задержки; TTL считается от ViewedAt.

Partition И TTL

  • Partition key: toYYYYMM(ViewedAt) — диапазон дат (обязательный фильтр DateFrom/DateTo) режется партициями до сканирования.
  • Order key (подсказка по индексам): (DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, PropertyObjectId, ViewedAt, FactId) — порядок ключа дословно повторяет иерархию фильтров DevStatsFilterModel. Обязательные фильтры (DeveloperId = актор → ProjectId) — префикс ключа; опциональные (BuildingIdLayoutGroupIdLayoutIdPropertyObjectId) — продолжение того же префикса. Любой запрос «сверху вниз» по иерархии — это префикс-скан по первичному индексу (без чтения хвостовых уровней). ViewedAt перед FactId даёт локальность по времени внутри объекта недвижимости; FactId в самом хвосте — только для dedup.
  • TTL: ViewedAt + INTERVAL 3 YEAR (авто-очистка, требование ADR-0057 «отслеживать 3 года»).

Consumers И Backfill

ProcessНазначениеCheckpoint
property-object-view-ended consumer (../consumers/property-object-view-ended.md)Приём pin.user-activity.property-object-view-ended.v1; обогащение иерархией из dim_property_object; ViewerKind из UserId+роль; вставка closed-факта (реальная DurationSeconds)Kafka offset (commit после подтверждённой batch-вставки)
action-tracked consumer (../consumers/action-tracked.md)Фильтр ActionType = ViewPropertyObject → open-факт (DurationSeconds = 0, LastActivityAt = ViewedAt), захват ViewSource; тот же PropertyObjectViewIdKafka offset
Backfill (будущий ../background/facts-backfill.md)Разовая перезаливка диапазона из истории user-activity при вводе сервисаДиапазон ViewedAt в таблице состояния job-а (PostgreSQL)

Обогащение иерархией: консюмер по PropertyObjectId читает dim_property_object (реплика chessboard), берёт из неё LayoutId/LayoutGroupId/BuildingId/ProjectId/DeveloperId (JOIN-free); при отсутствии измерения — строка уходит в отложенную дозагрузку (dimension ещё не долетел) без потери.

Security

  • Доступ — роль застройщика + permission dev-stats.view; scope строго = свой DeveloperId. Фильтр DeveloperId обязателен в каждом запросе и равен актору (fail-closed при попытке чужого id).
  • Персональные данные зрителей не раскрываются: API отдаёт типы (ViewerKind) и уникальные счётчики, не ФИО/телефон.
  • Прямой доступ к ClickHouse у фронта/других сервисов отсутствует; чтение — только через DevStats.Api.
  • ClickHouse не является operational source of truth (это user-activity).

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

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

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