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

Merge Strategy: слияние избранного при авторизации (device-wins / account-wins / merge)

Канонический дом трёх стратегий слияния гостевого и аккаунтного избранного + сущность-аудит FavoritesMergeOperation. API-метод слияния и use-case ссылаются сюда, не переописывая правила.

Назначение

Пользователь лайкает объекты недвижимости до входа (гостевая сессия, AnonymousId) и мог делать это на разных устройствах и в разных браузерах (у каждого свой AnonymousId из user-activity), а также имеет избранное, ранее сохранённое в аккаунте (UserId). После авторизации наборы могут расходиться — нужно решить, что станет избранным аккаунта. Предусмотрены три стратегии; Merge — рекомендация по умолчанию.

MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст favorites; операция слияния; таблица favorites.favorites_merge_operations
Assumptionsсвязывание «сессия ↔ пользователь» уже выполнено контекстом user-activity по событию входа pin.identity.sign-ins.v1 (ADR-0054)

Триггер и точки входа

  1. Пользователь входит (СМС/пароль) — фронт получает JWT, продолжая слать X-Anonymous-Id текущей сессии.
  2. Фронт вызывает GET /favorites/merge-preview — сервис возвращает счётчики обеих сторон и список конфликтных объектов недвижимости (см. модель ниже).
  3. Ветвление (автоматика без диалога — вырожденные случаи):
Состояние сторонПоведениеДиалог пользователю
Сессия пуста, аккаунт пустНичего не делать (операция не создаётся)Нет
Сессия пуста, аккаунт непустНичего не делать: избранное аккаунта уже активноНет
Сессия непуста, аккаунт пустАвтослияние стратегией Merge (вырожденный перенос всех записей сессии)Нет
Обе непусты и наборы PropertyObjectId идентичны, заметки не конфликтуютАвтослияние Merge (дедупликация + перенос заметок по правилам)Нет
Обе непусты и различаютсяТребуется явный выбор стратегииДа — 3 варианта
  1. Пользователь выбирает стратегию → фронт вызывает POST /favorites/merge { strategy }.
  2. Первичный импорт (importFavorites, см. ../api/public/import-favorites.md) выполняется до merge-preview: накопленные на устройстве PropertyObjectId записываются в гостевую сессию (или сразу в аккаунт, если пользователь уже авторизован), после чего работает обычное слияние.

Enum FavoritesMergeStrategy

Хранится как текст (varchar(16)).

ЗначениеСмысл
DeviceWins«использовать с текущего устройства (станет активным для всех)» — избранное аккаунта заменяется набором текущей сессии
AccountWins«использовать активное избранное из аккаунта» — набор текущей сессии отбрасывается
Merge«объединить избранные из сессии и текущего пользователя» — объединение наборов с дедупликацией; рекомендация по умолчанию

Таблица поведения по стратегиям

Обозначения: A = записи аккаунта (UserId = actor), S = записи гостевой сессии (AnonymousId = X-Anonymous-Id), дубль = объект недвижимости присутствует и в A, и в S.

АспектDeviceWinsAccountWinsMerge (рекомендуется)
Записи A (кроме дублей)Удаляются физическиОстаются без измененийОстаются без изменений
Записи S (кроме дублей)Переносятся в аккаунт: UserId := actor, AnonymousId := null, UpdatedAt := nowУдаляются физическиПереносятся в аккаунт (как в DeviceWins)
Дубли (объект недвижимости в A и в S)A-запись удаляется, S-запись переносится (заметка сессии сохраняется)S-запись удаляется, A-запись остаётся (заметка аккаунта сохраняется)S-запись удаляется, A-запись остаётся; заметка — по таблице конфликтов заметок ниже
Итоговый набор аккаунтаРовно набор текущего устройства/сессииРовно прежний набор аккаунтаОбъединение A ∪ S
Судьба заметокТолько заметки сессииТолько заметки аккаунтаОбе стороны, конфликт — LastWriterWins
Другие устройства/сессии пользователяПосле входа на них увидят новый набор аккаунта (их гостевые записи — предмет отдельного слияния при их входе)АналогичноАналогично
Потеря данныхДа — прежнее избранное аккаунтаДа — избранное текущей сессииНет (кроме проигравшей стороны конфликтной заметки)
Когда уместнаПользователь сознательно «начал заново» на устройствеГостевые лайки были случайнымиПоведение по умолчанию, «иметь в избранном все объекты»

Правила слияния заметок (только Merge, для дублей)

Заметка AЗаметка SРезультат в аккаунтной записи
nullnullnull
nullнепустаяЗаметка S (переносится, UpdatedAt := now)
непустаяnullЗаметка A (без изменений)
непустая, равна SнепустаяЗаметка A (без изменений)
непустая, отличается от SнепустаяLastWriterWins: побеждает заметка записи с бо́льшим UpdatedAt; проигравшая теряется (конкатенация запрещена — лимит 4000, INV-FAV-003)

Рекомендация: Merge по умолчанию

  • Единственная стратегия без потери объектов недвижимости; конфликт заметок — редкий кейс (заметки пишут уже вовлечённые пользователи, обычно в одном месте).
  • В диалоге фронта Merge — предвыбранный вариант; DeviceWins/AccountWins — явный выбор с предупреждением о потере данных (счётчики из merge-preview).
  • Автослияние (вырожденные случаи из таблицы триггеров) всегда выполняется стратегией Merge.

Entity: FavoritesMergeOperation

Аудит и идемпотентность слияния: одна операция на пару «пользователь + сессия».

Классификация

ПолеЗначение
Контекстfavorites
KindEntity (append-only журнал, не агрегат с поведением)
Source of truthfavorites
Таблицаfavorites.favorites_merge_operations
Tenant isolationне tenanted (ADR-0052 п.2); record-level по UserId

Запись создаётся один раз и не изменяется; уникальный индекс защищает от гонки параллельных слияний, поэтому версионирование/оптимистическая блокировка не требуются.

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор операции; AggregateId события FavoritesMergedKafkaEvent.
UserIdGuidДаunique ux_merge_ops_user_anonymous (user_id, anonymous_id) — идемпотентность INV-FAV-101; логическая ссылка identity.users(id) без FKJWTПользователь, в чей аккаунт слито избранное.
AnonymousIdGuidДавходит в ux_merge_ops_user_anonymous; логическая ссылка на сессию user-activity без FKзаголовок X-Anonymous-IdГостевая сессия-источник.
StrategyFavoritesMergeStrategyДахранится как текст, varchar(16)app (выбор пользователя / автослияние = Merge)Применённая стратегия.
IsAutomaticboolДаdefault falseapptrue — вырожденный случай без диалога (таблица триггеров).
AccountCountBeforeintДа>= 0вычислениеЗаписей в аккаунте до слияния.
SessionCountBeforeintДа>= 0вычислениеЗаписей в сессии до слияния.
MovedCountintДа>= 0вычислениеПеренесено S-записей в аккаунт.
RemovedFromAccountCountintДа>= 0вычислениеУдалено A-записей (только DeviceWins).
RemovedFromSessionCountintДа>= 0вычислениеУдалено S-записей (дубли Merge; всё — AccountWins).
NotesMergedCountintДа>= 0вычислениеЗаметок перенесено/перезаписано по таблице конфликтов.
TotalCountAfterintДа>= 0вычислениеИтоговое число записей аккаунта.
CreatedAtDateTimeOffsetДаindex ix_merge_ops_created_atappМомент выполнения.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
FavoritesMergeOperation → UserEntityлогическая referenceUserIdне загружается
FavoritesMergeOperation → Sessionлогическая referenceAnonymousIdне загружается

Инварианты

ИнвариантУсловиеГде проверяетсяОшибка
INV-FAV-101 MergeIdempotentНа пару (UserId, AnonymousId) — не более одной операции; повторный вызов возвращает сохранённый результат (идемпотентный успех, без повторных мутаций)шаг 1 алгоритма: поиск существующей операции по паре; страховка — ux_merge_ops_user_anonymous— (идемпотентный успех с существующей операцией)
INV-FAV-102 SessionLinkedСессия AnonymousId связана именно с этим UserId в user-activity (нельзя слить чужую сессию, угадав Guid)шаг 2: internal-запрос user-activity GET /internal/sessions/{anonymousId}/linklinkedUserId == actor.UserIdFAVORITES_SESSION_NOT_LINKED (403)
INV-FAV-103 KnownStrategystrategy ∈ enum FavoritesMergeStrategyпроверка входаValidationError
INV-FAV-104 SingleTransactionВсе мутации записей + запись операции + публикация события — в одном согласованном изменении; частичное слияние невозможнофиксация измененияинфраструктурная ошибка → retry фронтом (идемпотентно по INV-FAV-101)
INV-FAV-105 NoCrossUserLeakСлияние никогда не читает/не трогает записи других UserId/AnonymousIdвыборки строго по actor + связанной сессии

Кросс-сервисный контракт с user-activity

Слияние — единственная операция контекста favorites с синхронной зависимостью от соседа: связь «сессия ↔ пользователь» живёт в user-activity, а не в favorites (нельзя слить чужую сессию, угадав Guid — INV-FAV-102).

favorites ожидает (вход): синхронный ответ на internal-запрос связи сессии (GET /internal/sessions/{anonymousId}/link) — идентификатор пользователя, с которым связана сессия (linkedUserId: Guid?). Связывание выполняет user-activity асинхронно по событию входа pin.identity.sign-ins.v1 (ADR-0054).

Ответ user-activityТрактовка favoritesИсход
linkedUserId == actor.UserIdСессия принадлежит акторуСлияние продолжается
Сессия не найдена / linkedUserId == nullСвязывание ещё не произошло либо сессия чужаяFAVORITES_SESSION_NOT_LINKED (403)
linkedUserId != actor.UserIdПодставлен чужой AnonymousIdFAVORITES_SESSION_NOT_LINKED (403) + warning (возможный подбор)
Timeout / 5xx / недоступенОтвет не полученFAVORITES_MERGE_UNAVAILABLE (503); фронт откладывает слияние до следующего открытия /favorites

Окно eventual-consistency. Связывание сессии в user-activity происходит через секунды после входа (обработка pin.identity.sign-ins.v1). Если пользователь инициирует слияние раньше, чем user-activity обработал вход, ответ будет «не связана» → FAVORITES_SESSION_NOT_LINKED; фронт повторяет при следующем открытии /favorites (идемпотентно по INV-FAV-101). В промежуточном состоянии избранное аккаунта и гостевой сессии видны раздельно (каждое по своему фильтру), потери данных нет.

favorites предоставляет (выход): по завершении — FavoritesMergedKafkaEvent (../events/favorites-merged.md; ключ партиционирования UserId, ContractVersion = 1, AggregateId = MergeOperationId); потребитель user-activity пишет ActionEvent «объединил избранное». Событие асинхронно: итоговый список аккаунта доступен сразу (GET /favorites — то же хранилище, сильная согласованность), запись в истории действий появляется < 1 мин.

Модель ответа FavoritesMergePreviewResult (GET /favorites/merge-preview)

ПолеТипОбязательностьОписание
AccountCountintДаЗаписей в аккаунте.
SessionCountintДаЗаписей в гостевой сессии.
DuplicatePropertyObjectIdsIReadOnlyList<Guid>Да (может быть пустым)Объекты недвижимости, присутствующие в обеих сторонах.
NoteConflictPropertyObjectIdsIReadOnlyList<Guid>Да (может быть пустым)Дубли, у которых обе заметки непусты и различаются (пользователю можно показать предупреждение).
RequiresChoiceboolДаtrue — нужен диалог 3 вариантов; false — было/будет автослияние.
AppliedOperationIdGuid?НетЕсли пара уже слита (INV-FAV-101) — id существующей операции.

Алгоритм слияния

  1. Контекст и доступ. Актор — авторизованный пользователь (UserId из JWT claim sub); гостевая сессия — AnonymousId из заголовка X-Anonymous-Id. Проверка идемпотентности: если по паре (UserId, AnonymousId) операция слияния уже существует — вернуть её сохранённый результат без каких-либо мутаций (INV-FAV-101).
  2. Проверка входа и предусловий. strategy ∈ enum FavoritesMergeStrategy (INV-FAV-103), иначе ValidationError; AnonymousId != Guid.Empty. Связь сессии: синхронный internal-запрос в user-activity подтверждает, что сессия существует и связана именно с этим пользователем (INV-FAV-102). Исходы: сессия не найдена или связана с другим UserIdFAVORITES_SESSION_NOT_LINKED (403); user-activity недоступен (timeout/5xx) → FAVORITES_MERGE_UNAVAILABLE (503) — слияние откладывается, повтор безопасен (идемпотентность INV-FAV-101); контракт связи и трактовка ответов — раздел «Кросс-сервисный контракт с user-activity» ниже. Существование объектов недвижимости не проверяется (INV-FAV-004, favorite.md).
  3. Что читаем. Обе стороны читаются под блокировкой (чтобы параллельное добавление не потерялось между чтением и мутацией): записи сессии (AnonymousId = actor session) и записи аккаунта (UserId = actor). Строится множество дублей — объектов недвижимости, присутствующих и в аккаунте, и в сессии.
  4. Бизнес-правила и маппинг. По выбранной strategy (таблица поведения выше) вычисляются три множества: toDelete (записи целиком), toMove (S-записи: UserId := actor, AnonymousId := null, UpdatedAt := now), noteUpdates (для Merge-дублей — целевая заметка A-записи по таблице конфликтов заметок, сравнение UpdatedAt сторон — LastWriterWins). Счётчики полей FavoritesMergeOperation считаются из мощности множеств (TotalCountAfter = |A| - removedFromAccount + moved).
  5. Что меняется. В одном согласованном изменении, в порядке из-за partial-unique ux_favorites_user_property_object: (1) удаляются проигравшие записи toDelete (A при DeviceWins; S-дубли при Merge; все S при AccountWins) — освобождают пары (user_id, property_object_id); (2) переносятся S-записи toMove (смена владельца); (3) обновляются заметки по noteUpdates; (4) создаётся запись FavoritesMergeOperation (аудит + идемпотентность).
  6. Какие факты/события фиксируются. Публикуется FavoritesMergedKafkaEvent (ContractVersion = 1, topic pin.favorites.favorites-merged.v1, AggregateId = operation.Id, AggregateType = "FavoritesMergeOperation"); payload — все поля FavoritesMergeOperation, кроме заметок. Потребитель — user-activity (ActionEvent «слил избранное» в историю действий).
  7. Согласованность. Мутации записей, запись операции и публикация события фиксируются в одном согласованном изменении (INV-FAV-104). Конфликт unique-индекса операции (гонка двух параллельных merge) откатывает проигравшего; его повторный вызов попадает в идемпотентную ветку шага 1.
  8. Результат. Возвращаются OperationId, Strategy, IsAutomatic, MovedCount, RemovedFromAccountCount, RemovedFromSessionCount, NotesMergedCount, TotalCountAfter (маппинг 1:1 из полей созданной операции); заметки в ответ не включаются.

Ошибки операции слияния

Консолидированный контракт кодов (дублируется в шагах алгоритма и таблице инвариантов).

КодHTTPУсловиеRetry
ValidationError400strategy вне enum FavoritesMergeStrategy (INV-FAV-103); AnonymousId == Guid.Emptyno retry (дефект вызова)
FAVORITES_SESSION_NOT_LINKED403Сессия не найдена или связана с другим UserId (INV-FAV-102)не ретраить автоматически; возможна отложенная попытка после связывания сессии
FAVORITES_MERGE_UNAVAILABLE503user-activity недоступен при проверке связи (timeout/5xx)retry позже (идемпотентно по INV-FAV-101)
CONCURRENCY (unique violation → откат)409/500Гонка двух параллельных merge одной пары — проигравший полностью откатывается (INV-FAV-104)retry → попадает в идемпотентную ветку (сохранённый результат)

Идемпотентный повтор уже выполненной пары (UserId, AnonymousId) — успех с сохранённым результатом, не ошибка (INV-FAV-101); события при повторе не публикуются.

Наблюдаемость

  • Logs: старт/финиш merge c OperationId, Strategy, счётчиками (без PropertyObjectId-списков и заметок); warning при FAVORITES_SESSION_NOT_LINKED (возможный подбор чужого AnonymousId).
  • Metrics: favorites_merge_total{strategy,automatic}, favorites_merge_moved_count (histogram), favorites_merge_conflicts_total (конфликты заметок).
  • Traces: сквозной correlation, включая internal-вызов user-activity.
  • Dashboard/alerts/runbook — см. ../README.md (Runtime И Эксплуатация).

Индексы, хранение, безопасность (favorites_merge_operations)

АспектЗначение
ИндексыPK btree(id); ux_merge_ops_user_anonymous unique btree(user_id, anonymous_id); ix_merge_ops_created_at btree(created_at)
Партиционирование / retentionНет; журнал мал (≤ 1 операция на пару «пользователь×устройство»), не чистится
ConcurrencyУникальный индекс защищает от гонки параллельных merge
Permissions / PIIЗапись видит только сам пользователь (record-level UserId) и служба заботы (permission favorites.merge-log.view, расследования); PII нет — только id и счётчики
AuditСама сущность и есть аудит; плюс FavoritesMergedKafkaEvent

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

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

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