Runbook: Elevel / e.way ingestion deploy

Первичное подключение Elevel, проверка бесплатного full catalog, коммерческих данных, customer credentials и сцепления с существующими канониками.

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

  • Login/password не передавать в аргументах команд, логах, shell history, .env, Git или документации. Секретный JSON создаёт secret manager во временном файле с mode 0600; после seed файл удаляется и локально, и из контейнера.
  • Production config изменяется только через deploy-managed файлы. Ручное изменение /srv/tracium/infra/deploy/.env допустимо лишь как аварийный мост и не считается завершённым rollout.
  • fieldSet=max запрещён без отдельного ограниченного разрешения. Catalog и commerce jobs используют только fieldSet=standard.
  • Acceptance требует полный SUPPLIER_ELEVEL_CATALOG_MAX_ITEMS=0 и весь downstream-контур: raw storage → charnorm → matcher/LLM → canonical assignment.

Симптом

  • Elevel отсутствует в /suppliers admin или в /cabinet/credentials;
  • системный tick не создаёт supplier_offers / offer_observations;
  • customer credential сохраняется, но цена/остаток не меняются на клиентские;
  • Elevel offers не сцепляются с существующими канониками;
  • в provider trace появился fieldSet=max без согласованного разрешения.

Pre-flight

  1. Убедиться, что deploy содержит Elevel в supplier catalog, connector wiring, SUPPLIER_SYNC_SUPPLIERS и SUPPLIER_ELEVEL_* mapping.
  2. Проверить deploy-managed значения: SUPPLIER_ELEVEL_CATALOG_MAX_ITEMS=0, HTTPS base URL, разумные page size, RPS/burst и elevel:1h:true в schedule.
  3. Проверить, что master key Credentials BC доступен runtime через secret manager, не выводя его значение.
  4. Проверить global job-control gate и отсутствие активного Elevel tick перед первичным one-shot.

Seed системного credential

System metadata использует UUID 00000000-0000-0000-0000-000000000007. Secret-файл содержит только эту запись; значение — JSON-строка формы {login,password}. Для точечного onboarding CLI запускается с --only-credential-id=00000000-0000-0000-0000-000000000007, поэтому секреты других поставщиков не извлекаются и не ротируются.

Рекомендуемый порядок:

  1. Secret manager создаёт локальный временный файл mode 0600 без вывода содержимого в stdout/stderr.
  2. Выбирается maintenance-образ worker-tools:<release-sha> с тем же SHA, что и production runtime. Обычный runtime/api-образ по контракту не содержит seed-credentials; при отсутствии tools-образа допустим только статический бинарник, собранный из того же release SHA и временно примонтированный в контейнер. Secret-файл монтируется read-only как /tmp/elevel-system-credential.json.
  3. В maintenance-контейнере запускается /usr/local/bin/seed-credentials --secrets-file=/tmp/elevel-system-credential.json --only-credential-id=00000000-0000-0000-0000-000000000007. CLI принимает runtime POSTGRES_DSN и использует DATABASE_URL только как приоритетный явный override; CREDENTIALS_MASTER_KEY_V1 уже должен быть передан через secret manager.
  4. Оба временных файла удаляются; проверяется только metadata и наличие encrypted secret row:
SELECT c.id, c.supplier_ref, c.scope, c.status,
       c.allowed_usages,
       (s.credential_id IS NOT NULL) AS encrypted_secret_present
  FROM supplier_credentials c
  LEFT JOIN supplier_credential_secrets s ON s.credential_id = c.id
 WHERE c.id = '00000000-0000-0000-0000-000000000007'
   AND c.supplier_ref = 'elevel';

Ожидается одна active system запись, encrypted_secret_present=true. Поля ciphertext/nonce/key ID и тем более расшифрованный secret не выводить.

Catalog и commerce one-shot

Запустить два независимых Elevel job последовательно с production compose files. One-shot не должен останавливать постоянно работающий scheduler дольше проверки.

SUPPLIER_SYNC_SUPPLIERS='elevel:1h:true' \
SUPPLIER_SYNC_JOB_KIND=catalog \
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
 
SUPPLIER_SYNC_SUPPLIERS='elevel:1h:true' \
SUPPLIER_SYNC_JOB_KIND=commerce \
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

Catalog acceptance — terminal full-cycle event с full_cycle_exact=true. Commerce cursor может закрываться несколькими ticks; продолжать до terminal cycle, не вводя SKU cap.

Диагностика и DB assertions

WITH latest AS (
  SELECT DISTINCT ON (so.id)
         so.id, so.canonical_id, oo.prices, oo.stock_current, oo.raw_refs,
         oo.credential_scope, oo.observed_at
    FROM supplier_offers so
    JOIN offer_observations oo ON oo.offer_id = so.id
   WHERE so.supplier = 'elevel'
   ORDER BY so.id, oo.observed_at DESC
)
SELECT count(*) AS offers,
       count(*) FILTER (WHERE raw_refs ? 'goods') AS goods_payload,
       count(*) FILTER (WHERE raw_refs ? 'price') AS price_payload,
       count(*) FILTER (WHERE raw_refs ? 'remains') AS stock_payload,
       count(*) FILTER (WHERE prices <> '{}'::jsonb) AS nonempty_price,
       count(*) FILTER (WHERE stock_current <> '{}'::jsonb) AS nonempty_stock,
       count(DISTINCT canonical_id) AS canonical_count,
       max(observed_at) AS last_observed
  FROM latest;
 
SELECT count(*) AS overlap_offers
  FROM supplier_offers e
 WHERE e.supplier = 'elevel'
   AND EXISTS (
     SELECT 1
       FROM supplier_offers other
      WHERE other.canonical_id = e.canonical_id
        AND other.supplier <> 'elevel'
   );

Для overlap дополнительно вручную проверить несколько групп по normalized MPN, manufacturer/brand и name. Совпадение canonical ID без совместимой identity — ошибка, а не успех.

Provider/egress trace должен содержать nomenclature fieldSet=standard, registrationNomenclatures, stockOfGoodsOffline, pricesOffline и ровно ноль fieldSet=max.

Downstream и сцепление

После завершения ingestion выполнить порядок из live-supplier-contour.md:

  1. charnorm-worker --batch-once до объяснённого нулевого/стабильного tail;
  2. matcher-worker --batch-once до нулевого backlog;
  3. canonical-assignment-worker --batch-once, не прерывая активный LLM batch;
  4. повторить DB assertions и сохранить overlap sample с positive и negative cases.

Бесплатный Elevel catalog может не содержать характеристики. Нулевой Elevel offer_characteristic_raw сам по себе допустим: существующий canonical обогащается данными других поставщиков после корректного identity match.

Customer credential и UI

  1. В ЛК открыть /cabinet/credentials, убедиться, что в добавлении провайдера есть Elevel / e.way и схема login/password.
  2. Создать тестовый customer credential через UI без раскрытия секрета в DevTools/logs. Проверить active health/status.
  3. Выполнить customer proposal для известного Elevel SKU и убедиться, что observation имеет credential_scope=customer, нужный customer_ref, непустые current price/stock и не смешивается с другим клиентом.
  4. В admin /suppliers проверить connector, system account, последние catalog и commerce ticks, request/error counters и отсутствие paid max calls.

Смягчение

  • При auth rejection отключить только Elevel schedules, сохранить каталог и последнюю успешную коммерцию, затем безопасно ротировать DB-backed secret.
  • При rate limit уменьшить deploy-managed RPS/burst и продолжить с durable cursor; не ставить постоянный SKU cap.
  • При ошибочном matching остановить canonical assignment для затронутого набора и отправить disputes на moderation; ingestion цен/остатков можно продолжать отдельно.
  • При любом незапланированном fieldSet=max немедленно отключить Elevel jobs, сохранить trace/request IDs и проверить authorizer wiring до возобновления.

Контроль coverage characteristics

supplier_char_coverage — независимая служебная метрика характеристик, не контур цен и остатков. Её пересчёт выполняет supplier-sync последовательно, один supplier за тик. Если после простоя самая старая строка уже старше 48 часов, стартовый тик ограниченно догоняет ещё одну цель; это два последовательных запроса с тем же 8-минутным лимитом, не параллельный batch. После восстановления возвращается обычная ротация. Для крупных Russvet/Systeme запрос может идти дольше дефолтных 5 минут при активной загрузке. Целевое production-значение — SUPPLIER_CHARACTERISTICS_RECONCILE_QUERY_TIMEOUT=8m; оно обязано передаваться через deploy/docker/docker-compose.yml, а не правиться в runtime .env вручную.

У worker-pool есть общий POSTGRES_STATEMENT_TIMEOUT=60s. Поэтому coverage reconcile выставляет свой лимит transaction-local через set_config до агрегации: иначе Systeme может быть отменён общим лимитом, а computed_at не сдвинется, хотя тикер был запущен. Бюджет reconcile начинается после получения соединения из worker-pool: очередь на свободное соединение не должна отнимать время у самого SQL. Не повышать общий timeout ради этой фоновой метрики.

Перед выкладкой или повторным prod-health-check проверить, что нет client queries дольше 30 минут. После выкладки убедиться, что computed_at Russvet движется и остаётся моложе 48 часов. Если запрос не укладывается в 8 минут, не повышать лимит дальше автоматически: сохранить план EXPLAIN, проверить IO и оптимизировать сам запрос/его модель данных.

Текущая реализация агрегирует offer_characteristic_raw по supplier и offer параллельным index-only проходом (до четырёх workers), с локально отключённым sort, чтобы PostgreSQL мог объединить частичные группы. Настройки действуют только внутри reconcile-транзакции и не меняют план live-ingestion. Возврат к LATERAL-проверке каждой строки supplier_offers запрещён: на крупных источниках она превращает проверку здоровья в сотни тысяч случайных index probe и оставляет computed_at stale.

Миграция 0380_offer_characteristic_raw_supplier_offer_coverage_idx создаёт для этого прохода offer_characteristic_raw_supplier_offer_coverage_idx (supplier, offer_id) через CREATE INDEX CONCURRENTLY. После выкладки убедиться, что индекс indisvalid=true и очередной reconcile обновил computed_at stale-поставщиков; не заменять это повышением 8-минутного тайм-аута.

backend-integration запускает Go-тесты уже после сборки Docker-образа. Поэтому кэш модулей должен быть слоем образа, а не BuildKit cache mount: последний недоступен runtime-контейнеру и маскирует зависание загрузки модулей общим 30-минутным тайм-аутом job.

Устранение root cause

  • Auth: исправить/ротировать secret через Credentials BC, не через .env.
  • Transport: исправить HTTPS base URL, payload mapping или rate budget в deploy-managed конфигурации и выпустить обычным deploy.
  • Matching: исправить identity mapping/нормализацию с sanitized fixture и regression test; не сливать каноники вручную без проверки negative case.
  • Paid guard: оставить deny-by-default, добавить отдельный аудитируемый authorizer только для точных item IDs, credential, requester, reason и TTL.

Acceptance

  • Elevel виден в admin и ЛК;
  • system credential active и зашифрован в DB;
  • full catalog завершён без cap, price/stock payload сохранены;
  • customer credential возвращает customer-bound current price/stock;
  • raw objects присутствуют, matcher/canonical assignment завершены;
  • подтверждено хотя бы одно корректное multi-supplier canonical overlap и проверен negative case;
  • fieldSet=max=0, платные данные не запрашивались;
  • scheduler возвращён в штатный режим elevel:1h:true.

Post-deploy health и coverage

После перезапуска supplier-sync тикер coverage стартует сразу и пересчитывает самый протухший источник. prod-health-check может начаться раньше окончания этого тяжёлого прохода; в CI он ждёт максимум 10 минут только обновления уже протухшего снимка (CHAR_COVERAGE_BOOTSTRAP_WAIT_SECONDS=600). По окончании окна проверка остаётся строгой: старый снимок — это ошибка, а не warning.