Rails 6.1 つまみ食い① : 関連付けの非同期削除

おはようございます!

公私ともに2020年を納めきれるか心配になってきた @shutooike です。

今回から数回に亘って Rails 6.1 の新機能を浅く広くつまみ食いしていこうと思います!

セットアップ

techracho.bpsinc.jp

こちらの記事を参考に dip を使って環境構築をしていきます。

$ git clone https://github.com/hachi8833/rails6_docker_quicksetup_sqlite3.git
$ mv rails6_docker_quicksetup_sqlite3/ test_rails_latest/
$ cd test_rails_latest/
$ rm -rf .git
$ git init
$ dip provision
.
.
.
Webpacker successfully installed 🎉 🍰
Creating test_rails_latest_backend_run ... done
yarn install v1.22.4
[1/4] Resolving packages...
success Already up-to-date.
Done in 2.08s.
Creating test_rails_latest_runner_run ... done
Creating test_rails_latest_backend_run ... done
$ dip rails s
Creating test_rails_latest_rails_run ... done
=> Booting Puma
=> Rails 6.1.0 application starting in development
=> Run `bin/rails server --help` for more startup options
.
.

f:id:shutooike:20201221024624p:plain
localhost:3000

Yay!Rails 6.1 の環境構築がものの10分でできました!dip 良さげ!

次に Post has many comments なモデルを scaffold で作成します。

$ dip rails g scaffold post title:string body:string
$ dip rails g scaffold comment post:references body:string
$ dip rails db:migrate
class Post < ApplicationRecord
  has_many :comments
end

class Comment < ApplicationRecord
  belongs_to :post
end

セットアップ完了です!

Destroy Associations Async

weblog.rubyonrails.org

今回試すのは「関連レコードをバックグラウンドジョブで destroy してくれる」新機能です。

アソシエーションが大量にあるレコードを destroy する場合はタイムアウトやパフォーマンスを気にして、自前でこのような実装しているところも多いと思います。

こういう機能をフレームワークがサポートしてくれるのは嬉しいですね!

では早速、先ほどの Post モデルにつけて

class Post < ApplicationRecord
  has_many :comments, dependent: :destroy_async
end

Post レコードを削除してみます。

irb(main):001:0> post = Post.first
  Post Load (6.3ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Post id: 1, title: "test", body: "1", created_at: "2020-12-20 17:07:49.130967000 +0000", updated_at: "2020-12-20 17:07:49.130967000 +0000">
irb(main):002:0> post.comments
  Comment Load (7.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? /* loading for inspect */ LIMIT ?  [["post_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Comment id: 1, post_id: 1, body: "comment 1", created_at: "2020-12-20 17:08:23.394683000 +0000", updated_at: "2020-12-20 17:08:23.394683000 +0000">, #<Comment id: 2, post_id: 1, body: "comment 2", created_at: "2020-12-20 17:09:03.999279000 +0000", updated_at: "2020-12-20 17:09:03.999279000 +0000">]>
irb(main):003:0> post.destroy
  TRANSACTION (0.8ms)  begin transaction
  Comment Load (15.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 1]]
Enqueued ActiveRecord::DestroyAssociationAsyncJob (Job ID: 84f9208b-0e6d-46e9-a87e-17600e314c9c) to Async(default) with arguments: {:owner_model_name=>"Post", :owner_id=>1, :association_class=>"Comment", :association_ids=>[1, 2], :association_primary_key_column=>:id, :ensuring_owner_was_method=>nil}
  Post Destroy (45.6ms)  DELETE FROM "posts" WHERE "posts"."id" = ?  [["id", 1]]
  TRANSACTION (5.0ms)  rollback transaction
Traceback (most recent call last):
        1: from (irb):5
ActiveRecord::InvalidForeignKey (SQLite3::ConstraintException: FOREIGN KEY constraint failed)
irb(main):006:0>    (0.3ms)  SELECT sqlite_version(*)
Performing ActiveRecord::DestroyAssociationAsyncJob (Job ID: 84f9208b-0e6d-46e9-a87e-17600e314c9c) from Async(default) enqueued at 2020-12-20T17:12:21Z with arguments: {:owner_model_name=>"Post", :owner_id=>1, :association_class=>"Comment", :association_ids=>[1, 2], :association_primary_key_column=>:id, :ensuring_owner_was_method=>nil}
   (1.7ms)  SELECT sqlite_version(*)
  Post Load (12.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Error performing ActiveRecord::DestroyAssociationAsyncJob (Job ID: 84f9208b-0e6d-46e9-a87e-17600e314c9c) from Async(default) in 137.57ms: ActiveRecord::DestroyAssociationAsyncError (owner record not destroyed):
/usr/local/bundle/gems/activerecord-6.1.0/lib/active_record/destroy_association_async_job.rb:23:in `perform'
/usr/local/bundle/gems/activejob-6.1.0/lib/active_job/execution.rb:48:in `block in perform_now'
.
.

/usr/local/bundle/gems/concurrent-ruby-1.1.7/lib/concurrent-ruby/concurrent/executor/ruby_thread_pool_executor.rb:334:in `block in create_worker'

親の Post レコードが外部キー制約により削除できなかったため、ジョブで例外が発生しました。なんたるおバカムーブ、当たり前ですね 🤦‍♂️ 🤦‍♀️

外部キー制約を外して

class RemoveForeingKeyOnComment < ActiveRecord::Migration[6.1]
  def change
    remove_foreign_key :comments, :posts
  end
end
$ dip rails db:migrate

もう一度

irb(main):004:0> post.destroy
   (0.9ms)  SELECT sqlite_version(*)
Enqueued ActiveRecord::DestroyAssociationAsyncJob (Job ID: 8ac3ce06-aaaa-40c0-b40c-600028a99436) to Async(default) with arguments: {:owner_model_name=>"Post", :owner_id=>1, :association_class=>"Comment", :association_ids=>[1, 2], :association_primary_key_column=>:id, :ensuring_owner_was_method=>nil}
  TRANSACTION (1.6ms)  begin transaction
  Post Destroy (46.7ms)  DELETE FROM "posts" WHERE "posts"."id" = ?  [["id", 1]]
  TRANSACTION (52.5ms)  commit transaction
=> #<Post id: 1, title: "test", body: "1", created_at: "2020-12-20 17:07:49.130967000 +0000", updated_at: "2020-12-20 17:07:49.130967000 +0000">
irb(main):010:0>    (0.2ms)  SELECT sqlite_version(*)
Performing ActiveRecord::DestroyAssociationAsyncJob (Job ID: 8ac3ce06-aaaa-40c0-b40c-600028a99436) from Async(default) enqueued at 2020-12-20T17:26:22Z with arguments: {:owner_model_name=>"Post", :owner_id=>1, :association_class=>"Comment", :association_ids=>[1, 2], :association_primary_key_column=>:id, :ensuring_owner_was_method=>nil}
   (0.3ms)  SELECT sqlite_version(*)
  Post Load (6.6ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Comment Load (36.2ms)  SELECT "comments".* FROM "comments" WHERE "comments"."id" IN (?, ?) ORDER BY "comments"."id" ASC LIMIT ?  [[nil, 1], [nil, 2], ["LIMIT", 1000]]
  TRANSACTION (3.8ms)  begin transaction
  Comment Destroy (54.9ms)  DELETE FROM "comments" WHERE "comments"."id" = ?  [["id", 1]]
  TRANSACTION (31.8ms)  commit transaction
  TRANSACTION (0.6ms)  begin transaction
  Comment Destroy (109.4ms)  DELETE FROM "comments" WHERE "comments"."id" = ?  [["id", 2]]
  TRANSACTION (37.8ms)  commit transaction
Performed ActiveRecord::DestroyAssociationAsyncJob (Job ID: 8ac3ce06-aaaa-40c0-b40c-600028a99436) from Async(default) in 502.18ms

今度はうまくいきました!

(ジョブでの削除が失敗したケースは追記予定)

さいごに

destroy_async 名前がわかりやすくていいですね〜

もう少し動作の把握が出来たら個人の Rails プロダクトでも使ってみようと思います。

次回は ActiveStorage の permanent link を試すつもりです!

では良いお年を!