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

PropertyObjectPriceHistoryFact (ClickHouse)

Append-only факт «изменение цены объекта недвижимости». Питает график роста цены на странице /lots/[id]/analytics (серия price-per-meter до текущего месяца), API истории цен (грануляция day/month/year + growthPercent) и портфель инвестора (динамику читает контекст investor-owner через public API).

Назначение

PropertyObjectPriceHistoryFact хранит каждое изменение цены объекта недвижимости, полученное из контекста chessboard (событие изменения цены) при обновлении объекта. Бизнес-правило: цены не удаляются и не редактируются — только append-only вставка; хранилище — ClickHouse. Операционный источник истины текущей цены — контекст chessboard; этот факт — аналитическая лента изменений, ClickHouse не является операционным источником истины.

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

ПолеЗначение
Контекстinvest-analytics
KindClickHouse fact (append-only)
Source of truthтекущая цена — chessboard; лента изменений — этот факт
ХранилищеClickHouse, database pin_analytics, table property_object_price_history_facts
Tenant isolationTenantId = DeveloperId (CompanyId застройщика, ADR-0052); чтение — платформенное (цены публичны в каталоге)

Технические колонки факта: FactId (dedup), SourceEventId (трассировка), TenantId, BrandId? (в PIN не используется — всегда null), EventTime, IngestionTime.

Table

ПолеЗначение
Databasepin_analytics
Tableproperty_object_price_history_facts
EngineReplacingMergeTree (dedup по ключу сортировки)
Owner serviceinvest-analytics (фоновый консюмер пишет, API читает)
Source eventspin.chessboard.property-object-price-changed.v1 (+ разовый backfill из Talent Chessboard API)
Transform version1 (колонка TransformVersion)
Freshness SLA≤ 5 минут от события до видимости в API (batch ingestion консюмера, батч ≤ 1000 строк или 5 секунд)

Columns

ColumnTypeNullableSourcePartition/OrderPII
FactIdStringнет= SourceEventIdorder (последний компонент)нет
SourceEventIdStringнетидентификатор входного события; для backfill — backfill:{PropertyObjectId}:{ChangedAt:O}noneнет
TenantIdStringнетDeveloperId из payload события (Guid как строка)noneнет
BrandIdNullable(String)дане используется в PIN, всегда NULLnoneнет
EventTimeDateTimeнет= ChangedAt (бизнес-время изменения цены)PARTITION BY toYYYYMM(EventTime), order (предпоследний компонент)нет
IngestionTimeDateTimeнетnow() в момент вставки консюмеромnoneнет
PropertyObjectIdUUIDнетpayload события (property_object_id)order (1-й компонент)нет
DeveloperIdUUIDнетpayload события (снапшот измерений в событии, реплика измерений не нужна)noneнет
ProjectIdUUIDнетpayload событияnoneнет
BuildingIdInt64нетpayload событияnoneнет
LayoutGroupIdInt64нетpayload события; связывает с property_object_type_config.GroupIdnoneнет
LayoutIdInt64нетpayload событияnoneнет
OldPriceNullable(Decimal(18,2))даpayload события; NULL для первой зафиксированной цены объекта недвижимости и backfill-строк без предысторииnoneнет
NewPriceDecimal(18,2)нетpayload события; полная цена объекта недвижимости, рубnoneнет
OldPricePerMeterNullable(Decimal(12,2))даpayload события; NULL аналогично OldPricenoneнет
NewPricePerMeterDecimal(12,2)нетpayload события; руб/м²; если в событии нет — консюмер вычисляет NewPrice / AreaTotal (округление до 2 знаков)noneнет
AreaTotalDecimal(10,2)нетpayload события; общая площадь объекта недвижимости, м² (снапшот на момент изменения)noneнет
CurrencyIdInt16нетpayload события (CurrencyId — типизированная ссылка справочника валют handbook); RUB-only (ADR-0048), задел под будущую мультивалютность через справочникnoneнет
CurrencyIsoCodeLowCardinality(String)нетCurrencySnapshot.IsoCode из payload (денорм ISO-4217-кода для display; RUB)noneнет
ChangeSourceLowCardinality(String)нетконсюмер: talent-sync (событие chessboard) / backfill (первичная загрузка)noneнет
TransformVersionUInt16нетконстанта версии маппинга консюмера (сейчас 1)noneнет

Персональных данных в факте нет (PII — «нет» во всех колонках): факт описывает объект, не пользователя.

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

Ожидания от источника (chessboard, событие pin.chessboard.property-object-price-changed.v1): измерения объекта недвижимости приходят снапшотом в payload (PropertyObjectId, DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, AreaTotal, NewPrice, CurrencyId + CurrencySnapshot, ChangedAt) — отдельная реплика измерений в invest-analytics не нужна. Валюта — типизированная ссылка CurrencyId: short + CurrencySnapshot ({ CurrencyId, IsoCode, Name, Sign? }); строкового кода валюты payload отдельным полем не несёт (ISO-код живёт только внутри snapshot). Гарантии источника: at-least-once доставка, ключ партиционирования по объекту недвижимости (порядок изменений одного объекта сохранён). Допустимые пропуски: OldPrice/OldPricePerMeter могут отсутствовать (первая зафиксированная цена); NewPricePerMeter может отсутствовать — консюмер вычисляет NewPrice / AreaTotal. Случай, когда NewPricePerMeter отсутствует и AreaTotal <= 0 одновременно (цену м² не вычислить), обрабатывается на стороне консюмера (../consumers/chessboard-price-changed.md).

Гарантии потребителям (история цен через public API, investor-owner): чтение дубль-устойчиво (argMax(..., IngestionTime), FINAL в online-запросах запрещён); ряд всегда отсортирован по EventTime; freshness от события до видимости ≤ 5 мин. Устаревание измерений исключено: значения — снапшот на момент изменения, поздние правки измерений в chessboard не переписывают исторические факты (append-only, цены не редактируются).

Deduplication

  • Dedup key: FactId = SourceEventId — последний компонент ORDER BY; повторная доставка события (at-least-once) даёт строку с идентичным ключом сортировки, ReplacingMergeTree схлопывает дубликат при merge.
  • Replay behavior: идемпотентная вставка — переигрывание топика/повтор батча не порождает дублей после merge; читающие запросы дубль-устойчивы (агрегации argMax(NewPricePerMeter, IngestionTime) по периоду), FINAL в online-запросах запрещён.
  • Late event behavior: факт вставляется в партицию своего EventTime независимо от задержки; порядок внутри объекта недвижимости восстанавливается ключом сортировки (PropertyObjectId, EventTime, FactId), API всегда сортирует по EventTime.

Partition И TTL

  • Partition key: toYYYYMM(EventTime) — график и API режут диапазон дат партициями.
  • Order key: (PropertyObjectId, EventTime, FactId) — все запросы фильтруют по конкретному объекту недвижимости (график карточки), затем по диапазону дат; FactId в хвосте — только для dedup.
  • TTL/retention: TTL отсутствует — история хранится вечно. Обоснование: (1) график роста цены и метрика growthPercent считаются за весь жизненный цикл объекта; (2) портфель инвестора (investor-owner) показывает динамику от даты покупки без ограничения давности; (3) объём тривиален для ClickHouse даже на верхней границе ADR-0056: ~10⁶ объектов недвижимости × ~1–4 изменения цены в месяц × 10 лет ≈ 10⁸–5×10⁸ строк узкой таблицы (< 50 ГБ со сжатием). Пересмотр retention — только через ADR.
  • Rollup-ы/materialized views не создаются (simplicity-first, ADR-0056): грануляция day/month/year считается прямыми запросами GROUP BY toStartOfDay/Month/Year(EventTime) по одному объекту недвижимости — объём на объект недвижимости мал (сотни–тысячи строк). Отличие от dev-stats (ADR-0057), где rollup-ы обязательны из-за сканов по иерархии застройщика.

Consumers И Backfill

ProcessНазначениеCheckpoint
Фоновый консюмер chessboard-price-changed (../consumers/chessboard-price-changed.md)Приём pin.chessboard.property-object-price-changed.v1, маппинг payload → колонки, batch-вставка (≤ 1000 строк / 5 сек)offset входного топика (подтверждается после успешной вставки батча)
Backfill-job первичной загрузки (../jobs/price-history-backfill.md)Разовая загрузка текущих цен всех объектов недвижимости из Talent Chessboard API при вводе сервиса (ChangeSource = backfill, OldPrice = NULL)Диапазон обработанных PropertyObjectId в таблице состояния job-а (PostgreSQL)

Security

  • Raw PII не хранится (факт не содержит данных пользователей).
  • Чтение — только через public API invest-analytics (permission invest-analytics.price-history.view; гость по реферальной ссылке — readOnly-режим страницы аналитики); прямой доступ к ClickHouse у фронта/ других сервисов отсутствует.
  • Export permissions проверяются в API layer (PDF-отчёт — ../README.md, отложено).
  • ClickHouse не используется как операционный источник истины: текущая цена объекта недвижимости — chessboard.

Наблюдаемость

  • Logs: консюмер логирует размер батча, offset-диапазон (dedup — на стороне CH, число дублей не пишется).
  • Metrics: invest_analytics_price_facts_ingested_total, invest_analytics_price_ingest_lag_seconds (EventTime → IngestionTime), invest_analytics_price_ingest_batch_size.
  • Traces: SourceEventId = корреляция со входным событием; сквозная корреляция в API-запросах чтения.
  • Отказ консюмера — алерт по росту lag; runbook — переигрывание с offset (вставка идемпотентна).

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

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

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

  • Событие-источник: docs/06-services/chessboard/events/property-object-price-changed.md (контекст chessboard).
  • Потребление серии в расчёте доходности: investor-series-models.md.
  • ADR-0056 (границы применения ClickHouse), ADR-0057 (паттерн facts; здесь rollup-ы не нужны).