Consumer: синхронизация из Talent Owners (pin-investor-owner-talent-owners-sync)
Консолидированный документ Kafka-синхронизации из внешней системы Talent Owners в контекст
investor-owner PIN. Одна consumer group обрабатывает три события Talent Owners. Дома полей целевых
сущностей — owner-mapping.md и
investor-property.md; здесь описываются только маппинг payload → поля и
алгоритмы.
Назначение
Consumer group pin-investor-owner-talent-owners-sync потребляет события Talent Owners о собственниках
(talent.owners.owners.v1) и их владениях (talent.owners.ownerships.v1) и поддерживает в PIN:
- Реестр связей Собственник ↔ Инвестор (
OwnerMappingEntity): сопоставление выполняется напрямую поPhoneNumber(E.164); если инвестора с таким телефоном ещё нет — consumer инициирует его создание вidentityчерез internal-APIprovision-investorс заданнымId = OwnerId(id собственника = id пользователя-инвестора, правило INV-OWN-001); - Портфель объектов инвестора (
InvestorPropertyEntity,Source = TalentOwnersSync): владения собственника из Talent Owners становятся записями «Мои объекты недвижимости» связанного инвестора, включая уже проданные объекты («за сколько купили/продали, сколько и когда заработали»).
Каждое применённое изменение re-publish-ится собственными событиями PIN
(pin.investor-owner.owner-linked.v1, pin.investor-owner.property-added.v1,
pin.investor-owner.property-sold.v1), чтобы downstream-контексты PIN (notifications, profile,
user-activity) не подписывались на топики Talent напрямую (правило границ — service-boundaries.md).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | investor-owner |
| Consumer group | pin-investor-owner-talent-owners-sync |
| Producer | Talent Owners (внешняя система; контракты — upstream source of truth, schema registry Talent; имена топиков и состав payload — зафиксированное assumption README investor-owner до подтверждения) |
| Топик 1 | talent.owners.owners.v1 — события OwnerUpsertedEvent, OwnerDeletedEvent |
| Топик 2 | talent.owners.ownerships.v1 — событие OwnershipUpsertedEvent (снапшот владения; удаление владения Talent не транслирует — компенсация только admin-операцией, см. ограничения) |
| ContractVersion | ожидается 1 для всех событий Talent; при несовпадении версии — warn-and-proceed (метрика investor_owner_sync_contract_mismatch_total) |
| Порядок | key Kafka: OwnerId (топик 1) / OwnershipId (топик 2) → строгий порядок per-aggregate внутри топика; кросс-топиковый порядок owner→ownership НЕ гарантирован — обрабатывается retriable-правилом INV-8 (владение ждёт связь) |
| DLQ | pin.investor-owner.talent-owners-sync-dlq.v1 (исходное событие + заголовки x-dlq-reason, x-dlq-error-code, x-source-topic, x-original-offset) |
Три события группы и их эффект в owned-состоянии контекста:
| Событие | Эффект (событие → что меняется) |
|---|---|
OwnerUpsertedEvent | Upsert связи OwnerMappingEntity; provision инвестора в identity (INV-OWN-001); переходы PendingLink → Linked / Conflict |
OwnerDeletedEvent | Soft-delete связи (DeletedAt); записи портфеля НЕ трогаются (история живёт) |
OwnershipUpsertedEvent | Создание/upsert InvestorPropertyEntity (Source = TalentOwnersSync); фиксация продажи при проданном владении |
Контекст события
- Из метаданных события доступны:
MessageId(Guid v7), время событияCreatedAt(=OccurredAtсобытия Talent), версия контракта,AggregateId(OwnerId/OwnershipIdTalent),AggregateType(Owner/Ownership). - Actor — system-actor синхронизации (не пользовательский контекст): мутируемые сущности без
пользователя-создателя (
CreatedByUserId/UpdatedByUserId = null, правило домов). Сущности контекста не tenanted (ADR-0052); record-level фильтр поInvestorUserIdдля system-actor выключен (bypass, запись любых инвесторов). - Корреляция сквозного трейса восстанавливается из метаданных события и пробрасывается в исходящие
pin.investor-owner.*события и в вызовы internal-APIidentity/chessboard. - Идемпотентность: на уровне сообщения — по
MessageId(ключinvestor-owner-talent-sync:{EventType}:{MessageId}); на бизнес-уровне — version-guard INV-6 (payload.Version > mapping.ReplicaVersion) для связей и uniqueExternalOwnershipId(INV-7) для объектов портфеля.
Входные данные / payload событий
Контракты внешние (schema registry Talent — upstream source of truth); ниже — поля, которые consumers реально читают, и маппинг в поля домов owner-mapping.md / investor-property.md.
OwnerUpsertedEvent (snapshot собственника, топик talent.owners.owners.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | Ключ поиска связи; при создании → OwnerMappingEntity.Id (= OwnerId, INV-OWN-001) и требуемый Id инвестора в запросе provision-investor |
PhoneNumber | string | Да | Нормализация E.164 → OwnerMappingEntity.PhoneNumber (ключ сопоставления); ненормализуемый → Conflict(PhoneInvalid) (INV-8 PhoneFormat) |
FullName.FirstName | string | Да | → OwnerFullName.FirstName (снапшот; ≤ 128, при превышении truncate + warn) |
FullName.LastName | string? | Нет | → OwnerFullName.LastName (null → пустая строка — допустимо домом полей) |
FullName.MiddleName | string? | Нет | → OwnerFullName.MiddleName |
Version | long | Да | Монотонный guard INV-6 → ReplicaVersion; время события → LastSyncedAt |
OwnerDeletedEvent (топик talent.owners.owners.v1)
| Поле payload | Тип | Обязательность | Как используется |
|---|---|---|---|
Id | Guid | Да | Ключ поиска связи |
Version | long | Да | Монотонный guard INV-6 |
OwnershipUpsertedEvent (snapshot владения, топик talent.owners.ownerships.v1)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | Guid | Да | → InvestorPropertyEntity.ExternalOwnershipId — ключ идемпотентности INV-7 |
OwnerId | Guid | Да | Ключ поиска связи; InvestorUserId берётся из OwnerMapping.InvestorUserId, OwnerMappingId = OwnerId |
ObjectId | Guid | Да | Стабильный id объекта недвижимости в Talent Owners; → PropertyObjectId, если PinPropertyObjectId = null (см. правило PropertyObjectId ниже) |
PinPropertyObjectId | Guid? | Нет | Id объекта недвижимости PIN (chessboard), если объект Talent сопоставлен с шахматкой; → PropertyObjectId при наличии |
ObjectTitle | string | Да | → PropertyObjectSnapshot.Title (≤ 256, truncate + warn) |
Number | string? | Нет | → PropertyObjectSnapshot.Number |
ProjectName | string? | Нет | → PropertyObjectSnapshot.Project (ref {ProjectId, ProjectName}; ProjectId — из обогащения chessboard; null, если объект недвижимости не сопоставлен с шахматкой PIN) |
BuildingName | string? | Нет | → PropertyObjectSnapshot.Building (ref {BuildingId, BuildingName}; BuildingId — из обогащения chessboard; null при отсутствии сопоставления) |
AreaTotal | decimal? | Нет | → PropertyObjectSnapshot.AreaTotal (> 0, иначе null + warn) |
Floor | int? | Нет | → PropertyObjectSnapshot.Floor (>= -5, <= 200, иначе null + warn) |
Status | string: Active / Sold | Да | Active → создание записи Active; Sold → создание сразу Sold либо фиксация продажи существующей записи; неизвестное значение → DLQ |
PurchasePrice.Amount | decimal | Да | → PurchasePrice.Amount (> 0, INV-4) |
PurchasePrice.Currency | string | Да | Код ISO-4217 Talent → резолв в PurchasePrice.CurrencyId по справочнику валют handbook (правило резолва валют, шаг C2); Currency.IsoCode резолвленной валюты → денормализованный CurrencyIsoCode записи |
PurchasedAt | DateTimeOffset | Да | → PurchasedAt (<= now() + 5 мин clock-skew allowance) |
SalePrice.Amount / SalePrice.Currency | decimal / string | Нет (обязательны при Status = Sold) | → цена продажи; код валюты резолвится тем же правилом → SalePrice.CurrencyId; RealizedProfit вычисляет агрегат (INV-5), извне не принимается |
SoldAt | DateTimeOffset? | Нет (обязателен при Status = Sold) | → дата продажи; SoldAt >= PurchasedAt (INV-3) |
Version | long | Да | Логирование/диагностика out-of-order; version-guard на записи портфеля не ведётся — порядок обеспечен Kafka key = Id + иммутабельностью покупки (INV-9) и невозвратностью Sold |
Правило PropertyObjectId: PropertyObjectId = payload.PinPropertyObjectId ?? payload.ObjectId. При
PinPropertyObjectId = null объект Talent не сопоставлен с шахматкой PIN: ProjectId/BuildingId/
DeveloperId остаются null, история цен invest-analytics и карточка каталога недоступны — карточка
портфеля показывает только PropertyObjectSnapshot (ограничение README investor-owner). Уникальность INV-1
(InvestorUserId, PropertyObjectId, Active) при этом работает по ObjectId.
Internal-API identity provision-investor
Контракт — будущий путь ../../identity/api/provision-investor.md
(конвенция); здесь — модели, которые consumer реально использует. Операция на стороне identity
идемпотентна (поиск по телефону/Id до создания), поэтому повторный вызов при retry безопасен —
распределённая транзакция не нужна (simplicity-first, ADR-0056).
Запрос ProvisionInvestorRequest:
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
RequiredUserId | Guid | Да | payload.Id (OwnerId) | Требуемый id пользователя при создании (INV-OWN-001). |
PhoneNumber | string | Да | нормализованный payload.PhoneNumber | E.164; ключ поиска существующего инвестора. |
FullName | FullNameSnapshot (FirstName, LastName, MiddleName?) | Да | payload.FullName | ФИО для создаваемого инвестора (RegisteredVia = LazyRegistration); у существующего пользователя имя НЕ перезаписывается (правило дома owner-mapping.md). |
Ответ ProvisionInvestorResponse:
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
InvestorUserId | Guid | Да | Id найденного или созданного инвестора. |
Created | bool | Да | true — пользователь создан (InvestorUserId = RequiredUserId); false — найден существующий инвестор по телефону (его Id может отличаться от RequiredUserId). |
Коды ошибок ответа → реакция consumer-а:
Код identity | Смысл | Реакция |
|---|---|---|
IDENTITY_PROVISION_USER_NOT_INVESTOR | Пользователь с телефоном существует, но Type != Investor | Связь → Conflict(MatchedUserNotInvestor) — non-retriable, в admin-очередь |
IDENTITY_PROVISION_ID_COLLISION | В identity уже есть пользователь с Id = RequiredUserId, но с другим телефоном | Связь → Conflict(IdentityIdCollision) — non-retriable, в admin-очередь |
| HTTP 5xx / timeout / недоступность | Инфраструктурный сбой | Связь сохраняется в PendingLink, отказ INVESTOR_OWNER_SYNC_IDENTITY_UNAVAILABLE — retriable |
Алгоритм
Отдельный проход для каждого из трёх событий.
A. Собственник (OwnerUpsertedEvent) → связь + provision инвестора
- Контекст события и идемпотентность. Восстановить метаданные (
MessageId,CreatedAt,AggregateId = payload.Id); сверить версию контракта с1— при несовпадении warn-and-proceed. Если сообщение с такимMessageIdуже обработано → дубль: логskipped-duplicate, ack. - Проверка входа.
Idзаполнен;FullName.FirstNameнепустое (≤ 128, truncate + warn);PhoneNumberнепустой. НормализацияPhoneNumberв E.164 — ненормализуемый номер НЕ теряет событие: связь создаётся/обновляется снапшотом и переводится вConflict(PhoneInvalid)(шаг 4), исходный номер — вConflictDetailsмаскированно. Событие — upsert, отсутствие связи допустимо. - Поиск связи и проверка телефона. Найти связь по
Id = OwnerId(включая архивированную — повторное появление удалённого собственника снимает архив, восстановление реплики). Отдельно проверить, не занят ли нормализованный телефон другой не-конфликтной связью (INV-4 UniquePhone; uniqueux_owner_mappings_phone_number— вторая линия защиты при гонке). - Бизнес-правила и переходы.
- Version-guard (INV-6, с поправкой на
PendingLink): если связь существует и версия события<= ReplicaVersionи статус связи!= PendingLink— событие устаревшее/дубль: метрикаinvestor_owner_sync_stale_events_total, логskipped-stale, фиксируется только отметка идемпотентности, ack. Записи вPendingLinkguard не пропускает: это ретрай линковки после недоступностиidentity— снапшот уже применён, нужен только шаг provision (ветка линковки ниже). - Невалидный телефон: связь создаётся (если её нет) либо обновляется снапшотом и переводится в
Conflict(PhoneInvalid)(INV-3 ConflictCompleteness: причина обязательна). Фиксируется доменный фактOwnerMappingConflictDetected. Provision не вызывается. - Конфликт телефона (INV-4): телефон уже занят другой связью → та же ветка с
Conflict(PhoneAlreadyMappedToAnotherOwner)(вConflictDetails— id конкурирующей связи, маскированный телефон). Уже установленная (Linked) связь при этом НЕ разрушается — конфликтует только новый/несвязанный собственник; разбор — admin-APIresolve-owner-mapping-conflict. - Создание (связи нет): создать
OwnerMappingEntityиз снапшота Talent —Status = PendingLink,InvestorUserId = null,Id = OwnerId,ReplicaVersion= версия события. - Обновление (связь есть, версия события новее): перезаписать снапшот-поля
OwnerFullName,PhoneNumber; обновитьReplicaVersion,LastSyncedAt; снять архив, если был. Поля состояния связи (InvestorUserId,LinkKind,LinkedAt) НЕ трогаются (INV-7 ImmutableLinkKind). Особый случай: связьLinked, телефон изменился, новый номер занят другой не-конфликтной связью — телефон НЕ перезаписывается (нарушил быux_owner_mappings_phone_number): warn + метрикаinvestor_owner_sync_conflicts_total, связь и старый номер сохраняются, разбор — Служба заботы. - Линковка (только при
Status = PendingLinkпосле применения снапшота): вызвать internal-APIidentityprovision-investor(RequiredUserId = OwnerId, нормализованный телефон, ФИО) — внешний вызов ДО локальной фиксации (идемпотентен на сторонеidentity):- успех,
Created = true→ связьPendingLink → Linked,LinkKind = InvestorCreated,InvestorUserId = OwnerId(guard INV-OWN-001InvestorUserId == Id; при нарушении —INVESTOR_OWNER_ID_MISMATCH, DLQ + алерт: identity нарушил контракт),LinkedAt = now()(INV-2 LinkedCompleteness); фиксируетсяOwnerMappingLinked; - успех,
Created = false→ связьLinked,LinkKind = ExistingInvestorMatched(расхождениеInvestorUserId != Idдопустимо и фиксируется этимLinkKind); нарушение INV-5 UniqueInvestor (инвестор уже связан с другим собственником) ловится uniqueux_owner_mappings_investor_user_idпри фиксации →Conflict(PhoneAlreadyMappedToAnotherOwner)на повторной попытке; IDENTITY_PROVISION_USER_NOT_INVESTOR→ связьConflict(MatchedUserNotInvestor);IDENTITY_PROVISION_ID_COLLISION→ связьConflict(IdentityIdCollision);- недоступность/5xx/timeout → связь остаётся
PendingLink(снапшот иReplicaVersionсохраняются), отказINVESTOR_OWNER_SYNC_IDENTITY_UNAVAILABLE— retriable; отметка идемпотентности при retriable-ошибке НЕ пишется (повторная доставка обработает линковку заново; version-guard пропуститPendingLink).
- успех,
- Version-guard (INV-6, с поправкой на
- Согласованность. Создание/обновление связи, отметка идемпотентности и (при переходе в
Linked) исходящее событие фиксируются в одном согласованном изменении; offset — после фиксации. Гонка с admin-операциями (resolve-conflict) разрешается оптимистической блокировкой: конфликт → 3 немедленные попытки с перечиткой связи. - Событие о связи. При переходе в
LinkedпубликуетсяInvestorOwnerLinkedKafkaEvent→ топикpin.investor-owner.owner-linked.v1, key = id связи; payload:OwnerId(=Id),InvestorUserId,LinkKind,LinkedAt,Version— телефон в payload НЕ передаётся (PII, правило дома owner-mapping.md). ПриConflictинтеграционное событие не публикуется (конфликт — внутренняя admin-очередь), фиксируется только доменныйOwnerMappingConflictDetected. - Результат. Успех → ack (включая ветки
Conflict— конфликт зафиксирован в модели, событие обработано). Отказ retriable → retry по политике (таблица ниже); non-retriable → DLQ + алерт.
B. Собственник удалён (OwnerDeletedEvent) → архив связи
- Контекст и идемпотентность. Метаданные из
OwnerDeletedEvent(ключinvestor-owner-talent-sync:owner-deleted:{MessageId}); дубль → skip. - Проверка входа и поиск связи.
Idзаполнен. Найти связь поId(включая архивированную). Если связи нет → лог warndelete-for-unknown-owner+ метрика, отметка идемпотентности, ack (удаление никогда не виденного собственника — replay-безопасный no-op; retry не нужен: upsert после delete невозможен по version-guard). - Version-guard и архив. INV-6 (версия события
<= ReplicaVersion→ skip-stale). Иначе — архивировать связь (DeletedAt= время события,DeletedByUserId = null, обновитьReplicaVersion,LastSyncedAt). Записи портфеля (поOwnerMappingId) не трогаются — delete behaviorrestrictдома owner-mapping.md: история покупок/заработка остаётся уInvestorUserId. Повторный delete → идемпотентный no-op. Downstream-события при архиве не публикуются: архив связи не меняет портфель (минимальность ADR-0056). - Согласованность. Архив связи и отметка идемпотентности фиксируются в одном согласованном изменении; offset — после фиксации.
- Результат. Как A7.
C. Владение (OwnershipUpsertedEvent) → объект портфеля
- Контекст и идемпотентность. Метаданные из
OwnershipUpsertedEvent(ключinvestor-owner-talent-sync:ownership-upserted:{MessageId}); дубль → skip. - Проверка входа и резолв валют.
Id,OwnerId,ObjectIdзаполнены;ObjectTitleнепустой (≤ 256, truncate + warn);PurchasePrice.Amount > 0(INV-4),PurchasedAt <= now() + 5 мин— нарушение → отказINVESTOR_OWNER_SYNC_INVALID_PAYLOAD(non-retriable → DLQ: данные Talent требуют исправления). МаппингStatus: неизвестное значение →INVESTOR_OWNER_SYNC_UNKNOWN_STATUS(non-retriable → DLQ, unknown enum handling). Резолв валют: строковый кодPurchasePrice.Currency(иSalePrice.Currencyпри продаже) сопоставляется поIsoCodeсо справочником валют handbook (../../handbook/domain/currency.md; микросправочник, кэшируется на стороне consumer-а; актуализация — через internal resolve ../../handbook/api/internal/resolve-references.md): найден →CurrencyIdрезолвленной валюты; пустой или неизвестный код → валюта по умолчаниюRUB+ warn + метрикаinvestor_owner_sync_currency_fallback_total(данные Talent де-факто рублёвые, RUB-only ADR-0048 — событие не теряется). Недоступность справочника при пустом кэше →INVESTOR_OWNER_SYNC_HANDBOOK_UNAVAILABLE— retriable. ПриStatus = Sold:SalePrice.Amount > 0,SoldAt != null,SoldAt >= PurchasedAt(INV-3), валюта продажи = валюте покупки — после резолва поCurrencyId(INV-5) — нарушение → та же веткаINVESTOR_OWNER_SYNC_INVALID_PAYLOAD. - Существование и состояние связи (INV-8 LinkedOwnerOnly). Найти связь по
OwnerId(не архивированную) — родитель проверяется первым. Если связи нет →INVESTOR_OWNER_MAPPING_NOT_FOUND— retriable (out-of-order ownership→owner: связь придёт своимOwnerUpsertedEvent). Если связь неLinked→INVESTOR_OWNER_MAPPING_NOT_LINKED— retriable (PendingLinkдоработает ветка линковки A;Conflictразрешит Служба заботы — после исчерпания retry событие ждёт в DLQ и переигрывается runbook-ом после resolution). - Поиск записи портфеля и обогащение.
- Найти запись портфеля по
ExternalOwnershipId = payload.Id(включая архивированную): архивированная заботой запись НЕ пересоздаётся (компенсирующие правки не затираются синхронизацией) — лог warnskipped-archived, отметка идемпотентности, ack. - При создании определить объект недвижимости по правилу PropertyObjectId
(
PinPropertyObjectId ?? ObjectId) и проверить дубль по объекту (INV-1, шаг 5). - При создании и заданном
PinPropertyObjectId— best-effort обогащение изchessboard(internal-API get-property-objects-by-ids, будущий путь):ProjectId,BuildingId,DeveloperId+ дозаполнение пустых полей снапшота. НедоступностьchessboardНЕ блокирует появление объекта: warn + метрика, снапшот создаётся из payload,ProjectId/BuildingId/DeveloperId = null, дозаполнение — компенсирующая admin-операция.
- Найти запись портфеля по
- Бизнес-правила и переходы.
- Дубль между источниками (INV-1 SingleActivePerLot): если по паре (
InvestorUserIdсвязи,PropertyObjectId) уже есть активная запись (обычно объект уже создан consumer-ом crm-deal-completed по сделке PIN) →INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE— non-retriable → DLQ + алерт: ручная сверка заботой (adminarchive-investor-property+ replay). Автоматический merge запрещён — финансовые данные. (uniqueux_investor_properties_investor_property_object_active— вторая линия при гонке.) - Создание (записи нет): создать
InvestorPropertyEntityиз владения —Source = TalentOwnersSync,DealId = null(INV-2 SourceConsistency), INV-4;InvestorUserId— из связи,OwnerMappingId = OwnerId,ExternalOwnershipId = payload.Id; снапшот и цены — по таблице payload (CurrencyId— из резолва валют шага 2, денормализованныйCurrencyIsoCode— код резолвленной валюты). ФиксируетсяInvestorPropertyAdded. ЕслиStatus = Sold— сразу фиксируется продажа (переход[*] → Sold; агрегат вычисляетRealizedProfit = SalePrice.Amount − PurchasePrice.Amount, INV-5); фиксируетсяInvestorPropertySold. - Upsert (запись есть): поля покупки иммутабельны (INV-9) — расхождения
PurchasePrice/PurchasedAt/PropertyObjectIdс payload логируются warn + метрикаinvestor_owner_sync_conflicts_total, но НЕ перезаписываются (исправление — admin-операция). Единственная применяемая дельта:Status = Soldв payload приActive-записи → фиксация продажи (guard-ы INV-3/INV-4/INV-5).Status = Activeпри проданной записи — no-op + warn (продажа не откатывается синхронизацией; разбор вручную). Полное совпадение — идемпотентный no-op (логskipped-no-change).
- Дубль между источниками (INV-1 SingleActivePerLot): если по паре (
- Согласованность и события. Запись портфеля, отметка идемпотентности и исходящие события фиксируются
в одном согласованном изменении; offset — после фиксации. При создании —
InvestorPropertyAddedKafkaEvent→ топикpin.investor-owner.property-added.v1, key = id записи (контракт property-added.md); при фиксации продажи (в т.ч. при создании сразу вSold) — дополнительноInvestorPropertySoldKafkaEvent→pin.investor-owner.property-sold.v1, key = id записи; payload обоих — по дому investor-property.md: денежные суммы —{Amount, CurrencyId}+CurrencySnapshotрезолвленной валюты (шаг 2), снятый на момент публикации. Гонка двух инстансов по одному владению гасится uniqueux_investor_properties_external_ownership_id(проигравший — перечитка + no-op). - Результат. Как A7.
Диаграмма
Идемпотентность и replay
- На уровне сообщения: по
MessageIdсобытия (ключиinvestor-owner-talent-sync:{eventType}:{MessageId}) — повторная доставка после сбоя не мутирует состояние; отметка пишется только при успешном исходе (retriable-ошибка оставляет событие «необработанным»). - На бизнес-уровне: связи — version-guard INV-6 (
payload.Version <= ReplicaVersionприStatus != PendingLink→ skip-stale); объекты портфеля — uniqueExternalOwnershipId(INV-7, upsert) + иммутабельность покупки (INV-9) + невозвратностьSold. - Provision: вызов
identityидемпотентен на его стороне — повторный retry вернёт уже созданного/найденного инвестора, дубль учётки невозможен. - Replay: да. Полный re-read обоих топиков с offset 0 безопасен (upsert + guard-ы); события
SoldповерхSold— no-op. Runbook — «replay DLQ синхронизации» (README investor-owner). Точечный replay DLQ — после ручного resolution (разбор конфликта связи, admin-архив дубля портфеля).
Retry и DLQ
DLQ-топик группы: pin.investor-owner.talent-owners-sync-dlq.v1.
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — | Бесконечный exponential backoff 1s→60s с паузой партиции (событие не теряется) | Нет | Runbook investor-owner-overview: восстановить зависимость |
identity provision недоступен (5xx/timeout) | INVESTOR_OWNER_SYNC_IDENTITY_UNAVAILABLE | 6 попыток, backoff 30s → 1m → 2m → 4m → 8m → 16m; связь сохранена в PendingLink | Да, после исчерпания | Проверить identity; replay из DLQ (линковка доработает PendingLink) |
| Связь не найдена (out-of-order ownership→owner) | INVESTOR_OWNER_MAPPING_NOT_FOUND | та же политика | Да | Проверить, пришёл ли OwnerUpsertedEvent; replay из DLQ |
Связь не Linked (INV-8: PendingLink/Conflict) | INVESTOR_OWNER_MAPPING_NOT_LINKED | та же политика | Да | Разрешить конфликт (admin resolve-owner-mapping-conflict) → replay из DLQ |
| Справочник валют handbook недоступен при пустом кэше (резолв кода валюты) | INVESTOR_OWNER_SYNC_HANDBOOK_UNAVAILABLE | та же политика | Да, после исчерпания | Проверить handbook; replay из DLQ |
| Невалидный payload владения (цены/даты/валюты, INV-3/4/5) | INVESTOR_OWNER_SYNC_INVALID_PAYLOAD | Нет (non-retriable) | Да + алерт | Исправление данных в Talent → повторное событие |
Неизвестный enum Status владения | INVESTOR_OWNER_SYNC_UNKNOWN_STATUS | Нет | Да + алерт | Обновить маппинг (релиз) → replay |
| Активный дубль по объекту недвижимости (INV-1, объект уже из сделки PIN) | INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE | Нет | Да + алерт | Сверка заботой: admin archive-investor-property лишней записи → replay |
identity вернул InvestorUserId != OwnerId при Created = true | INVESTOR_OWNER_ID_MISMATCH | Нет | Да + алерт | Разбор контракта provision с командой identity |
| Конфликты связи (дубль телефона / не-инвестор / коллизия id / невалидный номер) | — (Status = Conflict в модели) | Нет (не ошибка consumer-а) | Нет | Admin-очередь list-owner-mapping-conflicts → resolve-owner-mapping-conflict |
| Оптимистическая блокировка при гонке с admin-операцией | — | 3 попытки немедленно (перечитать связь/запись) | После исчерпания | Расследование гонки |
Наблюдаемость и безопасность
- Logs (structured, по событию):
consumed/skipped-duplicate/skipped-stale/skipped-archived/skipped-no-change/mapping-created/mapping-linked/mapping-conflict/property-created/property-sold/dead-letteredс полямиEventId,OwnerId/OwnershipId,payload.Version,mapping.ReplicaVersion,ConflictReason. PII маскируется: телефон+7***1234, ФИОИ*** П***; суммы цен/дохода в логи открытым текстом не пишутся. - Metrics:
investor_owner_sync_lag_seconds(now − max(LastSyncedAt), по топику),investor_owner_sync_consume_duration_ms,investor_owner_sync_stale_events_total,investor_owner_sync_retries_total,investor_owner_sync_dlq_total,investor_owner_sync_conflicts_total(+labelreason),investor_owner_sync_contract_mismatch_total,investor_owner_sync_currency_fallback_total(пустой/неизвестный код валюты Talent →RUB),investor_owner_investors_provisioned_total(+labelcreated|matched),investor_owner_properties_synced_total(+labelcreated|sold|noop). - Traces: span приёма события → span обработки → span вызовов
identity/chessboard→ spans хранилища; correlation из метаданных события пробрасывается в исходящиеpin.investor-owner.*события. - Alerts (README investor-owner): лаг синхронизации > 15 мин; DLQ > 0; рост
Status = Conflict> 10/день; ошибки provision-APIidentity. - Dashboard / Runbooks: investor-owner-overview; runbooks «replay DLQ синхронизации», «разбор конфликтов связи», «компенсирующее исправление записи портфеля».
- Безопасность: consumers работают под system-actor; вызовы
identity/chessboard— m2m-аутентификация; доступ к топикам Talent — m2m ACL Kafka; секреты входа НЕ реплицируются (создание учётки — целиком ответственностьidentity); tenant isolation не применяется (данные инвестора вне tenant, ADR-0052) — изоляция record-level поInvestorUserIdдля user-чтений, consumer пишет с bypass; телефон/ФИО не попадают в исходящиеpin.investor-owner.*события.
Известные ограничения и assumptions
- Имена топиков и состав payload Talent Owners — assumption до подтверждения schema registry Talent; расхождения правятся в этом документе без изменения домов сущностей.
- Удаление владения Talent не транслируется событием (
ownership-deletedв контракте нет) — ошибочные записи портфеля исправляются admin-операциейarchive-investor-property. - Смена телефона собственника в Talent после установки связи обновляет только снапшот
PhoneNumberи не пересматривает связь (INV-7); расхождения телефона инвестора PIN мониторит consumer identity-users.md.
Acceptance Criteria
- Given
OwnerUpsertedEventпо новому собственнику с валидным телефоном, инвестора с которым вidentityещё нет, When consume, Then создаётся связь (PendingLink), вызываетсяprovision-investor(RequiredUserId = OwnerId), приCreated = trueсвязь →Linked,LinkKind = InvestorCreated,InvestorUserId = OwnerId(INV-OWN-001), публикуетсяowner-linkedбез телефона в payload. - Given тот же поток, но инвестор с телефоном уже существует, When provision, Then
Created = false, связь →Linked,LinkKind = ExistingInvestorMatched(расхождениеInvestorUserId != OwnerIdдопустимо). - Given
identityвернул не-инвестора / коллизию id / телефон невалиден / телефон занят другой связью, When consume, Then связь →Conflict(MatchedUserNotInvestor|IdentityIdCollision|PhoneInvalid| PhoneAlreadyMappedToAnotherOwner), доменныйOwnerMappingConflictDetected, интеграционногоowner-linkedнет; событие ack (конфликт зафиксирован), разбор — admin-очередь; ужеLinked-связь при этом не рушится. - Given
identityнедоступен (5xx/timeout), When линковка, Then связь остаётсяPendingLink,INVESTOR_OWNER_SYNC_IDENTITY_UNAVAILABLE— retriable (6 попыток → DLQ), отметка идемпотентности НЕ пишется, version-guard не пропускаетPendingLink— повтор дорабатывает линковку. - Given событие
<= ReplicaVersionприStatus != PendingLink, When consume, Thenskipped-staleno-op (INV-6 version-guard), инкрементinvestor_owner_sync_stale_events_total. - Given
OwnershipUpsertedEventпришёл раньше своегоOwnerUpsertedEvent(кросс-топиковый out-of-order), When consume, ThenINVESTOR_OWNER_MAPPING_NOT_FOUND— retriable; после прихода/линковки owner replay из DLQ проходит. - Given связь есть, но не
Linked(PendingLink/Conflict), When событие владения, ThenINVESTOR_OWNER_MAPPING_NOT_LINKED(INV-8) — retriable; после resolution события переигрываются из DLQ. - Given валидное
OwnershipUpsertedEventприLinked-связи и отсутствии записи поExternalOwnershipId, When consume, Then создаётсяInvestorPropertyEntity(Source = TalentOwnersSync,OwnerMappingId = OwnerId), приStatus = Sold— сразу продажа сRealizedProfit = SalePrice − PurchasePrice(INV-5); публикуютсяproperty-added(+property-sold). - Given
PinPropertyObjectId = null, When создание объекта, ThenPropertyObjectId = ObjectId,ProjectId/BuildingId/DeveloperId = null, карточка показывает только снапшот; Givenchessboardнедоступен при обогащении, Then объект всё равно создаётся из payload (best-effort, warn). - Given владение с пустым или неизвестным справочнику handbook кодом валюты, When consume, Then
запись создаётся с валютой по умолчанию
RUB(fallback), warn + инкрементinvestor_owner_sync_currency_fallback_total; событие не теряется и в DLQ не попадает. Given справочник валют недоступен при пустом кэше, ThenINVESTOR_OWNER_SYNC_HANDBOOK_UNAVAILABLE— retriable (6 попыток → DLQ). - Given запись по
ExternalOwnershipIdуже есть, When повторное/обновляющее событие, Then идемпотентный upsert (INV-7): поля покупки иммутабельны (INV-9, расхождения только warn+метрика), применяется лишь дельтаActive → Sold; полное совпадение —skipped-no-change. - Given объект уже присутствует активной записью из сделки PIN (дубль INV-1), When событие владения,
Then
INVESTOR_OWNER_PROPERTY_ALREADY_ACTIVE— non-retriable → DLQ + алерт; авто-merge запрещён. - Given
OwnerDeletedEvent, When consume, Then связь soft-delete (DeletedAt), записи портфеля НЕ трогаются (deleterestrict, история живёт), downstream-событий нет; delete для неизвестного собственника — replay-безопасный no-op. - Given невалидный payload владения (цены/даты/валюты, INV-3/4/5) или неизвестный
Status, When consume, ThenINVESTOR_OWNER_SYNC_INVALID_PAYLOAD/INVESTOR_OWNER_SYNC_UNKNOWN_STATUS— non-retriable → DLQ + алерт.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: InvestorPropertyEntity, Entity / Aggregate: OwnerMappingEntity
- API (3): GET /api/v1/admin/owner-mappings/conflicts — Очередь конфликтов связи Собственник ↔ Инвестор (listOwnerMappingConflicts), POST /api/v1/admin/owner-mappings/{mappingId}/resolve — Ручное разрешение конфликта связи (resolveOwnerMappingConflict), POST /api/v1/admin/portfolio/properties/{propertyId}/archive — Архивирование записи портфеля (admin, archiveInvestorProperty)
- Use Cases (1): UC-OWN-001. Синхронизация собственника из Talent Owners: owner → investor по телефону (system)
- События (3): Event Contract: pin.investor-owner.owner-linked (InvestorOwnerLinkedKafkaEvent), Event Contract: pin.investor-owner.property-added (InvestorPropertyAddedKafkaEvent), Event Contract: pin.investor-owner.property-sold (InvestorPropertySoldKafkaEvent)
- Консюмеры (2): Consumer: завершённая сделка CRM → объект в портфеле инвестора (pin-investor-owner-crm-deal-completed), Consumer: контроль смены телефона связанного инвестора (pin-investor-owner-identity-users)
- index.md (1): Сервис Инвестор-Собственник (investor-owner)
- registries (1): Реестр событий PIN
Связанные документы
- Дома сущностей: owner-mapping.md (INV-OWN-001, INV-2…INV-8), investor-property.md (INV-1…INV-9).
- Производимые контракты: property-added.md;
owner-linkedиproperty-sold— owner-linked.md, property-sold.md. - Смежный consumer: crm-deal-completed.md (второй источник портфеля).
- Внешние операции:
identityprovision-investor— ../../identity/api/provision-investor.md (будущий путь);chessboardget-property-objects-by-ids— ../../chessboard/api/internal/property-objects/get-property-objects-by-ids.md (будущий путь); справочник валютhandbook— ../../handbook/domain/currency.md, internal resolve — ../../handbook/api/internal/resolve-references.md, snapshot-модели — ../../handbook/domain/snapshots.md. - Решения: ADR-0052 (инвестор вне tenant), ADR-0056 (simplicity-first: PostgreSQL-only, идемпотентный provision вместо распределённых транзакций).