掲題の通り、
- APIモードのRails7
- セッションストアは未設定
ActiveRecord::Middleware::DatabaseSelector::Resolver
を使ってマルチDBでプライマリとリードレプリカの自動切換えをしている- POST, PATCH, PUT, DELETEリクエストにおいて500エラーが返る
- ログには
ActionDispatch::Request::Session::DisabledSessionError
が出力されている
ような事象の対処方法と原因です。
この記事は2022/01/16時点で最新であるRails 7.0.1に基づいて書いています。
まず最初に手っ取り早く動く対処方法だけ述べます。このような挙動になる理由と関係箇所のソースコード読解、考えうる対処方針は記事の続きにて。
手っ取り早く動く対処方法
/config/application.rb または /config/environments/ 以下の該当環境の設定に
config.session_store :cookie_store config.middleware.insert_before ActiveRecord::Middleware::DatabaseSelector, ActionDispatch::Cookies config.middleware.insert_before ActiveRecord::Middleware::DatabaseSelector, ActionDispatch::Session::CookieStore
と書くとひとまず動作するようにはなる。
このような挙動になる理由と関係箇所のソースコード読解
このような挙動になる直接的な理由としては、Rails7以降、セッションストアが未設定の状態でセッションストアに書き込もうとした場合に、 ActionDIspatch::Request::Sessionクラスのインスタンスがエラーをraiseするようになったため。 PRはこれ。 github.com
RailsのマルチDBの仕組みでは ActiveRecord::Middleware::DatabaseSelector
というRackミドルウェアがwiritingロール(プライマリ)とreadingロール(リードレプリカ)のどちらに接続するかを制御している。
その決定ロジックは ActiveRecord::Middleware::DatabaseSelector::Resolver
クラスに、決定に必要な情報は ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
クラスに表現されている。
前述したセッションストアを使っているのは後者の ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
クラス。
RailsガイドのマルチDBについてのページの第4節を参照するとこのように書いてある。
Railsは「自分が書き込んだものを読み取る」ことを保証するので、delayウィンドウの期間内であればGETリクエストやHEADリクエストをwriterに送信します。
自分の言葉で言い換えれば、自分が書き込んだものを確実に読み取るために、自分の最終書き込み時刻を記憶し、その時刻から一定時間(上記Railsガイドの例では2秒)以内はリードレプリカでなくプライマリから読み出すようにする、ということ。 その最終書き込み時刻の記憶先にセッションストアを使っているので、前述のPRと合わさると、セッションストアが未設定の場合にエラーがraiseされるというわけ。
であれば、セッションストアを設定すれば良いのだが、 RailsガイドのAPIモードについてのページのなかのセッションミドルウェアについての記述を参考に
config.session_store :cookie_store config.middleware.use ActionDispatch::Cookies config.middleware.use config.session_store, config.session_options
と記述しても実は動作しない。
なぜならば config.middleware.use
はミドルウェアスタックの末尾に新しいミドルウェアを追加するという動作のため、ActionDispatch::Cookies
と ActionDispatch::Session::CookieStore
は ActiveRecord::Middleware::DatabaseSelector
よりも後段に追加されてしまい、 ActiveRecord::Middleware::DatabaseSelector
を通る時点は結局セッションストアを使えないままだからである。
そのため最初に述べた「手っ取り早く動く対処方法」のように config.middleware.insert_before
を使って明示的に ActiveRecord::Middleware::DatabaseSelector
の前段に挿入すれば動作する、ということになる。
考えうる対処方針
APIモードのRailsは往々にしてシステム間アクセスだけを受けるような立場の場合もあり、そのような場合ではCookieは基本的に使われないため、 ActiveRecord::Middleware::DatabaseSelector::Resolver
クラスおよび同Sessionクラスは無駄な処理量になってしまう。
そのような場合にどう対処するかについては、自分で思いつく範囲では下記の3つの対処方針がある。
1. ActionDispatch::Session::CookieStore を設定する
本記事冒頭の「手っ取り早く動く対処方法」で述べた方針。
現状では ActiveRecord::Middleware::DatabaseSelector::Resolver
が唯一の公式実装なので、無駄な処理量を受け入れてでもそれに乗っておいたほうが安心という判断。
いままさに困っているわけでないのであれば自分でコードを書くべきではないのかもしれない。
セッションストアについての処理が増える程度ではリクエストの処理時間に大した差がつかないかもしれない。
無駄な処理をしないために独自実装するのは「推測するな、計測せよ」に違反あるいは「早すぎる最適化は諸悪の根源」に該当するかもしれない。
でも「推測するな、計測せよ」や「早すぎる最適化は諸悪の根源」はコードを理解しやすさのためにシンプルに保てという主旨なので、「システム間APIのRailsなのにどうしてCookieを使ったセッションストアが有効になっているのか」という混乱を生みかねないこの方針も少し微妙かもしれない…悩ましいところ。
2. セッションストアを使用しない ActiveRecord::Middleware::DatabaseSelector::Resolver::Session と同じインターフェイスのContextクラスを実装する
下記のような、インターフェイスだけ同じで結果が常に同じになるContextクラスを実装する案。
class PseudoContext def self.call(_) = new def initialize; end def last_write_timestamp = 0 def update_last_write_timestamp; end def save(_); end end
この方法はRailsガイドにも公に書いてあるので、できるだけ仕様が維持される公開APIと考えられる。
そうすればセッションストアは不要になるので、 ActionDispatch::Cookie
と ActionDispatch::Session::CookieStore
を追加する必要はなくなる。
このPseudoContextをどこに置くか迷ってしまうが…
3. Contextの結果に依らない ActiveRecord::Middleware::DatabaseSelector::Resolver と同じインターフェイスのResolverクラスを実装する
ActiveRecord::Middleware::DatabaseSelector::Resolver
がプライマリとリードレプリカのどちらに問い合わせするかを決めている。
Contextクラスの内容に依存しないResolverクラスを実装し、それを使う案。
ActiveRecord::Middleware::DatabaseSelector
が必ずContextクラスのインスタンスを生成してResolverクラスに渡すため、contextクラスも一応必要だが、中身は要らないので単に Class.new
で良くなるだろう。
そうすればセッションストアは不要になるので、 ActionDispatch::Cookie
と ActionDispatch::Session::CookieStore
を追加する必要はなくなる。
これも2の案と同様、Resolverクラスをどこに置くか迷ってしまうが…
また、2の案よりも独自実装のコードが3~4倍くらいになりそうである。まあ大した量ではないが…
まとめ
本記事では、マルチDBでリードレプリカを有効にしたAPIモードのRails7において書き込み系リクエストが失敗する事象について、そのような挙動になる理由と関係箇所のソースコード読解、考えうる対処方針を述べました。
Rails6だったころにはエラーがraiseされずリクエストが成功していたので、アップデートして挙動が変わり驚きました。 しかもdevelopment環境やtest環境ではマルチDBを設定していなかったためstaging環境にデプロイするまで判明しなかった。
考えうる対処方針の節で書いた3つの方針のうちどれがよさそうか、現時点でははっきりしません。
自分がこの事象に遭遇したプロダクトでは、ユーザのブラウザ上で動くReactからリクエストを受けうるAPIだったため、案1を採用しました。
同じ事象に遭遇した人の参考になれば幸いです。