LoginSignup
23
16

More than 3 years have passed since last update.

Cloud Functions for Firestoreの重複発火を見越した冪等性の実装方法を考える

Last updated at Posted at 2021-04-09

こんにちは。virapture株式会社もぐめっとです。

スクリーンショット 2021-04-10 7.46.02.png
最近アコギにはまってます。弾けるようになると楽しいですね。

本日は意外と知られてない?Cloud Functionsの冪等性について書いていこうと思います。

調べた結果のまとめみたいな記事になります。

Cloud Functionsの冪等性について

Cloud Functionsは実は1回以上処理されることがあります。

なので、基本的には何度実行しても同じ結果になるように冪等性を担保する必要があります。
もし冪等性が担保されていなかった場合、同じ内容のプッシュ通知が重複でとんでしまったり、カウントアップなどの処理が余剰に実施されてしまうなどの問題が発生してしまいます。

ちなみにHTTPS関数の方は一回呼び出しが保証されてるみたいです。

次からはどうやって何度実行されても問題ないようにするかを記述していきます。

方法1 日付の比較

一つの方法としてはデータが生成された時間を比較して、既に実行済みだったら実行しないようにするという方法があります。

CloudFcunctionsから渡されるsnapshotにはデータが生成された時を表すcreateTimeと、更新された時間を表すupdateTimeが存在します。

例えば記事を公開したら自身の公開記事数を増やし、非公開に更新したら公開記事数を減らすみたいな仕様だった場合、下記の実装が考えられます。

  • onCreate: snapshot.createTimeとuser.updatedAtを比較して一緒なら実施しない
  • onUpdate: snapshot.after.updateTimeとuser.updatedAtを比較して一緒なら実施しない

しかし、この実装の欠点としては、違う仕様の度にそれぞれのupdatedAtを変えてみないといけないです。
また、onDeleteの時の時刻はsnapshotからは取れないので、context.timestampなどもうまく使わないといけないです。
もう少し楽したいですね。

方法2 eventIdを使う

CloudFunctionsから渡されるcontextの中からeventIdを取得することができます。
これを用いて、eventIdごとにfirestoreにイベントを記録して、実施済みだったら実施しないといったことを実装することができます。
実装例は下記より引用させていただきます。

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
  }
  ...
}

しかし、別の関数で同じような実装を使っている際に、eventIdが被ることがあったりするらしいのでこれだとまだ不十分になります。

方法3 eventIdの重複を防ぐ

方法2から方法3の流れは完全に下記記事と一緒の流れにはなっています・・・w

検索結果で一番上に出てくるのでおそらく皆さん既にチェック済みかとは存じます。

この記事ではeventIdと、関数ごとにsuffixを追加することによってeventIdの重複を防いでます。
そしてより使いやすい関数へと昇華させていたので引用させていただきます。

昇華版比較関数

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
    }
  })
}

より汎用的に使いやすくした関数

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)
}

使うときはこんな感じ

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
  }))

ちなみにこの方法だと、実はまだ足りてなくて、eventを記録したあと、処理が実行されたときにCloudFunctions側の起因でなにかエラーが起こった際に、処理が正常実行されず、再実行しても既に実行済みなので、正常処理がされることなく終わってしまいます。

方法4 正常処理も判定する

正常処理の判定方法についてはこちらに紹介されている方法が参考になると思います。

eventIdをキーにしてレコードを記録しますが、正常処理ができたタイミングでそのフラグを書き込んでおり、重複処理判定のために正常処理のフラグをみて重複判定しています。
下記に引用させていただきます。

const sgMail = require('@sendgrid/mail');

exports.idempotentEmailFunction = (event) => {
  const content = ...;
  const eventId = event.context.eventId;
  const emailRef = db.collection('sentEmails').doc(eventId);

  return shouldSendWithLease(emailRef).then(send => {
    if (send) {
      // Send email.
      sgMail.setApiKey(...);
      sgMail.send({..., text: content.text});
      return markSent(emailRef);
    }
  }).then(() => {
    // Call another service.
    // ...
  });
};

// ...
const leaseTime = 60 * 1000; // 60s

function shouldSendWithLease(emailRef) {
  return db.runTransaction(transaction => {
    return transaction.get(emailRef).then(emailDoc => {
      if (emailDoc.exists && emailDoc.data().sent) {
        return false;
      }
      if (emailDoc.exists && new Date() < emailDoc.data().lease) {
        return Promise.reject('Lease already taken, try later.');
      }
      transaction.set(
          emailRef, {lease: new Date(new Date().getTime() + leaseTime)});
      return true;
    });
  });
}

function markSent(emailRef) {
  return emailRef.set({sent: true});
}

方法3と方法4を組み合わせればより堅牢な冪等性を担保することができますね!

まとめ

cloud functionsは多重的に実行される事があるので、多重的に呼ばれて困る場合は一工夫いれて冪等性を担保するようにしよう!

最後に、ワンナイト人狼オンラインというゲームを作ってます!よかったら遊んでね!

他にもCameconOffchaといったサービスも作ってるのでよかったら使ってね!

23
16
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
16