В настройках клиента базы стоял statement_timeout в одну секунду, а запрос выполнялся больше сорока. Таймаут выглядел включённым и не действовал. У этой поломки нашлись четыре родственницы. Пулер в тексте ошибки назвала только одна. Остальные выглядели как пропавшая таблица, недоступная база и простой после выката.

Постановка

База живёт в Neon, перед ней штатный пулер соединений: PgBouncer в режиме транзакций. Через него ходят в PostgreSQL фоновые задачи XMBoost, сервиса, над которым я работаю.

В режиме транзакций серверное соединение закрепляется за клиентом только на время транзакции. Транзакция закончилась, соединение вернулось в пул, и следующий запрос того же клиента может уйти на любое другое. Для PostgreSQL сессия и есть серверное соединение, поэтому всё, что он привязывает к сессии, перестаёт принадлежать клиенту.

В документации PgBouncer всё, что при этом ломается, сведено в одну таблицу. Поломки на неё не ссылаются. Следствия я выводил из поломок, по одной.

Ловушка 1. Таймаут из настроек клиента до сервера не доезжает

Служебный запрос на проде читал таблицу на 10 ГБ и шёл от 43 до 48 секунд. В настройках клиента, который его отправлял, стояло statement_timeout: 1000. Ошибок не было. Проверка тем же клиентом через пулер заняла одну строку:

SHOW statement_timeout;
-- 0

node-postgres отправляет statement_timeout отдельным параметром стартового пакета. Получает этот пакет пулер, а не PostgreSQL: серверные соединения он открывает сам. По документации PgBouncer пропускает оттуда только параметры, которые умеет отслеживать, на остальные отвечает ошибкой, а перечисленные в ignore_startup_parameters принимает и игнорирует. Мой ошибки не вызвал и до сервера не дошёл. Какой слой управляемого пулера его отбросил, снаружи не видно.

Обычный SET не выход: в таблице PgBouncer для режима транзакций напротив него стоит Never. Настройка осядет на том серверном соединении, которое досталось команде, и достанется чужим запросам. Для одного запроса её доносит SET LOCAL внутри транзакции:

function withStatementTimeout<T>(
  ms: number, run: (tx: Prisma.TransactionClient) => Promise<T>,
) {
  return db.$transaction(async (tx) => {
    await tx.$executeRawUnsafe(
      `SET LOCAL statement_timeout = ${Math.round(ms)}`);
    return run(tx);
  }, { timeout: ms + 4000 });
}

Транзакция целиком идёт на одном серверном соединении, а SET LOCAL действует до её конца. Число попадает в строку из константы, не из входных данных.

Если таймаут нужен всем соединениям роли, есть ALTER ROLE … SET statement_timeout: значение становится умолчанием для каждой новой серверной сессии этой роли. Мне нужен был потолок для одного клиента, поэтому транзакция.

Проверял отказом: pg_sleep(3) при таймауте в секунду обрывается, обычный запрос проходит, за пределы транзакции настройка не протекает.

Ловушка 2. Тот же стартовый пакет, но с отказом

Скрипт бэкапа для страховки открывал соединение только на чтение, через PGOPTIONS='-c default_transaction_read_only=on'. Первый же боевой снимок упал четыре раза:

unsupported startup parameter in options: default_transaction_read_only.
Please use unpooled connection

Единственная ловушка из пяти, которая назвала себя сама.

Механизм тот же, разница в упаковке: statement_timeout node-postgres кладёт отдельным параметром, а PGOPTIONS попадает внутрь параметра options, в ошибке так и написано: «in options». Один параметр пулер проглотил, другой отверг. Что из двух случится, решают списки в его конфигурации, а у управляемого пулера их составлял не я.

Оговорка: документация Neon называет пять разрешённых параметров стартового пакета, и default_transaction_read_only среди них нет, а в актуальной документации PgBouncer он отслеживается по умолчанию. На своём PgBouncer пример может не воспроизвестись.

Что сделал: бэкап ходит на прямой адрес. У Neon он отличается от адреса пулера отсутствием суффикса -pooler в имени хоста. Защита «только чтение» осталась прежней.

Ловушка 3. Временная таблица пропадает между запросами

psql-скрипт для разового замера собирал выборку во временную таблицу и следующей командой её читал.

CREATE TEMP TABLE picked AS
  SELECT id FROM items WHERE flag;

SELECT count(*) FROM picked;
-- ERROR:  relation "picked" does not exist

Первая команда отработала без ошибок. Два соседних скрипта с такими же таблицами до этого дня проходили.

Без явного BEGIN каждая команда идёт отдельной транзакцией. CREATE выполнился, серверное соединение вернулось в пул вместе с таблицей: временная таблица по умолчанию живёт до конца сессии, а сессия осталась на сервере. SELECT пулер вправе отдать другому соединению. Иногда он попадает на то же самое, и скрипт проходит. Удачный пробный прогон здесь ничего не доказывает.

Что сделал: любой скрипт с временной таблицей идёт в BEGIN; … COMMIT; и запускается с -v ON_ERROR_STOP=1, а таблица объявляется с ON COMMIT DROP. В таблице PgBouncer это единственный вид временных таблиц, отмеченный для режима транзакций как рабочий. Без него таблица переживёт COMMIT и останется на серверном соединении, которое пулер отдаст следующему клиенту.

Ловушка 4. Advisory lock остался на чужом соединении

Сборка упала на шаге миграций с кодом P1002 и текстом Timed out trying to acquire a postgres advisory lock. Следующая упала так же. По справочнику Prisma P1002 значит «сервер базы достигнут, но не ответил вовремя». Выглядит как авария базы, а не как ошибка конфигурации.

prisma migrate deploy перед накатом берёт сессионный advisory lock, pg_advisory_lock, чтобы два параллельных выката не накатывали одно и то же. Такую блокировку PostgreSQL держит, пока её не снимут явно или пока не закончится сессия. В таблице PgBouncer у неё то же Never.

Блокировку берёт один запрос. Запрос закончился, серверное соединение вернулось в пул, а блокировка осталась на нём: она привязана к сессии сервера, а не к клиенту. Пулер отдаёт это соединение приложению, и сессия с блокировкой живёт дальше. Следующий выкат ждёт её 10 секунд (срок у Prisma не настраивается) и падает с той же ошибкой.

Вскрылось это на двух одновременных сборках из одного репозитория в одну базу. Напрашивался диагноз «гонка», но гонка только сделала причину заметной.

Снять такую блокировку можно одним способом: оборвать серверное соединение, на котором она висит.

SELECT pid FROM pg_locks WHERE locktype = 'advisory';
SELECT pg_terminate_backend(12345);  -- pid из первого запроса

Что сделал: prisma migrate deploy ходит только прямым адресом. Если прямой адрес не задан, команда падает сразу, а не уходит в пулер. Отключать блокировку переменной PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK не стал: она и защищает от двойного наката.

Ловушка 5. Замена блокировки не умирает вместе с процессом

Эта ловушка уже не про пулер, а про цену обхода.

Фоновый цикл должен крутить один процесс: два прочитают одни и те же строки очереди и оба сходят во внешний платный API. Сессионный advisory lock отпал по причине из ловушки 4. pg_advisory_xact_lock с пулером безопасен, но живёт до конца транзакции, а проход цикла длится минуты. Осталась аренда (lease): строка с именем цикла, держателем и сроком, которую берут одним условным UPDATE.

const expiresAt = new Date(Date.now() + ttlMs);
const taken = await db.$executeRaw`
  UPDATE tick_lease
     SET holder = ${HOLDER}, expires_at = ${expiresAt}
   WHERE name = ${name}
     AND (holder = ${HOLDER}
          OR expires_at < (now() AT TIME ZONE 'UTC'))`;
// taken > 0: цикл сейчас мой

В упрощённом коде опущены первая вставка строки (гонку в ней ловит первичный ключ) и запрет опоздавшему продлению перехватывать аренду у сменщика. Держателем записан процесс, а не сервис: имя, pid и случайный хвост. Живой держатель продлевает аренду каждую треть срока, а потерявший получает AbortSignal и останавливается на безопасной границе.

Сессионную блокировку PostgreSQL снимает сам, когда сессия заканчивается. Строка в таблице о смерти процесса не узнаёт. Сначала я увидел это на перезапусках: процесс, убитый посреди прохода, не выполняет finally, и следующая копия ждёт конца срока. Добавил освобождение аренды в обработчик остановки.

Потом один выкат всё равно остановил обработку. Очередь росла на 66 строк в минуту и за двадцать минут дошла до 1 574. Ожила она в ту секунду, когда истёк срок аренды. Старая копия завершалась не по сигналу остановки, а через обработчик ошибки, с process.exit(1), и на этом пути аренду никто не отпускал.

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

Вторая ошибка была в часах. Колонка срока без часового пояса, драйвер пишет туда UTC, а голый now() PostgreSQL приводит по поясу сессии. В замере база с поясом Asia/Bangkok отдавала минутную аренду второму держателю сразу. Отсюда AT TIME ZONE 'UTC' в запросе.

Что из этого следует

У всех пяти случаев одна причина: приложение считает сессией своё клиентское соединение, а PostgreSQL считает сессией серверное.

Правила, которые у меня остались:

  1. Состояние живёт внутри одной транзакции. SET LOCAL, ON COMMIT DROP, pg_advisory_xact_lock через пулер работают, их сессионные близнецы нет.

  2. Настройку спрашивать у сервера. SHOW тем же адресом, каким ходит приложение. Строка в конфиге клиента говорит о намерении, а не о результате.

  3. Всему, что берёт сессионную блокировку или живёт дольше одной транзакции, нужен прямой адрес. У меня это prisma migrate deploy и бэкап базы.

  4. Проверять отказом. pg_sleep дольше таймаута, второй процесс рядом с арендой.

  5. Заменяя блокировку строкой, выписать, что блокировка делала сама. Она освобождалась при смерти процесса и жила по одним часам с базой. Оба свойства пришлось писать руками, и оба я сначала написал с ошибкой.