TL;DR
Railsでboolean型のカラムを作ったら、ElasticSearchにlong型でマッピングされてエラーが出た。
原因は本番環境へのソースコード反映並びにRailsの再起動と、DBへのmigrationが行われるタイミングのズレにあった。
対象モデルの as_indexed_json
メソッドを変更して改修した。
問題
ElasticSearchを導入してしばらく経ったある日、boolean型のカラムを追加する反映を行ったところ、対象モデルのレコードを編集、保存する機能全般で以下のようなエラーが出た。
[400] {"error":"MapperParsingException[failed to parse [column_name]]; nested: JsonParseException[Current token (VALUE_FALSE) not numeric, can not use numeric value accessors\n at [Source: [B@14286565; line: 1, column: 4408]]; ","status":400}
どうやら VALUE_FALSE
は numeric
じゃないからダメだと言われてるらしいので、本番環境で使用しているElasticSearchのマッピングを確認すると、追加したカラムについてのフィールドが、boolean型ではなくlong型になっていた。
原因
localではなかなか再現しなかったり、そもそもElasticSearchもいつの間にか導入されてたものでそれ自体よくわからなかったり、あまりにもわからなすぎてとりあえず1日寝かしてみたりした。
結構ガチャガチャいじり回した結果、別サーバーで行う本番DBへのmigrateと、実際のソースコード反映並びにRails再起動のタイミングにズレがあることが原因だと判明した。
具体的な挙動は以下のようなもの。
① 本番サーバーでRailsが起動する際に、DBのcolumn情報をモデルにロードする
この時点では新しく追加される予定のcolumn(図中の new_column
)に関する情報はロードされていない。
要するに前回の反映時にロードされたモデルの情報でサービスが動いている状態。
② 別サーバーを用いて、本番用のDBにcolumnを追加する
ソースコードを反映する前に、参照先のcolumnを用意しておくため、別サーバーを使って本番用のDBに変更を加える。
③ 本番サーバーにソースコードが反映される前に、サービス上でDBのデータがロードされてインスタンスが生成される
本番反映によるRailsの再起動前にサービス上でDBのデータがロードされ、インスタンスが生成される。
この際、new_column
にdefault値を設定していた場合、DBにはnew_column
のデータが存在するため、それも一緒にロードされる。しかし、Railsが持っているモデルの情報は更新されていないため、「なんかよくわからんけど知らん値が来たわ」という状態でインスタンスが生の値をそのまま持つ。
Railsのboolean型は、MySQLではtinyintで実現されるため、仮に default: false
としていた場合、この値は 0
である。
④ 生成されたインスタンスを保存し、documentをElasticSearchに追加する
インスタンスがsaveされる際、 after_commit
が走って refresh_index
が実行され、ElasticSearchにdocumentを追加する(要するにデータの更新)。
この時、dynamic mappingが有効になっていると、mappingが設定されていない属性について、インスタンスの持つ値から型が推察される。
図中で言うと、上から順に
「idは整数だからlong、nameはstring、そしてemailはstring。new_columnは……モデルに情報がないけど0なんだから整数でlongだよね」
という話になり、boolean型がlong型でマッピングされる処理が実現してしまう。
対策
1. dynamic mappingを切る
Elastic Searchのドキュメントに従って、dynamic mappingをオフにする。
Elastic Searchの用途がはっきりしているなら(というか本来的には用途がはっきりしていない状態で導入するべきではないと思うけども)、必要なattributeをホワイトリスト形式で指定して、dynamic mappingを切ってしまうのが一番安全だと思う。
ただ今回については、今後Elastic Searchをどのように利用していくかということについて、まだ明確になっていないところも多かったため、協議の結果これはナシでということに。
2. Elastic Searchに投げるattributeを、モデルの持つ情報準拠にする
Elastic Searchに送信するattributeは、 as_indexed_json
メソッドで指定できる。
例えば以下のようにすると、User
モデルのattributeに加えて、関連するItem
モデルのname
も送信できる。
def as_indexed_json(options = {})
options = options.merge(
include: {
item: { only: :name }
}
)
json = as_json(options)
end
そもそもモデルのインスタンスが持つattributeがElastic Searchに送信されるようになっているのは、ここで呼ばれているas_json
メソッドが、インスタンスメソッドのattribute_names
を使用しているため。
参考:
https://github.com/rails/rails/blob/master/activemodel/lib/active_model/serializers/json.rb#L88
https://github.com/rails/rails/blob/master/activemodel/lib/active_model/serialization.rb#L127
つまりこの部分で、モデルのインスタンスではなく、モデル自体が持つ情報をもとに送信するattributeを決定するようにしてやればよい。
具体的には、モデルのクラスメソッドの方のattribute_names
を使う。
インスタンスメソッドのattribute_names
とクラスメソッドのattribute_names
の違いについてはここにまとめたので、参考にどうぞ。
def as_indexed_json(options = {})
options = options.merge(root: false)
# migrationのタイミングに依存するバグを防ぐため、Railsが認識しているcolumnだけをindexに追加する
columns_to_index = self.class.column_names.map &:to_sym
options = options.merge(
only: columns_to_index,
include: {
item: { only: :name }
}
)
json = as_json(options)
end
この方法であれば、今後の利用法の幅に備えて全attributeをElastic Searchに送信しながら、今回起きたようなバグは防ぐことができる。
雑感
実際にサービスに乗せて運用してみないと顕在化しないようなバグにハマったのでなかなかに戸惑った。
対処法についても、特に何かを参照したわけではなく、gemやRailsの中を読みながらどうにかこうにかひねり出したものなので、ひょっとしたらあんまよくないやり方なのかもしれない。
そもそも記事を書くにあたって使った各用語の用法なんかも割と怪しい気がするので、ご指摘などありましたらよろしくおねがいします。