Skip to content

Commit 3c79b4a

Browse files
author
Bart de Water
committed
Add JobIteration::DestroyAssociationJob
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.
1 parent 96a7b4a commit 3c79b4a

File tree

4 files changed

+128
-0
lines changed

4 files changed

+128
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
### Master (unreleased)
2+
- [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
23

34
## v1.3.0 (Oct 7, 2021)
45
- [133](https://github.com/Shopify/job-iteration/pull/133) - Moves attributes out of JobIteration::Iteration included block
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
require "active_job"
4+
require "active_record/destroy_association_async_job"
5+
6+
module JobIteration
7+
# Port of https://github.com/rails/rails/blob/main/activerecord/lib/active_record/destroy_association_async_job.rb
8+
# (MIT license) but instead of +ActiveRecord::Batches+ this job uses the +Iteration+ API to destroy associated
9+
# objects.
10+
#
11+
# @see https://guides.rubyonrails.org/association_basics.html Using the 'dependent: :destroy_async' option
12+
# @see https://guides.rubyonrails.org/configuring.html#configuring-active-record Configuring Active Record
13+
# 'destroy_association_async_job' and 'queues.destroy' options
14+
class DestroyAssociationJob < ::ActiveJob::Base
15+
include(JobIteration::Iteration)
16+
17+
queue_as do
18+
# Compatibility with Rails 7 and 6.1
19+
queues = defined?(ActiveRecord.queues) ? ActiveRecord.queues : ActiveRecord::Base.queues
20+
queues[:destroy]
21+
end
22+
23+
discard_on(ActiveJob::DeserializationError)
24+
25+
def build_enumerator(params, cursor:)
26+
association_model = params[:association_class].constantize
27+
owner_class = params[:owner_model_name].constantize
28+
owner = owner_class.find_by(owner_class.primary_key.to_sym => params[:owner_id])
29+
30+
unless owner_destroyed?(owner, params[:ensuring_owner_was_method])
31+
raise ActiveRecord::DestroyAssociationAsyncError, "owner record not destroyed"
32+
end
33+
34+
enumerator_builder.active_record_on_records(
35+
association_model.where(params[:association_primary_key_column] => params[:association_ids]),
36+
cursor: cursor,
37+
)
38+
end
39+
40+
def each_iteration(record, _params)
41+
record.destroy
42+
end
43+
44+
private
45+
46+
def owner_destroyed?(owner, ensuring_owner_was_method)
47+
!owner || (ensuring_owner_was_method && owner.public_send(ensuring_owner_was_method))
48+
end
49+
end
50+
end

test/test_helper.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
require "job-iteration"
99
require "job-iteration/test_helper"
10+
require "job-iteration/destroy_association_job"
1011

1112
require "globalid"
1213
require "sidekiq"
@@ -19,6 +20,7 @@
1920

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

2325
module ActiveJob
2426
module QueueAdapters
@@ -43,6 +45,26 @@ def enqueue_at(job, _delay)
4345
ActiveJob::Base.queue_adapter = :iteration_test
4446

4547
class Product < ActiveRecord::Base
48+
has_many :variants, dependent: :destroy_async
49+
end
50+
51+
class SoftDeletedProduct < ActiveRecord::Base
52+
self.table_name = "products"
53+
has_many :variants, foreign_key: "product_id", dependent: :destroy_async, ensuring_owner_was: :deleted?
54+
55+
def deleted?
56+
deleted
57+
end
58+
59+
def destroy
60+
update!(deleted: true)
61+
run_callbacks(:destroy)
62+
run_callbacks(:commit)
63+
end
64+
end
65+
66+
class Variant < ActiveRecord::Base
67+
belongs_to :product
4668
end
4769

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

6890
ActiveRecord::Base.connection.create_table(Product.table_name, force: true) do |t|
6991
t.string(:name)
92+
t.string(:deleted, default: false)
93+
t.timestamps
94+
end
95+
96+
ActiveRecord::Base.connection.create_table(Variant.table_name, force: true) do |t|
97+
t.references(:product)
98+
t.string(:color)
7099
t.timestamps
71100
end
72101

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module JobIteration
6+
class DestroyAssociationJobTest < IterationUnitTest
7+
setup do
8+
@product = Product.first
9+
["pink", "red"].each do |color|
10+
@product.variants.create!(color: color)
11+
end
12+
end
13+
14+
test "destroys the associated records" do
15+
@product.destroy!
16+
17+
assert_difference(->() { Variant.count }, -2) do
18+
work_job
19+
end
20+
end
21+
22+
test "checks if owner was destroyed using custom method" do
23+
@product = SoftDeletedProduct.first
24+
@product.destroy!
25+
26+
assert_difference(->() { Variant.count }, -2) do
27+
work_job
28+
end
29+
end
30+
31+
test "throw an error if the record is not actually destroyed" do
32+
@product.destroy!
33+
Product.create!(id: @product.id, name: @product.name)
34+
35+
assert_raises(ActiveRecord::DestroyAssociationAsyncError) do
36+
work_job
37+
end
38+
end
39+
40+
private
41+
42+
def work_job
43+
job = ActiveJob::Base.queue_adapter.enqueued_jobs.pop
44+
assert_equal(job["job_class"], "JobIteration::DestroyAssociationJob")
45+
ActiveJob::Base.execute(job)
46+
end
47+
end
48+
end

0 commit comments

Comments
 (0)