Postgres advisory locks на Neon ломаются от TCP-сброса. История четырёх фиксов retry-логики

от автора

Расскажу про четыре production-инцидента на одном куске кода за десять дней. В каждом я думал, что разобрался. Закончилось тем, что я выкинул pg_advisory_lock из retry-пути и поставил FOR UPDATE SKIP LOCKED. Day-generation лок остался advisory-ным, но утечка там не критична — почему именно, разберу в конце. Полезно, если у вас Postgres на Neon (или Supabase, или Aiven serverless) и где-то по коду есть session-scoped advisory locks для координации задач между репликами.

Контекст

В моём пет-проекте автоматический генератор контента: cron каждые 6 часов вызывает generateDailyBlogPost(). Сама функция читается несложно:

  1. Проверяет, нет ли уже поста за сегодня (hasTodayPost()).

  2. Если нет, обращается к 5 LLM, выбирает тип контента по дню недели, склеивает статью, делает INSERT INTO blog_posts с published = TRUE.

  3. Переводит на английский (Gemini Flash Lite), постит в Telegraph, в два TG-канала (RU/EN), в стену VK-сообщества.

Координация между потенциально дублирующимися репликами (Railway во время деплоя на минуту крутит и старый, и новый инстанс) держится на pg_advisory_lock с ключом, рассчитанным от номера UTC-дня. Идея учебниковая: первый, кто захватил лок, генерирует, остальные видят lock_held и выходят.

База у меня живёт в Neon — это serverless Postgres, который масштабируется в обе стороны и активно сбрасывает простаивающие соединения. Это будет важно по ходу.

Раньше работало. 23 апреля начало падать.

Итерация 1 (23 апреля): lock_held пропускал retry целиком

Утром 23-го RU-пост в TG-канал ушёл, а EN не ушёл. В БД обе записи на месте, обе published = TRUE. У RU-поста channel_posted_at стоит, у EN-поста NULL. Пост создан, но публикация в канал не докатилась.

Полез в логи Railway. На одной из реплик было:

[blog] generation lock held, skipping[blog] returning lock_held

generateDailyBlogPost возвращал lock_held и сразу выходил. А retry-логика для пропавших публикаций (retryTodayChannelPosts) вызывалась только во внутренней функции _generateDailyBlogPostInner после ветки «пост уже существует» — а значит, retry никогда не запускался, если другая реплика уже держала лок.

Сценарий получался такой: реплика A заинсертила пост, но упала на TG (или VK завис на 30 секунд, или Telegraph вернул 504, что угодно). Реплика B через 6 часов приходит, видит залоченный day-key, думает «ну пусть А разбирается», уходит. А разбираться некому: А давно умерла вместе со своей сессией и локом.

Первый фикс был очевидный — вынести retry за пределы day-lock-а и защитить его отдельным advisory-lock-ом с ключом dayKey + 1_000_000_000, чтобы две реплики не дублировали retry друг другу:

const dayKey = Math.floor(Date.now() / 86400000);const retryKey = dayKey + 1_000_000_000;  // отдельный лок именно под retryconst retryLock = await pool().query(  'SELECT pg_try_advisory_lock($1) AS acquired',  [retryKey],);if (retryLock.rows[0]?.acquired) {  try {    await retryTodayChannelPosts();    await retryTodayVkPosts();  } catch (err) {    console.error(`[blog] Retry sweep failed: ${err.message}`);  } finally {    await pool().query('SELECT pg_advisory_unlock($1)', [retryKey]);  }}const lockResult = await pool().query(  'SELECT pg_try_advisory_lock($1) AS acquired',  [dayKey],);if (!lockResult.rows[0]?.acquired) {  return { created: false, reason: 'lock_held' };}// ... генерация

Деплой вечером, дёрнул generateDailyBlogPost() руками через ручку /admin/blog/retry-today-channels — retry-ветка нашла EN-пост с channel_posted_at IS NULL и догнала его в TG. 541/541 тестов зелёные. Закрыл инцидент.

Это работало 4 дня.

Итерация 2 (27 апреля): lock и unlock на разных клиентах

Утром 27 апреля — снова channel_posted_at = NULL у EN-поста. Сегодняшнего. Лезу в Railway logs:

[blog] retry sweep skipped (lock not acquired)[blog] retry sweep skipped (lock not acquired)

Retry-lock висит залоченный неизвестно кем — взять его никто не может, retry никогда не запускается. Плохо, но это уже другой симптом, чем 4 дня назад.

Перечитал PostgreSQL Explicit Locking ещё раз. По смыслу: session-level advisory lock держится до явного освобождения или до конца сессии. Лок привязан к конкретному соединению. Если я взял лок на одном клиенте, отпускать его нужно с того же клиента, иначе оригинальный клиент так и продолжает держать лок-сессионно.

Теперь смотрим на мой код. У меня везде pool().query(...). У node-postgres pool.query под капотом делает pool.connect(), выполняет запрос и автоматически возвращает клиент в пул. На каждый вызов он берёт первый свободный клиент — не обязательно тот же, что в прошлый раз. Между моими pg_try_advisory_lock и pg_advisory_unlock пул мог отдать разные соединения: lock уехал на client A, unlock — на client B. На client A лок не отпустился. Когда пул позже выдаёт client A какому-нибудь следующему запросу, и тот пытается взять этот же лок, pg_try_advisory_lock возвращает false: лок-то всё ещё держит client A со старого вызова.

Тестами это поймать практически невозможно. В тестах у меня один моковый client с одним mockQuery, никаких разных соединений не существует. В локальной разработке трафика мало, пул успевает обходиться одним-двумя клиентами и часто отдаёт один и тот же. Сценарий проявлялся только при реальной конкурентной нагрузке: два HTTP-запроса плюс cron — три параллельных пути за SQL-клиентом, и пул честно тасовал соединения.

Фикс простой — пинить один клиент через всю операцию:

const client = await pool().connect();try {  const retryAcquired = (await client.query(    'SELECT pg_try_advisory_lock($1) AS acquired',    [retryKey],  )).rows[0]?.acquired;  if (retryAcquired) {    try {      await retryTodayChannelPosts();      await retryTodayVkPosts();    } finally {      await client.query('SELECT pg_advisory_unlock($1)', [retryKey]);    }  }  const dayAcquired = (await client.query(    'SELECT pg_try_advisory_lock($1) AS acquired',    [dayKey],  )).rows[0]?.acquired;  if (!dayAcquired) {    return { created: false, reason: 'lock_held' };  }  try {    return await _generateDailyBlogPostInner();  } finally {    await client.query('SELECT pg_advisory_unlock($1)', [dayKey]);  }} finally {  client.release();}

pool.connect() отдаёт один клиент, держим его на всю операцию, в finally возвращаем в пул через client.release(). Теперь lock и unlock гарантированно ходят через один и тот же клиент.

Тесты обновил — в моке getPool теперь возвращает connect()-shape, который роутит в тот же mockQuery:

jest.unstable_mockModule('../db/index.js', () => ({  getPool: () => ({    query: mockQuery,    connect: async () => ({ query: mockQuery, release: () => {} }),  }),}));

541/541 проходит, деплой, закрыл инцидент с уверенностью «вот теперь точно». Так прожило двое суток.

Итерация 3 (29 апреля): Neon обрывает TCP у pinned-клиента

Утром 29-го открываю Railway logs:

[blog] retry sweep skipped (lock not acquired)[blog] retry sweep skipped (lock not acquired)[blog] retry sweep skipped (lock not acquired)

Снова. На этот раз ещё круче — не отстал EN-канал, а вообще ни одного канала за сегодня. RU channel_posted_at = NULL, EN channel_posted_at = NULL, VK vk_posted_at = NULL. Поста сегодняшнего просто нет нигде, кроме как у меня в БД. Тот самый сценарий «читатель напишет «а где пост»», от которого я думал, что застрахован.

Лезу разбираться. В отличие от итерации 2, никаких pool().query я не использовал, всё через client.query на pinned-клиенте. Почему unlock не проходит?

И вот тут до меня дошло. Между pg_try_advisory_lock и pg_advisory_unlock проходит несколько секунд. А если retry полный с TG/VK API-вызовами — десятки секунд. Что происходит за это время:

  1. pool.connect() отдал клиент A.

  2. Я выполнил pg_try_advisory_lock на A, получил true.

  3. Начал делать TG/VK retry, это 30-60 секунд API-вызовов.

  4. Пока я делаю API-вызовы, на pinned клиенте A никто не выполняет SQL. Соединение idle с точки зрения Postgres.

  5. Neon — serverless: compute-узлы автоматически уходят в suspend после периода idle, и долгие idle-соединения через proxy-слой закрываются. На Free-тарифе compute auto-suspend по умолчанию 5 минут; proxy-слой закрывает idle-сессии раньше, точное число у Neon в публичной документации не зафиксировано. В моём кейсе клиент терял TCP в первые минуты idle — а retry часто не успевал уложиться в это окно.

  6. Возвращаюсь к коду, пытаюсь сделать pg_advisory_unlock через клиент A.

  7. node-postgres обнаруживает, что underlying TCP-сокет умер: client.query отклоняется с Connection terminated unexpectedly. Никакого silent reconnect для pinned-клиента нет, клиент мёртв и пул его выбросит при release(). Unlock на сервер не уходит вообще.

  8. Старая backend-сессия, которая держала лок — на сервере она пока ещё «жива» в том смысле, что её процесс выполняется и продолжает держать advisory lock. С её точки зрения TCP клиента просто отвалился, но backend не завершается моментально. Между «TCP пропал» и «backend это заметил и закрылся» проходит время.

В обычном self-hosted Postgres этот зазор зависит от настроек tcp_keepalives_* и сетевой инфраструктуры — в дефолтной конфигурации Linux backend может не замечать ушедшего клиента до двух часов (default tcp_keepalive_time = 7200), но как только он попытается что-то записать в обнулённый сокет, обрыв вылезет сразу. В Neon-окружении зазор устроен сложнее. Между моим клиентом и реальным Postgres стоит proxy-слой со своими таймаутами; плюс Neon compute-узлы могут уходить в suspend между активной нагрузкой. Если backend, который держал мою сессию с локом, в этот момент в suspend, он узнаёт о моей смерти не когда я обрубил TCP, а только когда его поднимут обратно для следующего запроса. Запрос при этом не обязательно мой — любой входящий запрос на этом compute-узле разбудит. Это могут быть минуты, в плохом сценарии часы.

Я был на обычном Neon-эндпоинте (без -pooler). На -pooler-эндпоинте этот сценарий ловит ещё больнее: там session-locks работают непредсказуемо из-за pgbouncer transaction-mode multiplexing-а — соединение возвращается в общий пул после COMMIT, и следующий запрос «вашей» сессии может прилететь на чужой backend без локов. Подробности в Neon docs про connection pooling.

Итог: в моём случае осиротевший лок жил на сервере не «секунды-минуты», а «достаточно долго, чтобы перешагнуть несколько 6-часовых cron-тиков подряд». Я не разобрался до конца, что именно его удерживает — suspended compute, proxy timeout или их комбинация. Достаточно того, что в логах было видно: после утреннего инцидента 29-го advisory lock висел заблокированным несколько cron-тиков подряд, пока я не перезапустил Railway-инстанс ближе к вечеру. Перезапуск переоткрывал все сессии в пуле, и pg-сервер окончательно понимал, что старая сессия с локом ушла.

Я попробовал увеличить keepalive в pg-конфиге и пинговать pinned-клиент SELECT 1 каждые 10 секунд во время retry, чтобы не дать ему уйти в idle:

const keepAlive = setInterval(() => {  client.query('SELECT 1').catch(() => { /* транзиентная осечка, следующий тик попробует снова */ });}, 10_000);try {  await retryTodayChannelPosts();  await retryTodayVkPosts();} finally {  clearInterval(keepAlive);}

Это снизило частоту до «раз в неделю» вместо «раз в два дня», но не убрало причину. На любой network blip результат тот же: TCP-сброс, advisory lock висит на сервере без хозяина.

Стало ясно, что починить advisory lock в этой архитектуре нельзя — можно только заменить модель координации на ту, которую serverless-транспорт переживает корректно.

Решение: row-level locks с FOR UPDATE SKIP LOCKED

Логика retry-функций по сути такая: «найди в БД сегодняшние посты с channel_posted_at IS NULL, для каждого попробуй послать в TG, если успешно — проставь channel_posted_at = NOW()«. Это ровно то, что Postgres умеет нативно через FOR UPDATE SKIP LOCKED — захватывай по одной строке за раз, пропускай уже забронированные, обновляй и коммить.

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

async function retryTodayChannelPosts() {  if (!process.env.TELEGRAM_BOT_TOKEN) return;  const prefix = makeTodayPrefix();  const client = await pool().connect();  try {    const { publishToTelegraph } = await import('./telegraph.js');    while (true) {      await client.query('BEGIN');      const res = await client.query(        `SELECT slug, title, content_md, lang FROM blog_posts         WHERE slug LIKE $1           AND published = TRUE           AND channel_posted_at IS NULL         ORDER BY created_at         LIMIT 1         FOR UPDATE SKIP LOCKED`,        [`${prefix}-%`],      );      if (res.rowCount === 0) {        await client.query('COMMIT');        return;      }      const post = res.rows[0];      let success = false;      try {        const { channelOk, channelError } = await publishToTelegraph({          title: post.title,          slug: post.slug,          content_md: post.content_md,          lang: post.lang,        });        if (channelOk) {          await client.query(            'UPDATE blog_posts SET channel_posted_at = NOW() WHERE slug = $1',            [post.slug],          );          await client.query('COMMIT');          success = true;        } else {          await client.query('ROLLBACK');          console.error(`[blog] retry FAILED ${post.slug}: ${channelError || 'unknown'} (will retry next tick)`);        }      } catch (err) {        try { await client.query('ROLLBACK'); } catch { /* connection dead, lock auto-released */ }        console.error(`[blog] retry FAILED ${post.slug}: ${err.message}`);      }      // Bail on first failure - не молотим сломанное API в плотном цикле.      // Оставшиеся строки заберёт следующий 6h-tick или соседняя реплика.      if (!success) return;    }  } finally {    client.release();  }}

publishToTelegraph здесь — наша обёртка, которая постит и в Telegraph, и в TG-канал и возвращает { channelOk, channelError }. У вас на её месте будет ваша функция, важна только её контрактная часть: либо успех (тогда UPDATE + COMMIT), либо ошибка (тогда ROLLBACK и идём дальше).

Что меняется. Лок выдаётся не сессии, а строке. FOR UPDATE SKIP LOCKED внутри транзакции захватывает первую незаблокированную строку, остальные «потоки» её пропускают и идут к следующей. Если транзакция коммитится — лок отпускается, channel_posted_at стоит. Если транзакция роллбэчится (мы упали или TG ответил ошибкой) — лок отпускается, channel_posted_at остаётся NULL, на следующем тике другая реплика возьмёт ту же строку.

Главная разница: если коннект умер прямо в середине транзакции, Postgres сам делает rollback на серверной стороне и отпускает все локи в этой транзакции. Транзакционный лок не остаётся «потерянным», как может остаться advisory — всё, что выглядит как разрыв соединения (TCP-сброс, idle-timeout у proxy, suspended compute), Postgres переваривает через стандартный rollback. Гарантия не зависит от того, как именно соединение умерло.

Это переписывание убрало потребность в координации между репликами через session lock в retry-пути целиком. Если две реплики одновременно начали retryTodayChannelPosts — они просто будут последовательно брать разные строки благодаря SKIP LOCKED. На уровне нагрузки это безразлично, потому что в день максимум 1-3 поста попадают в retry, а размер блокировки на одной строке — десятки миллисекунд между SELECT FOR UPDATE и COMMIT.

Bail-on-first сделан намеренно: если TG-API сейчас отвечает 504, дёргать его ещё дважды в рамках одного tick-а смысла нет. Соседняя реплика или следующий 6h-tick подхватит остаток. Это компромисс между «починить всё что можно прямо сейчас» и «не молотить сломанное API в плотном цикле»; для канала в 1-3 поста в день второе важнее.

Day-generation lock я оставил advisory-ным — почему утечка там не критична, разберу в самом конце.

Деплой вечером 29-го. Тесты 540/541 (флакающий improvements-round2 на Windows к делу не относится).

Итерация 4 (1 мая): локальный dev-сервер выиграл production-лок

Эта итерация — не про сломавшийся row-lock, а про мою личную глупость: я сам подложил себе грабли.

У меня привычка локально гонять npm run dev против production DATABASE_URL, когда я отлаживаю что-то на фронте и нужен свежий список постов. И иногда я забываю убить этот процесс перед сном. На утро 1 мая обнаруживаю: пост в БД есть, но в каналы он не ушёл. Что произошло: cron сработал в 4 утра и в локальном, и в продакшен-инстансе. Локальный был быстрее на пару миллисекунд, выиграл day-generation advisory lock, заинсертил пост. После этого ему надо было постить в каналы — но TELEGRAM_BOT_TOKEN у локального нет, и постинг тихо скипается с warn-ом в консоль.

Production-инстанс к этому моменту тоже отработал свой 6-часовой тик, увидел lock_held, вышел. Следующий тик — ещё через 6 часов. В каналах целые сутки тишины, пока я не проснулся и не увидел.

Здесь нужно было два фикса. Первый — закрыть корень: instance без TELEGRAM_BOT_TOKEN не должен вообще выигрывать лок и инсертить пост, потому что он не сможет докатить публикацию.

async function _generateDailyBlogPostInner() {  if (await hasTodayPost()) {    return { created: false, reason: 'already_exists' };  }  if (!process.env.TELEGRAM_BOT_TOKEN && process.env.NODE_ENV !== 'test') {    return { created: false, reason: 'no_publish_tokens' };  }  // ... генерация}

Это решает «нормальный» случай. Но если в production случайно крутятся две реплики после деплоя и одна из них умирает на середине публикации — сценарий «пост в БД есть, в каналы не ушёл» всё равно может случиться. Поэтому второй фикс — safety net после lock_held:

let result = { created: false, reason: 'error' };const client = await pool().connect();try {  const dayAcquired = (await client.query(    'SELECT pg_try_advisory_lock($1) AS acquired',    [dayKey],  )).rows[0]?.acquired;  if (!dayAcquired) {    result = { created: false, reason: 'lock_held' };  } else {    try {      result = await _generateDailyBlogPostInner();    } finally {      try { await client.query('SELECT pg_advisory_unlock($1)', [dayKey]); }      catch { /* connection dead, лок отпустится сам когда сессия закроется */ }    }  }} finally {  client.release();}// Post-lock safety net: если другая реплика держала day-lock, она могла// вставить пост, но не докатить публикацию. Без этого ждали бы 6 часов// до следующего планового тика. Полный пайплайн (5 моделей + EN-перевод// + Telegraph + TG + VK) эмпирически укладывается в 30-90 секунд - берём// верх диапазона как разумную нижнюю границу ожидания. Тесты пропускают// через NODE_ENV === 'test', чтобы не добавлять 90с real-time wait.if (result.reason === 'lock_held' && process.env.NODE_ENV !== 'test') {  await new Promise((r) => setTimeout(r, 90_000));  try {    await retryTodayChannelPosts();    await retryTodayVkPosts();  } catch (err) {    console.error(`[blog] Post-lock retry sweep failed: ${err.message}`);  }}return result;

Если другая реплика всё-таки докатилась за 90 секунд — retry увидит, что channel_posted_at стоит у всех сегодняшних, и просто выйдет. Если не докатилась — заберёт FOR UPDATE SKIP LOCKED строку и сама опубликует.

Это работает уже три дня без сюрпризов. Может, рано закрывать.

Напоследок

В Neon (и в Supabase, Aiven, на любом serverless Postgres) advisory locks не переживают TCP-сброс. Сессионный лок живёт ровно столько, сколько живёт сессия, а serverless-инфра обрывает TCP штатно, не «когда что-то сломалось». Между acquire и release лок может остаться у мёртвого или suspend-нутого backend-а на минуты, иногда дольше. Тестами это не ловится, локально не воспроизводится. Узнаёшь только когда production падает.

pool.query() с advisory locks ломается ещё проще, даже без serverless-фактора: lock и unlock могут уехать в разные клиенты, и один из них останется держать лок навсегда. Lock и unlock обязательно через один pinned pool.connect() + release(). На обычном Postgres этого хватает; на serverless всё равно не спасает от TCP-сброса, и тогда уже нужен row-level подход.

Row-level locks через FOR UPDATE SKIP LOCKED устойчивее именно потому, что они отпускаются не только на commit/rollback, но и на disconnect — Postgres делает это в рамках штатного transaction abortion. Гарантия не зависит от того, что именно произошло на транспортном уровне.

И ещё одно: не держать соединение открытым во время долгого внешнего API-вызова. Между BEGIN и COMMIT (или между acquire и release лока) внешний вызов на 30 секунд это риск независимо от типа лока.

Про оставшийся day-generation advisory: я обещал в начале вернуться. Утечка там не критична потому, что дублей в БД ловит не лок, а уникальный slug плюс hasTodayPost(). Даже если сессия уйдёт с залоченным day-key на сутки, реальную дубликацию INSERT ... ON CONFLICT (slug) DO NOTHING обнулит. В худшем случае одна дневная генерация пропустится; retry-цикл к этому моменту от advisory locks уже не зависит.

Резюмирую: если у вас в коде стоит pg_advisory_lock на Neon, Supabase, Aiven или любом другом serverless Postgres, пройдитесь по нему сегодня. Большая часть таких локов либо не нужна вовсе (полагаемся на INSERT ... ON CONFLICT или сравнение состояния), либо переписывается на FOR UPDATE SKIP LOCKED. Session-lock реально нужен только для long-running worker-ов, которые держат соединение открытым весь свой жизненный цикл; в serverless такой архитектуры нет.

ссылка на оригинал статьи https://habr.com/ru/articles/1031236/