Cloud Functionsで、Cloud Firestoreトリガーを使う場合、「重複発火」に気をつけないといけません。
本来であれば、記述したCloud Functionsの処理は1回のトリガーで1回だけ実行されてほしいのですが…どういうわけか複数回呼び出されることがたまに起こります。

ちなみに、「少なくとも1回以上実行される可能性がある」ことと、「実行する関数自体は冪等性を担保して実装してね」とドキュメントには記載があります。

今回は重複発火が起こると困る例や、防ぐためのより良い方法を書いてみようと思います。

重複発火が起こると困る例

重複発火が起こると困る例としては、ドキュメント作成時にPush通知を送るようなコードを書いていた場合に、
重複発火が起こるとPush通知が複数回送られてしまいます。

または関数内でなにか計測したりする場合に複数回実行される可能性があるので計測にずれが生じる可能性もでてきます。

あるいは、あるドキュメントの配下のサブコレクションにドキュメントを追加したことをキャッチし、
その個数をFieldValue.incrementを使ってCloud Functionsで書き込む場合に、重複発火してしまうとカウントがずれてしまう恐れもあります。

基本的には冒頭でも説明したとおり、冪等性を担保して実装すべきなのですが、それが厳しいケースも正直あるかと思います。

重複発火を防ぐ方法

防ぐ方法ですが、こちらの記事で紹介されている方法で防ぐことができます。

具体的には

  • Firestoreトリガー(onCreate,onUpdate,onDelete,onWrite)が実行された時に渡されるcontextから、eventIdを取得する
  • トランザクションを貼り、eventIdをドキュメントIDとしたドキュメントが書き込まれていなければ、重複発火していないとみなし、eventIdをドキュメントIDとしてドキュメントを書き込み、falseを返す
  • すでにドキュメントが存在する場合は、重複発火したとみなし、trueを返す
  • 返却された真偽値を見て、関数の処理を続行するかを判断する

といった事をして、重複発火を防いでいます。
今回は先述の記事で紹介されていた方法に手を加え、より扱いやすいものを紹介していきます。
次のように関数を定義してみます。

import * as admin from 'firebase-admin'
const hasAlreadyTriggered = (
  eventID: string,
  suffix: string
): Promise<boolean> => {
  const id = [eventID, suffix].join('-')
  return firestore().runTransaction(async t => {
    const ref = admin.firestore()
      .collection('triggerEvents')
      .doc(id)
    const doc = await t.get(ref)
    if (doc.exists) {
      console.log(`EventID: ${id} has already triggered.`)
      return true
    } else {
      t.set(ref, { createTime: admin.firestore.FieldValue.serverTimestamp() })
      return false
    }
  })
}

これを使って、次のようにonCreateなどのトリガーのコールバック内の最初の行に

if (await hasAlreadyTriggered(context.eventId, 'suffix')) {
  return undefined
}

を書いてあげます。(hasAlreadyTriggeredは非同期実行されるので、async/awaitを使うと良いと思います。)

import * as functions from 'firebase-functions'

export const sendPostNotification = functions.firestore
  .document('users/{userID}/posts/{postID}')
  .onCreate(async (snapshot, context) => {
    if (await hasAlreadyTriggered(context.eventId, 'sendPostNotification')) {
      return undefined
    }
    
    // send notification to users
  })

紹介した記事にある例と比べて、suffix 指定を必要にしたのには理由があります。

同一のpathに対する同一のFirestoreトリガーを使った関数を複数定義した際に、1つの関数だけでなく複数の関数でeventIDが重複する可能性があるためです。
一つの関数で複数のロジックを処理するのではなく、それぞれのロジックごとに同一のFirestoreトリガーを使って関数を定義した場合に起こりうる可能性があります。

下記の例のように、同一のpathに対する同一のFirestoreトリガーを使って関数を定義した際に、例えばA,BでeventIDが一緒だった場合、どちらかは実行されどちらかはすでに重複発火したと誤判定され、実行されないといった事態が発生します。
発火する順番、eventIDの割り振りがそれぞれ別々になることも保証されていないので、Aが発火してBが発火しない、Bが発火してAが発火しない、どれも発火するといった形でばらつきが生じます。

この挙動は自分の携わっているプロジェクトで実際に遭遇し頭を悩ませました…。(すでに解決済み)

import * as functions from 'firebase-functions'

export const functionA = functions.firestore
  .document('users/{userID}/posts/{postID}')
  .onCreate(async (snapshot, context) => {
    if (await hasAlreadyTriggered(context.eventId)) {
      return undefined
    }

    // Do something
  })

export const functionB = functions.firestore
  .document('users/{userID}/posts/{postID}')
  .onCreate(async (snapshot, context) => {
    if (await hasAlreadyTriggered(context.eventId)) {
      return undefined
    }

    // Do something
  })

export const functionC = functions.firestore
  .document('users/{userID}/posts/{postID}')
  .onCreate(async (snapshot, context) => {
    if (await hasAlreadyTriggered(context.eventId)) {
      return undefined
    }

    // Do something
  })

なので、それぞれの関数で適切にsuffixを割り振り(例えばexportする際の定義名をsuffixにする、等)、重複発火をチェックする仕組みの中で、ドキュメントのIDを{eventID}-{suffix}の形式で保存すれば
同じpath、同じトリガーを使った関数が複数あっても、関数毎に重複発火を防ぐことができます。

より扱いやすくする

上記の方法で重複発火を防ぐことはできるようになりましたが、

if (await hasAlreadyTriggered(context.eventId, 'suffix')) {
  return undefined
}

を都度関数に記述するのはやや大変ですよね。 なので次のように、triggerOnceという関数を用意してあげます。

import { EventContext } from 'firebase-functions'
export const triggerOnce = <T>(
  suffix: string,
  handler: (data: T, context: EventContext) => PromiseLike<any> | any
): ((data: T, context: EventContext) => PromiseLike<any> | any) => async (
  data,
  context
) => {
  if (await hasAlreadyTriggered(context.eventId, suffix)) {
    return undefined
  }
  return handler(data, context)
}

これを使って書くと次のようになります。
本来であればFirestoreのトリガーにコールバックを書くところに、このtriggerOnce関数を噛ませる形になります。

import * as functions from 'firebase-functions'

export const sendPostNotification = functions.firestore
  .document('users/{userID}/posts/{postID}')
  .onCreate(triggerOnce('sendPostNotification', async (snapshot, context) => {
    // send notification to users
  }))

都度、if文を書くスタイルよりは書きやすくなるかなと思います。

Genericsを使ってあげることで、

  • onCreate , onDelete : dataの型がDocumentSnapshot
  • onUpdate , onWrite : dataの型がChange<DocumentSnapshot>

と、あとから推論されるようになり、すべてのトリガーでうまく機能します。

まとめ

今回は重複発火を防ぐためのよりよい手段と、複数の関数に仕込む際の面倒さを軽減する方法を紹介しました。 基本的には関数は1回以上実行されても問題ないように冪等性を担保し実装すればよいのですが、 どうしても重複発火しては困るものに関してはこうした手段を講じておくと安心できるかなと思います。