Per mantenere unita una transazione node-postgres, acquisisci un client con pool.connect(), esegui ogni istruzione tramite quel client e rilascialo dopo il commit o il rollback. Passa quel client anche alle funzioni helper. La guida ufficiale alle transazioni richiede questo schema.

Questo tutorial è rivolto a uno sviluppatore che usa Terminal su un Mac per fare in modo che più modifiche alle righe di articoli vengano applicate o annullate tutte insieme. Include un programma completo con righe di articoli sintetiche, un test di rollback intenzionale e una sequenza diagnostica per le query nascoste all’interno di funzioni helper.

Perché pool.query() interrompe l’ambito della transazione?

Un pool gestisce le connessioni, ma non fa sì che ogni query appartenga a un’unica transazione. Ogni chiamata a pool.query() acquisisce internamente un client disponibile. Di conseguenza, non è garantito che le chiamate consecutive usino la stessa connessione. La API Pool di node-postgres sconsiglia esplicitamente di usare questo metodo per le transazioni.

Per esempio, inviare BEGIN tramite il pool e poi inviare un’istruzione di inserimento tramite il pool non garantisce che tale inserimento rientri nella transazione avviata. In genere, PostgreSQL esegue le istruzioni al di fuori di un blocco di transazione esplicito all’interno di transazioni proprie. Un rollback successivo su un’altra connessione non può annullare queste modifiche già confermate in modo indipendente. Consulta la documentazione di BEGIN di PostgreSQL.

Il riferimento utile è l’oggetto client acquisito dal pool. Il nome di una funzione helper, il modulo che la contiene e il fatto che il chiamante ne attenda il completamento non determinano l’appartenenza alla transazione. Una funzione helper partecipa quando le sue query usano il client che ha avviato la transazione. Questo deriva direttamente dal requisito di usare lo stesso client indicato nella guida alle transazioni.

Esegui un esempio completo di commit e rollback sul tuo Mac

Ti servono Node.js con npm, un server PostgreSQL in esecuzione e un database di sviluppo locale già esistente. Questa procedura presuppone che il database si chiami article_demo e che le normali impostazioni di connessione locale funzionino già. L’installazione del database non rientra in questa attività sulle transazioni.

In Terminal, crea una nuova directory di lavoro e installa il driver:

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

Salva il programma completo seguente come transaction-demo.mjs. La sua tabella temporanea contiene solo ID e titoli di articoli sintetici. In genere, le tabelle temporanee di PostgreSQL sopravvivono ai commit e vengono rimosse al termine della sessione; queste proprietà consentono al programma di esaminare un batch riuscito dopo il rollback di un secondo batch. Consulta CREATE TABLE.

La funzione helper per le transazioni riportata di seguito segue la sequenza documentata BEGIN, COMMIT e ROLLBACK. Gestisce esplicitamente anche un eventuale errore del rollback, preservando entrambi gli errori invece di sostituire quello originale. I valori delle query usano l’API che accetta testo e valori, documentata nella sezione Queries.

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;
});

Eseguilo dalla stessa directory:

PGDATABASE=article_demo node transaction-demo.mjs

Il pool legge le variabili d’ambiente di connessione a PostgreSQL. Se la tua configurazione locale richiede impostazioni di connessione diverse, specifica i valori appropriati per PGHOST e PGPORT insieme a PGDATABASE. La guida ufficiale alla connessione descrive queste variabili e i relativi valori predefiniti.

Se l’esecuzione riesce, aspettati il messaggio Commit and rollback checks passed. seguito dalle due righe di articoli originali. Il secondo batch inserisce article-003 e poi tenta di riutilizzare article-001. Il duplicato genera lo SQLSTATE 23505, documentato come unique_violation in Gestione degli errori di serializzazione. Il rollback del batch rimuove anche il precedente inserimento; l’asserzione verifica che article-003 non sia presente.

I percorsi di controllo relativi a esito positivo, errore durante l’operazione ed errore del rollback nella funzione helper per le transazioni sono stati verificati con un client simulato deterministico. L’esempio con il database non è stato eseguito qui perché nell’ambiente mancavano il pacchetto pg e una connessione di test a PostgreSQL; il risultato riportato sopra descrive l’esito atteso.

Come dovrebbero condividere il client della transazione le funzioni helper?

Usa parametri client espliciti lungo tutta la catena di chiamate. Nel programma, main() acquisisce il client, inTransaction() passa lo stesso oggetto alla propria callback e insertBatch() lo inoltra a insertArticle(). Nessuna delle due funzioni helper di inserimento acquisisce una connessione.

Per questa implementazione, assegna responsabilità chiare:

Funzione Responsabilità
main() Acquisire il client, eseguire la configurazione di esempio, rilasciare il client e chiudere il pool dello script.
inTransaction() Avviare e concludere una transazione sul client fornito.
insertBatch() Attendere ogni operazione sull’articolo e inoltrare il client fornito.
insertArticle() Eseguire l’inserimento tramite il client fornito.

Il contratto della funzione helper è volutamente circoscritto: chiama inTransaction() solo quando il client non ha transazioni attive e lascia che gli errori si propaghino fuori dalla callback. Chiamare BEGIN all’interno di una transazione PostgreSQL esistente non crea una transazione annidata indipendente; PostgreSQL documenta i savepoint come meccanismo per il lavoro annidato. Consulta BEGIN.

Attendi ogni operazione appartenente alla transazione prima di uscire dalla sua callback. In questo esempio, il ciclo sequenziale rende esplicito il momento in cui tutte le operazioni sono concluse. Avviare inserimenti asincroni senza restituirne o attenderne le promise farebbe terminare la callback prima che ne abbia ricevuto tutti i risultati.

Cosa dovrebbe succedere se una query o il rollback non riescono?

Un errore ordinario di una query raggiunge il blocco catch della funzione helper per le transazioni, che tenta il rollback sullo stesso client e rilancia l’errore originale. Il duplicato intenzionale viene accettato solo dopo il completamento del rollback. Se il rollback fallisce, la funzione helper genera un AggregateError; il controllo dell’errore di duplicazione non lo accetta, quindi l’esecuzione raggiunge il gestore degli errori esterno.

In caso di errore imprevisto, l’esempio elimina prudentemente il client usando client.release(true). Nel percorso normale, restituisce il client con client.release(false). L’argomento per eliminarlo è documentato nella API Pool.

Rilascia sempre un client acquisito correttamente. In caso contrario, le chiamate successive potrebbero restare in attesa di una connessione che non diventerà mai disponibile. Chiudi il pool al termine di questo script autonomo; in un’applicazione, la chiusura del pool va gestita durante lo spegnimento dell’applicazione. La guida al pooling spiega i requisiti di rilascio e come pool.end() attende che i client acquisiti vengano restituiti.

Un singolo client garantisce uno snapshot stabile?

La continuità del client e il livello di isolamento rispondono a domande diverse. Il client determina a quale transazione vengono assegnate le istruzioni. Il livello di isolamento determina cosa possono osservare tali istruzioni mentre sono in esecuzione altre transazioni. La documentazione di PostgreSQL sull’isolamento delle transazioni definisce questi limiti:

Livello di isolamento Limite rilevante
READ COMMITTED Il livello predefinito. Letture ordinarie successive possono vedere dati diversi confermati da transazioni concorrenti. Ogni istruzione riceve un nuovo snapshot.
REPEATABLE READ Usa uno snapshot stabilito alla prima istruzione che non sia di controllo della transazione. PostgreSQL impedisce le letture fantasma, ma restano possibili anomalie di serializzazione.
SERIALIZABLE Le transazioni serializzabili confermate si comportano in modo coerente con una qualche esecuzione seriale; una transazione può non riuscire e dover essere ritentata.
READ UNCOMMITTED In PostgreSQL si comporta come READ COMMITTED.

Le letture possono anche vedere le modifiche apportate in precedenza dalla propria transazione. Di conseguenza, osservare il proprio inserimento non significa che tutte le letture successive abbiano una vista immutabile delle modifiche apportate da altre transazioni. Consulta Isolamento delle transazioni.

L’esempio seleziona esplicitamente READ COMMITTED nell’istruzione iniziale. Per usare un livello diverso, modifica quell’istruzione prima di eseguire la transazione. PostgreSQL consente di specificare le caratteristiche della transazione in BEGIN; se invece usi SET TRANSACTION, il livello di isolamento deve essere impostato prima della prima query o istruzione che modifica i dati. Consulta BEGIN e SET TRANSACTION.

Se scegli REPEATABLE READ o SERIALIZABLE, preparati a gestire lo SQLSTATE 40001. Ritenta l’intera transazione, inclusa la logica che sceglie le istruzioni e i valori, invece di ripetere solo la query non riuscita. L’esempio propaga intenzionalmente questi errori anziché implementare dei tentativi. Gestione degli errori di serializzazione di PostgreSQL spiega questo requisito e segnala che anche i nuovi tentativi possono fallire.

Come puoi trovare una query che è rimasta fuori dalla transazione?

Traccia un batch previsto dal momento in cui viene acquisita la connessione fino al commit o rollback finale. Usa questa sequenza per rivedere il codice:

  1. Individua la chiamata a pool.connect() e identifica il client che restituisce.
  2. Esamina ogni funzione helper chiamata tra BEGIN e il completamento della transazione. Verifica che ciascuna riceva e usi quel client.
  3. Esamina i wrapper di query condivisi per individuare chiamate nascoste a pool.query() o acquisizioni di connessioni aggiuntive.
  4. Verifica che ogni operazione venga attesa e che le funzioni helper propaghino gli errori.
  5. Adatta il test sintetico delle righe duplicate al tuo batch e verifica che un inserimento precedente scompaia dopo il fallimento di un’istruzione successiva.

Un test di rollback superato fornisce indicazioni utili per il percorso verificato, ma non dimostra che ogni ramo dell’applicazione usi il client giusto. Esamina anche le funzioni helper condizionali. Se le query restano sullo stesso client ma le letture ripetute continuano a differire, confronta questo comportamento con il livello di isolamento scelto prima di modificare la gestione delle connessioni.

Rendi visibili l’acquisizione e il rilascio, passa il client in modo esplicito e fai dipendere il completamento della transazione dalla conclusione di tutte le operazioni previste. Avrai così una struttura concreta da esaminare e un test di rollback da ripetere man mano che il flusso di lavoro sugli articoli si amplia.

Fonti