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-колонки). Гео- и валютные справочные ссылки резолвятся по локальной
реплике справочников handbook — handbook-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 group | pin-chessboard-talent-sync |
| Producer | talent.microservices.chessboard (внешняя система; контракты — upstream source of truth, schema registry Talent; имена топиков и состав payload — зафиксированное assumption README chessboard до подтверждения) |
| Топик 1 | talent.chessboard.developers.v1 — событие TalentDeveloperUpsertedEvent |
| Топик 2 | talent.chessboard.projects.v1 — событие TalentProjectUpsertedEvent |
| Топик 3 | talent.chessboard.buildings.v1 — событие TalentBuildingUpsertedEvent |
| Топик 4 | talent.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 flag | chessboard.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-guardpayload.Version > entity.ReplicaVersion(INV-DEV-4 / INV-PRJ-5 / INV-BLD-5 / INV-LAY-4 / INV-LGR-4 и guard объекта недвижимости, property-object.mdReplicaVersion).
Входные данные / payload событий
Контракты внешние (schema registry Talent — upstream source of truth); ниже — поля, которые consumers реально читают, и маппинг «поле payload → поле реплики». Целевые поля и их ограничения — дома сущностей (здесь не переописываются).
TalentDeveloperUpsertedEvent (snapshot застройщика)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Ключ поиска реплики по ExternalTalentDeveloperId; при создании → ExternalTalentDeveloperId |
TenantId | Guid | Да | При создании → 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) |
Name | string | Да | Перезапись DeveloperEntity.Name (реплика-поле, INV-DEV-3); ≤ 256 — truncate + warn |
LegalName | string? | Нет | Перезапись LegalName |
LogoFileId | Guid? | Нет | Перезапись LogoFileId (assumption: единый storage Talent/PIN, FileId переносится как есть) |
FoundedYear | short? | Нет | Перезапись FoundedYear; вне 1900..2100 → null + warn |
Version | long | Да | Монотонный guard → ReplicaVersion; LastSyncedAt = envelope.CreatedAt |
TalentProjectUpsertedEvent (snapshot проекта)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Поиск реплики по ExternalTalentProjectId; при создании → ProjectEntity.Id и ExternalTalentProjectId (id PIN = id Talent для реплик, project.md) |
DeveloperId | Guid | Да | Поиск застройщика по ExternalTalentDeveloperId → ProjectEntity.DeveloperId = TenantId; застройщика нет → retriable CHESSBOARD_SYNC_DEVELOPER_NOT_FOUND |
Name | string | Да | Перезапись Name (реплика-поле, INV-PRJ-9) |
Type | string (enum Talent) | Да | Маппинг → ProjectType; unknown → fallback ResidentialComplex + warn + chessboard_sync_unknown_enum_total (reference-data.md) |
Class | string | Да | Маппинг → ProjectClass; unknown → Comfort + warn |
ConstructionStatus | string | Да | Маппинг → ConstructionStatus; unknown → UnderConstruction + warn; регресс по порядку справочника — warn + применение (source of truth — Talent для реплика-поля) |
Region | string | Да | Строка региона для гео-резолва → RegionId/RegionName (name-match по реплике справочника, раздел «Гео-резолв и валюта (проект)») |
Stars | short? | Нет | Перезапись Stars; вне 1..5 → null + warn |
CoverImageFileId | Guid? | Нет | Перезапись 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) |
Version | long | Да | Монотонный 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 справочника не выполнен) → retriableCHESSBOARD_SYNC_CURRENCY_NOT_RESOLVED(см. Retry/DLQ). Справочная ссылка ≠ мультивалютность — RUB-only (ADR-0048).
TalentBuildingUpsertedEvent (snapshot здания/литера)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Поиск реплики по ExternalTalentBuildingId; при создании → BuildingEntity.Id и ExternalTalentBuildingId |
ProjectId | Guid | Да | Поиск проекта по ExternalTalentProjectId → BuildingEntity.ProjectId, TenantId = project.TenantId (INV-BLD-1); проекта нет → retriable CHESSBOARD_SYNC_PROJECT_NOT_FOUND |
Name | string | Да | Перезапись Name (INV-BLD-7); конфликт ux_buildings_project_name → суффикс (Talent) + warn (см. Retry/DLQ) |
Code | string? | Нет | Перезапись Code |
FloorsTotal | short | Да | Перезапись FloorsTotal; уменьшение ниже максимального занятого этажа объектов недвижимости — warn + применение с алертом CHESSBOARD_BUILDING_FLOORS_CONFLICT (данные Talent первичны для реплика-поля; конфликт — на ручной разбор) |
SectionsCount | short? | Нет | Перезапись SectionsCount |
DeliveryYear | short? | Нет | Перезапись DeliveryYear; вне 2000..2100 → null + warn |
DeliveryQuarter | short? | Нет | Перезапись DeliveryQuarter (пара с DeliveryYear; без года — null + warn) |
ConstructionStatus | string | Да | Маппинг → ConstructionStatus; unknown → UnderConstruction + warn; Delivered → building.MarkDelivered(DeliveredAt = payload.DeliveredAt ?? envelope.CreatedAt.Date) (INV-BLD-6: ReadinessPercent = 100) |
DeliveredAt | DateOnly? | Нет | → DeliveredAt при ConstructionStatus = Delivered |
ReadinessPercent | decimal | Да | Перезапись ReadinessPercent; clamp 0..100 + warn при выходе за диапазон |
CeilingHeight | decimal? | Нет | Перезапись CeilingHeightMeters; вне 2..10 → null + warn |
Version | long | Да | Монотонный guard → ReplicaVersion (INV-BLD-5) |
TalentPropertyObjectUpsertedEvent (snapshot объекта; модель PropertyObject)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Поиск реплики по ExternalTalentPropertyObjectId; при создании → PropertyObjectEntity.Id и ExternalTalentPropertyObjectId |
BuildingId | Guid | Да | Поиск здания по ExternalTalentBuildingId → PropertyObject.BuildingId, PropertyObject.ProjectId = building.ProjectId (INV-CHS-005), TenantId = building.TenantId (INV-CHS-004); здания нет → retriable CHESSBOARD_SYNC_BUILDING_NOT_FOUND |
Number | string | Да | Перезапись Number; конфликт ux_property_objects_building_number → non-retriable CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN (DLQ + алерт: дубль номера в источнике) |
Floor | short | Да | Перезапись Floor; вне -5..200 → non-retriable DLQ CHESSBOARD_SYNC_INVALID_RANGE |
Section | short? | Нет | Перезапись Section |
Rooms | short | Да | Перезапись Rooms (0 = студия); вне 0..20 → DLQ CHESSBOARD_SYNC_INVALID_RANGE |
Layout.Id | Guid? | Нет | Upsert LayoutEntity по ExternalTalentLayoutId (см. шаг D4); → PropertyObject.LayoutId (INV-CHS-007 выполняется по построению: layout создаётся в том же проекте) |
Layout.Name | string? | Нет | → LayoutEntity.Name; при null — генерация "{Rooms}К {Square} м²" + warn |
Layout.Square | decimal | Да (при наличии Layout) | → LayoutEntity.TotalArea и PropertyObject.TotalArea (обмерная площадь объекта недвижимости = типовая, пока Talent не прислал отличную); <= 0 → DLQ |
Layout.LivingSquare | decimal? | Нет | → LayoutEntity.LivingArea и PropertyObject.LivingArea; > Square → null + warn |
Layout.Group.Id | Guid? | Нет | Upsert LayoutGroupEntity по ExternalTalentLayoutGroupId → Layout.LayoutGroupId (INV-LAY-3 по построению) |
Layout.Group.Name | string? | Да (при наличии Group) | → LayoutGroupEntity.Name (реплика-поле) |
Layout.Schemas | [{FileId, Kind, Title?, SortOrder}] | Нет | → LayoutEntity.Schemas (LayoutSchemasDocument.Items); unknown Kind → Plan2D + warn (layout.md); > 20 элементов — первые 20 по SortOrder + warn |
WindowViews | string[] | Нет | → PropertyObject.WindowViews.Views (маппинг в WindowViewKind; unknown → Street + warn, дедупликация, ≤ 4 — первые 4 + warn) |
Status | string | Да | Маппинг статуса Talent → PropertyObjectStatus: Available → Free, Sold → Sold, Hidden → Hidden; unknown → Free + warn. Применение — только по правилам INV-CHS-001 (шаг D4: при создании — начальный статус; при обновлении — только Free → Sold) |
Price | decimal? | Нет | Перезапись 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 |
Capacity | short? | Нет | Перезапись Capacity; вне 1..20 → null + warn |
CeilingHeight | decimal? | Нет | Перезапись CeilingHeightMeters |
Version | long | Да | Монотонный guard → ReplicaVersion |
Алгоритм
Отдельный проход для каждого обрабатываемого события.
A. TalentDeveloperUpsertedEvent
- Контекст события. Проверить feature flag
chessboard.talent-sync.enabled(выключен → пауза партиции, события не теряются); восстановить метаданные события;Version != 1→ warn-and-proceed; проверить идемпотентность по ключуchessboard-talent-sync:developer-upserted:{MessageId}— дубль → логskipped-duplicate, ack. - Проверка входа.
IdиTenantIdзаданы;Nameнепустое (≤ 256, truncate + warn);Version >= 0;FoundedYearв1900..2100либо →null+ warn. Существование не проверяется — операция upsert. - Что читаем. Реплика застройщика по
ExternalTalentDeveloperId(может отсутствовать — первое событие); при создании дополнительно — проверка, что id (TenantId) не занят другим профилем (не Talent-репликой). - Бизнес-правила и переходы.
- Version-guard (INV-DEV-4): реплика есть и
payload.Version <= ReplicaVersion→ метрикаchessboard_sync_stale_events_total, логskipped-stale, к шагу 7 (только отметка идемпотентности). - Id-конфликт: при создании, если id занят профилем другого происхождения (
ExternalTalentDeveloperIdне совпадает) → non-retriableCHESSBOARD_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).
- Version-guard (INV-DEV-4): реплика есть и
- Что меняется. Реплика создаётся или обновляется; конкуренция «синк vs правка застройщика» разрешается оптимистической блокировкой (конфликт → retry с перечитыванием, version-guard отсеет дубль).
- Какие события фиксируются.
DeveloperUpsertedKafkaEvent→ топикpin.chessboard.developer-upserted.v1, key =developer.Id,AggregateType = Developer,ContractVersion = 1— полный snapshot (контракт). - Согласованность. Изменение реплики, отметка идемпотентности и исходящее событие фиксируются в одном согласованном изменении; offset подтверждается после успешной фиксации.
- Результат. Успех → ack; retriable-ошибка → retry по политике; non-retriable → DLQ + алерт. Метрики/логи — раздел «Наблюдаемость».
B. TalentProjectUpsertedEvent
- Контекст события. Как A1; ключ идемпотентности
chessboard-talent-sync:project-upserted:{MessageId}. - Проверка входа.
IdиDeveloperIdзаданы;Nameнепустое (≤ 256, truncate + warn);Regionнепустой;Version >= 0; маппинги enumType/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). - Что читаем. Реплика проекта по
ExternalTalentProjectId(может отсутствовать). - Бизнес-правила и переходы.
- 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-частьLocation—District/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.
- Что меняется. Реплика проекта создаётся или обновляется.
- Какие события фиксируются.
ProjectUpsertedKafkaEvent→pin.chessboard.project-upserted.v1, key =project.Id(контракт). - Согласованность. Как A7.
- Результат. Как A8.
C. TalentBuildingUpsertedEvent
- Контекст события. Как A1; ключ
chessboard-talent-sync:building-upserted:{MessageId}. - Проверка входа.
IdиProjectIdзаданы;Nameнепустое (≤ 128, truncate + warn);FloorsTotalв1..200(вне — DLQCHESSBOARD_SYNC_INVALID_RANGE);Version >= 0; диапазоныSectionsCount/DeliveryYear/DeliveryQuarter/CeilingHeight— вне диапазона →null+ warn;ReadinessPercent— clamp0..100+ warn. Родитель: проект поExternalTalentProjectIdне найден →CHESSBOARD_SYNC_PROJECT_NOT_FOUND— retriable. - Что читаем. Реплика здания по
ExternalTalentBuildingId(может отсутствовать); при создании — проверка занятости имени в проекте (INV-BLD-2). - Бизнес-правила и переходы.
- 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 первичен для реплика-поля; объекты недвижимости-нарушители — на ручной разбор).
- Что меняется. Реплика здания создаётся или обновляется.
- Какие события фиксируются.
BuildingUpsertedKafkaEvent→pin.chessboard.building-upserted.v1, key =building.Id(контракт). - Согласованность. Как A7.
- Результат. Как A8.
D. TalentPropertyObjectUpsertedEvent (объект недвижимости + layout + группа)
- Контекст события. Как A1; ключ
chessboard-talent-sync:property-object-upserted:{MessageId}. Обработка батчевая (см. раздел «Батчевая обработка»): шаги 2–7 выполняются для пачки событий одной партиции, согласованное изменение — одно на батч. - Проверка входа.
IdиBuildingIdзаданы;Numberнепустой (≤ 32);Floorв-5..200,Roomsв0..20(вне — DLQCHESSBOARD_SYNC_INVALID_RANGE); при наличииLayout—Layout.Square > 0;Version >= 0; маппинги enumStatus/WindowViews/Layout.Schemas[].Kind— unknown → fallback + warn. Родитель: здание поExternalTalentBuildingIdне найдено →CHESSBOARD_SYNC_BUILDING_NOT_FOUND— retriable (out-of-order building→propertyObject). - Что читаем (порядок: сначала справочные агрегаты, затем объект недвижимости):
- группа планировок по
ExternalTalentLayoutGroupId(при наличии группы в payload); - планировка по
ExternalTalentLayoutId(при наличииLayoutв payload); - реплика объекта по
ExternalTalentPropertyObjectId(может отсутствовать); - при создании объекта — проверка занятости номера в здании (INV-CHS-006).
- группа планировок по
- Бизнес-правила и переходы (порядок мутаций: 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): TalentSoldпри текущемFree→MarkSold(Source = TalentSync; продажа вне PIN;PropertyObjectStatusChangedEvent+PropertyObjectStatusChangedKafkaEvent); TalentSoldпри текущемReserved/Booked→ non-retriableCHESSBOARD_SYNC_STATUS_CONFLICT(DLQ + алерт: конкурирующая продажа против активного холда CRM — ручной разбор); прочие расхождения статуса (Hidden/Availableпротив PIN-статуса) — no-op + warn (холды и видимость в PIN управляются CRM/Developers API).
- Version-guard объекта: реплика есть и
- Что меняется (порядок с учётом FK): группа планировок → планировка → объект недвижимости — создаются/обновляются.
- Какие события фиксируются.
PropertyObjectUpsertedKafkaEvent→pin.chessboard.property-object-upserted.v1(контракт); при создании/изменении layout —LayoutUpsertedKafkaEvent→pin.chessboard.layout-upserted.v1; при создании/изменении группы —LayoutGroupUpsertedKafkaEvent→pin.chessboard.layout-group-upserted.v1(контракт); при смене цены —PropertyObjectPriceChangedKafkaEvent(контракт); приFree → Sold—PropertyObjectStatusChangedKafkaEvent(контракт). - Согласованность. Группа + планировка + объект + отметка идемпотентности + исходящие события фиксируются в одном согласованном изменении (для батча — одно на пачку); offset подтверждается после успешной фиксации.
- Результат. Как 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-importc фильтром проекта. 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).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook chessboard-overview: восстановить зависимость |
| Застройщик не найден (out-of-order developer→project) | CHESSBOARD_SYNC_DEVELOPER_NOT_FOUND | 6 попыток, 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 |
Неизвестное значение 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, Thenskipped-stale+chessboard_sync_stale_events_total, состояние не меняется (защита от out-of-order и replay топика). - Given событие
project/building/propertyObject, а родитель (developer/project/building) ещё не доехал, When consumed, ThenCHESSBOARD_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, ThenCHESSBOARD_SYNC_STATUS_CONFLICT→ DLQ + алерт (конкурирующая продажа против холда); при текущемFree→MarkSold(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 применяется, ThenCHESSBOARD_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 appliedenvelope.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 нет.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: BuildingEntity, Entity / Aggregate: DeveloperEntity, Entity / Aggregate: ProjectEntity
- API (1): POST /api/v1/developers/projects/{projectId}/publish — Публикация проекта
- Use Cases (2): UC-CHS-001. Создание и публикация проекта застройщиком, UC-CHS-005. Синхронизация реплики из Talent Chessboard (system)
- События (8): Event Contract: pin.chessboard.building-upserted (BuildingUpsertedKafkaEvent), Event Contract: pin.chessboard.developer-upserted (DeveloperUpsertedKafkaEvent), Event Contract: pin.chessboard.layout-group-upserted (LayoutGroupUpsertedKafkaEvent), Event Contract: pin.chessboard.layout-upserted (LayoutUpsertedKafkaEvent), Event Contract: pin.chessboard.project-upserted (ProjectUpsertedKafkaEvent), Event Contract: pin.chessboard.property-object-price-changed (PropertyObjectPriceChangedKafkaEvent), Event Contract: pin.chessboard.property-object-status-changed (PropertyObjectStatusChangedKafkaEvent), Event Contract: pin.chessboard.property-object-upserted (PropertyObjectUpsertedKafkaEvent)
- Консюмеры (1): Consumer: Identity Companies (pin-chessboard-identity-companies)
- Фоновые задачи (2): Background Job: project-aggregates-recalc (пересчёт денормализованных агрегатов), Background Job: talent-initial-import (первичная загрузка / bootstrap из Talent Chessboard)
- index.md (1): Chessboard Service (Pin.Chessboard)
- registries (1): Реестр событий PIN
Связанные документы
- Целевые сущности: developer.md, project.md, building.md, property-object.md, layout.md, layout-group.md; enum-fallback-и — reference-data.md.
- Реплика справочников для гео-резолва и валюты: handbook-replica.md (наполнение — consumer handbook-sync); ручное разрешение нерезолвленных гео-ссылок — update-project.
- Производимые события: property-object-upserted, property-object-status-changed, property-object-price-changed, project-upserted, developer-upserted, building-upserted, layout-upserted, layout-group-upserted.
- Соседний consumer: crm-property-object-status.md (статусы Резерв/Бронь/Продан от CRM).
- ADR-0052 (tenancy), ADR-0056 (simplicity-first).