Edited at

Firebase Cloud Function と Firebase Firestore を組み合わせて使うと onCreate/onDelete がたまに2回発火してしまう

More than 1 year has passed since last update.

Firebase Cloud Function と Firebase Firestore を組み合わせて使うと onCreate/onDelete がたまに2回発火してしまうという問題に遭遇しました。

stack overflow にも丁度同じ問題で困っている人がいるようです。

https://stackoverflow.com/questions/48735862/firestore-cloud-functions-oncreate-ondelete-sometimes-immediately-triggered-twic

ドキュメントの制約と保証の項に書いてある通り、1つのイベントで複数の呼び出しがある可能性があるそうです。


現在、関数呼び出しの配信は保証されていません。Cloud Firestore と Cloud Functions の統合が向上した段階で、少なくとも 1 回の配信を保証する予定です。ただし、ベータ版ではこれが保証されるとは限りません。1 つのイベントで複数の呼び出しが発生する可能性があるため、関数で高い精度が求められる場合には、関数の書き込みをべき等にする必要があります。


私のケースでは、ポストが投稿された際プッシュ通知を送信する関数を運用していて、これが原因で1つのポスト投稿で2重の通知が送られることが頻繁に発生しています。


関数の書き込みを冪等にする必要がある


とあったので eventId をグローバル変数に保存しておき、すでに発火したイベントは無視するコードを追加してみました。

var alreadyRunEventIDs = [];

function isAlreadyRunning(eventID) {
return alreadyRunEventIDs.indexOf(eventID) >= 0;
}

function markAsRunning(eventID) {
alreadyRunEventIDs.push(eventID);
}

exports.notifyNewPost = functions.firestore.document('path').onCreate(event => {
const eventID = event.eventId;
if (util.isAlreadyRunning(eventID)) {
return console.log('Ignore it because it is already running (eventId):', eventID);
}
markAsRunning(eventID);
...
}

これがうまくいくか試しています。

追記:

try! swift tokyo 2018 で firebase team の方がブースを出されていたので、この問題に対するベストプラクティスがあるか聞いてみたのですが、今の所はないとのことでした。チームに共有していいやり方がわかったら連絡してくれるとのことです。

上記のグローバル変数を使った実装はクラウドファンクションの複数のインスタンス毎に保持されるのでこのやり方だと防げないそうです。ログを確認しても防げている時と防げていない時があったので、防げている時はたまたま同じインスタンスで実行されたのでしょう。

また、上記の他にトランザクションを使って防ぐ実装についても聞いたところ、トランザクションは複数回リトライする可能性があるのでその場合防げないかもしれない、とのことでした。。。

追記:

関数がトリガーされた際、firestore にイベントIDを保存しておき、それをチェックする方法を試していますが、今の所うまく動いています。

export async function wasTriggered(eventId: string) {

return firestore.runTransaction(async t => {
const ref = eventIdRef
const doc = await t.get(ref)
if (doc.exists) {
return true
} else {
t.set(ref, {})
return false
}
})
}

export const listener = functions.firestore.document(`path`).onCreate(async (snapshot, context) => {
if (await utils.wasTriggered(context.eventId)) {
console.log('Ignore: it has already been triggered')
return 0
}
...
}