Невидимый syntax error в PostgreSQL: как одна строка SQL съела полтора дня дебага

от автора

Хочу рассказать про баг, который я искал полтора дня и который оказался опечаткой длиной в одну инструкцию. Если коротко: PostgreSQL (16.4 в моём случае, но воспроизводится начиная с 12-й) не разрешает UNION ALL сразу после ORDER BY ... LIMIT N без круглых скобок. И самое неприятное — ошибку об этом я узнал последним, потому что между моим SQL-запросом и логами стояло пять промежуточных слоёв, каждый из которых её по-своему проглотил.

Расскажу как нашёл, как фиксил, и что в итоге добавил в pre-deploy чек-лист.

Симптом: тихий пустой ответ от webhook

У меня есть n8n-воркфлоу, который раз в день проверяю — там GET /webhook/result отдаёт финальную карточку клиента в JSON: профиль, статус, прикреплённые объекты, лид-инфа. Простой read-only эндпоинт. Цепочка из шести нод: webhook trigger -> Postgres Load Session -> Postgres Load Lead -> Postgres Load Properties -> Code Build Card -> Respond to Webhook. Ничего хитрого.

В пятницу вечером фронтенд начал стабильно падать на странице результата с пустым ответом. Открыл DevTools — 200 OK, content-type application/json, body пустой. То есть никакой ошибки на сервере, просто 0 байт.

Самое раздражающее, что у меня в голове сразу выстроился неправильный причинно-следственный ряд: «фронт раньше работал, я последний раз правил рендер карточки, наверное где-то null-pointer». Полез смотреть фронт. Конечно ничего не нашёл (потому что нечего смотреть, body пустой). Откатил последний релиз — продолжает падать. Так что нет, дело не во фронте.

Перешёл смотреть n8n. Открываю executions — workflow выполняется, отдаёт ok: true, никаких error-выходов. То есть ноды формально пройдены. Но что-то по дороге становится пустым.

В понедельник утром, после кофе и с ясной головой, наконец сел открывать каждую ноду по очереди и смотреть Output руками. На второй ноде — Postgres: Load Lead — увидел что она отдаёт пустой массив []. Ну ок, лида для этой сессии может и не быть. Дальше код-нода Build Card ждёт минимум 1 строку (берёт [0]) и при [0] === undefined молча возвращает пустой объект. Который и улетает в Respond. Вот и весь пустой ответ.

Дальше начинается интересное.

Что было в SQL

Запрос в Load Lead был написан так (я писал его несколько недель назад, копируя свой же паттерн откуда-то с прошлого проекта):

SELECT summary, escalation_reason, created_at FROM leadsWHERE session_id = '...'ORDER BY created_at DESCLIMIT 1UNION ALL(SELECT NULL, NULL, NULL)LIMIT 1

Идея логичная: «возьми последний лид для сессии, а если его нет — верни строку с NULL-полями, чтобы downstream-нода не споткнулась о пустой массив». Без UNION ALL n8n получал бы 0 items и обрывал цепочку.

Запрос вроде читается. Лид есть — вернёт его. Лида нет — первая часть пустая, UNION ALL приклеит NULL-fallback, итого LIMIT 1 ограничит до одной строки. Всё.

Берёшь этот SQL, скармливаешь psql:

psql ... -c "SELECT 1 AS x WHERE 1=2 ORDER BY x DESC LIMIT 1 UNION ALL (SELECT NULL) LIMIT 1"

И получаешь:

ERROR:  syntax error at or near "UNION"LINE 2: SELECT 1 AS x WHERE 1=2 ORDER BY x DESC LIMIT 1 UNION ALL (S...

Сюрприз. PostgreSQL такое не понимает. Хотя по идее всё разумно.

Почему PostgreSQL так строг

Если копнуть в SQL:2011 спеку (или в документацию PostgreSQL про UNION) — грамматика <query expression> устроена так, что ORDER BY и LIMIT относятся к внешнему запросу, к результату всех UNION/INTERSECT/EXCEPT. То есть когда вы пишете:

SELECT a FROM t1 ORDER BY a LIMIT 1UNION ALLSELECT b FROM t2

Парсер не может решить, к чему относится ORDER BY a LIMIT 1 — к первому SELECT (что вы хотели) или вообще странная конструкция, потому что после LIMIT должен идти конец query, а не UNION.

Чтобы сказать парсеру «ORDER BY/LIMIT относится только к первому SELECT» — его надо обернуть в скобки. Тогда это становится <query primary>, который имеет право иметь свои собственные сортировки и лимиты.

Это не баг постгреса. Это так и задумано стандартом, чтобы не было неоднозначности. Но узнаёшь об этом только когда упрёшься.

Честно — я и до этого случая знал это правило теоретически, но никогда не задумывался об edge-кейсах с LIMIT ровно на той же строке что и UNION. В обычной практике ORDER BY всё-таки чаще ставят в самом конце, в <query expression>, и проблемы не возникает.

Три варианта фикса

После того как я понял в чём дело, у меня было три рабочих способа починить.

Вариант 1: круглые скобки

Самое прямолинейное решение — то самое, на которое прямо намекает спека:

(SELECT summary, escalation_reason, created_at FROM leads WHERE session_id = '...' ORDER BY created_at DESC LIMIT 1)UNION ALLSELECT NULL::text, NULL::text, NULL::timestamptzLIMIT 1

Скобки превращают первую часть в <query primary> со своими ORDER BY/LIMIT. Парсер счастлив, запрос работает.

Подвох: если первая часть нашла запись, то после UNION ALL получится 2 строки (найденная + NULL-fallback), а внешний LIMIT 1 обрежет до первой. Но порядок строк в UNION ALL без явной сортировки не гарантирован. То есть теоретически вы можете получить NULL-строку даже когда лид существует. На практике PostgreSQL обычно отдаёт в порядке выполнения подзапросов и этого не происходит, но полагаться я бы не стал.

Чтобы гарантированно получать «реальную» строку первой — надо добавить ещё один уровень с ORDER BY по флагу:

SELECT summary, escalation_reason, created_atFROM (  (SELECT 0 AS prio, summary, escalation_reason, created_at FROM leads   WHERE session_id = '...' ORDER BY created_at DESC LIMIT 1)  UNION ALL  SELECT 1 AS prio, NULL::text, NULL::text, NULL::timestamptz) tORDER BY prioLIMIT 1

Уже не так красиво. Поэтому я этот вариант отбросил.

Вариант 2: RIGHT JOIN с константной таблицей

Это то, что я в итоге выкатил. Идея простая: сделать join с гарантированно непустой константной строкой, чтобы downstream всегда получал ровно одну строку — либо с данными, либо с NULL-полями.

SELECT summary, escalation_reason, created_at FROM (  SELECT summary, escalation_reason, created_at FROM leads  WHERE session_id = '...'  ORDER BY created_at DESC LIMIT 1) tRIGHT JOIN (SELECT 1 AS d) s ON true

Что тут происходит:

  • Внутренний SELECT ... LIMIT 1 живёт в скобках без проблем (это derived table)

  • (SELECT 1 AS d) — константа, гарантированно одна строка

  • RIGHT JOIN ... ON true означает: «возьми все строки правой части (всегда одна) и соедини с левой если есть пара». При ON true пара есть всегда. Если левая пустая — правая возвращает себя с NULL во всех полях левой

  • Внешний SELECT берёт только колонки из t (поле d из правой нам не нужно)

Итог: ВСЕГДА одна строка. Если в leads есть запись — получаем её значения. Если нет — все NULL.

Прелесть в том, что я не описываю NULL-fallback явно (нет SELECT NULL, NULL, NULL) — он автоматически возникает из левого join’а. Если завтра я добавлю в SELECT ещё одну колонку, мне не нужно править fallback в двух местах. Только в одном внутреннем запросе. А типы — PostgreSQL вычислит сам.

Главный минус решения — оно непривычное. Когда коллега видит такой запрос впервые, ему нужно секунд тридцать чтобы понять что происходит. Я добавил коммент над запросом «// Trick: RIGHT JOIN with constant guarantees exactly 1 row, NULL when empty» — этого хватает.

Performance? На таблице leads с 50k записей и индексом по session_id запрос отрабатывает за 1.2 ms. RIGHT JOIN с однострочной константой — бесплатный по сути. Ничего не оптимизируется хуже чем оригинал.

Вариант 3: scalar subqueries

Кратко — можно вообще обойтись без UNION и JOIN, если просто разнести каждую колонку в отдельный scalar subquery:

SELECT  (SELECT summary           FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS summary,  (SELECT escalation_reason FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS escalation_reason,  (SELECT created_at        FROM leads WHERE session_id='...' ORDER BY created_at DESC LIMIT 1) AS created_at

Каждый subquery возвращает скаляр или NULL. Итого получаем строку из трёх колонок, каждая либо со значением, либо с NULL.

Минус очевидный — один и тот же WHERE/ORDER BY/LIMIT повторяется три раза. Не страшно для маленькой таблицы (планировщик может закешировать), но на большой может стать заметно медленнее. Плюс если завтра нужно добавить колонку — править в каждом subquery.

В моём случае таблица leads не размером с Кострому, но я всё равно выбрал RIGHT JOIN-вариант — он чище читается и компактнее.

Почему n8n проглотил ошибку

Самое обидное в этой истории не сам SQL-баг, а то, что я не увидел error-сообщения вовремя. Postgres-нода в n8n, когда executeQuery падает с syntax error, ведёт себя по-разному в зависимости от настроек:

  • On Error: Stop Workflow — workflow обрывается, error-сообщение видно в executions

  • On Error: Continue — workflow идёт дальше с пустым output, ошибки не видно

  • On Error: Continue (using error output) — workflow идёт дальше, но создаётся отдельный error-выход; ошибка доступна, если этот выход подключить к downstream-ноде

В моём workflow Load Lead был выставлен в Continue (using error output), но error-выход никуда не был подключен (просто потому что я не предусмотрел такой сценарий). Получилось: ошибка возникла, нода поставила 0 items на main-выход, error-выход остался обрывком. Workflow «успешно» завершился пустой картой.

Урок: если в Postgres-ноде SQL динамический (через expressions, через билдер, через template) — либо используйте Stop Workflow, чтобы любой syntax error падал громко, либо подключайте error-выход к Telegram-нотификации или к Sentry. Тихий error в продакшене — худшее что бывает.

Для моих воркфлоу я в итоге сделал гибрид: для read-only нод оставил Continue (потому что иногда легитимно ничего не находится), но добавил отдельную ноду-проверку перед Build Card: если предыдущий output пустой — сразу логирую event в Telegram. Это поймало бы мою же проблему ещё в пятницу.

Что добавил в pre-deploy чек-лист

Этот случай заставил меня формализовать одну вещь:

  • Любой SQL с UNION/INTERSECT/EXCEPT, у которого части содержат ORDER BY/LIMIT — обязательно прогоняется руками в psql ПЕРЕД сохранением в n8n. Не «потом проверим в браузере», не «всё равно же тестим воркфлоу через webhook» — именно через psql. Парсер n8n не равен парсеру psql, и иногда ошибка маскируется.

Заодно добавил себе универсальную проверку для любого нового SQL: запускаю с заведомо невыполнимым WHERE 1=2. Если получаю ожидаемый результат (пустая выдача либо корректный fallback) и структуру колонок такую же как при WHERE 1=1 — запрос можно деплоить. Это banalно но я как-то про это забывал, пока не получил полтора дня дебага.

Полезные ссылки

Если есть свой любимый паттерн «всегда вернуть 1 строку, даже когда таблица пустая» — был бы рад увидеть в комментариях. У меня их получилось три, но наверняка есть и четвёртый.

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