Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: Hotwireをアプリ構築で学ぶ、Active RecordのDurationとPostgreSQL intervalデータ型ほか(20220613前編)

こんにちは、hachi8833です。とうとう値上がりしました。

参考: Apple、Macの既存モデルを値上げ – 14インチProで35,000円アップ | マイナビニュース

つっつきボイス:「旧モデルも上がっちゃいましたね」「円安は仕方ないけど、それでもスペックの割にMacは高いな〜」「仕事で必要なら買うしかないですけどね」

「そういえばM1チップの中心人物Jeff Wilcoxさんが少し前にAppleからIntelに転職してましたね↓」

参考: 「Apple M1」開発責任者がIntelに転職 – PC Watch

「それで思い出しましたけど、ジム・ケラーというAMDのx86互換CPUのIntel独占をひっくり返した偉人は、会社を移るたびにその会社のCPUが進化する感ありますね」

参考: ジム・ケラー – Wikipedia

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

🔗 Active Storage: トランザクション内でattachの複数回実行が失敗するのを修正

  • トランザクション内でattachを複数実行するとファイルが正しくアップロードされない問題を修正

以下の例では、設定されたサービスへのアップロードは最後のファイル以外すべて失敗する。

  ActiveRecord::Base.transaction do
    user.attachments.attach({
      content_type: "text/plain",
      filename: "dummy.txt",
      io: ::StringIO.new("dummy"),
    })
    user.attachments.attach({
      content_type: "text/plain",
      filename: "dummy2.txt",
      io: ::StringIO.new("dummy2"),
    })
  end
  assert_equal 2, user.attachments.count
  assert user.attachments.first.service.exist?(user.attachments.first.key)  # Fails

アップロード待ちのサブチェンジをトラッキングし、トランザクションがコミットされた時点でアップロードすることでこの問題に対処した。
修正: #41661
Santiago Bartesaghi, Bruno Vezoli, Juan Roig, Abhay Nikam
同Changelogより


つっつきボイス:「Active Storageのバグ修正」「1トランザクション内でattachを複数回実行したら最後以外失敗してたとは」「ありそうな話」「pending_uploadsで変更を追いかけるようにしたようですね↓」

# #Lactivestorage/lib/active_storage/attached/changes/create_many.rb
module ActiveStorage
  class Attached::Changes::CreateMany # :nodoc:
-   attr_reader :name, :record, :attachables
+   attr_reader :name, :record, :attachables, :pending_uploads

-   def initialize(name, record, attachables)
+   def initialize(name, record, attachables, pending_uploads: [])
      @name, @record, @attachables = name, record, Array(attachables)
      blobs.each(&:identify_without_saving)
      @pending_uploads = Array(pending_uploads) + subchanges_without_blobs
      attachments
    end

    def attachments
      @attachments ||= subchanges.collect(&:attachment)
    end
    def blobs
      @blobs ||= subchanges.collect(&:blob)
    end

    def upload
-     subchanges.each(&:upload)
+     pending_uploads.each(&:upload)
    end
...
+     def subchanges_without_blobs
+       subchanges.reject { |subchange| subchange.attachable.is_a?(ActiveStorage::Blob) }
+     end

🔗 MemCacheStoreRedisCacheStoreのコネクションプーリングがデフォルトでオンになる

  • キャッシュストアのコネクションプール設定で:pool_sizeオプションと:pool_timeoutオプションを非推奨化

pool: trueにするとコネクションプールがデフォルトでオンになる

config.cache_store = :redis_cache_store, pool: true

または:poolオプションに個別のオプションを渡す。

config.cache_store = :redis_cache_store, pool: { size: 10, timeout: 2 }

fatkodima
同Changelogより


つっつきボイス:「#45111でコネクションプールの:pool_sizeオプションと:pool_timeoutオプションが非推奨化されたことに伴う改修だそうです」「connection_pool gemがデフォルトで追加されている: コネクションプールを使っていなければいいけど、既に使っている場合はコンフィグパラメータを変えないといけなくなりそうので影響がありますね」

参考: §2.2.1 コネクションプールのオプション — Rails のキャッシュ機構 – Railsガイド

「ところで、この種のキャッシュストアはいろいろありますけど、それぞれ設定や呼び出し方が違っているうえに複雑なんですよね」「そうそう、変数がserverだったりserversだったり」「覚えられなくて毎回ググってます」「しかもRailsのバージョンが変わるとちょくちょく変わったりしますし、動かさないと効いているかどうかもわからないし」「動かしてもなかなかわからなかったりしますけどね」「ヘッダーの変更やキャッシュストアに投げられる命令の変更などを調べて実際に動作を確かめるようにしてます」

参考: §2キャッシュストア — Rails のキャッシュ機構 – Railsガイド

🔗 暗号化済み属性のバリデーションのN+1クエリ問題を解消


つっつきボイス:「バリデーションのN+1クエリ問題の修正、なるほど」「eachで回していたのをinclude?(attribute)に変えてActive Recordにやってもらうように変えていますね」

# activerecord/lib/active_record/encryption/extended_deterministic_uniqueness_validator.rb#L10
      module EncryptedUniquenessValidator
        def validate_each(record, attribute, value)
          super(record, attribute, value)

          klass = record.class
-         klass.deterministic_encrypted_attributes&.each do |attribute_name|
-           encrypted_type = klass.type_for_attribute(attribute_name)
+         if klass.deterministic_encrypted_attributes&.include?(attribute)
+           encrypted_type = klass.type_for_attribute(attribute)
            [ encrypted_type, *encrypted_type.previous_types ].each do |type|
              encrypted_value = type.serialize(value)
              ActiveRecord::Encryption.without_encryption do
                super(record, attribute, encrypted_value)
              end
            end
          end
        end
      end

Rails: Bulletで検出されないN+1クエリを解消する

🔗 ActiveSupport::Notifications::Fanoutを高速化

(略)
後方互換性
ActiveSupport::Notificationsは古くからよく使われているインターフェイスで、これまで多くの機能や柔軟性がここに集約されてきたが、その大半は既に誰にも使われなくなっているのではないかと思う。しかしどれが使われていないかを具体的に知るのは難しいので、削除するなら少なくとも非推奨化サイクルが必要だろう。
そのため、この改修は互換性を完全に維持することを目的とする。含まれる改修には、Fanoutに代わる別の通知の実装を使えるようにする機能と、あらゆる種類のリスナーが受け取れるシグネチャ、InstrumenterFanout自身で使われるインターフェイス(たまに問題を起こすstartfinishも含む)がある。
安全性
これまでも#44167#21873などで、特にイベントをsubscribeするときとunsubscribeするときに”timestacks”が不正になる問題があった。これはトピックが動いているときにsubscribe/unsubscribeしたときのissue。
以前の実装では、スレッドローカルなスタック内でリスナーごとに個別のタイムスタンプやイベントオブジェクトを記録していた。これは、開始するリスナーと終了するリスナーは根本的に同一でなければならないということになる。
イベントのstartに使ったリスナーをfinishに渡し(Instrumenterfinish_with_state)、startfinishが同じセットになるようにすることでこのissueが回避される。
今回のコミットではこのissueをさらに回避する。個別の時刻をスタックにプッシュする代わりに、Handleという単一のオブジェクトをイベントのスタックにプッシュするようにした。Handleオブジェクトは(開始時刻に記録された)すべてのサブスクライバと、それらに関連付けられたすべてのデータを保持する。すなわち、startfinishがインターリーブしない限りこれらも安全ということになる。Instrumenter#instrumentが使われれば、スレッドローカルの利用を完全に回避できるようになる。
今回のコミットでは、build_handleもパブリックインターフェイスとして公開している。build_handleは、いつどんな順序でもstartfinishを呼び出せるHandleオブジェクトを返す。
build_handleの公開についてひとつ気になるのは、既存の”イベント化された”リスナー(startfinishを受け取る)側での準備がまだ整っていない(内部でスレッドローカルなスタックが維持されているなど)可能性があることだ。
(略)
同PRより抜粋

参考: ActiveSupport::Notifications::Fanout


つっつきボイス:「ActiveSupport::NotificationsFanoutというpub/sub周りを高速化したらしい」「後方互換性を維持しつつ、内部実装をかなり書き直している感じ」「ベンチマークはだいぶ良くなってますね」

# 同PRより: mainブランチ
           timed     66.739k (± 2.5%) i/s -    338.800k in   5.079883s
 timed_monotonic    138.265k (± 0.6%) i/s -    699.261k in   5.057575s
    event_object     48.650k (± 0.2%) i/s -    244.250k in   5.020614s
         evented    366.559k (± 1.0%) i/s -      1.851M in   5.049727s
    unsubscribed      3.696M (± 0.5%) i/s -     18.497M in   5.005335s
# 同PRより: 改修のブランチ
           timed    259.031k (± 0.6%) i/s -      1.302M in   5.025612s
 timed_monotonic    327.439k (± 1.7%) i/s -      1.665M in   5.086815s
    event_object    228.991k (± 0.3%) i/s -      1.164M in   5.083539s
         evented    296.057k (± 0.3%) i/s -      1.501M in   5.070315s
    unsubscribed      3.670M (± 0.3%) i/s -     18.376M in   5.007095s

「時刻ベースだった実装をイベントベースの実装に変えているように見える」「前にも登場したProcess::CLOCK_MONOTONICを使ってますね(ウォッチ20211108)」「これで時刻の精度が数桁はアップするはず👍」

# activesupport/lib/active_support/notifications/fanout.rb#L84
-     def publish(name, *args)
-       iterate_guarding_exceptions(listeners_for(name)) { |s| s.publish(name, *args) }
+     class MonotonicTimedGroup < BaseTimeGroup # :nodoc:
+       private
+         def now
+           Process.clock_gettime(Process::CLOCK_MONOTONIC)
+         end
      end

参考: Process::CLOCK_MONOTONIC (Ruby 3.1 リファレンスマニュアル)


「ところでファンインやファンアウトって電気回路の用語ですけど、こういうところにも使うんですね」「ソフトウェアでもpub/subやチャネルやバスといった概念で、電気回路のアナロジー的にファンインやファンアウトという言葉が使われることはあります」「そういえばAND回路は2入力1出力だから、ファンインが2でファンアウトが1、みたいに言いますね」

参考: ファンアウト/ファンインとは – コトバンク
参考: fan-out、fan-inパターン【Go】 – 技術向上

🔗 count_recordsで新規レコードがない場合の挙動を修正

# activerecord/lib/active_record/associations/has_many_association.rb#L84
        def count_records
          count = if reflection.has_cached_counter?
            owner.read_attribute(reflection.counter_cache_column).to_i
          else
            scope.count(:all)
          end

-         # If there's nothing in the database and @target has no new records
-         # we are certain the current target is an empty array. This is a
-         # documented side-effect of the method that may avoid an extra SELECT.
-         loaded! if count == 0
+         # If there's nothing in the database, @target should only contain new
+         # records or be an empty array. This is a documented side-effect of
+         # the method that may avoid an extra SELECT.
+         if count == 0
+           target.select!(&:new_record?)
+           loaded!
+         end

          [association_scope.limit_value, count].compact.min
        end

つっつきボイス:「スコープ付き関連付けの場合に、以下の1回目のassert_equalがパスするのに2回目がパスしていなかったのか↓」「修正量は少ないけど、突き止めるまでが大変そう」「踏むとつらいバグ」

# 同PRより
class Member < ActiveRecord::Base
  has_many :memberships, -> { where(favorite: true) }
end
# 同PRより
member = Member.create!
membership = member.memberships.create!(favorite: true)
membership.update!(favorite: false)

assert_equal 0, member.memberships.size
assert_equal 0, member.memberships.size

# The first assertion passes and the second fails with:
# Expected: 0
#   Actual: 1

🔗Rails

🔗 Active RecordのDurationをクエリで使う(Ruby Weeklyより)

以下はサンプルコードです↓。

thoughtbot/active-record-recipes - GitHub


つっつきボイス:「なるほど、PostgreSQLのintervalというデータ型を使うことで、期間をintegerではなくActiveSupport::Durationで扱えるようになる↓」「PostgreSQLでサポートされているものに近いデータ型がちょうどRailsでもサポートされているので、結果的にこういうことができるようになった感じですね」

# 同記事より
class CreateSteps < ActiveRecord::Migration[7.0]
  def change
    create_table :steps do |t|
      t.interval :duration
      ...
    end
  end
end
# 同記事より
step = Step.new(duration: 10.minutes)
step.duration
# => 10.minutes

参考: PostgreSQL 13 ドキュメント 8.5. 日付/時刻データ型
参考: Rails API ActiveSupport::Duration

「期間を足したり引いたり大小比較したりという操作を頻繁に使うなら、演算をDB側で行うことでパフォーマンスもよくなりそう👍」

# 同記事
class Recipe < ApplicationRecord
  has_many :steps

  def self.with_duration_less_than, -> (duration){
    joins(:steps)
      .group(:id)
      .having("SUM(steps.duration) <= ?", duration.iso8601)
  }
end

Recipe.with_duration_less_than(60.minutes)
# => [#<Recipe>, #<Recipe>]

「ところで、コード中のISO 8601をぐぐってみたら期間の表現方法も規格に含まれていた↓」「ほんとだ」「P3Y6M4DT12H30M5Sみたいなフォーマットは、Durationオブジェクトとして扱う分には気にしなくていいけど、SQLログを読んでデバッグするときのことを想像すると読みづらそうかな…」

参考: ISO 8601 – Wikipedia

たとえば、P3Y6M4DT12H30M5Sは、「3年、6か月、4日、12時間、30分、5秒」という継続時間を表現している。
Wikipediaより

🔗 Rails 7のquery_log_tags

つっつきボイス:「query_log_tagsを使うとクエリログに手軽にコメントを付けられる: development環境とかで便利そう👍」

参考: Rails API ActiveRecord::QueryLogs

🔗 RailsConf 2022のキーノートスピーチ(Ruby Weeklyより)

つっつきボイス:「タイトルはThe Success of Rails、発表者の@eileencodesさんはRailsコアチームの方です」「技術的な話よりもRailsの現在の活動や今後の展望がメインという感じのキーノートスピーチらしい発表👍」「オープンなDiscordサーバーがもうすぐ登場するらしい↓」

🔗 条件付きGETリクエストでRailsアプリを高速化(Ruby Weeklyより)

# 同記事より
def index
  @user = User.find(params[:user_id])
  if stale?(etag: @user.updated_at)
    @recipes = @user.recipes
  else
    # head 304
  end
end

つっつきボイス:「クックパッドの英語ブログです」「HTTPの条件付きリクエストか、似たような趣旨の記事を以前もウォッチで扱った覚えがありますね」

参考: HTTP 条件付きリクエスト – HTTP | MDN

「記事にあるETagとIf-MatchIf-None-Matchなどのヘッダーは、一般にはキャッシュを失効させるときなんかに使います」「なるほど」「HTTP条件付きリクエストと効果的に組み合わせられれば、リクエスト数がものすごく多いWebアプリのパフォーマンスを改善できるというのはわかる: MDNと合わせて読んでおくとよさそう👍」

参考: ETag – HTTP | MDN
参考: If-None-Match – HTTP | MDN


「そうそう、以前扱ったのはちょうどMDNにも載っている『楽観的ロックで更新が失われる問題を避ける』でしたね(ウォッチ20211102)」「そういえばそうでした」

🔗 フォーラムアプリを作りながらHotwireを学ぶ

つっつきボイス:「Hotwireを学ぶコースが無料になったと書かれている」「目次を見るとかなり充実してそう👍」

🔗 Hotwireよもやま

「何度か話しましたけど、Hotwireは割と筋がいいと思っています」「ちょうど以下のツィートを見かけました↓」「お〜、GitHubもHotwireを使っているんですね」「GitHubのHTMLソースを自分でも見てみると、たしかにdata-turbo-*がありました」「GitHubがHotwireを使っているなら、Hotwireを導入するうえでアピールになりそう👍」

「そういえば、古参のRails開発会社である万葉さんも、ちょうどHotwireを推す宣言をしていましたね↓」「お〜!」


前編は以上です。

バックナンバー(2022年度第2四半期)

週刊Railsウォッチ: Shopifyのlanguage server ruby-lsp、PostgreSQL 15 Beta 1リリースほか(20220607後編)

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。