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_progressdisputes. Запускайте один раз и ждите итоговый 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 (IEKpublic 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 порядок
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-
charnorm-worker: одинrestartдопустим, потому чтоCHARNORM_RUN_ON_START=true. Ждать ростаmapped_pctи отсутствия WARN/ERROR. -
canonical-assignment-worker: после charnorm можно запускать батчи доcanonicals_scanned=0, но не прерывать resolver. Если resolver уже начал LLM batch, дождатьсяcanonical_assignment_resolver.tick.summary. -
Если локальный тест сам прервал 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 через pagedgetprice, 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:
| Supplier | Result | Notes |
|---|---|---|
| ETM | 1000 offers, prices phase 318691 ms, stock phase 2672 ms | prices batch по 25 SKU; stock через /goods/remains, 24 SKU с ненулевым остатком |
| IEK | 500 offers, 8277 ms | public API не отдает stock axis, цены в goods payload |
| Systeme | 1000 offers, prices 166/1000, stock payload для 1000/1000 | устаревший sample до перехода на paged getprice + full getstockall; после оптимизации требуется новый full run |
| Russvet | 1000 offers, full run 767665 ms | all 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:
| Supplier | Finding | Operational meaning |
|---|---|---|
| IEK | page=1&pageSize=500 возвращает envelope с totalPage; pageSize > 500 API ограничивает до 500 | scheduled catalog должен пагинироваться по 500, без SKU-by-SKU; stock axis в public product API не найден |
| ETM | /goods/{50 sku}/price возвращает 50 rows; 100/120 SKU в path API фактически режет до 50 rows | batch size 50 является текущим provider max; увеличивать выше нельзя, иначе часть цен потеряется |
| ETM | /goods/remains возвращает полный stock export быстро, строки/коды приходят как strings | stock нужно грузить полным export и фильтровать по catalog snapshot; пустой stock payload для SKU не равен ошибке |
| Systeme | getdata / getprice работают page/pageSize=50; getstockall является full all-warehouses export | не использовать per-SKU getstock; длинный run анализировать по ingestion.http.request.end и ingestion.bulk.progress |
| Russvet | catalog и prices batch, но /specs/{sku} только per SKU; residue/partner stock идет по всем warehouses с cursor | full 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.