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

dim_property_object (ClickHouse dimension)

Реплика-измерение «Объект недвижимости» в БД dev-statsключевое измерение обогащения. Событие pin.user-activity.property-object-view-ended.v1 несёт только PropertyObjectId, но НЕ несёт вышестоящую иерархию; консюмер факта property_object_view_facts резолвит DeveloperId → ProjectId → BuildingId → LayoutGroupId → LayoutId из этой таблицы (dim_property_object → вверх) перед вставкой факта. Дом полей источника — chessboard: PropertyObjectEntity; здесь — только колонки, нужные dev-stats.

Назначение

dim_property_object хранит текущий снапшот объекта недвижимости (одна логическая строка на PropertyObjectId), реплицируемый из pin.chessboard.property-object-upserted.v1. Две роли:

  1. Обогащение фактов иерархией — консюмер property-object-view-ended берёт из dim_property_object весь путь вверх (DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId), чтобы факт просмотра лёг в property_object_view_facts уже денормализованным (JOIN-free аналитика по любому уровню, ADR-0057).
  2. Фильтрация/подпись по конкретному объекту — Number («квартира 145»), Status, Floor, Rooms.

Не факт, а стабильное измерение (ReplacingMergeTree, последняя версия перезаписывает предыдущую). Событие property-object-upserted уже несёт денормализованный LayoutGroupId (через привязанную планировку), поэтому вся иерархия доступна из одной строки dim_property_object — вторичный резолв через dim_layout не требуется.

Table

ПолеЗначение
Databasepin_dev_stats
Tabledim_property_object
EngineReplacingMergeTree(UpsertedAt)
Owner servicedev-stats (DevStats.Background пишет и читает при обогащении, DevStats.Api читает; DDL — DevStats.Migrator)
Source eventspin.chessboard.property-object-upserted.v1
Partition / Orderбез партиционирования (сотни тысяч строк, ADR-0056); ORDER BY (PropertyObjectId)
Freshness SLA≤ 5 минут от события до видимости

Стабильное измерение, не append-only факт: последняя версия перезаписывает предыдущую. Онлайн-чтение (в т.ч. lookup при обогащении факта) — дубль-устойчивое, без FINAL: актуальная строка берётся argMax(col, UpsertedAt) c GROUP BY PropertyObjectId.

Columns

ColumnTypeNullableSource (поле PropertyObjectEntity / payload property-object-upserted)Partition/OrderPII
PropertyObjectIdUUIDнетIdorder (единственный ключ)нет
BuildingIdUUIDнетBuildingId — родительnoneнет
LayoutIdNullable(UUID)даLayoutId (может отсутствовать)noneнет
LayoutGroupIdNullable(UUID)даLayoutGroupId (денормализация из планировки в payload)noneнет
ProjectIdUUIDнетProjectId — вверх по иерархииnoneнет
DeveloperIdUUIDнетTenantId (= DeveloperId проекта) — вершина иерархииnoneнет
StatusLowCardinality(String)нетStatusFree / Reserved / Booked / Sold / Hiddennoneнет
NumberStringнетNumber («квартира 145», «номер 12-А»)noneнет
FloorInt16нетFloor (ось Y шахматки)noneнет
RoomsInt16нетRooms (0 = студия) — атрибут разбивкиnoneнет
PublishedAtNullable(DateTime)даCreatedAt объекта недвижимости из payload property-object-upserted — момент публикации объекта; иммутабелен, переживает upsert-ыnoneнет
SourceVersionInt64нетVersion источника — монотонный guardnoneнет
UpsertedAtDateTime64(3)нетвремя события (CreatedAt)none (версия движка)нет

Минимальный набор: вся иерархия вверх (для обогащения) + атрибуты фильтрации/подписи объекта. Цена/площади/ виды из окон в dev-stats не нужны — не реплицируются. Status меняется событием property-object-upserted и не обязан быть свежим до секунды: аналитика внимания к объекту не зависит от live-статуса продажи. PublishedAt (иммутабельный момент публикации объекта недвижимости) нужен метрике time-to-first-view — время от публикации до первого просмотра; так как поле неизменно, ReplacingMergeTree-upsert его не портит (каждый snapshot несёт то же значение).

Инварианты

  • INV-DIM-PO-1 (единственность): одна логическая строка на PropertyObjectId (единственный компонент ORDER BY); merge оставляет строку с максимальным UpsertedAt.
  • INV-DIM-PO-2 (полная иерархия вверх): каждая строка несёт весь путь DeveloperId → ProjectId → BuildingId → LayoutGroupId? → LayoutId? (DeveloperId = TenantId); LayoutGroupId денормализован из планировки в payload, поэтому вторичный резолв через dim_layout не нужен — это инвариант самодостаточности обогащения.
  • INV-DIM-PO-3 (не удаляется): строка живёт, пока существует объект; Status = Sold/Hidden не удаляет строку, иначе 3-летние факты просмотров проданных/скрытых объектов перестали бы резолвиться в иерархию.
  • INV-DIM-PO-4 (precedence): актуальность — по UpsertedAt (max побеждает); SourceVersion монотонна per-key, служит анти-out-of-order-диагностикой.
  • INV-DIM-PO-5 (PublishedAt иммутабелен): момент публикации неизменен — каждый snapshot несёт то же значение, ReplacingMergeTree-upsert его не портит (вход метрики time-to-first-view).

Кросс-сервисный контракт

  • Ожидает от chessboard (property-object-upserted): PropertyObjectId, BuildingId, LayoutId?, LayoutGroupId? (денормализован из планировки), ProjectId, TenantId (= DeveloperId), Status, Number, Floor, Rooms, CreatedAt (→ PublishedAt), Version; key = PropertyObjectId (6 партиций, порядок per-object), at-least-once, атомарная публикация снапшота, additive-only для minor.
  • Предоставляет downstreamкритический контракт обогащения (в отличие от прочих dim_*): это единственный источник, из которого консюмер факта property-object-view-ended резолвит всю иерархию по одному PropertyObjectId перед вставкой факта (событие просмотра иерархию не несёт). Гарантия: после upsert весь путь вверх доступен из одной строки; lookup — argMax(…, UpsertedAt) GROUP BY PropertyObjectId без FINAL; свежесть ≤ 5 мин. Плюс подпись/фильтр по уровню PropertyObject (Number/Status/Floor/Rooms).
  • При отсутствии/устаревании: строки ещё нет, а факт просмотра уже пришёл (гонка «факт раньше property-object-upserted») → консюмер факта паркует сообщение (deferred re-enrich, при устойчивом промахе → DLQ + алерт «dimension gap»), просмотр не теряется. Поэтому измерение обязано наполняться до включения ingest фактов (backfill при вводе). Устаревшая доставка → берётся max(UpsertedAt).

Deduplication

  • Dedup key: PropertyObjectId (единственный компонент ORDER BY); ReplacingMergeTree(UpsertedAt) при merge оставляет строку с максимальным UpsertedAt.
  • Replay behavior: идемпотентный upsert; повтор топика логических дублей не даёт. До merge lookup при обогащении факта дубль-устойчив: argMax(DeveloperId, UpsertedAt), argMax(ProjectId, UpsertedAt), … FROM dim_property_object WHERE PropertyObjectId = ? GROUP BY PropertyObjectIdFINAL в online запрещён (правило SDK).
  • Version guard: событие применяется только при event.Version > SourceVersion; иначе идемпотентный пропуск.
  • Late-dimension / отсутствие строки: если факт просмотра пришёл раньше property-object-upserted (гонка), консюмер факта не находит dim_property_object — стратегия обработки (parking/DLQ/deferred re-enrich) описана в property_object_view_facts; измерение лишь гарантирует upsert по приходу события.

Partition И TTL

  • Partition key: нет — измерение адресуется точечно по PropertyObjectId, шардировать нечего (ADR-0056).
  • Order key: (PropertyObjectId) — lookup при обогащении и фильтр WHERE DeveloperId = актор AND … AND PropertyObjectId = ….
  • TTL/retention: нет — измерение живёт, пока существует объект недвижимости; Status = Sold/Hidden не удаляет строку, иначе 3-летние факты просмотров проданных/скрытых объектов недвижимости перестали бы резолвиться в иерархию.

Consumers И Backfill

ProcessНазначениеCheckpoint
Приём pin.chessboard.property-object-upserted.v1 (фоновый ingest)Проверка версии, маппинг snapshot (иерархия + атрибуты) → строка, batch-upsert (≤ 1000 / 5 с)Kafka consumer offset (commit после вставки батча)
Backfill измеренийПовтор топика property-object-upserted при вводе сервиса — обязателен до включения ingest фактов, иначе обогащение не найдёт иерархию (идемпотентно)Kafka offset / диапазон PropertyObjectId

Security

  • Raw PII не хранится (объект недвижимости — объект недвижимости, участники сделок — контекст crm).
  • Доступ — роль застройщика, permission dev-stats.view, scope строго = свой DeveloperId; каждый запрос API фильтрует DeveloperId = актор. Обогащение фактов также клеймит DeveloperId из dim_property_object, что замыкает tenant-scope на источнике данных.
  • ClickHouse не operational source of truth: источник объекта недвижимости (статус/цена/паспорт) — chessboard.

Acceptance criteria

  • Happy upsert. Given property-object-upserted с непустыми PropertyObjectId/BuildingId/ProjectId/ TenantId, When обработан батч, Then в dim_property_object есть строка с полным путём вверх и атрибутами Number/Status/Floor/Rooms/PublishedAt; чтение argMax(…, UpsertedAt) GROUP BY PropertyObjectId (без FINAL) отдаёт значения.
  • Обогащение факта. Given строка есть, When консюмер факта резолвит PropertyObjectId, Then получает DeveloperId/ProjectId/BuildingId/LayoutGroupId/LayoutId из одной строки (JOIN-free), без резолва через dim_layout.
  • Late-dimension. Given строки ещё нет, а факт пришёл раньше, When консюмер факта не нашёл иерархию, Then сообщение факта паркуется (deferred re-enrich); после прихода property-object-upserted и replay факт обогащается; просмотр не теряется, dev_stats_dim_property_object_miss_total растёт.
  • Идемпотентный повтор / устаревшая доставка. Given то же/старое событие доставлено повторно, When обработано, Then после merge логически одна строка; побеждает max(UpsertedAt).
  • Sold/Hidden не удаляют. Given объект в Sold/Hidden, When событие обработано, Then строка остаётся, 3-летние факты продолжают резолвить иерархию.
  • PublishedAt иммутабелен. Given повторный upsert объекта, When обработан, Then PublishedAt не меняется, метрика time-to-first-view стабильна.
  • Scope. Given запрос застройщика, When фильтруется dim_property_object, Then только строки DeveloperId = актор; обогащение факта клеймит тот же DeveloperId (замыкание tenant-scope на источнике).

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

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

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