Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby: 個人サービスをRuby v3.3.0にアップデートした💎✨

以下の問題があったのでRuby v3.3.0へのアップデートを見送っていたのですが、

madogiwa0124.hatenablog.com

Docker Hubの公式イメージに修正が入り正常に動作するようになったようなのでRuby on Rails製の個人サービスをRuby v3.3.0にアップデートしました💎✨

その後Docker Hubのdocker-library/rubyで本記事の問題が修正されました↓。これにより、Docker HubのRuby 3.3.0イメージは正常に動くようになりました。 PR: Workaround 3.3.0 crash on aarch64 by osyoyu · Pull Request #439 · docker-library/ruby Ruby 3.3.0: aarch64-linux環境でFiber.new{ }.resumeを呼ぶと落ちる問題|TechRacho by BPS株式会社

いつも通りアップデート後にRspecを実行してテストが通ることを確認できました🍏

個人サービスレベルの規模ではありますが、 特別やったことと言えばzeitwerkの警告が出ていたのでcsvを明示的にinstallするようにしたぐらいで、 互換性が担保されているのはありがたいですね🙏

ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/kernel.rb:34: warning: csv was loaded from the standard library, but will no longer be part of the default gems since Ruby 3.4.0. Add csv to your Gemfile or gemspec. Also contact author of zeitwerk-2.6.13 to add csv into its gemspec.

Ruby v3.4からcsvがdefault gemでは無くなるようですね 👀

gihyo.jp

Ruby on Rails: Capybaraで特定の要素が無くなるのを待つ方法MEMO

Capybaraで、読み込み中を表すコンポーネントが消えるのを待ってからスクショを撮りたいみたいな時に特定の要素が消えるのを待つ方法をMEMO

結論だけ言うと以下のようにスクリーンショットを習得する前にローダーが無くなることを判定すれば良い。

page.has_no_css?(".page-loader")
page.save_screenshot("tmp/foo/bar.png")

www.rubydoc.info

RSpecのmatcherとして提供されているものはhas_link?とかhas_content?とかで大体pageから呼び出せる。

便利!

決まりきったローダーがあるならmoduleに切り出してhelperにしておくと便利そう。

module WaitLoadingComponentHelper
  def wait_loading_component(page)
    page.has_no_css?(".page-loader")
  end
end

参考

blog.willnet.in

Ruby on Rails: CPSを有効化後にテスト環境で`content_security_policy_nonce`で空文字が返却されエラーになる件の対応方法MEMO

個人のWebサービスconfig.content_security_policy_report_only=trueを外し、CSP違反があった場合にブラウザエラーを発生させるようにしたところ、Sytem Specが軒並み落ちるようになり対応したのでやったことをメモ📝

事象

ブラウザエラーを見たところ、以下のようなCSP違反のログが多数出ていたので、

The source list for the Content Security Policy directive 'script-src' contains an invalid source: ''nonce-''. It will be ignored.

実際に返却されるhtmlを確認してみたのですが、scriptタグに設定されるnonceが空文字になっていました。

<script nonce="">

原因

Rails側のnonceの生成処理は以下のようになっているのですが、このrequest.sesionの参照処理時にテスト環境でだけ、#<ActionDispatch::Request::Session:0xd610 not yet loaded>となっており、sessionが未ロードの状態のため空文字が返ってしまっていた模様 🤔

Rails.application.configure do
  config.content_security_policy_nonce_generator = ->(request) {
    request.session.id.to_s
  }
end

テスト環境では実際にログインせずにcurrent_userallow_any_instance_ofで返してるので、その辺の理由でcontent_security_policy_nonce_generator実行時点でsessionが未ロードの状態になってしまっているのかも(?)

解決策

以下のようにrequest.session[:init] = trueを実施し明示的にsessionを扱うことでロードさせるようにしたところ解決した。

Rails.application.configure do
  config.content_security_policy_nonce_generator = ->(request) {
    # NOTE: テスト実行時に以下となりSessionが取得できずnonceが空文字になりCSP違反が発生してしまうので強制的にロードする
    # #<ActionDispatch::Request::Session:0xd610 not yet loaded>
    # https://github.com/rails/rails/issues/10813#issuecomment-297204965
    request.session[:init] = true
    request.session.id.to_s
  }
end

参考

github.com

Ruby: `define_method`でキーワード引数を持つメソッドを定義するメモ📝

Rubydefine_methodを使うと外部からレシーバーに任意のメソッドを定義できますが、キーワード引数を持つメソッドを定義するときにやり方を迷ったのでメモ📝

docs.ruby-lang.org

define_methodは以下のようにblockを渡してメソッドを定義できますが、block引数ではキーワード引数を定義できませんが、

class Foo
  def foo; end
end

Foo.define_method(:bar) do |prefix|
  puts "#{prefix} bar!"
end

Foo.new.bar("Hello")
#=> Hello bar!

define_methodには、以下の通りProcオブジェクトを渡すことができるので、

[PARAM] method: Proc、Method あるいは UnboundMethod のいずれかのインスタンスを指定します。 Module#define_method (Ruby 3.3 リファレンスマニュアル)

以下のようにキーワード引数を持つlamdaやprocのオブジェクトを渡すことでキーワード引数を持つメソッドをdefine_methodで定義することが出来ます✨

class Foo
  def foo; end
end

Foo.define_method(:bar, ->(prefix:) { puts "#{prefix} bar!" })
Foo.new.bar(prefix: "Hello")
#=> Hello bar!

Rubyは色々method定義できたりして便利ですね!

Ruby on Rails: GoodJobをRailsアプリケーションと同一プロセスで実行するメモ📝

Heroku等で運用しているとバッチサーバーを別のプロセスで実行するとお金が掛かったりするのでGoodJobを用いている場合にRailsアプリケーションと同一プロセスで起動する方法をメモ📝

GoodJobについてはこちら

madogiwa0124.hatenablog.com

やり方としては簡単で以下のようにgood_job.execution_mode:asyncに変更するだけで大丈夫だった。

 frozen_string_literal: true

Rails.application.configure do
   config.good_job.queues = "*"
-  config.good_job.execution_mode = :external
+  config.good_job.execution_mode = :async
   config.good_job.cleanup_preserved_jobs_before_seconds_ago = 1.hour.to_i
   config.good_job.max_threads = 2
end

以下の通りexecution_mode:asyncに設定するとRailsのアプリケーションプロセス内の別スレッドとして動作させることができるようです。また、consoleやmigration等で起動された場合には動作しないようになっているのもありがたいですね🙏

  • execution_mode (symbol) specifies how and where jobs should be executed. You can also set this with the environment variable GOOD_JOB_EXECUTION_MODE. It can be any one of:
    • :async (or :async_server) executes jobs in separate threads within the Rails web server process (bundle exec rails server). It can be more economical for small workloads because you don’t need a separate machine or environment for running your jobs, but if your web server is under heavy load or your jobs require a lot of resources, you should choose :external instead. When not in the Rails web server, jobs will execute in :external mode to ensure jobs are not executed within rails console, rails db:migrate, rails assets:prepare, etc.

https://github.com/bensheldon/good_job/blob/3933005203f8781d5937e8541256488eee47380f/README.md#configuration-options

コードを軽く読んでみた限りですが、execution_mode:asyncの場合にはRailsアプリケーションが起動されたタイミング(config.after_initialize)でGoodJob._start_async_adaptersが実行され、Railsアプリケーション内にGoodJobの各種機能が有効化され実行が開始され、

    initializer "good_job.start_async" do
      config.after_initialize do
        ActiveSupport.on_load(:active_record) do
          ActiveSupport.on_load(:active_job) do
            GoodJob._framework_ready = true
            GoodJob._start_async_adapters
          end
          GoodJob._start_async_adapters
        end
        GoodJob._start_async_adapters
      end
    end
  end

https://github.com/bensheldon/good_job/blob/3933005203f8781d5937e8541256488eee47380f/lib/good_job/engine.rb#L51-L70

エンキューされたタイミングで別スレッドを作って非同期で処理するようにしているようです。

    def enqueue_at(active_job, timestamp)
      # ...
          executed_locally = execute_async? && @capsule&.create_thread(execution.job_state)
          Notifier.notify(execution.job_state) if !executed_locally && send_notify?(active_job)

https://github.com/bensheldon/good_job/blob/3933005203f8781d5937e8541256488eee47380f/lib/good_job/adapter.rb#L181-L188

※cronとかもどうなるのかなと思ったけどGoodJobの各種機能が有効化されるタイミングでCronのマネージャーも有効化されるからRailsアプリケーションの中でcronも動くようだった。

Solid Queueに移行しようかなと思っていたのですが、

github.com

Solid Queueでは現時点では以下で検討されてますが、まだ同一プロセスでの実行はできないっぽいですね👀

github.com

GoodJob便利!

Ruby: Ruby 3.3アップデート後に`bin/rails`系のコマンド実行時にconcurrent-rubyでSegmentation faultが発生する件のメモ📝

個人のWebサービスRuby 3.3アップデート後にbin/rails系のコマンド実行時にconcurrent-rubyでSegmentation faultが発生したのでメモ📝

$ bin/rails c

/app/vendor/bundle/ruby/3.3.0/gems/concurrent-ruby-1.2.2/lib/concurrent-ruby/concurrent/atomic/lock_local_var.rb:14: [BUG] Segmentation fault at 0x007effff843e06c0
ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [aarch64-linux]

結論としてはRuby v3.3のバグのようだった。(arm64系のCPUを利用していると発生するっぽい?)

bugs.ruby-lang.org

concurrent-ruby側でもrubyの問題として上記のチケットへ誘導するためのissueが立てられている🎫

github.com

masterには対応のPRがマージされておりRuby 3.3.1のリリースで修正されるとのこと🙏

github.com

アップデートはRuby 3.3.1まで待つことにした・・・!

余談) Ruby 3.3のバックポートのリストを見ると、今後リリースされる予定のbug fixとかを見れることを知った📝

bugs.ruby-lang.org

Ruby: Haml v6アップデート時のカスタム属性の振る舞いの互換性を維持するメモ📝

Haml v6からHamlの内部実装がHamlitに置き換わりパフォーマンス向上等のメリットがありますが、

github.com

以下のissueでコメントされている通り、

github.com

Vue.js等を利用している場合に以下のようなfalsyの値がv5系では<cutsom-element />となっていたのが、

%cutsom-element{ ":costom-attributes": nil  }

Haml v6では<cutsom-element :costom-attributes='' />となってしまいます。

上記の場合Vue.jsを利用しているHaml v6の挙動ではpropsのデフォルト値が利用されなくなり、明示的にundefinedを渡すような実装に修正する必要があり非常に影響が大きいです😢

これに対応する仕組みが以下のHaml v6.2.2でリリースされたHaml::BOOLEAN_ATTRIBUTESに任意の属性名の文字列・正規表現を追加することでHaml v5相当の振る舞いにすることができます。

github.com

全部やるなら以下とすれば良さそうですが、

Haml::BOOLEAN_ATTRIBUTES.push(/.*/)

この変更自体がパフォーマンス向上のトレードオフで発生しているもののようなので、パフォーマンスに問題がないか等は計測して対応を入れる必要がありそうです。

It varies. No impact, as slow as Haml 5, or slower than Haml 5, depending on the benchmark.

https://github.com/haml/haml/issues/1148#issuecomment-1754421295

📝以下のようなスクリプトで既存のhamlファイルを読み込んで利用されているカスタムタグの属性のリストを抽出し、それがだけ許可するような感じでもいいのかもしれない🤔

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'haml'
end

require 'haml/parser'

haml = <<~HAML
  %h1 Hello!
  %custom-element{ ":custom-attributes1": false, "string-attributes1": "hello" }
  %custom-element{ ":custom-attributes2": nil, "string-attributes2": "" }
  %p
    %span World!
HAML

parser = Haml::Parser.new({})
parsedHaml = parser.call(haml)

def extract_tags_and_attributes(node)
  result = []

  node.children.each do |child|
    tag_name = child.value[:name]
    attribute_names = child.value[:attributes].keys
    dynamic_attributes = child.value[:dynamic_attributes]
    dynamic_attribute_hash = dynamic_attributes.old ? eval(dynamic_attributes.old) : {}
    result << { name: tag_name, attributes: attribute_names + dynamic_attribute_hash.keys }
    result.concat(extract_tags_and_attributes(child))
  end

  result
end

tags_and_attributes = extract_tags_and_attributes(parsedHaml)
is_custom_tag = ->(tag) { tag[:name].include?("-") }

puts tags_and_attributes.select(&is_custom_tag)
                        .flat_map { |tag| tag[:attributes] }
                        .uniq
# =>
# :custom-attributes1
# string-attributes1
# :custom-attributes2
# string-attributes2