Firebase Admin SDK における Cloud Firestore トランザクション挙動

はじめに

「DB と言えば MySQL」で育ってきた身としては、「Cloud Firestore サーバークライアントライブラリでのデータ競合は悲観的な同時実行制御で解決しているよ。」と言われれば、ああ、あんな感じね。と勝手に想像しがちなわけですが、排他処理の実挙動はデータを扱う上での肝であり、実装の途中で不安になってきたので、実際に挙動を確かめてみることにしました。

前提

利用 SDK

サーバークライアントライブラリでのデータ競合にフォーカスし、Firebase Admin Node.js SDK を使います。

確認挙動

今回はトランザクション内で単一ドキュメントに対して、参照後に更新を行うシンプルな処理を扱います。確認ポイントとしては下記です。

  • ドキュメントの有無が及ぼす影響
  • 参照時にドキュメント参照を渡すか、クエリを渡すかで違いはあるのか
  • 実処理ステップ(リトライ有無含め)

検証

Jest でテストコードを書いて検証しました。

テストコード

import * as admin from 'firebase-admin';
import * as serviceAccount from './service-account.json';

const TEST_COLLECTION_PATH = 'test';
type DocumentData = {
  id: string;
  number: number;
};

jest.setTimeout(10000);

beforeAll(() => {
  admin.initializeApp({
    credential: admin.credential.cert({
      projectId: serviceAccount.project_id,
      clientEmail: serviceAccount.client_email,
      privateKey: serviceAccount.private_key
    })
  });
});

beforeEach(() => {
  const collection = admin.firestore().collection(TEST_COLLECTION_PATH);
  return admin.firestore().runTransaction(async (transaction) => {
    transaction.delete(collection.doc('id0'));
    transaction.create(collection.doc('id1'), { id: 'id1', number: 1 });
    transaction.create(collection.doc('id2'), { id: 'id2', number: 2 });
    transaction.create(collection.doc('id3'), { id: 'id3', number: 3 });
  });
});

afterEach(() => {
  const collection = admin.firestore().collection(TEST_COLLECTION_PATH);
  return admin.firestore().runTransaction(async (transaction) => {
    transaction.delete(collection.doc('id1'));
    transaction.delete(collection.doc('id2'));
    transaction.delete(collection.doc('id3'));
  });
});

describe('ドキュメント参照でロック', () => {
  test.each([
    ['ドキュメント無し', 'id0', undefined],
    ['ドキュメント有り', 'id1', 1]
  ])('トランザクション内で参照後に更新。(%s)', async (_, id, firstDocValue) => {
    const collection = admin.firestore().collection(TEST_COLLECTION_PATH);
    const steps: string[] = [];
    const transaction1 =  admin.firestore().runTransaction(async (transaction) => {
      steps.push('[トランザクション1]transaction.get() 呼び出し前');
      const docSnapshot = await transaction.get(collection.doc(id));
      const docData = docSnapshot.exists ? docSnapshot.data() as DocumentData : undefined;
      steps.push(`[トランザクション1]transaction.get() 呼び出し結果: ${docData?.number}`);
  
      await new Promise(resolve => setTimeout(resolve, 1000));
  
      steps.push('[トランザクション1]transaction.set() 呼び出し前');
      transaction.set(docSnapshot.ref, { number: 11 }, { merge: true });
      steps.push('[トランザクション1]transaction.set() 呼び出し後');
    });
    const transaction2 =  admin.firestore().runTransaction(async (transaction) => {
      await new Promise(resolve => setTimeout(resolve, 100));
  
      steps.push('[トランザクション2]transaction.get() 呼び出し前');
      const docSnapshot = await transaction.get(collection.doc(id));
      const docData = docSnapshot.exists ? docSnapshot.data() as DocumentData : undefined;
      steps.push(`[トランザクション2]transaction.get() 呼び出し結果: ${docData?.number}`);
  
      steps.push('[トランザクション2]transaction.set() 呼び出し前');
      transaction.set(docSnapshot.ref, { number: 12 }, { merge: true });
      steps.push('[トランザクション2]transaction.set() 呼び出し後');
    });
  
    await Promise.all([transaction1, transaction2]);
  
    const latestDocSnapshot1 = await collection.doc(id).get();
    const latestDocData = latestDocSnapshot1.data() as DocumentData;
  
    expect(latestDocData.number).toBe(12);
    expect(steps).toStrictEqual([
      '[トランザクション1]transaction.get() 呼び出し前',
      `[トランザクション1]transaction.get() 呼び出し結果: ${firstDocValue}`, // 先にトランザクション1が参照。
      '[トランザクション2]transaction.get() 呼び出し前',
      `[トランザクション2]transaction.get() 呼び出し結果: ${firstDocValue}`, // 次にトランザクション2が参照。まだ排他処理は行われない。
      '[トランザクション2]transaction.set() 呼び出し前',
      '[トランザクション2]transaction.set() 呼び出し後', // 後発であるトランザクション2の更新が完了・・・に見えるが、先に開始されたトランザクション1と競合と判定されて失敗。トランザクション1待ちへ。
      '[トランザクション1]transaction.set() 呼び出し前',
      '[トランザクション1]transaction.set() 呼び出し後', // 先発であるトランザクション1の更新が完了。
      '[トランザクション2]transaction.get() 呼び出し前', // トランザクション2のリトライ開始。
      `[トランザクション2]transaction.get() 呼び出し結果: 11`, // 先発であるトランザクション1の更新結果を取得できている。
      '[トランザクション2]transaction.set() 呼び出し前',
      '[トランザクション2]transaction.set() 呼び出し後'
    ]);
  });  
});

describe.each([
  ['ドキュメント参照を FieldPath.documentId() 指定で無理矢理クエリ化', (collection: FirebaseFirestore.CollectionReference<FirebaseFirestore.DocumentData>, id: string) => collection.where(admin.firestore.FieldPath.documentId(), '==', id)],
  ['フィールドに格納されている id でクエリ', (collection: FirebaseFirestore.CollectionReference<FirebaseFirestore.DocumentData>, id: string) => collection.where('id', '==', id)]
])('クエリでロック %s', (_, getQuery) => {
  test.each([
    ['ドキュメント無し', 'id0', undefined],
    ['ドキュメント有り', 'id1', 1]
  ])('トランザクション内で参照後に更新。(%s)', async (__, id, firstDocValue) => {
    const collection = admin.firestore().collection(TEST_COLLECTION_PATH);
    const steps: string[] = [];
    const transaction1 =  admin.firestore().runTransaction(async (transaction) => {
      steps.push('[トランザクション1]transaction.get() 呼び出し前');
      const querySnapshot = await transaction.get(getQuery(collection, id));
      const docData = querySnapshot.docs.length > 0 ? querySnapshot.docs[0].data() as DocumentData : undefined;
      steps.push(`[トランザクション1]transaction.get() 呼び出し結果: ${docData?.number}`);
  
      await new Promise(resolve => setTimeout(resolve, 1000));
  
      steps.push('[トランザクション1]transaction.set() 呼び出し前');
      transaction.set(collection.doc(id), { number: 11 }, { merge: true });
      steps.push('[トランザクション1]transaction.set() 呼び出し後');
    });
    const transaction2 =  admin.firestore().runTransaction(async (transaction) => {
      await new Promise(resolve => setTimeout(resolve, 100));
  
      steps.push('[トランザクション2]transaction.get() 呼び出し前');
      const querySnapshot = await transaction.get(getQuery(collection, id));
      const docData = querySnapshot.docs.length > 0 ? querySnapshot.docs[0].data() as DocumentData : undefined;
      steps.push(`[トランザクション2]transaction.get() 呼び出し結果: ${docData?.number}`);
  
      steps.push('[トランザクション2]transaction.set() 呼び出し前');
      transaction.set(collection.doc(id), { number: 12 }, { merge: true });
      steps.push('[トランザクション2]transaction.set() 呼び出し後');
    });
  
    await Promise.all([transaction1, transaction2]);
  
    const latestDocSnapshot1 = await collection.doc(id).get();
    const latestDocData = latestDocSnapshot1.data() as DocumentData;
  
    expect(latestDocData.number).toBe(12);
    expect(steps).toStrictEqual([
      '[トランザクション1]transaction.get() 呼び出し前',
      `[トランザクション1]transaction.get() 呼び出し結果: ${firstDocValue}`, // 先にトランザクション1が参照。
      '[トランザクション2]transaction.get() 呼び出し前',
      `[トランザクション2]transaction.get() 呼び出し結果: ${firstDocValue}`, // 次にトランザクション2が参照。まだ排他処理は行われない。
      '[トランザクション2]transaction.set() 呼び出し前',
      '[トランザクション2]transaction.set() 呼び出し後', // 後発であるトランザクション2の更新が完了・・・に見えるが、先に開始されたトランザクション1と競合と判定されて失敗。トランザクション1待ちへ。
      '[トランザクション1]transaction.set() 呼び出し前',
      '[トランザクション1]transaction.set() 呼び出し後', // 先発であるトランザクション1の更新が完了。
      '[トランザクション2]transaction.get() 呼び出し前', // トランザクション2のリトライ開始。
      `[トランザクション2]transaction.get() 呼び出し結果: 11`, // 先発であるトランザクション1の更新結果を取得できている。
      '[トランザクション2]transaction.set() 呼び出し前',
      '[トランザクション2]transaction.set() 呼び出し後'
    ]);
  });  
});

テスト結果

> yarn test
yarn run v1.22.11
$ jest
 FAIL  src/__tests__/transaction.ts (20.405 s)
  ドキュメント参照でロック
 トランザクション内で参照後に更新。(ドキュメント無し) (3271 ms)
 トランザクション内で参照後に更新。(ドキュメント有り) (2669 ms)
  クエリでロック ドキュメント参照を FieldPath.documentId() 指定で無理矢理クエリ化
 トランザクション内で参照後に更新。(ドキュメント無し) (3517 ms)
 トランザクション内で参照後に更新。(ドキュメント有り) (2360 ms)
  クエリでロック フィールドに格納されている id でクエリ
 トランザクション内で参照後に更新。(ドキュメント無し) (1211 ms)
 トランザクション内で参照後に更新。(ドキュメント有り) (2787 ms)

 クエリでロック フィールドに格納されている id でクエリ トランザクション内で参照後に更新。(ドキュメント無し)

    expect(received).toBe(expected) // Object.is equality

    Expected: 12
    Received: 11

      134 |     const latestDocData = latestDocSnapshot1.data() as DocumentData;
      135 |
    > 136 |     expect(latestDocData.number).toBe(12);
          |                                  ^
      137 |     expect(steps).toStrictEqual([
      138 |       '[トランザクション1]transaction.get() 呼び出し前',
      139 |       `[トランザクション1]transaction.get() 呼び出し結果: ${firstDocValue}`, // 先にトランザクション1が参照。

      at src/__tests__/transaction.ts:136:34

Test Suites: 1 failed, 1 total
Tests:       1 failed, 5 passed, 6 total
Snapshots:   0 total
Time:        20.464 s
Ran all test suites.

クエリでロック フィールドに格納されている id でクエリ › トランザクション内で参照後に更新。(ドキュメント無し) が失敗しています。別途確認した所、後発のトランザクション2のリトライが行われていませんでした。どうやら transaction.get() が排他処理に対して何も寄与しなかったようです。

考察

transaction.get() の挙動

transaction.get() 自体は排他処理でなく、複数のトランザクションにおいて並列に実行され得ます。ただし、後発のトランザクションで更新を行おうとすると該当トランザクションは失敗し、その後リトライすることでトランザクションの排他処理を実現しているようです。ちなみにトランザクションのリトライ回数(maxAttempts)を1に制限してしまうとエラーで落ちてしまいます。

ドキュメントの有無が及ぼす影響

今回確認できた挙動は下記の通り。

  • ドキュメント有無に関わらず、ドキュメント参照を引数とする transaction.get() はトランザクションを排他処理にする。
  • ドキュメント有無に関わらず、FieldPath.documentId() 指定クエリを引数とする transaction.get() はトランザクションを排他処理にする。
  • フィールド指定クエリを引数とする transaction.get()ドキュメントが存在する場合に限りトランザクションを排他処理にする。

つまり、Cloud Firestore 単独で厳密な排他制御(ID やトークン等のユニーク制約実現等)を行いたい場合は、ドキュメントの存在を保証できない限り、何かしらの形でドキュメント ID を利用しなければいけない、とも言えるかと思います。

おわりに

実挙動の確認は非常に重要ですね。そして Cloud Firestore はやっぱり奥が深いですね。