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

Consumer: Calendar Identity User Sync (calendar-identity-users)

Назначение

Консюмер поддерживает в контексте calendar display-реплику ФИО пользователей: имя агента на публичной витрине свободных слотов, ФИО инвестора и агента в списках записей и в календаре команды — без синхронного запроса в identity. Идентити — source of truth ФИО; календарь хранит копию только отображаемого имени. Реплика ведётся по всем пользователям платформы (агент/консультант/инвестор — любой может стать участником записи), обновляется last-write-wins по версии. Собственных интеграционных событий консюмер не публикует.

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

ПолеЗначение
Сервис/контекстcalendar
ConsumerCalendar Identity User Sync
Обрабатываемое событиеpin.identity.user-upserted
Topicpin.identity.users.v1 (общий с событием блокировки пользователя; key = UserId — порядок per-user; дом — user-upserted.md)
ContractVersion1; при несовпадении — warn-and-proceed (метрика calendar_identity_sync_contract_mismatch_total)
Consumer groupcalendar-identity-users
Produceridentity (регистрация — в т.ч. lazy-регистрация анонима при самозаписи, обновление профиля, синк Talent, admin API)
Эффект (событие → что меняется)Upsert строки display-реплики UserDisplayReplicaEntity

Границы scope. Календарь хранит только ФИО. Телефон, Type, CompanyId, Status, RegisteredVia, ReferrerUrl не читаются и не реплицируются (data minimization: спискам и витрине нужно только отображаемое имя; контактные данные — забота identity/profile). Событие блокировки пользователя из общего топика игнорируется: блокировка на display-имя не влияет, доступ заблокированного пользователя отсекает identity (JWT). Права и permissions календарь тоже не реплицирует — они проверяются по токену.

Проекция UserDisplayReplicaEntity (calendar.user_display_replicas)

Consumer-owned проекция; дом полей — этот документ. Читатели — выборки витрины слотов (display-имя агента), списки записей и событий (ФИО инвестора/агента), календарь команды.

ПолеТипОбязательностьОграниченияИсточникОписание
UserIdGuidДаPKpayloadПользователь identity.
FirstNamestringДаvarchar(128)Name.FirstNameИмя.
LastNamestringДаvarchar(128); пустая строка допустима (lazy-регистрация)Name.LastNameФамилия.
MiddleNamestring?Нетvarchar(128)Name.MiddleNameОтчество.
IdentityUserVersionlongДамонотонный guardVersionПоследняя применённая доменная версия identity (business-идемпотентность).
CreatedAtDateTimeOffsetДаconsumerСоздание строки реплики.
UpdatedAtDateTimeOffsetДаconsumerПоследнее применённое событие.

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

Контракт — дом user-upserted.md (payload UserUpsertedKafkaEvent + вложенная FullNameSnapshot). Ниже — только поля, которые консюмер реально читает.

Поле payloadТипОбязательностьКак используется (маппинг)
UserIdGuidДаКлюч upsert-а строки реплики.
NameFullNameSnapshotДаFirstName/LastName/MiddleName поэлементно (LastName допускает пустую строку).
VersionlongДаМонотонный guard → IdentityUserVersion.

Алгоритм

  1. Контекст события. Восстановить метаданные (идентификатор сообщения, UserId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключу calendar-identity-users:{MessageId}: уже обработано → пропустить как дубль (ack).
  2. Проверка входа. UserId задан; Name.FirstName непустое (≤ 128; при превышении — truncate + warn, событие не теряется); Version ≥ 1. Нарушение → CAL_IDENTITY_SYNC_INVALID_PAYLOAD (non-retriable → DLQ + алерт).
  3. Version-guard. Строка реплики существует и Version ≤ IdentityUserVersion → устаревшее/дубль: лог skipped-stale, метрика, ack.
  4. Upsert. First-seen UserId → строка создаётся; известный → display-поля замещаются целиком (полный snapshot, не мёрж), IdentityUserVersionVersion. Анонимизированный snapshot (admin purge в identity) применяется тем же путём — PII-очистка распространяется на реплику.
  5. Согласованность. Upsert и отметка идемпотентности — в одном согласованном изменении; offset подтверждается после фиксации.
  6. Результат. Успех → ack (включая skipped-stale); retriable-ошибка → retry по политике; non-retriable → DLQ + алерт. Freshness: реплика eventually consistent, SLA < 1 мин (README, SLO лага consumer-ов).

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

  • На уровне консюмера: ключ calendar-identity-users:{MessageId} — повторная доставка не меняет состояние.
  • На бизнес-уровне: монотонный IdentityUserVersion-guard — защита от out-of-order (после retry) и replay (last-write-wins по key = UserId).
  • Replay: да. Полный повторный проход топика с offset 0 безопасен: per-key порядок + version-guard сходятся к последнему snapshot-у. Runbook — «пересинхронизация display-реплики пользователей календаря» (docs/06-services/calendar/runbooks/, будущий путь по конвенции).

Retry и DLQ

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)— (exception)Бесконечный exponential backoff 1s→60s с паузой партицииНетRunbook: восстановить зависимость
Отсутствуют обязательные поля / нераспознаваемый payloadCAL_IDENTITY_SYNC_INVALID_PAYLOADНетДа + алертИсправление данных в identity → повторное событие
Конкурентная правка строки (два события одного пользователя на разных партициях невозможны — key = UserId; конфликт только при replay параллельно с live)3 попытки немедленно (перечитать строку)После исчерпанияРасследование
ContractVersion mismatchwarn-and-proceedНетПроверить версию контракта producer-а

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

  • Logs (structured): consumed / skipped-duplicate / skipped-stale / upserted / failed — с MessageId, UserId, payload.Version. ФИО в логах маскируется (инициалы), телефон в payload есть, но консюмером не читается и в логи не попадает.
  • Metrics: calendar_identity_sync_consume_duration_ms, calendar_identity_sync_upserted_total, calendar_identity_sync_stale_total, calendar_identity_sync_dlq_total, calendar_identity_sync_contract_mismatch_total, calendar_identity_sync_lag_seconds.
  • Traces: span приёма → span обработки → span записи; корреляция из метаданных события.
  • Alerts: лаг консюмера > 5 мин; DLQ > 0.
  • Безопасность: system-actor; реплика — platform-scope справочник без tenant-принадлежности (ФИО пользователя не является tenant-данными; выборки календаря применяют собственные tenant/record-level правила поверх). Топик доступен по m2m ACL Kafka (доверенный consumer). Наружу (публичная витрина слотов) отдаётся только имя агента-владельца опубликованных слотов; ФИО инвесторов в публичных ответах не отдаются (availability-slot.md, «Permissions / PII»).

Acceptance Criteria

  • AC-1 (first-seen). Дано: реплики пользователя нет. Когда: user-upserted (Version = 1, регистрация). Тогда: строка создана с ФИО и IdentityUserVersion = 1, ack.
  • AC-2 (обновление). Дано: строка с IdentityUserVersion = 3. Когда: событие с Version = 5 и новым ФИО. Тогда: display-поля замещены целиком, IdentityUserVersion = 5.
  • AC-3 (устаревшая версия). Дано: строка с IdentityUserVersion = 5. Когда: событие с Version ≤ 5. Тогда: skipped-stale, строка без изменений, ack.
  • AC-4 (анонимизация). Дано: admin purge в identity опубликовал анонимизированный snapshot с наибольшей версией. Когда: обработка. Тогда: ФИО в реплике замещено анонимизированным значением — PII в календаре не переживает удаление в identity.
  • AC-5 (дубль). Дано: уже обработанный MessageId. Когда: повторная доставка. Тогда: skipped-duplicate, без изменений, ack.
  • AC-6 (игнор события блокировки). Дано: в общем топике приходит событие блокировки пользователя. Когда: доставка. Тогда: консюмер его игнорирует, реплика не меняется.
  • AC-7 (невалидный payload). Дано: событие без Name.FirstName. Когда: обработка. Тогда: CAL_IDENTITY_SYNC_INVALID_PAYLOAD → DLQ + алерт, остальные пользователи не блокируются.

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

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

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