ActiveRecord destroy on steroids

Sergio FigueroaMay 07, 2012

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.

blog comments powered byDisqus