PostgreSQLでは、2回目のBEGINを送ってもネストしたトランザクションは作成されません。警告を出して現在のトランザクションを維持し、最初に成功したCOMMITでそのトランザクションを終了します。ヘルパー関数で一部だけロールバックする必要がある場合は、セーブポイントを使います。PostgreSQL:BEGIN

このガイドでは、Mac Terminalで作業する開発者が、記事編集処理をヘルパー関数が早まってコミットしてしまう問題を診断する方法を説明します。例では架空の記事行を使い、ヘルパー関数が自身のバッチを取り消せるようにしながら、トランザクションの所有者を一つに保つ方法を示します。現行のPostgreSQLおよびnode-postgresのドキュメントに沿っています。

別のトランザクション内にBEGINとCOMMITがあるとどうなるか?

SQLのトランザクションコマンドは、接続上の現在のトランザクションに対して動作します。PostgreSQLは、繰り返されたBEGIN呼び出しを数えて、対応するCOMMIT呼び出しを待つことはありません。2回目のBEGINを実行してもトランザクションは変わらず、通常のCOMMITが成功すると、現在のトランザクションがコミットされます。その後、通常のCOMMITをもう一度実行すると、開いているトランザクションがないため警告が出ます。PostgreSQL:BEGIN、PostgreSQL:COMMIT

正常なトランザクションの境界は次のようになります。

1つの接続で送信するコマンド その結果の境界
最初のBEGIN トランザクションを開始する
最初の記事の挿入 そのトランザクションに属する
2回目のBEGIN 警告が出る。内部境界は作成されない
2つ目の記事の挿入 同じトランザクションに属する
最初のCOMMIT 両方の挿入をコミットして、トランザクションを終了する
別途送信した次のステートメント 通常の自動コミット動作により、独自のトランザクションで実行される
後から実行するROLLBACK すでにコミットされた挿入を取り消せない

これにより、よくあるアプリケーションの障害が説明できます。呼び出し元がトランザクションを開始した後、独自のBEGINとCOMMITを送信するヘルパー関数を呼び出します。ヘルパー関数のCOMMITも、呼び出し元のトランザクションを終了させてしまいます。その後、呼び出し元で例外が発生しても、元の全か無かの処理を復元できません。この結論は、PostgreSQLの文書化されたトランザクション境界に基づいています。PostgreSQL:トランザクション

Mac Terminalで早期コミットを再現する

稼働中のPostgreSQLデータベースと、接続設定が正しく機能しているpsqlコマンドが必要です。Terminalを開き、psql -Xで新しいセッションを開始します。-Xオプションは起動ファイルを読み込まないため、このデモをローカルのカスタマイズに左右されにくくできます。そのセッションで次のスクリプトを実行してください。自動コミットを明示的に有効にします。PostgreSQL:psql

このテーブルは一時テーブルで、架空の記事タイトルだけが含まれます。ON COMMIT PRESERVE ROWSを指定すると、トランザクション境界をまたいで確認できるよう行が保持されます。一時テーブルはセッション終了時にPostgreSQLによって削除されます。これはセッション内のロールバック動作を示すもので、切断後もデータが永続化されることを示すものではありません。PostgreSQL:CREATE TABLE

\set AUTOCOMMIT on

CREATE TEMP TABLE article_boundary_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_boundary_demo VALUES (1, 'Opening draft');

-- Deliberately incorrect attempt to open an inner transaction.
BEGIN;
INSERT INTO article_boundary_demo VALUES (2, 'Middle draft');
COMMIT;

-- The previous COMMIT has already ended the transaction.
INSERT INTO article_boundary_demo VALUES (3, 'Closing draft');
ROLLBACK;

SELECT id, title FROM article_boundary_demo ORDER BY id;

期待される結果には3つすべての行が含まれます。2回目のBEGINでは、トランザクションがすでに進行中であることを示す警告が出ます。COMMITによって、行1と2がコミットされ、その後、行3は自動コミットでコミットされます。最後のROLLBACKで取り消せる開いたトランザクションはありません。これらは文書化された動作から導かれる期待結果であり、データベースで実行した結果の報告ではありません。PostgreSQL:BEGIN、PostgreSQL:トランザクション

ここに示したとおり、この再現は対話型のpsqlで行ってください。クライアントからの送信方法が重要です。複数のSQLステートメントを1つのpsql -c文字列で渡すと、明示的なトランザクションコマンドで区切られていない限り、1つの暗黙的なトランザクションを共有する場合があります。個別に実行する場合は、別々のコマンドか標準入力を使うようドキュメントで推奨されています。PostgreSQL:psql

セーブポイントは外側のトランザクションをどう維持するか?

セーブポイントは、既存のトランザクション内の位置を記録します。ROLLBACK TO SAVEPOINTはその位置より後の変更を破棄し、それ以前の作業は保持します。RELEASE SAVEPOINTは目印を削除し、残った変更を包含するトランザクション内に保持します。変更を個別にコミットするものではありません。PostgreSQL:SAVEPOINT、PostgreSQL:RELEASE SAVEPOINT

同じセッションか、別の新しいpsqlセッションで、独自の一時テーブルを作成する次の完全な例を実行してください。

CREATE TEMP TABLE article_savepoint_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_savepoint_demo VALUES (1, 'Opening draft');

SAVEPOINT optional_article;
INSERT INTO article_savepoint_demo VALUES (2, 'Optional draft');
ROLLBACK TO SAVEPOINT optional_article;
RELEASE SAVEPOINT optional_article;

INSERT INTO article_savepoint_demo VALUES (3, 'Closing draft');
COMMIT;

SELECT id, title FROM article_savepoint_demo ORDER BY id;

期待される行は1と3です。行2はセーブポイントで破棄されますが、最初と最後の行は最後のCOMMITまで1つのトランザクションに属します。最後のCOMMITをROLLBACKに置き換えると、残っている両方の挿入も破棄されます。PostgreSQL:SAVEPOINT、PostgreSQL:トランザクション

ヘルパー関数を組み合わせる場合は、セーブポイントの有効期間も重要です。セーブポイントまでロールバックしても、その名前のセーブポイントは引き続き使用できますが、後に作成されたセーブポイントは破棄されます。セーブポイントを解放すると、そのセーブポイントと、後から作成されたすべての有効なセーブポイントが削除されます。この例では、復旧後に目印を明示的に解放しています。PostgreSQL:ROLLBACK TO SAVEPOINT、PostgreSQL:RELEASE SAVEPOINT

node-postgresのヘルパー関数はトランザクションをどう共有するか?

node-postgresでは、トランザクション内のすべてのステートメントに同じクライアントインスタンスを使う必要があり、自動的なトランザクションのネスト機能はありません。呼び出し元のクライアントをヘルパー関数に渡してください。アプリケーションでプールを使う場合は、pool.connect()でクライアントを取得し、そのクライアントを一貫して使った後に解放します。pool.query()では、連続したクエリが異なるクライアントに振り分けられることがあります。node-postgres:トランザクション、node-postgres:プール

次の完全なプログラムでは、1つの直接接続されたClientを使います。呼び出し元がBEGIN、COMMIT、および全体のROLLBACKを所有します。ヘルパー関数は、任意の2行バッチの前後にセーブポイントを設定します。2回目の挿入では、意図的に記事ID1を重複させ、一意性違反を発生させます。エラー処理では、先に成功した挿入も含めて任意のバッチ全体を破棄します。

完全な例を設定して実行する

npmをインストール済みの、現行のサポート対象Node.jsバージョンを使います。作業フォルダーでnpm install pgを実行し、次のプログラムをarticle-transactions.mjsとして保存します。設定されていれば、既存のPostgreSQL接続環境(PGHOST、PGPORT、PGDATABASE、およびPGUSERを含む)を使います。例を実行する前に、それらの設定でデータベースに接続できる必要があります。node-postgres:ようこそ、node-postgres:接続

import pg from 'pg'
const { Client } = pg

async function addOptionalArticles(client) {
  await client.query('SAVEPOINT optional_articles')
  try {
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [2, 'Optional draft']
    )
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [1, 'Duplicate draft']
    )
  } catch (error) {
    if (error.code !== '23505') throw error
    await client.query('ROLLBACK TO SAVEPOINT optional_articles')
    await client.query('RELEASE SAVEPOINT optional_articles')
    return false
  }
  await client.query('RELEASE SAVEPOINT optional_articles')
  return true
}

async function main() {
  const client = new Client({ connectionTimeoutMillis: 5000 })
  await client.connect()
  try {
    await client.query(`
      CREATE TEMP TABLE article_demo (
        id integer PRIMARY KEY,
        title text NOT NULL
      ) ON COMMIT PRESERVE ROWS
    `)
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED')
    try {
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [1, 'Opening draft']
      )
      const optionalKept = await addOptionalArticles(client)
      console.log('Optional batch kept:', optionalKept)
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [3, 'Closing draft']
      )
      await client.query('COMMIT')
    } catch (error) {
      try {
        await client.query('ROLLBACK')
      } catch (rollbackError) {
        throw new AggregateError(
          [error, rollbackError], 'Transaction and rollback failed'
        )
      }
      throw error
    }
    const result = await client.query(
      'SELECT id, title FROM article_demo ORDER BY id'
    )
    console.table(result.rows)
  } finally {
    await client.end()
  }
}

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

node article-transactions.mjsを実行します。期待される出力はOptional batch kept: falseと表示され、記事ID1と3が示されます。値には、文書化されたquery(text, values)パラメーターインターフェースを使います。JavaScriptの構文はNode.jsでチェック済みです。データベースのシナリオはドキュメントに照らして確認しましたが、ここでは実行していません。node-postgres:クエリ

明示的に許容した失敗だけから復旧する

PostgreSQLでは23505がunique_violationに割り当てられており、メッセージ本文ではなくエラーコードを確認することが推奨されています。ここでは、そのコードを許容するルールは任意の架空のバッチに対する説明用の例です。それ以外のエラーは呼び出し元に伝播し、呼び出し元がトランザクション全体のロールバックを試みます。ヘルパー関数は明示的な結果を返すため、破棄された任意の作業を呼び出し元が把握できます。PostgreSQL:PostgreSQLのエラーコード

JavaScriptの例外を捕捉しただけでは、失敗したPostgreSQLのトランザクションは復元されません。復旧には、事前に設定したセーブポイントまでロールバックするか、トランザクション全体をロールバックする必要があります。特に、中断されたトランザクションは、セーブポイントを解放するだけでは復旧できません。PostgreSQL:トランザクション、PostgreSQL:RELEASE SAVEPOINT

このヘルパー関数は、有効なトランザクションがあり、このクライアントを逐次的に使うことを前提としています。汎用的な再帰型トランザクションラッパーではありません。再利用可能なネスト機能を作るには、所有権とセーブポイント名の管理も明示的に行う必要があります。

境界を直した後にも残る分離レベルの制約は何か?

セーブポイントを使うと、現在のトランザクション内で一部だけロールバックできますが、独立した分離ポリシーが設定されるわけではありません。分離レベルは外側の境界で選択してください。PostgreSQLでは、トランザクション内で最初のクエリまたはデータ変更ステートメントを実行した後に分離レベルを変更できないため、ヘルパー関数が別のBEGINを送信して、すでに使われたトランザクションの分離レベルを引き上げることはできません。PostgreSQL:SAVEPOINT、PostgreSQL:SET TRANSACTION

通常のクエリでは、次の違いを把握してください。

分離レベル 関連する制約
READ COMMITTED 連続するクエリで、他のトランザクションが新たにコミットした変更が見えることがあります。1つのトランザクション内でも、スナップショットが固定されるとは限りません。
REPEATABLE READ クエリは、最初のトランザクション制御以外のステートメントで確立されたスナップショットを共有し、それ以前の自身の変更も確認できます。シリアライゼーションエラーが発生する可能性は残ります。
SERIALIZABLE 正常にコミットされたトランザクションは、何らかの直列順序に沿った効果を持ちます。それでもアプリケーションは、シリアライゼーションエラーに対処する必要があります。
READ UNCOMMITTED PostgreSQLではREAD COMMITTEDとして扱われます。

PostgreSQL:トランザクション分離、PostgreSQL:SET TRANSACTION

シリアライゼーションエラーが発生した場合は、そのSQLを実行する判断も含め、トランザクション全体を再試行する必要があります。ヘルパー関数のセーブポイント内の処理だけを再試行しても、外側のトランザクションのスナップショットは更新されません。PostgreSQLでは、シリアライゼーションエラーにSQLSTATE 40001が割り当てられています。このデモプログラムはそのエラーを伝播させ、自動再試行は実装していません。PostgreSQL:トランザクション分離

適切なヘルパー関数の境界をどう選ぶか?

呼び出し元と記事関連のヘルパー関数を確認するときは、次の手順で判断してください。

  1. すべての処理をまとめて成功させる必要がある場合: 呼び出し元がトランザクションを所有し、クエリだけを実行するヘルパー関数にクライアントを渡します。
  2. 呼び出し元が処理を続ける間に、1つのバッチを破棄できる場合: そのバッチの前にセーブポイントを設定し、許容した失敗だけから復旧して、結果を明示的に返します。
  3. バッチを独立してコミットする必要がある場合: 別のクライアントとトランザクションを使います。そのコミットは外側のロールバック後も保持されるため、元の全か無かの処理要件は満たせません。
  4. 並行実行によってシリアライゼーションエラーが発生した場合: ヘルパー関数を独立した再試行境界として扱わず、トランザクション全体をやり直します。

これらの選択は、PostgreSQLのセーブポイントの動作と、1つのトランザクションを同一のクライアントで処理しなければならないというnode-postgresの要件に沿っています。PostgreSQL:SAVEPOINT、node-postgres:トランザクション、PostgreSQL:トランザクション分離

ここで示した記事編集処理では、トランザクションの所有者を1つにし、呼び出しチェーンを通じて1つのクライアントを渡し、任意の処理にはセーブポイントを設定してください。すべてのヘルパー関数のトランザクションコマンドを確認しましょう。予期しないCOMMITは接続全体の境界を変えます。一方、セーブポイントを解放した後も、最終結果の責任は呼び出し元にあります。

参考資料