RSpec の spec type を独自フォルダ配下の spec にも自動で付与したい

f:id:shutooike:20210420184634p:plain

おはようございます!

最近 DMM Books の70%OFF祭りで読みたかった技術書を50冊 *1 ほど買い込んだ @shutooike です!

前回 と同様に、今回も整備中に見つけた RSpec 小ネタを共有します。

前提条件

rspec-rails: 3.9.0

spec type の自動付与とは?

# spec/rails_helper.rb

RSpec.configure do |config|
  config.infer_spec_type_from_file_location!
end

rails_helper.rb 内で上のようにすると

spec/models 配下の spec には type: :model というメタデータがつきます。これを今回は spec type の自動付与と呼びます。

この自動付与が行われるのは以下の場合のみで spec/ に掘った独自ディレクトリには当たり前ですが付与されません。

ディレクトリ タイプ
spec/controllers :controller
spec/helpers :helper
spec/jobs :job
spec/mailer :mailer
spec/models :model
spec/request, spec/integration, spec/api :request
spec/routing :routing
spec/view :views
spec/feature :feature
spec/system :system

やりたいこと

うちでは spec/lib/ という独自ディレクトリに lib/ の spec を書いていて、配下の spec に type: lib を付与する必要が出てきました。

解決策

# spec/rails_helper.rb

RSpec::Rails::DIRECTORY_MAPPINGS[:lib] = %w(spec lib) # <- この行を追加

RSpec.configure do |config|
.
.

解説

先ほど紹介した config.infer_spec_type_from_file_location!実装 を見ると

def infer_spec_type_from_file_location!
  DIRECTORY_MAPPINGS.each do |type, dir_parts|
    escaped_path = Regexp.compile(dir_parts.join('[\\\/]') + '[\\\/]')
    define_derived_metadata(:file_path => escaped_path) do |metadata|
      metadata[:type] ||= type
    end
  end
end

ほうほう、DIRECTORY_MAPPINGS というハッシュを回して type を付与しているっぽいですね。

DIRECTORY_MAPPINGS定義 を見にいきましょう。

DIRECTORY_MAPPINGS = {
  :controller => %w[spec controllers],
  :helper     => %w[spec helpers],
  :job        => %w[spec jobs],
  :mailer     => %w[spec mailers],
  :model      => %w[spec models],
  :request    => %w[spec (requests|integration|api)],
  :routing    => %w[spec routing],
  :view       => %w[spec views],
  :feature    => %w[spec features],
  :system     => %w[spec system]
}

ビンゴ!しかも freeze されていないので後から追加可能です。

また DIRECTORY_MAPPINGS は 2021/04/25 現在の main ブランチでも定義されてたので当分はバージョンアップに怯える必要もなさそうです。

おわりに

Rubymine のコードジャンプがあればソースリーディングが簡単にできていいですね〜

弊社では全ての分野のエンジニアを絶賛大募集中です!ご興味あればぜひ下記リンクからお願いします!

ingage.co.jp

ではまた!

*1:15万(総額) - 10万(クーポン) = 5万(支払い金額) という崩壊っぷりでした。

RSpec で特定の spec type の時だけ before フックを動かしたい

f:id:shutooike:20210420184634p:plain

おはようございます!

最近は隙間時間にインゲージのテスト環境を整備をしている @shutooike です!

今回は整備中に見つけた RSpec 小ネタを共有します。

前提条件

rspec-rails: 3.9.0

やりたいこと

RSpec で特定の spec type の時だけ before フックを動かしたい

解決策

# spec/rails_helper.rb

RSpec.configure do |config|
  # type が :model の時
  config.before(:each, type: :model) do
    # do something
  end
  
  # type が :model, :request ではない時
  config.before(:each, type: -> (t) { %i(model request).exclude?(t) }) do
    # do something
  end
end 

もちろん before:each 以外でも動きます。

おわりに

lambda も渡せるの便利ですねー!

弊社ではテスト環境に一家言お持ちの方もお持ちでない方も募集しております!!

ご興味あればぜひ下記リンクを覗いてみてください 👀

ingage.co.jp

ではまた!

Content-type: message/rfc822 の謎

f:id:ingage:20210419194939p:plain

id:kizashi1122 です。 メールの話です。

メールってとてもややこしいんですよね。 マルチパートと言って、パート部分を複数持つこともできますし、マルチパートを入れ子にすることもできます。

  • メール本体
    • 本文(テキスト)
    • 本文(HTML)
    • 添付ファイル1

みたいなのが典型的なマルチパートメールです。

添付ファイルであれば、Content-Type が image/png だったり application/pdf だったりするわけです。
たまに Content-Type が message/rfc822 の場合もあります。

よくあるのがメールの不達で返ってきたメール(バウンスメール)です。 @ より前のユーザが存在しない場合は、Unknown User で返ってくる例のあのメールです。

メールサーバーによりますがバウンスメールにはそもそも届けたかったメールが添付されていることがあります。 つまりメールにメールが添付されてるということになります。

そのときに使う Content-Typeが message/rfc822 なのです。 こんなイメージです。

Date: Mon, 19 Apr 2021 08:38:03 +0900 (JST)
From: MAILER-DAEMON@example.com (Mail Delivery System)
Subject: Undelivered Mail Returned to Sender
To: system@example.com
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
        boundary="aaaaaa"
Message-Id: <20210418233803@example.com>

This is a MIME-encapsulated message.

--aaaaaa
Content-Description: Notification
Content-Type: text/plain; charset=us-ascii

Unknown User ですよ的なメッセージ



--aaaaaa
Content-Description: Undelivered Message
Content-Type: message/rfc822

Content-Transfer-Encoding: 7bit
Content-Type: text/plain; charset=iso-2022-jp
Mime-Version: 1.0
To: example@example.jp
From: example@example.co.jp
Subject: foo


元メールの本分


--aaaaaa--

みたいな感じです。

タイトルで「謎」と書きましたが、何が謎なのか。

最近こういうバウンスメールをみるのです。

Content-Type: message/rfc822; charset=utf-8
Content-Transfer-Encoding: base64


WyRyO3Ikajh8JC84Zk5pPz0kNz5lJDIkXiQ5ISMbKEINCg0KGyRCQWFCLiRHJE8kNCQ2JCQkXiQ5
JCwhIhsoQg0KGyRCQGhITCEiJSshPCVJMnEwd01NJCskaSROJDRNeE1RSF1HJyROPz1AQSRLSDwk
:
:

ふーん、 元のメッセージが base64 エンコードされてるのね、というメールなのですが、うちのサービスではこのバウンスメールの扱いに失敗します。

なんでだろう?

と思って、RFCのドキュメントを読むことにしました。

RFC 2046: Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types

の Page 29 に

No encoding other than "7bit", "8bit", or "binary" is permitted for the body of a "message/rfc822" entity

とあります。つまり RFC 違反のメールであり、うちが使っているライブラリでは RFC に則って作られていたため、 Content-Transfer-Encoding: base64 が指定してあってもそれを無視して本文をそのまま扱っていたということになります。

フォーマットの違うCSVをインポートしようとしたらエラーで弾けばいいでしょう。 ただメールの世界ではルール違反(RFC違反)はもはや当たり前です。どこまでをサポートすればよいのかは悩み外どころです。

うちでは今回は Content-Transfer-Encoding: base64 が指定してあれば、明示的に base64 でデコードしてあげることにしました。

以上です。

インゲージではエンジニアを募集しています!

ではまた。

OS をアップデートしたら HTTP でつながらなくなったので調べてみた

こんにちは masm11 です。 私は社内向け某サービスを開発・運用しています。

先日、そのステージングで OS をアップデートしたところ、HTTP/HTTPS で つながらなくなってしまいまい、原因を調べました。今日はその件について 書きたいと思います。

やったこと

OS は Amazon Linux 2 です。

sudo yum update

して、EC2 ダッシュボードから再起動し、アプリをデプロイしただけです。

ブラウザから動作確認したところ、つながらなくなっていました…

調べる

とりあえず curl で試してみます。

まずはサーバの外からグローバル IP アドレスで接続してみます。

$ curl http://global-ip-address/
curl: (52) Empty reply from server
$ curl https://global-ip-address/
curl: (7) Failed to connect to global-ip-address port 443: Connection refused

次にサーバ内でプライベート IP アドレスで接続してみたところ、 こちらは正常でした。つまり、nginx で受けて unicorn への流れは問題なさそうです。

問題は、そこより外側、つまり AWS の NAT とか、そのへんにありそうだと感じます。 security group も確認しましたが、ssh だけの制限で、HTTP/HTTPS は制限していませんでした。

トラフィックの様子を見てみましょうか。

sudo tcpdump -n -vv -i eth0 port 80

この状態で接続してみましたが、よく解りません。やっぱり tcpdump の出力は難解です… wireshark にしましょう。

一旦キャプチャします。

sudo tcpdump -n -s 0 -w cap.dat -i eth0

念のため、port 80 は外しました。デフォルトでは大きいパケットは途中で切り捨てられるので、それを防ぐために -s 0 を付けています。-w cap.dat で cap.dat に保存します。

この状態でアクセスした後、ctrl+c で止めます。cap.dat を手元の PC にコピーした後、 wireshark で開きます。

開いた画面は以下です。

f:id:masm11:20210403184718p:plain

SYN パケットの直後に Destination unreachable (Host administratively prohibited) という ICMP パケットが 飛んでますね。このせいでしょう。

これでぐぐったところ、ファイアウォール等が原因のようです。

fail2ban を導入してます。それが誤動作してるのでしょうか? 状況を確認します。

[ec2-user@ip-172-31-9-10 ~]$ sudo iptables -L -v -n
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
   44  3080 ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
    0     0 ACCEPT     all  --  lo     *       0.0.0.0/0            0.0.0.0/0
    1    88 INPUT_direct  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    1    88 INPUT_ZONES_SOURCE  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    1    88 INPUT_ZONES  all  --  *      *       0.0.0.0/0            0.0.0.0/0
    0     0 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate INVALID
    0     0 REJECT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            reject-with icmp-host-prohibited
(以下略)

最後が icmp-host-prohibited で REJECT になってます。すごく怪しいです。 そして INPUT_direct やら INPUT_ZONES_SOURCE やら、見慣れない chain があります。 この chain 名でぐぐったところ、firewalld が作るもののようです。

[ec2-user@ip-172-31-9-10 ~]$ sudo systemctl list-unit-files | grep firewall
firewalld.service                             enabled

enable になってる… 止めます。

sudo systemctl stop firewalld

sudo iptables -L -v -n すると、これらの chain がなくなっていました。

curl やブラウザからアクセスして、問題ないことが確認できました。

まとめ

OS のアップデートしただけだったのに、大きく挙動が変わってしまい、困りました。 yum update ではそんなことにならないと思ってたのですが。

そもそも、

[ec2-user@ip-172-31-9-10 ~]$ sudo systemctl status firewalld
● firewalld.service - firewalld - dynamic firewall daemon
   Loaded: loaded (/usr/lib/systemd/system/firewalld.service; disabled; vendor preset: enabled)
   Active: inactive (dead) since 土 2021-04-03 18:34:39 JST; 2s ago
     Docs: man:firewalld(1)
  Process: 24320 ExecStart=/usr/sbin/firewalld --nofork --nopid $FIREWALLD_ARGS (code=exited, status=0/SUCCESS)
 Main PID: 24320 (code=exited, status=0/SUCCESS)

見えますか? vendor preset: enabled になってますね。だからインストールされた時にすぐ enable になったのでしょう。 余計なことをしてくれます…

ちなみに、fail2ban も停止してしまっていました。 こういったツールが2つ以上同時に動くのは危険なので、仕方ないのは解りますが、ほんと余計なことしてくれる…

インゲージではエンジニアを募集しています。 こういうことが (Google 等活用しながら) 自分で解決できる方、ぜひうちで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit/

Linux デスクトップにおけるクリップボードの性質

f:id:masm11:20210319205809p:plain

こんにちは、masm11 です。

今回はかなり毛色を変えて、デスクトップのある身近な機能について プログラム的な話をしようと思います。

その機能とは、クリップボードです。

セレクションとは

Linux デスクトップにおいて、「クリップボード」と、それと似たものがあるのは、 割と有名なことだと思っています。

以下の 3つがあって、まとめて「セレクション」と呼びます。

  • PRIMARY
  • SECONDARY
  • CLIPBOARD

ユーザの観点から使い方の違いを説明します。

CLIPBOARD はそのままクリップボードとして使われます。 ctrl+c でコピーして ctrl+v でペーストするやつです。

一方 PRIMARY は、範囲をマウスで選択すると、それだけでその範囲がコピーされます。 そして3ボタンマウスの中ボタンでペーストすることができます。

では SECONDARY とは何でしょうか? これは実は特に用途が決まっていません。

プログラム的には

セレクションは3つあり、ユーザから見た違いを説明しました。 では、プログラム的に違いはあるのでしょうか。

実はプログラム的には 3つは全く同じように使えます。

  1. コピー時に、アプリAが「PRIMARY を所有します」と宣言する。
  2. 他のアプリBが PRIMARY を使ってペーストしたい時は、B がデスクトップ経由で A を特定する
  3. B が A に接続する
  4. A と B との間でデータ型をネゴる
  5. A から B へデータを転送する

こんな感じです。PRIMARY でなく CLIPBOARD を使いたい時は CLIPBOARD に置き換えるだけです。

ただし、PRIMARY と CLIPBOARD とではユーザの使い方が違いますので、 そこは「所有します」と宣言するタイミングを変える必要はあります。

インターネット上の記事によっては「PRIMARY と CLIPBOARD はプログラム的な扱いも異なる」と 書いてあることがありますが、3つとも使い方は同じで、ユーザへの見せ方が違うだけです。

データ転送方法

Linux デスクトップのセレクションが、macOS や Windows のクリップボードと内部的に大きく 異なるのは、データの転送方法だと思います。

先程説明したとおり、アプリ同士が直接接続を張って、直接やりとりしています。

デスクトップは何をやっているかと言うと、セレクションを誰が所有しているかを管理しているだけです。 「PRIMARY を所有します」と宣言すると、PRIMARY を所有するアプリの情報を更新し、 別のアプリから「PRIMARY は誰が所有してるか?」と尋ねられたら、どのアプリが所有しているかを 返答します。

デスクトップはデータそのものは持ちません。 CLIPBOARD という名称ではありますが、クリップボードデータは持っていないのです。

他のデスクトップとの違い

ここで疑問に思われる方もいらっしゃるでしょう。

コピー操作をした後、そのアプリを終了したら、ペーストできるのか?

Linux デスクトップではペーストできません。 接続先アプリが存在しないし、 そもそもアプリ終了時点でそのセレクションを誰も持っていない状態になるからです。 ただし、クリップボードマネージャを使っている場合を除きます。

Linux デスクトップを使ってる方は試してみてください。

ここが Linux デスクトップのクリップボードが macOS や Windows のクリップボードと ユーザ視点で大きく異なる点だと思います。

まとめ

Linux デスクトップにおけるセレクション3つについて、 ユーザ視点での違い、プログラム的な違いを説明し、 さらに他のデスクトップのクリップボードとの大きな違いを説明しました。

本当は最後の段落が書きたかっただけです。

弊社には業務で Linux デスクトップを使っている人がいますが、 きっとそのクリップボードがそういうものだとは気づいていないと思います。

Linux デスクトップをこよなく愛するそこのあなた! 業務で Linux デスクトップを 使える日を夢見ながら一緒にインゲージで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit

ローカルの AWS のアクセスキーを自動的にローテーションする

f:id:kizashi1122:20210315143238p:plain

id:kizashi1122 です。

最初に発行したAWS のアクセスキーをそのままずっと使ってませんか?

docs.aws.amazon.com

そもそもできるならばキーは発行すべきではありませんが、発行することが必要なシチュエーションもありますよね。 上記記事にも、

  • アクセスキーを定期的に更新します

と、あるようにキーは漏洩したときのリスクを減らすためにも更新したほうがいいです。

f:id:kizashi1122:20210315134357p:plain:w200

こうならないようにしたいですね。

これ、実はアクセスキーを自動的にローテションする仕組みがあるんです。

手順

手順は以下の通りです。 .aws/credentials がすでにある前提です。

  • 当該 IAM ユーザに自身のキーを作成・削除する権限を与える
  • aws-rotate-iam-keys をインストールする
  • 依存コマンドである aws コマンドをインストールする
  • 依存コマンドである jq コマンドをインストールする

1つずつ見ていきましょう。

IAM ユーザの権限追加

以下のポリシーが追加されていればいいです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:ListAccessKeys",
                "iam:CreateAccessKey",
                "iam:DeleteAccessKey"
            ],
            "Resource": [
                "arn:aws:iam::*:user/${aws:username}"
            ]
        }
    ]
}

aws-rotate-iam-keys インストール

 $ git clone https://github.com/rhyeal/aws-rotate-iam-keys.git
 $ sudo cp aws-rotate-iam-keys/src/bin/aws-rotate-iam-keys /usr/local/bin/
 $ rm -rf aws-rotate-iam-keys

こんな感じで PATH の通ったところにおいてもらえたらいいです。

aws コマンドインストール

 $  curl "https://s3.amazonaws.com/aws-cli/awscli-bundle.zip" -o "awscli-bundle.zip"
 $  unzip awscli-bundle.zip
 $  sudo ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws

jq コマンドインストール

$ sudo yum install jq

yum でインストールできます(環境によっては他の方法でインストールしてください)

以上!

実行

超簡単です。

$ aws-rotate-iam-keys

だけ。プロファイルを切ってる場合は、

$ aws-rotate-iam-keys -p other_profile

と指定することができます。

あとはこれを crontab に入れるなり、.bashrc に追加するなりすれば自動化できます。

f:id:kizashi1122:20210315141736p:plain:w200

緑が気持ちいいです! インゲージの開発メンバーは皆この仕組みを導入しています。

実は・・・

今回の話は、2020年2月の JAWSUG で ABEJA の村主さんが発表されたこちらのスライドに書いている内容そのまんまです。

speakerdeck.com

他にもお役立ち情報が沢山載っているので必見です。

インゲージではエンジニアを募集しています! 興味ある方は こちら まで!

ActiveJob で queue を動的に指定する3つの方法

f:id:shutooike:20210311110057p:plain おはようございます。 @shutooike です!

業務上であるジョブのキューを条件によって動的に変えたい場面に遭遇し、ActiveJobのソースを読んだのでメモを残しておきます。

前提条件

Rails version: 5.2

queue_adapter: Sidekiq

対象のジョブクラスはこんな感じ

class SampleJob < ApplicationJob
  queue_as 'default'
  
  rescue_from(StandardError) do
    retry_job(wait: 5.minutes)
  end
  
  def perform(args)
    do_something
  end
end

方法1

ActiveJob::Core.setActiveJob::Enqueuing#enqueuequeue を渡す

ジョブの呼び出し元で制御するという思いつく限り一番簡単な方法です。

実装

# set を使うパターン
if ConditionModel.exists?(id: condition.id)
  SampleJob.set(queue: 'another_queue').perform_later(args)
else
  SampleJob.perform_later(args)
end

# enqueue に渡すパターン
if ConditionModel.exists?(id: condition.id)
  SampleJob.new(args).enqueue(queue: 'another_queue')
else
  SampleJob.perform_later(args)
end

内部をチラッと覗くと

module ActiveJob
  module Core
...
    module ClassMethods
...
      def set(options = {})
        ConfiguredJob.new(self, options)
      end
    end
...

ActiveJob::Core.set 内で ActiveJob::ConfiguredJob というインスタンスが作られます。

module ActiveJob
  class ConfiguredJob #:nodoc:
    def initialize(job_class, options = {})
      @options = options
      @job_class = job_class
    end
...
    def perform_later(*args)
      @job_class.new(*args).enqueue @options
    end
  end
end

ActiveJob::ConfiguredJob#perform_later はジョブのインスタンスを作って ActiveJob::Enqueuing#enqueue を呼ぶので、enqueue に渡すパターンと行き着く先は同じです。

#enqueue の実装は以下のようになっており、 options['queue'] があれば ジョブインスタンスの queue_name を上書きします。

module ActiveJob
  module Enqueuing
...    
    def enqueue(options = {})
      self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
      self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
      self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
      self.priority     = options[:priority].to_i if options[:priority]
      run_callbacks :enqueue do
        if scheduled_at
          self.class.queue_adapter.enqueue_at self, scheduled_at
        else
          self.class.queue_adapter.enqueue self
        end
      end
      self
    end

今回の変更対象のジョブはいろんなところで呼ばれていて、呼び出し元を変更するコストが高かったのでこの方法は 不採用

方法2

before_enqueue#queue_name を動的に変更する

ActiveJob は他の Rails のライブラリと同様コールバックを持っています。

今回はエンキュー前に呼ばれる必要があるので、before_enqueue コールバックを使います。

実装

class SampleJob < ApplicationJob
  queue_as 'default'
  
  rescue_from(StandardError) do
    retry_job(wait: 5.minutes)
  end
  
+  before_enqueue do |job|
+    if ConditionModel.exists?(id: job.arguments[0]) 
+      job.queue_name = 'another_queue'
+    end
+  end

...

これだと変更箇所が一箇所で済むのでいい感じです。

ただ、今回の変更対象のジョブは上の実装のようにジョブの引数を使ってDBにアクセスし queue を変更するか確認する必要がありました。

もしジョブ実行中にDBアクセスに失敗してエラーになった場合、retry_job 時にも before_enqueue が呼ばれてDBアクセスでエラーになり、ジョブのエンキューされないまま失敗してしまう可能性があると指摘をいただきました。なので方法2も 不採用

ただ、方法1を使うのはエンジニアとして負けな気がしたので頑張って探します。

方法3

ActiveJob::Core.queue_as にブロックを渡す

先に実装を示します。

実装

class SampleJob < ApplicationJob
-  queue_as 'default'

+  queue_as do
+    if ConditionModel.exists?(id: self.arguments[0])
+      'another_queue'
+    else
+      'default'
+    end
+  end

...

方法2の before_enqueue とほぼ同じ処理を queue_as のブロックに渡しただけです。

これでうまくいく理由をAJのソースコードと共に順を追って説明していきます。

まず、queue_as の定義を見ます。

module ActiveJob
  module QueueName
    extend ActiveSupport::Concern

    # Includes the ability to override the default queue name and prefix.
    module ClassMethods
...

      def queue_as(part_name = nil, &block)
        if block_given?
          self.queue_name = block
        else
          self.queue_name = queue_name_from_part(part_name)
        end
      end

...

    included do
      class_attribute :queue_name, instance_accessor: false, default: default_queue_name
      class_attribute :queue_name_delimiter, instance_accessor: false, default: "_"
    end

...

ブロックが渡されたらブロックをそのままクラス属性の queue_name に入れ、 それ以外は引数を queue_name_from_part(プレフィックスとかをつける処理)に通してクラス属性の queue_name に入れていますね。

このクラス属性の queue_name はどこで使われるかというと、

module ActiveJob
  module Core
...

    def initialize(*arguments)
      @arguments  = arguments
      @job_id     = SecureRandom.uuid
      @queue_name = self.class.queue_name
      @priority   = self.class.priority
      @executions = 0
    end
...

ジョブのコンストラクタ内で @queue_name に代入されています。

次にジョブが Sidekiq にエンキューされるまでの流れを追います。 まず方法1でも見た ActiveJob::Enqueuing#enqueue をもう一度確認します。

module ActiveJob
  module Enqueuing
...    
    def enqueue(options = {})
      self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
      self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
      self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
      self.priority     = options[:priority].to_i if options[:priority]
      run_callbacks :enqueue do
        if scheduled_at
          self.class.queue_adapter.enqueue_at self, scheduled_at
        else
          self.class.queue_adapter.enqueue self
        end
      end
      self
    end

最後に queue_adapterenqueue メソッドが呼ばれています。

弊社はアダプターに Sidekiq を使っているので SidekiqAdapter#enqueue が呼ばれます。

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end
...

SidekiqAdapter#enqueue はジョブインスタンスを引数に受け、Sidekiq 側に投げる際、 "queue"job.queue_name を指定していることが確認できます。

この job.queue_nameActiveJob::QueueName に定義されています。

module ActiveJob
  module QueueName
...

    # Returns the name of the queue the job will be run on.
    def queue_name
      if @queue_name.is_a?(Proc)
        @queue_name = self.class.queue_name_from_part(instance_exec(&@queue_name))
      end
      @queue_name
    end
  end
end

#queue_name はインスタンス変数 @queue_name がブロックなら、instance_exec*1 を使いジョブインスタンスのコンテキストでブロックを実行した結果を @queue_name に代入したあと返し、ブロック以外なら、@queue_name をそのまま返しています。

まとめると、queue_as にブロックを渡した場合、最終的に Sidekiq に "queue" として渡す値は、初回はブロックの実行結果を、2回目以降は初回の実行結果が返すこともわかりました。これにより retry_job 時(2回目以降)にはDBアクセスしないことがわかったので、ジョブのエンキューされないまま失敗してしまう心配もなく動的に queue を変更できるようになりました。ということで今回は方法3を 採用 🎉

補足

なぜリトライ時(2回目以降)も初回のブロック実行結果が引き継がれるの?

ActiveJob から Sidekiq::Client に投げる際、"args"job.serialize を渡しています。

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end
...

ジョブインスタンスの #serializeActiveJob::Core に定義があって

module ActiveJob
  module Core
...

    # Returns a hash with the job data that can safely be passed to the
    # queueing adapter.
    def serialize
      {
        "job_class"  => self.class.name,
        "job_id"     => job_id,
        "provider_job_id" => provider_job_id,
        "queue_name" => queue_name,
        "priority"   => priority,
        "arguments"  => serialize_arguments_if_needed(arguments),
        "executions" => executions,
        "locale"     => I18n.locale.to_s
      }
    end
...

上記のハッシュを返します。このハッシュに queue_name もありますね。

serialize があれば deserialize もあるはずです。deserialize だけを探してもいいですが、せっかくなので Sidekiq から ActiveJob のジョブが動く流れを追います。(Sidekiq のコードは追いません)

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end   
...
      class JobWrapper #:nodoc:
        include Sidekiq::Worker

        def perform(job_data)
          Base.execute job_data.merge("provider_job_id" => jid)
        end
      end
...

Sidekiq の worker は Redis からジョブ情報を取得し、 "class"に指定したクラスのインスタンスの perform"args" の一つ目の要素を引数に実行します。

ここで "class" として渡されている JobWrapperperform の処理は

module ActiveJob
  module QueueAdapters
    class SidekiqAdapter
...
      class JobWrapper #:nodoc:
        include Sidekiq::Worker

        def perform(job_data)
          Base.execute job_data.merge("provider_job_id" => jid)
        end
      end
...

ActiveJob::Base.execute を 呼び出しています。

module ActiveJob
  module Execution
...
    module ClassMethods
...
      def execute(job_data) #:nodoc:
        ActiveJob::Callbacks.run_callbacks(:execute) do
          job = deserialize(job_data)
          job.perform_now
        end
      end
    end
...

お、deserialize が出てきました。クラスメソッド の deserialize を探します。

module ActiveJob
  module Core
...
    module ClassMethods
      # Creates a new job instance from a hash created with +serialize+
      def deserialize(job_data)
        job = job_data["job_class"].constantize.new
        job.deserialize(job_data)
        job
      end

serialize で作ったハッシュをもとに新しいジョブのインスタンス作成すると言ってますね。 job_data というハッシュの "job_class" からジョブインスタンスを作成して、インスタンスメソッドの deserialize を呼び出しています。 インスタンスメソッドの deserialize を探します。

module ActiveJob
  module Core
...

    def deserialize(job_data)
      self.job_id               = job_data["job_id"]
      self.provider_job_id      = job_data["provider_job_id"]
      self.queue_name           = job_data["queue_name"]
      self.priority             = job_data["priority"]
      self.serialized_arguments = job_data["arguments"]
      self.executions           = job_data["executions"]
      self.locale               = job_data["locale"] || I18n.locale.to_s
    end
...

やっと辿り着きました。ジョブインスタンスの queue_namejob_data["queue_name"] が代入されています。 job_data["queue_name"] の値はブロックの実行結果が入っているのでリトライ時(2回目以降)に初回のブロック実行結果が引き継がれます。

おわりに

ActiveJob はめちゃくちゃ読みやすかったです!

【宣伝】 弊社では必要とあらばライブラリのソースコードをバリバリ読むぜ!というロックなエンジニアを募集しております!ご興味あればぜひ下記リンクをご覧ください! ingage.co.jp

ではまた!

*1:instance_exec についてはこちらの記事がわかりやすかったです。https://secret-garden.hatenablog.com/entry/2015/10/18/000000