Skip to content

Commit 1cdee90

Browse files
Merge pull request rails#40445 from robertomiranda/destroy_all-in_batcches
ActiveRecord::Relation#destroy_all perform its work in batches
2 parents c3a1bfe + 4e25ced commit 1cdee90

File tree

9 files changed

+111
-11
lines changed

9 files changed

+111
-11
lines changed

activerecord/CHANGELOG.md

+21
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
* Relation#destroy_all perform its work in batches
2+
3+
Since destroy_all actually loads the entire relation and then iteratively destroys the records one by one,
4+
you can blow your memory gasket very easily. So let's do the right thing by default
5+
and do this work in batches of 100 by default and allow you to specify
6+
the batch size like so: #destroy_all(batch_size: 100).
7+
8+
Apps upgrading to 7.0 will get a deprecation warning. As of Rails 7.1, destroy_all will no longer
9+
return the collection of objects that were destroyed.
10+
11+
To transition to the new behaviour set the following in an initializer:
12+
13+
```ruby
14+
config.active_record.destroy_all_in_batches = true
15+
```
16+
17+
The option is on by default for newly generated Rails apps. Can be set in
18+
an initializer to prevent differences across environments.
19+
20+
*Genadi Samokovarov*, *Roberto Miranda*
21+
122
* Adds support for `if_not_exists` to `add_foreign_key` and `if_exists` to `remove_foreign_key`.
223

324
Applications can set their migrations to ignore exceptions raised when adding a foreign key

activerecord/lib/active_record/associations/has_many_through_association.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def delete_records(records, method)
137137
case method
138138
when :destroy
139139
if scope.klass.primary_key
140-
count = scope.destroy_all.count(&:destroyed?)
140+
count = scope.each(&:destroy).count(&:destroyed?)
141141
else
142142
scope.each(&:_run_destroy_callbacks)
143143
count = scope.delete_all

activerecord/lib/active_record/core.rb

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def self.configurations
7777

7878
class_attribute :default_shard, instance_writer: false
7979

80+
class_attribute :destroy_all_in_batches, instance_accessor: false, default: false
81+
8082
def self.application_record_class? # :nodoc:
8183
if ActiveRecord.application_record_class
8284
self == ActiveRecord.application_record_class

activerecord/lib/active_record/relation.rb

+37-4
Original file line numberDiff line numberDiff line change
@@ -560,8 +560,9 @@ def touch_all(*names, time: nil)
560560
# Destroys the records by instantiating each
561561
# record and calling its {#destroy}[rdoc-ref:Persistence#destroy] method.
562562
# Each object's callbacks are executed (including <tt>:dependent</tt> association options).
563-
# Returns the collection of objects that were destroyed; each will be frozen, to
564-
# reflect that no changes should be made (since they can't be persisted).
563+
# Returns the collection of objects that were destroyed if
564+
# +config.active_record.destroy_all_in_batches+ is set to +false+. Each
565+
# will be frozen, to reflect that no changes should be made (since they can't be persisted).
565566
#
566567
# Note: Instantiation, callback execution, and deletion of each
567568
# record can be time consuming when you're removing many records at
@@ -573,8 +574,40 @@ def touch_all(*names, time: nil)
573574
# ==== Examples
574575
#
575576
# Person.where(age: 0..18).destroy_all
576-
def destroy_all
577-
records.each(&:destroy).tap { reset }
577+
#
578+
# If +config.active_record.destroy_all_in_batches+ is set to +true+, it will ensure
579+
# to perform the record's deletion in batches
580+
# and destroy_all won't longer return the collection of the deleted records
581+
#
582+
# ==== Options
583+
# * <tt>:start</tt> - Specifies the primary key value to start from, inclusive of the value.
584+
# * <tt>:finish</tt> - Specifies the primary key value to end at, inclusive of the value.
585+
# * <tt>:batch_size</tt> - Specifies the size of the batch. Defaults to 1000.
586+
# * <tt>:error_on_ignore</tt> - Overrides the application config to specify if an error should be raised when
587+
# an order is present in the relation.
588+
# * <tt>:order</tt> - Specifies the primary key order (can be :asc or :desc). Defaults to :asc.
589+
#
590+
# NOTE: These arguments are honoured only if +config.active_record.destroy_all_in_batches+ is set to +true+.
591+
#
592+
# ==== Examples
593+
#
594+
# # Let's process from record 10_000 on, in batches of 2000.
595+
# Person.destroy_all(start: 10_000, batch_size: 2000)
596+
#
597+
def destroy_all(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil, order: :asc)
598+
if ActiveRecord::Base.destroy_all_in_batches
599+
batch_options = { of: batch_size, start: start, finish: finish, error_on_ignore: error_on_ignore, order: order }
600+
in_batches(**batch_options).each_record(&:destroy).tap { reset }
601+
else
602+
ActiveSupport::Deprecation.warn(<<~MSG.squish)
603+
As of Rails 7.1, destroy_all will no longer return the collection
604+
of objects that were destroyed.
605+
To transition to the new behaviour set the following in an
606+
initializer:
607+
Rails.application.config.active_record.destroy_all_in_batches = true
608+
MSG
609+
records.each(&:destroy).tap { reset }
610+
end
578611
end
579612

580613
# Deletes the records without instantiating the records

activerecord/test/cases/helper.rb

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
# Quote "type" if it's a reserved word for the current connection.
3030
QUOTED_TYPE = ActiveRecord::Base.connection.quote_column_name("type")
3131

32+
# FIXME: Remove this in Rails 7.1 when it's no longer needed.
33+
ActiveRecord::Base.destroy_all_in_batches = true
34+
3235
def current_adapter?(*types)
3336
types.any? do |type|
3437
ActiveRecord::ConnectionAdapters.const_defined?(type) &&

activerecord/test/cases/relation/delete_all_test.rb

+23-4
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,37 @@ class DeleteAllTest < ActiveRecord::TestCase
1212
def test_destroy_all
1313
davids = Author.where(name: "David")
1414

15+
assert_difference("Author.count", -1) do
16+
davids.destroy_all
17+
end
18+
end
19+
20+
def test_destroy_all_no_longer_returns_a_collection
21+
davids = Author.where(name: "David")
22+
23+
assert_nil davids.destroy_all
24+
end
25+
26+
def test_destroy_all_deprecated_returns_collection
27+
ActiveRecord::Base.destroy_all_in_batches = false
28+
davids = Author.where(name: "David")
29+
1530
# Force load
1631
assert_equal [authors(:david)], davids.to_a
1732
assert_predicate davids, :loaded?
1833

19-
assert_difference("Author.count", -1) do
20-
destroyed = davids.destroy_all
21-
assert_equal [authors(:david)], destroyed
22-
assert_predicate destroyed.first, :frozen?
34+
assert_deprecated do
35+
assert_difference("Author.count", -1) do
36+
destroyed = davids.destroy_all
37+
assert_equal [authors(:david)], destroyed
38+
assert_predicate destroyed.first, :frozen?
39+
end
2340
end
2441

2542
assert_equal [], davids.to_a
2643
assert_predicate davids, :loaded?
44+
ensure
45+
ActiveRecord::Base.destroy_all_in_batches = true
2746
end
2847

2948
def test_delete_all

activerecord/test/cases/relations_test.rb

+16-2
Original file line numberDiff line numberDiff line change
@@ -1846,8 +1846,22 @@ def test_destroy_by
18461846

18471847
assert_difference("Post.count", -3) { david.posts.destroy_by(body: "hello") }
18481848

1849-
destroyed = Author.destroy_by(id: david.id)
1850-
assert_equal [david], destroyed
1849+
assert_difference("Author.count", -1) do
1850+
Author.destroy_by(id: david.id)
1851+
end
1852+
end
1853+
1854+
def test_destroy_all_deprecated_return_value
1855+
ActiveRecord::Base.destroy_all_in_batches = false
1856+
david = authors(:david)
1857+
1858+
assert_deprecated do
1859+
assert_difference("Author.count", -1) do
1860+
assert_equal [david], Author.where(id: david.id).destroy_all
1861+
end
1862+
end
1863+
ensure
1864+
ActiveRecord::Base.destroy_all_in_batches = true
18511865
end
18521866

18531867
test "find_by with hash conditions returns the first matching record" do

guides/source/configuring.md

+5
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,11 @@ The schema dumper adds two additional configuration options:
511511
`fk_rails_` are not exported to the database schema dump.
512512
Defaults to `/^fk_rails_[0-9a-f]{10}$/`.
513513
514+
* `config.active_record.destroy_all_in_batches` ensures
515+
ActiveRecord::Relation#destroy_all to perform the record's deletion in batches.
516+
ActiveRecord::Relation#destroy_all won't longer return the collection of the deleted
517+
records after enabling this option.
518+
514519
### Configuring Action Controller
515520

516521
`config.action_controller` includes a number of configuration settings:

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_0.rb.tt

+3
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@
4343
# of the video).
4444
# Rails.application.config.active_storage.video_preview_arguments =
4545
# "-vf 'select=eq(n\\,0)+eq(key\\,1)+gt(scene\\,0.015),loop=loop=-1:size=2,trim=start_frame=1' -frames:v 1 -f image2"
46+
#
47+
# Enforce destroy in batches when calling ActiveRecord::Relation#destroy_all
48+
Rails.application.config.active_record.destroy_all_in_batches = true

0 commit comments

Comments
 (0)