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

Реплика Talent Handbook: правила синка + Entity HandbookSyncState

Дом правил репликации справочников из talent.microservices.handbook в контекст handbook PIN (страны, регионы, города, валюты, языки — ADR-0063)

  • дом полей служебной сущности HandbookSyncState (чекпоинты синка). Поля целевых реплик НЕ переописываются — см. country.md, region.md, city.md, currency.md, language.md; здесь — реестр потребляемых топиков, таблицы маппинга «поле события Talent → поле реплики PIN» и правила R1–R9.

Назначение

Определяет: какие события Talent Handbook потребляются (и какие — нет), как поля событий источника маппятся в реплики PIN, кто владеет какими полями, как обеспечиваются идемпотентность, монотонность применения (по времени публикации источника — envelope CreatedAt + порядок партиции, а не по per-record версии, которой у событий справочников нет), порядок country→city и устойчивость к out-of-order, что происходит с удалениями источника (delete-gap) и как выполняется начальная загрузка / полная пересинхронизация. Consumer, реализующий эти правила, — talent-handbook-sync.md; bootstrap и reconciliation — talent-initial-import.md.

Классификация (HandbookSyncState)

ПолеЗначение
Контекстhandbook
KindEntity (служебная, anemic)
Source of truthhandbook (мета о синке); справочные данные — talent.microservices.handbook
Таблицаhandbook.talent_sync_states
Tenant isolationне tenanted — платформенная служебная таблица

Реестр потребляемых топиков

Контракты внешние — фиксируются как upstream source of truth (schema registry Talent, ContractVersion = 2). Consumer group — pin-handbook-talent-sync, DLQ — pin.handbook.talent-sync-dlq.v1.

Topic (Talent)PayloadРеплика PINRe-publish PIN
handbooks.after-country-information-updateполный snapshot страныCountryEntity (handbook.countries)country-upsertedpin.handbook.countries.v1
handbooks.after-city-information-updateполный snapshot города + вложенные CountryEventModel и RegionEventModelCityEntity (handbook.cities) + derive RegionEntity (handbook.regions)city-upsertedpin.handbook.cities.v1; при фактическом изменении региона — region-upsertedpin.handbook.regions.v1
handbooks.after-currency-information-updateполный snapshot валютыCurrencyEntity (handbook.currencies)currency-upsertedpin.handbook.currencies.v1
handbooks.after-language-information-updateполный snapshot языкаLanguageEntity (handbook.languages)language-upsertedpin.handbook.languages.v1
handbooks.after-contact-information-updateContactInfoTypeНЕ потребляется — вне scope v1 (ADR-0063 п.3): потребителей ContactInfoType в PIN нет; подключение при появлении потребности — additive (новый поток + реплика + свой pin.handbook.*-топик)
Особенности источника (принятые гэпы, ADR-0063 п.4–5)

Отдельного region-события нет — регионы derive-ятся из вложенной модели city-событий; delete-событий нет — удалённая запись просто перестаёт упоминаться (обрабатывается правилами R5/R7 и reconciliation).

Маппинг: country-событие → CountryEntity

Событие handbooks.after-country-information-update несёт полные поля страны.

Поле события TalentТипПоле реплики PINПравило
IdshortIdId PIN = id источника: прямая адресация без lookup-таблиц (ADR-0063 п.2).
ShortNamestring?ShortNameПерезапись при каждом применённом upsert (поле владеет синк).
FullNamestringFullNameПерезапись; display-конвенция Name = FullName (country.md). Длиннее 64 символов — truncate + warn, событие не теряется.
IsoCode2stringIsoCode2Нормализация в upper; уникальность среди неархивных стран (INV-HND-2) — конфликт → R6.
IsoCode3stringIsoCode3Нормализация в upper; INV-HND-2 — конфликт → R6.
DefaultCurrency? {CurrencyId, IsoCode, Sign, Name}модель?DefaultCurrencyIdБерётся только CurrencyId; display-поля вложенной модели не сохраняются — атрибуты валюты приходят её собственным событием. Логическая ссылка без FK: нерезолвимость до прихода currency-события допустима.
DefaultLanguage? {LanguageId, IsoCode2, IsoCode3, Name}модель?DefaultLanguageIdАналогично: только LanguageId, без FK.
CreatedAt (envelope события, метаданные)DateTimeOffsetLastEventAtМонотонный guard R2 (INV-HND-4): применяется только при CreatedAt > LastEventAt. У событий источника нет per-record Version — источник монотонности — время публикации (envelope CreatedAt) + порядок партиции Kafka (key = Id); денормализуется в HandbookSyncState.LastEventAt.
IsArchivedСинк не пишет, кроме auto-restore R5 (IsArchived = false при повторном приходе архивной записи).
CreatedAt / UpdatedAtШтампы применения.

Маппинг: city-событие → CityEntity + derive RegionEntity

Событие handbooks.after-city-information-update — единственный источник и городов, и регионов.

Город

Поле события TalentТипПоле реплики PINПравило
IdlongIdId PIN = id источника.
NamestringNameПерезапись; длиннее 256 символов — truncate + warn.
Country (CountryEventModel {CountryId short, Name, IsoCode2, IsoCode3})модельCountryIdБерётся только CountryId. Вложенная модель страну не создаёт и не обновляет (в ней нет полных полей) — страна обязана уже существовать своим событием; отсутствует → retriable HANDBOOK_SYNC_COUNTRY_NOT_FOUND (R4).
Region? (RegionEventModel {RegionId long, Name})модель?RegionIdRegionId = Region.RegionId; null — город вне региона (напр., города федерального значения). RegionEventModel не несёт CountryId — страну региона задаёт CountryId вмещающего city-события (ADR-0063 п.4). Согласованность цепочки — INV-HND-3 (city.md): смена страны у существующего региона → HANDBOOK_SYNC_GEO_MISMATCH → DLQ.
CreatedAt (envelope, метаданные)DateTimeOffsetLastEventAtМонотонный guard R2 (INV-HND-4): CreatedAt > LastEventAt. Per-record Version у события нет — монотонность по envelope CreatedAt + порядок партиции (key = Id).
IsArchivedТолько auto-restore R5.

Регион (derive из вложенной RegionEventModel)

Поле RegionEventModel / метаданныеТипПоле реплики PINПравило
RegionIdlongIdId PIN = id CountryRegion источника.
Country.CountryId вмещающего city-события (не поле RegionEventModel)shortCountryIdСтрана региона derive-ится из CountryId city-событияRegionEventModel собственного CountryId не несёт (ADR-0063 п.4). Задаётся при создании; смена страны у существующего региона → нарушение INV-HND-3 → HANDBOOK_SYNC_GEO_MISMATCH → DLQ (region.md).
NamestringNameПерезапись; длиннее 256 символов — truncate + warn.
CreatedAt city-события (envelope, метаданные)DateTimeOffsetLastEventAtУ источника нет per-record версии региона — монотонность по времени публикации: применяется только при CreatedAt > LastEventAt (INV-HND-4, R2).
IsArchivedТолько auto-restore R5.
Независимость guard-ов региона и города

Guard-ы региона и города независимы: stale-город не блокирует применение более свежего региона, и наоборот. region-upserted re-publish-ится только если запись региона фактически изменилась (создание, смена Name, restore) — переименование региона размножено по city-событиям всех его городов, и дедупликация фактических изменений защищает потребителей от шторма одинаковых событий.

Связи

Логические ссылки (без FK) между репликами и служебный чекпоинт HandbookSyncState — по одной строке на (AggregateType, ExternalId) каждой из пяти реплик:

Маппинг: currency-событие → CurrencyEntity

Поле события TalentТипПоле реплики PINПравило
IdshortIdId PIN = id источника.
NamestringNameПерезапись; длиннее 64 символов — truncate + warn.
IsoCodestringIsoCodeНормализация в upper (ISO-4217); уникальность среди неархивных валют (INV-HND-2) — конфликт → R6.
Signstring?SignПерезапись.
CreatedAt (envelope, метаданные)DateTimeOffsetLastEventAtМонотонный guard R2 (INV-HND-4): CreatedAt > LastEventAt. Per-record Version нет — монотонность по envelope CreatedAt + порядок партиции (key = Id).
IsArchivedТолько auto-restore R5.

Маппинг: language-событие → LanguageEntity

Поле события TalentТипПоле реплики PINПравило
IdshortIdId PIN = id источника.
NamestringNameПерезапись; длиннее 64 символов — truncate + warn.
IsoCode2stringIsoCode2Нормализация в lower (ISO 639-1); INV-HND-2 — конфликт → R6.
IsoCode3stringIsoCode3Нормализация в lower (ISO 639-2/3); INV-HND-2 — конфликт → R6.
FlagIsoCode2string?FlagIsoCode2Нормализация в upper — код страны флага (display-атрибут, логическая ссылка без FK).
CreatedAt (envelope, метаданные)DateTimeOffsetLastEventAtМонотонный guard R2 (INV-HND-4): CreatedAt > LastEventAt. Per-record Version нет — монотонность по envelope CreatedAt + порядок партиции (key = Id).
IsArchivedТолько auto-restore R5.

Правила синка (R1–R9)

#ПравилоСодержание
R1 ИдемпотентностьПовторная доставка события не меняет состояниеДедупликация по идентификатору события (MessageId): ключ handbook-sync:{eventType}:{MessageId}, eventType ∈ {country, city, currency, language}; дубль → skip + ack. Бизнес-идемпотентность — R2
R2 Монотонность примененияСобытие применяется только если его envelope CreatedAt > LastEventAt реплики (INV-HND-4). У событий источника нет per-record Version — источник монотонности — время публикации (envelope CreatedAt) и порядок партиции Kafka (key = Id → апдейты одной записи не переупорядочиваются); guard — «не откатывать реплику назад по CreatedAt источника». Одинаково для стран, городов, валют, языков и региона (derive)Stale-событие (CreatedAt <= LastEventAt) → skip реплики + метрика handbook_sync_stale_events_total; при этом HandbookSyncState.LastEventId/LastEventAt обновляются — отметка «запись жива в источнике», основа reconciliation-отчёта R9
R3 Snapshot-семантикаUpsert-событие несёт полный snapshot записи; применение перезаписывает все синк-владеемые поля (не диффы)Поле IsArchived синк не трогает (кроме R5): архив — локальное решение PIN (ADR-0063 п.5)
R4 Порядок country→cityКросс-справочный порядок источником не гарантирован. City-событие (и derive региона в нём) при отсутствии страны → retriable HANDBOOK_SYNC_COUNTRY_NOT_FOUND (retry с backoff → DLQ после исчерпания); страна появится своим событием, city-событие не теряетсяDefaultCurrencyId/DefaultLanguageId страны — логические ссылки без FK: нерезолвимость до прихода события валюты/языка допустима и retry не требует
R5 Auto-restoreПовторный приход заархивированной в PIN записи из источника → IsArchived = false + warn-метрика handbook_sync_auto_restores_total + re-publishИсточник главнее локального архива: раз запись re-эмитится — она жива; расхождение сигнализируется, но не блокирует синк
R6 ISO-конфликтНарушение INV-HND-2 (ISO-код занят другой неархивной записью того же справочника) → HANDBOOK_SYNC_ISO_CONFLICT, non-retriable → DLQ + алерт; HandbookSyncState.Status = ConflictРазрешение только ручное: данные правятся в Talent Handbook → replay события из DLQ (автопочинки на стороне PIN нет)
R7 УдаленияИсточник delete-событий не шлёт — физических удалений справочных записей в PIN нет вообще. Вывод из оборота — только IsArchived через admin archive/restore и reconciliation-процедуру (кандидаты подтверждает администратор)ADR-0063 п.5; исторические ссылки (<X>Id в старых документах/событиях) не ломаются — resolve включает архивные (INV-HND-5)
R8 АтомарностьМутация реплики + HandbookSyncState + отметка идемпотентности + re-publish pin.handbook.* — одно согласованное изменение; offset подтверждается только после фиксацииДля city-события: регион + город + оба чекпоинта + оба исходящих события — в одном изменении
R9 Bootstrap / полный ресинкНачальная загрузка и полная пересинхронизация — re-эмиссия всех строк источником (POST sync-handbooks Talent) и штатное применение consumer-ом; reconciliation-отчёт по не пришедшим в окно строкамtalent-initial-import.md; downstream-потребители (chessboard) бутстрапятся чтением compacted pin.handbook.* с offset 0 — без участия синка

Поля HandbookSyncState

Чекпоинт «одна строка на запись справочника»: диагностика отставания, отметка живости для reconciliation, DLQ-расследования.

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор записи.
AggregateTypeHandbookAggregateTypeДаvarchar(32): Country / Region / City / Currency / Language; часть uniqueconsumerТип справочной записи.
ExternalIdlongДаunique ux_handbook_sync_states_type_external (aggregate_type, external_id)consumerId записи в источнике (= id реплики PIN); short-id стран/валют/языков хранятся в long без потерь.
LastEventIdGuidДаметаданные событияMessageId последнего события по записи — включая stale-skip (R2).
LastEventAtDateTimeOffsetДаindex ix_handbook_sync_states_last_event_atметаданные событияEnvelope CreatedAt последнего применённого события по записи — источник монотонности (R2, INV-HND-4): событие применяется только при CreatedAt > LastEventAt. Обновляется и при stale-skip как отметка живости: reconciliation считает кандидатами в архив строки с LastEventAt старше окна ресинка. Единый механизм для всех типов записей — отдельного поля версии нет (у источника per-record Version отсутствует).
StatusHandbookSyncRecordStatusДаvarchar(32): Synced / Conflict / DeadLetteredconsumerConflict — ISO/GEO-конфликт данных (R6, INV-HND-3); DeadLettered — событие ушло в DLQ после исчерпания retry (R4).
LastErrorstring?Нетvarchar(1024)consumerТекст последней ошибки (без PII — PII в справочниках нет by design).
CreatedAt / UpdatedAtDateTimeOffsetДаconsumerШтампы.
Инварианты

Одна строка на (AggregateType, ExternalId) — unique-индекс; Status = Conflict/DeadLettered требует ручного действия (правка данных в источнике → replay из DLQ) с возвратом в Synced. stateDiagram тривиален (Synced ↔ Conflict/DeadLettered), отдельно не приводится.

Входной и исходящий контракты

Входной контракт (что синк ожидает от Talent). Key события = id записи → строгий порядок per-record в партиции топика; полный snapshot в каждом событии (R3); per-record Version в payload нет — источник монотонности — envelope CreatedAt (время публикации) + порядок партиции Kafka (key = Id не даёт переупорядочивания апдейтов одной записи), guard R2 «не откатывать реплику назад по CreatedAt»; статический ContractVersion = 2 (несовпадение — warn-and-proceed + метрика handbook_sync_contract_mismatch_total). Кросс-справочный порядок (country→city, currency/language → country) не гарантируется — компенсируется R4 и логическими ссылками без FK. Delete-событий нет (R7). Точные контракты — schema registry talent.microservices.handbook (upstream source of truth).

Assumption

Имена топиков handbooks.after-* — зафиксированное assumption, подтверждается интеграционным контрактом при реализации.

Исходящий контракт (что downstream может предполагать). Синк не «тихий»: каждое применённое изменение (upsert синка, auto-restore, admin archive/restore, пересинхронизация) re-publish-ится собственным событием PIN {country|region|city|currency|language}-upserted в compacted-топик pin.handbook.{countries|regions|cities|currencies|languages}.v1: key = Id записи (порядок per-record), ContractVersion = 1, payload = полный snapshot реплики, включая IsArchived и LastEventAt (envelope CreatedAt последнего применённого события источника — единый для всех типов записей). Доставка at-least-once после фиксации изменения (R8); compacted-хранение позволяет новому потребителю бутстрапиться чтением с offset 0 в любой момент. Так как страна применяется раньше своих регионов/городов (R4), потребитель, зависящий от страны, получает pin.handbook.countries.v1 не позже связанных regions.v1/cities.v1. Downstream-сервисы PIN на топики Talent не подписываются (ADR-0063 п.1). Контракты событий: ../events/country-upserted.md, ../events/region-upserted.md, ../events/city-upserted.md, ../events/currency-upserted.md, ../events/language-upserted.md.

Наблюдаемость, безопасность

АспектЗначение
Метрикиhandbook_sync_lag_seconds (задержка применения: момент применения − envelope CreatedAt, по AggregateType), handbook_sync_stale_events_total, handbook_sync_dlq_total, handbook_sync_contract_mismatch_total, handbook_sync_auto_restores_total
Логипо событию: MessageId, AggregateType, ExternalId, envelope CreatedAt, действие (created/updated/skipped-stale/auto-restored/conflict/dead-lettered); PII в справочных данных нет
Трейсингкорреляция из метаданных события пробрасывается в исходящие pin.handbook.*
АлертыDLQ > 0; Conflict > 0; лаг применения > 15 мин при ненулевом потоке
Runbookreplay DLQ: исправить данные в Talent Handbook → повторная подача события; полная пересинхронизация — R9 (talent-initial-import.md)
Безопасностьconsumer работает под system-actor (не пользовательский контекст); доступ к топикам Talent — m2m ACL Kafka; справочники не tenanted и не содержат PII

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

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

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