あきろぐ

いろいろめもするよ🐈🐈🐈

FactoryBotでデータを大量に作成する2パターンの速度比較をしてみた

背景

Rspecでテストを行う際にテストデータが大量に必要だったので、create_listを使ってデータ作成していましたが、create_listを使うよりbuild_listしたものをbulk_importした方が高速化できると教えてもらいました。

bulk_importActiverecord-Importというライブラリを入れるとバルクインサートができるようになるものです。

github.com

そこで、実際にどの程度高速化するのか気になったので、create_listを使う場合とbuild_listbulk_importを使う場合で速度検証してみました。

検証内容

  • 作成するデータ量は、5、10、100、200、300の5パターンとする
  • 計測するのはデータを作成するのにかかった時間(Time.currentをデータ作成前後で取得し差分とる)
  • 各パターンで5回の速度を計測したものの平均値を算出し、比較する
# create_listを使う場合
create_list(:article, 5)

# build_listとbulk_importを使う場合
Article.bulk_import build_list(:article, 5)

検証結果

データ量が300になると、2秒程度の差になりました。想定よりそこまで差がつかない結果となりました。

回数 create_listの場合(s) build_list & bulk_importの場合(s)
5 0.3082704 0.2623282
10 0.7855298 0.6998084
50 3.892437 3.3376432
100 7.7685084 7.0694844
200 15.8506986 14.4952762
300 22.8225408 20.3966336

Rspecのプロファイリングを有効にする--profileオプションを使ってテスト実行にかかった時間でも比較してみました。

※ 300データのみで計測(5回平均)

# rspecのテストはAPIレスポンスが200を返すかどうかの確認のみ
# 細かい部分は省略
subject { get articles_path }

it 'return 200' do
  subject
  expect(response.status).to eq 200
end
回数 create_listの場合(s) build_list & bulk_importの場合(s)
300 24.358 19.116

テストの実行時間でみると約5秒の差がつきました。

どちらも大きな差がでるほどではありませんでしたが、これが複数のexampleだったり大量のテストデータを何回も作成する場合であれば、大きな差になりそうです。

まとめ

どちらを使っていても大量にデータを作成する場合は、数に比例して遅くなってしまうので、例えばデータの数を閾値としてつかっていて、定数化しているならstub_constを使って定数をスタブ化し、低い閾値にした方がテストが遅くならずに効率的に実施できるかと思います。

rubydoc.info

Railsで時間をJSON形式の文字列に変換するときに気をつけたいこと

前提

Railscreated_atを含む文字列をJSONに変換したものを受け取った後、そのJSON文字列からcreated_atを取り出し、DBにデータを取りに行くような実装がされていたとします。 その際、想定通りのデータがDBから取得できずに困ったので、その調査&原因をまとめます。

# articleテーブルから1レコードを取得する
article = Article.last
  Article Load (6.0ms)  SELECT "articles".* FROM "articles" ORDER BY "articles"."id" DESC LIMIT $1  [["LIMIT", 1]]
=>
#<Article:0x000000010c6eae08
...

# articleのレコードを使ってjsonデータを生成する
data = { id: article.id, created_at: article.created_at }
=> {:id=>1040597104, :created_at=>Sun, 05 Mar 2023 21:34:37.707825000 JST +09:00}
json = JSON.generate(data)
=> "{\"id\":1040597104,\"created_at\":\"2023-03-05 21:34:37 +0900\"}"

問題

上記のようにJSON化されたデータを受け取り、パースした上でDBにデータを取りに行くと、articlesに含まれていてほしいレコードが存在していませんでした。

data = JSON.parse(json)
=> {"id"=>1040597104, "created_at"=>"2023-03-05 21:34:37 +0900"}

articles = Article.where('id < ? and created_at < ?', data["id"], data["created_at"])
  Article Load (1.8ms)  SELECT "articles".* FROM "articles" WHERE (id < 1040597104 and created_at < '2023-03-05 21:34:37 +0900')
=>
[#<Article:0x000000010c1d16d8
...

原因

created_atを含む文字列をJSON.generateするとミリ秒が切り捨てられるため、ほぼ同時刻にDBに保存されたデータは対象外となり取得できていませんでした。

解決策

created_atiso8601形式のStringに変換した後、JSON化すればミリ秒を保持することができます。

data = { 'id': article.id, 'created_at': article.created_at.iso8601(6) }
=> {:id=>1040597104, :created_at=>"2023-03-05T21:34:37.707825+09:00"}

# ミリ秒まで保持されている
json = JSON.generate(data)
=> "{\"id\":1040597104,\"created_at\":\"2023-03-05T21:34:37.707825+09:00\"}"

パースする時は、in_time_zoneメソッドでActiveSupport::TimeWithZoneクラスのオブジェクトに変換します。

data = JSON.parse(json)
=> {"id"=>1040597104, "created_at"=>"2023-03-05T21:34:37.707825+09:00"}

data['created_at'].in_time_zone
=> Sun, 05 Mar 2023 21:34:37.707825000 JST +09:00

参考

api.rubyonrails.org

docs.ruby-lang.org

Elasticsearchの調査系コマンド&便利な使い方

基本的なElasticsearchのコマンドは、たくさんまとまった記事があると思うので特に困らないと思いますが、プラスαとしてElasticsearchクラスタを運用していく上で知っていると便利かもと感じたコマンドや使い方を備忘録としてまとめてみました。

定期的に追加していくかもしれないです。

表示するカラムを絞り込む

例えば、クラスタに所属するノードの情報を表示させたい場合、cat nodes APIを利用しますが、デフォルトだとレスポンスは以下のようになります。

$ curl -X GET "localhost:9200/_cat/nodes?v"

ip           heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
xxx.xx.x.xxx           65          97  19    0.08    0.12     0.09 mdi       -      node-1
xxx.xx.x.xxx            72          97  17    0.24    0.19     0.16 mdi       *      node-2

レスポンスに含めるのは必要な情報だけで良い場合、以下のようにクエリパラメータにhを使うことでカラムを絞り込むことができます。

# ip, cpu, heapPercentだけ表示
$ curl "localhost:9200/_cat/nodes?h=ip,cpu,heapPercent&v=true"

ip           heap.percent cpu
xxx.xx.x.xxx          80   8
xxx.xx.x.xxx           85   4

また、デフォルトでは表示されないカラムを指定し、レスポンスに含めることも可能です。表示できるカラムは公式ドキュメントを確認してください。

# heap.max, pidも含めて表示(選出は適当)
$ curl -X GET "localhost:9200/_cat/nodes?h=ip,heap.percent,heap.max,cpu,pid&v=true"

ip           heap.percent heap.max cpu pid
xxx.xx.x.xxx            69  495.3mb   2 4257
xxx.xx.x.xxx            65  495.3mb  11 3686

www.elastic.co

特定カラムの昇順、降順でソートする

ノードのリソース使用状況を確認する上で、使用率が高いもの順に表示させたいというユースケースもあると思います。その場合、クエリパラメータsを使うことで降順または昇順にソートさせることができます。

# heapPercent降順で表示
$ curl -X GET "localhost:9200/_cat/nodes?h=ip,heap.percent,cpu&s=heap.percent:desc&v=true"
ip           heap.percent cpu
xxx.xx.x.xxx            84  10
xxx.xx.x.xxx            61   5

シャードがUNASSIGNEDになった原因を調べる

Elasticsearchクラスタを運用する上で、基本的に可用性担保のためアロケーションを有効化している場合が多いかと思います。 ノードに障害が発生しても自動的に正常なノードにシャードがアロケーションされるようになっているので、アロケーションによってシャードが配置されないことは稀にしか発生しないと思いますが、何かしらの作業によってシャードがUNASSIGNEDになってしまい困った・・・という場合に使えるコマンドが、cluster allocation explain APIです。 このコマンドは、UNASSIGNEDになってしまった原因について確認することができます。

UNASSIGNEDが発生する原因は、ノード数に対してインデックスのレプリカ数が多かったり、アロケーションが無効化されていることによってシャードが配置できない等いくつか挙げられます。

# レスポンスは公式ドキュメントの例
$ curl localhost:9200/_cluster/allocation/explain?pretty

{
  "index" : "my-index-000001",
  "shard" : 0,
  "primary" : true,
  "current_state" : "unassigned",                 
  "unassigned_info" : {
    "reason" : "INDEX_CREATED",                   
    "at" : "2017-01-04T18:08:16.600Z",
    "last_allocation_status" : "no"
  },
  "can_allocate" : "no",                          
  "allocate_explanation" : "cannot allocate because allocation is not permitted to any of the nodes",
  "node_allocation_decisions" : [
    {
      "node_id" : "xxxxxx",
      "node_name" : "node-0",
      "transport_address" : "127.0.0.1:9401",
      "node_attributes" : {},
      "node_decision" : "no",                     
      "weight_ranking" : 1,
      "deciders" : [
        {
          "decider" : "filter",                   
          "decision" : "NO",
          "explanation" : "node does not match index setting [index.routing.allocation.include] filters [_name:\"nonexistent_node\"]"  
        }
      ]
    }
  ]
}

www.elastic.co

アロケーションの有効化、無効化

例えば、ノードの入れ替え作業で一時的にアロケーションを無効化したい場合などに使えます。

現在のアロケーション設定の確認および変更は、cluster APIを使います。 cluster.routing.allocation.enableに設定できるのは、allprimariesnew_primariesnoneの4つです。allは全てのシャードがアロケーション対象になります。

www.elastic.co

# アロケーション確認(有効な場合、cluster.routing.allocation.enable = allなどnone以外)
$ curl localhost:9200/_cluster/settings?pretty

{
  "persistent" : {
    "cluster" : {
      "routing" : {
        "allocation" : {
          "enable" : "all"
        }
      }
    }
  },
  "transient" : {
    "cluster" : {
      "routing" : {
        "allocation" : {
          "exclude" : {
            "_name" : ""
          }
        }
      }
    }
  }
}
# アロケーション無効にする(cluster.routing.allocation.enable = node)
$ curl -XPUT localhost:9200/_cluster/settings -d '{
"persistent": {"cluster.routing.allocation.enable": "none"}
}'

# 確認
$ curl localhost:9200/_cluster/settings?pretty
{
  "persistent" : {
    "cluster" : {
      "routing" : {
        "allocation" : {
          "enable" : "none"
        }
      }
    }
  },
  "transient" : { }
}

特定ノードにアロケーションされないようにする

ノードの入れ替え作業で特定ノードにアロケーションされないようにするときに使うコマンドです。 cluster.routing.allocation.exclude._nameにdataノード名を指定することでそのノードにアロケーションされないようになります。

curl -XPUT localhost:9200/_cluster/settings -d '{
  "transient": {
    "cluster.routing.allocation.exclude._name": "dataノード名"
  }
}'

ノード名(_name)だけでなく_ip_hostなどで指定することも可能です。

www.elastic.co

参考

Elasticsearch関連の本はこちらがおすすめです。

FactoryBotでインスタンスを一気に作成するときに値をユニークにしたい

create_listは、複数のインスタンスを作成するときに便利なメソッドですが、作成されるインスタンスのテストデータは同じになってしまいます。

create_list(:article, 3, title: 'test', description: 'test')

createメソッドで愚直に1つずつインスタンスを作成すれば、以下のように渡す引数をインスタンスごとに変えることはできますが、

create(:article, title: 'test1', description: 'test')
create(:article, title: 'test2', description: 'test')
create(:article, title: 'test3', description: 'test')

でもそれは面倒なのでもっと簡潔に書きたい、できれば一行で...という場合もあると思います。 そこで、一行で書ける解決方法について模索したのでまとめてみます。

1. 配列をループさせてインスタンス作成するときに渡す引数を変える

タイトルをユニークな値にしたいので、ユニークな値を持った配列をループさせてcreateメソッドでインスタンスを作成します。

['test1', 'test2', 'test3'].each { |title| create(:article, title: title, description: 'test') }

2. 先にインスタンスを作成し、後からインスタンスの持つ値を変更する

create_listメソッドで一気にインスタンスを作成し、後からタイトルを変更するパターンです。注意すべきなのは、create_listメソッドで初期データはDBに保存されますが、後で書き換えられたタイトルは保存されていないので、再度saveする必要があります。

create_list(:article, 3, title: 'test', description: 'test') { |v, i| v.title = 'test' + i.to_s }.map(&:save!)

3. そもそもFactoryBotの定義でユニークな値になるようにすればいい

そもそも論かもしれませんが、最初からsequenceを使ってtitleがユニークになるようにしとけばOKかもですね。

FactoryBot.define do
  factory :article do
    sequence(:title) { |n| "test#{n}" }
    description { 'test' }
  end
end

create_list(:article, 3)

github.com

sequenceを使ってtitleがユニークになるように定義していても、テストケースによってはデフォルト値から変更したい場合も発生すると思うので、上記の2パターンが有効な場合もありそうです。

参考

github.com