Первое подключение проходило, повторное — «не удалось сохранить»
upsert не берёт частичный уникальный индекс: Postgres отвечает 42P10 на планировании. Ломается только второй раз, и в интерфейсе это выглядит случайным сбоем.
Уникальность «один сервис на компанию» у нас стояла частичным индексом — с условием, потому что вебхуков на компанию бывает много, а всего остального по одному:
CREATE UNIQUE INDEX ... ON integrations (organization_id, kind)
WHERE kind <> 'webhook';
Клиент сохранял настройку через upsert с onConflict: "organization_id,kind".
И получал отказ — но не всегда.
Что именно ломалось
Postgres не выводит частичный индекс из голого списка колонок. Чтобы он его взял, тот же предикат нужен в самом запросе, а через PostgREST его не передать. Ответ приходит такой:
42P10 there is no unique or exclusion constraint matching
the ON CONFLICT specification
Коварство в том, что первое подключение проходило. Конфликта не было, запись
вставлялась — но запрос всё равно шёл с ON CONFLICT, и падал он на
планировании, а не на записи. То есть отказ зависел не от данных, а от того,
дошёл ли планировщик до разбора этой конструкции.
Ломалось только повторное сохранение. В интерфейсе это выглядело как случайный сбой: «первый раз сохранилось, второй — нет», и воспроизвести на пустой базе не получалось.
Чем лечится
Явным путём вместо upsert: найти → обновить или вставить, с обработкой
23505 как гонки. Дороже на один запрос и полностью предсказуемо.
Соблазн «сделать индекс полным, чтобы upsert заработал» мы отвергли: условие в
индексе стоит там не для красоты, а потому что вебхуков на компанию
действительно много. Подгонять схему под удобство клиента — менять правду о
данных ради синтаксиса.
Приём: проверить, ничего не записав в боевую базу
Отдельно стоит запомнить, потому что применимо шире этого случая.
Отправьте тот же запрос с заведомо несуществующим внешним ключом.
42P10возникает на планировании и приходит раньше проверки ключа. Если вы получили его — запрос сломан, и до записи дело не дошло.23503(нарушение внешнего ключа) означает, что запрос валиден: план построился, и остановила его только проверка ключа. Ничего не создалось.
Так проверяется форма запроса на живой базе, без тестовой записи и без уборки за собой.
Что из этого следует
Отказ, который зависит от порядка обработки запроса, а не от данных, всегда выглядит случайным — и потому ищется дольше всего. Здесь ошибка была в первом же подключении, но проявлялась только со второго, и вся диагностика уходила в данные, где её не было.
Признак такого класса ошибок один: не воспроизводится на чистой базе, но устойчиво воспроизводится на второй попытке. Если видите это — смотрите не в данные, а в план запроса.
У нас уведомления уходят своим ботом и своим релеем, а настройки каналов хранятся на компанию — тот самый случай, где эта уникальность и понадобилась. Как это устроено — Уведомления в Telegram.
Читайте также
Автор
Александр ЦапковОснователь Скоупворк
Веду платформу и её боевой контур сам: разработка, выкат, дежурство. Пишу о том, на чём мы обожглись, — с датами, замерами и ссылками на решения в репозитории.