ADR-0063: Pooled bulk ingestion + canonical system observation owner
Status: accepted Date: 2026-06-15 Deciders: Maxim Belkanov
Контекст
System bulk ingestion («Наполнение») для batch-поставщиков (ETM / IEK / Systeme / Russvet) выполнял полный обход каталога на каждый активный system-credential:
for cred := range Credentials.ListActive(ctx, supplier) {
// полный независимый sweep с курсором scope "<jobKind>/credential/<ref>"
}N system-ключей → N полных дублей обхода. Это не снижало rate-limit на ключ, а
множило нагрузку. Round-robin пул (P4c-2) уже существовал, но использовался
только в customer live-refresh (pool_service.go, proposal_integration).
Главный блокер при переходе на round-robin (резолв P1#1): discovery кэш-предложений
матчит наблюдения по точному oo.credential_ref выбранного ключа без фильтра по
scope (offer_discoverer_stub.go), а системный выбор ключа в proposal был
round-robin (legacy_router → pool.Pick). Сегодня это работало лишь потому, что
каждый ключ делал полный дубль (полное покрытие на каждый ref). После round-robin
системного commerce-обхода наблюдения разъехались бы по ключам → кэш-предложения
стали бы частичными.
Решение
Approach A — ротатор на границе страницы. Один логический StreamRequest на
обход; коннектор на границе страницы спрашивает у CredentialRotator следующий ключ
(round-robin, health/budget-aware), берёт сессию (SessionManager кэширует per
credential_ref), тянет страницу. Подпункты:
-
Канонический системный владелец (резолв P1#1). Системные наблюдения (enumeration / commerce / by-SKU shared) штампуются
credential_ref = PrimarySystemCredentialRef(supplier)— детерминированный минимальный по ID активный system-ключ, НЕ транспортный ключ;scope=system,CustomerRef=nil. Транспортный ключ — только audit (connectortransport, ADR-0057). Системный путь выбора ключа в proposal делается детерминированным на тот же канон, поэтому discovery SQL не меняется (oo.credential_ref = selection, где selection = канон = ref владения). ИнвариантObservation.Validateэто допускает (связи ref↔scope нет). -
Transport ≠ ownership.
applyObservationCredentialWithOwnership(obs, transport, ownership, scope, customer): пустой ownership → транспорт владеет (customer-schedule / legacy, поведение не меняется); непустой → ownership=канон, scope=system. -
Per-supplier курсоры. Scope
"<jobKind>/credential/<ref>"→"catalog/<supplier>"/"commerce/<supplier>". Russvet внутренние source-owned курсорыrussvet_stock:<ref>/russvet_specs:<ref>→russvet_stock:<supplier>/russvet_specs:<supplier>. Миграция — lazy-merge-on-first-read: при первом чтении per-supplier курсора legacy per-credential строки сводятся в самый продвинутый через strategy-specificMoreAdvanced(pipeline) либо source-owned comparator (russvet stock/specs), upsert per-supplier, delete legacy. Без миграции схемы и downtime. -
Enumeration = coarse, system-only. Перечисление каталога ведёт только system-пул (page-курсор безопасен лишь при идентичной пагинации у всех ключей); ротатор выбирает один ключ на запуск энумератора. Per-page ротация применяется только в by-SKU / commerce постраничных циклах. Полное system+customer enumeration — за флагом
INGESTION_COMBINED_ENUMERATION_<SUPPLIER>(default false) после invariant-check. -
Schedule-aware фабрика ротатора (
ingestion/di.go): customer-scoped расписание → customer-пул владельца, ownership=transport (customer); system / legacy → system-пул per jobKind (EnumerationCandidates/CommerceCandidates/SharedBySkuCandidates), ownership=канон. -
Page failure →
ReportFailureфактического ключа + retry той же страницы следующим ключом (черезexclude); курсор не двигается до успеха.
Последствия
Плюсы
- Один логический обход вместо N — давление на per-key rate-limit падает при том же агрегатном throughput; запросов на каждый аккаунт ~вдвое меньше при пуле из 2 ключей.
- Покрытие кэш-предложений сохранено (канон + детерминированный системный выбор).
- Курсоры консолидированы per-supplier без потери прогресса (lazy-merge самого продвинутого).
- Попутно устранено нарушение Clock-инварианта:
poolRotator.Nextбольше не зовётtime.Now()напрямую.
Минусы
- Окно рассинхрона при смене primary (revoke/добавление ключа с меньшим ID) ≤ один commerce-цикл (commerce переписывается каждый обход; самоисцеление). Принято.
- Живой системный refresh без customer-ключа (редкий путь) перестаёт ротировать транспорт — бьёт primary. Customer live-refresh не затронут.
- Per-page acquire добавляет накладные расходы на сессии (mitigated кэшем сессий per
credential_ref). - В concurrent price-batch путях (ETM, Russvet price/stock) ротация per-batch без same-page retry — чтобы не переписывать агрегацию ошибок воркеров; распределение нагрузки сохранено, retry — только в последовательных циклах (stock pairs, details).
Нейтральные последствия
StreamRequestполучил опциональное полеRotator; nil-ротатор сохраняет legacy single-session путь байт-в-байт (все существующие тесты коннекторов зелёные).RotatorFactorynil-tolerant: при отсутствии пуловых портов pipeline откатывается на single-credential ротатор от первогоListActive.- Dashboard cursor-state query расширен с
*/credential/%доcatalog/%/commerce/%, чтобы per-supplier scope продолжал отображаться в «Наполнении».
Рассмотренные альтернативы
Approach B — постраничный прогон через MaxPages=1
Гонять конвейер по одной странице, ротируя ключ между вызовами. Отклонён: коннекторы
honor’ят MaxItems, но не MaxPages — потребовал бы «одна страница и стоп» во все 4
коннектора, эффорт не меньше, профиль хуже (повторный bootstrap сессии/каталога).
Транспортное владение (без канона)
Штамповать credential_ref фактическим транспортным ключом. Отклонён: round-robin
разъезжает наблюдения по ключам, а discovery матчит по точному ref без scope-фильтра →
частичное покрытие кэш-предложений (P1#1).
Eager-мигратор курсоров
Отдельный one-shot мигратор per-credential → per-supplier с downtime. Отклонён в пользу lazy-merge-on-first-read: идемпотентно, без миграции схемы и простоя.
Дополнение после деплоя (2026-06-15)
Канонический владелец должен применяться к каждому пути записи системных
наблюдений, не только к bulk-конвейеру. Live-verify на проде вскрыл второй путь:
worker stock-detail-warmer (internal/core/stock-warming/app/warmer.go)
прогревал остатки циклом «тик на каждый активный system-ключ» и штамповал
транспортный credential_ref — при двух+ активных system-ключах остатки одного
SKU разъезжались по ключам и терялись для discovery (та же P1#1-утечка).
Исправлено (commit 12b6c0af): Worker.Tick штампует канон (min-ID system),
сохраняя прогрев под сессией каждого ключа ради warehouse-покрытия. Латентно тот
же паттерн в IngestOrchestrator (legacy full-catalog путь) — не триггерится,
пока нет non-bulk поставщиков с 2+ активными system-ключами; follow-up.
Ссылки
- Spec:
docs/superpowers/specs/2026-06-15-credential-pool-bulk-ingestion-design.md - Plan:
docs/superpowers/plans/2026-06-15-credential-pool-bulk-ingestion.md - ADR-0060 (universal cursor), ADR-0057 (connector transport log), P4c-2 (credentials BC)
- Память:
tracium_credentials_model,tracium_proposal_breadth_principle