MessengerContactSearchDocument
Rebuildable-проекция поиска MessengerContactSearchDocument в Elasticsearch — для поиска
контактов/пользователей по имени и телефону в мессенджере (опциональный search страницы «Контакты»,
../api/contacts/list-contacts.md). Обслуживает fuzzy-поиск по ФИО с
typo-tolerance и транслитерацией; глобальный точечный поиск по телефону остаётся прямым lookup-ом в
PostgreSQL (см. «Границы»).
Назначение
Source of truth — реплика MessengerUserEntity (messenger.users, ФИО +
нормализованный телефон из identity); проекция хранит только индексируемые/фильтруемые поля для fuzzy-
поиска (typo-tolerance, транслитерация кириллица↔латиница, морфология ФИО — ADR-0059 п.1). Наполняется тем
же консюмером, что и реплика (../consumers/identity-user-upserted.md),
плюс rebuild-джоб.
Границы. Проекция обслуживает FTS по имени/телефону (поиск внутри подтверждённых контактов —
list-contacts). Глобальный точечный поиск по телефону
(../api/contacts/search-users-by-phone.md) — не search-
поверхность: телефон уникален (ux_users_phone), это прямой btree-lookup в PostgreSQL, Elasticsearch не
задействован (ADR-0056).
Index
| Поле | Значение |
|---|---|
| Index alias | messenger-contacts |
| Versioned index | messenger-contacts-v1 (переключение alias, ADR-0029) |
| Owner service | messenger |
| Source of truth | MessengerUserEntity (../domain/messenger-user.md) в PostgreSQL (messenger.users) |
| Rebuild job | MessengerContactSearchRebuildJob |
Document Id И Routing
- Document id:
UserId(Guid =messenger.users.user_id=identity.users.id; детерминирован — upsert идемпотентен). - Routing key: не требуется — справочник person-level (глобальный, вне tenant, ADR-0052); кластер малый, шардирование не нужно (ADR-0056).
- Delete/tombstone: строка живёт, пока жив identity-пользователь (реплика не hard-удаляется). Блокировка/
архивация в
identity→IsActive = false(документ остаётся, но security-trimming-ом вырезается из discovery-выдачи,INV-MU); физическое удаление реплики → delete документа поUserId.
Поля
| Поле | Тип | Indexed | Stored | Analyzer | Source | Security |
|---|---|---|---|---|---|---|
UserId | keyword | да | да | — | MessengerUserEntity.UserId | include |
FirstName | text | да | да | ru_name (russian morphology + lowercase + edge-ngram) | FirstName | PII (маска не на имя) |
MiddleName | text | да | да | ru_name | MiddleName | PII |
LastName | text | да | да | ru_name | LastName | PII (в выдаче маска до Accepted, INV-MU-3) |
NameTranslit | text | да | нет | translit (ICU кириллица↔латиница) | FirstName ⊕ MiddleName ⊕ LastName | PII |
FullNameSearch | text | да | нет | ru_name | FirstName ⊕ MiddleName ⊕ LastName (copy_to) | основной матч searchTerm |
PhoneNumber | keyword | да | да | — | PhoneNumber (E.164) | PII (маска +7***NNNN до Accepted) |
PhoneDigits | text | да | нет | digits_edge_ngram | PhoneNumber (только цифры) | префикс/подстрока телефона |
IsActive | boolean | да | да | — | IsActive | security-trimming discovery |
SchemaVersion | integer | да | да | — | версия схемы документа | include |
UpdatedAt | date | да | да | — | MessengerUserEntity.UpdatedAt | freshness watermark |
IndexedAt | date | да | да | — | момент индексации | freshness |
Внешний ввод (searchTerm) идёт только параметром поискового запроса (multi-match по FullNameSearch/
NameTranslit/PhoneDigits с fuzziness) — не в raw-DSL. IdentityUserVersion в проекцию не кладётся
(guard применяется в источнике до индексации).
Source Events
| Event | Version | Transform |
|---|---|---|
pin.identity.users.v1 | 1 | Тот же консюмер, что наполняет реплику (в том же согласованном изменении): при payload.Version > IdentityUserVersion — upsert документа (map ФИО/телефон/IsActive); stale/дубль → skip (version-guard INV-MU-1). |
Проекция наполняется инкрементально консюмером реплики
(identity-user-upserted.md) + периодический/ручной
MessengerContactSearchRebuildJob из messenger.users для bootstrap и восстановления консистентности.
Собственного доменного события у реплики нет — индексация выполняется в той же обработке
user-upserted (после успешной фиксации реплики).
Freshness
- SLA:
search(секунды–минуты; eventual относительно PostgreSQL). - Watermark:
IndexedAt+ offset топикаpin.identity.users.v1. - Stale behavior: показать результаты с freshness-маркером; адресуемость/статус контакта критичные проверки
(
IsActive, снятие маски) делаются по source-of-truth (messenger.users/ContactEntity), не по проекции.
Согласованность и деградация
- Порядок обновлений — per-user. Индексация идёт тем же консюмером и в том же порядке, что реплика:
топик
pin.identity.users.v1, ключUserId, монотонный version-guard (payload.Version > IdentityUserVersion, INV-MU-1). Устаревшее событие пропускается — документ не «омолаживается» назад; между разнымиUserIdпорядок не важен (id детерминирован, upsert идемпотентен). - ES недоступен/отстаёт. Поиск-параметр
searchвlist-contactsдеградирует: листинг контактов безsearchработает по PostgreSQL немедленно (../api/contacts/list-contacts.md); точечный поиск по телефону (../api/contacts/search-users-by-phone.md) не затронут — это прямой btree-lookup, вне Elasticsearch. Восстановление консистентности — консюмер (догон offset) +MessengerContactSearchRebuildJob. - Гонка индексации и чтения. Матч по проекции возвращает только
UserId+ score; актуальныеIsActive/маскирование/статус контакта потребитель перечитывает по source-of-truth (messenger.users/ContactEntity) — устаревший документ не приводит к утечке (заблокированный/архивный вырезается security-trimming-ом, INV-MU) и не снимает маску раньшеAccepted. - Удаление/tombstone.
IsActive = false(блокировка/архивация вidentity) оставляет документ, но вырезает из discovery-выдачи; физическое удаление реплики → delete документа поUserId. Повторная доставка удаления — идемпотентный no-op.
Security
- Scope-фильтр (до выполнения запроса, ADR-0059 п.3) для
list-contacts— record-level: сузить к множествуUserIdподтверждённых контактов актора (ContactEntity.Status = Accepted, обе стороны пары);IsActive = falseвырезается из discovery-поверхностей. - PII fields:
FirstName/MiddleName/LastName/PhoneNumber— индексируются для матча, но выдаются по политике маскирования: доAccepted-контакта фамилия маскируется первой буквой, телефон+7***NNNN(INV-MU-3); вlist-contactsвсе строки ужеAccepted→ профиль полный (INV-CT-5). Маскирование выполняет потребитель проекции, индекс отдаёт толькоUserId+ match-score. - Полнотекстовый индекс не хранит secrets, tokens, платёжные/KYC-данные, тела сообщений, IP/UA.
Rebuild
- Создать версионный индекс
messenger-contacts-vNс analyzers (ru_name,translit,digits_edge_ngram). - Backfill
MessengerContactSearchRebuildJobиз PostgreSQL (messenger.users): map строкMessengerUserEntityвMessengerContactSearchDocument. - Валидация counts/checksums против источника (
messenger.users). - Атомарно переключить alias
messenger-contactsна новую версию. - Держать предыдущий индекс до конца rollback-окна.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity (Replica): MessengerUserEntity
- API (1): GET /api/messenger/contacts — Список подтверждённых контактов
- Консюмеры (1): Consumer: Messenger User Replica Sync (MessengerUserUpsertedConsumer)
- index.md (1): Messenger Service
Связанные документы
- Source of truth (реплика): ../domain/messenger-user.md; наполнение — ../consumers/identity-user-upserted.md; входное событие — identity/events/user-upserted.md.
- Потребитель поиска (API): ../api/contacts/list-contacts.md; соседний точечный lookup (не search) — ../api/contacts/search-users-by-phone.md.
- Решения: ADR-0059 (Elasticsearch-проекции), ADR-0008 (search projection), ADR-0029 (alias strategy), ADR-0014 (scope-filters), ADR-0052 (tenancy), ADR-0056 (simplicity-first).