Consumer: Talent Handbook Sync (pin-handbook-talent-sync)
Консолидированный документ Kafka-синка из talent.microservices.handbook в контекст handbook PIN. Один
consumer group обрабатывает четыре потока событий (country, city + derive региона, currency, language);
пятый топик источника (contact-info) не потребляется. Правила синка R1–R9, таблицы маппинга полей и
HandbookSyncState — дом talent-replica.md; целевые реплики —
country.md, region.md, city.md,
currency.md, language.md. Retry/DLQ/replay-политика
группы описана здесь единожды на все потоки.
Назначение
Consumer group pin-handbook-talent-sync потребляет upsert-события Talent Handbook и поддерживает в PIN
реплики пяти справочников: страны, регионы (derive из city-событий), города, валюты, языки. Каждое
применённое изменение re-publish-ится собственным событием PIN в compacted-топики pin.handbook.*.v1,
чтобы downstream-сервисы PIN (chessboard и будущие потребители) не подписывались на топики Talent
напрямую (ADR-0063 п.1).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | handbook |
| Consumer group | pin-handbook-talent-sync |
| Producer | talent.microservices.handbook (внешняя система; контракты — upstream source of truth, schema registry Talent; имена топиков — зафиксированное assumption ADR-0063) |
| Топик 1 | handbooks.after-country-information-update — полный snapshot страны |
| Топик 2 | handbooks.after-city-information-update — полный snapshot города + вложенные CountryEventModel / RegionEventModel |
| Топик 3 | handbooks.after-currency-information-update — полный snapshot валюты |
| Топик 4 | handbooks.after-language-information-update — полный snapshot языка |
| ContractVersion | ожидается 2 для всех событий источника; при несовпадении — warn-and-proceed + метрика handbook_sync_contract_mismatch_total |
| Порядок | key события = id записи → все события одной записи в одной партиции, строгий порядок per-record; кросс-справочный порядок (country→city) НЕ гарантирован — правило R4 talent-replica.md |
| DLQ | pin.handbook.talent-sync-dlq.v1 |
Маппинг «событие → эффект» (бизнес-термины):
| Поток | Событие (топик) | Эффект |
|---|---|---|
| A | country-событие | Создание/обновление CountryEntity + re-publish country-upserted |
| B | city-событие | Derive RegionEntity из вложенной RegionEventModel (+ re-publish region-upserted при фактическом изменении) + создание/обновление CityEntity + re-publish city-upserted |
| C | currency-событие | Создание/обновление CurrencyEntity + re-publish currency-upserted |
| D | language-событие | Создание/обновление LanguageEntity + re-publish language-upserted |
| E | contact-info-событие (handbooks.after-contact-information-update) | Не потребляется — ContactInfoType вне scope v1 (ADR-0063 п.3) |
Контекст события
- Из метаданных события доступны:
MessageId(идентификатор для дедупликации), envelopeCreatedAt(время публикации события источником — источник монотонности R2 для всех типов записей, включая регион),ContractVersionсхемы (статический, = 2), идентификатор записи-источника (key). - Per-record
Versionв payload источника нет — порядок применения upsert-ов задаётся временем публикации (envelopeCreatedAt) и порядком партиции Kafka (key =Idне даёт переупорядочивания апдейтов одной записи). - Актор — система синка (не пользовательский контекст); справочники платформенные, tenant-фильтр не применяется, PII нет.
- Корреляция сквозного трейса восстанавливается из метаданных и пробрасывается в исходящие
pin.handbook.*. - Ключ идемпотентности:
handbook-sync:{eventType}:{MessageId},eventType ∈ {country, city, currency, language}; бизнес-идемпотентность — monotonic-guard R2 (CreatedAt > LastEventAtдля всех типов записей, включая регион).
Входные данные / payload событий
Контракты внешние (schema registry Talent — upstream source of truth); ниже — поля, которые consumer реально читает. Полные таблицы маппинга «поле события → поле реплики» — дом talent-replica.md; здесь — связка «поле payload → шаг алгоритма».
Country-событие (полный snapshot страны)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | short | Да | Ключ поиска/создания страны → CountryEntity.Id |
ShortName | string? | Нет | Перезапись ShortName |
FullName | string | Да | Перезапись FullName (display Name = FullName); > 64 символов — truncate + warn |
IsoCode2 | string | Да | Нормализация upper → IsoCode2; guard INV-HND-2 |
IsoCode3 | string | Да | Нормализация upper → IsoCode3; guard INV-HND-2 |
DefaultCurrency | модель? {CurrencyId short, IsoCode, Sign, Name} | Нет | Только CurrencyId → DefaultCurrencyId (логическая ссылка без FK); display-поля игнорируются |
DefaultLanguage | модель? {LanguageId short, IsoCode2, IsoCode3, Name} | Нет | Только LanguageId → DefaultLanguageId |
CreatedAt (envelope, метаданные) | DateTimeOffset | Да | Монотонный guard R2 → LastEventAt (per-record Version в payload нет) |
City-событие (полный snapshot города + вложенные модели)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | long | Да | Ключ поиска/создания города → CityEntity.Id |
Name | string | Да | Перезапись CityEntity.Name; > 256 символов — truncate + warn |
Country | CountryEventModel {CountryId short, Name, IsoCode2, IsoCode3} | Да | Только CountryId → CityEntity.CountryId; страна обязана существовать — иначе retriable R4; вложенная модель страну НЕ создаёт |
Region | RegionEventModel? {RegionId long, Name} | Нет | Derive RegionEntity: Id = Region.RegionId, Name, CountryId — из Country.CountryId этого же события (RegionEventModel CountryId не несёт, ADR-0063 п.4) + CityEntity.RegionId = Region.RegionId; guard INV-HND-3 |
CreatedAt (envelope, метаданные) | DateTimeOffset | Да | Монотонный guard R2 → LastEventAt и города, и региона (per-record Version в payload нет; источник монотонности — envelope CreatedAt + порядок партиции) |
Currency-событие (полный snapshot валюты)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | short | Да | Ключ поиска/создания валюты → CurrencyEntity.Id |
Name | string | Да | Перезапись Name; > 64 символов — truncate + warn |
IsoCode | string | Да | Нормализация upper → IsoCode; guard INV-HND-2 |
Sign | string? | Нет | Перезапись Sign |
CreatedAt (envelope, метаданные) | DateTimeOffset | Да | Монотонный guard R2 → LastEventAt (per-record Version в payload нет) |
Language-событие (полный snapshot языка)
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
Id | short | Да | Ключ поиска/создания языка → LanguageEntity.Id |
Name | string | Да | Перезапись Name; > 64 символов — truncate + warn |
IsoCode2 | string | Да | Нормализация lower → IsoCode2; guard INV-HND-2 |
IsoCode3 | string | Да | Нормализация lower → IsoCode3; guard INV-HND-2 |
FlagIsoCode2 | string? | Нет | Нормализация upper → FlagIsoCode2 (код страны флага, display) |
CreatedAt (envelope, метаданные) | DateTimeOffset | Да | Монотонный guard R2 → LastEventAt (per-record Version в payload нет) |
Алгоритм
Отдельный проход для каждого потока. Общая рамка каждого шага 1: восстановить метаданные (MessageId,
envelope CreatedAt, key); сверить ContractVersion схемы с ожидаемым 2 — при несовпадении
warn-and-proceed + метрика; идемпотентность по ключу handbook-sync:{eventType}:{MessageId} — дубль → лог
skipped-duplicate, ack.
A. Country (country-событие)
- Контекст события. Рамка выше,
eventType = country. - Проверка входа.
Idзадан;FullNameнепустое (truncate + warn при > 64);IsoCode2/IsoCode3нормализуются в upper; envelopeCreatedAtзадан. Существование записи не проверяется — операция upsert. - Что читаем. Страна по
Id(может отсутствовать — первое событие); еёHandbookSyncState; для guard INV-HND-2 — наличие другой неархивной страны с тем жеIsoCode2илиIsoCode3. - Правила и переходы.
- Monotonic-guard (R2/INV-HND-4): страна есть и envelope
CreatedAt <= LastEventAt→ событие устаревшее/повтор ресинка: метрикаhandbook_sync_stale_events_total, логskipped-stale, обновить вHandbookSyncStateтолькоLastEventId/LastEventAt(отметка живости для reconciliation), к шагу 7, ack. - ISO-guard (R6/INV-HND-2):
IsoCode2/IsoCode3занят другой неархивной страной → отказHANDBOOK_SYNC_ISO_CONFLICT(non-retriable → DLQ + алерт,HandbookSyncState.Status = Conflict). - Auto-restore (R5): запись заархивирована в PIN, но пришла из источника →
IsArchived = false+ warn-метрикаhandbook_sync_auto_restores_total(источник главнее локального архива). - Создание/обновление: snapshot-семантика R3 — перезапись всех синк-владеемых полей по таблице
маппинга talent-replica.md;
LastEventAt = envelope CreatedAt.
- Monotonic-guard (R2/INV-HND-4): страна есть и envelope
- Что меняется. Страна (создание/обновление).
HandbookSyncState: при отсутствии создаётся (AggregateType = Country,ExternalId = Id), затемLastEventId = MessageId,LastEventAt = envelope CreatedAt,Status = Synced,LastError = null. - События.
country-upserted→pin.handbook.countries.v1(compacted, key =Id, ContractVersion 1, полный snapshot реплики +IsArchived+LastEventAt) — контракт. - Согласованность. Реплика +
HandbookSyncState+ отметка идемпотентности + исходящее событие — атомарно (R8); offset подтверждается только после фиксации. - Результат. Успех → ack; retriable-код → retry по политике (таблица ниже); non-retriable → DLQ +
HandbookSyncState.Status = Conflict/DeadLettered+ алерт.
B. City (city-событие + derive региона)
- Контекст события. Рамка выше,
eventType = city. - Проверка входа.
Idзадан;Nameнепустое (truncate + warn при > 256);Country.CountryIdзадан; envelopeCreatedAtзадан; при наличииRegion—Region.RegionIdиRegion.Nameзаданы (CountryIdуRegionEventModelнет — берётся изCountry.CountryIdсобытия). - Что читаем. Страна по
Country.CountryId— отсутствует → отказHANDBOOK_SYNC_COUNTRY_NOT_FOUND, retriable (R4): страна появится своим событием, city-событие не теряется. Далее: регион поRegion.RegionId(может отсутствовать — derive создаст); город поId(может отсутствовать);HandbookSyncStateрегиона и города. - Правила и переходы. Порядок внутри события: страна (проверка) → регион (derive) → город; guard-ы
региона и города независимы (stale-город не блокирует более свежий регион, и наоборот).
- Регион (derive, только при наличии
Region):- INV-HND-3 GeoConsistency: страна региона берётся из
Country.CountryIdвмещающего city-события (RegionEventModelсобственногоCountryIdне несёт, ADR-0063 п.4); для существующего региона это значение должно совпасть сCountryIdего записи (перенос региона между странами не поддерживается); нарушение →HANDBOOK_SYNC_GEO_MISMATCH(non-retriable → DLQ + алерт). - Monotonic-guard (R2): envelope
CreatedAt <= LastEventAtсуществующего региона → derive пропускается (метрика stale + отметка живости вHandbookSyncStateрегиона). - Auto-restore (R5): регион заархивирован →
IsArchived = false+ warn-метрика. - Upsert: создание записи региона либо перезапись
Name/CountryId;LastEventAt = envelope CreatedAt.
- INV-HND-3 GeoConsistency: страна региона берётся из
- Город:
- Monotonic-guard (R2/INV-HND-4): envelope
CreatedAt <= LastEventAt→ skip города (метрика stale + отметка живости), регион при этом мог примениться. - INV-HND-3:
RegionIdзадан, а регион после derive не существует либо принадлежит другой стране →HANDBOOK_SYNC_GEO_MISMATCH→ DLQ. - Auto-restore (R5): город заархивирован →
IsArchived = false+ warn-метрика. - Upsert snapshot-семантикой (R3):
CountryId,RegionId,Name;LastEventAt = envelope CreatedAt.
- Monotonic-guard (R2/INV-HND-4): envelope
- Регион (derive, только при наличии
- Что меняется. Регион (возможно) и город;
HandbookSyncStateобеих записей (AggregateType = RegionиAggregateType = City— монотонность обеих поLastEventAt, отдельного поля версии нет). - События.
region-upserted→pin.handbook.regions.v1— только при фактическом изменении записи региона (создание, сменаName, restore) — дедупликация шторма одинаковых событий от городов одного региона (контракт);city-upserted→pin.handbook.cities.v1— при применённом изменении города (контракт). - Согласованность. Регион + город + оба
HandbookSyncState+ отметка идемпотентности + оба исходящих события — одно согласованное изменение (R8). - Результат. Как A8.
C. Currency (currency-событие)
- Контекст события. Рамка выше,
eventType = currency. - Проверка входа.
Idзадан;Nameнепустое (truncate + warn при > 64);IsoCodeнормализуется в upper; envelopeCreatedAtзадан. - Что читаем. Валюта по
Id; еёHandbookSyncState; наличие другой неархивной валюты с тем жеIsoCode(INV-HND-2). - Правила и переходы. Monotonic-guard R2 (envelope
CreatedAt <= LastEventAt→ skip + отметка живости); ISO-guard R6 (HANDBOOK_SYNC_ISO_CONFLICT→ DLQ); auto-restore R5; upsert snapshot-семантикой R3 (LastEventAt = envelope CreatedAt). - Что меняется. Валюта;
HandbookSyncState(AggregateType = Currency). - События.
currency-upserted→pin.handbook.currencies.v1(контракт). - Согласованность. Атомарно (R8).
- Результат. Как A8.
D. Language (language-событие)
- Контекст события. Рамка выше,
eventType = language. - Проверка входа.
Idзадан;Nameнепустое (truncate + warn при > 64);IsoCode2/IsoCode3нормализуются в lower,FlagIsoCode2— в upper; envelopeCreatedAtзадан. - Что читаем. Язык по
Id; егоHandbookSyncState; наличие другого неархивного языка с тем жеIsoCode2илиIsoCode3(INV-HND-2). - Правила и переходы. Monotonic-guard R2 (envelope
CreatedAt <= LastEventAt→ skip); ISO-guard R6 (HANDBOOK_SYNC_ISO_CONFLICT→ DLQ); auto-restore R5; upsert snapshot-семантикой R3 (LastEventAt = envelope CreatedAt). - Что меняется. Язык;
HandbookSyncState(AggregateType = Language). - События.
language-upserted→pin.handbook.languages.v1(контракт); текущих подписчиков нет — топик публикуется для будущих потребителей (compacted-хранение). - Согласованность. Атомарно (R8).
- Результат. Как A8.
E. Contact-info
handbooks.after-contact-information-update не потребляется — ContactInfoType вне scope v1
(ADR-0063 п.3); подключение при появлении потребителя — additive (новый поток + реплика + свой
pin.handbook.*-топик).
Диаграмма (поток B — самый сложный: derive региона)
Идемпотентность и replay
- По событию: дедупликация по
MessageId(ключиhandbook-sync:{eventType}:{MessageId}) — повторная доставка after-commit-crash не меняет состояние. - По бизнес-правилу: monotonic-guard R2 — событие с envelope
CreatedAt <= LastEventAt(для всех типов записей, включая регион) пропускается; при stale-skip обновляется отметка живостиHandbookSyncState.LastEventId/LastEventAt— основа reconciliation-отчёта. - Replay: да, безопасен. Полный re-read топиков источника с offset 0 и re-эмиссия всех строк при
пересинхронизации (
POST sync-handbooks) безопасны: upsert + monotonic-guard превращают повторы в no-op реплики; runbook — talent-initial-import.md. Точечный replay DLQ — после правки данных в Talent Handbook (переводHandbookSyncState.Status → Synced+ повторная подача события).
Retry и DLQ
DLQ-топик группы: pin.handbook.talent-sync-dlq.v1 (исходное событие + заголовки x-dlq-reason,
x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/шина недоступны, timeout) | — | Бесконечный exponential backoff 1s→60s с паузой партиции (событие не теряется) | Нет | Восстановить зависимость |
| Страна не найдена (out-of-order country→city, R4) | HANDBOOK_SYNC_COUNTRY_NOT_FOUND | Retriable: 6 попыток, backoff 30s → 1m → 2m → 4m → 8m → 16m | Да, после исчерпания + алерт | Проверить, пришло ли country-событие; при необходимости — пересинхронизация; replay из DLQ |
Гео-рассогласование (INV-HND-3: CountryId вмещающего city-события не совпадает с CountryId существующей записи региона — смена страны региона) | HANDBOOK_SYNC_GEO_MISMATCH | Нет (non-retriable) | Да + алерт + HandbookSyncState.Status = Conflict | Правка данных в Talent Handbook → replay из DLQ (автопочинки в PIN нет) |
| ISO-конфликт (INV-HND-2: код занят другой неархивной записью) | HANDBOOK_SYNC_ISO_CONFLICT | Нет (non-retriable) | Да + алерт + HandbookSyncState.Status = Conflict | Правка данных в источнике → replay из DLQ |
| Гонка с admin archive/restore (конкурентное изменение записи) | — | 3 попытки немедленно (перечитать запись) | После исчерпания | Расследование гонки |
Наблюдаемость и безопасность
- Logs (structured, по событию):
consumed/skipped-duplicate/skipped-stale/created/updated/auto-restored/conflict/dead-letteredс полямиMessageId,AggregateType,ExternalId, envelopeCreatedAt,entity.LastEventAt. PII в справочных данных нет. - Metrics:
handbook_sync_lag_seconds(задержка применения: момент применения − envelopeCreatedAt, поAggregateType),handbook_sync_stale_events_total,handbook_sync_dlq_total,handbook_sync_contract_mismatch_total,handbook_sync_auto_restores_total,handbook_sync_retries_total,handbook_sync_consume_duration_ms. - Traces: consume-span → span обработки → spans хранилища; корреляция из метаданных пробрасывается в
исходящие
pin.handbook.*. - Alerts: DLQ > 0;
HandbookSyncState.Status = Conflict> 0; лаг применения > 15 мин при ненулевом потоке;contract_mismatch> 0 (warn — сигнал смены контракта источника, ContractVersion 2 → 3). - Безопасность: consumer работает под system-actor; доступ к топикам Talent — m2m ACL Kafka; справочники платформенные (не tenanted), PII нет.
Пред/постусловия
- До обработки события. Реплика может содержать или не содержать целевую запись (операция upsert). Порядок per-record гарантирован ключом источника; кросс-справочный порядок country→city источником не гарантируется — компенсируется R4 (retry с backoff до появления страны).
- После обработки применённого события. Целевая реплика отражает последний snapshot источника
(
LastEventAt = envelope CreatedAt— единый механизм для всех типов записей, включая регион; синк-владеемые поля перезаписаны;IsArchivedснят при auto-restore);HandbookSyncState = Synced(либоConflict/DeadLetteredпри R6/INV-HND-3/исчерпании retry); ушли соответствующие событияpin.handbook.*; offset ack — только после фиксации. Устаревшее или дублирующее событие — состояние реплики не изменено (skip), отметка живости обновлена, ack. Downstream-сервисы PIN зависят только отpin.handbook.*, на топики Talent не подписаны.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (6): Реплика Talent Handbook: правила синка + Entity HandbookSyncState, Entity: CityEntity, Entity: CountryEntity, Entity: CurrencyEntity, Entity: LanguageEntity, Entity: RegionEntity
- API (2): GET /api/v1/admin/handbook/sync-state — Состояние синхронизации справочников с Talent, Handbook API — реестр операций областей public / internal / admin
- Use Cases (2): UC-HND-001. Администратор поддерживает справочник (archive/restore), UC-HND-002. Система синхронизирует реплику справочника из Talent
- События (5): Event Contract: city-upserted (CityUpsertedEvent), Event Contract: country-upserted (CountryUpsertedEvent), Event Contract: currency-upserted (CurrencyUpsertedEvent), Event Contract: language-upserted (LanguageUpsertedEvent), Event Contract: region-upserted (RegionUpsertedEvent)
- Консюмеры (1): Consumer: Handbook Sync (pin-chessboard-handbook-sync)
- Фоновые задачи (1): Background Job: talent-initial-import (bootstrap и reconciliation справочников из Talent Handbook)
- index.md (1): Handbook Service
- registries (1): Реестр событий PIN
Связанные документы
- Правила синка и маппинг полей: talent-replica.md (R1–R9,
HandbookSyncState). - Целевые реплики: country.md, region.md, city.md, currency.md, language.md.
- Производимые события: country-upserted, region-upserted, city-upserted, currency-upserted, language-upserted.
- Bootstrap / reconciliation: talent-initial-import.md.
- ADR-0063; образец — identity/consumers/talent-identity-sync.md.