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

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)
ConsumerDevStatsPropertyObjectUpsertedConsumer
Обрабатываемое событиеproperty-object-upserted
Topicpin.chessboard.property-object-upserted.v1 (6 партиций, key = PropertyObjectId)
ContractVersion1; при несовпадении — warn-and-proceed
Consumer groupdev-stats-dim-property-object
Producerchessboard (Developers API управления объектами недвижимости, синк Talent)
Эффект (событие → что меняется)upsert строки в pin_dev_stats.dim_property_object (ReplacingMergeTree)

Контекст события

  • Из метаданных события доступны идентификатор источника (AggregateId = PropertyObjectId), тип агрегата (PropertyObject), время события (CreatedAtUpsertedAt, версия 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ТипОбязательностьКак используется (маппинг)
PropertyObjectIdGuidДаPropertyObjectId (UUID, ключ ORDER BY, ключ обогащения); Guid.Empty → poison (DLQ)
BuildingIdGuidДаBuildingId (родитель); Guid.Empty → poison
LayoutIdGuid?НетLayoutId (Nullable(UUID))
LayoutGroupIdGuid?НетLayoutGroupId (Nullable(UUID), денормализация из планировки в payload)
ProjectIdGuidДаProjectId (вверх по иерархии); Guid.Empty → poison
TenantIdGuidДаDeveloperId (вершина иерархии, ADR-0052); Guid.Empty → poison
Statusstring (enum PropertyObjectStatus)ДаStatus (LowCardinality): Free/Reserved/Booked/Sold/Hidden; unknown → сохранить как есть + warning + метрика (на чтении — как Hidden)
NumberstringДаNumber («квартира 145», подпись объекта)
FloorshortДаFloor (Int16, ось Y шахматки)
RoomsshortДаRooms (Int16, 0 = студия; атрибут разбивки)
VersionlongДа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 мин). Объекты недвижимости — самый массовый агрегат (сотни тысяч), поэтому батчевость критична для пропускной способности обогащения.

  1. Проверить контекст события: сверить ContractVersion (= 1; при несовпадении — warning-лог и продолжить). Дубль на входе не отсеивается (dedup декларативен).
  2. Проверить payload: ошибка десериализации / PropertyObjectId / BuildingId / ProjectId / TenantId == Guid.Empty → DLQ (poison) + error-лог. Проверки существования родителей нет: вся иерархия приходит в snapshot-е объекта недвижимости (денормализованный LayoutGroupId в payload), измерение самодостаточно.
  3. Маппинг поле-в-поле: построить строку dim_property_object со всей иерархией вверх (DeveloperId = TenantId), UpsertedAt = время события, SourceVersion = Version. Добавить в буфер; внутрибатчевая свёртка по PropertyObjectId (max UpsertedAt).
  4. Пакетно вставить (upsert) буфер строк в pin_dev_stats.dim_property_object. Upsert обеспечивает ReplacingMergeTree(UpsertedAt) (merge → max UpsertedAt per PropertyObjectId); точечное обновление/удаление не применяются (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; lookup argMax(…, 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

СбойRetryDLQРучное действие
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 = актор).

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

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

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