Back to Blog

Rails 6.1 allows associations to be destroyed asynchronously

on December 8, 2020
This blog is part of our Rails 6.1 series.

In Rails 6.1, Rails will enqueue a background job to destroy associated records if dependent: :destroy_async is setup.

Let's consider the following example.

1class Team < ApplicationRecord
2  has_many :players, dependent: :destroy_async
3end
4
5class Player < ApplicationRecord
6  belongs_to :team
7end

Now, if we call the destroy method on an instance of class Team Rails would enqueue an asynchronous job to delete the associated players records.

We can verify this asynchronous job with the following test case.

1class TeamTest < ActiveSupport::TestCase
2  include ActiveJob::TestHelper
3
4  test "destroying a record destroys the associations using a background job" do
5    team = Team.create!(name: "Portugal", manager: "Fernando Santos")
6    player1 = Player.new(name: "Bernardo Silva")
7    player2 = Player.new(name: "Diogo Jota")
8    team.players << [player1, player2]
9    team.save!
10
11    team.destroy
12
13    assert_enqueued_jobs 1
14    assert_difference -> { Player.count }, -2 do
15      perform_enqueued_jobs
16    end
17  end
18end
19
20Finished in 0.232213s, 4.3064 runs/s, 8.6128 assertions/s.
211 runs, 2 assertions, 0 failures, 0 errors, 0 skips

Alternatively, this enqueue behavior can also be demonstrated in rails console.

1irb(main):011:0> team.destroy
2  TRANSACTION (0.1ms)  begin transaction
3  Player Load (0.6ms)  SELECT "players".* FROM "players" WHERE "players"."team_id" = ?  [["team_id", 6]]
4
5Enqueued ActiveRecord::DestroyAssociationAsyncJob (Job ID: 4df07c2d-f55b-48c9-8c20-545b086adca2) to Async(active_record_destroy) with arguments: {:owner_model_name=>"Team", :owner_id=>6, :association_class=>"Player", :association_ids=>[1, 2], :association_primary_key_column=>:id, :ensuring_owner_was_method=>nil}
6
7Performed ActiveRecord::DestroyAssociationAsyncJob (Job ID: 4df07c2d-f55b-48c9-8c20-545b086adca2) from Async(active_record_destroy) in 34.5ms

However, this behaviour is inconsistent and the destroy_async option should not be used when the association is backed by foreign key constraints in the database.

Let us consider another example.

CASE: With a simple foreign key on the team_id column in place.

1irb(main):015:0> team.destroy
2  TRANSACTION (0.1ms)  begin transaction
3  Player Load (0.1ms)  SELECT "players".* FROM "players" WHERE "players"."team_id" = ?  [["team_id", 7]]
4
5Enqueued ActiveRecord::DestroyAssociationAsyncJob (Job ID: 69e51e5f-5b59-4095-92db-90aab73a7f65) to Async(default) with arguments: {:owner_model_name=>"Team", :owner_id=>7, :association_class=>"Player", :association_ids=>[1], :association_primary_key_column=>:id, :ensuring_owner_was_method=>nil}
6  Team Destroy (0.9ms)  DELETE FROM "teams" WHERE "teams"."id" = ?  [["id", 7]]
7  TRANSACTION (1.1ms)  rollback transaction
8
9Performing ActiveRecord::DestroyAssociationAsyncJob (Job ID: 69e51e5f-5b59-4095-92db-90aab73a7f65) from Async(default) enqueued at 2021-01-03T21:10:21Z with arguments: {:owner_model_name=>"Team", :owner_id=>7, :association_class=>"Player", :association_ids=>[1], :association_primary_key_column=>:id, :ensuring_owner_was_method=>nil}
10Traceback (most recent call last):
11        1: from (irb):15
12ActiveRecord::InvalidForeignKey (SQLite3::ConstraintException: FOREIGN KEY constraint failed)

An exception is raised by Rails and the record is not destroyed.

CASE: With a cascading foreign key using on_delete: :cascade

Here, even though ActiveRecord::DestroyAssociationAsyncJob would run to successful completion, the associated players records would already be deleted inside the same transaction block destroying the team record, and it would skip any destroy callbacks like before_destroy, after_destroy or after_commit on: :destroy.

This makes using destroy_async redundant in such a case.

Check out the pull request for more details.