おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

Rails 7.0.5以降におけるcreate_associationメソッドの挙動変更についてまとめ

この件、関連するPRやIssueが複数あってコメントも分散しており、人に説明するのがややこしいのでブログとしてまとめたものになります。間違いや意見などあったらコメントください!

追記(2023/08/02)

7-0-stableブランチに今回の変更をrevertするコミットが入りました。挙動が変わって困った人が出たので一度元に戻して仕切り直しにする方針のようです。

[7-0-stable] Revert singular association breaking changes by zzak · Pull Request #48809 · rails/rails

7.0.7がいつリリースされるかはわかりませんが、今の状態でリリースされたら7.0.4の振る舞いに戻ることになります。7.0.5以降の変更で困っている人は一旦7-0-stableを指すようにすると良いかもしれません。

概要

  • Railsの7.0.4と7.0.5以降ではhas_one関連を定義したときに生成されるcreate_associationメソッドの挙動が違う
  • create_associationの振る舞いを「新しい関連先のレコードを作る」という挙動だけだと思っていると、7.0.5以降を使うためにコードを修正する必要があるかもしれない

前提

  • has_one関連で、すでに関連先が永続化されているときに新しく関連先を作ろうとすると、既存の関連先は削除される*1
  • 削除のされ方はhas_oneのdependentオプションによって決まる*2
    • :nullify、もしくはdependentオプションを設定していないときは外部キーをNULLにしてupdate
    • :destoryのときはdestroyメソッドで削除
    • :deleteのときはdeleteメソッドで削除

例えば次のコードでの(1)~(3)の全てで、既存の関連先に紐づくレコードがあればそれを削除します。

class User < ApplicationRecord
  has_one :address, dependent: :destroy
end

class Address < ApplicationRecord
  belongs_to :user
end

user = User.find(42)
user.address = Address.new # (1)
user.create_address # (2)
user.build_address # (3)

Rails7.0.4までの挙動

これまで、create_associationの挙動は Improve singular association creation by lazaronixon · Pull Request #46386 · rails/rails で説明があるように別々のトランザクションでinsertしてからdeleteするという挙動でした。

# begin transaction
INSERT new_record;
# commit transaction

# begin transaction
DELETE old_record;
# commit transaction

さらに、先行のinsert(saveメソッド経由)がバリデーションエラーなどで失敗しても、後続のdeleteは実行されます。この挙動により、既存のレコードがある状態でcreate_association!をした際にバリデーションエラーになっても既存のレコードが削除される、という不具合がありました。

関連Issue: has_one association getting deleted on using create_association & validation fails · Issue #46737 · rails/rails

他にも、次のような流れで既存レコードの削除が効かない、という問題もありました。

  1. バリデーションが通った際は新規にレコードを作成する
  2. 次に既存のレコードを削除する
  3. 削除するための既存のレコードの取得はhas_one関連経由になる
  4. has_one関連のorder byが設定されていない場合(has_oneでorder by設定することはほぼないと思われるので、ほとんどのケースが該当する)で稀に作ったばかりのレコードを取得してしまう
  5. この作ったばかりのレコードが取得された場合は削除するレコードがないという扱いになる
  6. 結果として、削除が効かずレコードが2件以上存在する

関連Issue: Sometimes create_association does not delete existing records · Issue #47554 · rails/rails

Rails7.0.5以降の挙動

7.0.5では次のように同一トランザクションでdeleteしてからinsertする、という挙動に変わりました。上記の不具合はほぼ解消*3されました。

# begin transaction
DELETE old_record;
INSERT new_record;
# commit transaction

しかし、もともとhas_oneの外部キーに対してユニーク制約をかけており、既存のレコードがあるときにcreate_associationするとバリデーションエラーとしたいアプリケーションにとってはコードの修正が必要になります。

関連Issue: has_one relation start deleting existing record even when new record failed passing validation · Issue #48330 · rails/rails

所感

7.0.5における変更は「前提」の節で説明した挙動の整合性を取るために必要な修正であったと個人的に感じていますが、一方で「association=build_associationcreate_associationをしたら既存のレコードが削除される」というのは知らない人にとってはびっくりする挙動とも思うので、今回の変更でコードの修正を強いられてつらい、という人の気持ちもわかります。

これ、そもそもどのような挙動をするとみんなが幸せになれるんでしょうね…???

*1:association=がその挙動をするのは書かれている http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_one けれど、build_associationやcreate_associationはRailsガイド含めて書かれていなさそう

*2: コード的にはこのあたりが該当します。 https://github.com/rails/rails/blob/89508b95d8d289b430cca7db05109d8171ff7a5f/activerecord/lib/active_record/associations/has_one_association.rb#L94-L116

*3:!なしのcreate_associationでバリデーションエラーになったときは変わらず既存のレコードがDELETEされるのですが、これはbuild_associationしてsaveしても一緒なのでそういう仕様と言えなくもない

株式会社ウィルネットは設立6周年を迎えました

2月21日は弊社の設立記念日です。6周年あっという間ですね。

おおまかな仕事の仕方は設立2周年のときから特に大きく変わってはいません。が4年間でお手伝い先も子供も増えて、コロナ禍で通勤がなくなったにもかかわらずめっちゃ忙しくしております。

それでも多少子育てに余裕が出てきた気がするので、今期はメモブログも合わせて発信をもう少し増やしていきたいな〜、と思っています。関係各位今後とも宜しくお願いします(\( ⁰⊖⁰)/)

miucheeをサービス終了します

10年ほど前にサービス開始したmiucheeですが、この度Twitter API有料化を受けてサービス終了することにしました。

10年前はぼくの両親の携帯電話はガラケーでしたが、今となっては二人ともスマホを使うようになり、みてね 経由で孫の写真を共有したり、facetimeでビデオ通話をしたりとするようになりました。

昔と比べるとスマホを始めとするデバイスがいろんな世代にも普及して、情報共有がやりやすくなった結果miucheeもその役目を終えたのではと思います。

ご利用いただいていたユーザの皆様ありがとうございました。

wkhtmltopdfの次どうするか問題

wkhtmltopdfというライブラリは、HTMLからPDFを生成してくれるライブラリです。Rubyからだとwicked_pdfpdfkit 経由で使われることが多いです。

さてそんなwkhtmltopdfですが、GitHubリポジトリ を見るとわかるようにアーカイブされてしまっています。公式ページ に経緯が書いてありますが、要約するとwkhtmltopdfが依存しているQtWebkitのメンテが止まったのが原因でメンテが続けられなくなったということのようです。

wkhtmltopdfが参照しているQtWebkitはかなり古いものなので、モダンなブラウザでの描画と差分が出たり、脆弱性を付かれたりする可能性がありそう。なのでなにか別のやり方に乗り換えたい。

選択肢を考えてみる

乗り換え先は具体的に何がいいの、というと僕も自信を持ってこれだ!という解を持っていないのですが、headless chromeを使ってHTMLをPDFに変換するという方法がぱっと思い浮かびます。そしてchromeを動かすライブラリとしては ferrum がいいかな?となっているところです。同系統のgemには他にも grover などのgemがありますが、これはpuppetterを経由してchromeを実行します。ferrumは直接chromeを操作するので、登場人物が一つ減る分運用が楽そう。

しかしそもそもchromeを使ってHTMLをPDFに変換するのってメモリ容量やCPUへの負荷はどれくらいかかるものでしょうか?まだなにも計測していないのですが、wkhtmltopdfのときよりも負荷が高そうな雰囲気を感じます。(もちろん要件次第ですが)Thinreports などの直接PDFを生成するツールを活用したほうが良かったりするかもしれません。

と、以上のように考えていたら煮詰まってきました。そこでいったんここまでの状態でまとめて、PDFに詳しい人からのご意見を聞けたらいいなと思ってブログとして公開しておきます。だれか知見を持っている人アドバイスください :pray:

余談

wkhtmltopdf自体のインストール方法はいくつかありますが、wicked_pdfやPDFKitのREADMEなどで紹介されている wkhtmltopdf_binary_gem を使ってていることが多いかと思います。このgemは現在メンテナンスが滞り気味で、例えばaarch64(M1 mac)用のバイナリに現状対応していません。M1 macで開発していて困っている人、それなりにいるのではないでしょうか。

とりあえずdebianに関してはこのPR で対応済みなので、現状M1で使えなくて困るぞ、というのであればこのブランチを使ってください。

(2024/05/10追記)PRがマージされて新しいバージョンがリリースされたので、M1 macでもwkhtmltopdf_binary_gemを使えるようになっています

失敗したテストのログだけを出力するぞ

表題の通りのことができるgem、CiLoggerが便利ですよという話です。

私達は大量のテストをCI上で実行しています。テスト結果を見れば失敗理由が自明なものもありますが、E2Eテストなどでよく起きる「たまに失敗するテスト」の調査はログやスクリーンショットなど、可能な限りの情報を集めないと根本原因がつかめないことが多いです。

そんなときに、特に考えずRailsデフォルトの設定(config.log_level #=> :debug)のままにしておくと、膨大なログの中から該当するテストに関連する行を探し当てる作業が必要になります。これは事前の準備なしではほぼ不可能です。

事前の準備として簡単に思いつく方法は、テスト前後で「どのテストが開始/終了したか」をログに出力することです。

config.around do |example|
  Rails.logger.debug("start example at #{example.location}")
  example.run
  Rails.logger.debug("finish example at #{example.location}")
end

これで頑張れば失敗したテストに関連するログを探せるようになりました。が、やってみるとわかるのですが大量のログから該当行を探し当てるのは面倒です。それでも他の方法がなかったので長い間これで運用を続けていました。

そんな折ふと「そもそもCIでほしいのは失敗したテストのログだけであり、他は不要では?」と思いついたのでCiLoggerを作ってみました。このgemの内部ではloggerへの書き込みをいったんメモリ上に保持しておき、テストが失敗したらファイルに書き出す、という処理を行っています。これでしばらく運用してみて、仮説(失敗したログだけあれば良い)が正しそうであることを実感しています。

また、GitHub ActionsやCircleCIなどはArtifactの容量によって課金されるので、CiLoggerを使うとそれらの料金を抑えられるのもメリットです。よければ使ってみてください。

注意点

現時点(v0.3.0)ではRSpecのみに対応しています。minitestで使いたい人はPRお願いします :pray:

余談

開発当初は、CiLoggerを使うとログファイルへの書き込み量が減るので速度改善効果もあるだろうと想定していたのですが、少なくとも僕が測定した限りでは優位な速度差が出ませんでした。CiLoggerを使わずにlog_level:errorにしても速度差が出なかったので、計測方法が悪いのかなにか見落としがあるのか…。知見ある人いたら教えて下さい><

iCARE Dev Meetup #34で登壇しました

【iCARE Dev Meetup #34】10年続くRailsアプリ開発のために大事なこと - connpass

所感

長期間アプリケーションを無理なく運用していくには内部品質を一定以上に保つ必要があり、それに注力するためには発表で話した「ふつうのこと」に対するエンジニアをはじめとした会社全体の理解が大事なので僕たちは頑張ってコミュニケーションとっていきましょう、そしてふつうを普通にやっていきましょうということが言いたかったのですが、それだけだとRails風味が0なのと抽象的かつ実現が困難な話に終止してしまうので、推しgem(ViewComponent)の紹介を追加して具体的なRails味を出してみました。

どの会社の発表も、みんな泥臭く汗をかいて頑張っているんだな、というのが伝わり自分もやっていくぞ!という気持ちが高まる良い発表でした!今日もやっていきます(\( ⁰⊖⁰)/)