Consumer: DevStatsPropertyObjectUpsertedConsumer
Ingest-консюмер ключевого измерения обогащения dev-stats: принимает паспортный snapshot объекта недвижимости из
chessboard и upsert-ит строку в ClickHouse-измерение dim_property_object
(ReplacingMergeTree, pin_dev_stats). Именно из dim_property_object консюмер факта
property_object_view_facts резолвит всю иерархию DeveloperId → ProjectId → BuildingId → LayoutGroupId → LayoutId по одному PropertyObjectId (событие просмотра несёт только PropertyObjectId), поэтому это измерение
обязано наполняться до включения ingest фактов (ADR-0057). Дом payload —
event-contract: property-object-upserted; дом колонок —
dimensions/property-object.md.
Назначение
Consumer DevStatsPropertyObjectUpsertedConsumer слушает событие property-object-upserted (топик
pin.chessboard.property-object-upserted.v1) и пишет срез объекта недвижимости — всю иерархию вверх (BuildingId/LayoutId/
LayoutGroupId/ProjectId/DeveloperId) + атрибуты фильтрации/подписи (Status/Number/Floor/Rooms)
- версия — в
dim_property_object. Событиеproperty-object-upsertedуже несёт денормализованныйLayoutGroupId(через привязанную планировку), поэтому вся иерархия доступна из одной строкиdim_property_object— вторичный резолв черезdim_layoutне требуется. Owned-состояния в PostgreSQL нет; цена/площади (Price/TotalArea/LivingArea),PricePerMeter,Currency,Section,WindowViews,Capacityне реплицируются.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | dev-stats (проект DevStats.Background) |
| Consumer | DevStatsPropertyObjectUpsertedConsumer |
| Обрабатываемое событие | property-object-upserted |
| Topic | pin.chessboard.property-object-upserted.v1 (6 партиций, key = PropertyObjectId) |
| ContractVersion | 1; при несовпадении — warn-and-proceed |
| Consumer group | dev-stats-dim-property-object |
| Producer | chessboard (Developers API управления объектами недвижимости, синк Talent) |
| Эффект (событие → что меняется) | upsert строки в pin_dev_stats.dim_property_object (ReplacingMergeTree) |
Контекст события
- Из метаданных события доступны идентификатор источника (
AggregateId = PropertyObjectId), тип агрегата (PropertyObject), время события (CreatedAt→UpsertedAt, версияReplacingMergeTree(UpsertedAt)), версия snapshot-а (Version). - Сквозной трейс восстанавливается из метаданных события.
- Идемпотентность — декларативная, на стороне ClickHouse: dedup-ключ =
PropertyObjectId(единственный компонентORDER BY), версионирование —ReplacingMergeTree(UpsertedAt). Порядок per-property-object гарантирован ключом сообщения (key = PropertyObjectId, 6 партиций); отдельная таблица идемпотентности не используется (PG-транзакции нет).
Входные данные / payload события
Ссылка на контракт: event-contract: property-object-upserted. Дом и типы колонок — dimensions/property-object.md, раздел «Columns»; ниже — поля, которые консюмер реально читает, и маппинг.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
PropertyObjectId | Guid | Да | → PropertyObjectId (UUID, ключ ORDER BY, ключ обогащения); Guid.Empty → poison (DLQ) |
BuildingId | Guid | Да | → BuildingId (родитель); Guid.Empty → poison |
LayoutId | Guid? | Нет | → LayoutId (Nullable(UUID)) |
LayoutGroupId | Guid? | Нет | → LayoutGroupId (Nullable(UUID), денормализация из планировки в payload) |
ProjectId | Guid | Да | → ProjectId (вверх по иерархии); Guid.Empty → poison |
TenantId | Guid | Да | → DeveloperId (вершина иерархии, ADR-0052); Guid.Empty → poison |
Status | string (enum PropertyObjectStatus) | Да | → Status (LowCardinality): Free/Reserved/Booked/Sold/Hidden; unknown → сохранить как есть + warning + метрика (на чтении — как Hidden) |
Number | string | Да | → Number («квартира 145», подпись объекта) |
Floor | short | Да | → Floor (Int16, ось Y шахматки) |
Rooms | short | Да | → Rooms (Int16, 0 = студия; атрибут разбивки) |
Version | long | Да | → SourceVersion (guard/диагностика) |
Section,TotalArea,LivingArea,Price,Currency,PricePerMeter,WindowViews,Capacity | — | — | не реплицируются |
Колонки, заполняемые консюмером: UpsertedAt = время события.
Кросс-сервисный контракт
Ожидает от chessboard (входной контракт property-object-upserted):
- Поля snapshot-а:
PropertyObjectId,BuildingId,LayoutId?,LayoutGroupId?,ProjectId,TenantId,Status,Number,Floor,Rooms,Version(перечень и типы — таблица payload выше). - Денормализованная иерархия в самом событии:
LayoutGroupIdприходит из привязанной планировки в payload, поэтому строкаdim_property_objectсодержит весь путь вверх без вторичного резолва черезdim_layout. - Гарантии продюсера:
key = PropertyObjectId(6 партиций) → порядок per-object;Versionмонотонна per-object; событие публикуется атомарно со снапшотом; доставка at-least-once; совместимость additive-only для minor. Порядок между агрегатами (объект vs его проект/здание) не гарантирован.
Предоставляет downstream (в пределах dev-stats, без публикации событий):
- Критический контракт обогащения: строка
dim_property_object— единственный источник, из которого консюмер фактаproperty-object-view-endedрезолвитDeveloperId → ProjectId → BuildingId → LayoutGroupId → LayoutIdпо одномуPropertyObjectId(событие просмотра иерархию не несёт). Гарантия: после upsert иерархия доступна из одной строки, lookup —argMax(…, UpsertedAt) GROUP BY PropertyObjectIdбезFINAL. Ключ обогащения —PropertyObjectId; окно свежести ≤ 5 мин. - Атрибуты фильтрации/подписи объекта (
Number/Status/Floor/Rooms) для чтения метрик по уровнюPropertyObject.
Поведение при отсутствии/устаревании строки:
- Строки ещё нет, а факт просмотра уже пришёл (гонка «факт раньше
property-object-upserted») → консюмер факта не находит иерархию и паркует сообщение (deferred re-enrich, при устойчивом промахе → DLQ + алерт «dimension gap»); просмотр не теряется (см. property-object-view-facts.md). Поэтому это измерение обязано наполняться до включения ingest фактов (backfill при вводе). - Устаревшая доставка → precedence по
UpsertedAt(побеждает максимальный);SourceVersion— диагностика.
Алгоритм
Консюмер батчевый: шаги 1–3 — на сообщение, шаг 4 — на батч (порог ≤ 1000 строк или 5 секунд; freshness SLA ≤ 5 мин). Объекты недвижимости — самый массовый агрегат (сотни тысяч), поэтому батчевость критична для пропускной способности обогащения.
- Проверить контекст события: сверить
ContractVersion(=1; при несовпадении — warning-лог и продолжить). Дубль на входе не отсеивается (dedup декларативен). - Проверить payload: ошибка десериализации /
PropertyObjectId/BuildingId/ProjectId/TenantId == Guid.Empty→ DLQ (poison) + error-лог. Проверки существования родителей нет: вся иерархия приходит в snapshot-е объекта недвижимости (денормализованныйLayoutGroupIdв payload), измерение самодостаточно. - Маппинг поле-в-поле: построить строку
dim_property_objectсо всей иерархией вверх (DeveloperId = TenantId),UpsertedAt= время события,SourceVersion = Version. Добавить в буфер; внутрибатчевая свёртка поPropertyObjectId(maxUpsertedAt). - Пакетно вставить (upsert) буфер строк в
pin_dev_stats.dim_property_object. Upsert обеспечиваетReplacingMergeTree(UpsertedAt)(merge → maxUpsertedAtperPropertyObjectId); точечное обновление/удаление не применяются (Status = Sold/Hidden— обычная строка, иначе 3-летние факты проданных объектов недвижимости перестали бы резолвиться в иерархию). Смещение (offset) фиксируется строго после подтверждённой вставки; при ошибке — retry всего батча.
Диаграмма
Предусловия и постусловия
Предусловия (до обработки батча): таблица dim_property_object создана миграцией; consumer group
dev-stats-dim-property-object зарегистрирована; при вводе сервиса backfill измерения запущен до включения
ingest фактов (иначе просмотры паркуются на late-dimension). Существование строк родителей (dim_building/
dim_project) для обработки не требуется — иерархия целиком в snapshot-е объекта.
Постусловие (после успешного батча): для каждого валидного PropertyObjectId в dim_property_object есть
строка со всей иерархией вверх (DeveloperId = TenantId), атрибутами объекта, UpsertedAt = время события,
SourceVersion = Version; merge оставляет max(UpsertedAt) на PropertyObjectId; offset сдвинут. Строка
пригодна как ключ обогащения для консюмера факта. Poison ушли в DLQ; PostgreSQL/события не затронуты.
Acceptance criteria
- Happy upsert. Given валидное
property-object-upserted(непустыеPropertyObjectId/BuildingId/ProjectId/TenantId), When консюмер обработал батч, Then вdim_property_objectесть строка с полной иерархией вверх и атрибутамиNumber/Status/Floor/Rooms; lookupargMax(…, UpsertedAt) GROUP BY PropertyObjectId(безFINAL) отдаёт эти значения. - Обогащение факта. Given строка измерения есть, When консюмер факта просмотра резолвит
PropertyObjectId, Then получаетDeveloperId/ProjectId/BuildingId/LayoutGroupId/LayoutIdиз одной строки (JOIN-free). - Идемпотентный повтор / устаревшая доставка. Given то же/старое событие доставлено повторно, When обработано,
Then после merge логически одна строка; побеждает максимальный
UpsertedAt(старый снапшот не понижает). - Late-dimension (гонка). Given факт просмотра пришёл раньше строки измерения, When консюмер факта не нашёл
иерархию, Then сообщение факта паркуется (deferred re-enrich), а по приходу
property-object-upsertedстрока появляется и повторный проход обогащает факт; просмотр не теряется. - Unknown enum. Given
Statusвне множестваFree/Reserved/Booked/Sold/Hidden, When обработан, Then значение сохранено как есть + warning + метрика; на чтении применяется какHidden. - Poison. Given
PropertyObjectId/BuildingId/ProjectId/TenantId == Guid.Emptyили ошибка десериализации, When обработан, Then DLQ + error-лог, батч продолжается. - Sold/Hidden не удаляются. Given объект перешёл в
Sold/Hidden, When событие обработано, Then строка остаётся обычным снапшотом (не удаляется), иначе 3-летние факты проданных объектов перестали бы резолвиться в иерархию.
Идемпотентность и replay
- Business key:
PropertyObjectId; версия-precedence —UpsertedAt(ReplacingMergeTree). Дубль перезаписывается идемпотентно; до merge lookup при обогащении факта дубль-устойчив —argMax(DeveloperId, UpsertedAt), argMax(ProjectId, UpsertedAt), … FROM dim_property_object WHERE PropertyObjectId = ? GROUP BY PropertyObjectId(FINALзапрещён). - Version guard (dimensions/property-object.md) — декларативный: per-key-ordering +
ReplacingMergeTree(UpsertedAt)+ внутрибатчевая свёртка; version-lookup нет (ADR-0056). - Replay/backfill: да и обязателен до включения ingest фактов — иначе обогащение просмотров не найдёт
иерархию (гонка «факт раньше
dim_property_object» описана в facts/property-object-view-facts.md: parking/DLQ/deferred re-enrich). Повтор топикаproperty-object-upsertedгруппойdev-stats-dim-property-objectбезопасен; первичное наполнение — background/dimension-backfill.md.
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| ClickHouse недоступен / timeout вставки | Да: retry батча, backoff (1с → 60с), offset не commit-ится | Нет (инфраструктура) | Алерт ingest-lag > 15 мин; восстановить CH |
| Ошибка десериализации payload | Нет | Да (poison) | Разбор из DLQ; issue producer-у chessboard |
PropertyObjectId/BuildingId/ProjectId/TenantId == Guid.Empty (шаг 2) | Нет | Да (poison) + error-лог | Проверить контракт; replay из DLQ после фикса |
Версия события ≠ ContractVersion | Не сбой: warning-лог | Нет | Сверить миграцию контракта |
| Сбой между вставкой и commit offset | Повторная доставка (at-least-once) | Нет | Не требуется — dedup движком |
Наблюдаемость и безопасность
- Logs: consumed, skipped-poison (
PropertyObjectId), flushed (строк, длительность), failed (CH), unknown-enum (Status). - Metrics:
dev_stats_dim_property_object_upserted_total,dev_stats_dim_ingest_lag_seconds,dev_stats_dim_ingest_batch_size,chessboard_sync_unknown_enum_total, retry/DLQ. - Traces: consume span + insert span; корреляция из метаданных события.
- PII/masking: PII отсутствует (объект недвижимости — объект недвижимости; участники сделок — контекст
crm). Tenant:DeveloperId=TenantId(ADR-0052); обогащение факта клеймитDeveloperIdизdim_property_object, что замыкает tenant-scope на источнике данных; scope-фильтрация чтения — на API (DeveloperId = актор).
Обратные ссылки
Автоссылки: где используется этот документ.
- События (2): События dev-stats, Event Contract: pin.chessboard.property-object-upserted (PropertyObjectUpsertedKafkaEvent)
- Консюмеры (6): Consumer: DevStatsBuildingUpsertedConsumer, Consumer: DevStatsDeveloperUpsertedConsumer, Consumer: DevStatsLayoutGroupUpsertedConsumer, Consumer: DevStatsLayoutUpsertedConsumer, Consumer: DevStatsProjectUpsertedConsumer, Consumer: DevStatsPropertyObjectViewEndedConsumer
- Фоновые задачи (1): Background Job: DimensionBackfillJob
- index.md (1): DevStats Service (Pin.DevStats)
Связанные документы
- Дом колонок и dedup: dimensions/property-object.md; контракт события: chessboard/events/property-object-upserted.md.
- Потребитель обогащения: facts/property-object-view-facts.md (резолв иерархии просмотра
из
dim_property_object). - Соседние ingest-консюмеры: developer-upserted.md, project-upserted.md, building-upserted.md, layout-group-upserted.md, layout-upserted.md.
- Обзор: ../README.md; backfill: ../background/dimension-backfill.md.
- ADR-0052, ADR-0056, ADR-0057.