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

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 grouppin-rewards-chessboard-dimensions
Producerchessboard (агрегаты layout.md, layout-group.md)
Топик 1pin.chessboard.layout-upserted.v1LayoutUpsertedKafkaEvent (создание/обновление/архив планировки), key = Id
Топик 2pin.chessboard.layout-group-upserted.v1LayoutGroupUpsertedKafkaEvent (создание/обновление/архив группы), key = Id
ContractVersion1 у обоих событий; несовпадение версии — 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)Описание
LayoutIdGuidДаPK pk_layout_dimensionsId события (= chessboard.layouts.id)Идентификатор планировки — тот же, что в chessboard.
TenantIdGuidДаindex ix_layout_dimensions_tenant (btree: tenant_id) — основная выборка валидацииTenantId событияКомпания-застройщик (в chessboard tenant планировки = DeveloperId проекта).
ProjectIdGuidДаindex ix_layout_dimensions_projectProjectIdПроект-владелец (диагностика/отображение названия правила в админке).
LayoutGroupIdGuid?Нетбез FK (реплики независимы; группа может прийти позже)LayoutGroupIdГруппа планировки; null — без группы.
NamestringДаvarchar(128)NameНазвание планировки — отображение выбранных условий в админке застройщика.
RoomsshortДаcheck rooms between 0 and 20RoomsКомнатность (справочно; в предикате правила участвует Rooms объекта недвижимости из события сделки, не реплики).
TotalAreaM2decimalДаnumeric(8,2), check total_area_m2 > 0TotalAreaТиповая площадь (справочно, для админки).
IsArchivedboolДаdefault false; partial-filter where is_archived = false на ix_layout_dimensions_tenantIsArchivedАрхив в chessboard: архивная планировка не проходит валидацию активации правила.
ReplicaVersionlongДа>= 0VersionAnti-out-of-order guard (монотонный, INV-DIM-1).
SyncedAtDateTimeOffsetДавремя событияМомент последнего применённого синка (метрика лага).

LayoutGroupDimensionEntity (rewards.layout_group_dimensions)

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)Источник (payload)Описание
LayoutGroupIdGuidДаPK pk_layout_group_dimensionsId события (= chessboard.layout_groups.id)Идентификатор группы планировок.
TenantIdGuidДаindex ix_layout_group_dimensions_tenantTenantId событияКомпания-застройщик.
ProjectIdGuidДаindex ix_layout_group_dimensions_projectProjectIdПроект-владелец.
NamestringДаvarchar(128)NameНазвание группы («Студии», «Люксы») для админки.
IsArchivedboolДаdefault falseIsArchivedАрхив в chessboard.
ReplicaVersionlongДа>= 0VersionAnti-out-of-order guard (INV-DIM-1).
SyncedAtDateTimeOffsetДавремя событияМомент последнего применённого синка.
ИнвариантУсловиеГде проверяетсяПоведение
INV-DIM-1 MonotonicReplicaСобытие применяется только при payload.Version > row.ReplicaVersionhandler (шаг 4)Устаревшее/дубль — идемпотентный skip
INV-DIM-2 SnapshotSemanticsКаждое применённое событие перезаписывает все реплика-поля целиком (upsert-snapshot)handler (шаг 4)Частичных обновлений нет
INV-DIM-3 NoHardDeleteСтроки не удаляются; архив — IsArchived = truehandlerИстория валидаций правил проверяема

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

  • Из метаданных события доступны: идентификатор сообщения, время события, 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ТипОбязательностьКак используется (маппинг)
IdGuidДаКлюч FindBy(LayoutId) / PK строки → LayoutDimensionEntity.LayoutId
TenantIdGuidДаTenantId (застройщик)
ProjectIdGuidДаProjectId
LayoutGroupIdGuid?НетLayoutGroupId
NamestringДаName (truncate до 128 + warn при превышении)
RoomsshortДаRooms
TotalAreadecimalДаTotalAreaM2
IsArchivedboolДаIsArchived
VersionlongДаМонотонный guard INV-DIM-1 → ReplicaVersion

Поля LivingArea, PropertyObjectsCount снапшота consumer-ом не читаются (в валидации правил не участвуют).

LayoutGroupUpsertedKafkaEvent

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаКлюч FindBy(LayoutGroupId) / PK строки → LayoutGroupDimensionEntity.LayoutGroupId
TenantIdGuidДаTenantId
ProjectIdGuidДаProjectId
NamestringДаName (truncate до 128 + warn)
IsArchivedboolДаIsArchived
VersionlongДаМонотонный guard INV-DIM-1 → ReplicaVersion

Поля SortOrder, LayoutsCount, AreaMin, AreaMax не читаются.

Алгоритм

Оба события обрабатываются симметрично — описан общий проход, различия отмечены.

  1. Контекст события. Восстановить метаданные события; сверить ContractVersion — при несовпадении warn-and-proceed. Проверить идемпотентность по ключу rewards:layout-upserted:{MessageId} (для группы — rewards:layout-group-upserted:{MessageId}) — дубль → skipped-duplicate, пропуск (ack).
  2. Проверка входа. 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, отсутствие допустимо.
  3. Что читаем. Существующую строку реплики по LayoutId (для группы — по LayoutGroupId); её отсутствие допустимо (первое событие агрегата).
  4. Бизнес-правила и переходы.
    • 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 — строка не удаляется).
  5. Что меняется. Строка реплики создаётся или обновляется. Конкурентность разрешается version-guard INV-DIM-1 (per-key порядок Kafka исключает гонку внутри партиции).
  6. Согласованность. Строка реплики и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается после фиксации. Исходящих событий нет: реплика — приватное состояние rewards; изменение справочника не меняет активные правила (архив планировки в chessboard делает условие невыполнимым, правило остаётся — reward-rule.md, раздел «Связи»).
  7. Результат. Успех → 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 нет — топики chessboard compacted/полные, 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).

СбойКодRetryDLQРучное действие
Инфраструктурный (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. Аналогично LayoutGroupUpsertedKafkaEventrewards.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).

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

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

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

  • Потребитель реплики: 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, без кэшей).