古いドキュメントの保存を拒否するには、エディターが最初に読み込んだリビジョンを更新条件に含め、その後、リビジョンをアトミックにインクリメントします。SERIALIZABLEだけでは、ドキュメントが以前のトランザクションで読み込まれている場合、その要件を表現できません。
このチュートリアルは、Terminalを使うMacの開発者を対象としています。PostgreSQLの記事エディターで、古い置換テキストを拒否する方法を説明します。合成した記事行と、node-postgresを使った完全なデモを扱います。要件は明確です。送信された下書きの基になったリビジョンが、保存先に残っている場合にのみ保存します。
SERIALIZABLEでも古い内容への置換が許可されるのはなぜですか?
PostgreSQLでは、シリアライズ可能な実行とは、コミットされたトランザクションを何らかの順序で一度に1つずつ実行した場合と同等の効果を生む実行と定義されています。この保証が対象とするのは、データベースのトランザクションとその操作です。PostgreSQL: Transaction Isolation。
次の例を考えてみましょう。
- エディターがリビジョン1の記事1を読み込み、読み取りトランザクションが終了します。
- 別の保存処理が記事のテキストを置き換え、リビジョン2をコミットします。
- 最初のエディターが、リビジョン1を基にした置換テキストを送信します。
- 新しいシリアライズ可能トランザクションが、IDだけを使って記事1を更新します。
最後のトランザクションは成功する可能性があります。トランザクションはすでに順番に実行されているためです。id = 1だけを条件とする更新には、送信されたテキストがリビジョン1を必要としていることを示すものがありません。これは、文書化されたシリアライザビリティの定義に沿った動作であり、その例外ではありません。
同時実行されるトランザクションは別です。並行する更新がスナップショットと競合する場合や、依存関係によってシリアル実行が不可能になる場合、PostgreSQLはシリアライズ可能トランザクションを拒否できます。スナップショットは、最初のトランザクション制御以外の文が実行された時点で確立されます。それより前にBEGINを発行しても、エディターが以前に読み込んだ状態が維持されるわけではありません。PostgreSQL: Transaction Isolation。
したがって、確認すべきことは次のとおりです。**保存処理は、エディターが読み込んだリビジョンをどこで明示的に要求していますか?**どこにもなければ、分離レベルを変えても不足している条件は補えません。
リビジョンチェックで保証すべきことは何ですか?
読み込んだ各下書きを、編集可能なテキストとベースリビジョンの組として扱います。保存処理では、記事ID、置換テキスト、変更していないベースリビジョンを送ります。データベースの更新では、IDとそのリビジョンの両方が一致する行を対象に、テキストを置き換え、保存済みのリビジョンを同じ文でインクリメントする必要があります。
たとえば、条件WHERE id = $1 AND revision = $2が前提条件を表し、SET body = $3, revision = revision + 1が状態の遷移を実行します。PostgreSQLは条件に合致する行を更新し、RETURNINGは実際に更新された行の値を返します。更新対象が0行でもSQLコマンド自体は成功するため、アプリケーション側で明示的に解釈する必要があります。PostgreSQL: UPDATE。
このパターンは、オプティミスティック同時実行制御または比較交換(compare-and-swap)と呼ばれることがよくあります。この記事の設計では、編集可能な記事コンテンツを変更するすべての操作でリビジョンを進めます。リビジョンをNULLにせず、リセットせず、古いリビジョントークンのまま記事IDを再利用しないでください。そうしないと、古い下書きがまだ一致していると誤って判断される可能性があります。
IDだけを使った更新の前に別途読み取りを行っても、書き込み自体に要件は含まれません。同様に、保存直前に最新のリビジョンを取得し、送信されたリビジョンの代わりに使うと、リクエストの意味が変わります。エディターがベースとして使っていないバージョンへの置換が許されてしまいます。
Macで合成データを使った完全な例を実行する
稼働中のPostgreSQLインスタンス、既存のスクラッチデータベース、現在サポートされているLTS版または現行版のNode.jsとnpmを用意します。Terminalを開き、作業フォルダーを作成して、文書化されているnpm install pgコマンドでドライバーをインストールします。node-postgres: Introduction and Install。
mkdir revision-write-demo
cd revision-write-demo
npm install pg
次の完全なプログラムを、そのフォルダー内のrevision-demo.mjsとして保存します。文書化されているESM互換のデフォルトインポートを使っています。ClientとPoolはどちらも環境変数からPostgreSQL接続設定を読み込めます。実行前に、既存のスクラッチデータベース用の設定を行ってください。node-postgres: ESM、node-postgres: Connecting。
このプログラムは、合成した2つの記事を比較します。記事1には、無条件のシリアライズ可能な保存を2回行います。記事2には、同じ元のリビジョンを使ったリビジョンチェック付きの保存を2回行います。デモを永続的な記事テーブルから分離するため、一時テーブルを使います。JavaScriptの構文はNode.jsで確認しましたが、調査環境ではデータベースのデモを実行していません。
import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();
async function saveArticle(id, expectedRevision, body) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
let active = false;
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
active = true;
const result = await client.query(
`UPDATE revision_demo_articles
SET body = $3, revision = revision + 1
WHERE id = $1 AND revision = $2
RETURNING id, body, revision`,
[id, expectedRevision, body]
);
if (result.rowCount === 0) {
await client.query('ROLLBACK');
active = false;
return { status: 'stale-or-missing' };
}
await client.query('COMMIT');
active = false;
return { status: 'saved', article: result.rows[0] };
} catch (error) {
if (active) await client.query('ROLLBACK');
if (error.code !== '40001' || attempt === 3) throw error;
}
}
}
await client.connect();
try {
await client.query(`
CREATE TEMP TABLE revision_demo_articles (
id integer PRIMARY KEY,
body text NOT NULL,
revision integer NOT NULL CHECK (revision > 0)
)
`);
await client.query(
`INSERT INTO revision_demo_articles (id, body, revision)
VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
);
const loaded = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
const expectedRevision = loaded.rows[1].revision;
// Illustrative blind saves: each is a separate transaction.
for (const body of ['First edit', 'Stale replacement']) {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
try {
await client.query(
`UPDATE revision_demo_articles
SET body = $1, revision = revision + 1 WHERE id = 1`,
[body]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
const first = await saveArticle(2, expectedRevision, 'First edit');
const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
assert.equal(first.status, 'saved');
assert.equal(first.article.revision, 2);
assert.equal(stale.status, 'stale-or-missing');
const final = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
assert.deepEqual(final.rows, [
{ id: 1, body: 'Stale replacement', revision: 3 },
{ id: 2, body: 'First edit', revision: 2 }
]);
console.table(final.rows);
console.log(stale.status);
} finally {
await client.end();
}
既存のスクラッチデータベース名がrevision_demoの場合は、次のコマンドを実行します。実際のデータベース名に置き換え、ローカルインスタンスで必要な接続環境設定はそのまま指定してください。node-postgres: Connecting。
PGDATABASE=revision_demo node revision-demo.mjs
最終的に期待される値は次のとおりです。
| 記事 | 本文 | リビジョン | 意味 |
|---|---|---|---|
| 1 | Stale replacement |
3 | 無条件の保存が両方ともコミットされた |
| 2 | First edit |
2 | 2回目のリビジョンチェック付き保存が拒否された |
出力される最終ステータスはstale-or-missingです。
この手順では、タイミングに依存する同時実行を意図的に避けています。重要な境界を直接示すためです。すべての保存トランザクションがシリアライズ可能であっても、保存処理が有効な直列順序で実行されるため、古い内容への置換が成功する可能性があります。
node-postgresでは各結果をどのように扱いますか?
このプログラムでは、ドライバーの文書化されたパラメーター化クエリAPIを使い、値をSQLテキストとは別に渡します。また、更新処理の行数を示すrowCountも確認します。空のrows配列だけでは、更新件数の確認の代わりにはなりません。node-postgres: Queries、node-postgres: pg.Result。
関数を応用する際は、次の判断表を使います。
| 結果 | アプリケーションの動作 |
|---|---|
| 1行が更新され、コミットに成功 | 保存を受け入れ、返されたリビジョンを保持する |
| 更新対象が0行 | 古いか記事が存在しないため、この保存を拒否し、送信された下書きを保持する |
SQLSTATE 40001 |
上限を定めた方針に従ってロールバックし、トランザクション全体を再試行する |
| その他のエラー、または再試行上限に到達 | 失敗を通知し、保存に成功したと報告しない |
更新対象が0行でも、記事が削除されたのか、リビジョンが変わったのかは分かりません。このデモでは、意図的に両方を同じ結果として扱っています。画面で詳細が必要なら、その後に現在の記事を読み込み、後の時点で確認した結果として表示します。失敗した更新時点の正確な状態を証明するものとして説明しないでください。
シリアライゼーション失敗にはSQLSTATE 40001が使われます。PostgreSQLでは、文や値を決めるロジックを含め、トランザクション全体を再試行する必要があります。複数回の試行が必要になることがあり、最終的に完了する保証もありません。この例では、アプリケーション方針の例として3回試行します。PostgreSQL: Serialization Failure Handling。
再試行中もexpectedRevisionは変更しないでください。これは、送信された下書きの前提条件を表します。別の保存処理でリビジョンが進んでいれば、新しいトランザクションは古いトークンを更新対象0行として拒否できます。トークンを最新のリビジョンに自動で置き換えると、古い書き込みを防げなくなります。
関数はCOMMITが完了した後にのみ成功を返します。node-postgresの要件に従い、すべてのトランザクション文で同じClientを使います。これをプールに応用する場合は、pool.connect()でクライアントを取得し、トランザクション全体でそのクライアントを使ってから解放します。node-postgres: Transactions、node-postgres: pg.Pool。
SERIALIZABLEやSELECT FOR UPDATEは引き続き必要ですか?
このように単一行を条件付きで更新する範囲では、Read Committedでもリビジョンの前提条件を満たせます。コミットされた並行更新の完了を待った後、PostgreSQLは更新済みの行に対して更新条件を再評価します。ただし、Read Committedが複雑な複数行ルールまで保証するわけではありません。PostgreSQL: Transaction Isolation。
周辺のトランザクションが、より広い範囲の読み取りと書き込みに依存し、その保証を必要とする場合は、SERIALIZABLEを使ってください。リビジョン条件はエディターの前提条件を表し、分離レベルはトランザクションのデータベース操作間の相互作用を制御します。実際の要件に応じて両方を選んでください。
SELECT FOR UPDATEは、行を調べて複数の依存操作を行うトランザクションに使えます。この行ロックは、トランザクションが終了するまで競合する書き込みをブロックします。Repeatable ReadまたはSerializableでは、トランザクションのスナップショット以降に変更された行をロックすると、エラーが発生する場合があります。PostgreSQL: Explicit Locking。
保存リクエストを受け取った時点でロックを取得しても、送信されたベースリビジョンを比較する責任はアプリケーション側に残ります。現在の行をロックしても、下書きがどの過去のリビジョンから作られたのかはデータベースには分かりません。ここで示した単一行の置換では、条件付き更新が比較と変更をすでにまとめています。
このパターンを組み込む前に、コンテンツを書き込むすべての経路を確認し、拒否時もエディターの下書きを保持し、保存成功を確認した後にのみエディターのベースリビジョンを更新してください。古い保存が拒否された場合は、保持している下書きと一緒に現在の記事を示し、読者が内容を照合して、実際に確認したリビジョンを基に新しい保存を送れるようにします。
参考資料
- 13.2. Transaction Isolation — PostgreSQL Global Development Group。
- UPDATE — PostgreSQL Global Development Group。
- 13.5. Serialization Failure Handling — PostgreSQL Global Development Group。
- 13.3. Explicit Locking — PostgreSQL Global Development Group。
- node-postgres: Introduction and Install — node-postgres。
- ESM — node-postgres。
- Connecting — node-postgres。
- Queries — node-postgres。
- pg.Result — node-postgres。
- Transactions — node-postgres。
- pg.Pool — node-postgres。