Runbook: live supplier contour

Полная проверка ingestion → normalization → matching → canonical assignment на реальных поставщиках. Используется перед выводом нового поставщика в prod и после оптимизаций batch/cursor стратегий.

Жесткие правила

  • Provider credentials и customer credentials проверяются через БД / Credentials BC. В .env не искать логины, пароли, API tokens, master keys. В .env допустимы только base URL, таймауты, лимиты, режимы enumerator и инфраструктурные endpoints.
  • One-shot supplier-sync --tick-once не заменяет постоянно работающий scheduler. Если перед ручным прогоном supplier-sync был остановлен, после проверки обязательно вернуть его через docker compose ... up -d supplier-sync.
  • Для batch-capable supplier регулярный путь должен идти через BulkSnapshotPipeline / cursor, а не legacy per-SKU loop. Исключение только для документированного API без массового метода.
  • Assignment resolver нельзя проверять серией частых restart: можно прервать LLM batch и получить временные in_progress disputes. Запускайте один раз и ждите итоговый summary либо штатный stale reclaim.

Команда live one-shot

Лог всегда сохранять в JSONL, чтобы можно было пересчитать метрики после прогона:

mkdir -p .cache/live-runs
RUN_ID=$(date -u +%Y%m%dT%H%M%SZ)-suppliers
LOG=.cache/live-runs/${RUN_ID}.jsonl
 
SUPPLIER_SYNC_SUPPLIERS='etm:30m:true,iek:30m:true,systeme:30m:true,russvet:30m:true' \
SUPPLIER_SYNC_RUN_ON_START=false \
docker compose --env-file .env --env-file deploy/.env -p tracium \
  -f deploy/docker/docker-compose.yml \
  -f deploy/docker/docker-compose.web.yml \
  -f deploy/docker/docker-compose.web.ci.yml \
  -f deploy/docker/docker-compose.local-prod.yml \
  run --rm --no-deps supplier-sync /usr/local/bin/supplier-sync --tick-once \
  2>&1 | tee "$LOG"

Для длинного Russvet backfill допускается отдельный прогон с увеличенным stock budget:

SUPPLIER_RUSSVET_STOCK_PAGES_PER_TICK=5000 \
SUPPLIER_RUSSVET_SPECS_MAX_ITEMS_PER_TICK=0 \
SUPPLIER_SYNC_SUPPLIERS='russvet:30m:true' \
SUPPLIER_SYNC_RUN_ON_START=false \
docker compose --env-file .env --env-file deploy/.env -p tracium \
  -f deploy/docker/docker-compose.yml \
  -f deploy/docker/docker-compose.web.yml \
  -f deploy/docker/docker-compose.web.ci.yml \
  -f deploy/docker/docker-compose.local-prod.yml \
  run --rm --no-deps supplier-sync /usr/local/bin/supplier-sync --tick-once \
  2>&1 | tee "$LOG"

SUPPLIER_RUSSVET_SPECS_MAX_ITEMS_PER_TICK=0 означает полный specs backfill. Для регулярного prod-режима предпочтителен bounded window (>0), чтобы длинные specs не монополизировали тик.

Prod warmup limits

Для первичного наполнения prod на пустой базе supplier ticks должны быть ограничены чанками и переизбираться scheduler’ом, а не держать один процесс до конца полного каталога. Это сохраняет heartbeat, дает понятный прогресс в ingestion_job_runs и позволяет новым данным попадать в очередь между backfill-окнами.

Рекомендуемые стартовые лимиты:

  • SUPPLIER_ETM_PREFER_CATALOG_ENUMERATOR=false — ETM characteristics warmup берет backlog из БД/детального enumerator, а не полный SgGds каталог на каждый тик.
  • SUPPLIER_ETM_DETAILS_MAX_ITEMS=1000, SUPPLIER_ETM_DETAILS_CONCURRENCY=1, SUPPLIER_ETM_DATA_RPS=1, SUPPLIER_ETM_DATA_BURST=1 — ETM details API остается в supplier rate budget и завершает тик bounded window.
  • SUPPLIER_IEK_CATALOG_MAX_ITEMS=5000 и SUPPLIER_SYSTEME_CATALOG_MAX_ITEMS=5000 — initial warmup режется на наблюдаемые пачки; 0 допустим только для ручного полного прогона.
  • SUPPLIER_RUSSVET_SPECS_MAX_ITEMS_PER_TICK=1000 — specs backfill не должен блокировать commerce/catalog расписание.

DB scheduler claims work independently per supplier_ref + job_kind. This lets catalog/specs backfill continue while commerce refreshes prices and stock for the same supplier. ingestion_schedules.max_parallel caps active schedules inside each group; provider-facing request pressure is still bounded by the supplier rate-limit environment variables above.

После каждого bounded тика проверяйте, что следующий next_run_at близко к текущему времени в warmup-окне, а last_outcome переходит в success, partial или объяснимый supplier-side skip. Повторяющиеся killed, timeout или unbounded running старше SLA тика означают, что лимит/offset не работает.

Логи

Краткая сводка фаз:

jq -rc '
  select(.event=="ingestion.bulk_snapshot.tick.summary"
    or (.event=="ingestion.bulk.phase" and .state=="completed"))
  | [.time,.supplier,(.phase//"summary"),.outcome,.duration_ms,
     (.item_count//""),(.price_count//""),(.stock_count//""),(.stock_complete//"")]
  | @tsv
' "$LOG"

Прогресс длинных batch/paged фаз:

jq -rc '
  select(.event=="ingestion.bulk.progress"
    or .event=="ingestion.bulk.retry"
    or .event=="ingestion.bulk.page.skipped"
    or .event=="ingestion.bulk.batch.skipped"
    or .event=="ingestion.bulk.phase.skipped"
    or .event=="ingestion.bulk.item.skipped")
  | {time,supplier,phase,page,batch,batch_size,rows_seen,rows_matched,target_sku_count}
' "$LOG"

Оценка скорости незавершенного live-run:

# ETM prices: сколько batch уже закрыто и среднее время batch.
jq -Rrc '
  fromjson?
  | select(.event=="ingestion.http.request.end"
      and .supplier=="etm" and .phase=="prices" and .outcome=="success")
  | .duration_ms
' "$LOG" | awk '{sum+=$1; n++} END {printf "batches=%d avg_ms=%.0f total_min=%.1f\n", n, (n?sum/n:0), sum/60000}'
 
# Russvet specs: сколько per-SKU specs уже пройдено.
jq -Rrc '
  fromjson?
  | select(.event=="ingestion.http.request.start"
      and .supplier=="russvet" and .phase=="specs")
' "$LOG" | wc -l
 
# Systeme catalog/prices: постраничная скорость.
jq -Rrc '
  fromjson?
  | select(.event=="ingestion.http.request.end"
      and .supplier=="systeme" and (.phase=="catalog" or .phase=="prices")
      and .outcome=="success")
  | [.phase,.page,.rows_seen,.duration_ms] | @tsv
' "$LOG" | tail -30

Ошибки HTTP без секретов:

jq -rc '
  select(.event=="ingestion.http.request.end" and .outcome!="success")
  | {time,supplier,phase,path,status,duration_ms,error}
' "$LOG"

DB assertions

Покрытие последнего observation по supplier:

WITH latest AS (
  SELECT DISTINCT ON (so.supplier, so.supplier_sku)
         so.supplier, so.supplier_sku, oo.prices, oo.stock_current,
         oo.raw_refs, oo.observed_at, so.canonical_id
    FROM supplier_offers so
    JOIN offer_observations oo ON oo.offer_id = so.id
   ORDER BY so.supplier, so.supplier_sku, oo.observed_at DESC
)
SELECT supplier,
       count(*) AS offers,
       count(*) FILTER (
         WHERE prices->'RUB'->'Net' IS NOT NULL
           AND prices->'RUB'->'Net' <> 'null'::jsonb
       ) AS with_net_price,
       count(*) FILTER (WHERE stock_current <> '{}'::jsonb) AS with_stock_payload,
       count(*) FILTER (WHERE raw_refs ? 'remains') AS raw_remains,
       count(DISTINCT canonical_id) AS canonical_count,
       max(observed_at) AS last_observed
  FROM latest
 GROUP BY supplier
 ORDER BY supplier;

Интерпретация:

  • offers — уникальные supplier SKU в текущей базе.
  • with_net_price — SKU, где поставщик реально отдал числовую net-цену. Цена on_request не считается числовой ценой, но payload сохраняется.
  • with_stock_payload — SKU с ненулевым stock payload. Это не должно быть равно offers: у многих SKU поставщик честно отдает пустой остаток.
  • raw_remains — для полного stock snapshot означает, что ось остатка была обработана для SKU, даже если остаток пустой. Для supplier без stock API (IEK public API) ожидается 0.
  • canonical_count около offers не означает провал merge: оно зависит от пересечений в текущей выборке. Для проверки схлопывания нужна overlap выборка.

Downstream состояние:

SELECT status, match_confidence, tier, count(*)
  FROM match_decisions
 GROUP BY status, match_confidence, tier
 ORDER BY count(*) DESC;
 
SELECT supplier,
       count(*) AS raw,
       count(*) FILTER (WHERE mapping_id IS NOT NULL) AS mapped,
       round(100.0 * count(*) FILTER (WHERE mapping_id IS NOT NULL) / nullif(count(*),0), 2) AS mapped_pct
  FROM offer_characteristic_raw
 GROUP BY supplier
 ORDER BY supplier;
 
SELECT state, count(*)
  FROM assignment_dispute_queue
 GROUP BY state
 ORDER BY count(*) DESC;

Downstream порядок

  1. matcher-worker --batch-once до нулевого backlog:
docker compose --env-file .env --env-file deploy/.env -p tracium \
  -f deploy/docker/docker-compose.yml \
  -f deploy/docker/docker-compose.web.yml \
  -f deploy/docker/docker-compose.web.ci.yml \
  -f deploy/docker/docker-compose.local-prod.yml \
  run --rm --no-deps matcher-worker /usr/local/bin/matcher-worker --batch-once
  1. charnorm-worker: один restart допустим, потому что CHARNORM_RUN_ON_START=true. Ждать роста mapped_pct и отсутствия WARN/ERROR.

  2. canonical-assignment-worker: после charnorm можно запускать батчи до canonicals_scanned=0, но не прерывать resolver. Если resolver уже начал LLM batch, дождаться canonical_assignment_resolver.tick.summary.

  3. Если локальный тест сам прервал resolver и оставил свежие in_progress, штатный reclaim вернет их через 15 минут. Не фиксить prod этим способом; для локального восстановления допустим ручной reset только после подтверждения, что это артефакт тестового рестарта.

Acceptance

  • Все включенные suppliers имеют ingestion.bulk_snapshot.tick.summary с outcome=success.
  • В логах нет ERROR и нет ingestion.http.request.end outcome=error.
  • Все batch-capable suppliers показывают batch/paged/full-export phases, не legacy per-SKU refresh. ETM prices идут batch, ETM stock через полный /goods/remains, Systeme prices через paged getprice, Systeme stock через полный getstockall, Russvet prices через massprice, stock по всем warehouses.
  • Для scanner loop допустимы ingestion.bulk.retry и после исчерпания retry budget *.skipped: страница/batch/SKU/full-export помечается missing, а supplier tick продолжает писать остальной snapshot. Повторяющиеся skipped-события по одной фазе — blocker для prod до выяснения причины у поставщика или в proxy.
  • Charnorm доводит mapped coverage до 100% либо оставляет явно объясненный tail.
  • Matcher backlog равен 0, conflicts отсутствуют или зарегистрированы в moderation queue.
  • Canonical assignment дошел до canonicals_scanned=0 после новых mappings.
  • Canonical merge проверен на overlap-наборе: один физический товар от разных поставщиков попадает в один canonical, но одинаковые характеристики разных производителей/брендов не схлопываются.

Live evidence 2026-05-06

Контур с DB-backed credentials:

SupplierResultNotes
ETM1000 offers, prices phase 318691 ms, stock phase 2672 msprices batch по 25 SKU; stock через /goods/remains, 24 SKU с ненулевым остатком
IEK500 offers, 8277 mspublic API не отдает stock axis, цены в goods payload
Systeme1000 offers, prices 166/1000, stock payload для 1000/1000устаревший sample до перехода на paged getprice + full getstockall; после оптимизации требуется новый full run
Russvet1000 offers, full run 767665 msall warehouses stock cycle завершен, 309 SKU с ненулевым stock

На текущем 1000-SKU sample найдено 5 реальных overlap groups ETM+Russvet. Этого достаточно для smoke exact MPN merge, но недостаточно для приемки ожидаемых triple overlaps Russvet/IEK/ETM/Systeme. Для prod acceptance нужен целевой overlap seed или расширенный catalog window.

Live evidence 2026-05-07

Контрактные нюансы, подтвержденные real curl / live-run:

SupplierFindingOperational meaning
IEKpage=1&pageSize=500 возвращает envelope с totalPage; pageSize > 500 API ограничивает до 500scheduled catalog должен пагинироваться по 500, без SKU-by-SKU; stock axis в public product API не найден
ETM/goods/{50 sku}/price возвращает 50 rows; 100/120 SKU в path API фактически режет до 50 rowsbatch size 50 является текущим provider max; увеличивать выше нельзя, иначе часть цен потеряется
ETM/goods/remains возвращает полный stock export быстро, строки/коды приходят как stringsstock нужно грузить полным export и фильтровать по catalog snapshot; пустой stock payload для SKU не равен ошибке
Systemegetdata / getprice работают page/pageSize=50; getstockall является full all-warehouses exportне использовать per-SKU getstock; длинный run анализировать по ingestion.http.request.end и ingestion.bulk.progress
Russvetcatalog и prices batch, но /specs/{sku} только per SKU; residue/partner stock идет по всем warehouses с cursorfull specs backfill долгий; cursor должен сохранять progress, чтобы после transient fail продолжать с места падения

Текущий 10k критерий для локальной приемки: каждый поставщик должен закрыть ingestion.bulk_snapshot.tick.summary outcome=success, после чего DB assertion должен показывать свежие observed_at по загруженному окну. До завершения длинных фаз ETM/Russvet/Systeme отсутствие свежих строк в БД не является самостоятельным маппинг-багом: BulkSnapshotPipeline пишет observation только после того, как supplier source начал yield конкретных FetchResult.