Mysql2 の "MySQL client is not connected" について

Rails アプリケーションのドメインロジックを共有するためにバッチでも Rails を使っているケースはあるかと思います。
先日、長時間稼働しているバッチで MySQL サーバの再起動後に MySQL client is not connected が起きたんですが、数年 Rails を使っていて初めて遭遇したエラーだったので、次の 2 点について調べてみました。

  • このエラーにはどう対処すべきなのか?
  • バッチ特有の問題なのか?

そもそも MySQL client is not connected とは?

Mysql2この辺で定義されているエラーです。

定義を見る限り、client が初期化されているにも関わらず、network socket (file descriptor) が無効な状態だとこのエラーになるみたいですね。
network socket が無効な状態になるケースですが、主に次の 2 つのケースが考えられるようです。

mysql_send_query でエラーになった

例えば MySQL server が停止していたり、再起動が原因で今まで使っていた network socket が使えなくなったケースです。

cf. https://github.com/brianmario/mysql2/blob/0.4.9/ext/mysql2/client.c#L776

require "mysql2"

Mysql2::VERSION  #=> "0.4.9"
client = Mysql2::Client.new
client.query("SELECT 1")

system("mysql.server restart")
client.query("SELECT 1")
#=> Mysql2::Error: MySQL server has gone away
client.query("SELECT 1")
#=> Mysql2::Error: MySQL client is not connected

SQL が read_timeout 以内に実行が終わらなかった

read_timeout は SQL を投げてから実行が終わるまで(NOT 結果を受け取るまで)に最大で何秒許容するかを指定しますが、この時間が過ぎても実行が終わらなければタイムアウトし、network socket も無効化されるみたいです。

cf. https://github.com/brianmario/mysql2/blob/0.4.9/ext/mysql2/client.c#L784

client = Mysql2::Client.new(read_timeout: 0)
client.query("SELECT 1")
#=> Mysql2::Error: Timeout waiting for a response from the last query. (waited 0 seconds)
client.query("SELECT 1")
#=> Mysql2::Error: MySQL client is not connected

MySQL client is not connected の対処方法

Mysql2

クライアントを作り直すのが良さそうです。

client.close
client = Mysql2::Client.new
client.query("SELECT 1")

Active Record

Active Record では Mysql2Adapter#reconnect! でクライアントを作り直してくれます。

require "active_record"

ActiveRecord::VERSION::STRING  #=> "5.1.4"
ActiveRecord::Base.establish_connection(adapter: "mysql2")
ActiveRecord::Base.connection.execute("SELECT 1")

system("mysql.server restart")
ActiveRecord::Base.connection.execute("SELECT 1")
#=> ActiveRecord::StatementInvalid: Mysql2::Error: MySQL server has gone away: SELECT 1
ActiveRecord::Base.connection.execute("SELECT 1")
#=> ActiveRecord::StatementInvalid: Mysql2::Error: MySQL client is not connected: SELECT 1

ActiveRecord::Base.connection.reconnect!
ActiveRecord::Base.connection.execute("SELECT 1")

MySQL API の reconnect オプションではダメなのか?

MySQL の API には接続が切れた場合に自動で再接続してくれるオプションがあるんですが、Active Record を利用する場合はセッションの初期化等色んな処理が走っているので、どういう処理が行われているか正確に理解していて Rails のアップグレードにも追従できる自信がない限りは使わない方が無難と思われます。

cf. MySQL :: MySQL 5.7 Reference Manual :: 27.8.20 C API Automatic Reconnection Control

例えば、AbstractMysqlAdapter ではセッションの最初に sql_mode をセットしているんですが、recoonect を有効にすると再接続の際に sql_mode がリセットされます。

ActiveRecord::Base.establish_connection(adapter: "mysql2", reconnect: true)
ActiveRecord::Base.connection.execute("SHOW VARIABLES LIKE 'sql_mode'").to_a.dig(0, 1).split(",")
#=> ["ONLY_FULL_GROUP_BY",
# "NO_AUTO_VALUE_ON_ZERO",
# "STRICT_TRANS_TABLES",
# "STRICT_ALL_TABLES",
# "NO_ZERO_IN_DATE",
# "NO_ZERO_DATE",
# "ERROR_FOR_DIVISION_BY_ZERO",
# "NO_AUTO_CREATE_USER",
# "NO_ENGINE_SUBSTITUTION"]

system("mysql.server restart")
ActiveRecord::Base.connection.execute("SHOW VARIABLES LIKE 'sql_mode'").to_a.dig(0, 1).split(",")
#=> ["ONLY_FULL_GROUP_BY",
# "STRICT_TRANS_TABLES",
# "NO_ZERO_IN_DATE",
# "NO_ZERO_DATE",
# "ERROR_FOR_DIVISION_BY_ZERO",
# "NO_AUTO_CREATE_USER",
# "NO_ENGINE_SUBSTITUTION"]

MySQL client is not connected はバッチ特有の問題なのか?

Rails を Rack アプリケーションとして利用し、普通の運用を行っていればバッチ特有のエラーと言えそうです。しかも、バッチの中でも大量のデータを処理し、一部のデータでエラーが発生しても警告を出すだけで処理を継続させるようなバッチ特有のエラーと言えます。

というのも、普通のバッチであれば MySQL server has gone awayLost connection to MySQL server during query のようなエラーになった時点で異常終了するので、それでも処理を継続しない限り MySQL client is not connected にはならないからです。

また、Rack アプリケーションとしての Rails であれば、HTTP リクエストごとに ConnectionPool#connection 内の ConnectionPool#checkout で新規または既存のコネクションを取得し、その状態を確認し、接続が切れていれば再接続するようになっているので、MySQL server has gone awayLost connection to MySQL server during query のようなエラーになった時点で 500 を返し、次の HTTP リクエストで再接続することになります。
ConnectionPool#connection は同じ HTTP リクエストの間はキャッシュされるんですが、HTTP リクエストの最後でキャッシュを破棄しているので、HTTP リクエストごとに ConnectionPool#checkout が実行されます。

コネクションのキャッシュを破棄する流れは非常に追いにくいのですが、次のような仕組みのようです。

  1. ActionDispatch::Executor が Rails アプリケーション (Rack アプリケーション) の middleware として登録される
  2. active_record がロードされた時に ActiveRecord::QueryCache の callbacks を ActionDispatch::Executor に登録する