惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Cloudflare Blog
Microsoft Security Blog
Microsoft Security Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
L
LangChain Blog
W
WeLiveSecurity
P
Proofpoint News Feed
月光博客
月光博客
NISL@THU
NISL@THU
L
LINUX DO - 最新话题
Webroot Blog
Webroot Blog
T
Threatpost
Y
Y Combinator Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
T
Threat Research - Cisco Blogs
Vercel News
Vercel News
Jina AI
Jina AI
阮一峰的网络日志
阮一峰的网络日志
S
Schneier on Security
J
Java Code Geeks
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
小众软件
小众软件
MyScale Blog
MyScale Blog
N
News and Events Feed by Topic
Stack Overflow Blog
Stack Overflow Blog
有赞技术团队
有赞技术团队
The Hacker News
The Hacker News
Schneier on Security
Schneier on Security
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Help Net Security
Help Net Security
Recent Announcements
Recent Announcements
S
Security @ Cisco Blogs
C
CXSECURITY Database RSS Feed - CXSecurity.com
S
Securelist
T
The Exploit Database - CXSecurity.com
云风的 BLOG
云风的 BLOG
C
Cisco Blogs
雷峰网
雷峰网
量子位
Google DeepMind News
Google DeepMind News
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Spread Privacy
Spread Privacy
L
Lohrmann on Cybersecurity
I
Intezer
T
The Blog of Author Tim Ferriss
G
GRAHAM CLULEY
D
DataBreaches.Net
V
Vulnerabilities – Threatpost
P
Privacy & Cybersecurity Law Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
罗磊的独立博客

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Postgres advisory locks на Neon ломаются от TCP-сброса. История четырёх фиксов retry-логики
MatushkinVD · 2026-05-04 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение12 мин

Охват и читатели1.3K

Ретроспектива

Расскажу про четыре 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;  // отдельный лок именно под retry

const 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 такой архитектуры нет.