去る2024-02-10に取り行われたYAPC::Hiroshima 2024にて、弊社スマートバンクは椅子スポンサーをさせていただきました。椅子にチラシを掲載する権利を得た我々は、広島グルメマップの掲載、および「CTOを破産から救おうチャレンジ」というクイズ企画を行いました。
YAPC初スポンサーで椅子スポンサーさせてもらってます!自分の顔が至る所に貼ってあって若干気まずいw@ohbarye の冪等性の話を理解すると解けるクイズになっているので是非トライして見てください👍#yapcjapan #yapc_b pic.twitter.com/HPXfred0yX
— 堀井 雄太 | SmartBank CTO (@yutadayo) 2024年2月10日
本記事ではこのスポンサーチャンスを活かして行なった #CTOを破産から救おうチャレンジ の紹介と解説をいたします。
#CTOを破産から救おうチャレンジ の紹介
タイトルから想像しにくいかもしれませんが、本企画は10~20分程度で回答できるコーディングクイズです。
Perl, Ruby, Goの3言語のいずれかで挑戦可能ですので、参加者/非参加者を問わずまだ見ていない方はぜひ遊んでみてください。回答後にXにpostする導線もありますので、感想やフィードバックもお待ちしております。
あらすじ/設定
READMEよりあらすじを抜粋します。
2024年新春、YAPC::Hiroshimaのスポンサーとなった株式会社スマートバンクはイベントを最大限に盛り上げるため、広島のお食事どころ紹介企画を行っています。参加者の皆様にはグルメマップをノベルティとして配布していますのでぜひご覧ください。
さて、当企画にあたりスマートバンクCTO @yutadayo は広島の飲食店を練り歩いたのですが...困ったことが起きました。カード決済ネットワークの途中で障害が起き、飲食店からカード会社への決済リクエストが何重にもリトライ送信されてしまったのです。
もし全てのリクエストがカード会社で処理されたら...口座残高がなくなりスマートバンクCTOは破産に追い込まれてしまいます。重複リクエストを適切にさばくプログラムを記述してCTOを破産から救いましょう!
この内容はYAPC::Hiroshima 2024にて@ohbaryeが発表した「My Favorite Protocol: Idempotency-Key Header」のテーマにちなんだものとなっており、発表での学びを深められる問題になっています。
なお、クイズの題材となっているIdempotency-Key Headerの詳しい解説は以下の資料をご覧ください。
解説
クイズの概要を紹介したところで早速解説に入っていきます。なお、本記事ではRubyでの解説とさせていただき、簡単のためIdempotency-Keyをkey
と表現します。
なにはともあれ実行してみる
いっさいコードを変更せずに make
で採点を走らせると以下の採点結果が出力されます。
########################### # あなたの回答 # ########################### 【CTOの訪問したお店】 - いちにち - うどんや ととや (中略) 【CTOの口座残高】 -5344845円 ########################### # 採点結果 # ########################### 【ランク】C 【CTOからの一言】もう終わりだ… ❌ CTOは破産してしまった...💸 ❌ 訪問したお店を正確に記録できなかった...💔
重複を含むすべての決済リクエストを処理してしまった結果【CTOの口座残高】 -5344845円
となり、派手に破産しています。なんとかマイナスから脱却させてあげたいものです。
Idempotency-Key Headerの取得
まずは今回CTOを救う鍵となるkeyを取得します。シンプルにクライアントから送信されているHTTPヘッダーのうち、Idempotency-Key Headerを参照すればOKです。
post '/payments' do
body = JSON.parse(request.body.read)
+ idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"]
keyの保存
送信されたkeyが未知か既知かを判断できるようにするため、keyをサーバ側で保存する処理を入れます。安易にグローバル変数を用意して記録します。
+idempotency_keys = {} post '/payments' do body = JSON.parse(request.body.read) idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"] payment.synchronize do balance -= Integer(body["amount"]) shop_names << body["shop_name"] + idempotency_keys[idempotency_key] = true end status 201 end
決済処理の前にkeyの存在チェック
これにより、既知のkeyが記録されるようになったので決済処理を行う前に「すでに処理済みかどうか」を判定できるようになりました。
+idempotency_keys = {} post '/payments' do body = JSON.parse(request.body.read) idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"] + if idempotency_key[idempotency_key] + status 201 + else payment.synchronize do balance -= Integer(body["amount"]) shop_names << body["shop_name"] + idempotency_keys[idempotency_key] = true end status 201 + end end
ここまでの実装でIdempotency-Key Headerを用いるメインシナリオがカバーできたといえます。
make
を実行して大勝利...かと思いきや結果は【ランク】Bです。
########################### # あなたの回答 # ########################### 【CTOの訪問したお店】 - お好み焼き みっちゃん 横川店分家 - キッチネッテ - ビストロ巴里食堂 大手町店 - リストランテマリオ - 広島やぷし軒 - 旬と肴と炙り「月あかり」 - 汁なし担担麺 キング軒 大手町本店 - 焼肉・すき焼き とみや別館 【CTOの口座残高】 18857円 ########################### # 採点結果 # ########################### 【ランク】B 【CTOからの一言】助かった…のか…? ✅ CTOを破産から救うことができた!💰 ❌ 訪問したお店を正確に記録できなかった...💔
訪問したお店を正確に記録できなかったとのことで、余分なお店を記録したか、必要なお店が不足しているか、いずれかの問題があるようです。
ヘッダーが付与されていないリクエストをスキップする
今一度READMEの説明をよく読むと以下の記述があります。
Idempotency-Keyヘッダーを受け付けるAPIエンドポイントは、このヘッダーが付与されていないリクエストを処理してはいけません。
この一文から「keyを送っていないリクエストがあるのか?」と勘づいた方、疑ってくれてありがとう・・・・・・・・・!
post '/payments' do
body = JSON.parse(request.body.read)
idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"]
+ puts "idempotency_key: #{idempotency_key}"
標準出力にkeyを表示するようにしてdocker compose logs
にて値を確認するとkeyが存在しないリクエストが存在します。
[17/Feb/2024:01:40:56 +0000] "POST /payments HTTP/1.1" 201 - 0.0001 idempotency_key: 362b327e-50a8-4074-a182-3f1016283ac2 [17/Feb/2024:01:40:56 +0000] "POST /payments HTTP/1.1" 201 - 0.0001 idempotency_key: [17/Feb/2024:01:40:56 +0000] "POST /payments HTTP/1.1" 201 - 0.0001 idempotency_key: [17/Feb/2024:01:40:56 +0000] "POST /payments HTTP/1.1" 201 - 0.0001 idempotency_key: 5b6d3bc2-f023-4efd-bbf1-b5b201a0f3d5 [17/Feb/2024:01:40:56 +0000] "POST /payments HTTP/1.1" 201 - 0.0002 idempotency_key: 5b6d3bc2-f023-4efd-bbf1-b5b201a0f3d5 [17/Feb/2024:01:40:56 +0000] "POST /payments HTTP/1.1" 201 - 0.0001 idempotency_key:
nil
をkeyとして扱うことで、余分に決済処理を行なってしまっていたことがわかりました。keyがない場合にも処理をスキップするようにします。
post '/payments' do body = JSON.parse(request.body.read) idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"] - if idempotency_keys[idempotency_key] + if !idempotency_key + status 400 # 今回はステータスコードはなんでもOKですがIETF draftに従い400にしておきます + elsif idempotency_keys[idempotency_key] status 201 else
これにて結果が【ランク】Aとなります。CTOを破産から救いつつ、訪問したお店を正しく記録することができました 🎉
########################### # あなたの回答 # ########################### 【CTOの訪問したお店】 - お好み焼き みっちゃん 横川店分家 - キッチネッテ - ビストロ巴里食堂 大手町店 - リストランテマリオ - 旬と肴と炙り「月あかり」 - 汁なし担担麺 キング軒 大手町本店 - 焼肉・すき焼き とみや別館 【CTOの口座残高】 25544円 ########################### # 採点結果 # ########################### 【ランク】A 【CTOからの一言】助けてくれてありがとう…ありがとう… ✅ CTOを破産から救うことができた!💰 ✅ 訪問したお店を正確に記録できた!🥳
クリア後コンテンツ: 既知のkey、かつ以前と異なるペイロードには422を返す
ここまででチャレンジはクリアなのですが、クリア後コンテンツとして以下の追加仕様を用意してありました。こちらについても解説します。
サーバ側で既知のidempotency keyに対して以前と異なるリクエストボディが送信された場合、サーバーはHTTPステータスコード422 Unprocessable Entityを返します。
この仕様を満たすためにはリクエストボディをサーバが覚えておく必要があります。そのため未知のkeyに対して決済処理を行なったときに、keyだけではなくbodyも保存するようにします。
payment.synchronize do
balance -= Integer(body["amount"])
shop_names << body["shop_name"]
+ idempotency_keys[idempotency_key] = body
end
後続のリクエストで同一keyが来た際にリクエストボディも同一かどうかのチェックを行い、異なるなら422を返しましょう。
post '/payments' do body = JSON.parse(request.body.read) idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"] if !idempotency_key status 400 elsif idempotency_keys[idempotency_key] + if idempotency_keys[idempotency_key] != body + status 422 + else status 201 + end else
これにて【ランク】S到達です 🎉
########################### # あなたの回答 # ########################### 【CTOの訪問したお店】 - お好み焼き みっちゃん 横川店分家 - キッチネッテ - ビストロ巴里食堂 大手町店 - リストランテマリオ - 旬と肴と炙り「月あかり」 - 汁なし担担麺 キング軒 大手町本店 - 焼肉・すき焼き とみや別館 【CTOの口座残高】 25544円 ########################### # 採点結果 # ########################### 【ランク】S 【CTOからの一言】うちに入社してくれ〜! ✅ ハードモードクリア!🎊 ✅ CTOを破産から救うことができた!💰 ✅ 訪問したお店を正確に記録できた!🥳
最終的な実装
【ランク】Sに至る最終的な実装は以下のようになります。
idempotency_keys = {} post '/payments' do body = JSON.parse(request.body.read) idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"] if !idempotency_key status 400 elsif idempotency_keys[idempotency_key] if idempotency_keys[idempotency_key] != body status 422 else status 201 end else payment.synchronize do balance -= Integer(body["amount"]) shop_names << body["shop_name"] idempotency_keys[idempotency_key] = body end status 201 end end
なかなかコードが増えましたが、Idempotency-Key Headerの提案仕様に準拠するにはもう少し追加実装が必要です。keyやボディだけでなくレスポンスも保存する必要がありますし、エラーシナリオの考慮やボディの同一性チェックももっと厳密でなければなりません。
対応するすべてのAPIエンドポイントにこのような実装を行うのはかなり煩雑です。講演において「Idempotency-Key Headerのハンドリングはmiddlewareレイヤー(Rack, WSGI, PSGI etc.)での実装を推奨する」とお伝えした理由が伝わればと思います。
さらに、今回のクイズではkeyをサーバプロセスのインメモリに保存しましたが、水平スケールするアプリケーションであればこれはNGで、外部ストレージの利用が必要になります。RDBを使うのか、トランザクションをどう捉えるのか…等々、発展的な話題に派生することができますが本記事では割愛いたします。
振り返り
良かったこと
GitHub repositoryのtrafficを見るに、100名以上の方にrepositoryを見て頂き、数十名 (UU) の方には少なくともcloneして挑戦いただいたようでした。まったくの無風とならずに済みクイズの作り手としても安心しました。
また、今回のクイズでは回答後にXへのpost導線を設けており、ここからXにpostいただいたみなさま、盛り上げていただきありがとうございました!
わたしが残すことができたCTOの財産は25544円でした
— ミヒャエル@療養中 (@mihyaeru21) 2024年2月10日
【ランク】S
【CTOからの一言】うちに入社してくれ〜!
✅ ハードモードクリア!🎊
✅ CTOを破産から救うことができた!💰
✅ 訪問したお店を正確に記録できた!🥳#yapcjapan #CTOを破産から救おうチャレンジ https://t.co/q8YuZ1ZE8f
最速のpostは同日12:47でした
改善できたこと
導線と回答ファネル
YAPC::Hiroshima 2024参加者は400名ほどいたとのことなので、25%ほどにしかリーチできなかったことになります。会場のチラシ以外からも導線をたくさん目立つところに置くなど、クイズ挑戦への導線をより工夫できれば良かったですね。
また、Xのpostとclone数しか知ることができず、実際にクイズに挑戦した方がどれだけいたか、採点処理がどれだけ行われたかを計測できなかったのも心残りです。採点のたびに外部にリクエストを飛ばす仕組みも企画段階では検討していたのですが、準備が間に合いませんでした...!
segmentation fault on aarch64-linux
Ruby 3.3.0のFiberにはバグがありaarch64-linuxだとうまく動かないことがあるようで、挑戦いただいた方からsegmentation faultが起きたとの報告を受けてしまいました。 Ruby本体ではすでにレポートされている Bug #20085: Fiber.new{ }.resume causes Segmentation fault for Ruby 3.3.0 on aarch64-linux - Ruby master - Ruby Issue Tracking System の件です。
実はこの問題は社内でフィードバックを募ったときに同僚が踏んでいたのですが、直後にコードを変更したら再現しなくなった → さらにそのあとに変更を加えたら再現するようになっていた → 再テストが漏れたまま当日を迎えた...という経緯でした。
急遽Rubyを3.2.3にdowngradeすることで動くように修正しまして────ライブ感は出ましたが────ご迷惑をおかけしました 🙏
難易度
これは反省ポイントかどうかわかりませんが、難易度が適切だったかどうか?気になっています。
Xにpostされた結果はいずれも【ランク】Sを達成しており、YAPC参加者にとっては楽勝だった可能性がありますが、ランクSが早々に並んだのでC~Aを投稿しにくくなってしまった可能性もあります。こちらも採点タイミングで計測できていれば良い振り返り材料にできたなと反省です。
挑戦された方からのフィードバックお待ちしております。
余談
3言語での出題について
上記の解説から察せられるように、スマートバンクはRubyをメイン言語とする会社です。カード決済周辺ではGoも利用しており一部のエンジニアはGoに詳しかったりもしますが、Perlについて詳しいエンジニアは(今のところ)おりません。
どの言語で出題すべきか逡巡しましたがYAPCでPerlは外せないだろうと判断。複数言語のコードを準備するのは大変そうに思ったものの、拡張子が異なる言語のファイルにコードをコピペすると変換してくれるJetBrains AI Assistantの便利機能 (Use AI to convert files to another language | IntelliJ IDEA Documentation) によりサクッと移植が完了。
あとは月並みですがChatGPTに頼りつつDockerfileを書き上げる程度で済みました。
クライアントコードの難読化
「クライアントの挙動を見ればクイズへの回答は容易なのでは?」と思った方もいると思います。
その通りでして、repositoryに同梱しているクライアントコード _blackbox/original_client.rb
に採点のロジックは記述されているので、見ればある程度の推測は立つものです。今回は商品・景品などのインセンティブを設けなかったため、このあたりはゆるくやらせてもらいました。
一応、_blackbox/README.md
に「このディレクトリは見なくても解ける」旨を記述したり、実際に動くクライアントコードclient/client.rb
は難読化しておいたりと地味な工夫をしておりました。
このクイズ、クライアントコードを読めばサーバーが実装すべき振る舞いはバレちゃいますね...
— ohbarye (@ohbarye) 2024年2月10日
クライアントコードを読めれば...https://t.co/NFMvl0XFR1#yapcjapan #yapc_b #CTOを破産から救おうチャレンジ https://t.co/OyOSgGodhQ pic.twitter.com/7TmQ4O2cCk
おわりに
イベントにてこのようなクイズ企画を実施するのは個人としても会社としても初の試みではありましたが、作問する過程や、同僚からのフィードバック、当日の反響... 各々を通じて大変楽しませてもらいました。今後に活かせる学びも得られまたので次回作にご期待ください。
最後に、スマートバンクでは実際にIdempotency-Key Headerを導入した決済サービスB/43を提供しており、冪等性を意識した堅牢な設計と実装に楽しさを感じるエンジニアを募集しています。カジュアル面談などは随時実施していますので、ぜひお気軽にお声がけください。
本記事は@ohbaryeが執筆しました。本クイズの作成にあたりフィードバックをくれた @tmnb @shohei_mitani @osyoyu @hirotea に改めて感謝を捧げます!