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:
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ção | A aplicação vê | Query no servidor depois do erro | Próxima query no mesmo pool |
|---|---|---|---|
query_timeout: 1000 | Query read timeout em 1,0 s | continua rodando, mesmo depois do pool.end() | esperou atrás da antiga e também estourou |
statement_timeout: 1000 | 57014 em 1,0 s | sumiu | 7 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
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:
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.