Хочу рассказать про баг, который я искал полтора дня и который оказался опечаткой длиной в одну инструкцию. Если коротко: 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но но я как-то про это забывал, пока не получил полтора дня дебага.
Полезные ссылки
-
PostgreSQL docs — Combining Queries (UNION, INTERSECT, EXCEPT)
-
PostgreSQL docs — SELECT statement, ORDER BY/LIMIT semantics
Если есть свой любимый паттерн «всегда вернуть 1 строку, даже когда таблица пустая» — был бы рад увидеть в комментариях. У меня их получилось три, но наверняка есть и четвёртый.
ссылка на оригинал статьи https://habr.com/ru/articles/1032354/