元祖若手のプログラミング奮闘記

元祖若手の奮闘記。主にメモ

Elasticsearch7にしたらtoo_many_buckets_exceptionになってしまった

結論

一回でまとめて取得していたものを数回に分けて取得するようにした。

対象のインデックス

GET logs/_mapping

{
  "logs" : {
    "mappings" : {
      "properties" : {
        "action" : {
          "type" : "text"
        },
        "user_id" : {
          "type" : "integer"
        },
        "company_id" : {
          "type" : "integer"
        },
        "request" : {
          "type" : "nested",
          "properties" : {
            "id" : {
              "type" : "integer"
            },
            "type" : {
              "type" : "text",
              "fielddata" : true
            }
          }
        },
        "timestamp" : {
          "type" : "date"
        }
      }
    }
  }
}

too_many_buckets_exceptionとは

Elasticsearchの用語に合わせて説明すると
指定した集約(aggregation)方法で分類(Bucket)された数が
1万を超えたために出たエラーである

SQLに例えて説明すると
GROUP BY で出力されたレコードの数が
1万を超えた場合である。

実際のElasticsearch で叩いてた内容

まずこんな検索Queryを作ったことを許してほしい

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "company_id": 1
          }
        },
        {
          "range": {
            "timestamp": {
              "gte": "now-1y",
              "lt": "now"
            }
          }
        }
      ]
    }
  },
  "aggs":{
    "by_user_id":{
      "terms":{
        "field":"user_id",
        "size":1000000,
        "missing":0
      },
      "aggs":{
        "by_month":{
          "date_histogram":{
            "field":"timestamp",
            "interval":"month",
            "format":"yyyyMM"
          },
          "aggs":{
            "by_request":{
              "nested":{
                "path":"request"
              },
              "aggs":{
                "by_request_type":{
                  "terms":{
                    "field":"request.type"
                  },
                  "aggs":{
                    "by_request_id":{
                      "terms":{
                        "field":"request.id"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

SQLで表現すると

SELECT
  user_id,
  DATE_FORMAT(timestamp,'%Y-%m') AS month,
  request_id,
  request_type
FROM
  logs
WHERE
  company_id = 1 AND
  timestamp BETWEEN DATE_SUB(NOW(),INTERVAL 1 YEAR) AND NOW()
GROUP BY
  user_id, DATE_FORMAT(timestamp,'%Y-%m'), request_id, request_type

確かに一年間分となるとそうなるよなと思いつつ
このエラーに対する設定は変えられるのだが、
現時点ではElasticsearch 公式では設定ではなくクエリ自体を修正するように勧めている。

公式通り検索範囲を狭めた

一年で1回ではなく 一ヶ月で12回に分けるようにした。

GET logs/_search

{
   "query":{
      "bool":{
         "must":[
            {
               "match":{
                  "company_id": 1
               }
            },
            {
               "range":{
                  "timestamp":{
                     "gte":"2020-01-01T00:00:00.000+09:00",
                     "lte":"2020-01-31T23:59:59.999+09:00"
                  }
               }
            }
         ]
      }
   },
   "aggs":{
      "by_user_id":{
         "terms":{
            "field":"user_id",
            "size":1000000,
            "missing":0
         },
         "aggs":{
            "by_request":{
               "nested":{
                  "path":"request"
               },
               "aggs":{
                  "by_request_type":{
                     "terms":{
                        "field":"request.type"
                     },
                     "aggs":{
                        "by_request_id":{
                           "terms":{
                              "field":"request.id"
                           }
                        }
                     }
                  }
               }
            }
         }
      }
   }
}

あとはサーバーサイド側で整えて今回は乗り越えた。

やはり基礎は学んでおかないと。。。

今回はこの本を読んで基本を抑えました。

Kindle unlimitedの対象となっていたので読んでみましたが
例えや図が多く、わかりやすい内容になっているので急いで学ばなくてはいけない人にもおすすめです

Rails5.2にしてからデプロイが徐々に遅くなっている

Ruby on Rails
Ruby on Rails

結果だけ気になる人のために
まずは結論から

原因

Rails5.2から導入されたgem Bootsnap
処理を早くするためにデプロイ毎にcacheし
古いのが消えずに残り続けていたから。

解決方法

cache を消した。

cd /var/www/myapp/shared
rm -rf tmp/cache/bootsnap*

原因わからず半ベソ編

最初はただのRails バージョンをあげただけだった。。

最近はどこの現場でもRailsのバージョンを最新にしたがる人なので
今回も段階的にRails6にしようと思ってた矢先でした

Rails5.2にしてからだんだんデプロイ時間が遅くなっていることに気付き、
5分もかからなかったデプロイが
三日ほどで10分以上かかるようになってしまいました。。。

グラフ1
グラフ1

グラフを見てもらうとわかるようにデプロイ失敗しているんですが
処理時間が長すぎるために10分応答がなかったので
CircleCIが失敗と判断しデプロイができないようになってしまいました。。

Unicornも勝手に落ちるように。。

挙げ句の果てにUnicornも一日のどこかで
一時的に504エラーを返すようになり
完全に半ベソ状態でした。。

考えつく限り対策したけどダメだった編

以下の三つだけ少し効果があったので紹介します

release以下のRailsを最低限削除

capistranoを使用しているのでreleaseディレクトリに5つのRailsが入っているので
最新から3つほど残し、他は一時的に削除した。

tmp/assets配下にある使われてないjsファイルを削除

こちらもプリコンパイルしたときのjsファイルが昔からずっと残っていたので
config/deploy.rb に以下の設定を加えて古いjsファイルを削除した。

set :keep_assets, 3

スワップ領域を作ってメモリを割り当てた

さすがAWSという感じでこちら の説明がわかりやすかった

あと他試したけど全く変わらなかった

  • ロードバランサーで一台切り離してから サーバー再起動 & Unicorn再起動
  • Unicorn ワーカプロセスを2つから5つに増やす。
  • メモリキャッシュ削除
  • CircleCI上でassets:compileして転送

やっとわかった編

当時はデプロイが遅いのとUnicornが勝手に止まったのが重なったせいで
いろんな説が飛び交いまして

Unicornの性能が悪い説
CircleCIが勝手に設定変えた説

とかもう結構何が悪いのかわからなくなっていたところ
bootsnap で処理が早くなるという記事を見つけて
「え!実は効いていないとか?有効にすれば元に戻るかも!」
と思い Githubを見ていたらこんなことを書いているのを見つけました。

Bootsnap readme
Bootsnap readme

大事なので一部抜粋すると

If you notice deploys getting progressively slower, this is almost certainly the cause.

もしデプロイが遅くなっているならこれが原因だ的なこと書いてるじゃん!!
ということですぐにcacheを削除しデプロイしてみた。。

嬉しい!!!

グラフ2
グラフ2

13分くらいかかってたのが5分程度に戻ってる!!
てかすみません僕が今回の戦犯でした。。。。
現場の皆様は叱ることなく今回の件を褒めていただきました。。

締め

今回の件でいろいろと学ばせてもらいました。
インフラも少し毛が生える程度に知識を得ることができました。。
雑にRailsのバージョンをあげてるわけではないんですが
もう少し差分があるところはもう少し調べないとなあと思います。。

Rails 更新時にParams Permitを間違えてまるっと子モデルのデータが消えた話

困った問題

なぜか子モデルが消して入れ直す形になってしまったので 忘れないようにメモ

class UsersController < ApplicationController
  before_action :set_user, only: %i[edit update]

  def edit; end

  def update
    if @user.update(user_params)
      redirect_to home_path
    else
      render :edit
    end
  end

  private

  def user_params
    params.require(:user).permit(
      :email,
      :password,
      user_profile_attributes: %i[address nick_name]
    )
  end

  def set_user
    @user = User.find(session[:user_id])
  end
end

# 親モデル
class User < ApplicationRecord
  has_one :user_profile, dependent: :destroy
  accepts_nested_attributes_for :user_profile
end

# 子モデル
class UserProfile < ApplicationRecord
  belongs_to :user
end

そして今回ハマったのがここなんですが user_params

これとは違うControllerで UserProfileを更新することがあるのですが

そうするとUsersController で更新した際に 見事に一回消して入れて行なってしまうため 前の項目がまるっと消されてる! という状況が生まれてしまうのでした。。

Unpermitted parameter: :id
   (1.6ms)  BEGIN
  UserProfile Load (2.4ms)  SELECT  `user_profiles`.* FROM `user_profiles` WHERE `user_profiles`.`user_id` = 1 LIMIT 1
  SQL (2.2ms)  DELETE FROM `user_profiles` WHERE `user_profiles`.`id` = 101
  SQL (2.9ms)  INSERT INTO `user_profiles` (`user_id`, `address`, `nick_name`, `created_at`, `updated_at`) VALUES (1, 'tokyo', 'hoge', 2020-02-20 13:51:02', '2020-02-20 13:51:02')
  SQL (2.5ms)  UPDATE `users` SET `updated_at` = '2020-02-20 13:51:02' WHERE `users`.`id` = 1
   (10.1ms)  COMMIT

ですが実はもう答えは書いてあるのです! そこ! Unpermitted parameter: :id

解決策

子モデルの permit parameter に :id を追加してあげれば ちゃんと更新されるのでした。

def user_params
  params.require(:user).permit(
    :email,
    :password,
    user_profile_attributes: %i[id address nick_name]
  )
end
 (1.9ms)  BEGIN
UserProfile Load (2.6ms)  SELECT  `user_profiles`.* FROM `user_profiles` WHERE `user_profiles`.`user_id` = 1 LIMIT 1
SQL (2.5ms)  UPDATE `user_profiles` SET `nick_name` = 'hogehoge', `updated_at` = '2020-02-20 14:01:29' WHERE `user_profiles`.`id` = 102
SQL (2.3ms)  UPDATE `users` SET `updated_at` = '2020-02-20 14:01:29' WHERE `users`.`id` = 1
 (3.6ms)  COMMIT

初歩的なミスですが 重大なバグになりそうなので書いておきました。

Rails5.2とbelongs_toとN+1問題の話

結果
レコードに登録するときに
外部キーをidとして入れると
N+1になる

環境はこちら

rails 5.2.1
activerecord-import 0.27.0
ruby 2.5.3

なにいってんだこいつ的な感じだと思うので説明

class User < ApplicationRecord
  has_many :items
end

class Item < ApplicationRecord
  belongs_to :user
  validates :name, presence: true
end

そしてItemを複数取り込みする処理があるとする

class ItemImport
  def execute
    items = User.all.limit(100).each_with_object([]) do |user, items|
      %w(item_a item_b item_c).each do |name|
        item = Item.new(user_id: user.id, name: name)
        next unless item.valid?
        items << item
      end
    end

    Items.import items
  end
end

ここでN+1発生

>> ItemImport.new.execute
(前略)
User Load (0.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
User Load (0.1ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
User Load (0.1ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
User Load (0.2ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
User Load (0.6ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
User Load (1.3ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
User Load (0.1ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
(後略)

Rails5からbelongs_toで指定した値は
入力必須のため外部IDだけ入れておくと
ちゃんと有効な値か存在チェックをしてくれるという
仕様からうまれた問題だった

item = Item.new(user: user, name: name)

とすることで
チェック不要の有効な値が入っていると認識してくれて
無駄なクエリを発行せず
Insertしてくれるのでした
おしまい!

Railsって結構オンラインプログラミングスクールの
教材として使われるけど
完全にオーバースペックだよね
っていう小話

ファイルを複数コピーするだけのbash

使い方は

cp_files hogehoge.txt

と打つと
同じ階層に
1_hogehoge.txt
2_hogehoge.txt
...
5_hogehoge.txt

と作ってくれます。

cp_files hogehoge.txt 2

と引数にファイル数を指定することもできますが

簡単な処理なので
自分でカスタマイズした方がいいかもしれないです。
あくまで参考程度に

#/bin/bash

function cp_files() {
  if [ -e $1 ]; then
    local first_path="./"
    local array=( `echo $1  | tr -s '/' ' '`)
    local last_index=`expr ${#array[@]}`
    # ここでエラーがでたら 上を `expr ${#array[@]} - 1` に書き換えてください
    local file_name=$array[$last_index]

    if [[ "$1" =~ "^/" ]]; then
      first_path=/
    elif [[ "$1" =~ "^~/" ]]; then
      first_path=~/
    fi

    for i in `seq 1 "${2:=5}"`
    do
      array[last_index]=$i\_$file_name
      echo "cp $1 $first_path$(IFS=/; echo "${array[*]}")"
      cp $1 $first_path$(IFS=/; echo "${array[*]}")
    done
  else
    echo "Not Exists File: $1"
  fi
}

Crystal languageをJET BRAINS社のIDEで使いたい


って思い
RubyMine 2018.2
プラグインをインストールした話

使ってみた結果:
VSCODEを使おう
※2018/10時点

それでも使いたい方はこちらご参考ください

まずこちらURLを見ると
https://plugins.jetbrains.com/plugin/10213-crystal-language-plugin
2018.1.6で終了していることに気づく。。

まじかあと思いつつ
Githubを見てると見事に
開発が止まっていた。。
残念。。

ってことで
Issue, PullRequestを見てるとこんなすばらしいプルリクが!!
https://github.com/crystal-lang-tools/intellij-crystal-lang/pull/5


どうやら動くっぽいけど
Masterにマージしないっぽい。。

じゃあ自分で同じ差分を準備して
Buildするかってことで

git clone

git clone https://github.com/crystal-lang-tools/intellij-crystal-lang.git
cd intellij-crystal-lang

一応念のためブランチ切っておく

git checkout -b feature/2018.2
git fetch origin pull/5/head:feature/2018.2
cat gradle.properties

これで ideaVersion = 2018.2
となっていることを確認する

そしてビルド
(JDK必要です!)

./gradlew idea
./gradlew build


ビルド成功したら

勝手にプラグインが有効になってると思いきや
なってなかった。。(今思うと当たり前か)

ってことで
ここからIDEで操作

Preferences > Plugins > Install plugin from disk...

を選択し

下記パスにあるzipファイルのままでOpenする

intellij-crystal-lang/build/distributions/intellij-crystal-1.3-SNAPSHOT.zip

するとPluginsのリストの中に
Crystal language plugin の項目があるので
Checkして再起動して

無事に動くことを確認!!

使い勝手は色々ともう少し機能があればなと
思うのだが引き続き調査中!

【jQuery】 Gifを出したりしまったりする

app2.html

<html>
<head>
  <title>Gif TEST</title>
</head>
<body>
  <h1>Gif TEST</h1>

  <div>
    <h3>最初からDOMを用意して display: none; するパターン</h3>
    <div id="loading1" style="display: none;">
      <label>Loading Now!!</label>
      <label>これだとIEだと表示されない可能性大</label>
      <img src="loading.gif"><!-- どっかからgifを取ってきてください。。 -->
    </div>
    Check this out!!<input type="checkbox" id="checkLoad1">
  </div>

  <div>
    <h3>JSにDOMを用意させて入れたり取ったりするパターン</h3>
    <div id="loading2"></div>
    Check this out!!<input type="checkbox" id="checkLoad2">
  </div>

  <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
  <script src="app2.js"></script>
</body>
</html>

app2.js

$(function() {
  $('#checkLoad1').on('click', function(){
    if ($(this).prop('checked')) {
      $('#loading1').show();
    } else {
      $('#loading1').hide();
    }
  })

  $('#checkLoad2').on('click', function(){
    if ($(this).prop('checked')) {
      var load_tmp = "<label>Loading Now!!</label>" +
        "<img src=\"loading.gif\">";

      $('#loading2').append(load_tmp).show(); // 意外とshowが大事だったりする。
    } else {
      $('#loading2').empty();
    }
  })
})