Para manter uma transação node-postgres coesa, obtenha um cliente com pool.connect(), execute todas as instruções por meio desse cliente e libere-o após o commit ou rollback. Passe esse cliente também para as funções auxiliares. O guia de Transações oficial exige esse padrão.

Este tutorial é para um desenvolvedor que usa o Terminal em um Mac para fazer com que várias alterações em linhas de artigos sejam confirmadas ou desfeitas em conjunto. Ele inclui um programa completo com linhas de artigos sintéticas, um teste deliberado de rollback e uma sequência de diagnóstico para consultas ocultas em funções auxiliares.

Por que pool.query() interrompe o escopo da transação?

Um pool gerencia conexões; ele não faz com que todas as consultas pertençam a uma única transação. Cada chamada a pool.query() adquire internamente um cliente disponível. Portanto, não há garantia de que chamadas consecutivas usem a mesma conexão. A API Pool do node-postgres alerta explicitamente contra o uso desse método em transações.

Por exemplo, enviar BEGIN pelo pool e depois enviar uma inserção pelo pool não garante que essa inserção faça parte da transação iniciada. Normalmente, o PostgreSQL executa instruções fora de um bloco de transação explícito em transações próprias. Um rollback posterior em outra conexão não pode desfazer essas alterações confirmadas de forma independente. Consulte a documentação de BEGIN do PostgreSQL.

O limite útil é o objeto cliente retirado do pool. O nome de uma função auxiliar, o módulo que a contém e o fato de quem a chama estar usando await não determinam se ela pertence à transação. Uma função auxiliar participa quando suas consultas usam o cliente que iniciou a transação. Isso decorre diretamente do requisito de usar o mesmo cliente no guia de Transações.

Execute um exemplo completo de commit e rollback no Mac

Você precisa do Node.js com npm, de um servidor PostgreSQL em execução e de um banco de dados de desenvolvimento local já criado. Este passo a passo pressupõe que o banco se chama article_demo e que as configurações normais de conexão local já funcionam. A instalação do banco de dados está fora do escopo desta tarefa sobre transações.

No Terminal, crie um novo diretório de trabalho e instale o driver:

mkdir pg-transaction-demo
cd pg-transaction-demo
npm init -y
npm install pg

Salve o programa completo a seguir como transaction-demo.mjs. A tabela temporária contém apenas IDs e títulos sintéticos de artigos. Normalmente, as tabelas temporárias do PostgreSQL persistem após os commits e são removidas quando a sessão termina; essas propriedades permitem que o programa inspecione um lote confirmado depois que um segundo lote sofre rollback. Consulte CREATE TABLE.

O auxiliar de transação abaixo segue a sequência documentada BEGIN, COMMIT e ROLLBACK. Ele acrescenta tratamento explícito para o caso de o rollback também falhar, preservando os dois erros em vez de substituir a falha original. Os valores das consultas usam a API de texto e valores documentada em Consultas.

import pg from 'pg';
import assert from 'node:assert/strict';

const { Pool } = pg;

async function inTransaction(client, work) {
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

  try {
    const result = await work(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    try {
      await client.query('ROLLBACK');
    } catch (rollbackError) {
      throw new AggregateError(
        [error, rollbackError],
        'Transaction failed; rollback also failed'
      );
    }
    throw error;
  }
}

async function insertArticle(client, article) {
  const result = await client.query(
    `INSERT INTO pg_temp.transaction_demo_articles (id, title)
     VALUES ($1, $2)
     RETURNING id, title`,
    [article.id, article.title]
  );
  return result.rows[0];
}

async function insertBatch(client, articles) {
  const inserted = [];
  for (const article of articles) {
    inserted.push(await insertArticle(client, article));
  }
  return inserted;
}

async function main() {
  const pool = new Pool({ connectionTimeoutMillis: 5000 });
  let client;
  let discardClient = false;

  pool.on('error', (error) => {
    console.error('Idle client error:', error);
    process.exitCode = 1;
  });

  try {
    client = await pool.connect();

    await client.query(`
      CREATE TEMP TABLE transaction_demo_articles (
        id text PRIMARY KEY,
        title text NOT NULL
      ) ON COMMIT PRESERVE ROWS
    `);

    const saved = await inTransaction(client, (tx) =>
      insertBatch(tx, [
        { id: 'article-001', title: 'A guide to paper shapes' },
        { id: 'article-002', title: 'Choosing a notebook layout' }
      ])
    );
    assert.equal(saved.length, 2);

    await assert.rejects(
      () => inTransaction(client, (tx) =>
        insertBatch(tx, [
          { id: 'article-003', title: 'Arranging a reading desk' },
          { id: 'article-001', title: 'Deliberate duplicate ID' }
        ])
      ),
      (error) => error.code === '23505'
    );

    const remaining = await client.query(`
      SELECT id, title
      FROM pg_temp.transaction_demo_articles
      ORDER BY id
    `);

    assert.deepEqual(
      remaining.rows.map((row) => row.id),
      ['article-001', 'article-002']
    );

    console.log('Commit and rollback checks passed.');
    console.table(remaining.rows);
  } catch (error) {
    discardClient = true;
    throw error;
  } finally {
    if (client) client.release(discardClient);
    await pool.end();
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Execute-o no mesmo diretório:

PGDATABASE=article_demo node transaction-demo.mjs

O pool lê as variáveis de ambiente de conexão do PostgreSQL. Se sua configuração local exigir outras opções de conexão, forneça os valores apropriados de PGHOST e PGPORT junto com PGDATABASE. O guia de Conexão oficial descreve essas variáveis e seus valores padrão.

Se a execução for bem-sucedida, espere ver Commit and rollback checks passed. seguido pelas duas linhas originais de artigos. O segundo lote insere article-003 e depois tenta reutilizar article-001. Essa duplicata gera o SQLSTATE 23505, documentado como unique_violation em Tratamento de Falhas de Serialização. O rollback do lote também remove a inserção anterior; a asserção verifica que article-003 está ausente.

Os caminhos de controle de sucesso, erro durante o trabalho e erro durante o rollback do auxiliar de transação foram verificados com um cliente simulado determinístico. O exemplo de banco de dados não foi executado aqui porque o ambiente não tinha o pacote pg nem uma conexão de teste com PostgreSQL; o resultado acima descreve o que se espera.

Como as funções auxiliares devem compartilhar o cliente da transação?

Use parâmetros de cliente explícitos em toda a cadeia de chamadas. No programa, main() adquire o cliente, inTransaction() passa o mesmo objeto para sua função de callback e insertBatch() o encaminha a insertArticle(). Nenhum dos dois auxiliares de inserção obtém uma conexão do pool.

Nesta implementação, deixe claras as responsabilidades:

Função Responsabilidade
main() Adquirir o cliente, realizar a configuração da demonstração, liberar o cliente e fechar o pool do script.
inTransaction() Iniciar e concluir uma transação no cliente fornecido.
insertBatch() Aguardar cada operação de artigo e encaminhar o cliente fornecido.
insertArticle() Executar a inserção por meio do cliente fornecido.

O contrato do auxiliar é deliberadamente restrito: chame inTransaction() somente quando esse cliente não tiver uma transação ativa e deixe que as falhas se propaguem para fora da função de callback. Chamar BEGIN dentro de uma transação PostgreSQL existente não cria uma transação aninhada independente; o PostgreSQL documenta os pontos de salvamento como mecanismo para trabalho aninhado. Consulte BEGIN.

Aguarde todas as operações que pertencem à transação antes de retornar da função de callback. Neste exemplo, o loop sequencial torna explícito o ponto em que a execução termina. Iniciar inserções assíncronas sem retornar ou aguardar suas promessas faria a função de callback terminar antes de receber todos os resultados.

O que deve acontecer quando uma consulta ou um rollback falha?

Uma falha comum de consulta chega ao bloco catch do auxiliar de transação, que tenta fazer rollback no mesmo cliente e relança o erro original. A duplicata deliberada só é aceita depois que esse rollback termina. Se o rollback falhar, o auxiliar lança um AggregateError; a verificação do erro de duplicata não o aceita, então a execução chega ao manipulador externo de falhas.

O exemplo, por precaução, destrói o cliente após qualquer falha inesperada usando client.release(true). No fluxo normal, ele devolve o cliente com client.release(false). O argumento de destruição está documentado na API Pool.

Sempre libere um cliente adquirido com sucesso. Caso contrário, chamadas posteriores poderão ficar aguardando uma conexão que nunca ficará disponível. Feche o pool depois que este script independente terminar; em um aplicativo, o encerramento do pool deve ocorrer durante o encerramento do aplicativo. O guia de Pooling explica os requisitos de liberação e como pool.end() aguarda o retorno dos clientes retirados do pool.

Um único cliente garante um snapshot estável?

A continuidade do cliente e o nível de isolamento respondem a perguntas diferentes. O cliente determina em qual transação suas instruções serão executadas. O nível de isolamento determina o que essas instruções podem observar enquanto outras transações estão em execução. A documentação de Isolamento de Transações do PostgreSQL define esses limites:

Nível de isolamento Limite relevante
READ COMMITTED O padrão. Leituras comuns sucessivas podem ver dados diferentes que foram confirmados simultaneamente por outras transações. Cada instrução recebe um novo snapshot.
REPEATABLE READ Usa um snapshot estabelecido na primeira instrução que não seja de controle de transação. O PostgreSQL impede leituras fantasma, mas ainda podem ocorrer anomalias de serialização.
SERIALIZABLE Transações serializáveis confirmadas se comportam de forma compatível com alguma execução serial; uma transação pode falhar e exigir uma nova tentativa.
READ UNCOMMITTED No PostgreSQL, comporta-se como READ COMMITTED.

As leituras também podem ver alterações feitas anteriormente pela própria transação. Portanto, observar a própria inserção não significa que todas as leituras posteriores tenham uma visão congelada das alterações de outras transações. Consulte Isolamento de Transações.

O exemplo seleciona explicitamente READ COMMITTED na instrução inicial. Para usar outro nível, altere essa instrução antes de executar a transação. O PostgreSQL permite definir características da transação em BEGIN; se, em vez disso, você usar SET TRANSACTION, o isolamento deve ser definido antes da primeira consulta ou instrução de modificação de dados. Consulte BEGIN e SET TRANSACTION.

Se você escolher REPEATABLE READ ou SERIALIZABLE, prepare-se para o SQLSTATE 40001. Repita a transação inteira, incluindo a lógica que escolhe as instruções e os valores, em vez de repetir somente a consulta que falhou. O exemplo propaga esses erros deliberadamente, sem implementar novas tentativas. O Tratamento de Falhas de Serialização do PostgreSQL explica esse requisito e observa que as novas tentativas também podem falhar.

Como encontrar uma consulta que escapou da transação?

Rastreie um lote planejado desde a obtenção da conexão até o commit ou rollback final. Use esta sequência para revisar seu código:

  1. Localize a chamada pool.connect() e identifique o cliente que ela retorna.
  2. Siga cada função auxiliar chamada entre BEGIN e a conclusão da transação. Confirme que cada uma recebe e usa esse cliente.
  3. Inspecione os wrappers de consulta compartilhados em busca de chamadas ocultas a pool.query() ou de obtenções adicionais de conexão.
  4. Confirme que todas as operações são aguardadas e que as funções auxiliares propagam as falhas.
  5. Adapte o teste sintético de linha duplicada ao seu lote e verifique que uma inserção anterior desaparece depois que uma instrução posterior falha.

Um teste de rollback bem-sucedido fornece evidências úteis para o caminho exercitado, mas não comprova que todos os caminhos do aplicativo usam o cliente correto. Revise também as funções auxiliares condicionais. Se as consultas permanecerem no mesmo cliente e ainda assim leituras repetidas produzirem resultados diferentes, compare esse comportamento com o nível de isolamento selecionado antes de alterar o tratamento de conexões.

Mantenha visíveis a obtenção e a liberação, passe o cliente explicitamente e só conclua a transação depois que todas as operações pretendidas terminarem. Assim, você terá uma estrutura concreta para inspecionar e um teste de rollback para repetir à medida que o fluxo de trabalho com artigos crescer.

Fontes