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_routerpool.Pick). Сегодня это работало лишь потому, что каждый ключ делал полный дубль (полное покрытие на каждый ref). После round-robin системного commerce-обхода наблюдения разъехались бы по ключам → кэш-предложения стали бы частичными.

Решение

Approach A — ротатор на границе страницы. Один логический StreamRequest на обход; коннектор на границе страницы спрашивает у CredentialRotator следующий ключ (round-robin, health/budget-aware), берёт сессию (SessionManager кэширует per credential_ref), тянет страницу. Подпункты:

  1. Канонический системный владелец (резолв 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 нет).

  2. Transport ≠ ownership. applyObservationCredentialWithOwnership(obs, transport, ownership, scope, customer): пустой ownership → транспорт владеет (customer-schedule / legacy, поведение не меняется); непустой → ownership=канон, scope=system.

  3. 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-specific MoreAdvanced (pipeline) либо source-owned comparator (russvet stock/specs), upsert per-supplier, delete legacy. Без миграции схемы и downtime.

  4. Enumeration = coarse, system-only. Перечисление каталога ведёт только system-пул (page-курсор безопасен лишь при идентичной пагинации у всех ключей); ротатор выбирает один ключ на запуск энумератора. Per-page ротация применяется только в by-SKU / commerce постраничных циклах. Полное system+customer enumeration — за флагом INGESTION_COMBINED_ENUMERATION_<SUPPLIER> (default false) после invariant-check.

  5. Schedule-aware фабрика ротатора (ingestion/di.go): customer-scoped расписание → customer-пул владельца, ownership=transport (customer); system / legacy → system-пул per jobKind (EnumerationCandidates/CommerceCandidates/SharedBySkuCandidates), ownership=канон.

  6. Page failureReportFailure фактического ключа + 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 путь байт-в-байт (все существующие тесты коннекторов зелёные).
  • RotatorFactory nil-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