Consumer: identity.users.v1 → AuthorReplica (news-identity-users)
Обработка топика pin.identity.users.v1, наполняющего read-реплику подписи автора AuthorReplica в
контексте news. Контракты входных событий — дом user-upserted.md
(UserUpsertedKafkaEvent) и user-blocked.md
(UserBlockedKafkaEvent). Целевая реплика (дом полей, enum, инвариантов) —
identity-replicas.md; здесь описывается только приём событий и маппинг.
Назначение
Consumer group news-identity-users потребляет топик pin.identity.users.v1 и поддерживает
news.author_replicas в актуальном состоянии, чтобы лента и карточка поста рендерили подпись автора
(ФИО/тип/компания/статус) одной выборкой без синхронного джойна в identity (README news,
паттерн денормализации). Обрабатывает два события общего топика:
UserUpsertedKafkaEvent— upsertAuthorReplica(создание при first-seenUserId, иначе обновление display-полей);UserBlockedKafkaEvent— переводAuthorReplica.Status = Blocked(INV-REP-002; выдача ленты помечает/скрывает посты автора по решению query-слоя).
Собственных Kafka-событий consumer не публикует (реплика — конечная точка read-модели, ADR-0056: без
событий без потребителей). PhoneNumber/Email из payload игнорируются — для подписи не нужны,
минимизация PII (identity-replicas.md, «Permissions / PII»).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | news |
| Consumer group | news-identity-users |
| Producer | identity (контракты — upstream source of truth) |
| Topic | pin.identity.users.v1 (key = UserId, порядок per-user) |
| ContractVersion | ожидается 1; при несовпадении версии события — warn-and-proceed |
| Эффект (событие → что меняется) | AuthorReplica (news.author_replicas): подпись автора |
Топик несёт два события общего семейства; они обрабатываются как два эффекта над одной репликой:
| Событие | Эффект на AuthorReplica |
|---|---|
UserUpsertedKafkaEvent | Upsert всех display-полей |
UserBlockedKafkaEvent | Status = Blocked |
Контекст события
- Из метаданных события доступны:
MessageId(дедупликация),CreatedAt, версия контракта,AggregateId = UserId,AggregateType = "User". - Actor — system-actor консюмера; реплика не tenanted (платформенные подписи, ADR-0052 п.3).
- Корреляция сквозного трейса восстанавливается из метаданных события.
- Ключ бизнес-идемпотентности:
news-identity-users:{EventType}:{MessageId}; бизнес-ключ реплики —UserId(INV-REP-004).
Входные данные / payload события
Ниже — только поля, которые consumer реально читает, и их маппинг в AuthorReplica (дом полей,
ограничений и enum — identity-replicas.md; дом контрактов событий —
user-upserted.md / user-blocked.md).
UserUpsertedKafkaEvent → AuthorReplica (upsert)
| Поле payload | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid | Да | → AuthorReplica.Id (PK = бизнес-ключ; прямая адресация без lookup) |
Name | FullNameSnapshot | Да | → DisplayName = FirstName + ' ' + LastName (trim; LastName может быть пустой → только FirstName); обрезка до varchar(384) + warning; MiddleName в подпись не входит |
Type | string (enum UserType) | Да | → Type : PostAuthorType по маппингу enum ниже |
CompanyId | Guid? | Нет | → CompanyId (null — инвестор/сотрудник; логическая ссылка, без FK, INV-REP-004) |
Status | string (enum UserStatus) | Да | → Status : AuthorReplicaStatus по маппингу ниже |
Version | long | Да | → SourceVersion (монотонный guard INV-REP-001) |
PhoneNumber, Email, RegisteredVia, ReferrerUrl | — | — | игнорируются (PII не нужен для подписи / не относится к реплике) |
CreatedAt события → SyncedAt (время исходного события); UpdatedAt = now().
UserBlockedKafkaEvent → AuthorReplica (переход статуса)
| Поле payload | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid | Да | Ключ реплики (AuthorReplica.Id) для перевода в Blocked |
Version | long | Да | → SourceVersion (монотонный guard INV-REP-001) |
CompanyId, BlockedAt, Reason | — | — | не применяются — у AuthorReplica полей причины/времени блокировки нет |
Маппинг enum (дом значений и правило — identity-replicas.md)
UserType → PostAuthorType: 1:1 по совпадающему имени (Investor/DeveloperManager/Agent/PinEmployee);PartnerManager(нет прямого соответствия) → ближайшееAgencyManager+ warning + метрикаnews_replica_unknown_enum_total; событие не теряется (additive-правило источника).UserStatus → AuthorReplicaStatus:Invited/Active→Active;Blocked→Blocked;Archived→Archived; unknown →Blocked(fail-closed).
Алгоритм
Оба события различаются только шагами 1 и 4 (какой эффект применить); шаги 2–3, 5–7 — общие.
- Контекст события. Восстановить метаданные (
MessageId,CreatedAt, версия, payload); сверить версию сContractVersion = 1— при несовпадении warn-and-proceed. Проверить бизнес-идемпотентность по ключуnews-identity-users:{EventType}:{MessageId}: дубль → логskipped-duplicate, пропуск (ack). Определить эффект: upserted → upsert; blocked → переход статуса. - Проверка входа.
UserId != Guid.Empty;Version >= 1; для upsert —Name.FirstNameнепуст. Нарушение контракта payload (обяз. поле отсутствует/пусто) →NEWS_REPLICA_INVALID_PAYLOAD(non-retriable → DLQ). Существование связаннойCompanyReplicaсинхронно не проверяется (порядок событий user/company не гарантирован, без FK, INV-REP-004). - Что читаем. Текущая строка реплики по
UserId(может отсутствовать — first-seen допустим). Для события blocked при отсутствии строки: логskipped-missing-replica, пропуск (ack) —DisplayNameиз blocked-события не восстановить (порядок per-user гарантирует предшествующий upsert; см. Assumptions). - Бизнес-правила и маппинг. Guard монотонности (INV-REP-001): если строка есть и
Versionсобытия<=сохранённогоSourceVersion→ skip + метрикаnews_replica_stale_total, логskipped-stale, пропуск (ack). Иначе:- upsert: собрать целевые поля (
DisplayName,Type,CompanyId,Status,SourceVersion,SyncedAt) по таблицам маппинга (enum-fallback с warning); - blocked:
Status = Blocked,SourceVersion = Version,SyncedAt = CreatedAtсобытия (INV-REP-002 — эффект на выдачу применяется query-слоем ленты, не здесь).
- upsert: собрать целевые поля (
- Что меняется. Upsert строки реплики (insert при first-seen, update иначе). Конкурентный доступ параллельных консюмеров user/company — оптимистическая блокировка (обновление не должно затирать более свежее).
- Согласованность. Upsert
author_replicasи отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации. - Результат. Успех → ack. Контролируемая ошибка → retry/DLQ по политике. No-op-ветки (дубль, stale-версия, отсутствующая реплика на blocked) — тоже ack.
Диаграмма
Идемпотентность и replay
- Consumer-level: дедупликация по
MessageId(ключnews-identity-users:{EventType}:{MessageId}) — повтор доставки →skipped-duplicate→ ack. - Business-level: бизнес-ключ —
UserId(INV-REP-004): upsert поIdидемпотентен; монотонный guard INV-REP-001 (Version > SourceVersion) гарантирует, что переупорядоченная/повторная доставка не откатывает состояние. - Replay: да. Повторный re-read топика (и backfill с earliest на первом деплое greenfield) безопасен —
идемпотентный ключ
UserId+ version-guard дают то же финальное состояние. Runbook —news/runbooks/(replay DLQ консюмеровidentity, READMEnews).
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — | Exponential backoff с паузой партиции | Нет | Runbook: восстановить зависимость |
| Нарушение контракта payload (обяз. поле отсутствует/пусто) | NEWS_REPLICA_INVALID_PAYLOAD | Нет (non-retriable) | Да + алерт | Исправить продьюсер identity → replay |
Систематическая ошибка маппинга (нераспознаваемая структура Name/enum вне additive-правила) | NEWS_REPLICA_MAPPING_FAILED | Нет | Да + алерт | Согласовать контракт с identity, добавить маппинг, replay |
Unknown UserType/UserStatus (additive у продьюсера) | — | Нет | Нет | Warn + fallback (AgencyManager/Blocked) + метрика; при необходимости добавить значение (релиз) |
| Гонка параллельных консюмеров (user/company на одну строку) | — | Оптимистичный ретрай (перечитать) | Нет | Автоматически |
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/skipped-stale/skipped-missing-replica/upserted/blocked/dead-letteredс полямиMessageId,UserId(=AggregateId),EventType,SourceVersion. PII: в логи попадает толькоDisplayName(ФИО автора) — маскируется по правилу producer/consumer;PhoneNumber/Emailне реплицируются и в логах отсутствуют. - Metrics:
news_replica_consume_duration_ms,news_replica_upserted_total,news_replica_blocked_total,news_replica_skipped_total{reason}(stale/duplicate/missing),news_replica_unknown_enum_total{field},news_replica_dlq_total,news_replica_lag_seconds(now − время события). - Traces: span приёма события → span обработки → span обращения к хранилищу; correlation из метаданных.
- Alerts: лаг консюмеров
identity> 1 мин (SLO READMEnews); DLQ > 0; всплескnews_replica_unknown_enum_total(drift enum источника). - Безопасность: consumer под system-actor; реплика не tenanted (платформенные подписи, ADR-0052 п.3);
топик
pin.identity.users.v1— доступ по m2m ACL Kafka (доверенные consumer-ы). Кросс-контекстной записи нет: consumer пишет толькоnews.author_replicasпоUserId.
Assumptions и ограничения
- Порядок per-user гарантирован Kafka key =
UserId(оба события общего топика упорядочены между собой, user-upserted.md/user-blocked.md), поэтомуUserBlockedприходит после регистрационногоUserUpserted(Version = 1) — реплика к моменту blocked существует. Defensiveskipped-missing-replicaоставлен на случай старта группы не с earliest. PartnerManager → AgencyManager— «ближайший» маппинг (identity публикуетPartnerManager,newsзнаетAgencyManager); фиксируется как assumption, требует сверки при появлении новых значенийUserType.- Свежесть подписи — eventual (лаг консюмера); INV-REP-002 (эффект
Blockedна выдачу) и INV-REP-003 (компании) применяются query-слоем, а не этим консюмером. AvatarFileIdв текущем контрактеuser-upsertedотсутствует — реплика получаетnullдо появления поля в источнике (additive-совместимо).
Предусловия и постусловия
- Предусловие: топик
pin.identity.users.v1доступен; consumer groupnews-identity-usersактивна;identity— единственный писатель топика (m2m ACL). Реплика может как существовать, так и отсутствовать (first-seen допустим). - Постусловие (успех upsert): строка
AuthorReplica(UserId)содержит display-поля события,SourceVersion = event.Version,SyncedAt = event.CreatedAt; ключ идемпотентности{EventType}:{MessageId}зафиксирован; offset подтверждён. Никакие поля, кроме реплики подписи, не менялись — consumer пишет толькоnews.author_replicas. - Постусловие (no-op ветки): при дубле/stale/отсутствии строки на
blockedсостояние реплики не меняется, offset подтверждён (ack), событие не попадает в DLQ.
Acceptance Criteria
- First-seen upsert. Дано реплики по
UserIdнет; когда приходитUserUpsertedKafkaEvent(Version = 1,Name.FirstNameнепуст); тогда создаётся строкаAuthorReplicaсDisplayName,Type,CompanyId,Status,SourceVersion = 1,SyncedAt = event.CreatedAt. - Монотонный guard (stale). Дано реплика с
SourceVersion = 5; когда приходит событие сVersion = 4(переупорядочение/replay); тогда строка не меняется, инкрементитсяnews_replica_stale_total, логskipped-stale, ack. - Дубликат доставки. Дано событие с
MessageId = Mуже обработано; когда то жеMдоставлено повторно; тогдаskipped-duplicate, состояние не меняется, ack. - Блокировка. Дано существует
AuthorReplicaвActive; когда приходитUserBlockedKafkaEventсо свежейVersion; тогдаStatus = Blocked,SourceVersionобновлён (эффект на выдачу — query-слой, INV-REP-002). - Blocked без реплики. Дано реплики по
UserIdнет; когда приходитUserBlockedKafkaEvent; тогдаskipped-missing-replica, ack (подпись из blocked-события не восстановить). - Unknown enum (additive у источника). Дано
UserType = PartnerManager(нет прямого соответствия); когда обрабатывается upsert; тогдаType = AgencyManager(ближайшее) + warning +news_replica_unknown_enum_total; событие не теряется.UserStatusunknown →Blocked(fail-closed). - Нарушение контракта payload. Дано обязательное поле отсутствует/пусто (напр. пустой
FirstNameв upsert); когда обрабатывается событие; тогдаNEWS_REPLICA_INVALID_PAYLOAD, без retry, в DLQ + алерт. - Идемпотентность цепочки. Дано полный replay топика с earliest; когда все события переиграны; тогда
финальное состояние реплик идентично (upsert по
UserId+ version-guard детерминированы).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: AuthorReplica + CompanyReplica
- API (1): GET /api/v1/posts — Глобальная лента + поиск
- События (7): События сервиса news: карта топиков и отклонённые события/консюмеры, Event Contract: pin.crm.lead-created (LeadCreatedEvent), Event Contract: pin.identity.user-blocked (UserBlockedKafkaEvent), Event Contract: pin.identity.user-upserted (UserUpsertedKafkaEvent), Event Contract: pin.support.ticket-created (TicketCreatedKafkaEvent), Event Contract: pin.support.ticket-message-posted (TicketMessagePostedKafkaEvent), Event Contract: pin.support.ticket-status-changed (TicketStatusChangedKafkaEvent)
- Консюмеры (1): Consumer: identity.companies.v1 → CompanyReplica
Связанные документы
- Целевая реплика (дом полей/enum/инвариантов INV-REP-001..004): identity-replicas.md;
потребитель полей типа автора — post.md (
PostAuthorType, snapshot-семантика). - Входные контракты (дом payload): user-upserted.md,
user-blocked.md; дом enum источника —
identity/domain/user.md(UserType/UserStatus). - Соседний консюмер реплик: identity-companies.md (
CompanyReplica); карта событий/консюмеров — README контекста. - ADR: 0052 tenancy, 0056 simplicity-first.