DB-VPS: насыщение диска — диагностика и рычаги

Раннбук по итогам разбора 2026-08-01/02, когда почти всё на проде — выкладки, присвоения характеристик, вакуумы — упиралось в одну причину.

Как понять, что дело в диске, а не в коде

ssh tracium-db "vmstat 3 2 | tail -1"          # смотреть колонку wa
ssh tracium-db "iostat -x nvme0n1 1 2 | tail -3"
ssh tracium-db "grep -w Dirty /proc/meminfo"
ssh tracium-db "ps -eo state,cmd | awk '\$1==\"D\"' | head"

Признаки насыщения, замеренные в инциденте: iowait 89% при user 7%, 43 процесса заблокированы, f_await 57 мс, глубина очереди 30, грязных страниц до 5.8 ГБ. Контейнеры воркеров при этом показывали около 0% CPU — они просто ждали базу.

Ключевая проверка для докера: docker ps, exec и inspect продолжают работать при насыщенном диске, а вот создание контейнера — нет. Проверять надо именно его:

ssh tracium-db "timeout 60 docker run --rm --network none <образ> /bin/sh -c 'exit 0'"

Если это висит, а docker ps отвечает — виноват не docker и не compose, а ввод-вывод.

Потолок железа

Postgres лежит на nvme0n1p4 — Micron 2210 MTFDHBA512QFD, 477 ГБ, потребительский QLC. Не enterprise. При заполнении под 80% и постоянной случайной записи деградирует резко. Наблюдаемая задержка чтения 8К под нагрузкой — около 30 мс, для NVMe это в десятки раз хуже нормы.

RAM 61 ГБ, shared_buffers 12 ГБ, 12 ядер. При базе 253 ГБ кэшировалось ~22% — то есть почти любой скан шёл на диск. Сокращение базы даёт больше, чем оптимизация запросов.

Почему вакуумы идут часами

Фаза vacuuming indexes читает все индексы таблицы целиком. Соотношение на 2026-08-02: 117 ГБ индексов против 112 ГБ данных.

таблицаданныеиндексы
canonical_products0.6 ГБ4.4 ГБ (7.4×)
offer_characteristic_facts4.2 ГБ20 ГБ (4.8×)
offer_characteristic_raw4.1 ГБ14 ГБ (3.4×)
canonical_analog_assignment_lookup13 ГБ29 ГБ (таблица производная)
supplier_offers6.9 ГБ10 ГБ (29 индексов)

Настройки вакуума уже агрессивные и запаса не дают: autovacuum_vacuum_cost_limit 2000 при дефолте 200, задержка 2 мс, maintenance_work_mem 2 ГБ (хватает на один проход по индексам).

HOT-обновлений ноль

supplier_offers: 10 806 375 обновлений, HOT — 12. offer_characteristic_facts: 3 из 2 782 721.

Две причины, устранять надо обе сразу:

  1. fillfactor = 100 — в странице нет места под HOT.
  2. last_seen_at бампится каждым обновлением при опросе и входит в девять индексов supplier_offers. HOT невозможен, если меняется хоть один индексируемый столбец.

Поэтому один только fillfactor эффекта не даст.

Индексы: чем доказывать избыточность

idx_scan = 0 — не доказательство. Два кандидата из двух проверку не прошли: индекс по выражению держал запасной путь матчера с дефолтом флага false, а overlap_idx реально использовался редким путём тира 2. Плюс счётчики могли обнулиться при падении postgres.

Годное доказательство — структурное: индекс, чьи колонки являются строгим префиксом другого. Проверять в откатываемой транзакции:

BEGIN;
DROP INDEX имя_подозреваемого;
EXPLAIN (costs off) <запрос, который его использовал>;
ROLLBACK;

Если планировщик уходит на составной и остаётся на Index Only Scan — удалять можно. Так сняты два индекса offer_observations (миграция 0278).

Освобождение места: что работает

DELETE не возвращает место операционной системе. Возвращают только DROP партиции и VACUUM FULL. Удалять построчно то, что и так уйдёт с дропом партиции, — вред: мёртвые кортежи, WAL, разбуженный автовакуум.

Дроп партиции требует ACCESS EXCLUSIVE на родителя. При непрерывных вставках окна нет, а DETACH CONCURRENTLY не работает при наличии default-партиции. Рабочий приём — короткий lock_timeout с повторами, чтобы не держать вставки в очереди:

for i in $(seq 1 15); do
  docker exec tracium-postgres-1 psql -U tracium -d tracium -v ON_ERROR_STOP=1 -q \
    -c "SET lock_timeout='8s'" -c "DROP TABLE <партиция>" && break
  sleep 6
done

Проверять результат по коду возврата, а не по тексту вывода: проверка через grep ERROR однажды дала ложное «снята», партиции остались на месте.

pg_dump блокирует дроп. Держит AccessShareLock на копируемой партиции:

SELECT pid, now()-query_start, left(query,60)
  FROM pg_stat_activity WHERE application_name='pg_dump';

Отменять мягко — pg_cancel_backend(pid), не pg_terminate_backend.

Результат разовой уборки 2026-08-02 (дроп двух июльских партиций): диск 313 → 218 ГБ занято, база 253 → 159 ГБ, заполнение 80% → 56%, мгновенно.

Бюджеты запросов

POSTGRES_STATEMENT_TIMEOUT воркеров — 60 секунд, и это бюджет на один запрос, не на тик. Всё, что делается пачками, обязано в него укладываться. CANONICAL_ASSIGNMENT_UPSERT_BATCH = 1000 не укладывался и ронял тик целиком, выбрасывая уже посчитанное; 250 укладывается. Этот же размер задаёт пачки чтения существующих присвоений и загрузки наблюдений.

Admission control фоновых обходов

ingestion_schedules.max_parallel ограничивает только пару (supplier_ref, job_kind). Он не защищает базу от одновременного старта разных поставщиков: после рестарта все незавершённые jobs снова становятся due, и без общего бюджета scheduler мог поднять до восьми тяжёлых обходов.

Источник истины — deploy-managed переменные:

переменнаяprodсмысл
SUPPLIER_SYNC_MAX_IN_FLIGHT3все supplier jobs с живым lease
SUPPLIER_SYNC_MAX_CATALOG_IN_FLIGHT1catalog и legacy monolith
SUPPLIER_SYNC_MAX_NON_CATALOG_IN_FLIGHT2commerce и details

Проверка фактического бюджета:

SELECT count(*) AS total,
       count(*) FILTER (WHERE job_kind IN ('catalog','monolith')) AS catalog,
       count(*) FILTER (WHERE job_kind NOT IN ('catalog','monolith')) AS non_catalog
  FROM ingestion_schedules
 WHERE locked_by IS NOT NULL
   AND locked_until >= now()
   AND (last_heartbeat_at IS NULL
        OR last_heartbeat_at >= now() - interval '5 minutes');

Ожидается total <= 3, catalog <= 1, non_catalog <= 2. Ожидающий capacity job не claim’ится: его next_run_at и checkpoint не меняются. После рестарта boot recovery делает прерванную job снова due, и она продолжает работу, когда освободится её lane.

Bootstrap на deploy передаёт seed с NextRunAt=now(), но для уже включённого schedule с неизменным интервалом repository сохраняет существующий будущий next_run_at. Вперёд подтягиваются только re-enable и реальное изменение интервала. Это принципиально отличается от boot recovery: прерванная job должна стать due, успешно завершённая daily catalog — не должна повторяться из-за выкладки.

Matcher имеет отдельный steady-state бюджет: MATCHER_CONCURRENCY=1, MATCHER_WEAK_RECHECK_COOLDOWN=336h. Для 1.25 млн tier='none' это около 62 страховочных recheck в минуту вместо 289. Изменения identity/category/facts сбрасывают решение триггерами немедленно и не ждут 14 дней. Откат значений делается только в deploy/prod-env-template.env с обычной выкладкой; ручная правка /srv/tracium/infra/deploy/.env переживёт лишь до следующего deploy и источником истины не является.

Профиль ввода-вывода: как посчитать, кто занимает диск

Разбор 2026-08-06. Суммарные счётчики для этого не годятся — они копятся неделями, поэтому профиль строится разницей двух снимков за окно 20–40 минут: scripts/pg_io_profile_report.py.

Три ловушки, каждая из которых уже давала неверный вывод:

  1. Вложенные запросы нельзя складывать с внешними. При pg_stat_statements.track = all буферы триггера и вызванной функции учитываются и у внешнего вызова. Ранжировать физическую стоимость только по toplevel = true, вложенные оставлять как объяснение плана. Проверка: SELECT queryid, bool_or(toplevel), bool_or(NOT toplevel) FROM pg_stat_statements GROUP BY 1.
  2. Среднее нельзя вычитать. mean_exec_time не складывается; за окно оно считается как delta(total_exec_time) / delta(calls), иначе получаются отрицательные значения.
  3. Остаток «всё, что не statements» — не доказательство вины автовакуума. Долю считать по pg_stat_io (PostgreSQL 16+), а не вычитанием.

Замер по pg_stat_io за 756 секунд:

процессблоков чтениядоля полосывремя чтенияна блок
autovacuum worker (vacuum)1 801 95853.3%521.4 с0.29 мс
client backend (normal)1 521 70645.0%3 351.9 с2.20 мс

Вывод, который меняет приоритеты: автовакуум первый по полосе, но клиентские запросы дают 85% времени диска, потому что читают вразброс по одному блоку. Полосу режут правки частоты обновлений и HOT, а ожидание в запросах — индексы и такты фоновых пересчётов.

Привязка запроса к процессу делается через application_name. Пул проставляет его сам именем бинаря (internal/platform/storage/postgres/pool.go); переопределяется через POSTGRES_APPLICATION_NAME или параметр в DSN. До этой правки все бэкенды приходили как (unknown) и связь приходилось искать текстом SQL по репозиторию.

Такт пересчёта снимка качества

supplier_quality_coverage_recompute() читает 427 434 блока (около 3.3 ГБ) за 181 секунду. На прежнем десятиминутном такте это было порядка 475 ГБ чтения в сутки ради одной строки снимка. С миграции 0393 сводка поддерживается инкрементально только на INSERT/DELETE и фактических изменениях поставщика, категории или флагов покрытия; heartbeat-обновления в триггеры не входят. Полный пересчёт остался страховочной сверкой и атомарно допускается не чаще раза в 24 часа.

Единственный потребитель — админский GET /api/v1/admin/quality/overview; в подборе, сопоставлении и ценообразовании снимок не участвует, поэтому продовый такт обновления всего снимка переведён на 6 часов.

рычаггде живётдефолт
QUALITY_OVERVIEW_REFRESH_INTERVALapi-server, App-VPS → scripts/render_production_env.sh6h

Ловушка та же, что была с RETENTION_*: рычаг читает api-server, а он живёт на App-VPS, поэтому значение из deploy/prod-env-template.env (DB-VPS) до него не доходит. Порог короткого замыкания вычисляется как такт минус минута, отдельной переменной нет.

Осиротевшие бэкенды держат горизонт автовакуума

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

Так и вышло 2026-08-17. Три бэкенда supplier-sync висели 3ч15м и 1ч01м, хотя их контейнер был пересоздан в 13:06:31 — то есть клиентов уже не существовало. Старейшая транзакция держалась с 10:51:32, и при живом автовакууме (проход по supplier_offers в 14:02) в таблице стояло 956 630 мёртвых строк на 1 527 344 живых — 38%. После снятия запросов старейшая транзакция стала 7м57с.

Почему запрос выживает: дедлайн контекста на стороне Go отменяет ожидание клиента, но не исполнение на сервере. pgx посылает запрос на отмену отдельным соединением, и если воркера уже нет — послать его некому. PostgreSQL узнаёт о разрыве, только когда сам попробует писать в сокет, а бэкенд, застрявший на чтении с диска, не пишет ничего. На QLC-диске это ожидание измеряется часами.

Постоянный клапан — client_connection_check_interval = 10s (миграция 0373): бэкенд сам проверяет сокет и прерывается, если клиента нет. Взят именно он, а не statement_timeout: потолок времени зарубил бы и законные долгие запросы — обходы ретенции, бэкфиллы, обслуживание, — тогда как убивать надо ровно бесхозное. context=user, перезапуск базы не нужен, откат — RESET.

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

-- кто держит горизонт
SELECT pid, application_name, backend_start, xact_start,
       now() - query_start AS dur, state, wait_event_type, wait_event
  FROM pg_stat_activity
 WHERE state <> 'idle' AND now() - query_start > interval '10 minutes'
 ORDER BY query_start;
 
-- сирота или живой клиент: сверить backend_start с временем старта контейнера
--   docker inspect -f '{{.State.StartedAt}}' tracium-<worker>-1
-- запрос старше своего контейнера = клиента нет, результат доставить некуда

Прочие держатели горизонта, которые надо исключить прежде чем винить запросы: неактивные слоты репликации (pg_replication_slots), pg_prepared_xacts, сессии в idle in transaction.

Снятие сироты — pg_cancel_backend(pid), при отказе pg_terminate_backend(pid). Это не противоречит правилу «автовакуумы не снимать» ниже: там речь о самих процессах вакуума, чья работа полезна, здесь — о бесхозном запросе, чей результат уже некому получить.

Write amplification без внешней нагрузки (2026-08-26)

Симптом: каталог уже наполнен, клиентского трафика почти нет, обновляются в основном цены и остатки, но NVMe держит util 70–80%, iowait 60–75%, рядом постоянно идут autovacuum. Накопительная статистика с 2026-07-30 показала 9.5 ТБ WAL. Это не объём новых бизнес-данных: у supplier_offers около 2 млн живых строк, но 152 млн обновлений и всего 11 тыс. HOT-update; read-model canonical_analog_assignment_lookup получил 645 млн вставок.

Причина — каскад no-op записей. Один коммерческий опрос двигал свежесть карточки каталога, повторное автоназначение переписывало ту же характеристику, после чего триггеры заново ставили projector/analog work, а analog drain удалял и вставлял обратно все строки каноника. Автовакуум в этой картине — полезное следствие churn, а не первичная причина.

Классификация записи:

КлассПолитика
Цена/остаток или поле карточки реально изменилисьзаписать значение и нужный downstream
Бизнес-состояние совпадаетне менять tuple/timestamps, не писать audit event, projector или dirty job
Каталоговая карточка неизменнамаксимум один supplier_offers.last_seen_at heartbeat за 24 часа
Коммерческий опрос неизменного значенияне трогать supplier_offers; редкий offer_observations.observed_at heartbeat остаётся, потому что по нему pricing/stock policy считает staleness
Снимок характеристик совпадаетне переписывать offer_characteristic_raw, не создавать projector event; offer_char_snapshot_state.fetched_at подтверждать не чаще раза в 12 часов
Поставщик повторно вернул пустой блок характеристикотфильтровать свежую строку состояния до DML; конкурентный повтор отсечь ON CONFLICT ... WHERE, тот же предел 12 часов
Matcher recheck завершён без измененияне менять доменное решение и updated_at: очередь движется детерминированным минутным UUID-sweep по всему cooldown; операторский forced-rematch marker подтверждается ровно один раз
Canonical assignment пересчитан без измененияdecided_at, audit, pricing revision и projector не писать; ack уже выполняет удаление canonical из dirty-очереди
Autovacuumне останавливать; после снятия источника churn он дочищает накопленные dead tuples

Для неизменного коммерческого состояния freshness-ack — отдельный переход, а не разрешение писать на каждом supplier tick. Оба массовых writer (ingestion и offers) пропускают обычный повтор до SQL и обновляют observed_at не чаще одного раза в 12 часов: это половина действующего 24-часового price freshness budget. Прежние 30 минут создавали до 11 363 тяжёлых UPDATE и около 320 MB WAL вместе с coverage-trigger за две минуты production-нагрузки.

Restart recovery длительных catalog job при этом не отключается: RecoverBootLocks/RecoverWorkerHostLocks после деплоя снова поднимают незавершённые пакетные операции. Идемпотентность обеспечивается на границе записи: повторный проход читает сохранённый hash/freshness, но для свежего совпадающего снимка не вызывает raw rewrite и state upsert. Отключение recovery замаскировало бы I/O ценой потери прогресса при каждом deploy и поэтому не является допустимым рычагом.

Допустимый рычаг для нескольких одновременно восстановленных catalog-job — deploy-managed SUPPLIER_SYNC_PERSIST_CONCURRENCY=1. Он не снимает claim, не останавливает heartbeat и не теряет саму длительную операцию; ограничивается только число параллельных persist-item внутри каждой джобы. Замер 2026-08-26 при четырёх catalog-job и прежнем 8 дал до 32 persist-worker, 208 вызовов matcher-overlap и 32 076 прочитанных блоков за 30 секунд, пока projector дренировал очередь лишь примерно на 18 SKU/с. Промежуточное 2 при пяти catalog и трёх commerce job всё ещё означало до 16 item writer: NVMe держался на 74–78% util, CPU — на 74–82% iowait. Значение 1 оставляет все восемь job активными, но делает каждую последовательной. Возвращать 2/4/8 можно только по delta-профилю, если matcher-overlap перестал доминировать и скорость projector не падает.

Matcher регулируется отдельно: production MATCHER_CONCURRENCY=2. Замер 2026-08-26 при прежнем 8 показал, что почти все matcher-соединения одновременно выполняли facts-overlap, а один тик на 339–372 слабых/no-candidate решений длился 69–98 секунд. На том же интервале NVMe держался на 66–79% util и CPU на 58–77% iowait; параллельно шли конечные autovacuum offer_observations_2026_08 и часовой quality-recompute. Промежуточное 4 сохраняет минутный UUID-sweep и не касается restart recovery supplier-job, но всё ещё оставляет четыре конкурирующих overlap-readers. До SQL-исправления 2 сохраняло recheck, ограничивая именно чтение matcher. После исправления UUID lookup это минимальная безопасная параллельность для 72-часового sweep. Возвращать 4/8 можно после завершения накопленного projector backlog и повторного delta-профиля.

После выкладки 4 первые пять тиков обработали 324–400 кандидатов за 33–120 секунд: throughput не ухудшился относительно 8 (69–98 секунд на 326–372), потому что снизилась конкуренция чтений. После завершения накопленного autovacuum текущие трёхсекундные срезы показали 59–67% NVMe util и 49–55% iowait вместо пиков 74–79% и 58–77%. Остаток создают девять одновременно восстановленных supplier-job с последовательным persist и конечный projector backlog; выключать их recovery ради красивого графика запрещено.

Повторный delta-профиль при MATCHER_CONCURRENCY=2 нашёл более сильную причину: за 20 секунд facts-overlap прочитал 70 229 блоков. В контекстном SQL canonical_id приводился к text до ctx_flags, поэтому Postgres планировал semi-join через индексный проход примерно по 2,0 млн supplier_offers вместо UUID lookup. На одном production offer старый план занял 42,1 секунды, 2 175 777 buffer hits и 61 154 физических чтения. С UUID внутри CTE и приведением к text только в финальной проекции тот же запрос вернул те же 13 кандидатов за 7,1 мс, 2 550 hits и без физических чтений; стоимость плана снизилась с 212 348 до 465. После выкладки 2 обработало 1 237 офферов за 33,5 секунды вместо 327 за 87 секунд; среднее чтение overlap упало примерно с 1 211 до 122 блоков на вызов. Контрольный запуск с 1 обработал 717 офферов за 105,9 секунды; с обязательной минутной паузой это около 259 офферов/мин при требуемых примерно 309 офферов/мин для текущего backlog 1,335 млн за 72 часа. Один reader создаёт вечный backlog и почти непрерывную IO-нагрузку, поэтому production возвращён на 2: короткий параллельный burst завершается быстрее и оставляет диску минутное окно без matcher.

Restart recovery долгих supplier-job отключать нельзя. Schedule lock старого контейнера освобождается, run помечается killed, а расписание поднимается через короткий retry и продолжает работу с durable cursor. ETM catalog раньше был исключением: он записывал только терминальный marker после всего отчёта и после рестарта повторял уже обработанную часть. Теперь catalog сохраняет отдельный catalog_offset каждые 500 SKU (не смешивая его с legacy pair_index) и повторяет максимум один незавершённый chunk.

Ключ входит в фиксированный allowlist deploy-db-vps.sh: одного изменения prod-env-template.env недостаточно, иначе быстрый image-only deploy оставит старое значение в live deploy/.env. После первой выкладки изменения самого deploy-скрипта повторить deploy-db-vps для того же SHA: shell дочитывает старую открытую копию скрипта, а новый allowlist начинает действовать со следующего запуска.

SHA-tag worker может отставать от API законно: ci-reuse-image публикует новый tag поверх прежних байтов, а fast-path не прерывает длительные job при том же Docker image ID и compose config hash. Поэтому секция V health-check сначала сравнивает content digest: разные теги с одним digest зелёные; разные digest — предупреждение о реальном content drift.

Прежний repo-level pre-shed считал любое изменение prod-env-template.env общим и останавливал все workers раньше, чем Compose успевал сравнить конфигурацию каждого сервиса. Поэтому переход matcher 8 → 4 лишний раз перезапустил supplier-sync и восстановил девять незавершённых job. Выкладка теперь хранит защищённую временную копию env до синхронизации и сравнивает старый/новый отрендеренный spec общей инфраструктуры. Если Postgres/Kafka/Redis/Elasticsearch не менялись, глобального pre-shed нет, а штатный per-service Compose hash пересоздаёт только целевой worker. Живая проверка 4 → 2: matcher получил новый StartedAt, а supplier-sync, charnorm, catalog-projector и Postgres сохранили прежний; новых recovery-событий не было. Ошибка рендера закрывается безопасно — глобальной остановкой. Миграции и реальное изменение общей инфраструктуры по-прежнему заранее останавливают workers.

Физический no-op проверять не только значением fetched_at, но и версией tuple: два повтора внутри 12 часов должны сохранить ctid и xmin строки offer_char_snapshot_state; после 12 часов меняется только эта строка, а ctid строк offer_characteristic_raw остаются прежними. В pg_stat_statements отдельно сравнивать calls/WAL state-upsert и raw DELETE+INSERT до и после релиза.

Секция I prod-health-check ограничивает окно ошибок ещё и текущим docker inspect .State.StartedAt. Docker сохраняет логи через restart attempts одного контейнера, поэтому уже завершившийся start failed: context deadline exceeded не является ошибкой нового успешно запущенного процесса. Ошибка после текущего StartedAt по-прежнему проваливает секцию; crash-loop и нездоровое состояние независимо ловятся проверкой контейнеров в секции B.

Projector намеренно пропускает отдельный ES bulk item с постоянным 400, чтобы poison document не блокировал всю очередь, но full reconcile обязан продолжать считать такой документ drift. Типовой случай: structured characteristic с большим raw_value нельзя целиком класть в keyword characteristics.value_str — Lucene отвергает immense term. Проекция оставляет в value_str только scalar либо компактный value_canonical; числовой объект дополнительно попадает в value_num_lo/hi. Если full-run трижды заканчивается с одним drift, искать тот же canonical ID в WARN skipped permanently-failing bulk items, а не увеличивать число проходов.

Raw payload хранится на том же NVMe в MinIO. Production-политика — lifecycle 7 дней, operational cleaner старше 12 часов и не чаще раза в час. Эти три RETENTION_S3_RAW_* ключа обязаны переноситься из prod-env-template.env в live deploy/.env самим deploy-db-vps.sh. Иначе compose молча использует локальные fallback 1d / 30m / 300s: supplier-sync непрерывно создаёт timestamp-keyed raw objects, а cleaner через полчаса удаляет их каждые пять минут, создавая встречный поток записи/удаления без продуктовой нагрузки. Проверять надо env самого контейнера minio-raw-retention-cleaner, а не только значения в шаблоне.

Сам raw writer использует exact-content key supplier=<s>/sku=<sku>/sha256=<digest>/kind=<kind>.json. Пока объект жив в retention-окне, байт-в-байт одинаковый ответ делает только metadata lookup и возвращает прежний raw_ref, не вызывая PUT. Изменённые байты создают новый immutable object. Время опроса не кодируется в object key: оно принадлежит offer_observations.observed_at, connector events и job runs; raw evidence и раньше могло исчезнуть независимо после retention.

Guards должны стоять на последней границе записи, а не только в вызывающем коде: IS DISTINCT FROM в ON CONFLICT DO UPDATE, фильтрация событий по фактически изменённым ключам и diff-обновление read-model. Иначе гонка или другой producer снова запустит каскад.

Для supplier_offers одного WHERE ... IS DISTINCT FROM внутри INSERT ... ON CONFLICT DO UPDATE недостаточно: PostgreSQL всё равно пишет speculative-insert WAL до обнаружения конфликта. Поэтому InsertOrTouch сначала делает индексный read-only preflight по (supplier, supplier_sku) и возвращает существующую строку, если все эффективные значения и 24-часовой heartbeat совпадают. Только новая или реально изменившаяся карточка доходит до конкурентно-безопасного upsert. Интеграционный контроль для точного повтора: в открытой транзакции pg_current_xact_id_if_assigned() должен оставаться NULL (до guard один вызов давал 96 байт WAL). Глобальный pg_current_wal_insert_lsn() нельзя использовать в общем CI Postgres: WAL соседнего пакета даёт ложное падение даже при полностью read-only вызове.

Отдельный read-hotspot обнаружен в admin GET /api/v1/admin/charnorm/characteristics?status=active. На 9393 активных характеристиках и 20,3 млн canonical_assignments прежний план для каждой характеристики выбирал в среднем около 7000 строк по canonical_assignments_char_idx, сортировал их по decided_at и параллельно делал полный scan char_name_mappings. Один запрос занимал весь 60-секундный statement budget и держал примерно 25 MB/s случайного чтения без заметного клиентского трафика.

Миграция 0392 добавляет частичный latest-first индекс canonical_assignments_char_latest_unit_idx по (characteristic_id, decided_at DESC NULLS LAST, value->>'unit') только для непустых unit. Correlated LIMIT 1 после этого останавливается на одной записи. Fallback сравнивает lower(char_name_mappings.canonical_name) и использует существующий индекс миграции 0167. Пустое более новое unit не скрывает последнее валидное canonical SI unit.

Проверка после deploy:

SELECT c.relname, i.indisready, i.indisvalid, pg_size_pretty(pg_relation_size(c.oid))
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'canonical_assignments_char_latest_unit_idx';
 
EXPLAIN (COSTS, BUFFERS)
SELECT ca.value->>'unit'
FROM canonical_assignments ca
WHERE ca.characteristic_id = '<characteristic_uuid>'
  AND NULLIF(ca.value->>'unit', '') IS NOT NULL
ORDER BY ca.decided_at DESC NULLS LAST
LIMIT 1;

Ожидается Index Only Scan либо Index Scan по индексу 0392 без отдельного Sort. Откат DROP INDEX CONCURRENTLY безопасен для записей, но возвращает старую I/O-деградацию endpoint; сначала откатывать handler/release, затем индекс, не наоборот.

У observation write-path признак фактической записи проходит до app-слоя: если merge вернул no-op, не создаются ни projector event, ни outbox offer.observation.v2. Manufacturer identity fast-path также проверяет trusted ValueNorm hint: resolver и exact-observation пропускаются только когда активный persisted manufacturer уже связан с тем же canonical_value_id.

Редкий 12-часовой freshness-ack меняет только актуальную строку наблюдения. Триггер trg_offer_observation_coverage сравнивает бизнес-состояние без per-warehouse ObservedAt и не трогает coverage/read-model на таком heartbeat. Реальное изменение цены, остатка, прогноза, доставки, outcome или набора coverage-значимых видов raw evidence по-прежнему обновляет coverage. Значение content-addressed raw_ref не является состоянием coverage: смена SHA при том же наличии ключа remains не должна каскадировать запись. Контрольные production-срезы: до guard — 690 observation UPDATE и ровно 690 лишних coverage UPDATE за 30 секунд; после первого guard, но до семантического сравнения raw_refs — 1947 observation и 1910 coverage UPDATE за 40 секунд.

Эффект релиза проверять только дельтой за одинаковое окно, не накопительными числами: scripts/pg_io_profile_report.py, pg_stat_wal.wal_bytes, pg_stat_user_tables.n_tup_upd/n_tup_hot_upd и calls/WAL горячих statement. На первом окне автовакуум ещё может держать диск: он обрабатывает churn, созданный до релиза. Критерий результата — падает скорость появления новых updates/WAL/dirty rows, затем снижаются dead tuples и I/O.

Docker pressure-cleaner проверяет /var/lib/docker каждые пять минут (RETENTION_DOCKER_PRUNE_INTERVAL_SECONDS=300). До порога 85% он удаляет только образы старше 72 часов; при >=85% включает уже предусмотренный unfiltered prune, который не затрагивает образы активных контейнеров. Интервал deploy-managed и обязан входить в allowlist deploy-db-vps.sh; иначе live env останется на дефолтных 24 часах. Часовой интервал оказался слишком большим: несколько последовательных deploy добавили неактивные SHA-образы после первой проверки, и /var вырос с 83% до 86% до следующего запуска. Инцидентный замер 2026-08-26: штатный pressure path освобождает место без рестарта Postgres или workers; пятиминутный интервал ограничивает время такого post-deploy всплеска.

Оба housekeeping-контейнера (minio-raw-retention-cleaner и docker-prune-cleaner) относятся в deploy к MAINTENANCE_SERVICES, а не к PostgreSQL-зависимым WORKERS. Поэтому migration shed их не останавливает: долгий mc rm не начинает полный обход объектов заново после каждой миграции. При этом missing-container self-heal и обычная сверка Compose config-hash для них сохранены; собственное изменение конфигурации штатно пересоздаёт только соответствующий cleaner. Проверка после deploy с миграцией: у MinIO-cleaner не должен меняться StartedAt, а в его журнале одна пара start → done не должна разрываться новым boot.

Очередь прогрева «по требованию»: два молчаливых отказа

Клиентский запрос (поиск, смета, подбор аналогов) ставит позиции в sku_warm_state.on_demand_requested_at, чтобы их цена и остаток обновились ближайшим тактом прогрева (такт — минута). Отметка снимается в MarkOk/MarkOutcome, поэтому «висит давно» = никто не разобрал.

Отказ первый: ворота TTL отсекали запрошенную позицию. Отметка участвовала только в ORDER BY, то есть поднимала приоритет среди строк, УЖЕ прошедших отбор. А отбор для известного нуля — 144 часа (ttl × zeroRecheckMult). Замер прода 2026-08-18: etm/5000442, last_total_stock = 0, поднят поиском, отсеян воротами при возрасте наблюдения 27 часов. То есть «показывает ноль, проверьте ещё раз» — ровно тот случай, который не работал. Починено: запрос по требованию пропускает ворота (on_demand_requested_at IS NOT NULL в WHERE).

Отказ второй: producer ставил запрос поставщику без detail consumer. После расширения warmer обслуживаемые поставщики задаются deploy-managed списком STOCK_DETAIL_WARMER_SUPPLIERS (сейчас etm,russvet,systeme,iek). Elevel в него не входит: его connector не предоставляет поштучный detail-вызов. Общие пути analog/estimate/proposal раньше всё равно вызывали WarmStateRepository.EnqueueOnDemand, поэтому Elevel-флаг оставался навсегда.

Исправление находится на общей границе repository: неподдерживаемый supplier получает успешный no-op до SQL, а startup reconciliation снимает старые on_demand_requested_at, не удаляя warm-state, stock или observation history. Capability rejection логируется один раз на supplier за жизнь процесса.

Оба отказа теперь видны в prod-health-check, секция W · warm-ondemand-drain. Для поставщиков из WARM_ONDEMAND_SERVED_SUPPLIERS застой очереди — провал; для остальных строк в steady state быть не должно. Их появление остаётся предупреждением и означает regression producer gate либо рассинхрон списка с STOCK_DETAIL_WARMER_SUPPLIERS. Появится поштучный запрос у нового поставщика — оба списка меняются deploy-managed правкой одной ревизии.

Что делать при следующем насыщении

  1. Убедиться, что дело в диске (iowait, проба создания контейнера).
  2. Посмотреть, кто грузит: pg_stat_progress_vacuum, долгие DO-блоки ремонтных скриптов, pg_dump.
  3. Автовакуумы не снимать — их работа нужна, а отмена выбрасывает уже проделанное.
  4. Если нужна выкладка — пауза воркеров освобождает диск за минуты (нагрузка 46 → 16, грязные страницы 1.38 ГБ → 292 МБ). Приём вшит в deploy-db-vps.sh, отключается через DEPLOY_SHED_WORKERS=0.
  5. Долгосрочно — сокращать базу и число индексов, а не поднимать таймауты.

Смежное: DB-VPS backup tier, Matching and analogs search.