sh4.tech

Dica 3 min de leitura Postgres · Node.js

query_timeout não cancela nada

No node-postgres, o query_timeout desiste só do lado do cliente. A query continua rodando no servidor e a conexão fica ocupada. Use statement_timeout e keepalive TCP.

Publicado
Medido
, teste local, pg 8.16.3 e Postgres 16
Stack
Node.js, node-postgres (pg) por baixo do Kysely, BullMQ, Postgres
Também em
English

De onde veio

Um sync horário de inventário ficou travado 17 dias. O job do BullMQ estava em active para sempre, sem timeout em lugar nenhum, e parecia vivo porque cada deploy rodava o sync uma vez no boot. Como a gente faz deploy com frequência, demorou até alguém olhar para o job em si, e não para a tela.

Não havia um único timeout no caminho, então a primeira correção colocou em quatro camadas (HTTP, pool do Postgres, Redis e o próprio job). No pool eu pus dois:

primeira versão
const pool = new Pool({
  connectionString,
  connectionTimeoutMillis: 15_000,
  statement_timeout: 120_000,
  query_timeout: 130_000, // rede no cliente, caso o servidor nunca responda
});

Parecia uma rede de segurança no cliente, um pouco mais longa que o limite do servidor. Na revisão do mesmo dia eu tirei o query_timeout, e o motivo é o resto deste texto.

O que ele faz de verdade

No node-postgres, o query_timeout é um cronômetro dentro do seu processo, e só. Quando ele dispara, a promise rejeita com Query read timeout, e acabou: nada é enviado ao Postgres. O backend continua executando a query, segurando lock e CPU, até ela terminar sozinha.

Não quis escrever isso de memória, então testei num Postgres 16 local com pg_sleep(8) e limite de 1 segundo:

ConfiguraçãoA aplicação vêQuery no servidor depois do erroPróxima query no mesmo pool
query_timeout: 1000Query read timeout em 1,0 scontinua rodando, mesmo depois do pool.end()esperou atrás da antiga e também estourou
statement_timeout: 100057014 em 1,0 ssumiu7 ms

A terceira coluna é o que eu chamo de slot envenenado, e a quarta é a consequência. Se você faz connect() e devolve o cliente com release() num finally, o pool recebe de volta uma conexão que ainda tem query em andamento, e quem pegar essa conexão depois fica esperando um resultado que ninguém quer mais. Com pool.query() o cliente é descartado, mas o backend segue trabalhando no servidor os 8 segundos inteiros do mesmo jeito.

A correção

pool.ts
import { Pool } from "pg";

const pool = new Pool({
  connectionString,
  max: 20,                             // o padrão do pg é 10; explícito para o teto ficar visível
  connectionTimeoutMillis: 15_000,     // para abrir conexão ou conseguir uma livre no pool
  statement_timeout: 120_000,          // o servidor cancela a query
  keepAlive: true,                     // detecta socket derrubado por NAT ou ociosidade
  keepAliveInitialDelayMillis: 30_000,
});

O statement_timeout vai para o Postgres como configuração da sessão, na abertura da conexão. No limite, o próprio servidor cancela o comando e responde 57014 canceling statement due to statement timeout; a conexão sai limpa e volta pronta para o pool.

O caso que o query_timeout deveria cobrir, servidor que nunca responde, no nosso caso era socket morto (um NAT ou firewall que derrubou a conexão sem avisar ninguém). Quem acha isso é o keepalive TCP, no nível do socket, e é para isso que serve o keepAlive acima.

Para ver com seus olhos, aponte para qualquer banco de teste:

repro.js
const { Pool } = require("pg");
const cs = process.env.DATABASE_URL;
const mon = new Pool({ connectionString: cs, max: 1 });
const running = async (q) => (await mon.query(
  "select count(*)::int n from pg_stat_activity where query = $1 and state = 'active'", [q]
)).rows[0].n;

(async () => {
  const a = new Pool({ connectionString: cs, max: 1, query_timeout: 1000 });
  await a.query("select pg_sleep(8.1)").catch((e) => console.log("A:", e.message));
  console.log("A ainda no servidor:", await running("select pg_sleep(8.1)")); // 1

  const b = new Pool({ connectionString: cs, max: 1, statement_timeout: 1000 });
  await b.query("select pg_sleep(8.2)").catch((e) => console.log("B:", e.code));
  console.log("B ainda no servidor:", await running("select pg_sleep(8.2)")); // 0

  await Promise.all([a.end(), b.end(), mon.end()]);
})();

Se você usa outro driver, vale conferir o que o timeout dele faz antes de confiar no nome. A pergunta que eu faço hoje é quem cancela a query, o cliente ou o servidor.

Arquivado pelo problema que resolve

Luciano Serra
Luciano Serra Founder-engineer · cofundador e CTO, construiu do zero uma rede de 15.000 telas, 2015–2024 · São Paulo, remoto

Desenho sistemas de produção do zero e assumo os que já estão rodando. Nos dois casos eles precisam aguentar quando o negócio cresce. Cofundei uma empresa de mídia out-of-home e construí a tecnologia dela por dez anos; meus sócios captaram o dinheiro e cuidaram do comercial.

Seu negócio está crescendo mais rápido que os sistemas? Vamos conversar →