Consumer: ChessboardPropertyObjectPriceChangedConsumer
Назначение
Единственный пишущий процесс ленты истории цен invest-analytics. Слушает событие изменения цены объекта
недвижимости PropertyObjectPriceChangedKafkaEvent из контекста chessboard и добавляет (никогда не
обновляет и не удаляет) append-only факт PropertyObjectPriceHistoryFact
в ленту ClickHouse pin_analytics.property_object_price_history_facts батчами. Из фактов public API строит
график роста цены (day/month/year + growthPercent,
../api/public/get-property-object-price-history.md), а
расчёт серий доходности берёт историческую часть серии price-per-meter
(../domain/investor-series-models.md). Owned-состояние в PostgreSQL
consumer не меняет: снапшот измерений (Developer/Project/Building/LayoutGroup/Layout/AreaTotal) приходит в
payload события — реплика измерений не заводится.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | invest-analytics |
| Consumer | ChessboardPropertyObjectPriceChangedConsumer |
| Обрабатываемое событие | PropertyObjectPriceChangedKafkaEvent |
| Topic | pin.chessboard.property-object-price-changed.v1 |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed |
| Consumer group | invest-analytics-price-history |
| Producer | chessboard (агрегат PropertyObject, каждое изменение Price, в т.ч. синком из Talent — docs/06-services/chessboard/domain/property-object.md) |
| Эффект (событие → что меняется) | добавляется строка факта цены в ленту ClickHouse pin_analytics.property_object_price_history_facts (append-only); PG-состояние не меняется |
Контекст события
- Из метаданных события доступны:
TenantId,AggregateId(=PropertyObjectId),AggregateType(=PropertyObject), время события (CreatedAt); идентификатор сообщения (EventId) прописывается в факт какSourceEventId(сквозная трассировка событие → строка ленты). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Бизнес-идемпотентность — декларативная, на стороне ленты ClickHouse:
FactId = SourceEventId— последний компонент ключа сортировкиReplacingMergeTree, повторная доставка схлопывается при merge, а читающие запросы дубль-устойчивы (argMax(..., IngestionTime);FINALзапрещён). Отдельная таблица идемпотентности не ведётся: у консюмера нет PG-транзакции, а отметка в PG отдельно от вставки в ленту была бы вторым несогласованным хранилищем вместо одного встроенного механизма dedup.
Входные данные / payload события
Контракт входного события — docs/06-services/chessboard/events/property-object-price-changed.md (будущий
путь, владелец — chessboard). Ниже — поля payload, которые consumer реально читает, и маппинг в колонки
факта. Дом полей и типы колонок — ../domain/price-history-fact.md, раздел
«Columns».
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
PropertyObjectId | Guid | Да | → колонка PropertyObjectId (UUID, 1-й компонент ORDER BY); Guid.Empty → poison (DLQ) |
DeveloperId | Guid | Да | → колонки DeveloperId (UUID) и TenantId (Guid как строка, ADR-0052: tenant = компания застройщика) |
ProjectId | Guid | Да | → колонка ProjectId |
BuildingId | long | Да | → колонка BuildingId (Int64) |
LayoutGroupId | long | Да | → колонка LayoutGroupId; связывает факт с property_object_type_config.GroupId |
LayoutId | long | Да | → колонка LayoutId |
AreaTotal | decimal | Да | → колонка AreaTotal (Decimal(10,2)); знаменатель fallback-вычисления цены за метр; <= 0 → poison (DLQ) |
OldPrice | decimal? | Нет | → колонка OldPrice; null для первой зафиксированной цены объекта недвижимости |
NewPrice | decimal | Да | → колонка NewPrice (Decimal(18,2), полная цена объекта недвижимости, руб); < 0 → poison (DLQ) |
OldPricePerMeter | decimal? | Нет | → колонка OldPricePerMeter; если отсутствует, а OldPrice задан — вычисляется round(OldPrice / AreaTotal, 2) (симметрично NewPricePerMeter) |
NewPricePerMeter | decimal? | Нет | → колонка NewPricePerMeter; если отсутствует — вычисляется round(NewPrice / AreaTotal, 2) |
CurrencyId | short | Да | → колонка CurrencyId (Int16, типизированная ссылка справочника валют handbook); RUB-only (ADR-0048) |
CurrencySnapshot | CurrencySnapshot ({ CurrencyId, IsoCode, Name, Sign? }) | Да | CurrencySnapshot.IsoCode → колонка CurrencyIsoCode (денорм ISO-4217-кода для display); RUB-выравнивание по IsoCode: IsoCode ≠ "RUB" → warning-лог (мультивалютность вне v1, дом факта), но факт пишется с фактическими CurrencyId/IsoCode. Строкового кода валюты payload отдельным полем не несёт — код живёт только внутри snapshot |
ChangedAt | DateTimeOffset | Да | → колонка EventTime (DateTime, UTC) — бизнес-время изменения цены в Talent; определяет партицию toYYYYMM; default/будущее время > now + 1 ч → poison (DLQ) |
Source | PropertyObjectStatusSource | Нет | не маппится в колонку: ChangeSource факта проставляет consumer (talent-sync), а не producer; поле используется только в логе |
Version | uint | Нет | не маппится; используется только в логе (диагностика гонок в chessboard) |
Колонки, которые consumer заполняет сам (не из payload): FactId = SourceEventId = EventId события;
IngestionTime = now() момента вставки; BrandId = NULL; ChangeSource = talent-sync;
TransformVersion = 1 (версия маппинга этого consumer-а; bump при изменении правил маппинга).
Зависимость от контракта producer-а
Текущий набросок payload в docs/06-services/chessboard/domain/property-object.md (PropertyObjectId,
TenantId, ProjectId, OldPrice, NewPrice, CurrencyId + CurrencySnapshot, PricePerMeter,
ChangedAt, Source, Version) не содержит снапшота измерений DeveloperId, BuildingId,
LayoutGroupId, LayoutId,
AreaTotal, OldPricePerMeter. Требование к детальному ТЗ chessboard (зафиксировано и в «Известных
ограничениях» ../README.md): событие обязано нести снапшот измерений — иначе
invest-analytics пришлось бы держать реплику измерений или ходить в chessboard синхронно из consumer-а.
Добавление полей — additive-only, bump ContractVersion не требуется.
Алгоритм
Consumer батчевый: шаги 1–4 выполняются на сообщение, шаги 5, 7–8 — на батч (flush по порогу: ≤ 1000 строк или 5 секунд — freshness SLA факта ≤ 5 минут выполняется с запасом).
- Контекст события. Восстановить трейс и контекст из метаданных события (идентификатор сообщения
EventId→SourceEventId,PropertyObjectId, тип агрегата, время); сверитьContractVersion(= 1) — при несовпадении warning-лог и продолжить (warn-and-proceed). Бизнес-идемпотентность — без lookup: dedup декларативен (FactId = SourceEventId), поэтому дубль не отсеивается на входе, а схлопывается хранилищем. - Проверка входа (по-полевой чеклист — колонка «Обязательность» таблицы payload):
- десериализация payload не удалась → сообщение в DLQ (poison), обработка партиции продолжается;
PropertyObjectId == Guid.Empty/DeveloperId == Guid.Empty/ProjectId == Guid.Empty→ DLQ + error-лог (нарушение контракта producer-а);BuildingId <= 0/LayoutGroupId <= 0/LayoutId <= 0→ DLQ + error-лог (см. «Зависимость от контракта producer-а» — алерт, покаchessboardне публикует снапшот измерений);NewPrice < 0,AreaTotal <= 0,ChangedAt == defaultилиChangedAt > now() + 1 час→ DLQ;- существование сущностей по id не проверяется: consumer не проверяет FK в PG — измерения приходят снапшотом, факт самодостаточен;
- допустимость состояния не проверяется: у append-only факта нет статусов.
- Что читаем — ничего: consumer не читает ни PG, ни ClickHouse, ни
chessboard; вход события достаточен для построения строки факта. - Бизнес-правила и маппинг поле-в-поле. Построить
PropertyObjectPriceHistoryFactпо таблице маппинга выше; вычислимые правила:NewPricePerMeter = payload.NewPricePerMeter ?? round(NewPrice / AreaTotal, 2);OldPricePerMeter = payload.OldPricePerMeter ?? (OldPrice is null ? null : round(OldPrice / AreaTotal, 2));EventTime = ChangedAt(UTC);TenantId = DeveloperId;CurrencyId = payload.CurrencyId,CurrencyIsoCode = payload.CurrencySnapshot.IsoCode(RUB-выравнивание:IsoCode ≠ "RUB"→ warning-лог, не сбой);ChangeSource = "talent-sync";TransformVersion = 1. Переходов состояний нет. Добавить строку в буфер батча. - Что меняется. По достижении порога (1000 строк или 5 секунд с первой строки буфера) — один batch
INSERT буфера в ленту
pin_analytics.property_object_price_history_facts.Update/Delete/Upsertзапрещены — append-only; пишет только этот consumer. - Какие факты/события фиксируются. Доменных событий нет (агрегата нет); сам ingestion интеграционных
событий не публикует (append-only ClickHouse, без PG/outbox). Пересчёт витринных метрик доходности и
публикация
pin.invest-analytics.property-object-metrics.v1— отдельный recompute-компонент, реагирующий на это же изменение цены (../events/property-object-metrics.md); карта событий — ../events/README.md. - Согласованность. Границей атомарности служит один batch INSERT ClickHouse; commit Kafka offset
выполняется строго после подтверждённой вставки батча. Сбой между вставкой и commit → повторная
доставка батча → дубли схлопываются
ReplacingMergeTree(at-least-once без потерь и без видимых дублей). - Результат. Успех батча → commit offset; метрики/логи (см. «Наблюдаемость»); контролируемая ошибка вставки → retry всего батча (идемпотентно), offset не двигается.
Диаграмма
Идемпотентность и replay
- Business key:
FactId = SourceEventId(=EventIdсобытия, уникален на событие). Поведение дубля: строка вставляется повторно и схлопываетсяReplacingMergeTreeпри merge; до merge дубли невидимы читателям — запросы API дубль-устойчивы (argMax(NewPricePerMeter, IngestionTime)внутри периода). - Replay: да, штатный runbook (см. ../README.md, «Runtime И Эксплуатация») —
переигрывание топика с произвольного offset consumer-group-ой
invest-analytics-price-historyбезопасно; «дырки» истории после инцидента закрываются повтором диапазона offset без ручной очистки CH. - Пересечение с backfill-ом исключено схемой ключей: у backfill-строк
SourceEventId = backfill:{PropertyObjectId}:{ChangedAt:O}, у consumer-строк — GuidEventIdсобытия (см. talent-price-sync.md).
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| ClickHouse недоступен / timeout вставки | Да: retry батча с экспоненциальным backoff (1с → 60с), без ограничения попыток; offset не commit-ится | Нет (батч не «яд» — сбой инфраструктуры) | Алерт по росту invest_analytics_price_ingest_lag_seconds > 15 мин; восстановить CH; consumer догонит сам |
| Ошибка десериализации payload | Нет | Да (poison, per-message) | Разбор сообщения из DLQ; при системной причине — issue producer-у chessboard |
Невалидный payload (шаг 2: пустые id, AreaTotal <= 0, NewPrice < 0, битое ChangedAt) | Нет | Да (poison, per-message) + error-лог | Проверить контракт producer-а (см. «Зависимость от контракта producer-а»); после исправления — replay сообщения из DLQ |
ContractVersion события не совпал | Не сбой: warning-лог, обработка продолжается | Нет | Сверить график миграции контракта с chessboard |
| Сбой между вставкой батча и commit offset | Повторная доставка батча (at-least-once) | Нет | Не требуется — дубли схлопывает ReplacingMergeTree |
Наблюдаемость и безопасность
- Logs: consumed (offset-диапазон, размер батча), skipped-poison (причина +
SourceEventId), flushed (строк, длительность вставки), failed (ошибка CH). Число дублей не логируется — dedup на стороне CH. - Metrics:
invest_analytics_price_facts_ingested_total,invest_analytics_price_ingest_lag_seconds(EventTime → IngestionTime),invest_analytics_price_ingest_batch_size, счётчики retry и DLQ. - Traces: span приёма события + span вставки; correlation из метаданных события;
SourceEventIdв строке факта связывает CH-данные с событием. - PII/masking: PII в payload и факте отсутствует (факт описывает объект, не пользователя).
- Actor/permissions: пишет только consumer; прямой записи в CH из API/других сервисов нет. Tenant:
TenantIdфакта берётся из payload (DeveloperId), scope-фильтрация на запись не применяется — consumer платформенный; tenant mismatch метаданных vs payload → warning-лог (fail closed не требуется: чтение цен публично).
Acceptance Criteria
- Given валидное событие изменения цены, When consumer обработал сообщение и батч сброшен, Then в
ленте ClickHouse появляется ровно одна строка факта (
FactId = SourceEventId); Kafka offset commit-ится строго после подтверждённой вставки батча. - Given то же событие доставлено повторно (at-least-once), When обработано, Then дубль строки
схлопывается
ReplacingMergeTreeпри merge; до merge читающие запросы дубль-устойчивы (argMax(..., IngestionTime)); lookup идемпотентности не выполняется. - Given событие с более ранним
EventTimeпришло с задержкой (out-of-order), When обработано, Then факт добавляется в партицию своегоEventTimeи не затирает более свежие; порядок по объекту восстанавливается сортировкой API поEventTime. - Given payload не десериализуется или пустой
PropertyObjectId/DeveloperId/ProjectIdилиAreaTotal <= 0/NewPrice < 0илиChangedAt = default/> now + 1 ч, When обработано, Then сообщение → DLQ (poison, per-message) + error-лог; обработка партиции продолжается. - Given ClickHouse недоступен/timeout вставки, When flush батча, Then батч ретраится с backoff (1с → 60с) без ограничения попыток, offset не двигается; в DLQ ничего не уходит (сбой инфраструктуры).
- Given
ContractVersionсобытия ≠ 1, When обработано, Then warning-лог и продолжение (warn-and-proceed), не сбой. - Given событие без
NewPricePerMeter, When маппинг, Then значение вычисляется какround(NewPrice / AreaTotal, 2);CurrencyId=payload.CurrencyId,CurrencyIsoCode=payload.CurrencySnapshot.IsoCode,ChangeSource = talent-sync. - Given событие с
CurrencySnapshot.IsoCode ≠ "RUB"(мультивалютность вне v1), When маппинг, Then warning-лог RUB-выравнивания, факт всё равно пишется с фактическимиCurrencyId/CurrencyIsoCode(не DLQ, не сбой).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): PropertyObjectPriceHistoryFact (ClickHouse)
- API (1): GET /api/v1/property-objects/{propertyObjectId}/price-history — История цены объекта недвижимости (график роста)
- Use Cases (1): UC-PRC-001. Просмотр графика роста цены объекта недвижимости (гость / инвестор / реф-гость readOnly)
- События (2): События invest-analytics: карта контрактов, Event Contract: pin.chessboard.property-object-price-changed (PropertyObjectPriceChangedKafkaEvent)
- Консюмеры (4): Consumer: DevStatsActionTrackedConsumer, Consumer: DevStatsPropertyObjectViewEndedConsumer, Consumer: DevStatsSessionEndedConsumer, Talent Price Sync: сведён к consumer-у chessboard-price-changed (решение, не отдельный consumer)
- index.md (1): InvestAnalytics Service
- jobs (1): Background Job: PriceHistoryBackfillJob
Связанные документы
- Контракт события:
docs/06-services/chessboard/events/property-object-price-changed.md(будущий путь); набросок payload —docs/06-services/chessboard/domain/property-object.md. - Дом факта: ../domain/price-history-fact.md; канал синка Talent: talent-price-sync.md; первичная загрузка: ../jobs/price-history-backfill.md (будущий док).
- Produced-события контекста: ../events/README.md (property-object-metrics.md — переиздаётся recompute-компонентом при изменении цены).
- ADR-0052 (tenancy), ADR-0056 (simplicity-first, границы ClickHouse), ADR-0057 (паттерн CH-facts).