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_products | 0.6 ГБ | 4.4 ГБ (7.4×) |
offer_characteristic_facts | 4.2 ГБ | 20 ГБ (4.8×) |
offer_characteristic_raw | 4.1 ГБ | 14 ГБ (3.4×) |
canonical_analog_assignment_lookup | 13 ГБ | 29 ГБ (таблица производная) |
supplier_offers | 6.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.
Две причины, устранять надо обе сразу:
fillfactor= 100 — в странице нет места под HOT.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_FLIGHT | 3 | все supplier jobs с живым lease |
SUPPLIER_SYNC_MAX_CATALOG_IN_FLIGHT | 1 | catalog и legacy monolith |
SUPPLIER_SYNC_MAX_NON_CATALOG_IN_FLIGHT | 2 | commerce и 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.
Три ловушки, каждая из которых уже давала неверный вывод:
- Вложенные запросы нельзя складывать с внешними. При
pg_stat_statements.track = allбуферы триггера и вызванной функции учитываются и у внешнего вызова. Ранжировать физическую стоимость только поtoplevel = true, вложенные оставлять как объяснение плана. Проверка:SELECT queryid, bool_or(toplevel), bool_or(NOT toplevel) FROM pg_stat_statements GROUP BY 1. - Среднее нельзя вычитать.
mean_exec_timeне складывается; за окно оно считается какdelta(total_exec_time) / delta(calls), иначе получаются отрицательные значения. - Остаток «всё, что не statements» — не доказательство вины автовакуума. Долю считать по
pg_stat_io(PostgreSQL 16+), а не вычитанием.
Замер по pg_stat_io за 756 секунд:
| процесс | блоков чтения | доля полосы | время чтения | на блок |
|---|---|---|---|---|
| autovacuum worker (vacuum) | 1 801 958 | 53.3% | 521.4 с | 0.29 мс |
| client backend (normal) | 1 521 706 | 45.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_INTERVAL | api-server, App-VPS → scripts/render_production_env.sh | 6h |
Ловушка та же, что была с 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 правкой одной ревизии.
Что делать при следующем насыщении
- Убедиться, что дело в диске (iowait, проба создания контейнера).
- Посмотреть, кто грузит:
pg_stat_progress_vacuum, долгиеDO-блоки ремонтных скриптов,pg_dump. - Автовакуумы не снимать — их работа нужна, а отмена выбрасывает уже проделанное.
- Если нужна выкладка — пауза воркеров освобождает диск за минуты (нагрузка 46 → 16, грязные страницы 1.38 ГБ → 292 МБ). Приём вшит в
deploy-db-vps.sh, отключается черезDEPLOY_SHED_WORKERS=0. - Долгосрочно — сокращать базу и число индексов, а не поднимать таймауты.
Смежное: DB-VPS backup tier, Matching and analogs search.