# YAPC::Hiroshima 2024 #CTOを破産から救おうチャレンジ クイズの紹介と解説 去る2024-02-10に取り行われた[YAPC::Hiroshima 2024](https://yapcjapan.org/2024hiroshima/)にて、弊社スマートバンクは**椅子スポンサー**をさせていただきました。**椅子にチラシを掲載する権利**を得た我々は、広島グルメマップの掲載、および「CTOを破産から救おうチャレンジ」というクイズ企画を行いました。 https://twitter.com/yutadayo/status/1756149993365528743 _"神輿"として担がれるCTOのようす_ 本記事ではこのスポンサーチャンスを活かして行なった #CTOを破産から救おうチャレンジ の紹介と解説をいたします。 <!-- more --> https://github.com/smartbank-inc/yapc2024-quiz ## #CTOを破産から救おうチャレンジ の紹介 タイトルから想像しにくいかもしれませんが、10~20分程度で回答できるコーディングクイズを出題しました。 https://github.com/smartbank-inc/yapc2024-quiz Perl, Ruby, Goの3言語のいずれかで挑戦可能ですので、参加者/非参加者を問わずまだ見ていない方はぜひ遊んでみてください。回答後にXにpostする導線もありますので、感想やフィードバックもお待ちしております。 ### あらすじ/設定 READMEよりあらすじを抜粋します。 >2024年新春、YAPC::Hiroshimaのスポンサーとなった株式会社スマートバンクはイベントを最大限に盛り上げるため、広島のお食事どころ紹介企画を行っています。参加者の皆様にはグルメマップをノベルティとして配布していますのでぜひご覧ください。 > >さて、当企画にあたりスマートバンクCTO @yutadayo は広島の飲食店を練り歩いたのですが...困ったことが起きました。カード決済ネットワークの途中で障害が起き、飲食店からカード会社への決済リクエストが何重にもリトライ送信されてしまったのです。 > >もし全てのリクエストがカード会社で処理されたら...口座残高がなくなりスマートバンクCTOは破産に追い込まれてしまいます。重複リクエストを適切にさばくプログラムを記述してCTOを破産から救いましょう! この内容はYAPC::Hiroshima 2024にて[@ohbarye](https://twitter.com/ohbarye)が発表した「[My Favorite Protocol: Idempotency-Key Header](https://fortee.jp/yapc-hiroshima-2024/proposal/c907c754-a4e5-4573-a14a-c8bb828e8825)」のテーマにちなんだものとなっており、発表での学びをさらに深めることもできる問題になっています。 なお、クイズの題材となっているIdempotency-Key Headerの詳しい解説は以下の資料をご覧ください。 <iframe class="speakerdeck-iframe" frameborder="0" src="https://speakerdeck.com/player/fd67dc8e804649c2ad98ff96042551d3" title="My Favorite Protocol: Idempotency-Key Header" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe> ## 解説 クイズの内容について紹介したところで早速解説に入っていきます。 なお、本記事ではRubyでの解説とさせていただき、簡単のためIdempotency-Keyを`key`と表現します。 ### なにはともあれ実行してみる いっさいコードを変更せずに `make` で採点を走らせると以下の採点結果が出力されます。 ``` ########################### # あなたの回答 # ########################### 【CTOの訪問したお店】 - いちにち - うどんや ととや (中略) 【CTOの口座残高】 -5344845円 ########################### # 採点結果 # ########################### 【ランク】C 【CTOからの一言】もう終わりだ… ❌ CTOは破産してしまった...💸 ❌ 訪問したお店を正確に記録できなかった...💔 ``` 重複を含むすべての決済リクエストを処理してしまった結果`【CTOの口座残高】 -5344845円`となり、**派手に破産**しています。なんとかマイナスから脱却させてあげたいものです。 ### Idempotency-Key Headerの取得 まずは今回CTOを救う鍵となるkeyを取得します。シンプルにクライアントから送信されているHTTPヘッダーのうち、Idempotency-Key Headerを参照すればOKです。 ```diff post '/payments' do body = JSON.parse(request.body.read) + idempotency_key = request.env["HTTP_IDEMPOTENCY_KEY"] ``` ### keyの保存 送信されたkeyが未知か既知かを判断できるようにするため、keyをサーバ側で保存する処理を入れます。安易にグローバル変数を用意して記録します。 ```diff +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が記録されるようになったので決済処理を行う前に「すでに処理済みかどうか」を判定できるようになりました。 ```diff +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を用いるメインシナリオがカバーできたといえます。 ![image](https://hackmd.io/_uploads/H1jRriTjT.png) `make`を実行して大勝利...かと思いきや結果は【ランク】Bです。 ``` ########################### # あなたの回答 # ########################### 【CTOの訪問したお店】 - お好み焼き みっちゃん 横川店分家 - キッチネッテ - ビストロ巴里食堂 大手町店 - リストランテマリオ - 広島やぷし軒 - 旬と肴と炙り「月あかり」 - 汁なし担担麺 キング軒 大手町本店 - 焼肉・すき焼き とみや別館 【CTOの口座残高】 18857円 ########################### # 採点結果 # ########################### 【ランク】B 【CTOからの一言】助かった…のか…? ✅ CTOを破産から救うことができた!💰 ❌ 訪問したお店を正確に記録できなかった...💔 ``` 訪問したお店を正確に記録できなかったとのことで、余分なお店を記録したか、必要なお店が不足しているか、いずれかの問題があるようです。 ### ヘッダーが付与されていないリクエストをスキップする 今一度[READMEの説明](https://github.com/smartbank-inc/yapc2024-quiz?tab=readme-ov-file#idempotency-key%E3%83%98%E3%83%83%E3%83%80%E3%83%BC%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)をよく読むと以下の記述があります。 >Idempotency-Keyヘッダーを受け付けるAPIエンドポイントは、このヘッダーが付与されていないリクエストを処理してはいけません。 この一文から「keyを送っていないリクエストがあるのか?」と勘づいた方、疑ってくれてありがとう・・・・・・・・・! ```diff 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がない場合にも処理をスキップするようにします。 ```diff 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を返す ここまででチャレンジはクリアなのですが、[クリア後コンテンツ](https://github.com/smartbank-inc/yapc2024-quiz?tab=readme-ov-file#%E3%82%AF%E3%83%AA%E3%82%A2%E5%BE%8C%E3%82%B3%E3%83%B3%E3%83%86%E3%83%B3%E3%83%84)として以下の追加仕様を用意してありました。こちらについても解説します。 >サーバ側で既知のidempotency keyに対して以前と異なるリクエストボディが送信された場合、サーバーはHTTPステータスコード422 Unprocessable Entityを返します。 この仕様を満たすためにはリクエストボディをサーバが覚えておく必要があります。そのため未知のkeyに対して決済処理を行なったときに、keyだけではなくbodyも保存するようにします。 ```diff payment.synchronize do balance -= Integer(body["amount"]) shop_names << body["shop_name"] + idempotency_keys[idempotency_key] = body end ``` 後続のリクエストで同一keyが来た際にリクエストボディも同一かどうかのチェックを行い、異なるなら422を返しましょう。 ```diff 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に至る最終的な実装は以下のようになります。 ```ruby 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やボディだけでなくレスポンスも保存する必要がありますし、エラーシナリオの考慮やボディの同一性チェックももっと厳密でなければなりません。 ![image](https://hackmd.io/_uploads/Ske9Io6i6.png) 対応するすべてのAPIエンドポイントにこのような実装を行うのはかなり煩雑です。講演において「Idempotency-Key Headerのハンドリングはmiddlewareレイヤー(Rack, WSGI, PSGI etc.)での実装を推奨する」としたのですが、その理由が伝わればと思います。 さらに、今回のクイズではkeyをサーバプロセスのインメモリに保存しましたが水平スケールするアプリケーションであれば当然NGで、外部ストレージの利用が必要になります。RDBを使うのか、トランザクションをどう捉えるのか…等々、発展的な話題に派生することができますが本記事では割愛いたします。 ![image](https://hackmd.io/_uploads/ryVC8j6s6.png) ## 振り返り ### 良かったこと GitHub repositoryのtrafficを見るに、100名以上の方にrepositoryを見て頂き、数十名 (UU) の方には少なくともcloneして挑戦いただいたようでした。まったくの無風とならずに済みクイズの作り手としても安心しました。 また、今回のクイズでは回答後にXへのpost導線を設けており、ここからXにpostいただいたみなさま、盛り上げていただきありがとうございました! ![image](https://hackmd.io/_uploads/BJNTPsTi6.png) https://twitter.com/mihyaeru21/status/1756162976602886623 ### 改善できたこと #### 導線と回答ファネル YAPC::Hiroshima 2024参加者は400名ほどいたとのことなので、25%ほどにしかリーチできなかったことになります。会場のチラシ以外からも導線をたくさん目立つところに置くなど、クイズ挑戦 = repositoryへの導線をより工夫できれば良かったですね。 また、Xのpostとclone数しか知ることができず、実際にクイズに挑戦した方がどれだけいたか、採点処理がどれだけ行われたかを計測できなかったのも心残りです。採点のたびに外部にリクエストを飛ばす仕組みも企画段階では検討していたのですが、準備が間に合いませんでした...! #### 難易度 これは反省ポイントかどうかわかりませんが、難易度が適切だったかどうか?気になっています。 Xにpostされた結果はいずれも【ランク】Sを達成しており、YAPC参加者にとっては楽勝だった可能性がありますが、ランクSが早々に並んだのでC~Aを投稿しにくくなってしまった可能性もあります。こちらも採点タイミングで計測できていれば良い振り返り材料にできたなと反省です。 挑戦された方からのフィードバックお待ちしております。 #### segmentation fault on aarch64-linux Ruby 3.3.0のFiberがaarch64-linuxだと動かないようで、挑戦いただいた方からsegmentation faultが起きたとの報告を受けてしまいました。 https://bugs.ruby-lang.org/issues/20085 実はこの問題は社内でフィードバックを募ったときに同僚が踏んでいたのですが、直後にコードを変更したら再現しなくなった → さらにそのあとに変更を加えたら再現するようになっていた → 再テストが漏れたまま当日を迎えた...という経緯でした。 急遽Rubyを3.2.3にdowngradeすることで動くように修正しまして────ライブ感は出ましたが────ご迷惑をおかけしました 🙏 ## 余談 ### 3言語での出題について 上記の解説から察せられるように、スマートバンクはRubyをメイン言語とする会社です。カード決済周辺ではGoも利用しており一部のエンジニアはGoに詳しかったりもしますが、Perlについて詳しいエンジニアは(たぶん、今のところ)おりません。 どの言語で出題すべきか逡巡しましたがYAPCでPerlは外せないだろうと判断。複数言語のコードを準備するのは大変そうに思ったものの、**拡張子が異なる言語のファイルにコードをコピペすると変換してくれるJetBrains AI Assistantの便利機能**によりサクッと移植が完了。 https://www.jetbrains.com/help/idea/convert-files-to-another-language.html あとは月並みですがChatGPTに頼りつつDockerfileを書き上げる程度で済みました。 ### クライアントコードの難読化 「クライアントの挙動を見ればクイズへの回答は容易なのでは?」と思った方もいると思います。 その通りでして、repositoryに同梱しているクライアントコード `_blackbox/original_client.rb` に採点のロジックは記述されているので、見ればある程度の推測は立つものです。今回は商品・景品などのインセンティブを設けなかったため、このあたりはゆるくやらせてもらいました。 一応、`_blackbox/README.md`に「このディレクトリは見なくても解ける」旨を記述したり、実際に動くクライアントコード`client/client.rb`は難読化しておいたりと地味な工夫をしておりました。 https://twitter.com/ohbarye/status/1756181099049587184 ## おわりに イベントにてこのようなクイズ企画を実施するのは個人としても会社としても初の試みではありましたが、作問する過程や、同僚からのフィードバック、当日の反響... 各々を通じて大変楽しませてもらいました。次回以降にも活かしていける学びも得られました。 最後に、スマートバンクでは実際にIdempotency-Key Headerを導入した決済サービス[B/43](https://b43.jp/)を提供しており、冪等性を意識した堅牢な設計と実装に楽しさを感じるエンジニアを募集しています。カジュアル面談などは随時実施していますので、ぜひお気軽にお声がけください。 https://smartbank.co.jp/positions/205ff77ca53648a1952efe962422e71c --- 本記事は[@ohbarye](https://twitter.com/ohbarye)が執筆しました。本クイズの作成にあたりフィードバックをくれた [@tmnb](https://twitter.com/tmnbst) [@shohei_mitani](https://twitter.com/shohei1913) [@osyoyu](https://twitter.com/osyoyu) [@hirotea](https://twitter.com/nifuchi222222) に改めて感謝を捧げます!