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) |
| Kind | ClickHouse fact (append-only) |
| Source of truth | операционный просмотр — user-activity; аналитическая лента — этот факт |
| Хранилище | ClickHouse, database pin_dev_stats, table property_object_view_facts (dev-stats владеет своей схемой) |
| Tenant isolation | TenantId = DeveloperId = CompanyEntity.Id (тип Developer); каждый запрос жёстко ограничен своим застройщиком |
Table
| Поле | Значение |
|---|---|
| Database | pin_dev_stats |
| Table | property_object_view_facts |
| Engine | ReplacingMergeTree (dedup по ключу сортировки; версия — по порядку вставки) |
| Owner service | dev-stats (DevStats.Background пишет, DevStats.Api читает, DevStats.Migrator создаёт DDL) |
| Source events | pin.user-activity.property-object-view-ended.v1 (основной), pin.user-activity.action-tracked.v1 (ViewPropertyObject — open-факт) |
| Transform version | 1 |
| Freshness SLA | ≤ 5 минут от события до видимости в API (batch ingestion, батч ≤ 1000 строк / 5 секунд) |
Columns
| Column | Type | Nullable | Source | Partition/Order | PII |
|---|---|---|---|---|---|
FactId | String | нет | = SourceEventId (идентификатор события) | order (последний компонент) | нет |
SourceEventId | String | нет | идентификатор события (EventId) | none | нет |
TenantId | String | нет | = DeveloperId (Guid как строка) | none | нет |
BrandId | Nullable(String) | да | не используется в PIN, всегда NULL | none | нет |
EventTime | DateTime | нет | = ViewedAt (бизнес-время начала просмотра) | PARTITION BY toYYYYMM(ViewedAt), order (предпоследний) | нет |
IngestionTime | DateTime | нет | now() при вставке консюмером; версия dedup при чтении | none | нет |
PropertyObjectViewId | UUID | нет | id агрегата просмотра из user-activity; бизнес-ключ склейки open/closed строк | none | нет |
PropertyObjectId | UUID | нет | payload события; ключ обогащения из dim_property_object | order (6-й компонент) | нет |
SessionId | UUID | нет | payload события; связь с session_facts | none | нет |
AnonymousId | UUID | нет | payload события; идентификатор гостя | none | нет |
UserId | Nullable(UUID) | да | payload; NULL для гостя | none | косвенно |
ViewerKind | LowCardinality(String) | нет | консюмер из UserId + роль: Guest / Agent / Investor | order-компонент rollup | нет |
ViewSource | LowCardinality(String) | нет | Catalog / Search / Selection / Favorites / News / DirectLink / Unknown (из метаданных action) | none | нет |
DeveloperId | UUID | нет | обогащение из dim_property_object → dim_building → dim_project → dim_developer | order (1-й компонент) | нет |
ProjectId | UUID | нет | обогащение из dim_property_object/dim_project | order (2-й компонент) | нет |
BuildingId | UUID | нет | обогащение из dim_property_object/dim_building | order (3-й компонент) | нет |
LayoutGroupId | UUID | нет | обогащение из dim_property_object/dim_layout_group | order (4-й компонент) | нет |
LayoutId | UUID | нет | обогащение из dim_property_object/dim_layout | order (5-й компонент) | нет |
DurationSeconds | Int32 | нет | payload property-object-view-ended; для open-факта action-tracked = 0 | none | нет |
LastActivityAt | DateTime | нет | payload; для open-факта = ViewedAt | none | нет |
Персональные данные: только UserId/AnonymousId как псевдонимные идентификаторы; ФИО/контакты в факте
не хранятся (агрегаты застройщику отдают типы зрителей и уникальные счётчики, не персоны — ADR-0057 §6).
Инварианты
| Код | Инвариант | Смысл / где проверяется |
|---|---|---|
| INV-LV-1 AppendOnly | Строки только добавляются; точечное обновление/удаление/upsert запрещены | Консюмер пишет append-only; корректировки — новой строкой + dedup при чтении |
| INV-LV-2 NonNegativeDuration | DurationSeconds >= 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 TenantEqualsDeveloper | TenantId = DeveloperId (обогащение замыкает tenant-scope на источнике данных) | Чтение фильтрует DeveloperId = актор (fail-closed) |
| INV-LV-5 DedupByFactId | FactId = SourceEventId — последний компонент ключа сортировки; повтор доставки схлопывается | at-least-once без задвоения |
| INV-LV-6 ViewGluedByViewId | open- и 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 BYc последним компонентом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) — префикс ключа; опциональные (BuildingId→LayoutGroupId→LayoutId→PropertyObjectId) — продолжение того же префикса. Любой запрос «сверху вниз» по иерархии — это префикс-скан по первичному индексу (без чтения хвостовых уровней).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; тот же PropertyObjectViewId | Kafka 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).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Классификация зрителя и источника (ViewerKind / ViewSource), DevStatsFilterModel (общий вход метрик dev-stats)
- API (9): GET /api/v1/dev-stats/activity-heatmap — Хитмап активности (час × день недели), GET /api/v1/dev-stats/bounce-signal — Сигнал отказа (короткие просмотры по объекту), GET /api/v1/dev-stats/duration/histogram — Гистограмма длительностей просмотра, GET /api/v1/dev-stats/repeat-frequency — Повторные просмотры и частота возврата на объект, GET /api/v1/dev-stats/session-depth — Глубина сессии (объектов за сессию), GET /api/v1/dev-stats/time-to-first-view — Время до первого просмотра (TTFV), GET /api/v1/dev-stats/viewers/unique — Уникальные зрители (гость/агент/инвестор), GET /api/v1/dev-stats/views/summary — Сводка просмотров (кто/сколько/как долго/как часто), GET /api/v1/dev-stats/views/trend — Динамика просмотров + сравнение периодов
- Use Cases (2): UC-STA-001. Застройщик смотрит статистику проекта (период + проект), UC-STA-002. Детализация просмотров до здания / группы планировок / планировки
- Консюмеры (4): Consumer: DevStatsActionTrackedConsumer, Consumer: DevStatsPropertyObjectUpsertedConsumer, Consumer: DevStatsPropertyObjectViewEndedConsumer, Consumer: DevStatsSessionEndedConsumer
- Фоновые задачи (2): Background Job: DimensionBackfillJob, Background Job: FactsBackfillJob
- Отчёты (1): Отчет/Export: dev-stats-export (выгрузка аналитики застройщика)
- Дашборды (1): Панель Метрик Застройщика: dev-stats-overview (аналитика внимания)
- index.md (1): DevStats Service (Pin.DevStats)
- dimensions (1): dim_property_object (ClickHouse dimension)
- facts (3): funnel_daily (ClickHouse rollup / воронка), property_object_view_daily (ClickHouse rollup / materialized view), SessionFact (ClickHouse)
- registries (1): Реестр сущностей PIN
Связанные документы
- Rollup: property-object-view-daily-rollup.md; воронка: funnel-daily-rollup.md.
- Сессии: session-facts.md; измерения: ../dimensions/property-object.md.
- События-источники:
docs/06-services/user-activity/events/property-object-view-ended.md,.../action-tracked.md. - Шаблон: ../../../99-templates/clickhouse-fact-template.md.
- ADR-0057 (паттерн dev-stats), ADR-0056 (границы ClickHouse, simplicity-first).