Runbook: matching & analogs search (facts projection + pgvector)

Сводный operations-документ по двум проекциям: offer_characteristic_facts (latest snapshot для matcher candidate_reader) и pgvector-MVP (canonical_products.name_embedding для /v1/canonical/{id}/analogs). Здесь же — slow-log мониторинг и индексные дороги, проложенные в рамках раунда оптимизаций 2026-05-12.

Severity (default): P2 Owner: backend / matching Связанные алерты:

  • facts_projection_lag_seconds
  • factsproj_events_failed_total
  • embedding_dirty_queue_depth
  • pg_stat_statements top-N (см. ниже)

1. Архитектурная карта

┌────────────────────────────┐
│ ingestion (supplier-sync,  │
│ details_offer_enumerator)  │
└────────────┬───────────────┘
             │ INSERT offer_characteristic_raw (mapping_id may be NULL)
             ▼
┌────────────────────────────┐
│ offer_characteristic_raw   │  ← append-only history, source of truth
└────────────┬───────────────┘
             │ emit snapshot_replaced.v1 per (offer, supplier) [tx]
             ▼
┌────────────────────────────┐
│ outbox_events              │  matching.char_facts.v1
└────────────┬───────────────┘
             │ pull cursor-based (DELETE+RETURNING SKIP LOCKED не для outbox)
             ▼
┌────────────────────────────┐
│ facts-projector            │  cmd/facts-projector, port 9096
│ tick 5s, tight-loop ≤50    │  fact_stats refresh every 10m
└────────────┬───────────────┘
             │ ReplaceSnapshot (DELETE stale + UPSERT current,
             │  observed_at-monotonic)
             ▼
┌────────────────────────────┐
│ offer_characteristic_facts │  ← latest snapshot, matcher reads from here
│ + char_fact_stats          │  ← rarity guard MV, refreshed by projector
└────────────────────────────┘

┌────────────────────────────┐
│ canonical_products         │  +name_embedding +name_tsvector
└────────────┬───────────────┘
             │ triggers on (name UPDATE) and canonical_assignments
             ▼
┌────────────────────────────┐
│ canonical_embedding_dirty  │  ← work queue, attempt_count < 5
└────────────┬───────────────┘
             │ ClaimDirtyBatch (DELETE … RETURNING SKIP LOCKED)
             ▼
┌────────────────────────────┐
│ embedding-worker           │  cmd/embedding-worker, port 9097
│ tick 30s                   │  noop when LLM_MODEL_EMBEDDING=""
└────────────┬───────────────┘
             │ EmbeddingService.Embed → SaveEmbedding (vector + tsvector)
             ▼
┌────────────────────────────┐
│ GET /v1/canonical/{id}/    │  composite ranking:
│      analogs               │  sem * 0.6 + txt * 0.2 + struct * 0.2
└────────────────────────────┘

1.1 Действующий путь подбора аналогов

Current implementation. Это PostgreSQL serving-путь для GET /v1/canonical/{id}/analogs. Он не является описанным выше target-поиском на Elasticsearch и сохраняется как фактический контракт качества до его возможной замены.

  1. Ранкер получает canonical-якорь, его нормализованные характеристики и категорию.
  2. Строгая область кандидатов — категория якоря и члены её category_equivalence-группы. Она не включает произвольные соседние или supplier-native категории.
  3. Для характеристики строятся действующие identity-оси:
    • подтверждённая запись значимости участвует только если прошла CanTightenCandidateSet;
    • к ней добавляется seed из charsemantics: rated_current, группа полюсов и tripping_characteristic/trip_curve;
    • ось без значения у якоря не действует.
  4. Строгий пул требует совпадения всех действующих identity-осей. Если он пуст, ранкер пробует relaxed-путь: он сохраняет те же identity-условия, но может расширить область категориями, содержащими назначения с broad_group_fallback. Это временно допускает товары из неразобранного мешка, но не из чужой разобранной категории.
  5. На кандидаты накладываются category_analog_rules (hard_divergence, monotone_ge, candidate_only_forbidden и другие), затем они упорядочиваются по совпадению и весам значимости. Derived-значимость меняет вес, но не создаёт жёсткий фильтр.

Пример: для модульного автомата 3P C6A действуют оси 6A, 3 полюса и кривая C. Модульные автоматы с теми же значениями остаются кандидатами, дифавтоматы отсекаются candidate_only_forbidden, а товары из уже классифицированных категорий аксессуаров не попадают в область поиска.

1.2 analog-quality-sweep: проверка всего каталога

backend/cmd/analog-quality-sweep — read-only команда, а не второй ранкер. Она вызывает тот же AnalogsService, с теми же правилами, значимостью, областью кандидатов и бюджетом публичного endpoint. HTTP намеренно не используется: он добавил бы нагрузку api-server и скрыл бы фазовые тайминги, но бюджет ответа остаётся тем же.

Для каждой eligible категории команда детерминированно выбирает до трёх якорей и пишет построчный JSONL-артефакт. Соединение открывается с default_transaction_read_only=on; попытка записи должна завершиться ошибкой PostgreSQL. Checkpoint и stop-file позволяют безопасно останавливать и продолжать длинный обход.

Каждая строка сохраняет действующие identity-оси, размеры строгого и relaxed-пулов, фазовые времена, результат и диагноз. Важно не смешивать результат с диагнозом:

ПолеПримерСмысл
statusperformance_failureЧто произошло с запросом: успешно ли он выполнился в публичном бюджете.
empty_reasonno_effective_identity_keysПочему запрос оказался пустым или дорогим. Не заменяет status.
pool_sizes_unknowntrueДиагностика пула не уложилась в свой короткий срок; это не утверждение, что пул пуст.

Статусы: passed, no_comparable_products, taxonomy_or_data_gap, rule_gap, performance_failure, infra_failure, diagnostics_incomplete. Пустая выдача не равна ошибке: sweep отделяет отсутствие сопоставимых товаров от пробела таксономии, правила или производительности.

Для сравнимого полного прогона нужно зафиксировать изменяемые входы: приостановить category reclassification и пересчёт значимости, сохранить метаданные до и после run. Иначе ранние и поздние якоря увидят разные категории и разные права broad fallback.

1.3 mapping-collision-audit: где одна ось склеила разные признаки поставщика

backend/cmd/mapping-collision-audit — read-only аудит сопоставлений характеристик поставщика. Он ищет рискованную ситуацию: у одного поставщика два разных исходных названия сведены в одну каноническую ось, причём одно из сопоставлений ещё ожидает ревью, а другое уже подтверждено. Такой конфликт может дать технически неверное значение на карточке и, если ось участвует в контракте, неверно допустить кандидата в подбор аналогов.

Команда открывает PostgreSQL с default_transaction_read_only=on; любые записи запрещены самой базой. Результат — JSONL-артефакт с одной строкой на подозрительное сопоставление: поставщик, исходный код, целевая ось, число затронутых канонов, число расходящихся значений, число текущих назначений, следующих сомнительному значению, и примеры. timed_out=true означает, что ущерб не измерен в заданный бюджет, а не что ущерба нет.

Основные параметры прогона:

ПараметрНазначение
-gating-onlyоставить только оси, которые участвуют в контракте аналогов; начинать разбор с них
-limitограничить число сопоставлений для пробного прогона
-sample-canonicalsзадать верхнюю границу канонов на одну строку; при усечении артефакт помечается sampled=true
-statement-timeoutограничить один запрос; дефолт 90 секунд
-max-io-waitersуступать ресурсы, когда в PostgreSQL уже много активных сессий, ожидающих I/O
-stop-fileмягко остановить длинный прогон после уже записанных строк
-mapping-idдобрать одну ранее неуспевшую строку с отдельным бюджетом

Аудит не исправляет данные и не меняет правила аналогов. После него владелец каталога разбирает верхние строки по ущербу: разделяет оси или отклоняет сомнительное сопоставление, затем запускает обычную нормализацию и повторяет аудит. До решения конфликтная ось не должна трактоваться как доказательство инженерной эквивалентности.


2. Feature flags + env vars (всё через config.Config)

EnvDefaultЧто
MATCHER_USE_FACTS_PROJECTIONfalsematcher candidate_reader: legacy ocr scan ↔ facts path
MATCHER_FACTS_RARITY_THRESHOLD300 на prod временноотсечка doc_count для generic характеристик; до RAM 64GB снижено с 1000, чтобы ограничить matcher Tier-2 fanout
LLM_MODEL_EMBEDDING""пусто = embedding-worker no-op; text-embedding-3-small — реальные embeddings
WORKER_PAUSED_EMBEDDING_WORKERtrue на prodkill-switch ticker’а embedding-worker; держать true, пока gateway не отвечает на POST /v1/embeddings
LLM_BASE_URL / LLM_API_KEY(compose default)shared CLIProxy для chat + embeddings
ANALOGS_SEM_WEIGHT0.6вес косинус-similarity в /analogs
ANALOGS_TEXT_WEIGHT0.2вес tsvector ts_rank
ANALOGS_STRUCT_WEIGHT0.2вес structural overlap через offer_characteristic_facts
ANALOGS_ADMIN_HTTP_TIMEOUT45s на prod временнолимит только explainable admin endpoint; оставляет запас до 60s edge/statement timeout
CATALOG_RECONCILER_BATCH_SIZE100hot reconciler PG/ES hash batch; снижать при ReadCanonicals statement timeout
CATALOG_RECONCILER_COLD_CRON0 3 * * * UTCежедневный full audit текущего projection contract; invalid cron блокирует wiring
CATALOG_RECONCILER_FULL_MAX_PASSES3максимум циклов enqueue → queue fence → verify до failed
CATALOG_RECONCILER_FULL_FENCE_TIMEOUT12hожидание projector-событий с seq не выше fence данного прохода; бюджет покрывает initial manufacturer backfill при безопасном projector batch
CATALOG_RECONCILER_FULL_RUN_TIMEOUT24hобщий бюджет одного startup/scheduled/manual full-run
CATALOG_PROJECTOR_BATCH_SIZE200 на prod временноprojector queue batch; держать 200 до RAM 64GB и проверки p95 ReadCanonicals после LATERAL-фикса; 500 ещё ловил редкие 60s timeout под общей IO-нагрузкой
CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS7 на prod временноокно свежести price/stock rollup в ES; держать 7 до расширения DB-VPS RAM до 64GB, затем пересмотреть возврат к 30

Прод-rollout порядок:

  1. Поднять facts-projector (compose service уже есть, default false для matcher).
  2. Подождать пока facts_projection_lag_seconds < 30s стабильно.
  3. Флипнуть MATCHER_USE_FACTS_PROJECTION=true, рестарт matcher-worker.
  4. Через сутки — pg_slow_queries.sh top: убедиться что legacy candidate_reader query ушёл из топа.
  5. Проверить gateway: POST /v1/embeddings должен возвращать не 404. Только после этого выставить WORKER_PAUSED_EMBEDDING_WORKER=false и поднять embedding-worker с LLM_MODEL_EMBEDDING=text-embedding-3-small.
  6. После полного backfill (или асимптотики embedding_dirty_queue_depth → 0) — /v1/canonical/{id}/analogs готов отдавать 200.

Временное окно catalog-projector price/stock

С 2026-07-09 production CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS=7 и CATALOG_PROJECTOR_BATCH_SIZE=200. Это временный DB-VPS mitigation до расширения RAM до 64GB и проверки p95 ReadCanonicals после LATERAL-фикса. На текущем объёме offer_observations значение 30 затрагивает июньскую партицию offer_observations_2026_06 (~76GB) и ReadCanonicals регулярно упирался в POSTGRES_STATEMENT_TIMEOUT=60s; проверка 2026-07-09 также показала, что 7d при batch 500 всё ещё даёт редкие timeout под общей IO-нагрузкой.

Окно влияет только на ES canonical rollup: has_price_any, has_stock_any, suppliers_with_price, suppliers_with_stock.

Rollout нормализованного поиска по артикулу

identifier_index — добавочное keyword-поле ES-проекции канонического товара. Оно содержит нормализованные viewable_id, артикул производителя и supplier SKU: разделители игнорируются, русские и латинские х/x считаются одинаковыми. Оно нужно для поиска внутреннего артикула, его фрагментов и значений с ./,.

Порядок выкладки:

  1. Выложить версию catalog-projector/api-server с mapping. На старте EnsureIndex добавит поле через PUT _mapping; alias или новая версия индекса не нужны.
  2. Подтвердить, что mapping текущего canonical-индекса содержит identifier_index типа keyword.
  3. Поднять версию projection contract в коде reconciler-а. Standalone catalog-reconciler увидит отсутствие успешного запуска для нового contract и начнёт full-run при старте; далее тот же контур запускается по CATALOG_RECONCILER_COLD_CRON в UTC. Ручной POST /api/v1/admin/catalog/projector/reindex?mode=full остаётся аварийным повтором и использует тот же global advisory lock.
  4. Дождаться строки catalog_reconciler_full_runs.status='succeeded' с verified_drift=0. Full-run сначала ставит drift в очередь, ждёт все непроцессированные строки до сохранённого queue_fence, затем выполняет verify-only cold pass. Новые события с seq > queue_fence не удерживают проход. Нулевая очередь без свежего zero-drift run не является доказательством исторической сходимости.
  5. Проверить в «Канонических товарах» известную позицию: полный внутренний артикул, его часть, 3120001142 и 3-12-0001142, 3х2,5 и 3x2.5, а также односимвольную опечатку в названии. Опечатка допустима только для названия/производителя, не для идентификаторов. PostgreSQL данные, supplier_offers и история offer_observations не удаляются. После увеличения RAM проверить EXPLAIN/pg_stat_statements для ReadCanonicals и вернуть 30/2000, если p95 укладывается в timeout.

Операционная проверка полного прохода:

SELECT run_id, projection_contract, trigger, status, started_at, finished_at,
       passes, scanned, initial_drift, enqueued, verified_drift, queue_fence,
       error
  FROM catalog_reconciler_full_runs
 ORDER BY started_at DESC
 LIMIT 10;

running старше CATALOG_RECONCILER_FULL_RUN_TIMEOUT и любой последний failed требуют разбора логов reconciler/projector. Не менять /srv/tracium/infra/deploy/.env вручную: расписание и бюджеты исправляются в deploy/prod-env-template.env и compose mapping, затем выкладываются обычным deploy.

Для contract catalog-canonical-v2-official-manufacturer-identity full audit не перечитывает тяжёлую коммерческую проекцию каждого canonical. Он агрегирует в ES уникальные (manufacturer_id, manufacturer_name.raw), сверяет этот компактный набор с manufacturers, redirect-ами и однозначными global aliases, затем scroll-ит и ставит в очередь только доказанный drift. Hot-loop продолжает использовать projection hash для недавнего окна. Если новый projection contract затрагивает другие поля, перед bump нужно расширить contract auditor и его acceptance tests; одного изменения строки ProjectionContract недостаточно.

Production dry-run 2026-08-26 нашёл 1 188 488 canonical documents без manufacturer_id, чьи имена однозначно разрешаются через 1 782 global aliases/official names. Это реальный initial drift, а не повторная постановка корректных документов. При безопасном CATALOG_PROJECTOR_BATCH_SIZE=200 его дренирование занимает около 4–5 часов, поэтому fence budget меньше 12 часов не использовать. После initial convergence ежедневные проходы должны ставить только новый drift.

При рестарте владелец global advisory lock переводит оставшиеся от прежнего процесса running rows в failed/superseded, а shutdown ждёт сохранения cancelled-state. Поэтому свежий orphan running после deploy — дефект, а не нормальное ожидание 24-часового stale timeout.

Временный timeout admin-аналогов

До расширения DB-VPS ANALOGS_ADMIN_HTTP_TIMEOUT=45s. Он действует только на GET /api/v1/admin/catalog/canonical-products/{id}/analogs: public endpoint сохраняет свой лимит 5s. Внешний edge для этого маршрута не подменяет typed JSON 504 своей HTML-страницей.

Каждый завершённый запрос пишет structured event catalog.analogs.admin.request с canonical_id, outcome, duration и timeout. Для диагностики outcome=timeout|error сопоставлять с pg_stat_statements, pg_slow_queries.sh active|waiting и состоянием catalog-projector. Не повышать лимит выше 45s как замену устранению DB IO pressure. После 64GB собрать 24h baseline и пересмотреть значение по p95/p99.

Post-64GB DB-VPS checklist

После расширения DB-VPS RAM до 64GB все изменения делать только через deploy:

  1. Поднять в deploy/prod-env-template.env Postgres memory knobs: POSTGRES_SHARED_BUFFERS, POSTGRES_EFFECTIVE_CACHE_SIZE, POSTGRES_SHM_SIZE; POSTGRES_WORK_MEM не повышать глобально без отдельного расчёта по concurrency.
  2. Задеплоить DB-VPS и собрать новый baseline pg_stat_statements минимум за 24 часа.
  3. Проверить ReadCanonicals p95/p99 на текущих batch=200, window=7.
  4. Поэтапно вернуть CATALOG_PROJECTOR_BATCH_SIZE=500, затем 2000; после каждого шага проверить p95/p99 и отсутствие SQLSTATE 57014.
  5. Только после успешного batch-теста вернуть CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS=30 и повторить p95/p99 проверку.
  6. Перепроверить matcher slowlogs с MATCHER_FACTS_RARITY_THRESHOLD=300. Если facts overlap больше не доминирует в pg_stat_statements, опционально протестировать прежнее значение 1000 на recall и сравнить query p95/p99 перед тем как оставить его.
  7. Обновить этот runbook фактическими замерами и оставить итоговое значение env в deploy template.

3. Slow-query monitoring

CLI: ./scripts/pg_slow_queries.sh

./scripts/pg_slow_queries.sh top 20         # cumulative total_exec_time
./scripts/pg_slow_queries.sh slowest 10     # mean_exec_time
./scripts/pg_slow_queries.sh io 10          # shared_blks_read (cold-cache cost)
./scripts/pg_slow_queries.sh active         # currently running
./scripts/pg_slow_queries.sh waiting        # lock-waiting + blocker
./scripts/pg_slow_queries.sh indexes        # large-seq-scan tables + unused indexes
./scripts/pg_slow_queries.sh reset          # zero counters

Postgres-side (compose command:): pg_stat_statements, track_io_timing=on, log_min_duration_statement=500, work_mem=64MB, hash_mem_multiplier=4, random_page_cost=1.1.


4. Накатанные индексы (раунд оптимизаций 2026-05-12)

MigrationЧтоЭффект
0097pg_stat_statements + canonical_products(lower(name)) + supplier_offers WHERE category_id IS NULLquality.Refresher ↓
0099offer_observations(offer_id) WHERE prices/stock_current <> '{}'dataCollection 419s → 46s
0101offer_characteristic_facts + 3 индексаlatest-snapshot for matcher
0102char_facts_projector_cursorprojector state
0103outbox_events(topic, id)fetch range scan
0104char_fact_stats materialized viewrarity guard
0105offer_observations(offer_id) узкий single-colno_obs Hash Anti Join 25.7s → 2.4s
0106assignment_dispute_queue partial pending/in_progressclaim-path
0107canonical_assignments(canonical_id, decided_at DESC)LATERAL MAX 14.3s → 2.6s
0108offer_characteristic_raw(mapping_id) WHERE raw_value ? ‘unit’unit-upgrade probe
0109vector ext + canonical_products embedding columns + ivfflat + gin + dirty queuepgvector MVP
0110triggers canonical_products.name + canonical_assignments → dirty queueподдержка dirty
0111match_decisions partial (status=conflict / status=active)ListCandidates path
0234canonical_analog_assignment_lookup + triggersbounded no-embedding fallback для analogs
0235canonical_analog_assignment_lookup(canonical_id, characteristic_id)fast trigger maintenance for assignment writes
0237offer_characteristic_facts(offer_id, canonical_key, observed_at DESC) INCLUDE raw hash/mappingpost-rank latest facts для analog rule scoring

Полные результаты per query — см. commit cbcd1a72, 7e4af2a7, b360bb3b.


5. Симптомы и действия

5.1 «Matcher грузит CPU», pg_slow_queries.sh top → candidate_reader 90%+

Если MATCHER_USE_FACTS_PROJECTION=false — это нормальный legacy путь. Флипнуть на true после проверки projector lag.

MATCHER_USE_MPN_NORM_COLUMN=true включает lookup по материализованной колонке supplier_offers.mpn_norm; флипать только после backfill mpn_norm и готового индекса supplier_offers_mpn_norm_idx.

Если true — посмотреть facts_projection_lag_seconds. Лаг > 5 мин: projector отстаёт от outbox.

5.1.1 Backlog matcher не уменьшается при коротких batch

Симптом: число supplier_offers без match_decisions остаётся большим, но matcher-worker пишет loading_candidates с десятками или нулём позиций. Полный точный backlog можно проверить анти-соединением ниже, но это диагностический тяжёлый запрос, а не реализация каждого matcher tick. На 2,4 млн офферов не запускать его по кругу и ограничить statement timeout:

SET LOCAL statement_timeout = '60s';
SELECT count(*)
FROM supplier_offers so
WHERE NOT EXISTS (
  SELECT 1 FROM match_decisions md WHERE md.offer_id = so.id
);

Прежний ListCandidates выполнял этот глобальный anti-join каждую минуту. На проде query набрал 275 вызовов, 8002 секунды выполнения и 25,7 млн физических чтений блоков (~200 ГБ); под общим I/O каждый tick упирался в 60s timeout и отбрасывал уже найденные recheck-кандидаты.

Текущий путь состоит из двух ограниченных полос:

  • recent seed сначала читает не больше 1000 свежих unlocked-офферов, затем отбрасывает имеющие решение;
  • historical sweep каждую UTC-минуту читает один из 1024 непересекающихся UUID PK ranges (hard limit 10 000). Полный круг занимает около 17 часов и не требует таблицы cursor или служебного UPDATE, поэтому переживает рестарты.

Так recent-поток попадает в ближайший tick, а старый undecided-tail не голодает. Продовый EXPLAIN (ANALYZE, BUFFERS) bounded seeds: ~0,35s и 192 прочитанных блока даже во время full reconcile вместо глобального timeout. Tier-3 parser принимает confidence и как JSON-число, и как числовую строку: LLM-провайдеры могут вернуть оба эквивалентных представления, и это не должно выбрасывать целый chunk undecided SKU.

5.1.2 match_decisions растёт по UPDATE/WAL без изменения решений

До исправления DecisionRepo.Upsert безусловно двигал updated_at, потому что он одновременно служил курсором recheck. Продовый baseline: 4,1 млн UPDATE, 0 HOT UPDATE и 26,7 ГБ WAL по основному upsert query. Это отдельная write amplification, даже если canonical assignment ниже уже умеет распознавать no-op.

Теперь расписание не зависит от записи:

  • probable/conflict распределены по UUID ranges на 24h;
  • fallback tier='none' — на 72h (4320 диапазонов, примерно 289 строк/мин при 1,25 млн решений);
  • updated_at < 2001-01-01 остаётся явным forced-rematch marker, обходит расписание и подтверждается ровно один раз;
  • сервис сравнивает новое решение с уже прочитанным и вообще не вызывает persistence при равенстве; repository дополнительно защищён ON CONFLICT ... DO UPDATE ... WHERE changed.

После выкладки проверить, что matcher-worker больше не пишет load_failed, а рост pg_stat_user_tables.n_tup_upd у match_decisions соответствует реальным сменам решения/forced-rematch, не числу обработанных fallback SKU.

5.2 facts-projector lag растёт монотонно

./scripts/pg_slow_queries.sh active   # есть ли blocked queries?
docker logs tracium-facts-projector-1 --tail 100 | grep -E "factsproj:|error"

Проверить outbox-курсор:

SELECT last_processed_id, last_processed_at FROM char_facts_projector_cursor;
SELECT max(id) FROM outbox_events WHERE topic = 'matching.char_facts.v1';

Если курсор не двигается — projector упал на failed event. factsproj_events_failed_total{event_kind} покажет тип.

Mitigation: рестарт docker compose restart facts-projector. Tight-loop из P-O-7 за один tick обработает ≤10k backlog.

С 2026-07-15 facts-projector не должен морозить live replay на время исторического seed. При большом backlog это оставляет свежие характеристики невидимыми для canonical assignment и ES на часы. Правильный порядок старта:

  1. прочитать pre-seed max(outbox_events.id) для matching.char_facts.v1;
  2. запустить live ticker, чтобы свежий outbox продолжал дрениться;
  3. параллельно запустить SeedFromHistory, который строит latest snapshot из offer_characteristic_raw;
  4. при успешном seed сдвинуть char_facts_projector_cursor до pre-seed watermark через JumpCursorTo;

Если seed падает или истекает FACTS_PROJECTOR_SEED_BUDGET, cursor прыгать не должен: live ticker обязан продолжить обычный outbox replay со старой позиции.

Если в /queues у FACTS-PROJECTOR Возраст курсора растет, а Последнее событие # не меняется, сначала проверить cursor и активные запросы:

SELECT last_processed_id, last_processed_at, last_error, blocked_outbox_id
FROM char_facts_projector_cursor;
 
SELECT pid, wait_event_type, wait_event, now() - query_start AS age, left(query, 300)
FROM pg_stat_activity
WHERE query ILIKE '%offer_characteristic_facts%'
   OR query ILIKE '%char_facts_projector_cursor%';

Если сервис застрял на историческом seed, но нужно срочно разгребать outbox без полной выкладки, временно ограничить seed budget и пересоздать только worker:

/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  facts-projector FACTS_PROJECTOR_SEED_BUDGET=1s

Это не включает LLM и не чинит category/value workers; оно только возвращает движение offer_characteristic_facts через outbox replay.

5.3 Matcher matches generic характеристик («Нет», «шт»)

MATCHER_FACTS_RARITY_THRESHOLD управляет компромиссом: чем ниже значение, тем меньше fanout и IO в Tier-2 facts overlap; чем выше — тем больше generic характеристик участвуют в matching evidence. На prod с 2026-07-09 временно стоит 300 вместо прежних 1000, пока DB-VPS не расширен до 64GB и slowlogs не перебазированы.

При положительном пороге matcher обязан использовать один и тот же ключ для статистики редкости и overlap: (canonical_key, raw_value_hash). Нельзя фильтровать редкость по raw_value_hash, а соединять кандидатов по canonical_value_id: одно редкое написание тогда раскрывается в миллионы нормализованных строк и фактически отменяет fanout guard. Режим 0 явно отключает guard и сохраняет cross-supplier overlap по canonical_value_id; он предназначен для ограниченной калибровки, а не для production sweep.

Если качество matching страдает из-за отсечения generic-пар — поднимать порог deploy-managed изменением deploy/prod-env-template.env, затем деплой. После изменения порог применяется к инкрементально поддерживаемой char_fact_stats без отдельного refresh.

Проверить top-generic:

SELECT canonical_key, doc_count FROM char_fact_stats ORDER BY doc_count DESC LIMIT 20;

5.4 /v1/canonical/{id}/analogs → model=facts-text-fallback

Public endpoint не должен ждать полного embedding backfill. Если embedding для anchor canonical ещё не посчитан, сервис использует no-embedding ranker и возвращает обычный JSON с model=facts-text-fallback. Admin/explain endpoint может вернуть 503 Retry-After, если вызывающий слой явно требует embedding status.

Проверить готовность embedding:

SELECT name_embedding IS NOT NULL AS has_emb,
       embedding_generated_at,
       embedding_model
FROM canonical_products WHERE id = $1;
 
SELECT count(*) FROM canonical_embedding_dirty
WHERE canonical_id = $1;

Если canonical_id есть в queue и embedding_dirty_queue_depth падает — просто ждать. Если очередь застряла:

docker logs tracium-embedding-worker-1 --tail 100 | grep -E "embedding:|error"

Возможные причины:

  • LLM_MODEL_EMBEDDING="" — service disabled (intentional).
  • WORKER_PAUSED_EMBEDDING_WORKER=true — ticker остановлен намеренно.
  • LLM gateway возвращает 404 на /v1/embeddings — сначала чинить gateway/провайдера, не держать worker в retry-loop.
  • LLM provider rate-limit / API key invalid — embedding_failed{reason="provider_error"} ↑.

5.5 No-embedding fallback пустой или медленный

Fallback без embeddings не должен сканировать свежие offers по широкой supplier-native категории. С 0234 hot path берёт кандидатов из canonical_analog_assignment_lookup: это read-model по текущим canonical_assignments и canonical_category_assignments, индексированный по (category_id, characteristic_id, value_hash, canonical_id).

Быстрая проверка read-model:

SELECT reltuples::bigint AS approx_rows
FROM pg_class
WHERE oid = 'canonical_analog_assignment_lookup'::regclass;
 
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid = 'canonical_analog_assignment_lookup_idx'::regclass;
 
SELECT tgname
FROM pg_trigger
WHERE tgname LIKE 'canonical_analog_assignment_lookup_%'
ORDER BY tgname;

Ожидание: таблица непустая, индекс indisvalid=true и indisready=true, есть два триггера на canonical_assignments и canonical_category_assignments. Если таблица пустая после миграции, выполнить backfill из миграции 0234 и VACUUM (ANALYZE) canonical_analog_assignment_lookup.

Если EXPLAIN показывает nested loop без index seek по canonical_analog_assignment_lookup_idx, проверить что target canonical имеет canonical_category_assignments и непустые canonical_assignments. Fallback ограничивает вклад каждой характеристики через LATERAL:

  • identity-характеристики (rated_current, poles, trip curve) ищутся шире, LIMIT 3000, потому что значения вроде C и 1P очень частые и LIMIT 1000 может не включить пересечение всех критичных групп;
  • current_type тоже попадает в широкий lookup pool и остаётся весовым структурным сигналом, но не является hard identity gate: в реальных данных AC, AC/DC и voltage-derived значения часто отражают supplier-доступность неполно и иначе вырезают коммерчески пригодные модульные автоматы;
  • остальные характеристики остаются узкими, LIMIT 80, чтобы не возвращаться к полному скану категории.

Candidate проходит дальше только если совпали все hard identity-группы, которые есть у target. voltage_type остается весовым сигналом, но не hard gate: в реальных данных он часто нормализован разными словарными строками и иначе обнуляет валидные автоматы. Для poles hard gate использует poles_total (poles / pole_count / total_number_of_poles), а protected_pole_count и power_pole_count остаются весовыми сигналами: это отсекает 1P+N при target 1P, но не требует полного набора helper-полей у каждого поставщика. Text score считается через to_tsvector('russian', cp.name) на лету, потому что на prod canonical_products.name_tsvector может быть пустым до отдельного embedding/text backfill.

Если canonical-assignment-worker пишет upsert batch failed с statement timeout, а phase build_decisions завершается быстро, проверить maintenance-delete read-model. Row-level trigger на canonical_assignments сначала удаляет stale lookup rows по (canonical_id, characteristic_id), и без индекса 0235 planner вынужден сканировать большой category-first индекс:

EXPLAIN (COSTS, BUFFERS)
DELETE FROM canonical_analog_assignment_lookup
WHERE canonical_id = '<canonical_uuid>'
  AND characteristic_id = '<characteristic_uuid>';
 
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid IN (
  'canonical_analog_assignment_lookup_idx'::regclass,
  'canonical_analog_assignment_lookup_canonical_char_idx'::regclass
);

Ожидание после 0235: canonical_analog_assignment_lookup_canonical_char_idx есть, indisvalid=true, indisready=true, а EXPLAIN использует этот индекс. Если индекс отсутствует или invalid, пересоздать его только concurrent-командой из миграции 0235 и затем ANALYZE canonical_analog_assignment_lookup.

Если direct /v1/canonical/{id}/analogs?limit=5 возвращает JSON 504 примерно за 5s, но сам no-embedding rank SQL в pg_stat_statements укладывается в 1-4s, проверить post-rank этап LatestFacts. После ranking сервис загружает latest facts для anchor + candidate canonical IDs и применяет analog rules (poles, rated_current, breaking_capacity, DC-safety). Этот этап должен идти через маленький target_offers set и индекс 0237:

SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid = 'offer_characteristic_facts_offer_key_observed_idx'::regclass;
 
SELECT calls, mean_exec_time, max_exec_time, left(query, 220)
FROM pg_stat_statements
WHERE query ILIKE '%target_offers AS MATERIALIZED%'
  AND query ILIKE '%offer_characteristic_facts%'
ORDER BY max_exec_time DESC
LIMIT 5;

Ожидание после 0237: индекс валиден, а LatestFacts не добавляет секунды к 5s hard budget analog endpoint даже во время активного supplier ingestion. Индекс намеренно не включает raw_value: крупные JSON/text значения могут превысить btree row limit, поэтому raw_value дочитывается из heap после быстрого offer-scoped lookup.

5.6 /v1/canonical/{id}/analogs → 504 на тяжёлой категории

Если target embedding уже есть, но category subtree большой, primary embedding-rank может упереться в edge timeout. API обязан оставить бюджет на деградацию: после короткого внутреннего таймаута embedding-rank выполняется fallback на facts/text ranking и возвращается обычный JSON-ответ. В таком ответе model выставляется в facts-text-fallback, чтобы прод-диагностика не путала деградацию с полноценным semantic rank.

Если вместо этого клиент всё ещё получает raw 504, проверить активные запросы и pg_stat_statements по category_candidates, category_recent_offers и RankWithEmbedding-SQL. Это означает, что slow path съедает весь внешний request budget до запуска fallback.

Для canonical с несколькими supplier categories anchor выбирается как самая глубокая и узкая supplier-native category: depth DESC, category_offer_counts.cnt ASC, затем latest_seen DESC. Это защищает direct analogs от широких родительских категорий вроде «Низковольтное и промышленное электрооборудование», где один запрос может затронуть сотни тысяч offers и проиграть edge timeout.

Если одновременно /v1/products/resolve, /v1/estimates/proposals и direct analogs деградируют, сначала проверить не hot-path SQL, а background IO:

SELECT now() - query_start AS age, wait_event_type, wait_event, left(query, 220)
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY query_start;

Опасные для foreground latency паттерны: autovacuum: VACUUM public.supplier_offers, title-extract-worker subtree scans по supplier_offers, continuous supplier-sync writes и stock-detail-warmer detail writes, facts-projector tight-loop по offer_characteristic_raw, matcher loading_candidates и canonical-assignment-worker bulk writes. title-extract-worker, stock-detail-warmer, facts-projector, matcher и canonical assignment не участвуют в синхронном чтении уже построенных предложений; supplier-sync обновляет ingestion cache, но API продолжает читать последние cached observations. Для быстрого снятия нагрузки можно временно выставить:

Production admission для supplier-sync должен оставаться 2/1/1/1: SUPPLIER_SYNC_MAX_IN_FLIGHT=2, один catalog, один non-catalog и одна job на supplier_ref. Последний гейт принципиален: Systeme commerce сам проходит /getdata, поэтому его параллельный запуск с Systeme catalog дублирует полный provider/DB scan. Ожидаемо максимум два live-lock, без повторяющегося supplier_ref; прерванные job остаются due и продолжаются после освобождения слота, а не удаляются.

Для Systeme commerce профиль задаёт max_pages_per_tick=50: это примерно 5 тысяч SKU за короткий тик вместо непрерывного прохода на несколько часов. После каждой страницы сохраняется page_offset с Done=false, поэтому следующий тик и перезапуск контейнера продолжают обход с очередной страницы. У catalog этого cap нет: полный discovery остаётся суточным.

Неизменная карточка supplier_offers на суточном catalog-проходе не должна переписываться. Catalog liveness heartbeat сохраняется не чаще одного раза в 6 суток — это оставляет сутки до существующего quality stale-порога 7 суток. В pg_stat_statements устойчивый поток WITH prev ... INSERT INTO supplier_offers с rows ≈ calls на уже заполненном каталоге означает, что no-op guard не сработал или большинство карточек действительно старше 6 суток.

MinIO raw hot-cleaner не должен постоянно обходить небольшой bucket. В production RETENTION_S3_RAW_CLEANER_PRESSURE_BYTES=8589934592; ниже 8 GiB в логах ожидается minio_raw_retention.skip ... reason=below_pressure. При этом семидневный ILM остаётся активен. Если виден minio_raw_retention.start, сначала проверьте usage_bytes: полный mc rm --recursive допустим только при достижении порога, а не как постоянный фон на общем с PostgreSQL NVMe. Внутренний scanner должен видеть MINIO_SCANNER_SPEED=slowest; это не отключает ILM/healing, а снижает их приоритет относительно рабочих I/O.

/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  title-extract-worker WORKER_PAUSED_TITLE_EXTRACT_WORKER=true
 
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  stock-detail-warmer WORKER_PAUSED_STOCK_DETAIL_WARMER=true
 
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  supplier-sync WORKER_PAUSED_SUPPLIER_SYNC=true
 
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  matcher-worker WORKER_PAUSED_MATCHER_WORKER=true
 
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  canonical-assignment-worker WORKER_PAUSED_CANONICAL_ASSIGNMENT_WORKER=true
 
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  facts-projector WORKER_PAUSED_FACTS_PROJECTOR=true

После паузы повторить estimate matrix. Baseline ожидание для 16 строк: /v1/products/resolve < 2s, /v1/estimates/proposals < 5s, без 504 по direct analogs. Если workers нужны обратно, сначала снизить их batch/interval или добавить недостающие индексы, затем включать по одному и повторять matrix. Для facts-projector pause должен останавливать не только регулярные тики, но и startup self-seed. Быстрая проверка после recreate/deploy:

SELECT count(*) FILTER (WHERE lower(query) LIKE '%offer_characteristic_raw%') AS facts_seed,
       count(*) FILTER (WHERE wait_event_type = 'IO') AS io_waits
FROM pg_stat_activity
WHERE state = 'active' AND pid <> pg_backend_pid();

Ожидаемый результат при WORKER_PAUSED_FACTS_PROJECTOR=true: facts_seed=0 и отсутствие factsproj: starting self-seed from raw history в свежих логах.

5.7 /v1/products/resolve для сметы отдаёт свежие, но нерелевантные кандидаты

Для free-text строк вроде «Автоматический выключатель 1P C16 6кА» resolver не должен наполнять пул первыми свежими совпадениями по общим словам автоматический / выключатель. Phase-2 candidate search сначала делает bounded-запросы по специфичным токенам (C16, артикулоподобные модели, электрические значения) вместе с общим текстовым контекстом, и только потом падает в широкий OR fallback. Это сохраняет supplier_offers_name_trgm_idx index path и не возвращает последние обновленные, но неподходящие крупные автоматы или аксессуары.

Для проверки на prod:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM supplier_offers
WHERE canonical_id IS NOT NULL
  AND lower(name) LIKE '%c16%'
  AND (lower(name) LIKE '%автоматический%'
    OR lower(name) LIKE '%выключатель%')
ORDER BY last_seen_at DESC, supplier, supplier_sku
LIMIT 20;

Ожидаемый plan — bitmap через supplier_offers_name_trgm_idx по C16 и top-N sort на малом candidate set. Если plan снова идет через широкий last_seen_at scan по общим словам, проверить stats и наличие trigram index.

Pole matching в scorer трактует однополюсный, 1п и модельные формы вроде 1C16 как 1P, но не считает 1P+N точным совпадением 1P; иначе дифференциальные автоматы могут вытеснить обычные однополюсные кандидаты.

Если у строки несколько разных canonical с одинаковой максимальной confidence после scoring (например, ВА47-60M 4P 63A без кривой B/C/D), resolver должен вернуть ambiguous/multiple_top_candidates, а estimate line — needs_review. Нельзя молча выбирать первый вариант: это дает ложный resolved и может подставить цену/остаток для неверной модификации.

/v1/products/resolve обрабатывает строки сметы параллельно, поэтому диагностические источники и тестовые doubles для resolve/proposals не должны писать в общий state без синхронизации. Иначе CI -race может падать флейково, а прод-диагностика будет маскировать реальные проблемы качества аналогов шумом от тестовой инфраструктуры.

5.8 embedding_dirty_queue_depth растёт неконтролируемо

Триггеры наполняют быстрее worker’а. Причины:

  • LLM_MODEL_EMBEDDING="" — worker no-op’ит.
  • LLM rate-limit — embedding_failed{reason="provider_error"} ↑.
  • Партия canonical INSERT/UPDATE из ingestion-tick’а.

Mitigation: повысить MaxBatch в embedding/domain/types.go (default 50). Не повышать выше 100 — LLM-provider может отбрасывать.

5.10 В выдаче аналогов только товары того же бренда

Симптом: у якоря есть кандидаты, но все — того же производителя; замен другой марки нет вовсе.

Причина: ось бренда получила право отсекать кандидатов. Аналог по определению товар ДРУГОГО производителя, поэтому такое право у брендовых осей отнято (app.IsCrossBrandIdentityKey: manufacturer, manufacturer_name, manufacturer_article, brand, trade_mark, series, mpn). Два канала:

  1. Правило в category_analog_rules любого вида, кроме decorative. Авторинг такое предлагать больше не может — ParseResponse отбрасывает предложение до записи, независимо от уверенности. Прод 2026-08-11: погашен 661 накопленный правило (все llm и carried), бэкап — analog_rules_brand_axis_backup_20260811; 141 pending-предложение отклонено с reviewer = 'system:cross-brand-guard'.
  2. Роль identity в category_characteristic_significance: попадала в strict-фильтр отбора и отсекала кандидатов до ранжирования (было у 312 категорий по manufacturer и у 53 по series).

Диагностика:

SELECT canonical_key, kind, source, count(*)
  FROM category_analog_rules
 WHERE canonical_key IN ('manufacturer','brand','series','trade_mark','mpn')
   AND kind <> 'decorative' AND disabled = false
 GROUP BY 1,2,3;

Непустой результат = правила проехали мимо валидатора; гасить через disabled = true, предварительно сняв бэкап.

Строки значимости при этом НЕ правим: роль identity на производителе нужна product family — линейка всегда принадлежит одному бренду. Фильтр стоит в путях подбора, а не в данных.

Граница, которую нельзя размывать: запрет касается только подбора аналогов. Идентичность каноника считается по производителю и артикулу (ADR-0070) в matcher, который значимость не читает. Одинаковые по характеристикам товары разных брендов — разные товары и в один каноник не сливаются.

5.11 Категория отдаёт пустую выдачу, хотя правила и товары есть

Симптом: подбор отвечает быстро, кандидаты в каталоге заведомо существуют, но results пуст.

Сначала отличите три разных случая — они лечатся по-разному:

признак в артефакте sweepчто этолечение
rejected_by_rules велик, elapsed_ms малпул набран вслепую, контракт режетперенести вид сравнения в значимость
path=aborted, elapsed_ms ≈ бюджетstrict не укладываетсяудешевить strict, не расширять контракт
effective_identity_keys пуст, path=fallbackструктурного пути нет вовсезавести оси

Связка, которую легко упустить. CanTightenCandidateSet() даёт оси право сужать пул по compare_kind строки значимости; роль identity требуется только там, где вид сравнения не задан. Но compare_kind не заполняется из category_analog_rules автоматически — правило hard_divergence может существовать, а отбор о нём не знает. Тогда пул набирается текстовым путём, и правило честно режет заведомо негодных кандидатов.

Проверка расхождения:

SELECT r.canonical_key, r.kind, s.role, coalesce(s.compare_kind, '(NULL)')
  FROM category_analog_rules r
  JOIN categories cat ON cat.id = r.category_id
  JOIN characteristics c ON lower(c.code) = r.canonical_key
  JOIN category_characteristic_significance s
    ON s.category_id = r.category_id AND s.characteristic_id = c.id
 WHERE cat.slug = '<slug>' AND r.disabled = false AND r.kind = 'hard_divergence';

compare_kind = (NULL) при живом правиле — то самое расхождение. Переносят его миграции 0359 (шесть категорий) и 0360 (весь каталог); у обеих точечный Down по маркеру в evidence.

Важно: перенос включает strict-путь, а он дороже. Прод 2026-08-12: на шести целевых категориях 9/18 непустых → 11/18 и обрывов 3 → 1, но на независимой выборке из 12 категорий 27/36 → 26/36, обрывов 0 → 1, p95 215 → 540 мс. Прежде чем расширять контракт, мерьте — иначе меняете пустоту на обрыв.

5.12 Проверить правку подбора без выкладки

analog-quality-sweep -categories <id|название через запятую> идёт тем же кодом, что полный обход, и запускается локально против прода по read-only DSN. Цикл проверки гипотезы — минуты.

ssh -f -N -L 15432:192.168.1.85:5432 tracium-db
POSTGRES_DSN='postgres://tracium:<pw>@127.0.0.1:15432/tracium?sslmode=disable&options=-c%20default_transaction_read_only%3Don' \
  ./analog-quality-sweep -categories "<uuid>,<uuid>" -limit 36 -analogs 10 -out m.jsonl

Снимайте замер ДО правки и сравнивайте по трём числам: непустых из общего, обрывов (path=aborted), p95 elapsed_ms. Бинарь пересобирайте после каждой правки — иначе меряете старый код.

5.9 EXPLAIN на новый запрос показывает Seq Scan по большой таблице

Проверить ANALYZE:

SELECT relname, last_analyze, last_autoanalyze, n_live_tup
FROM pg_stat_user_tables
WHERE relname = '<table>';

Если last_analyze пустой / устаревший — ANALYZE <table>.

Если ANALYZE свежий, но planner всё равно seq-scan’ит — посмотреть pg_stats.correlation. Низкая correlation → bitmap-scan норма.

Если pg_stat_statements показывает реалистично-hot query (>1% общего времени) — добавить индекс. Pattern для новой миграции:

-- +goose NO TRANSACTION
-- +goose Up
SET lock_timeout = '30s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_name ON tbl (...);
 
-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS idx_name;

6. Backfill процедуры

offer_characteristic_facts

cmd/facts-backfill:

# Dry-run — посчитать rows to insert без записи
docker compose exec api-server /usr/local/bin/facts-backfill --dry-run
 
# Реальный прогон
docker compose exec api-server /usr/local/bin/facts-backfill --statement-timeout=1h

После — VACUUM ANALYZE offer_characteristic_facts обязательно.

facts-projector self-seed на старте — backfill идемпотентен, безопасно прогонять повторно. На prod не запускать self-seed вместе с foreground incident mitigation: WORKER_PAUSED_FACTS_PROJECTOR=true должен оставить service поднятым, но без чтения offer_characteristic_raw до ручного снятия pause.

Для prod tuning без полного CI/CD можно поменять runtime env и пересоздать только facts-projector:

/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
  facts-projector FACTS_PROJECTOR_SEED_BUDGET=4h

Скрипт редактирует /srv/tracium/infra/deploy/.env, делает backup и запускает docker compose up -d --no-build --no-deps --force-recreate facts-projector. Он принимает только не-секретные worker/runtime keys и печатает имена ключей без значений. Для history seed важные параметры: FACTS_PROJECTOR_BATCH_SIZE, FACTS_PROJECTOR_MAX_TIGHT_LOOP_ITERATIONS, FACTS_PROJECTOR_SEED_BUDGET.

canonical embeddings

Backfill идёт через триггеры: при первом запуске worker’а с включённым LLM_MODEL_EMBEDDING он начнёт обрабатывать всё, что насобирали триггеры. Принудительно поставить все canonicals в очередь:

На prod embedding-worker дополнительно закрыт runtime-флагом WORKER_PAUSED_EMBEDDING_WORKER=true, потому что текущий gateway может обслуживать chat completions, но должен быть явно проверен на POST /v1/embeddings перед включением backfill. Если в логах появляется 404 Not Found по embeddings, сразу вернуть WORKER_PAUSED_EMBEDDING_WORKER=true или остановить service и не тратить LLM-квоту на повторные неуспешные тики.

INSERT INTO canonical_embedding_dirty (canonical_id)
SELECT id FROM canonical_products
ON CONFLICT (canonical_id) DO NOTHING;

После полной обработки (embedding_dirty_queue_depth = 0):

  • VACUUM ANALYZE canonical_products
  • Опционально REINDEX INDEX canonical_products_name_emb_idx с tuned lists (rule of thumb: sqrt(N)).

7. Откат

Откатить matcher facts → legacy

# .env
MATCHER_USE_FACTS_PROJECTION=false
docker compose restart matcher-worker

Откат немедленный, legacy candidate_reader идёт прямо в offer_characteristic_raw. facts остаётся, projector продолжает работать впустую — безопасно.

Полный rollback facts-projection

docker compose stop facts-projector
goose -dir backend/migrations -tags integration down  # до 0100

offer_characteristic_facts + char_facts_projector_cursor + char_fact_stats дропнутся. Не критично — matcher на legacy путях.

Полный rollback pgvector / analogs

docker compose stop embedding-worker
LLM_MODEL_EMBEDDING=""
goose -dir backend/migrations down  # до 0108

/v1/canonical/{id}/analogs начнёт возвращать 500 (колонка name_embedding нет) — endpoint снять до полного rollback.


8. Метрики observability (OTel → Prometheus)

МетрикаТипЧто показывает
facts_projection_lag_secondsgaugenow() - max(facts.updated_at)
factsproj_events_processed_total{event_kind}counterapplied events
factsproj_events_failed_total{event_kind, reason}counterfailed events
embedding_dirty_queue_depthgaugepending canonicals
embedding_batch_claimed_totalcounterrows claimed from queue
embedding_skipped_unchanged_totalcounterhash-skip (idempotent re-run)
embedding_processed_total{model}counterpersisted vectors
embedding_failed_total{reason}counterfailures (disabled / provider_error / save_error)

Prometheus alerts (черновик):

- alert: FactsProjectorLagHigh
  expr: facts_projection_lag_seconds > 300
  for: 5m
  labels:
    severity: warning
 
- alert: EmbeddingQueueGrowing
  expr: deriv(embedding_dirty_queue_depth[10m]) > 100
  for: 30m
  labels:
    severity: warning

Связано

  • ADR-0054 pgvector для поиска аналогов
  • docs/plans/2026-05-12-17-00-pgvector-analogs-mvp.md (parent spec)
  • docs/plans/2026-05-12-17-25-offer-characteristic-facts.md (pre-step subspec)
  • Memory: tracium_facts_projection_runbook (создан этим runbook’ом)
  • Slow-log script: scripts/pg_slow_queries.sh
  • Migrations 0097, 0099–0111
  • Commits: cbcd1a72, 899237a8, 7e4af2a7, 5ba723aa, 1d3e6649, b360bb3b