[Node.js]Firestoreのコレクションにサーバーサイドから最速でドキュメントを書き込む書き方


#Overview

Firestoreの一つのコレクションに1万件ほどデータを書き込んでいたが2分は要して遅かった。
公式ドキュメントのみではなかなか最速にたどり着けないためメモとして残しておく。

#Target reader

- Firestoreでコレクションへのサーバーサイドからへの書き込みをNode.jsを問わず高速にしたい方。

#Prerequisite

- SDKはNode.jsのものを使用するが、他の言語でも同様になると考える。
- Node.jsのバージョンはGoogle Cloud Function(GCF)に依存し、現時点ではV10系とする。
- [Firestoreのインポート](https://firebase.google.com/docs/firestore/manage-data/export-import?hl=ja)による書き込みは調査対象外です。読む限り大量のデータを書き込む用途ではなくリストア向けに見えたこと、特に速度面で有利になる記述がなかったため。


#Body

## 理論上の限界値を把握する

まずは公式ドキュメントから上限値を把握する。

https://firebase.google.com/docs/firestore/quotas?hl=ja#writes_and_transactions

> データベースあたりの最大書き込み回数/秒	10,000（最大 10 MiB/秒）
ドキュメントへの最大書き込み速度	1 秒あたり 1
コレクションへの最大書き込み速度（コレクションに含まれているドキュメントのインデックス付きフィールドには順次値が含まれている）	1 秒あたり 500
Commit オペレーションに渡すか、トランザクションで実行することができる書き込みの最大数	500

1つのコレクションに対して書き込める速度は500ドキュメント/sだと理解。
コレクションを分けても10,00ドキュメント/sだと理解。
DBが重要なプロジェクトではなかったため把握してなかったが、意外にも少ないというのが正直な感想。
これを意識して設計する必要があり、実際はこれより低いと考えておいた方が安全だろう。

## バッチ書き込み

500ドキュメント/sを目指して高速化していく。
Firestoreがバッチ書き込みできることを知っていれば、公式ドキュメントの該当ページを読むことになる。
読んでみると不可思議な記述にたどり着く。

https://firebase.google.com/docs/firestore/manage-data/transactions#batched-writes

> 注: データを一括入力するには、並列化された個別の書き込みでサーバー クライアント ライブラリを使用します。バッチ書き込みは、シリアル化された書き込みより優れたパフォーマンスを発揮しますが、並列書き込みほど優れてはいません。一括データ オペレーションには、モバイル / ウェブ SDK ではなく、サーバー クライアント ライブラリを使用する必要があります。

つまり、サーバーサイドにおいては 並列化された個別の書き込み ＞ バッチ書き込み ＞ シリアルでの書き込み ということか。
ここでつまずいたのがサーバー クライアント ライブラリでの書き込みは、専用のメソッドがあるのか、それとも`Promise.all()`による並列書き込みなのかわからなかった。
SDKを漁るがどうも専用メソッドはなく、どうやら`Promise.all()`であることは予測できたが、果たして多数の並列書き込みでバッチを超えれるのかという疑問があった。

## ぐぐる

並列書き込みのアンサーを探すのに苦労したが、stackoverflowに回答があった。

https://stackoverflow.com/questions/58897274/what-is-the-fastest-way-to-write-a-lot-of-documents-to-firestore

>```javascript
async function testParallelIndividualWrites(datas) {
  await Promise.all(datas.map((data) => collection.add(data)));
}
```

結論としてはこれが最速。だが、これでは実用的ではないので実用性を加えていく。
これを1万ドキュメントに適用すると以下のようなエラーになる。

```terminal:terminal
Error: 10 ABORTED: Too much contention on these documents. Please try again.
```

そう、コレクションは500doc/sだから1万ドキュメントも並列化したらエラーになるのは当然のこと。
FaaSのような環境ではソケットが枯渇する可能性も考えられる。

batch書き込みでも同様にエラーになる。おまけとして実際に使ったコードは以下。

```javascript
const updateDocuments = async (collectionName, documents) => {
    const batches = [];

    // Firestoreのドキュメント数の上限に抵触しないように小さく書き込んでいく
    while (documents.length) {
        const batch = db.batch();
        batches.push(batch);

        documents.splice(0, 500).forEach(
            v => batch.set(db.collection(collectionName).doc(v.id), v)
        );
    }

    // Error: 10 ABORTED: Too much contention on these documents. Please try again.
    await Promise.all(batches.map((batch) => batch.commit()));
}
```

無制限の並列化はエラーを避けられないため、何らかの方法でこれをコントロールする必要がある。

ちなみに`splice(0, 500)`の部分を`splice(0, 250)`にして、かつ後述の書き込み数コントロールのsleepを導入すると約97秒を要した。
500ドキュメントを同時実行できるようになったとしても半分の約48秒になることが予想され、理論値に程遠いと予測できる。
Firestoreの公式ドキュメントでもバッチはトランザクションと類似扱いされていることから、簡易トランザクションであり速度面では最速とはなりえさそうだ。

## 最速級コード

500doc/sにするには主に二つの選択肢がある。

- 書き込み側で500doc/sにコントロールする
- 無制限で書き込みつつ、エラーになったら書き込みタイミングをコントロールしてリトライする

書き込みが一か所からでしか行われないなら前者を採用可能。
書き込みが複数なら、後者を採用しないといけないだろう。
私のケースではバッチ処理で同時に複数からの書き込みはないため前者を採用する。

```javascript

const updateDocuments = async (collectionName, documents) => {
    let start = Date.now();

    // Firestoreのドキュメント数の上限に抵触しないように数を制限して書き込む
    while (documents.length) {
        await Promise.all(
            documents.splice(0, 500)
                .map((v) => db.collection(collectionName).doc(v.id).set(v))
        );

        // 最低1秒の間隔を保つ
        start = await sleepForProtection(1000, start);
    }
}

const sleepForProtection = async (duration, start) => {
    const sleepTime = start + duration - Date.now();
    if (sleepTime > 0) {
        console.log('sleep...');
        await sleep(sleepTime);
    }
    return Date.now();
}
```

`sleepForProtection()`により最低1秒の間隔をキープしつつ、500ドキュメントずつ書き込む。
`splice()`は参照先のデータ自体を変更するため、`updateDocuments()`が終了したら配列は空になる。
これを避けたい場合は、```const documents = [..._documents]```(関数の引数は`_documents`に変更したとする)のような感じにするといい。

このコードにより、約12500ドキュメントを約28.7秒で書き込み完了できるようになった。
約435doc/sと理論値500doc/sと比較して約87%とお手軽なコードのわりにはまずまずの結果になった。
理論値が25秒なのでこれで作業は終了した。
お手軽に書いたが、厳密に1秒に近づけるなら`map()`の戻り値を直接`Promise.all()`に投入せず変数に入れ、そのあと`start`を更新したほうが正確性が増す。

注意事項として、このコードは失敗しないことを前提にしているため、本番コードはエラーを考慮する必要がある。
まず、`Promise.all()`はいずれかが失敗するとそこで終了するため、そこに対してフォローが必要になる。
具体的にはNode.jsのバージョンが12.10以上なら`Promise.allSettled()`に変更したほうがよさそう。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
個別の処理結果を取得できるようになるため、失敗した処理へのフォローが簡単にできるようになるはず。（私使ったことないので予想:joy:）
`(v) => db.collection(collectionName).doc(v.id).set(v)`の配列変数を作って`Promise.allSettled()`に投入し、rejectのインデックスに対応したドキュメントを再投入にまわすということになるかな？:thinking:
完全なコードを示せず申し訳ないです:bow:最後の見せ場は残しておきます:sweat_smile:

GCFだと現状v10で`Promise.allSettled()`が存在しないので、shimるのが手っ取り早いですね:wink:
https://www.npmjs.com/package/promise.allsettled


#Conclusion

最速コードがBatch書き込みではなく、通常の`set()`を`Promise.all()`で実行することで約435doc/sという結果になりました。
そうなってくると、サーバーSDKのBatchの存在意義って…:thinking:クライアントSDKでは通信回数が格段に減るのでそれだけでも存在意義があるけど。


Have a great day!