Pular para o conteúdo
upgbp

prepared statement s0 already exists: por que o pooler quebra

O erro só aparece depois que você troca a conexão direta pelo pooler em modo transaction. A causa está em como o Postgres guarda statements preparados.

8 min de leitura

O erro tratado aqui

ERROR: prepared statement "s0" already exists (42P05)

Ambiente testado

  • Prisma 6.5
  • Drizzle 0.44
  • postgres.js 3.4
  • Supavisor · PgBouncer
Neste artigo (9)

Contexto: o que estava rodando

Logo depois de mover a aplicação da conexão direta com o Postgres para o pooler em modo transaction — a correção certa para serverless, e que resolveu um problema de esgotamento de conexões — um erro novo começou a aparecer.

Ele não era constante. Com trânsito baixo, nada. Com concorrência, uma fatia das requisições quebrava.

O erro

vercel logs --prodexit 1
PrismaClientUnknownRequestError:
Invalid `prisma.produto.findMany()` invocation:

Error occurred during query execution:
ConnectorError(ConnectorError {
  user_facing_error: None,
  kind: QueryError(PostgresError {
    code: "42P05",
    message: "prepared statement \"s0\" already exists",
    severity: "ERROR",
    detail: None,
  }),
})

Com Drizzle sobre postgres.js, a mesma coisa em outra embalagem:

logs da aplicaçãoexit 1
PostgresError: prepared statement "s3" already exists
    at ErrorResponse (/var/task/node_modules/postgres/cjs/src/connection.js:788:26)
  severity_local: 'ERROR',
  code: '42P05',
  routine: 'StorePreparedStatement'

O número muda — s0, s3, s17. O código 42P05 é sempre o mesmo.

Diagnóstico

Statement preparado pertence à sessão, não à consulta

Quando um driver executa a mesma consulta muitas vezes, ele evita reenviar o SQL inteiro: manda uma vez com um nome (s0), e depois só manda o nome e os parâmetros. Isso economiza análise sintática e planejamento a cada chamada.

O detalhe que importa: esse nome vive na sessão da conexão com o Postgres. Enquanto a sessão for a mesma, s0 continua existindo e significando a mesma coisa.

O modo transaction destrói essa premissa

Um pooler em modo transaction não dá uma sessão a você. Ele mantém um punhado de conexões reais com o banco e empresta uma delas por transação. Terminada a transação, aquela conexão volta para o bolo e pode ir para outro cliente.

O resultado é este:

cliente A ─┐
cliente B ─┼─→ pooler ─→ conexão real #1 ─→ Postgres
cliente C ─┘

A executa uma consulta  → pega a #1 → cria s0 na sessão da #1 → devolve a #1
B executa outra consulta → pega a #1 → tenta criar s0 → já existe → 42P05

O cliente B acha que está numa sessão limpa. Não está: ele herdou uma sessão onde outro cliente já registrou s0. Os dois drivers numeram do zero, então a colisão é questão de tempo.

Linha do tempo de três clientes que compartilham uma conexão real através de um pooler em modo transação. O cliente A cria o statement s0; B e C recebem a mesma sessão, tentam criar s0 de novo e recebem o erro 42P05.
A sessão é da conexão, não do cliente. O s0 registrado por A continua lá quando B pega a mesma conexão.

O que não resolve

Reiniciar o pooler. Limpa as sessões e o erro some por alguns minutos. Volta.

Trocar de porta sem pensar. Voltar para a conexão direta faz o erro sumir — e traz de volta o esgotamento de conexões que motivou o pooler. Trocar um problema pelo outro não é correção.

Capturar o erro e tentar de novo. A repetição pode até funcionar, porque a próxima transação talvez pegue outra conexão. É loteria, e ainda dobra a latência da requisição azarada.

A solução

A correção é desligar os statements preparados no cliente. Perde-se a economia de planejamento; ganha-se um sistema que funciona. Na prática, para consultas simples com índice, a diferença de latência é pequena diante do ganho de não manter conexão dedicada por instância.

  1. Prisma: pgbouncer=true na URL da aplicação.

    Este parâmetro não é entendido pelo Postgres — é uma instrução para o próprio Prisma, que ao vê-lo desativa o cache de statements.

    .env.production
    # Aplicação: pooler em modo transaction, sem statements preparados
    DATABASE_URL="postgresql://postgres.REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"
    
    # Migrations: conexão direta, onde statements preparados são bem-vindos
    DIRECT_URL="postgresql://postgres.REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres"
    prisma/schema.prisma
    datasource db {
      provider  = "postgresql"
      url       = env("DATABASE_URL")
      directUrl = env("DIRECT_URL")
    }
  2. postgres.js (e Drizzle sobre ele): prepare: false.

    Aqui não existe parâmetro na URL. É opção do cliente:

    src/lib/db.ts
    import { drizzle } from 'drizzle-orm/postgres-js';
    import postgres from 'postgres';
    import * as schema from './schema';
    
    const cliente = postgres(process.env.DATABASE_URL!, {
      // Obrigatório atrás de pooler em modo transaction. Sem isso, o driver
      // nomeia statements e colide com os de outro cliente na mesma conexão.
      prepare: false,
      max: 1,
    });
    
    export const db = drizzle(cliente, { schema });

    O max: 1 segue a mesma lógica: cada instância serverless atende uma requisição por vez, e um pool maior só produz conexão ociosa.

  3. node-postgres: não nomeie.

    O pg só prepara quando você passa name na consulta. A correção é remover o campo:

    src/lib/consultas.ts
    // Antes — nomeado, quebra atrás do pooler
    await client.query({
      name: 'buscar-produto',
      text: 'select * from produtos where id = $1',
      values: [id],
    });
    
    // Depois — parametrizado, sem nome. Continua imune a SQL injection.
    await client.query('select * from produtos where id = $1', [id]);

    Vale registrar: tirar o nome não desliga a parametrização. A proteção contra injeção vem dos $1, que continuam lá.

  4. Deixar as migrations pela conexão direta.

    Migrations usam recursos de sessão — bloqueios de aviso, set de configuração, DDL em transações longas — que o modo transaction não sustenta. Elas precisam da porta direta, e é para isso que serve o directUrl.

Como confirmar que resolveu

Gere concorrência de propósito. O erro não aparece com uma requisição por vez, então o teste precisa de várias ao mesmo tempo:

npx autocannon -c 50 -d 20 https://seu-app.vercel.app/api/produtos

Vinte segundos com cinquenta conexões e zero 42P05 nos logs é evidência suficiente.

Confirme que a aplicação está mesmo no pooler. Uma URL errada em algum serviço faz o erro voltar de um canto que você não olhou:

select
  inet_server_port() as porta,
  current_setting('application_name', true) as aplicacao;

Verifique que não sobrou statement preparado:

select count(*) from pg_prepared_statements;

Com o cache desligado nos clientes, essa contagem fica em zero durante o tráfego normal da aplicação.

Armadilhas que sobram depois disso

Poolers recentes sabem lidar com isso — se configurados. Versões novas do PgBouncer e do Supavisor conseguem administrar statements preparados nomeados em modo transaction. Depende de opção habilitada no lado do pooler, e você nem sempre controla essa configuração. Desligar no cliente é o caminho que não exige saber qual versão está rodando.

O modo session não tem esse problema, e não serve para serverless. No modo session a conexão é sua até desconectar, então s0 é só seu. Só que aí cada cliente segura uma conexão real, que é exatamente o que o pooler deveria evitar.

Uma URL esquecida derruba tudo. Basta um script, um worker ou um serviço de cron ainda apontando para a conexão direta sem o parâmetro para o erro reaparecer de forma aparentemente aleatória. Vale conferir todas as variáveis de ambiente do projeto, não só a principal.

LISTEN/NOTIFY e advisory locks continuam fora. Eles dependem de sessão estável e não funcionam em modo transaction, com ou sem statements preparados. Se a aplicação usa esses recursos, aquele caminho específico precisa de conexão direta própria.

Continue por aqui