kurosame’s diary

フロントエンドが少しできます

Firebase の Functions から Firestore への連携が(たまに)失敗する件の対応

あるシステムで以下のような処理を行っている

  1. Firebase の Storage にファイルをアップロード
  2. そのファイルを Functions でダウンロードして色々処理
  3. 加工した情報を Firestore に保存

Firestore への保存処理は Functions でadmin.firestore().collection('...').doc(fileName).set(...)のようにset関数を使って行っている

この Firestore への保存処理が成功する時と失敗する時があった

Functions が原因の可能性が高いと思ったが、エラーも出ておらず、成功する時と失敗する時があるのでちゃんとした再現が難しく、調査に難航した

Firebase は Spark プラン(無料のやつ)
Firebase プロジェクトのリージョンはnam5 (us-central)
Firestore のリージョンはnam5 (us-central)

やったこと、やろうとしたこと

  • 何かプランの制限にかかっていないか確認
  • 試しに Functions のリージョンを変えてみる
  • Firebase リソースのリージョンをすべて東京にする
  • Firestore へ反映されていなかったらリトライする

何かプランの制限にかかっていないか確認

以下を確認
https://firebase.google.com/docs/functions/quotas

とくに大丈夫そう

試しに Functions のリージョンを変えてみる

Functions はデフォルトではus-central1で実行される
東京リージョンに変えて実行してみた

functions
  .region('asia-northeast1')
  .storage.object()
  .onFinalize(...)

Functions のダッシュボード上で以下に変わっていればオッケー f:id:kurosame-th:20200515192533p:plain

なぜか Functions のリージョンを東京に変えたら失敗しなくなったっぽい(何回か試しただけだけど)
でも Storage も Firestore もリージョンはnam5 (us-central)なので、Functions の実行環境として 1 番近いのはus-central1である

この検証結果は忘れることにした

Firebase リソースのリージョンをすべて東京にする

Firebase プロジェクトのリージョンは後から変更できない
変えるならば新規プロジェクトを作り、全データ移行および設定し直しをする必要がある

かなり面倒だからやめた。。

ちなみに Firebase プロジェクトをasia-northeast1リージョンに変更しても、Firebase の主要リソースはすべて利用できる
また、自分が調べた限りasia-northeast1リージョンで不利になるのは以下だけ

  • Firebase Hosting で Functions を使って動的コンテンツを配信するのはus-central1のみをサポート
  • Realtime Database は常にus-central1になる

参考
https://firebase.google.com/docs/functions/locations?hl=ja#background

Firestore へ反映されていなかったらリトライする

結局、失敗したらリトライ処理をするプログラムを書くことにした

const doSet = async (): Promise<void> => {
  admin
    .firestore()
    .collection('...')
    .doc(fileName)
    .set(...)
  await new Promise(resolve => setTimeout(resolve, 120000)) // Sleep for 2 minutes
  return admin
    .firestore()
    .collection('...')
    .doc(fileName)
    .get()
    .then(doc => {
      if (!doc.exists) return Promise.reject()
      const data = doc.data()
      return newData === data ? Promise.resolve() : Promise.reject()
    })
    .catch(() => Promise.reject())
}

けっこう省略してるが、doSet関数は以下の処理を行っている

  1. admin.firestore().collection('...').doc(fileName).set(...)で Firestore のドキュメントを登録/更新
  2. 2 分スリープ
  3. admin.firestore().collection('...').doc(fileName).get()で Firestore のドキュメントを取得
  4. 取得したドキュメントをチェック
    1. ドキュメントが存在しない、または更新されていない場合はPromise.reject()を返す
    2. ドキュメントが登録/更新されている場合はPromise.resolve()を返す

doSetは以下のように呼ぶ

doSet()
  .catch(doSet)
  .catch(doSet)
  .catch(() => console.error(`Document not updates`))

最大 3 回doSetをコールする
3 回目のコールでdoSetPromise.reject()を返せば、エラーログを出して処理終了
いずれかのdoSetPromise.resolve()を返せば、キャッチされないので処理終了

本当は Functions でエラー時にアラートを仕込めたらよいが Firebase コンソール上ではできなそう
GCP の Stackdriver Logging(有料)を使って色々設定すればできるかも

また、注意点として、Functions のデフォルトのタイムアウト時間は 60 秒なので以下のように変更しておく

functions
  .runWith({ timeoutSeconds: 540 })
  .storage.object()
  .onFinalize(...)

540 秒(9 分)が最大値
Functions のダッシュボード上で以下に変わっていればオッケー f:id:kurosame-th:20200515192607p:plain

また、runWithオプションで CPU のコア数やメモリも増やせる

参考
https://firebase.google.com/docs/functions/manage-functions?hl=ja#set_timeout_and_memory_allocation

さいごに

Firebase で最初にプロジェクトを作ったとき、ちゃんと東京リージョンにしておけば、今回の問題は起きなかったかもしれない

Functions のプログラム全体は以下
https://github.com/kurosame/glossary/blob/master/functions/src/index.ts