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

こんにちは。[virapture株式会社](https://virapture.com/)の[もぐめっと](https://twitter.com/mogmet)です。

![スクリーンショット 2021-04-10 7.46.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/a79760a8-2a73-1211-5a93-20f33e45b6f0.png)
最近アコギにはまってます。弾けるようになると楽しいですね。

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

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

## Cloud Functionsの冪等性について

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

https://firebase.google.com/docs/firestore/extend-with-functions?hl=ja#limitations_and_guarantees

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

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

https://cloud.google.com/functions/docs/concepts/exec?hl=ja#execution_guarantees

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

## 方法1 日付の比較

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

https://qiita.com/akagire/items/024089ee47c35de04865

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にイベントを記録して、実施済みだったら実施しないといったことを実装することができます。
実装例は下記より引用させていただきます。

https://qiita.com/nkmrh/items/d2ef9853e6680a63527a

```typescript
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の流れは完全に下記記事と一緒の流れにはなっています・・・ｗ

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

https://tech-blog.sgr-ksmt.org/2019/12/16/104935/

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

昇華版比較関数

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

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

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

使うときはこんな感じ

```typescript
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 正常処理も判定する

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

https://cloud.google.com/blog/products/serverless/cloud-functions-pro-tips-building-idempotent-functions

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

```javascript
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は多重的に実行される事があるので、多重的に呼ばれて困る場合は一工夫いれて冪等性を担保するようにしよう！

最後に、[ワンナイト人狼オンライン](https://online.1nite-jinro.com/link/blog)というゲームを作ってます！よかったら遊んでね！

https://online.1nite-jinro.com/link/blog

他にも[Camecon](https://camecon.me/)や[Offcha](https://offcha.com/)といったサービスも作ってるのでよかったら使ってね！

https://camecon.me/

https://offcha.com/