Cloud Tasks キューで遅延プッシュ通知を実装した話

2024/11/09に公開

こちらの記事は、LUUPのTVCM放映に合わせた一足早い「Luup Developers Advent Calendar 2024」の9日目の記事です。

はじめに

こんにちは。Luup Serverチーム所属の井上です。
突然ですが問いを発します。

  • Q. サーバー(GCP上)からiOS/Androidアプリへ送るプッシュ通知を、ユーザーの行動から一定時間遅延させるには…?
  • A. Cloud Tasks キュー でscheduleDelaySeconds を指定するとキューイングを遅延させることができるよ!タスクを受け取った側でプッシュ通知を送信しよう!

…ということをお伝えするために筆を取りましたが、それだけだと上記の2行で終わってしまうので背景のご説明とサンプルコード等を以下に記してまいります。

https://zenn.dev/luup_developers/articles/etc-okada-20231225#技術構成

PM「ライド終了後、適切なタイミングでユーザーへプッシュ通知を送りたい」

Luupではユーザーの利便性やライド体験向上のため、A/Bテストによる仮説検証などを通じて日々様々な施策・機能改善を行っています。
最近、とある機能をユーザーにもっと活用していただくべく、機能の認知度向上に向けたA/Bテストが企画されました。

このときPMが提示した要件は以下のようなものでした。

  • 介入群のユーザーには、ライド終了後に上記機能の存在をお知らせするプッシュ通知を送る
  • ただし、ライド終了直後はそこから目的地へ向かおうとしているシチュエーションがほとんどと予想され、落ち着いて通知を目にしてもらうには少し間を空けたい

どう実装するか?検討の中でのボツ案ご紹介

起点となるライド終了のタイミングから遅延してプッシュ通知を行う必要がありますが、LUUPアプリでは前例がなく、方式の検討から始めました。
以下に私の脳内会議でボツになった案を記しておきます。

ボツ① 素朴にCloud Functionsの処理内で待つ

先ほどの要求を実現するのに、まずパッと浮かんだのは「ライド終了をトリガーにPub/Subトピックを飛ばし、それをサブスクライブするFunctionで一定時間waitしてからプッシュ送信」でした。

…が、時間課金のCloud Functionsで何もせずに分単位で待つ…? だと…!?
Pull Requestを出したところで弊社サーバーエンジニア陣に一蹴されてしまうでしょう。


私の心の中のネテロ会長も「そりゃ悪手だろ🐜」と呟いています

ボツ② 高頻度にスケジュール処理を回す

では… 一定間隔のスケジュール処理にて、データベース内のライドデータをスキャンし、終了から3分経過したものを対象に処理するのはどうでしょうか?(Pub/Subのスケジュール処理はLUUPバックエンドの様々な処理で使われています)

しかしこの案ではよほど工夫しない限り「3分後」のタイミングをうまく捉えられないでしょう。実行周期を高頻度にしないと3分+数秒〜数十秒と遅れたタイミングで処理開始になりそうです。一定の時間幅で対象データを検索することになりますが、取り漏らしを防ぐのにある程度マージンを乗せて、すると重複実行の考慮も出てきて、Cloud Functionsのタイムアウトも考慮して… とどんどん複雑になってしまいそうです。
コスト観点からも良くない選択肢でしょう。

そこでCloud Tasks キュー

自前で作るのではなく、クラウド基盤側で遅延実行を扱う仕組みがあるはず… と調べたところやはりありました。ようやく冒頭のお話に戻ってまいりました。

Cloud Tasks キュー自体がどういったプロダクトであるかはここでは割愛させていただきます。下記のGCP(Firebase)公式ガイドをご覧ください。
このガイド内に、さらっとですが遅延実行オプション(dispatchDeadlineSeconds)についての記載がありました。
https://firebase.google.com/docs/functions/task-functions?hl=ja&gen=1st


これでやりたかったことがシンプルに実現できます

サンプルコード

上記を参照し、私がやりたかったことを抜粋してサンプルコードを書くと以下のようになります。
(Node.js + TypeScriptでの例示)

// キューにタスクを渡す側のFunction
import { getFunctions } from "firebase-admin/functions";

export const enqueueTaskWithDelay = async () => {
  const queue = getFunctions().taskQueue(
    "locations/asia-northeast1/functions/{'キューからタスクを受け取る側'の関数名}"
  );
  await queue.enqueue(
    // キューに渡すペイロードデータ、フォーマットは任意
    {
      userId: "..."
      title: "タイトル",
      body: "メッセージ本文",
      linkUrl: "https://...",
    },
    // オプション指定
    {
      scheduleDelaySeconds: 60 * 3, // 3 minutes遅延送信 ← コレ!
      dispatchDeadlineSeconds: 60 * 1, // タスクが完了するまでにCloud Tasksが待機する最長時間の指定
      id: "...", // 重複送信排除用のID,タスクごとにユニークに
    }
  );
}
// キューからタスクを受け取る側のFunction
import * as functions from "firebase-functions";
import { FirebaseError } from "firebase-admin";
import { getMessaging } from "firebase-admin/messaging";

export const handleTask = functions.tasks
  .taskQueue()
  .onDispatch(async (task) => {
    console.log("Received task:", task);
    // 受け取ったタスクデータからpush通知用データ生成(詳細割愛)
    const message = createMessageFromTask(task);
    // push通知送信
    await getMessaging()
      .send(message)
      .catch(async (error: FirebaseError) => {
        // エラー処理
        // ...
      });
  });

標準で重複排除オプションが組み込まれていたり、遅延時間指定だけでなく絶対時刻の指定オプションもあったりと活用の幅が広そうな仕様となっていました。

サービスアカウントに必要なroleを付与

上記サンプルコードを動作させるには、Cloud Functions実行に使用するサービスアカウントへ以下のroleを付与する必要があります。
(キューにタスクを渡す側・キューからタスクを受け取る側、どちらも同じサービスアカウントの前提としています)

  • roles/cloudtasks.enqueuer
  • roles/iam.serviceAccountUser
  • roles/cloudfunctions.invoker

また、デプロイを行うサービスアカウントに対してもキュー作成の権限を与える必要があります。

  • roles/cloudtasks.queueAdmin

なおAWSでは

SQSに「遅延キュー」の機能があり、同等のことが可能です。

https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-delay-queues.html

おわりに

これにて、めでたくシンプルに「3分遅延プッシュ通知」を実装できました!

Luupでは今回ご紹介した要件のような施策や機能アップデートを日々行い、より良いライド体験の提供と、当社ミッションである「街じゅうを『駅前化』するインフラを作る」ことを追求しています。

それらに向けていかに品質よく・素早く価値提供していけるか?
頭を捻りつつも楽しみながら開発に取り組んでいる Luupエンジニアのお仕事が気になった方は、ぜひカジュアル面談へ!お気軽にご応募ください。

https://recruit.luup.sc

Luup Developers Blog

Discussion