Consumer: Selections Events (UserActivitySelectionItemAddedConsumer)
Назначение
Когда инвестор добавляет объект недвижимости в подборку, selections публикует
pin.selections.selection-item-added (поэлементно). Consumer записывает в user_activity.action_events
запись AddToSelection — так действие попадает в историю действий инвестора (/profile/events), а через
собственное action-tracked — в ленту карточки лида CRM (агенту видно намерение клиента собрать подборку,
поток «ACT → CRM» service-boundaries.md). user-activity не владеет подборками — пишет производную
запись действия (source of truth — selections).
Событие selection-item-added.v1 эмитируется по событию на каждый добавленный объект недвижимости из диффа
PUT /selections/{id}/items и из POST /selections/from-favorites
(контракт); payload несёт готовые PropertyObjectId +
SelectionId, маппинг в AddToSelectionActionEvent прямой.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | user-activity (консюмеры и worker-ы) |
| Consumer | UserActivitySelectionItemAddedConsumer |
| Consumer group | pin-user-activity-selections |
| Producer | selections (операция добавления элемента подборки) |
| Обрабатываемое событие | pin.selections.selection-item-added (контракт) |
| Topic | pin.selections.selection-item-added.v1 (key = SelectionId) |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed |
| Эффект (событие → что меняется) | append-only запись AddToSelectionActionEvent (user_activity.action_events) + исходящее action-tracked |
Контекст события
- Из метаданных события доступны:
MessageId(ключ дедупа),CreatedAt(=OccurredAt),Version,AggregateId = SelectionId,AggregateType = "Selection". - Actor — system-actor консюмера. Контекст вне tenant (ADR-0052 п.2): изоляция record-level по
UserId/AnonymousId/SessionId. - Корреляция сквозного трейса восстанавливается из метаданных события и пробрасывается в исходящее
action-tracked. - Идемпотентность: consumer-level — ключ
user-activity-selections:{MessageId}; business-level — INV-ACT-004 (SourceMessageId).
Входные данные / payload события
Дом контракта — ../../selections/events/selection-item-added.md.
Consumer читает нижеперечисленное и маппит в AddToSelectionActionEvent (дом полей — action-event.md);
остальные поля payload читаются как игнорируемые additive.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
SelectionId | Guid | Да | → AddToSelectionActionEvent.SelectionId (логическая ссылка на подборку, без FK); = AggregateId, Kafka key |
PropertyObjectId | Guid | Да | → AddToSelectionActionEvent.PropertyObjectId (логическая ссылка на объект недвижимости, INV-LV-006) |
OwnerUserId | Guid | Да | Ключ резолва Active-сессии владельца; снимок UserId берётся из найденной сессии |
OccurredAt | DateTimeOffset | Да | → AddToSelectionActionEvent.OccurredAt (доменное время; server-clock rule INV-ACT-005) |
SelectionItemId, SelectionItemsCount, Version | — | Нет | не маппятся (дедуп — по MessageId; лента фиксирует факт, не счётчик/порядок) |
OwnerUserId всегда заполнен: подборка принадлежит авторизованному владельцу — гость подборки не
создаёт (INV-SEL-002; в отличие от гостевого избранного). Пустой OwnerUserId → error-лог invalid-owner
- skip (ack, не DLQ). Гостевой ветки (
AnonymousId) у этого события нет.
Алгоритм
- Приём входа и контекста. Восстановить контекст события (
MessageId,CreatedAt,Version, payload); actor = system; сверить версию сContractVersion = 1— warn-and-proceed. Consumer- идемпотентность по ключуuser-activity-selections:{MessageId}— дубль →skipped-duplicate, ack. - Проверка входа.
SelectionId,PropertyObjectId,OwnerUserId,SourceMessageIdнепустые — пустойOwnerUserId→invalid-owner, ack-skip. Существование подборки/объекта недвижимости синхронно не проверяется (кросс-контекст, логические ссылки). - Business-дедуп и резолв сессии. Если запись с этим
SourceMessageIdуже есть → ack (INV-ACT-004). РезолвActive-сессии владельца (INV-ACT-003) поOwnerUserId; фоллбэк на последнюю сессию владельца поLastActivityAt(consumer не синтезирует сессию —Deviceв payload нет).OwnerUserIdвсегда авторизован — гостевой ветки (AnonymousId) нет. Сессии нет →skipped-no-session, ack. - Бизнес-правила + маппинг. Собрать
AddToSelectionActionEvent(INV-ACT-002 required-поля):PropertyObjectId,SelectionId,SessionId = session.Id,AnonymousId = session.AnonymousId,UserId = session.UserId(снимок),OccurredAt = OccurredAt,SourceMessageId = MessageId. Запись immutable (INV-ACT-001) — переходов статуса нет. - Что меняется. Появляется append-only запись
action_events+ отметка idempotency-ключа. - Какие события фиксируются.
ActionTrackedKafkaEvent→pin.user-activity.action-tracked.v1(AggregateId = ActionEventId,ContractVersion = 1; payload зеркалит TPH:ActionType = AddToSelection,SessionId,AnonymousId,UserId?,OccurredAt,PropertyObjectId,SelectionId). Downstreamcrmпока не мапитAddToSelectionв ленту лида напрямую (no-op; см. crm-консюмеры) — событие всё равно эмитится для единства ленты действий (news-рекомендации — будущий потребитель, ADR-0054). - Согласованность. Вставка
action_events+ отметка идемпотентности + исходящее событие фиксируются в одном согласованном изменении; offset коммитится после фиксации. - Результат. Успех → ack. Контролируемые skip-ветки (дубль,
invalid-owner,skipped-no-session) — ack. Инфраструктурная ошибка → retry по политике консюмера.
Диаграмма
Идемпотентность и replay
- Consumer-level: дедуп по
MessageId(ключuser-activity-selections:{MessageId}). - Business-level (INV-ACT-004): partial-unique
ux_action_events_source_messageнаSourceMessageId = MessageId— повтор доставки ловится проверкой существования/constraint-ом → ack (одна запись на сообщение). - Событие — факт (append-only): version-guard не нужен; достаточно дедупа по
MessageId. - Replay: да — повторный re-read безопасен (idempotency-ключ + unique
SourceMessageId).
Retry и DLQ
DLQ-топик группы: pin.user-activity.selections-dlq.v1 (событие + x-dlq-reason, x-source-topic).
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
Пустой OwnerUserId | Нет — контролируемый invalid-owner, ack | Нет | Исправить selections → replay |
Нет сессии владельца (skipped-no-session) | Нет — контролируемый ack | Нет | Ожидаемо вне трекнутой сессии; не ошибка |
| Contract mismatch (несовпадение версии) | Нет — warn-and-proceed | Нет | Обновить маппинг при breaking-bump (.v2) |
Гонка SourceMessageId (unique violation) | Нет | Нет | Трактуется как дубль → ack |
| Непредвиденная ошибка обработки | 3 попытки немедленно | После исчерпания | Расследование гонки/бага |
Acceptance Criteria
| # | Given / When / Then |
|---|---|
| AC1 | Given selection-item-added с валидными SelectionId/PropertyObjectId/OwnerUserId и Active-сессией владельца When обработано Then создаётся AddToSelectionActionEvent со снимком UserId/SessionId, публикуется action-tracked (ActionType = AddToSelection) |
| AC2 | Given повторная доставка события с тем же MessageId When обработано Then запись не дублируется (дедуп по MessageId/SourceMessageId, INV-ACT-004), ack без изменений |
| AC3 | Given событие с пустым OwnerUserId (гость подборки не создаёт, INV-SEL-002) When обработано Then invalid-owner, ack-skip, без записи и без DLQ |
| AC4 | Given владелец без трекнутой сессии When резолв сессии (по OwnerUserId) пуст Then skipped-no-session, ack (не ошибка), запись не создаётся |
| AC5 | Given запись создана When downstream crm получает action-tracked Then лента лида не меняется (no-op для AddToSelection); событие всё равно эмитится для единства ленты действий (будущий потребитель news) |
| AC6 | Given PostgreSQL/Kafka недоступны When обработка падает Then бесконечный backoff-ретрай с паузой партиции; событие не теряется |
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/invalid-owner/skipped-no-session/recorded/failedс полямиMessageId,SelectionId,PropertyObjectId,SessionId. PII нет. - Metrics:
user_activity_selections_consume_duration_ms,user_activity_selections_recorded_total,user_activity_selections_skipped_total{reason},user_activity_selections_retries_total,user_activity_selections_dlq_total(0),user_activity_selections_lag_seconds. - Traces: consume-span → span обработки → spans обращений к хранилищу; correlation из метаданных события →
исходящее
action-tracked. - Alerts: лаг > 5 мин; DLQ > 0; всплеск
invalid-owner(регресс контрактаselections). - Безопасность: system-actor; вне tenant (ADR-0052 п.2), record-level-изоляция; доступ к топику
pin.selections.*— m2m ACL Kafka. Секретов/PII в payload нет.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: ActionEvent (TPH-иерархия событий действий)
- События (3): События контекста selections: карта топиков и отклонённые события, Event Contract: pin.selections.selection-item-added (SelectionItemAddedKafkaEvent), Event Contract: pin.user-activity.action-tracked (ActionTrackedKafkaEvent)
- Консюмеры (1): Consumer: Favorites Events (UserActivityFavoriteAddedConsumer / ...RemovedConsumer)
- index.md (1): Selections Service (Pin.Selections)
Связанные документы
- Дом изменяемого агрегата: ../domain/action-event.md (
AddToSelection, INV-ACT-001..006); сессия-владелец: ../domain/session.md (INV-ACT-003). - Входной контракт: ../../selections/events/selection-item-added.md (карта — ../../selections/events/README.md, ../../selections/README.md); соседний consumer действий — favorites-events.md; downstream — ../../crm/consumers/user-activity-actions.md; карта событий — ../README.md.
- ADR: 0052 tenancy, 0054 объединение user-activity, 0056 simplicity-first.