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.