Skip to content

Commit

Permalink
Add JobIteration::DestroyAssociationJob
Browse files Browse the repository at this point in the history
Active Record 6.1 introduced the 'dependent: :destroy_async' option for associations with a configurable job to asynchronously destroy related models. This PR ports this job to use the Iteration API, making it interruptible and resumable.
  • Loading branch information
Bart de Water committed Oct 30, 2021
1 parent 96a7b4a commit 6d790be
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
### Master (unreleased)
- [140](https://github.com/Shopify/job-iteration/pull/140) - Add `JobIteration::DestroyAssociationJob` to be used by Active Record associations with the `dependent: :destroy_async` option

## v1.3.0 (Oct 7, 2021)
- [133](https://github.com/Shopify/job-iteration/pull/133) - Moves attributes out of JobIteration::Iteration included block
Expand Down
50 changes: 50 additions & 0 deletions lib/job-iteration/destroy_association_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

require "active_job"
require "active_record/destroy_association_async_job"

module JobIteration
# Port of https://github.com/rails/rails/blob/main/activerecord/lib/active_record/destroy_association_async_job.rb
# (MIT license) but instead of +ActiveRecord::Batches+ this job uses the +Iteration+ API to destroy associated
# objects.
#
# @see https://guides.rubyonrails.org/association_basics.html Using the 'dependent: :destroy_async' option
# @see https://guides.rubyonrails.org/configuring.html#configuring-active-record Configuring Active Record
# 'destroy_association_async_job' and 'queues.destroy' options
class DestroyAssociationJob < ::ActiveJob::Base
include(JobIteration::Iteration)

queue_as do
# Compatibility with Rails 7 and 6.1
queues = defined?(ActiveRecord.queues) ? ActiveRecord.queues : ActiveRecord::Base.queues
queues[:destroy]
end

discard_on(ActiveJob::DeserializationError)

def build_enumerator(params, cursor:)
association_model = params[:association_class].constantize
owner_class = params[:owner_model_name].constantize
owner = owner_class.find_by(owner_class.primary_key.to_sym => params[:owner_id])

unless owner_destroyed?(owner, params[:ensuring_owner_was_method])
raise ActiveRecord::DestroyAssociationAsyncError, "owner record not destroyed"
end

enumerator_builder.active_record_on_records(
association_model.where(params[:association_primary_key_column] => params[:association_ids]),
cursor: cursor,
)
end

def each_iteration(record, _params)
record.destroy
end

private

def owner_destroyed?(owner, ensuring_owner_was_method)
!owner || (ensuring_owner_was_method && owner.public_send(ensuring_owner_was_method))
end
end
end
29 changes: 29 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

require "job-iteration"
require "job-iteration/test_helper"
require "job-iteration/destroy_association_job"

require "globalid"
require "sidekiq"
Expand All @@ -19,6 +20,7 @@

GlobalID.app = "iteration"
ActiveRecord::Base.include(GlobalID::Identification) # https://github.com/rails/globalid/blob/master/lib/global_id/railtie.rb
ActiveRecord::Base.destroy_association_async_job = JobIteration::DestroyAssociationJob

module ActiveJob
module QueueAdapters
Expand All @@ -43,6 +45,26 @@ def enqueue_at(job, _delay)
ActiveJob::Base.queue_adapter = :iteration_test

class Product < ActiveRecord::Base
has_many :variants, dependent: :destroy_async
end

class SoftDeletedProduct < ActiveRecord::Base
self.table_name = "products"
has_many :variants, foreign_key: "product_id", dependent: :destroy_async, ensuring_owner_was: :deleted?

def deleted?
deleted
end

def destroy
update!(deleted: true)
run_callbacks(:destroy)
run_callbacks(:commit)
end
end

class Variant < ActiveRecord::Base
belongs_to :product
end

host = ENV["USING_DEV"] == "1" ? "job-iteration.railgun" : "localhost"
Expand All @@ -67,6 +89,13 @@ class Product < ActiveRecord::Base

ActiveRecord::Base.connection.create_table(Product.table_name, force: true) do |t|
t.string(:name)
t.string(:deleted, default: false)
t.timestamps
end

ActiveRecord::Base.connection.create_table(Variant.table_name, force: true) do |t|
t.references(:product)
t.string(:color)
t.timestamps
end

Expand Down
50 changes: 50 additions & 0 deletions test/unit/destroy_association_job_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

require "test_helper"

module JobIteration
class DestroyAssociationJobTest < IterationUnitTest
setup do
skip unless defined?(ActiveRecord.queues) || defined?(ActiveRecord::Base.queues)

@product = Product.first
["pink", "red"].each do |color|
@product.variants.create!(color: color)
end
end

test "destroys the associated records" do
@product.destroy!

assert_difference(->() { Variant.count }, -2) do
work_job
end
end

test "checks if owner was destroyed using custom method" do
@product = SoftDeletedProduct.first
@product.destroy!

assert_difference(->() { Variant.count }, -2) do
work_job
end
end

test "throw an error if the record is not actually destroyed" do
@product.destroy!
Product.create!(id: @product.id, name: @product.name)

assert_raises(ActiveRecord::DestroyAssociationAsyncError) do
work_job
end
end

private

def work_job
job = ActiveJob::Base.queue_adapter.enqueued_jobs.pop
assert_equal(job["job_class"], "JobIteration::DestroyAssociationJob")
ActiveJob::Base.execute(job)
end
end
end

0 comments on commit 6d790be

Please sign in to comment.