Rails 6.1 allows associations to be destroyed asynchronously

Srijan Kapoor

Srijan Kapoor

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.

class Team < ApplicationRecord
  has_many :players, dependent: :destroy_async
end

class Player < ApplicationRecord
  belongs_to :team
end

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.

class TeamTest < ActiveSupport::TestCase
  include ActiveJob::TestHelper

  test "destroying a record destroys the associations using a background job" do
    team = Team.create!(name: "Portugal", manager: "Fernando Santos")
    player1 = Player.new(name: "Bernardo Silva")
    player2 = Player.new(name: "Diogo Jota")
    team.players << [player1, player2]
    team.save!

    team.destroy

    assert_enqueued_jobs 1
    assert_difference -> { Player.count }, -2 do
      perform_enqueued_jobs
    end
  end
end

Finished in 0.232213s, 4.3064 runs/s, 8.6128 assertions/s.
1 runs, 2 assertions, 0 failures, 0 errors, 0 skips

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

irb(main):011:0> team.destroy
  TRANSACTION (0.1ms)  begin transaction
  Player Load (0.6ms)  SELECT "players".* FROM "players" WHERE "players"."team_id" = ?  [["team_id", 6]]

Enqueued 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}

Performed 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.

irb(main):015:0> team.destroy
  TRANSACTION (0.1ms)  begin transaction
  Player Load (0.1ms)  SELECT "players".* FROM "players" WHERE "players"."team_id" = ?  [["team_id", 7]]

Enqueued 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}
  Team Destroy (0.9ms)  DELETE FROM "teams" WHERE "teams"."id" = ?  [["id", 7]]
  TRANSACTION (1.1ms)  rollback transaction

Performing 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}
Traceback (most recent call last):
        1: from (irb):15
ActiveRecord::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.

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.