Testing Postgres race conditions with synchronization barriers - Postgresの競合状態を同期バリアでテストする

テストで「消えるお金」を防ぐ:同期バリアでPostgresの書き込み競合を確実に再現する方法

要約

同時実行の書き込み競合はテストでは再現しづらいが、同期バリアを使えば「必ず同じタイミングで読む」状況を作り出せる。これによりロックやトランザクションの有無が正しく動くかを決定的に検証できる。

この記事を読むべき理由

金融系や在庫管理など、データの整合性が命の日本のプロダクトでは、レース条件が本番で致命的になる。 flaky な再試行や運任せのテストに頼らず、CIで確実に検出できる方法を知っておくべきだから。

詳細解説

// javascript / typescript
const credit = async (accountId: number, amount: number) => {
  const [row] = await db.execute(sql`SELECT balance FROM accounts WHERE id = ${accountId}`);
  const newBalance = row.balance + amount;
  await db.execute(sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`);
};
// javascript / typescript
function createBarrier(count: number) {
  let arrived = 0;
  const waiters: (() => void)[] = [];
  return async () => {
    arrived++;
    if (arrived === count) {
      waiters.forEach(resolve => resolve());
    } else {
      await new Promise<void>(resolve => waiters.push(resolve));
    }
  };
}
// javascript / typescript
async function credit(
  accountId: number,
  amount: number,
  hooks?: { onTxBegin?: () => Promise<void> | void },
) {
  await db.transaction(async (tx) => {
    if (hooks?.onTxBegin) await hooks.onTxBegin();
    const [row] = await tx.execute(sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`);
    const newBalance = row.balance + amount;
    await tx.execute(sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`);
  });
}

実践ポイント

以上を導入すれば、$100 + 50 + 50 = 200$ になるべき処理が「いつの間にか150になる」悲劇をCIで未然に防げる。