Consumer: identity.companies.v1 → CompanyReplica
Обработка топика pin.identity.companies.v1, наполняющего read-реплику компании-автора CompanyReplica.
Контракты входных событий — дом company-upserted.md и
company-suspended.md. Целевая проекция и инварианты —
дом identity-replicas.md (CompanyReplica, INV-REP-001..004).
Назначение
Consumer group news-identity-companies потребляет публичный snapshot компаний из identity и
поддерживает денормализованную подпись компании-автора поста (CompanyReplica: название, тип, лого,
статус) — чтобы лента и карточка поста отдавали автора-компанию одной выборкой без синхронного запроса
в identity (паттерн денормализации — identity-replicas.md).
CompanyUpsertedKafkaEvent → upsert строки реплики (полный snapshot); CompanySuspendedKafkaEvent →
перевод Status = Suspended (INV-REP-003: посты компании исключаются фильтром выдачи из публичных
лент, контент не удаляется). Реплика — конечная точка данных: собственных Kafka-событий consumer не
публикует (identity-replicas.md, раздел «События»).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | news |
| Consumer group | news-identity-companies |
| Producer | identity — upstream source of truth |
| Topic | pin.identity.companies.v1 (CompanyUpsertedKafkaEvent и CompanySuspendedKafkaEvent — общий топик, key = CompanyId → порядок per-company гарантирован) |
| ContractVersion | ожидается 1; при несовпадении версии события — warn-and-proceed |
| Эффект (событие → что меняется) | CompanyReplica (news.company_replicas): подпись компании-автора |
Топик несёт два события общего семейства; они обрабатываются как два эффекта над одной репликой:
| Событие | Kind (CompanyReplicaChangeKind) | Эффект |
|---|---|---|
CompanyUpsertedKafkaEvent | Upsert | Upsert CompanyReplica (полный snapshot полей) |
CompanySuspendedKafkaEvent | Suspend | Status = Suspended (INV-REP-003), прочие поля не трогает |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (
MessageId, для дедупликации), время события (CreatedAt), версия контракта, идентификатор и тип агрегата-источника (AggregateId = CompanyId,AggregateType = "Company"), payload. - Actor — system-actor консюмера (не пользовательский контекст).
CompanyReplicaне tenanted — платформенная подпись (ADR-0052 п.3). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Ключ бизнес-идемпотентности:
news-identity-companies:{Kind}:{MessageId}; business key проекции =CompanyId(upsert поIdидемпотентен, INV-REP-004) + монотонный guardSourceVersion(INV-REP-001).
Входные данные / payload события
Ниже — только поля, которые consumer реально читает, и их маппинг в CompanyReplica. Дом полей и enum —
identity-replicas.md; дом значений CompanyType/CompanyStatus —
identity/domain/company.md.
CompanyUpsertedKafkaEvent → CompanyReplica (Kind = Upsert)
| Поле payload | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
CompanyId | Guid | Да | → CompanyReplica.Id (PK, = identity CompanyId; прямая адресация без lookup) |
Name | string | Да | → CompanyReplica.Name (varchar(256); обрезка до 256 + warning) |
Type | string (enum CompanyType) | Да | → CompanyReplica.Type (enum CompanyType, реплика 1:1 additive: Developer/Agency/Broker/Partner/Pin); unknown → сохранить как есть + news_replica_unknown_enum_total |
LogoFileId | Guid? | Нет | → CompanyReplica.LogoFileId (без FK — внешний storage/CDN) |
Status | string (enum CompanyStatus) | Да | → CompanyReplica.Status (CompanyReplicaStatus, маппинг: Pending/Active → Active; Suspended/Archived → Suspended; unknown → Suspended, fail-closed) |
Version | long | Да | → CompanyReplica.SourceVersion (монотонный guard INV-REP-001) |
CreatedAt (метаданные) | DateTimeOffset | Да | → CompanyReplica.SyncedAt (лаг синка) |
Поля Slug/Requisites/SyncSource из snapshot не реплицируются — для подписи поста не нужны
(минимизация: news показывает только название/лого/тип/статус компании-автора).
CompanySuspendedKafkaEvent → CompanyReplica (Kind = Suspend)
| Поле payload | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
CompanyId | Guid | Да | Резолв строки реплики; адресация перехода |
Version | long | Да | → CompanyReplica.SourceVersion (монотонный guard INV-REP-001) |
SuspendedAt | DateTimeOffset | Да | Не хранится (у реплики нет поля; UpdatedAt ставит app) |
Reason | string (enum CompanySuspendReason) | Да | Не реплицируется (причина не влияет на подпись; остаётся в аудите identity) |
Переход всегда устанавливает CompanyReplica.Status = Suspended (Kind Suspend — сигнальное событие).
Алгоритм
Оба события различаются только шагами 1 и 4 (какой эффект применить); шаги 2–3, 5–8 — общие.
- Контекст события. Восстановить метаданные (
MessageId,CreatedAt, версия,AggregateId, payload); сверить версию сContractVersion = 1— при несовпадении warn-and-proceed. Проверить бизнес-идемпотентность по ключуnews-identity-companies:{Kind}:{MessageId}: дубль → логskipped-duplicate, пропуск (ack). Определить эффект: upserted →Kind = Upsert(полный snapshot); suspended →Kind = Suspend. - Проверка входа.
CompanyId != Guid.Empty;SourceVersion >= 1; дляKind = Upsert—Nameнепустой,Typeприсутствует. Нарушение контракта payload (обяз. поле отсутствует) →NEWS_REPLICA_INVALID_PAYLOAD(non-retriable → DLQ). - Что читаем. Текущая строка реплики по
CompanyId(может отсутствовать при первомUpsert). ДляKind = Suspendпри отсутствии строки (аномалия — порядок per-company гарантирован ключом) → логsuspended-missing-replica, пропуск (ack); последующийUpsertвосстановит строку. - Бизнес-правила и маппинг. Guard монотонности INV-REP-001: если строка есть и
SourceVersionсобытия<=сохранённого → устаревшее/повторное событие → логskipped-stale, метрикаnews_replica_stale_total, пропуск (ack), мутаций нет. Иначе:Kind = Upsert: собрать целевую строку —Id = CompanyId,Name,Type(CompanyType),LogoFileId,Status(маппингCompanyStatus→CompanyReplicaStatus),SourceVersion = Version,SyncedAt = CreatedAtсобытия (таблица выше);Kind = Suspend: на существующей строкеStatus = Suspended(INV-REP-003),SourceVersion = Version, прочие поля (Name/Type/LogoFileId) неизменны.
- Что меняется. Upsert (Insert/Update по
Id) либо обновление статуса существующей строки. Конкурентный доступ параллельных консюмеров — оптимистическая блокировка (обновление не должно затирать более свежее). - Согласованность. Мутация
company_replicasи отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации. - Результат. Успех → ack. Контролируемая ошибка → retry/DLQ по политике. No-op-ветки (дубль,
skipped-stale,suspended-missing-replica) — тоже ack.
Диаграмма
Предусловия и постусловия
Предусловия. Топик pin.identity.companies.v1 доставляет события с key = CompanyId — порядок событий одной
компании гарантирован; identity — upstream source of truth. CompanyReplica может отсутствовать до первого
Upsert (и допустимо отсутствует при Suspend до создающего Upsert — аномалия ретеншена → no-op ack).
Постусловия (после обработки события с SourceVersion новее сохранённого):
Upsert: строкаCompanyReplicaпоId = CompanyIdотражаетName/Type/LogoFileId/Status(маппингCompanyStatus → CompanyReplicaStatus)/SourceVersion/SyncedAtиз snapshot; лента и карточка поста показывают свежую подпись компании-автора одной выборкой в пределах лага консюмера (< 1 мин).Suspend:Status = Suspended(INV-REP-003),SourceVersionобновлён, прочие поля не тронуты; посты компании исключаются из публичных лент фильтром выдачи query-слоя, контент не удаляется.- Устаревшее/повторное событие (
SourceVersion <=сохранённого) состояние реплики не меняет (INV-REP-001, монотонный guard); offset подтверждается только после фиксации мутации реплики и отметки идемпотентности в одном согласованном изменении. - Нарушение контракта payload (обяз. поле отсутствует) не мутирует реплику → запись уходит в DLQ
(
NEWS_REPLICA_INVALID_PAYLOAD), offset не подтверждается как обработанный успешно. - Консюмер собственных Kafka-событий не публикует —
CompanyReplicaконечная точка данных.
Acceptance Criteria
- First-seen upsert. Дано: реплики по
CompanyIdнет. Когда: приходитCompanyUpsertedKafkaEvent(Version = 1,Nameнепуст,Typeзадан). Тогда: создаётся строкаCompanyReplicaсName/Type/LogoFileId/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 (INV-REP-004 — upsert поIdидемпотентен). - Suspend. Дано: существует
CompanyReplicaвActive. Когда: приходитCompanySuspendedKafkaEventсо свежейVersion. Тогда:Status = Suspended,SourceVersionобновлён,Name/Type/LogoFileIdне тронуты; посты компании исключаются из публичных лент query-слоем (INV-REP-003). - Suspend без реплики. Дано: реплики по
CompanyIdнет. Когда: приходитCompanySuspendedKafkaEvent. Тогда:suspended-missing-replica, ack (строку восстановит последующийUpsert). - Reactivate. Дано: реплика в
Suspended. Когда: приходитCompanyUpsertedKafkaEventсоStatus = Activeи свежейVersion. Тогда:Status = Active(отдельного событияSuspended → Activeнет — приходит обычный upsert, INV-REP-001). - Unknown enum (additive у источника). Дано:
Typeбез прямого соответствия. Когда: обрабатывается upsert. Тогда:Typeсохранён как есть + warning +news_replica_unknown_enum_total;Statusunknown →Suspended(fail-closed); событие не теряется. - Битый payload. Дано: в событии отсутствует обязательное поле. Когда: обработка. Тогда:
NEWS_REPLICA_INVALID_PAYLOAD→ DLQ + алерт, реплика не мутирована.
Идемпотентность и replay
- Consumer-level: дедупликация по
MessageId(ключnews-identity-companies:{Kind}:{MessageId}). - Business-level: business key проекции =
CompanyId; upsert поIdидемпотентен (INV-REP-004), повтор доставки не меняет состояние. Монотонный guardSourceVersion(INV-REP-001) отсекает устаревшие/переставленные события. - Replay: да. Повторный re-read топика безопасен (идемпотентный ключ + upsert-by-
Id+ version-guard); runbook «replay DLQ консюмеровidentity» — README контекста, раздел «Runtime».
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
| Нарушение контракта payload (обяз. поле отсутствует) | NEWS_REPLICA_INVALID_PAYLOAD | Нет (non-retriable) | Да + алерт | Исправить продьюсер identity → replay |
Устаревшее событие (SourceVersion <= сохранённого) | — | Нет | Нет | No-op ack (INV-REP-001, ожидаемо при replay/переупорядочивании) |
Unknown enum Type/Status (additive у продьюсера) | — | Нет | Нет | Warn + fallback (Type как есть; Status → Suspended); при необходимости добавить маппинг (релиз) |
Suspend для отсутствующей строки | — | Нет | Нет | No-op ack (suspended-missing-replica); строку восстановит Upsert |
| Гонка параллельных консюмеров (конфликт оптимистической блокировки) | — | Да (короткий backoff, перечитать) | Нет | Автоповтор; при исчерпании — как инфраструктурный |
Политика retry/DLQ единообразна с консюмером пользователей identity-users.md
(тот же топик-семейство identity, тот же паттерн реплики).
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/skipped-stale/suspended-applied/suspended-missing-replica/dead-letteredс полямиMessageId,CompanyId,Kind,SourceVersion. PII нет — реплицируются только публичные display-поля (Name/Type/LogoFileId);Requisites/Slug/SyncSourceне реплицируются. - Metrics:
news_identity_companies_consume_duration_ms,news_replica_upserted_total{kind},news_replica_suspended_total,news_replica_stale_total(INV-REP-001 skip),news_replica_unknown_enum_total,news_replica_retries_total,news_replica_dlq_total,news_replica_lag_seconds(now − время события). - Traces: span приёма события → span обработки → span обращения к хранилищу; correlation из метаданных.
- Alerts: лаг > 1 мин (SLO README «лаг консюмеров
identity< 1 мин»); DLQ > 0; всплескnews_replica_unknown_enum_total(drift enum источника). - Безопасность: consumer под system-actor;
CompanyReplicaне tenanted (платформенная подпись, ADR-0052 п.3), кросс-tenant-записи нет по построению. Топикpin.identity.companies.v1— платформенный, доступ — m2m ACL Kafka;identityпубликует display-поля доверенным consumer-ам (company-upserted.md, раздел «Безопасность»).
Assumptions и ограничения
- Порядок событий одной компании гарантирован общим Kafka key =
CompanyId(оба типа — один топик); поэтомуSuspendожидается после создающегоUpsert. Отсутствие строки наSuspendтрактуется как аномалия ретеншена → no-op ack (см. Retry). - Обратный переход
Suspended → Activeотдельного события не имеет — приходит обычныйCompanyUpsertedKafkaEventсоStatus = Active(INV-REP-001 переводит реплику обратно вActive). - INV-REP-003 (исключение постов компании из публичных лент) — правило выдачи query-слоя по
CompanyReplica.Status = Suspended; consumer только выставляет статус, посты физически не трогает. - Sibling-консюмер identity-users.md наполняет
AuthorReplicaизpin.identity.users.v1— отдельный документ (разные топик/проекция).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: AuthorReplica + CompanyReplica
- API (1): GET /api/v1/posts — Глобальная лента + поиск
- События (3): События сервиса news: карта топиков и отклонённые события/консюмеры, Event Contract: pin.identity.company-suspended (CompanySuspendedKafkaEvent), Event Contract: pin.identity.company-upserted (CompanyUpsertedKafkaEvent)
- Консюмеры (1): Consumer: identity.users.v1 → AuthorReplica (news-identity-users)
Связанные документы
- Целевая проекция и инварианты: identity-replicas.md (
CompanyReplica, INV-REP-001..004, маппингCompanyStatus→CompanyReplicaStatus); потребитель полей — post.md (AuthorCompanyId). - Входные контракты (дом полей): company-upserted.md,
company-suspended.md; дом enum —
identity/domain/company.md(CompanyType/CompanyStatus). - Sibling-консюмер реплик: identity-users.md; карта событий/консюмеров — README контекста.
- ADR: 0052 tenancy, 0056 simplicity-first.