Running out of memory or taking a lot of time when destroying a lot of records? The solution to this problem is destroyer
. destroyer
helps you delete all records and their related records marked as :dependent => :destroy
without the need to instantiate them, this way the process is faster.
Installation
Include destroyer
in your Gemfile and then run:
bundle install
or simply execute:
gem install destroyer
Then, you just need to add destroyer
to the model you want to delete objects from, passing it a lambda
or Proc
that returns an array of ids, and then just callYourModel.start_destroyer
.
Example
class PurchaseOrder < ActiveRecord::Base
has_many :line_items, :dependent => :destroy
destroyer lambda { select("id").where(["state = 'deleted' AND created_at < ?", 1.month.ago]) }
end
class LineItem < ActiveRecord::Base
has_many :variant_line_items, :dependent => :destroy
belongs_to :purchase_order
end
class VariantLineItem < ActiveRecord::Base
belongs_to :line_item
end
PurchaseOrder.start_destroyer
This code will delete all purchase orders whose ‘state’ is ‘deleted’ and are older that a month ago. It will also delete all its related line items as well as all of their variant line items without instantiating their objects.
You can also send different blocks to destroyer
that return different results, take into account that the first block you pass to destroyer
will be the default
block, for example, you could also do this:
PurchaseOrder.destroyer lambda { PurchaseOrder.select("id").where(["state = 'incomplete'"]) }
or
PurchaseOrder.destroyer lambda { [1,2,3,4,5] }
but you have to make sure to run PurchaseOrder.start_destroyer
to execute the last block you passed to it, otherwise, the next time you execute PurchaseOrder.start_destroyer
will have that last block and you might have different results than expected, or, if you didn’t use that last block, then make sure to set it to nil
:
User.destroyer_block = nil
setting the block to nil
won’t set original block to nil
Notes
destroyer
also accepts a hash of options, although right now the only available option is batch_size
. It is used to delete all records in batches, its default is 1000, make sure to set it to an empty hash if you modified the value and did not call start_destroyer
, otherwise it will have the last value the next time you call the start_destroyer
method.
destroyer
starts deleting from the last related active record model to the top one(the one in which you put the destroyer
with the block) to avoid problems if you’re using foreign key constraints.
Benchmarking
Using a MackBook Pro and sqlite3
, I created the following data:
1000.times do
po = PurchaseOrder.create
2.times do
line_item = LineItem.create(:purchase_order => po)
2.times do
VariantLineItem.create(:line_item => line_item)
end
end
end
and then using ActiveRecord destroy_all
:
Benchmark.measure { PurchaseOrder.destroy_all }
I got this:
destroying 1000 Purchase Orders, 2000 Line Items and 4000 Variant Line Items
4.540000 0.030000 4.570000 ( 4.559492)
Using destroyer
:
PurchaseOrder.destroyer lambda { PurchaseOrder.select("id").all }
I got this:
destroying 1000 Purchase Orders, 2000 Line Items and 4000 Variant Line Items
0.220000 0.000000 0.220000 ( 0.223531)
and if I increase the batch size to 4000 using destroyer
like this:
PurchaseOrder.destroyer lambda { PurchaseOrder.select("id").all }, :batch_size => 4000
I got this:
destroying 1000 Purchase Orders, 2000 Line Items and 4000 Variant Line Items
0.190000 0.000000 0.190000 ( 0.192718)
As you can see, using destroyer
is faster that using destroy_all
because destroyer
does not instantiate the objects, but destroy_all
does instantiate the object and runs its before_destroy
and after_destroy
callbacks, if you want to run the callbacks then your option would be destroy_all
.