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

Consumer: Talent Chessboard Sync (pin-chessboard-talent-sync)

Консолидированный документ Kafka-синка из talent.microservices.chessboard в контекст chessboard PIN: синхронизация на основе шахматки, данных, событий и сущностей Talent, с возможностью дополнять, расширять и корректировать информацию. Один consumer group = четыре обрабатываемых события Talent; retry/DLQ/replay-политика группы едина. Дома полей целевых сущностей — developer.md, project.md, building.md, property-object.md, layout.md, layout-group.md; правила владения полями «реплика vs PIN» — инварианты *-SyncOwnedFields этих документов (INV-DEV-3, INV-PRJ-9, INV-BLD-7, INV-LAY-6 и SyncSource-колонки). Гео- и валютные справочные ссылки резолвятся по локальной реплике справочников handbookhandbook-replica.md (раздел «Гео-резолв и валюта (проект)» ниже).

Назначение

Consumer group pin-chessboard-talent-sync потребляет события Talent Chessboard о застройщиках, проектах, зданиях и объектах недвижимости (PropertyObject, включая вложенную планировку layout{square, livingSquare, group, schemas} и windowViews/status) и поддерживает в PIN реплики: DeveloperEntity, ProjectEntity, BuildingEntity, PropertyObjectEntity, LayoutEntity, LayoutGroupEntity (SyncSource = TalentChessboard). Синк перезаписывает только реплика-поля; PIN-поля (описания, Highlights, статусы публикации, jsonb-вкладки проекта, кроме address-части Location) не трогаются. Каждое применённое изменение переопубликуется собственными событиями PIN (pin.chessboard.*), чтобы downstream-контексты (catalog, dev-stats, invest-analytics, rewards) не подписывались на топики Talent напрямую (правило границ — service-boundaries.md).

Owning-сервис и триггер

ПолеЗначение
Сервис/контекстchessboard (проект Pin.Chessboard.Background)
Consumer grouppin-chessboard-talent-sync
Producertalent.microservices.chessboard (внешняя система; контракты — upstream source of truth, schema registry Talent; имена топиков и состав payload — зафиксированное assumption README chessboard до подтверждения)
Топик 1talent.chessboard.developers.v1 — событие TalentDeveloperUpsertedEvent
Топик 2talent.chessboard.projects.v1 — событие TalentProjectUpsertedEvent
Топик 3talent.chessboard.buildings.v1 — событие TalentBuildingUpsertedEvent
Топик 4talent.chessboard.property-objects.v1 — событие TalentPropertyObjectUpsertedEvent (объект недвижимости + вложенные layout/group)
ContractVersionожидается 1 для всех событий Talent; при envelope.Version != ContractVersion — warn-and-proceed (метрика chessboard_sync_contract_mismatch_total)
Порядокkey Kafka = id агрегата Talent → все события одного агрегата в одной партиции, строгий порядок per-aggregate; кросс-агрегатный порядок developer→project→building→propertyObject не гарантирован — обрабатывается retriable-ошибкой *_NOT_FOUND (см. Retry/DLQ)
Feature flagchessboard.talent-sync.enabled — при false consumer приостанавливает партиции (пауза синка при инцидентах источника, README chessboard)
СобытиеЭффект (событие → что меняется)
TalentDeveloperUpsertedEventСоздание/обновление DeveloperEntity-реплики
TalentProjectUpsertedEventСоздание/обновление ProjectEntity-реплики
TalentBuildingUpsertedEventСоздание/обновление BuildingEntity-реплики
TalentPropertyObjectUpsertedEventСоздание/обновление PropertyObjectEntity + upsert вложенных LayoutEntity/LayoutGroupEntity

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

  • Из метаданных события доступны: MessageId (Guid v7), CreatedAt (= OccurredAt события Talent), Version (= ContractVersion producer-а), AggregateId (id агрегата Talent), AggregateType (Developer/Project/Building/PropertyObject).
  • Actor — system-actor синка (не пользовательский контекст): CreatedByUserId/UpdatedByUserId мутируемых сущностей штампуются служебным идентификатором; работа в platform-scope без tenant-фильтра — consumer пишет кросс-tenant, TenantId каждой записи определяется данными, а не актором.
  • Correlation: сквозной trace восстанавливается из метаданных события и пробрасывается в исходящие pin.chessboard.* события.
  • Идемпотентность на уровне доставки — по MessageId, ключ chessboard-talent-sync:{EventType}:{MessageId}; бизнес-идемпотентность — монотонный version-guard payload.Version > entity.ReplicaVersion (INV-DEV-4 / INV-PRJ-5 / INV-BLD-5 / INV-LAY-4 / INV-LGR-4 и guard объекта недвижимости, property-object.md ReplicaVersion).

Входные данные / payload событий

Контракты внешние (schema registry Talent — upstream source of truth); ниже — поля, которые consumers реально читают, и маппинг «поле payload → поле реплики». Целевые поля и их ограничения — дома сущностей (здесь не переописываются).

TalentDeveloperUpsertedEvent (snapshot застройщика)

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаКлюч поиска реплики по ExternalTalentDeveloperId; при создании → ExternalTalentDeveloperId
TenantIdGuidДаПри создании → DeveloperEntity.Id (assumption: застройщик в Talent привязан к tenant Talent Identity, а identity-синк создаёт CompanyEntity c Id = TenantId Talent → выполняется Id = CompanyId, ADR-0052, INV-DEV-1 — eventually consistent с консюмером pin.identity.companies.v1)
NamestringДаПерезапись DeveloperEntity.Name (реплика-поле, INV-DEV-3); ≤ 256 — truncate + warn
LegalNamestring?НетПерезапись LegalName
LogoFileIdGuid?НетПерезапись LogoFileId (assumption: единый storage Talent/PIN, FileId переносится как есть)
FoundedYearshort?НетПерезапись FoundedYear; вне 1900..2100null + warn
VersionlongДаМонотонный guard → ReplicaVersion; LastSyncedAt = envelope.CreatedAt

TalentProjectUpsertedEvent (snapshot проекта)

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаПоиск реплики по ExternalTalentProjectId; при создании → ProjectEntity.Id и ExternalTalentProjectId (id PIN = id Talent для реплик, project.md)
DeveloperIdGuidДаПоиск застройщика по ExternalTalentDeveloperIdProjectEntity.DeveloperId = TenantId; застройщика нет → retriable CHESSBOARD_SYNC_DEVELOPER_NOT_FOUND
NamestringДаПерезапись Name (реплика-поле, INV-PRJ-9)
Typestring (enum Talent)ДаМаппинг → ProjectType; unknown → fallback ResidentialComplex + warn + chessboard_sync_unknown_enum_total (reference-data.md)
ClassstringДаМаппинг → ProjectClass; unknown → Comfort + warn
ConstructionStatusstringДаМаппинг → ConstructionStatus; unknown → UnderConstruction + warn; регресс по порядку справочника — warn + применение (source of truth — Talent для реплика-поля)
RegionstringДаСтрока региона для гео-резолва → RegionId/RegionName (name-match по реплике справочника, раздел «Гео-резолв и валюта (проект)»)
Starsshort?НетПерезапись Stars; вне 1..5null + warn
CoverImageFileIdGuid?НетПерезапись CoverImageFileId
Address{Country?, Region?, City?, District?, AddressLine?, PostalCode?, Latitude?, Longitude?}НетCountry → гео-резолв CountryId; City → гео-резолв CityId/CityName; Region внутри Address для резолва не используется (канонична верхнеуровневая строка Region); District/AddressLine/PostalCode/Latitude+Longitude — перезапись только address-части Location (ProjectLocationDocument: District/AddressLine/PostalCode/Geo); PIN-части (TransportNodes, SeaDistanceMeters, CenterDistanceMinutes) не трогаются (INV-PRJ-9)
VersionlongДаМонотонный guard → ReplicaVersion (INV-PRJ-5)

Гео-резолв и валюта (проект)

Talent передаёт географию свободными строками; PIN хранит ссылки справочника handbook (CountryId/RegionId/CityId + display-имена, project.md). Синк резолвит строки в ссылки name-match-ем по локальной реплике справочников handbook-replica.md — без сетевых вызовов, при каждом применённом событии проекта:

  • Нормализация и сравнение. Строка Talent нормализуется (trim, схлопывание пробелов); сравнение с Name записей реплики — точное, без учёта регистра. Матчатся только неархивные записи (HR-5); неоднозначный матч (две и более записи) приравнивается к «не найдено» — защита от неверной привязки.
  • Страна. Address.Country → запись handbook_countries (по Name, также принимается ISO-код IsoCode2/IsoCode3) → CountryId. Строка отсутствует или не найдена → default страна «Россия» (IsoCode2 = 'RU'); непустая ненайденная строка — warn + метрика (регион/город такого проекта в пределах default-страны тоже не резолвятся → публикация блокируется INV-PRJ-3, «чужая» страна не публикуется молча).
  • Регион. Верхнеуровневая строка Region → запись handbook_regions в пределах резолвленной страны. Найдена → RegionId = id записи, RegionName снимается из реплики. Не найдена → RegionId = null, RegionName = нормализованная строка Talent (truncate ≤ 128 + warn) — исходное имя сохраняется для карточки и ручного разбора.
  • Город. Address.City → запись handbook_cities в пределах резолвленной страны; при резолвленном регионе дополнительно сверяется city.RegionId = RegionId (город другого региона — «не найдено»: синк не создаёт рассогласованную гео-цепочку, INV-PRJ-10). Найден → CityId + CityName из реплики; не найден → CityId = null, CityName = строка Talent (truncate ≤ 128 + warn); строка отсутствует → CityId = null, CityName = null.
  • Сохранение ручного разрешения. Успешный матч перезаписывает ссылку и display-имя. Безуспешный матч не сбрасывает уже резолвленную ссылку (проставленную предыдущим событием либо вручную через update-project) — id и display-имя сохраняются, warn; display-имя обновляется строкой Talent только при RegionId = null / CityId = null.
  • Эффект нерезолвленной ссылки. Применение синка не блокируется: реплика создаётся/обновляется с id = null + warn + метрика chessboard_sync_geo_unresolved_total{field}. Публикация проекта блокируется INV-PRJ-3 (project.md) до ручного разрешения — застройщик/ПИН проставляет ссылки справочника через update-project.
  • Валюта. Talent код валюты не передаёт; при создании проекта CurrencyId определяется резолвом по IsoCode = 'RUB' в реплике справочника валют (default проекта, project.md); запись RUB в реплике ещё отсутствует (bootstrap справочника не выполнен) → retriable CHESSBOARD_SYNC_CURRENCY_NOT_RESOLVED (см. Retry/DLQ). Справочная ссылка ≠ мультивалютность — RUB-only (ADR-0048).

TalentBuildingUpsertedEvent (snapshot здания/литера)

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаПоиск реплики по ExternalTalentBuildingId; при создании → BuildingEntity.Id и ExternalTalentBuildingId
ProjectIdGuidДаПоиск проекта по ExternalTalentProjectIdBuildingEntity.ProjectId, TenantId = project.TenantId (INV-BLD-1); проекта нет → retriable CHESSBOARD_SYNC_PROJECT_NOT_FOUND
NamestringДаПерезапись Name (INV-BLD-7); конфликт ux_buildings_project_name → суффикс (Talent) + warn (см. Retry/DLQ)
Codestring?НетПерезапись Code
FloorsTotalshortДаПерезапись FloorsTotal; уменьшение ниже максимального занятого этажа объектов недвижимости — warn + применение с алертом CHESSBOARD_BUILDING_FLOORS_CONFLICT (данные Talent первичны для реплика-поля; конфликт — на ручной разбор)
SectionsCountshort?НетПерезапись SectionsCount
DeliveryYearshort?НетПерезапись DeliveryYear; вне 2000..2100null + warn
DeliveryQuartershort?НетПерезапись DeliveryQuarter (пара с DeliveryYear; без года — null + warn)
ConstructionStatusstringДаМаппинг → ConstructionStatus; unknown → UnderConstruction + warn; Deliveredbuilding.MarkDelivered(DeliveredAt = payload.DeliveredAt ?? envelope.CreatedAt.Date) (INV-BLD-6: ReadinessPercent = 100)
DeliveredAtDateOnly?НетDeliveredAt при ConstructionStatus = Delivered
ReadinessPercentdecimalДаПерезапись ReadinessPercent; clamp 0..100 + warn при выходе за диапазон
CeilingHeightdecimal?НетПерезапись CeilingHeightMeters; вне 2..10null + warn
VersionlongДаМонотонный guard → ReplicaVersion (INV-BLD-5)

TalentPropertyObjectUpsertedEvent (snapshot объекта; модель PropertyObject)

Поле payloadТипОбязательностьКак используется (маппинг)
IdGuidДаПоиск реплики по ExternalTalentPropertyObjectId; при создании → PropertyObjectEntity.Id и ExternalTalentPropertyObjectId
BuildingIdGuidДаПоиск здания по ExternalTalentBuildingIdPropertyObject.BuildingId, PropertyObject.ProjectId = building.ProjectId (INV-CHS-005), TenantId = building.TenantId (INV-CHS-004); здания нет → retriable CHESSBOARD_SYNC_BUILDING_NOT_FOUND
NumberstringДаПерезапись Number; конфликт ux_property_objects_building_number → non-retriable CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN (DLQ + алерт: дубль номера в источнике)
FloorshortДаПерезапись Floor; вне -5..200 → non-retriable DLQ CHESSBOARD_SYNC_INVALID_RANGE
Sectionshort?НетПерезапись Section
RoomsshortДаПерезапись Rooms (0 = студия); вне 0..20 → DLQ CHESSBOARD_SYNC_INVALID_RANGE
Layout.IdGuid?НетUpsert LayoutEntity по ExternalTalentLayoutId (см. шаг D4); → PropertyObject.LayoutId (INV-CHS-007 выполняется по построению: layout создаётся в том же проекте)
Layout.Namestring?НетLayoutEntity.Name; при null — генерация "{Rooms}К {Square} м²" + warn
Layout.SquaredecimalДа (при наличии Layout)LayoutEntity.TotalArea и PropertyObject.TotalArea (обмерная площадь объекта недвижимости = типовая, пока Talent не прислал отличную); <= 0 → DLQ
Layout.LivingSquaredecimal?НетLayoutEntity.LivingArea и PropertyObject.LivingArea; > Squarenull + warn
Layout.Group.IdGuid?НетUpsert LayoutGroupEntity по ExternalTalentLayoutGroupIdLayout.LayoutGroupId (INV-LAY-3 по построению)
Layout.Group.Namestring?Да (при наличии Group)LayoutGroupEntity.Name (реплика-поле)
Layout.Schemas[{FileId, Kind, Title?, SortOrder}]НетLayoutEntity.Schemas (LayoutSchemasDocument.Items); unknown KindPlan2D + warn (layout.md); > 20 элементов — первые 20 по SortOrder + warn
WindowViewsstring[]НетPropertyObject.WindowViews.Views (маппинг в WindowViewKind; unknown → Street + warn, дедупликация, ≤ 4 — первые 4 + warn)
StatusstringДаМаппинг статуса Talent → PropertyObjectStatus: Available → Free, Sold → Sold, Hidden → Hidden; unknown → Free + warn. Применение — только по правилам INV-CHS-001 (шаг D4: при создании — начальный статус; при обновлении — только Free → Sold)
Pricedecimal?НетПерезапись PropertyObject.Price (+ пересчёт PricePerMeter = round(Price / TotalArea, 2)); < 0 → пропуск цены + warn; смена цены → PropertyObjectPriceChangedKafkaEvent (Source = TalentSync); при Status in (Reserved, Booked, Sold) в PIN — цена НЕ перезаписывается (INV-CHS-008), warn + chessboard_sync_conflicts_total
Capacityshort?НетПерезапись Capacity; вне 1..20null + warn
CeilingHeightdecimal?НетПерезапись CeilingHeightMeters
VersionlongДаМонотонный guard → ReplicaVersion

Алгоритм

Отдельный проход для каждого обрабатываемого события.

A. TalentDeveloperUpsertedEvent

  1. Контекст события. Проверить feature flag chessboard.talent-sync.enabled (выключен → пауза партиции, события не теряются); восстановить метаданные события; Version != 1 → warn-and-proceed; проверить идемпотентность по ключу chessboard-talent-sync:developer-upserted:{MessageId} — дубль → лог skipped-duplicate, ack.
  2. Проверка входа. Id и TenantId заданы; Name непустое (≤ 256, truncate + warn); Version >= 0; FoundedYear в 1900..2100 либо → null + warn. Существование не проверяется — операция upsert.
  3. Что читаем. Реплика застройщика по ExternalTalentDeveloperId (может отсутствовать — первое событие); при создании дополнительно — проверка, что id (TenantId) не занят другим профилем (не Talent-репликой).
  4. Бизнес-правила и переходы.
    • Version-guard (INV-DEV-4): реплика есть и payload.Version <= ReplicaVersion → метрика chessboard_sync_stale_events_total, лог skipped-stale, к шагу 7 (только отметка идемпотентности).
    • Id-конфликт: при создании, если id занят профилем другого происхождения (ExternalTalentDeveloperId не совпадает) → non-retriable CHESSBOARD_DEVELOPER_TALENT_DUPLICATE (DLQ + алерт: id компании занят профилем другого происхождения, ручной разбор; INV-DEV-2).
    • Создание (реплики нет): завести DeveloperEntity из снапшота — SyncSource = TalentChessboard, Status = Draft (публикация — решение ПИН/застройщика, не синка), дефолтный Highlights (ContractVersion = 1, ReliabilityMax = 10); доменное событие DeveloperCreatedEvent.
    • Обновление (реплика есть): перезапись реплика-полей Name, LegalName, LogoFileId, FoundedYear (snapshot-семантика: null в payload = очистка поля); ReplicaVersion = payload.Version, LastSyncedAt = event.CreatedAt. PIN-поля (Description, Highlights, Website, контакты, Status) не трогаются (INV-DEV-3).
  5. Что меняется. Реплика создаётся или обновляется; конкуренция «синк vs правка застройщика» разрешается оптимистической блокировкой (конфликт → retry с перечитыванием, version-guard отсеет дубль).
  6. Какие события фиксируются. DeveloperUpsertedKafkaEvent → топик pin.chessboard.developer-upserted.v1, key = developer.Id, AggregateType = Developer, ContractVersion = 1 — полный snapshot (контракт).
  7. Согласованность. Изменение реплики, отметка идемпотентности и исходящее событие фиксируются в одном согласованном изменении; offset подтверждается после успешной фиксации.
  8. Результат. Успех → ack; retriable-ошибка → retry по политике; non-retriable → DLQ + алерт. Метрики/логи — раздел «Наблюдаемость».

B. TalentProjectUpsertedEvent

  1. Контекст события. Как A1; ключ идемпотентности chessboard-talent-sync:project-upserted:{MessageId}.
  2. Проверка входа. Id и DeveloperId заданы; Name непустое (≤ 256, truncate + warn); Region непустой; Version >= 0; маппинги enum Type/Class/ConstructionStatus — unknown → fallback + warn + метрика (consumer не падает, правило unknown enum reference-data.md). Гео-строки (страна/регион/город) резолвятся по правилам раздела «Гео-резолв и валюта (проект)» — нерезолвленная ссылка не ошибка (id = null + warn + chessboard_sync_geo_unresolved_total). Существование родителя: застройщик по ExternalTalentDeveloperId не найден → CHESSBOARD_SYNC_DEVELOPER_NOT_FOUND — retriable (out-of-order developer→project).
  3. Что читаем. Реплика проекта по ExternalTalentProjectId (может отсутствовать).
  4. Бизнес-правила и переходы.
    • Version-guard INV-PRJ-5: stale → skip (как A).
    • Смена застройщика: проект есть и его застройщик отличается от найденного → non-retriable CHESSBOARD_SYNC_PROJECT_DEVELOPER_CHANGED (DLQ + алерт: перенос проекта между застройщиками ломает tenant-иерархию INV-PRJ-1, ручной разбор).
    • Создание (реплики нет): завести ProjectEntity из снапшота — TenantId = developer.Id (INV-PRJ-1), Status = Draft (публикацию делает застройщик/админ, INV-PRJ-3 требует заполненности), SyncSource = TalentChessboard; гео-ссылки CountryId/RegionId/RegionName/CityId/CityName — из гео-резолва; CurrencyId — резолв IsoCode = 'RUB' по реплике справочника валют (default); jsonb-документы — дефолтные (ContractVersion = 1; address-часть LocationDistrict/AddressLine/PostalCode/Geo из payload); доменное событие ProjectCreatedEvent.
    • Обновление: перезапись реплика-полей Name, Type, Class, ConstructionStatus, гео-полей CountryId/RegionId/RegionName/CityId/CityName (по правилам гео-резолва — безуспешный матч не затирает ручное разрешение), Stars, CoverImageFileId + address-часть Location (поэлементно; PIN-части Location и остальные jsonb-вкладки не трогаются, INV-PRJ-9); ReplicaVersion, LastSyncedAt.
  5. Что меняется. Реплика проекта создаётся или обновляется.
  6. Какие события фиксируются. ProjectUpsertedKafkaEventpin.chessboard.project-upserted.v1, key = project.Id (контракт).
  7. Согласованность. Как A7.
  8. Результат. Как A8.

C. TalentBuildingUpsertedEvent

  1. Контекст события. Как A1; ключ chessboard-talent-sync:building-upserted:{MessageId}.
  2. Проверка входа. Id и ProjectId заданы; Name непустое (≤ 128, truncate + warn); FloorsTotal в 1..200 (вне — DLQ CHESSBOARD_SYNC_INVALID_RANGE); Version >= 0; диапазоны SectionsCount/DeliveryYear/DeliveryQuarter/CeilingHeight — вне диапазона → null + warn; ReadinessPercent — clamp 0..100 + warn. Родитель: проект по ExternalTalentProjectId не найден → CHESSBOARD_SYNC_PROJECT_NOT_FOUND — retriable.
  3. Что читаем. Реплика здания по ExternalTalentBuildingId (может отсутствовать); при создании — проверка занятости имени в проекте (INV-BLD-2).
  4. Бизнес-правила и переходы.
    • Version-guard INV-BLD-5: stale → skip.
    • Конфликт имени: имя занято в проекте → имя дополняется суффиксом (Talent) + warn + chessboard_sync_conflicts_total (событие не теряется; резолюция — admin-операция).
    • Создание: завести BuildingEntity из снапшота — TenantId = project.TenantId (INV-BLD-1), Status = Draft, SyncSource = TalentChessboard; маппинг всех реплика-полей по таблице payload; доменное событие BuildingCreatedEvent.
    • Обновление (INV-BLD-7): при map(payload.ConstructionStatus) = Delivered и текущем статусе не Delivered — переход MarkDelivered (INV-BLD-6: ReadinessPercent = 100, BuildingDeliveredEvent); уменьшение FloorsTotal ниже максимального занятого этажа объектов — warn + алерт CHESSBOARD_BUILDING_FLOORS_CONFLICT, значение применяется (Talent первичен для реплика-поля; объекты недвижимости-нарушители — на ручной разбор).
  5. Что меняется. Реплика здания создаётся или обновляется.
  6. Какие события фиксируются. BuildingUpsertedKafkaEventpin.chessboard.building-upserted.v1, key = building.Id (контракт).
  7. Согласованность. Как A7.
  8. Результат. Как A8.

D. TalentPropertyObjectUpsertedEvent (объект недвижимости + layout + группа)

  1. Контекст события. Как A1; ключ chessboard-talent-sync:property-object-upserted:{MessageId}. Обработка батчевая (см. раздел «Батчевая обработка»): шаги 2–7 выполняются для пачки событий одной партиции, согласованное изменение — одно на батч.
  2. Проверка входа. Id и BuildingId заданы; Number непустой (≤ 32); Floor в -5..200, Rooms в 0..20 (вне — DLQ CHESSBOARD_SYNC_INVALID_RANGE); при наличии LayoutLayout.Square > 0; Version >= 0; маппинги enum Status/WindowViews/Layout.Schemas[].Kind — unknown → fallback + warn. Родитель: здание по ExternalTalentBuildingId не найдено → CHESSBOARD_SYNC_BUILDING_NOT_FOUND — retriable (out-of-order building→propertyObject).
  3. Что читаем (порядок: сначала справочные агрегаты, затем объект недвижимости):
    • группа планировок по ExternalTalentLayoutGroupId (при наличии группы в payload);
    • планировка по ExternalTalentLayoutId (при наличии Layout в payload);
    • реплика объекта по ExternalTalentPropertyObjectId (может отсутствовать);
    • при создании объекта — проверка занятости номера в здании (INV-CHS-006).
  4. Бизнес-правила и переходы (порядок мутаций: group → layout → propertyObject, FK-зависимость):
    • Version-guard объекта: реплика есть и payload.Version <= ReplicaVersion → skip-stale.
    • Группа планировок (upsert) (при наличии группы): нет — завести LayoutGroupEntity из снапшота (TenantId = building.TenantId, INV-LGR-1; SyncSource = TalentChessboard); есть — обновить имя только при payload.Version > group.ReplicaVersion (INV-LGR-4; события объектов одной группы приходят конкурентно — guard отсеивает перезапись старым именем).
    • Планировка (upsert) (при наличии Layout): нет — завести LayoutEntity из снапшота (projectId = building.ProjectId, layoutGroupId = group?.Id, name = Layout.Name ?? сгенерированное, rooms, totalArea = Layout.Square, livingArea = Layout.LivingSquare, схемы; INV-LAY-1/3 по построению; конфликт имени в проекте → суффикс (Talent) + warn); есть — обновить при payload.Version > layout.ReplicaVersion (INV-LAY-4/6).
    • Объект недвижимости — создание (реплики нет): номер занят → non-retriable CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN (DLQ + алерт). Иначе завести PropertyObjectEntity из снапшота — TenantId = building.TenantId (INV-CHS-004), PricePerMeter = round(Price / TotalArea, 2), StatusSource = TalentSync, StatusChangedAt = event.CreatedAt, SyncSource = TalentChessboard, начальный статус — из Talent; доменные события PropertyObjectCreatedEvent + ProjectAggregatesRecalcRequestedEvent.
    • Объект недвижимости — обновление: перезапись реплика-полей Number, Floor, Section, Rooms, TotalArea, LivingArea, WindowViews, Capacity, CeilingHeightMeters, LayoutId. Цена: цена задана и отличается → при статусе Free/Hidden — смена цены (Source = TalentSync, пересчёт PricePerMeter, PropertyObjectPriceChangedKafkaEvent); при статусе Reserved/Booked/Sold — цена не применяется (INV-CHS-008), warn + chessboard_sync_conflicts_total. Статус (INV-CHS-001): Talent Sold при текущем FreeMarkSold (Source = TalentSync; продажа вне PIN; PropertyObjectStatusChangedEvent + PropertyObjectStatusChangedKafkaEvent); Talent Sold при текущем Reserved/Booked → non-retriable CHESSBOARD_SYNC_STATUS_CONFLICT (DLQ + алерт: конкурирующая продажа против активного холда CRM — ручной разбор); прочие расхождения статуса (Hidden/Available против PIN-статуса) — no-op + warn (холды и видимость в PIN управляются CRM/Developers API).
  5. Что меняется (порядок с учётом FK): группа планировок → планировка → объект недвижимости — создаются/обновляются.
  6. Какие события фиксируются. PropertyObjectUpsertedKafkaEventpin.chessboard.property-object-upserted.v1 (контракт); при создании/изменении layout — LayoutUpsertedKafkaEventpin.chessboard.layout-upserted.v1; при создании/изменении группы — LayoutGroupUpsertedKafkaEventpin.chessboard.layout-group-upserted.v1 (контракт); при смене цены — PropertyObjectPriceChangedKafkaEvent (контракт); при Free → SoldPropertyObjectStatusChangedKafkaEvent (контракт).
  7. Согласованность. Группа + планировка + объект + отметка идемпотентности + исходящие события фиксируются в одном согласованном изменении (для батча — одно на пачку); offset подтверждается после успешной фиксации.
  8. Результат. Как A8.

Диаграмма

Батчевая обработка

Talent отдаёт объекты списками, поэтому поток событий пиками — до десятков тысяч событий при пересинке проекта. Правила:

  • consumer читает пачку событий одной партиции (max 500 событий / 5 с ожидания — конфигурация группы);
  • предзагрузка одним запросом по собранным ключам батча (объекты, планировки, группы, здания по ExternalTalent*Id) — вместо N одиночных чтений;
  • внутри батча события одного объекта недвижимости схлопываются до последнего по payload.Version (промежуточные версии не применяются — snapshot-семантика);
  • изменения аккумулируются в одном согласованном изменении на батч; ошибка отдельного события не валит батч: событие уходит в retry/DLQ индивидуально, остальные применяются;
  • пересчёт агрегатов проекта не выполняется синхронно: поднимается один ProjectAggregatesRecalcRequestedEvent на затронутый ProjectId (debounce 30 с — джоб project-aggregates-recalc);
  • первичный импорт (bootstrap пустой БД) — отдельный admin-запускаемый джоб talent-initial-import (../background/talent-initial-import.md): постранично тянет объекты по HTTP API Talent и применяет тот же маппинг снапшотов; дальнейшая актуализация — только событиями.

Идемпотентность и replay

  • На уровне доставки: по MessageId события (ключи chessboard-talent-sync:{eventType}:{MessageId}) — повторная доставка after-commit-crash не мутирует состояние.
  • Бизнес-уровень: монотонный ReplicaVersion-guard на каждой сущности — событие с Version <= ReplicaVersion пропускается (защита от out-of-order и replay целого топика).
  • Replay: да. Полный re-read топиков с offset 0 безопасен (upsert + version-guard). Runbook «пересинк проекта из Talent»: точечный replay по ключу агрегата либо запуск talent-initial-import c фильтром проекта. Replay DLQ — после ручного resolution (admin-операция «resolution конфликтов синка», область admin).

Retry и DLQ

DLQ-топик группы: pin.chessboard.talent-sync-dlq.v1 (envelope исходного события + заголовки x-dlq-reason, x-dlq-error-code, x-source-topic, x-original-offset).

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)— (exception)Бесконечный exponential backoff 1s→60s с паузой партицииНетRunbook chessboard-overview: восстановить зависимость
Застройщик не найден (out-of-order developer→project)CHESSBOARD_SYNC_DEVELOPER_NOT_FOUND6 попыток, backoff 30s → 1m → 2m → 4m → 8m → 16mДа, после исчерпанияПроверить событие developer; replay из DLQ
Проект не найден (project→building)CHESSBOARD_SYNC_PROJECT_NOT_FOUNDта же политикаДаТо же
Здание не найдено (building→propertyObject)CHESSBOARD_SYNC_BUILDING_NOT_FOUNDта же политикаДаТо же
Валюта RUB не найдена в реплике справочника (bootstrap handbook не выполнен)CHESSBOARD_SYNC_CURRENCY_NOT_RESOLVEDта же политикаДа, после исчерпанияВыполнить bootstrap/пересинк справочников (handbook-sync); replay из DLQ
Id компании занят чужим профилем (INV-DEV-2)CHESSBOARD_DEVELOPER_TALENT_DUPLICATEНетДа + алертРучной merge профилей → resolution → replay
Перенос проекта между застройщикамиCHESSBOARD_SYNC_PROJECT_DEVELOPER_CHANGEDНетДа + алертРазбор с Talent; admin-resolution
Дубль номера объекта недвижимости в здании (INV-CHS-006)CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKENНетДа + алертИсправление данных в Talent → повторное событие
Значение вне жёсткого диапазона (Floor/Rooms/Square)CHESSBOARD_SYNC_INVALID_RANGEНетДа + алертИсправление данных в Talent → replay
Talent Sold против активного холда CRM (INV-CHS-001/003)CHESSBOARD_SYNC_STATUS_CONFLICTНетДа + алерт (конкурирующая продажа)Разбор с застройщиком/CRM; ручной release или подтверждение продажи
Конфликт версии при гонке с правкой застройщика— (конфликт)3 попытки немедленно (перечитать агрегат)После исчерпанияРасследование гонки
Unknown enum (Type/Class/ConstructionStatus/Status/WindowViews/Kind)— (не ошибка)Нет — fallback + warn + метрикаПри росте chessboard_sync_unknown_enum_total — обновить маппинг (релиз)
Гео-строка не резолвится name-match-ем (страна/регион/город)— (не ошибка)Нет — id = null, display-имя из Talent, warn + chessboard_sync_geo_unresolved_totalРучное разрешение через update-project; при росте метрики — пополнение справочника handbook
Skip — не ошибка

Неизвестное значение enum (Type/Class/ConstructionStatus/Status/WindowViews/Kind) и нерезолвленная гео-строка (страна/регион/город) — не ошибки: применяются fallback-значение или id = null + warn + метрика, событие обрабатывается без DLQ.

Acceptance Criteria

  • Given реплики агрегата ещё нет, When приходит Talent*UpsertedEvent с валидным snapshot и найденным родителем, Then создана реплика (SyncSource = TalentChessboard, developer/project/building → Status = Draft; объект недвижимости — статус из Talent), опубликовано соответствующее pin.chessboard.*-upserted, ack.
  • Given реплика есть и payload.Version <= ReplicaVersion, When событие consumed, Then skipped-stale + chessboard_sync_stale_events_total, состояние не меняется (защита от out-of-order и replay топика).
  • Given событие project/building/propertyObject, а родитель (developer/project/building) ещё не доехал, When consumed, Then CHESSBOARD_SYNC_*_NOT_FOUND (retriable) → повтор по backoff → DLQ после исчерпания.
  • Given неизвестное значение enum (Type/Class/ConstructionStatus/Status/WindowViews/Kind), When применяется snapshot, Then consumer не падает: fallback-значение + warn + chessboard_sync_unknown_enum_total, событие применяется.
  • Given объект в PIN Reserved/Booked (активный холд CRM), When Talent присылает Status = Sold, Then CHESSBOARD_SYNC_STATUS_CONFLICT → DLQ + алерт (конкурирующая продажа против холда); при текущем FreeMarkSold (Source = TalentSync) применяется.
  • Given объект в PIN Reserved/Booked/Sold, When Talent присылает изменившуюся цену, Then цена не перезаписывается (INV-CHS-008), warn + chessboard_sync_conflicts_total; при Free/Hidden — цена применяется + PropertyObjectPriceChangedKafkaEvent.
  • Given дубль номера объекта в здании (INV-CHS-006) или значение вне жёсткого диапазона (Floor/Rooms/Square), When применяется, Then CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN / CHESSBOARD_SYNC_INVALID_RANGE → DLQ + алерт.
  • Given конфликт имени здания/планировки в проекте, When применяется, Then имя дополняется суффиксом (Talent) + warn + chessboard_sync_conflicts_total (событие не теряется, резолюция — admin).
  • Given пачка событий одного объекта в батче, When обрабатывается, Then схлопывается до последнего по payload.Version (промежуточные версии не применяются), один ProjectAggregatesRecalcRequestedEvent на затронутый проект.
  • Given строка региона/города Talent не матчится с репликой справочника (нет записи / архивна / неоднозначна), When событие проекта применяется, Then consumer не падает: реплика применена с RegionId/CityId = null и display-именем из строки Talent, warn + chessboard_sync_geo_unresolved_total; публикация проекта блокирована INV-PRJ-3 до ручного разрешения через update-project.
  • Given гео-ссылка проекта уже резолвлена (предыдущим событием синка либо вручную через update-project), When приходит событие с нерезолвируемой гео-строкой, Then ссылка и display-имя не сбрасываются (warn) — безуспешный матч не затирает разрешение.

Наблюдаемость и безопасность

  • Logs (structured): consumed / skipped-duplicate / skipped-stale / created / updated / price-changed / status-changed / conflict / dead-lettered с полями EventId, ExternalId, payload.Version, entity.ReplicaVersion, batch.Size. PII нет (данные недвижимости публичны/internal).
  • Metrics: chessboard_sync_lag_seconds (now − max applied envelope.CreatedAt, по топику), chessboard_sync_consume_duration_ms, chessboard_sync_batch_size, chessboard_sync_stale_events_total, chessboard_sync_retries_total, chessboard_sync_dlq_total, chessboard_sync_conflicts_total, chessboard_sync_unknown_enum_total{enum,value}, chessboard_sync_geo_unresolved_total{field} (field = country/region/city), chessboard_sync_contract_mismatch_total.
  • Traces: span приёма события → span обработки → spans обращений к хранилищу; correlation из метаданных события — в исходящие pin.chessboard.* события.
  • Alerts: лаг синка > 5 мин (SLO README chessboard); DLQ > 0; рост unknown-enum; CHESSBOARD_SYNC_STATUS_CONFLICT > 0.
  • Dashboard / Runbook: chessboard-overview; runbooks «replay DLQ синка», «пересинк проекта из Talent».
  • Безопасность: consumers работают под system-actor; запись кросс-tenant без tenant-фильтра, TenantId каждой записи выводится из данных (INV-CHS-004, INV-BLD-1, INV-PRJ-1 — fail closed при рассогласовании); доступ к топикам Talent — m2m ACL Kafka; секретов/PII в payload нет.

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

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

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