マルチDBでリードレプリカを有効にしたAPIモードのRails7において書き込み系リクエストが失敗する事象の対処方法と原因

掲題の通り、

  • 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::CookiesActionDispatch::Session::CookieStoreActiveRecord::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 が唯一の公式実装なので、無駄な処理量を受け入れてでもそれに乗っておいたほうが安心という判断。

いままさに困っているわけでないのであれば自分でコードを書くべきではないのかもしれない。
セッションストアについての処理が増える程度ではリクエストの処理時間に大した差がつかないかもしれない。
無駄な処理をしないために独自実装するのは「推測するな、計測せよ」に違反あるいは「早すぎる最適化は諸悪の根源」に該当するかもしれない。

でも「推測するな、計測せよ」や「早すぎる最適化は諸悪の根源」はコードを理解しやすさのためにシンプルに保てという主旨なので、「システム間APIRailsなのにどうして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::CookieActionDispatch::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::CookieActionDispatch::Session::CookieStore を追加する必要はなくなる。 これも2の案と同様、Resolverクラスをどこに置くか迷ってしまうが…

また、2の案よりも独自実装のコードが3~4倍くらいになりそうである。まあ大した量ではないが…

まとめ

本記事では、マルチDBでリードレプリカを有効にしたAPIモードのRails7において書き込み系リクエストが失敗する事象について、そのような挙動になる理由と関係箇所のソースコード読解、考えうる対処方針を述べました。

Rails6だったころにはエラーがraiseされずリクエストが成功していたので、アップデートして挙動が変わり驚きました。 しかもdevelopment環境やtest環境ではマルチDBを設定していなかったためstaging環境にデプロイするまで判明しなかった。

考えうる対処方針の節で書いた3つの方針のうちどれがよさそうか、現時点でははっきりしません。
自分がこの事象に遭遇したプロダクトでは、ユーザのブラウザ上で動くReactからリクエストを受けうるAPIだったため、案1を採用しました。

同じ事象に遭遇した人の参考になれば幸いです。