user11769641

user11769641

Пикабушник
108 рейтинг 0 подписчиков 0 подписок 2 поста 0 в горячем
7

Лок в облачном Postgres завис на 5 дней. Виноват не код, а serverless

Если у вас Postgres на Neon (или Supabase, или любом другом serverless), и вы используете "pg_advisory_lock" для координации между репликами - проверьте сегодня. Скорее всего у вас тлеющий баг.

Лок в облачном Postgres завис на 5 дней. Виноват не код, а serverless

Где словил

У меня cron каждые 6 часов запускает функцию: проверить, есть ли пост за сегодня, если нет - сгенерировать, отправить в TG-каналы и в VK. Между двумя репликами на хостинге координация через advisory lock на номер UTC-дня. Учебниковый паттерн. Плюс отдельная retry-функция, которая досылает посты, не ушедшие в каналы с прошлого тика.

И вдруг утром обнаруживаю: retry пятый день подряд логирует одно и то же:

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

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

Retry-lock висит залоченный кем-то - взять его никто не может, retry никогда не запускается. А посты в каналы не идут. И хвост постов с "channel_posted_at IS NULL" тем временем растёт.

Первое подозрение - lock и unlock на разных клиентах

Перечитал PostgreSQL-доку про Explicit Locking. По смыслу: session-level advisory lock привязан к конкретному соединению и держится до явного освобождения или до конца сессии. Если взял на одном клиенте - отпускать нужно с того же. У меня же везде было "pool().query(...)", а "node-postgres" "pool.query" берёт первое доступное соединение из пула. Между lock и unlock пул мог отдать разные клиенты.

Тут я и налажал. Думал, pool сам разрулит сессионную привязку - он же с обычными запросами разруливает. (Нет).

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

> // retryKey = что-то стабильное на день, чтобы лок был общий

> const client = await pool().connect();

> try {

> const acq = (await client.query(

> 'SELECT pg_try_advisory_lock($1) AS acquired',

> [retryKey],

> )).rows[0]?.acquired;

> if (acq) {

> try {

> await retryTodayChannelPosts();

> } finally {

> await client.query('SELECT pg_advisory_unlock($1)', [retryKey]);

> }

> }

> } finally {

> client.release();

> }

"pool.connect()" отдаёт один клиент, в "finally" возвращаем в пул. Lock и unlock теперь гарантированно через одно соединение. Деплоил с уверенностью, что разобрался.

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

Второе попадание - Neon обрывает TCP у pinned-клиента

Просыпаюсь утром на третий день - в логах опять то же:

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

Снова. Дошло. Между "pg_try_advisory_lock" и "pg_advisory_unlock" у меня внутри блока шёл retry с TG/VK API-вызовами, это десятки секунд. Что происходит за это время:

  1. Я взял клиент A через "pool.connect()".

  2. Сделал "pg_try_advisory_lock" на A, получил "true".

  3. Начал делать retry, дёргаю TG/VK API. Обычно 30-60 секунд, но иногда залипает на 60-120, если TG отвечает медленно. Пока я в API, на клиенте A никто не выполняет SQL. Соединение idle.

  4. Neon - serverless: compute-узлы суспендятся после idle (на Free-тарифе через 5 минут), плюс свои таймауты у proxy-слоя между клиентом и backend-ом. В моём кейсе proxy успевал убить сокет раньше, чем срабатывал compute-suspend - и в особо медленные retry-сеансы unlock уже ехал в мёртвое соединение.

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

  6. node-postgres видит, что underlying socket мёртв: клиент A на самом деле труп, "client.query" падает с "Connection terminated unexpectedly". Сам по себе клиент не переподключится - просто мёртв, пул его выбросит при "release()".

  7. Unlock в принципе не выполнился. Старая backend-сессия с локом - на сервере она пока ещё "жива" в смысле, что её процесс выполняется. С её точки зрения TCP клиента просто отвалился, но backend не завершается моментально.

  8. В обычном Postgres зазор "TCP пропал -> backend это понял -> сессия закрылась" зависит от tcp_keepalive: при активности это секунды, на дефолтных Linux-настройках без активности backend может узнать о клиенте через часы. В Neon ещё веселее: между моим клиентом и реальным backend-ом стоит proxy-слой со своими таймаутами. Плюс backend может быть suspended (cold compute) и узнаёт о моей смерти только когда его поднимут обратно. Это могут быть минуты или дольше. Всё это время "pg_try_advisory_lock" для остальных возвращает "false".

Я тогда офигел. Unlock не сработал, лок болтается на сервере без хозяина, реплики приходят, видят "false", разворачиваются. И так всё новые сутки. Никто не может зайти.

Локально Postgres такого не делает - он не serverless, idle-сессии висят сколько угодно. В тестах и подавно: моки соединений не разрывают, CI зелёный. А на проде стабильно ловит раз в 2-3 дня.

Я попробовал keepalive - пинговать клиент "SELECT 1" каждые 10 секунд, чтобы не ушёл в idle. Это снизило частоту до "раз в неделю", но не убрало корень. На любой network blip - то же самое.

Что сработало

Перевести retry на row-level locks через "FOR UPDATE SKIP LOCKED" - вообще без advisory locks.

Логика retry была: "найди в БД сегодняшние посты с "channel_posted_at IS NULL", для каждого попробуй послать, если успешно - проставь дату". Это же ровно то, что Postgres умеет нативно через "FOR UPDATE SKIP LOCKED".

> const client = await pool().connect();

> try {

> while (true) {

> await client.query('BEGIN');

> // в боевом коде ещё фильтр slug LIKE 'YYYY-MM-DD-%' под сегодняшние, опускаю для простоты

> const res = await client.query(

> `SELECT slug, title, content_md, lang FROM blog_posts

> WHERE published = TRUE AND channel_posted_at IS NULL

> ORDER BY created_at LIMIT 1

> FOR UPDATE SKIP LOCKED`,

> );

> if (res.rowCount === 0) {

> await client.query('COMMIT');

> return;

> }

> const post = res.rows[0];

> try {

> const { channelOk } = await publishToChannel(post);

> if (channelOk) {

> await client.query(

> 'UPDATE blog_posts SET channel_posted_at = NOW() WHERE slug = $1',

> [post.slug],

> );

> await client.query('COMMIT');

> } else {

> await client.query('ROLLBACK');

> return; // bail на первой ошибке, не ddos-им сломанный API

> }

> } catch (err) {

> try { await client.query('ROLLBACK'); } catch {}

> return;

> }

> }

> } finally {

> client.release();

> }

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

И главное: если коннект умер прямо в середине транзакции, Postgres сам делает rollback на серверной стороне и отпускает все локи в этой транзакции. Транзакционный лок не может остаться "потерянным" как advisory: TCP-сброс, idle-timeout, suspended compute - всё это Postgres обрабатывает через штатный transaction abortion. Гарантирует это сама архитектура движка, не настройки.

Если две реплики одновременно начали retry - они просто будут последовательно брать разные строки благодаря "SKIP LOCKED". На уровне нагрузки безразлично, потому что в день максимум 1-3 поста попадают в retry.

Деплоил, четыре дня тишины. Пока нормас.

Итого

Если у вас в коде стоит "pg_advisory_lock" на serverless Postgres - сегодня хороший день пройти по всем местам и спросить себя: а что тут координируется на самом деле? Часто оказывается, что session-lock не нужен вовсе - хватает "INSERT ... ON CONFLICT" или сравнения состояния. В остальных случаях его можно заменить на "FOR UPDATE SKIP LOCKED" под конкретные строки.

Те случаи, когда session-lock действительно нужен - это long-running worker, который держит соединение всю свою жизнь, не отпуская его в idle. В serverless такого зверя нет.

Что бесило больше всего - баг проявлялся раз в 2-3 дня и локально не воспроизводился вообще. Тесты зелёные, CI зелёный. На проде тлеет. Узнавал глубже только когда читатель в комментариях писал «а где сегодняшний пост». Не лучший способ узнавать.

#postgres #neon #nodejs #грабли

Показать полностью

Telegraph API обещает 64 КБ. Валится на 17. Замерил и обошёл

В документации Telegraph API на эндпоинт "createPage" написано:

"content (Array of Node, up to 64 KB)". По факту русскоязычный markdown в районе 20 КБ ловит "CONTENT_TOO_BIG". Замерил, где реально проходит граница, и расскажу, как обойти.

Поймал я это так. У меня сервис генерит посты блога и публикует их в двух местах сразу: на сайте и параллельно отправляет в Telegraph ради красивой OG-карточки в TG-канале. Англоязычные переводы летели нормально. Русские оригиналы с конца марта молча перестали публиковаться в Telegraph, в обработке стоял fallback «если ссылка не вернулась, шлём в канал без неё», и недели две никто этого не замечал. В логи смотреть надо было раньше.

Telegraph API обещает 64 КБ. Валится на 17. Замерил и обошёл

Минимальное воспроизведение

Создаём свежий аккаунт через "createAccount" (он сразу выдаёт "access_token"), кидаем через "createPage" контент разного размера:

const para = 'Тестовый абзац с нормальным текстом. ';

let md = '';

while (md.length < kbTarget * 1024) md += para + '\n\n';

const content = md.split('\n\n').filter(Boolean).map(

p => ({ tag: 'p', children: [p] })

);

const res = await fetch('https://api.telegra.ph/createPage', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({ access_token, title: `${kbTarget} KB`, content }),

});

Прогнал от 5 до 40 КБ с паузой полторы секунды между вызовами, чтобы исключить rate-limit. Что вышло.

5 КБ исходного markdown - это после "JSON.stringify" 8 581 символ, по UTF-8 примерно 13 600 байт. Прошло. 10 КБ markdown - 17 096 символов JSON, около 27 200 байт. Тоже прошло. 20 КБ - 34 191 символов JSON, около 54 400 байт - "CONTENT_TOO_BIG". 30 и 40 КБ markdown упираются ровно в ту же стену.

Реальный потолок где-то между 10 и 20 КБ исходного markdown. Не 64, как в документации, и даже не близко.

Пробовал отправлять через "application/x-www-form-urlencoded" вместо JSON - поведение идентично, ошибка на тех же порогах. Envelope добавляет 200-300 байт, разница до обещанных 64 не заполняется. Скорее всего, Telegraph внутри парсит payload в свою структуру "Node" и меряет лимит уже после парсинга, где она пухнет за счёт служебных полей. Но это домыслы, проверить нечем.

Почему русский язык упирается раньше английского

Это вторая половина бага. В JavaScript длина строки считается в UTF-16 code units, и "String.prototype.length" для кириллической буквы вернёт 1, как и для латинской. А вот в HTTP-теле строка едет в UTF-8, и там кириллический символ занимает 2 байта на букву. Получается, при одинаковом "length" русский текст по сети ровно в два раза тяжелее английского.

Если Telegraph меряет лимит по байтам после кодирования (это правдоподобная версия), русский контент упирается в потолок где-то на 40-50% быстрее. EN-переводы у меня делает Gemini Flash Lite, они на 30-40% короче по символам, и на все 50% - по байтам. Большой пост в RU даёт 60-80 КБ, его перевод 20-30 КБ. Перевод помещается в зелёную зону, оригинал нет. Вот вам и «на одном языке работает, на другом нет», хотя код между ними общий.

Налажал я именно тут: думал, что байтовый лимит и символьный - это одно и то же. Получил по голове.

Как обойти

Резать markdown под безопасный размер и ставить маркер «полный текст на сайте». Эмпирическая зелёная зона - 18 КБ исходного markdown:

const TELEGRAPH_SAFE_MD_BYTES = 18 * 1024;

function truncateMarkdownAtBoundary(md, maxBytes, lang) {

if (Buffer.byteLength(md, 'utf8') <= maxBytes) {

return { md, truncated: false };

}

let cut = md;

while (Buffer.byteLength(cut, 'utf8') > maxBytes) {

cut = cut.slice(0, Math.floor(cut.length * 0.9));

}

// По возможности режем по границе абзаца, а не посреди слова

const lastBreak = cut.lastIndexOf('\n\n');

if (lastBreak > cut.length * 0.5) cut = cut.slice(0, lastBreak);

const note = lang === 'en'

? '\n\n\_Article truncated for Telegraph. Read full version on the site.\_'

: '\n\n\_Статья сокращена для Telegraph. Полный текст на сайте.\_';

return { md: cut + note, truncated: true };

}

Две детали, на которых легко обжечься.

Цикл режет по длине строки, а не по байтовому индексу. Если бы я сразу резал по целевому байт-индексу, мог бы попасть в середину кириллического двухбайтового символа. Получилась бы невалидная UTF-8 последовательность, и Telegraph такой payload отбрасывает без объяснений. Срез через "String.prototype.slice" по длине гарантирует валидную границу для BMP-символов, куда попадает кириллица.

Шаг 10% на первый взгляд жадноват, но на реальных входах цикл обычно укладывается в 1-2 итерации. На очень коротких текстах работает ранний return.

Поверх этого ставлю retry: если 18 КБ всё равно не прошло (бывает, если в тексте много ссылок - каждая "[text](url)" это плюс 30-50 байт после сериализации), уменьшаю лимит на 40% и пробую ещё раз. Максимум три попытки. Если всё ушло в потолок, публикую без Telegraph-ссылки, в канале остаётся ссылка только на сайт.

Что забрать с собой

Документированные 64 КБ у Telegraph - не верхняя граница, не нижняя, вообще не граница. Эмпирическое число на апрель 2026 - 17-18 КБ исходного markdown. Если они подкрутят свой внутренний лимит, придётся подкручивать и у вас. Сервис существует с 2016, документация обновлялась, кажется, тогда же.

Если у вас пайплайн «генерируем контент - публикуем в Telegraph для OG-карточки - потом ссылка в TG-канал», и в логах хоть раз мелькало "CONTENT_TOO_BIG", - скорее всего, RU-публикация у вас молча отваливается на длинных постах. Загляните в логи, там может быть приятный сюрприз.

И наконец общее. UTF-8 байты и UTF-16 code units - разные вещи. Если интеграция живёт на байтовом лимите, считать надо через "Buffer.byteLength", не через "length". Касается и баз, и транспортного слоя, и rate-лимитов. С токенами LLM ситуация ещё веселее, там для кириллицы вообще другие правила, чем для латиницы, и единственный способ не промахнуться - прогонять корпус на всех языках, которые у вас в проде.

Показать полностью 1
Отличная работа, все прочитано!

Темы

Политика

Теги

Популярные авторы

Сообщества

18+

Теги

Популярные авторы

Сообщества

Игры

Теги

Популярные авторы

Сообщества

Юмор

Теги

Популярные авторы

Сообщества

Отношения

Теги

Популярные авторы

Сообщества

Здоровье

Теги

Популярные авторы

Сообщества

Путешествия

Теги

Популярные авторы

Сообщества

Спорт

Теги

Популярные авторы

Сообщества

Хобби

Теги

Популярные авторы

Сообщества

Сервис

Теги

Популярные авторы

Сообщества

Природа

Теги

Популярные авторы

Сообщества

Бизнес

Теги

Популярные авторы

Сообщества

Транспорт

Теги

Популярные авторы

Сообщества

Общение

Теги

Популярные авторы

Сообщества

Юриспруденция

Теги

Популярные авторы

Сообщества

Наука

Теги

Популярные авторы

Сообщества

IT

Теги

Популярные авторы

Сообщества

Животные

Теги

Популярные авторы

Сообщества

Кино и сериалы

Теги

Популярные авторы

Сообщества

Экономика

Теги

Популярные авторы

Сообщества

Кулинария

Теги

Популярные авторы

Сообщества

История

Теги

Популярные авторы

Сообщества

Недвижимость и ремонт

Теги

Популярные авторы

Сообщества