Consumer: Chessboard Dimensions Sync (pin-rewards-chessboard-dimensions)
Синхронизация справочника планировок из chessboard в контекст rewards. Одна consumer group обрабатывает
два события — по планировке (Layout) и по группе планировок (LayoutGroup); оба описаны в этом документе,
чтобы политика retry/DLQ/replay группы была единой. Ссылки из ../README.md и
reward-rule.md на consumer справочника планировок разрешаются в этот документ.
Назначение
Consumer group pin-rewards-chessboard-dimensions потребляет события справочника планировок
chessboard и поддерживает в rewards локальные реплики измерений Layout / LayoutGroup
(таблицы rewards.layout_dimensions, rewards.layout_group_dimensions). Реплика используется
только для валидации условий правил вознаграждений при активации (INV-4
reward-rule.md: все Conditions.LayoutIds/LayoutGroupIds существуют,
принадлежат застройщику TenantId правила и не архивированы) — без синхронного запроса в
chessboard. В расчёте комиссии по сделке реплика не участвует (атрибуты объекта недвижимости приходят в payload
pin.crm.deal-completed.v1, crm-deal-completed.md).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | rewards (фоновый обработчик) |
| Consumer group | pin-rewards-chessboard-dimensions |
| Producer | chessboard (агрегаты layout.md, layout-group.md) |
| Топик 1 | pin.chessboard.layout-upserted.v1 — LayoutUpsertedKafkaEvent (создание/обновление/архив планировки), key = Id |
| Топик 2 | pin.chessboard.layout-group-upserted.v1 — LayoutGroupUpsertedKafkaEvent (создание/обновление/архив группы), key = Id |
| ContractVersion | 1 у обоих событий; несовпадение версии — warn-and-proceed (метрика rewards_contract_mismatch_total) |
| Порядок | key = Id → строгий порядок per-aggregate; кросс-агрегатный порядок group↔layout не гарантирован и не требуется (реплики независимы, FK между ними нет) |
| Событие | Эффект (событие → что меняется) |
|---|---|
pin.chessboard.layout-upserted.v1 | создание/обновление строки реплики rewards.layout_dimensions (upsert-snapshot) |
pin.chessboard.layout-group-upserted.v1 | создание/обновление строки реплики rewards.layout_group_dimensions (upsert-snapshot) |
Целевые реплики (owned projections; дом полей — этот документ)
Реплики — плоские строки (не агрегаты): без доменных событий и аудита (изменения вносит только system-actor
синка). Физическое удаление не выполняется — архив флагом IsArchived (история активаций правил остаётся
проверяемой).
LayoutDimensionEntity (rewards.layout_dimensions)
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник (payload) | Описание |
|---|---|---|---|---|---|
LayoutId | Guid | Да | PK pk_layout_dimensions | Id события (= chessboard.layouts.id) | Идентификатор планировки — тот же, что в chessboard. |
TenantId | Guid | Да | index ix_layout_dimensions_tenant (btree: tenant_id) — основная выборка валидации | TenantId события | Компания-застройщик (в chessboard tenant планировки = DeveloperId проекта). |
ProjectId | Guid | Да | index ix_layout_dimensions_project | ProjectId | Проект-владелец (диагностика/отображение названия правила в админке). |
LayoutGroupId | Guid? | Нет | без FK (реплики независимы; группа может прийти позже) | LayoutGroupId | Группа планировки; null — без группы. |
Name | string | Да | varchar(128) | Name | Название планировки — отображение выбранных условий в админке застройщика. |
Rooms | short | Да | check rooms between 0 and 20 | Rooms | Комнатность (справочно; в предикате правила участвует Rooms объекта недвижимости из события сделки, не реплики). |
TotalAreaM2 | decimal | Да | numeric(8,2), check total_area_m2 > 0 | TotalArea | Типовая площадь (справочно, для админки). |
IsArchived | bool | Да | default false; partial-filter where is_archived = false на ix_layout_dimensions_tenant | IsArchived | Архив в chessboard: архивная планировка не проходит валидацию активации правила. |
ReplicaVersion | long | Да | >= 0 | Version | Anti-out-of-order guard (монотонный, INV-DIM-1). |
SyncedAt | DateTimeOffset | Да | — | время события | Момент последнего применённого синка (метрика лага). |
LayoutGroupDimensionEntity (rewards.layout_group_dimensions)
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник (payload) | Описание |
|---|---|---|---|---|---|
LayoutGroupId | Guid | Да | PK pk_layout_group_dimensions | Id события (= chessboard.layout_groups.id) | Идентификатор группы планировок. |
TenantId | Guid | Да | index ix_layout_group_dimensions_tenant | TenantId события | Компания-застройщик. |
ProjectId | Guid | Да | index ix_layout_group_dimensions_project | ProjectId | Проект-владелец. |
Name | string | Да | varchar(128) | Name | Название группы («Студии», «Люксы») для админки. |
IsArchived | bool | Да | default false | IsArchived | Архив в chessboard. |
ReplicaVersion | long | Да | >= 0 | Version | Anti-out-of-order guard (INV-DIM-1). |
SyncedAt | DateTimeOffset | Да | — | время события | Момент последнего применённого синка. |
| Инвариант | Условие | Где проверяется | Поведение |
|---|---|---|---|
| INV-DIM-1 MonotonicReplica | Событие применяется только при payload.Version > row.ReplicaVersion | handler (шаг 4) | Устаревшее/дубль — идемпотентный skip |
| INV-DIM-2 SnapshotSemantics | Каждое применённое событие перезаписывает все реплика-поля целиком (upsert-snapshot) | handler (шаг 4) | Частичных обновлений нет |
| INV-DIM-3 NoHardDelete | Строки не удаляются; архив — IsArchived = true | handler | История валидаций правил проверяема |
Контекст события
- Из метаданных события доступны: идентификатор сообщения, время события,
Version, агрегат-источник (AggregateId = Id,AggregateType = "Layout"/"LayoutGroup"). - Обработка — под system-actor синка вне tenant-контекста (platform-scope):
TenantIdстроки берётся из payload, consumer пишет кросс-tenant. - Корреляция сквозного трейса восстанавливается из метаданных события; исходящих событий нет.
- Ключи идемпотентности сообщений —
rewards:layout-upserted:{MessageId}/rewards:layout-group-upserted:{MessageId}; бизнес-идемпотентность — version-guard INV-DIM-1.
Входные данные / payload событий
Дом контрактов — chessboard: снапшоты описаны в
layout.md и layout-group.md
(раздел «Доменные и интеграционные события»); детальные контракты —
../../chessboard/events/layout-upserted.md, ../../chessboard/events/layout-group-upserted.md
(пути по конвенции). Ниже — только поля, которые consumers реально читают.
LayoutUpsertedKafkaEvent
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Ключ FindBy(LayoutId) / PK строки → LayoutDimensionEntity.LayoutId |
TenantId | Guid | Да | → TenantId (застройщик) |
ProjectId | Guid | Да | → ProjectId |
LayoutGroupId | Guid? | Нет | → LayoutGroupId |
Name | string | Да | → Name (truncate до 128 + warn при превышении) |
Rooms | short | Да | → Rooms |
TotalArea | decimal | Да | → TotalAreaM2 |
IsArchived | bool | Да | → IsArchived |
Version | long | Да | Монотонный guard INV-DIM-1 → ReplicaVersion |
Поля LivingArea, PropertyObjectsCount снапшота consumer-ом не читаются (в валидации правил не участвуют).
LayoutGroupUpsertedKafkaEvent
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Ключ FindBy(LayoutGroupId) / PK строки → LayoutGroupDimensionEntity.LayoutGroupId |
TenantId | Guid | Да | → TenantId |
ProjectId | Guid | Да | → ProjectId |
Name | string | Да | → Name (truncate до 128 + warn) |
IsArchived | bool | Да | → IsArchived |
Version | long | Да | Монотонный guard INV-DIM-1 → ReplicaVersion |
Поля SortOrder, LayoutsCount, AreaMin, AreaMax не читаются.
Алгоритм
Оба события обрабатываются симметрично — описан общий проход, различия отмечены.
- Контекст события. Восстановить метаданные события; сверить
ContractVersion— при несовпадении warn-and-proceed. Проверить идемпотентность по ключуrewards:layout-upserted:{MessageId}(для группы —rewards:layout-group-upserted:{MessageId}) — дубль →skipped-duplicate, пропуск (ack). - Проверка входа.
Id != Guid.Empty;TenantId != Guid.Empty;ProjectId != Guid.Empty;Nameнепустое (≤ 128 — truncate + warn, событие не теряется); для Layout:Rooms in 0..20,TotalArea > 0;Version >= 0. Нарушение —REWARDS_DIMENSIONS_EVENT_INVALID(non-retriable → DLQ). Существование строки не проверяется — операция upsert, отсутствие допустимо. - Что читаем. Существующую строку реплики по
LayoutId(для группы — поLayoutGroupId); её отсутствие допустимо (первое событие агрегата). - Бизнес-правила и переходы.
- Version-guard (INV-DIM-1): для существующей строки
payload.Version <= ReplicaVersion— устаревшее/дубль: метрикаrewards_dimensions_stale_events_total, логskipped-stale, только отметка идемпотентности, пропуск. - Tenant-guard: для существующей строки
TenantId != payload.TenantId— аномалия (планировка сменила застройщика) →REWARDS_DIMENSIONS_TENANT_MISMATCH(non-retriable → DLQ + алерт, fail closed). - Snapshot-семантика (INV-DIM-2): маппинг поле-в-поле по таблицам payload выше — создаётся новая
строка либо перезаписываются все реплика-поля существующей;
ReplicaVersion = payload.Version;SyncedAt= время события;IsArchived = payload.IsArchived(INV-DIM-3 — строка не удаляется).
- Version-guard (INV-DIM-1): для существующей строки
- Что меняется. Строка реплики создаётся или обновляется. Конкурентность разрешается version-guard INV-DIM-1 (per-key порядок Kafka исключает гонку внутри партиции).
- Согласованность. Строка реплики и отметка идемпотентности фиксируются в одном согласованном
изменении; offset подтверждается после фиксации. Исходящих событий нет: реплика — приватное состояние
rewards; изменение справочника не меняет активные правила (архив планировки вchessboardделает условие невыполнимым, правило остаётся — reward-rule.md, раздел «Связи»). - Результат. Успех → ack; non-retriable коды → DLQ + алерт; инфраструктурные сбои → retry (таблица ниже).
Использование реплики (для полноты картины; сама валидация — в документе операции активации правила)
При Activate правила (../api/admin/activate-reward-rule.md) INV-4
проверяется по реплике: все id из Conditions.LayoutIds должны существовать среди неархивных строк
layout_dimensions того же застройщика (TenantId правила); аналогично для LayoutGroupIds по
layout_group_dimensions. Любой отсутствующий/архивный/чужой id → REWARDS_RULE_LAYOUT_NOT_FOUND. Отставшая
реплика (лаг синка) отклонит активацию правила с только что созданной планировкой — retry после догона синка
(failure mode зафиксирован в ../README.md, «Зависимости»).
Диаграмма
Идемпотентность и replay
- Consumer-level: отметка по идентификатору сообщения.
- Business-level: version-guard INV-DIM-1 — событие с
Version <= ReplicaVersionпропускается (защита от out-of-order и replay). - Replay: да. Полный re-read обоих топиков с offset 0 безопасен (upsert + version-guard);
используется и как первоначальное наполнение реплики при внедрении
rewards(отдельного import-job нет — топикиchessboardcompacted/полные, ADR-0056). Runbook —rewards-overview(раздел «пересинхронизация справочника планировок», по конвенции).
Retry и DLQ
DLQ-топик группы: pin.rewards.chessboard-dimensions-dlq.v1 (исходное событие + заголовки
x-dlq-reason, x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook rewards-overview |
Нарушение контракта payload (пустые id, TotalArea <= 0, Rooms вне диапазона) | REWARDS_DIMENSIONS_EVENT_INVALID | Нет (non-retriable) | Да + алерт | Разбор с командой chessboard; после фикса — replay из DLQ |
| Смена tenant существующей строки | REWARDS_DIMENSIONS_TENANT_MISMATCH | Нет | Да + алерт | Ручной разбор аномалии данных chessboard |
Устаревшее событие (Version <= ReplicaVersion) | — (не ошибка) | — | Нет | Не требуется (идемпотентный skip) |
Acceptance-критерии
- Создание реплики (happy path). Дано: первое
LayoutUpsertedKafkaEventпланировки. Когда: обработка. Тогда: создана строкаrewards.layout_dimensions(snapshot полей,ReplicaVersion = Version,SyncedAt= время события); ack. АналогичноLayoutGroupUpsertedKafkaEvent→rewards.layout_group_dimensions. - Обновление (snapshot). Дано: новое событие той же планировки с
Version > ReplicaVersion. Когда: обработка. Тогда: все реплика-поля перезаписаны целиком (INV-DIM-2),ReplicaVersion = Version. - Архивация. Дано: событие с
IsArchived = true. Когда: обработка. Тогда: строка не удаляется,IsArchived = true(INV-DIM-3); архивная планировка перестаёт проходить валидацию активации правил (REWARDS_RULE_LAYOUT_NOT_FOUND). - Устаревшее/дубль (version-guard). Дано:
Version <= ReplicaVersion. Когда: обработка. Тогда:skipped-stale, метрикаrewards_dimensions_stale_events_total, состояние не меняется, ack (INV-DIM-1). - Дубль доставки. Дано: то же
MessageId. Когда: обработка. Тогда:skipped-duplicate, ack. - Нарушение контракта. Дано: пустой
Id/TenantId/ProjectId,TotalArea <= 0илиRoomsвне0..20. Когда: обработка. Тогда:REWARDS_DIMENSIONS_EVENT_INVALID→ DLQ + алерт. - Смена tenant. Дано: существующая строка, событие с иным
TenantId. Когда: обработка. Тогда:REWARDS_DIMENSIONS_TENANT_MISMATCH→ DLQ + алерт (fail-closed). - Длинное название. Дано:
Nameдлиннее 128. Когда: обработка. Тогда: значение усечено до 128 + warn, событие не теряется. - Replay/наполнение. Дано: re-read обоих топиков с offset 0. Когда: обработка. Тогда: реплики сходятся к последнему snapshot каждой планировки/группы (upsert + version-guard), двойных строк нет.
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-stale/created/updated/archived/dead-letteredс полямиMessageId,Id,TenantId,payload.Version,row.ReplicaVersion. PII нет (названия планировок — Internal). - Metrics:
rewards_dimensions_lag_seconds(now − max(SyncedAt) по типу),rewards_dimensions_consume_duration_ms,rewards_dimensions_stale_events_total,rewards_dimensions_dlq_total,rewards_contract_mismatch_total. - Traces: consume-span → handler-span → DB-span.
- Alerts: lag > 5 мин; DLQ > 0.
- Dashboard / Runbook: Grafana
rewards-overview. - Безопасность: system-actor, platform-scope bypass с явным
TenantIdиз payload доверенного внутреннего топика; tenant mismatch → fail closed (REWARDS_DIMENSIONS_TENANT_MISMATCH); чтение реплики — только внутренними операциямиrewards(валидация правил, отображение названий в админке застройщика в его tenant-scope).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: RewardRuleEntity
- API (1): POST /api/v1/reward-rules/{ruleId}/activate — Активация правила вознаграждения
- Use Cases (1): UC-RWD-001. Застройщик настраивает правила вознаграждений агентов
- События (2): Event Contract: pin.crm.deal-completed (DealCompletedEvent), Event Contract: pin.crm.fixation-approved (FixationApprovedEvent)
- Консюмеры (1): Consumer: Agent Performance Projection
- index.md (1): Rewards Service
Связанные документы
- Потребитель реплики: reward-rule.md (INV-4,
Conditions). - Producer-агрегаты: layout.md,
layout-group.md; контракты событий —
../../chessboard/events/layout-upserted.md,../../chessboard/events/layout-group-upserted.md(по конвенции). - Шаблон: consumer-template.md; ADR-0052 (tenancy), ADR-0056 (simplicity-first — реплика в PostgreSQL, без кэшей).