Consumer: Favorites Events (UserActivityFavoriteAddedConsumer / ...RemovedConsumer)
Назначение
favorites при добавлении/удалении объекта недвижимости в избранное публикует pin.favorites.favorite-added /
favorite-removed. Consumer-ы записывают в user_activity.action_events типизированные записи
AddToFavorites / RemoveFromFavorites — так лайк/анлайк попадает в историю действий инвестора
(/profile/events), а через собственное событие action-tracked — в ленту карточки лида CRM (агент видит и
рост, и охлаждение интереса к объекту недвижимости, поток «ACT → CRM» service-boundaries.md). user-activity
не владеет избранным — пишет производную запись действия (source of truth — favorites).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | user-activity (хост Pin.UserActivity.Background — консюмеры и worker-ы) |
| Consumer group | pin-user-activity-favorites |
| Producer | favorites: POST /favorites, POST /favorites/import, DELETE /favorites/{propertyObjectId} |
| Топик 1 | pin.favorites.favorite-added.v1 — FavoriteAddedKafkaEvent (6 партиций, key = FavoriteId) |
| Топик 2 | pin.favorites.favorite-removed.v1 — FavoriteRemovedKafkaEvent (6 партиций, key = FavoriteId) |
| ContractVersion | 1; при несовпадении версии события — warn-and-proceed (апкаста нет) |
| Эффект (событие → что меняется) | новая производная запись ActionEvent в ленте действий + исходящее action-tracked (source of truth избранного — favorites) |
Группа обрабатывает два события одним общим правилом; тип записи ленты определяется событием (паттерн как в ../../crm/consumers/user-activity-actions.md):
| Событие | Эффект — запись ленты (ActionEventType) |
|---|---|
favorite-added (pin.favorites.favorite-added.v1) | AddToFavoritesActionEvent (ActionType = AddToFavorites) |
favorite-removed (pin.favorites.favorite-removed.v1) | RemoveFromFavoritesActionEvent (ActionType = RemoveFromFavorites) |
Контекст события
- Из метаданных события доступны:
MessageId(Guid v7 — ключ дедупа),CreatedAt(=OccurredAtдействия), версия контракта,AggregateId = FavoriteId,AggregateType = "Favorite". - Actor — system-actor консюмера (не пользовательский контекст). Контекст вне tenant (ADR-0052 п.2):
tenant-интерсепторы не подключаются; изоляция record-level по
AnonymousId/UserId/SessionId. - Correlation: сквозной trace на consume; correlation из метаданных события пробрасывается в исходящее
pin.user-activity.action-tracked.v1. - Идемпотентность: consumer-level — по ключу
user-activity-favorites:{ActionType}:{MessageId}; business-level — INV-ACT-004 (SourceMessageId).
Входные данные / payload события
Оба события имеют идентичную форму (дом полей — контракты favorites). Ниже — только поля, которые consumer
реально читает, и их маппинг в подтип ActionEvent (дом полей — action-event.md).
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
FavoriteId | Guid | Да | → *ActionEvent.FavoriteId (логическая ссылка на favorites, без FK) |
PropertyObjectId | Guid | Да | → *ActionEvent.PropertyObjectId (логическая ссылка на объект недвижимости, существование не проверяется, INV-LV-006) |
UserId | Guid? | Условно (XOR с AnonymousId, INV-FAV-001) | Ключ резолва сессии владельца (при AnonymousId == null); снимок UserId берётся из найденной сессии |
AnonymousId | Guid? | Условно (XOR с UserId) | Ключ резолва Active-сессии устройства по AnonymousId |
OccurredAt | DateTimeOffset | Да | → *ActionEvent.OccurredAt (доменное время действия; сервер клиентский timestamp не подменяет, INV-ACT-005) |
Инвариант payload: заполнен ровно один из UserId / AnonymousId; оба null/оба заданы — контрактная
ошибка producer-а → error-лог + skip (ack, не DLQ — ретрай не восстановит данные, правило favorites).
Алгоритм
Оба события обрабатываются одним правилом; различие — только тип создаваемой записи (ActionType по событию).
- Контекст события. Восстановить метаданные (
MessageId,CreatedAt/OccurredAt, версия, payload); actor = system; сверить версию сContractVersion = 1— при несовпадении warn-and-proceed. ОпределитьActionTypeпо событию (AddToFavorites/RemoveFromFavorites). Consumer-идемпотентность по ключуuser-activity-favorites:{ActionType}:{MessageId}— дубль → логskipped-duplicate, ack (no-op). - Проверка входа.
FavoriteId != Guid.Empty,PropertyObjectId != Guid.Empty,SourceMessageId != Guid.Empty; заполнен ровно один изUserId/AnonymousId(XOR, INV-FAV-001) — нарушение → логinvalid-owner, ack (skip, не DLQ). Существованиеfavorites.favorites(FavoriteId)/объекта недвижимости синхронно не проверяется (кросс-контекст, логические ссылки). - Что читаем. Business-дедуп: запись с этим
SourceMessageIdуже есть → ack (no-op, INV-ACT-004). Резолв сессии-владельца (INV-ACT-003): поAnonymousId(илиUserId) —Active-сессия; если её нет — последняя сессия владельца поLastActivityAt. Действие якорится к существующей сессии; consumer не синтезирует сессию (в payload нетDevice, ср. identity-signed-in.md ветка A). Сессии нет вовсе → логskipped-no-session, ack. - Бизнес-правила и маппинг. По
ActionTypeсоздаётся подтип (INV-ACT-002 required-поля):AddToFavoritesActionEvent/RemoveFromFavoritesActionEventсPropertyObjectId,FavoriteId,SessionId,AnonymousId,UserId(снимок сессии на момент действия),OccurredAt = OccurredAtсобытия,SourceMessageId = MessageId. Запись immutable (INV-ACT-001) — переходов статуса нет. - Что меняется. Новая строка
ActionEventв ленте действий + отметка идемпотентности. - Какие события фиксируются.
ActionTrackedKafkaEvent→pin.user-activity.action-tracked.v1(AggregateType = "ActionEvent",ContractVersion = 1; payload зеркалит поля:ActionType,SessionId,AnonymousId,UserId?,OccurredAt,PropertyObjectId,FavoriteId) через outbox. Потребитель CRM переводитAddToFavorites → FavoriteAdded;RemoveFromFavoritesв ленте лида пока не мапится (no-op downstream, ../../crm/consumers/user-activity-actions.md). - Согласованность. Вставка
action_events, отметка идемпотентности и запись outbox фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации. - Результат. Успех → ack. Контролируемые skip-ветки (дубль,
invalid-owner,skipped-no-session) — тоже ack. Инфраструктурная ошибка → retry по политике консюмера (см. «Retry и DLQ»).
Диаграмма
Идемпотентность и replay
- Consumer-level: дедуп по
MessageId(ключuser-activity-favorites:{ActionType}:{MessageId}). - Business-level (INV-ACT-004): partial-unique
ux_action_events_source_messageнаSourceMessageId = MessageId— повтор at-least-once после after-commit-crash ловится проверкой/constraint-ом → трактуется как дубль → ack. Одна запись действия на одно исходное сообщение. - Событие — факт (append-only): version-guard состояния не нужен (правило favorite-*.md), достаточно дедупа.
- Replay: да — повторный re-read топиков безопасен (idempotency-ключ + unique
SourceMessageId).
Retry и DLQ
DLQ-топик группы: pin.user-activity.favorites-dlq.v1 (envelope + x-dlq-reason, x-source-topic).
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
Нарушение XOR UserId/AnonymousId (контракт producer-а) | Нет — контролируемый invalid-owner, ack | Нет | Исправить favorites → replay при необходимости |
Нет сессии владельца (skipped-no-session) | Нет — контролируемый ack | Нет | Ожидаемо для лайка вне трекнутой сессии; не ошибка |
Contract mismatch (envelope.Version != 1) | Нет — warn-and-proceed | Нет | Обновить маппинг при breaking-bump (.v2) |
Гонка SourceMessageId (unique violation) | Нет | Нет | Трактуется как дубль → ack |
| Непредвиденная ошибка обработки | 3 попытки немедленно | После исчерпания | Расследование гонки/бага |
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/invalid-owner/skipped-no-session/recorded/failedс полямиMessageId,FavoriteId,PropertyObjectId,SessionId,ActionType. PII нет —Favorite.Noteв payload отсутствует и не логируется. - Metrics:
user_activity_favorites_consume_duration_ms,user_activity_favorites_recorded_total{action_type},user_activity_favorites_skipped_total{reason},user_activity_favorites_retries_total,user_activity_favorites_dlq_total(ожидается 0),user_activity_favorites_lag_seconds(now −envelope.CreatedAt). - Traces: consume-span → handler-span → DB-spans; correlation из envelope пробрасывается в outbox
action-tracked. - Alerts: лаг > 5 мин (SLO README); DLQ > 0; всплеск
invalid-owner(регресс контрактаfavorites). - Безопасность: system-actor (не внешний); контекст вне tenant (ADR-0052 п.2) — изоляция record-level;
доступ к топикам
pin.favorites.*— m2m ACL Kafka. Событие не содержит secrets/токенов/заметок.
Предусловия, окно согласованности и постусловия
- Предусловие: объект недвижимости добавлен/удалён из избранного в
favorites(source of truth), событиеfavorite-added/favorite-removedопубликовано; у устройства/пользователя есть трекнутая сессия (иначе действие не привяжется —skipped-no-session). - Постусловие: в ленте действий владельца появилась ровно одна запись
AddToFavorites/RemoveFromFavoritesна исходное сообщение (INV-ACT-004); опубликованоaction-tracked. - Окно согласованности: цепочка асинхронна —
favorites→ (Kafka) → этот consumer → (Kafkaaction-tracked) →crm. Инвестор видит лайк в своей истории/profile/eventsпосле обработки consumer-ом (лаг обычно в пределах SLO README); агент видитFavoriteAddedв ленте лида после дополнительного лага CRM-консюмера.RemoveFromFavoritesв ленту лида не попадает (downstream no-op), но в истории инвестора присутствует. В промежуточном состоянии (событие принято, но ещё не обработано) действие в истории не видно — это ожидаемая eventual-consistency, не потеря.
Acceptance Criteria
| # | Given / When / Then |
|---|---|
| AC1 | Given favorite-added с валидными FavoriteId/PropertyObjectId и Active-сессией владельца When consumer обрабатывает событие Then создаётся AddToFavoritesActionEvent со снимком UserId/SessionId, публикуется action-tracked (ActionType = AddToFavorites), offset подтверждается после фиксации |
| AC2 | Given favorite-removed When обработано Then создаётся RemoveFromFavoritesActionEvent, action-tracked эмитится; лента лида CRM не меняется (no-op downstream) |
| AC3 | Given повторная доставка события с тем же MessageId When consumer её видит Then запись не дублируется (дедуп по MessageId и SourceMessageId, INV-ACT-004), ack без изменений |
| AC4 | Given событие с обоими или ни одним из UserId/AnonymousId (нарушение XOR INV-FAV-001) When обработано Then invalid-owner, ack-skip, без записи и без DLQ |
| AC5 | Given событие владельца без трекнутой сессии When резолв сессии пуст Then skipped-no-session, ack (не ошибка), запись не создаётся |
| AC6 | Given гостевой лайк (AnonymousId задан, UserId = null) с Active-сессией устройства When обработано Then запись создаётся со снимком AnonymousId и UserId = null; после связывания сессии действие читается в истории пользователя через связанную сессию (без backfill) |
| AC7 | Given PostgreSQL/Kafka недоступны When обработка падает Then бесконечный backoff-ретрай с паузой партиции; событие не теряется и в DLQ не уходит |
Assumptions и ограничения
- Имя consumer-дока. README
user-activityи action-event.md фиксируют канонconsumers/favorites-events.md(этот файл); контрактыfavoritesссылаются на устаревший путьconsumers/favorites-actions.md— расхождение имён, требует reconciliation в докахfavoritesперед реализацией. action-trackedvsaction-recorded. README (owning-service) именует produced-событиеActionTrackedKafkaEvent/pin.user-activity.action-tracked.v1; action-event.md —ActionRecordedKafkaEvent/action-recorded.v1(README «Принятые решения» #4). Здесь принято имя README; расхождение — assumption.- Якорение к сессии. INV-ACT-003 допускает создание сессии «оживлением устройства», но payload
favoritesне несётDevice— consumer только резолвит существующую сессию, иначеskipped-no-session(ADR-0056, консистентно с identity-signed-in.md).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: ActionEvent (TPH-иерархия событий действий)
- События (2): Event Contract: pin.favorites.favorite-added (FavoriteAddedKafkaEvent), Event Contract: pin.user-activity.action-tracked (ActionTrackedKafkaEvent)
- Консюмеры (1): Consumer: Selections Events (UserActivitySelectionItemAddedConsumer)
Связанные документы
- Дом изменяемого агрегата и подтипов: ../domain/action-event.md
(
AddToFavorites/RemoveFromFavorites, INV-ACT-001..006); сессия-владелец: ../domain/session.md (INV-ACT-003 резолв). - Входные контракты: ../../favorites/events/favorite-added.md, ../../favorites/events/favorite-removed.md; источник полей — ../../favorites/domain/favorite.md (INV-FAV-001).
- Соседний consumer действий: selections-events.md; downstream лента лида — ../../crm/consumers/user-activity-actions.md; карта событий — ../README.md.
- ADR: 0052 tenancy, 0054 объединение user-activity, 0056 simplicity-first.