dim_project (ClickHouse dimension)
Реплика-измерение «Проект» (ЖК / апарт-отель) в БД dev-stats. JOIN-free справочник для резолва
имени/типа/класса/статуса проекта и для фильтрации метрик по уровню Project — минимальному обязательному
уровню детализации в ЛК застройщика (ADR-0057). Дом полей источника —
chessboard: ProjectEntity; здесь — только колонки, нужные dev-stats.
Назначение
dim_project хранит текущий снапшот проекта (одна логическая строка на ProjectId), реплицируемый из
pin.chessboard.project-upserted.v1. Факты уже несут
денормализованный ProjectId/DeveloperId; это измерение даёт подпись графика (Name) и атрибуты для
разбивки/фильтра просмотров по проекту (Type, Class, ConstructionStatus, RegionId+RegionName). Не факт, а
стабильное измерение (ReplacingMergeTree, последняя версия перезаписывает предыдущую).
Table
| Поле | Значение |
|---|---|
| Database | pin_dev_stats |
| Table | dim_project |
| Engine | ReplacingMergeTree(UpsertedAt) |
| Owner service | dev-stats (DevStats.Background пишет, DevStats.Api читает; DDL — DevStats.Migrator) |
| Source events | pin.chessboard.project-upserted.v1 |
| Partition / Order | без партиционирования (тысячи строк); ORDER BY (ProjectId) |
| Freshness SLA | ≤ 5 минут от события до видимости |
Стабильное измерение, не append-only факт: последняя версия перезаписывает предыдущую. Онлайн-чтение —
дубль-устойчивое, без FINAL: актуальная строка берётся argMax(col, UpsertedAt) c GROUP BY ProjectId.
Columns
| Column | Type | Nullable | Source (поле ProjectEntity) | Partition/Order | PII |
|---|---|---|---|---|---|
ProjectId | UUID | нет | Id | order (единственный ключ) | нет |
DeveloperId | UUID | нет | DeveloperId (= TenantId) — родитель вверх по иерархии | none | нет |
Name | String | нет | Name | none | нет |
Status | LowCardinality(String) | нет | Status — Draft / Published / Hidden / Archived | none | нет |
Type | LowCardinality(String) | нет | Type — ResidentialComplex / ApartHotel / Hotel / Villa | none | нет |
Class | LowCardinality(String) | нет | Class — Standard / Comfort / Business / Premium / Elite | none | нет |
ConstructionStatus | LowCardinality(String) | нет | ConstructionStatus — Planned / UnderConstruction / Commissioned / Delivered | none | нет |
RegionId | Int64 | да (пусто ⇔ гео не резолвлено в источнике; у Published заполнен) | RegionId — ссылка на справочник handbook, ключ разбивки/фильтра | none | нет |
RegionName | LowCardinality(String) | да (заполнен ⇔ RegionId задан) | RegionSnapshot.Name — денормализованное имя региона на момент публикации | none | нет |
SourceVersion | Int64 | нет | Version источника — монотонный guard | none | нет |
UpsertedAt | DateTime64(3) | нет | время события (CreatedAt) | none (версия движка) | нет |
Минимальный набор для фильтрации/подписи. Денормализованные цены/счётчики (PriceMin/PropertyObjectsFreeCount) и
jsonb-вкладки в dev-stats не нужны — не реплицируются (метрики считаются по фактам просмотров, а не по
атрибутам продаж).
Инварианты
- INV-DIM-PRJ-1 (единственность): одна логическая строка на
ProjectId(единственный компонентORDER BY); merge оставляет строку с максимальнымUpsertedAt. - INV-DIM-PRJ-2 (родитель денормализован):
DeveloperIdреплицируется в строку проекта, чтобы восхождение по иерархии и проверка scope шли без обращения кdim_developer. - INV-DIM-PRJ-3 (не удаляется): строка живёт, пока существует проект;
Status = Archived/Hiddenпомечает архив/скрытость, но не удаляет (3-летние факты должны резолвить подпись/фильтр). - INV-DIM-PRJ-4 (precedence): актуальность — по
UpsertedAt(max побеждает);SourceVersionмонотонна per-key, служит анти-out-of-order-диагностикой; устаревшая доставка не понижает строку. - INV-DIM-PRJ-5 (минимальность): реплицируется только набор для подписи/разбивки (
Name/Type/Class/ConstructionStatus/RegionId+RegionName); цены/счётчики/валюта/страна/город/jsonb-вкладки не хранятся; PII нет (проект — объект). - INV-DIM-PRJ-6 (гео-денормализация):
RegionNameденормализуется изRegionSnapshot.Nameсобытия и не «догоняется» самостоятельно — переименование региона в справочнике доезжает переопубликацией события источником (HR-6chessboard);RegionIdостаётся ключом разбивки при устаревшем имени.
Кросс-сервисный контракт
- Ожидает от
chessboard(project-upserted):Id,DeveloperId,Name,Status,Type,Class,ConstructionStatus,RegionId+RegionSnapshot(гео-ссылка + display-снапшот региона),Version;key = Id(порядок per-project), at-least-once, атомарная публикация снапшота, additive-only для minor; порядок между агрегатами разных уровней не гарантирован. Страна/город и валюта событием несутся, но в измерение не реплицируются. - Предоставляет downstream (в пределах
dev-stats): JOIN-free подпись (Name) и атрибуты разбивки/фильтра метрик по уровнюProject. Измерение не на критическом пути обогащения факта — факт несёт денормализованныйProjectId;dim_projectнужен для подписи/фильтра при чтении. - При отсутствии/устаревании: строки ещё нет → метрика резолвится по
ProjectId, подпись деградирует до id без ошибки; устаревшая доставка → берётся max(UpsertedAt).
Deduplication
- Dedup key:
ProjectId(единственный компонентORDER BY);ReplacingMergeTree(UpsertedAt)оставляет строку с максимальнымUpsertedAtпри merge. - Replay behavior: идемпотентный upsert; повтор топика логических дублей не даёт, до merge — дубль-устойчивое
чтение
argMax(…, UpsertedAt) … GROUP BY ProjectId(FINALв online запрещён). - Version guard: событие применяется только при
event.Version > SourceVersion(anti-out-of-order); иначе идемпотентный пропуск.
Partition И TTL
- Partition key: нет — измерение мало (проектов тысячи).
- Order key:
(ProjectId)— точечный резолв и фильтрWHERE DeveloperId = актор AND ProjectId = …. - TTL/retention: нет — измерение живёт, пока существует проект;
Status = Archived/Hiddenне удаляет строку (3-летние факты должны резолвиться), а помечает архив/скрытость.
Consumers И Backfill
| Process | Назначение | Checkpoint |
|---|---|---|
Приём pin.chessboard.project-upserted.v1 (фоновый ingest) | Проверка версии, маппинг snapshot → строка измерения, batch-upsert (≤ 1000 / 5 с) | Kafka consumer offset (commit после вставки батча) |
| Backfill измерений | Повтор топика project-upserted / перезаливка при вводе сервиса (идемпотентно) | Kafka offset / диапазон ProjectId |
Security
- Raw PII не хранится (проект — объект, не пользователь).
- Доступ — роль застройщика, permission
dev-stats.view, scope строго = свойDeveloperId; каждый запрос фильтруетDeveloperId = актор, чужие проекты недоступны. - ClickHouse не operational source of truth: источник проекта —
chessboard.
Acceptance criteria
- Happy upsert. Given
project-upsertedс непустымиId/DeveloperId, When обработан батч, Then вdim_projectесть строка сName/Status/Type/Class/ConstructionStatus/RegionId/RegionNameиUpsertedAt= время события; чтениеargMax(…, UpsertedAt) GROUP BY ProjectId(безFINAL) отдаёт значения. - Идемпотентный повтор / устаревшая доставка. Given то же/старое событие доставлено повторно, When обработано,
Then после merge логически одна строка; побеждает max(
UpsertedAt). - Archived/Hidden не удаляет. Given проект переведён в
Archived/Hidden, When событие обработано, Then строка остаётся, факты 3-летней глубины резолвят подпись/фильтр. - Деградация подписи. Given строки ещё нет при чтении метрики, Then метрика резолвится по
ProjectId, подпись показывает id без ошибки. - Scope. Given запрос застройщика, When фильтруется
dim_project, Then только строки сDeveloperId = актор; чужой проект недоступен.
Обратные ссылки
Автоссылки: где используется этот документ.
- Консюмеры (1): Consumer: DevStatsProjectUpsertedConsumer
- dimensions (5): dim_building (ClickHouse dimension), dim_developer (ClickHouse dimension), dim_layout (ClickHouse dimension), dim_layout_group (ClickHouse dimension), dim_property_object (ClickHouse dimension)
- registries (1): Реестр сущностей PIN
Связанные документы
- Источник: chessboard:
ProjectEntity, событие project-upserted; snapshot-модель региона (RegionSnapshot): handbook/domain/snapshots.md. - Соседние измерения: developer.md, building.md, layout-group.md, layout.md, property-object.md.
- Шаблон: clickhouse-fact-template.md; ADR-0057, ADR-0056.