I wanted to improve the responsiveness of the Delete account action, which could be slow for bigger/older accounts due to the many associated records to be destroyed (downtimes, metrics) being sparsely distributed in the database.
This sounded like a wonderfully perfect use-case for the recent dependent: :destroy_async
option added in Rails 6.1. Except I'm not using ActiveRecord but Mongoid, and Mongoid at the time of writing (8.1.3) has not yet implemented this feature.
So in the meantime I had to implement this "the old way", meaning simply writting a worker to perform the deletion and I though I would share with you my implementation in case other people need to do the same. I wrote a slightly more generic worker which is not only specific for deletion, so I can use it for different records and actions. It accepts any record (by class and id), any method and then some optional arguments. I'll simply verify those exists, and execute the method on the instance:
# mongoid_method_worker.rb
# Generic worker to execute a method on a Mongoid model asynchronously:
# Example: MongoidMethodWorker.enqueue(User.first, :destroy)
# Supports basic positional arguments valid in JSON
class MongoidMethodWorker
include Sidekiq::Worker
sidekiq_options queue: 'low'
def perform(class_name, id, method, ...)
klass = class_name.constantize
record = klass.find(id)
record.public_send(method, ...)
end
def self.enqueue(record, method, ...)
fail ArgumentError.new("record must be persisted") if !record.persisted?
fail NoMethodError.new("undefined method `#{method}' for #{record.inspect}") if !record.respond_to?(method)
perform_async(record.class.name, record.id.to_s, method.to_s, ...)
end
end
# mongoid_method_worker_spec.rb require 'rails_helper' describe MongoidMethodWorker do let(:check) { create :check } describe '#perform' do it "finds the record and calls the method" do expect_any_instance_of(Check).to receive(:http_method).with(no_args).and_call_original described_class.new.perform(check.class.name, check.id.to_s, 'http_method') end it "finds the record and calls the method with args" do # just an example, this type won't work in json from = 1.month.ago to = Time.now expect_any_instance_of(Check).to receive(:downtime).with(from, to).and_call_original described_class.new.perform(check.class.name, check.id.to_s, 'downtime', from, to) end it "raise if the class can't be found" do expect { described_class.new.perform("Something", nil, nil) }.to raise_error(NameError, "uninitialized constant Something") end it "raise if the record can't be found" do expect { described_class.new.perform("Check", '1234', nil) }.to raise_error(Mongoid::Errors::DocumentNotFound) end it "raise if the method can't be found" do expect { described_class.new.perform("Check", check.id.to_s, "yolo") }.to raise_error(NoMethodError, /undefined method `yolo' for/) end it "raise if the signature doesn't match" do expect { described_class.new.perform("Check", check.id.to_s, "hostname", 42) }.to raise_error(ArgumentError, "wrong number of arguments (given 1, expected 0)") end end describe "#enqueue" do it "simplify syntax" do expect_any_instance_of(Check).to receive(:http_method).with(no_args).and_call_original described_class.enqueue(check, :http_method) described_class.drain end it "protects against unsaved records" do expect { described_class.enqueue(build(:check), :http_method) }.to raise_error(ArgumentError, "record must be persisted") end it "protects against wrong methods" do expect { described_class.enqueue(check, :http_met) }.to raise_error(NoMethodError, /undefined method `http_met' for/) described_class.drain end end end
#enqueue
method is a helper to make the call easier to read and perform some validations to avoid programming errors early.Sidekiq
here for background job because that's what all my other worker uses, but it should be easy to switch it to ActiveJob
or anything else.Then in the controller where the account deletion happens, I was able to replace the destroy call:
current_user.destroy
# replaced with:
MongoidMethodWorker.enqueue(current_user, :destroy)
ProTip: when deleting records asynchronously, you want to avoid being able to interact with the records while it's being deleted. In this example when a User
account is currently being deleted, you don't want the user from being able to sign-in again before the deletion is finished, that would lead to poor UX (broken UI, 500 errors after a couple seconds, no way to disconnect except by deleting cookies, etc..). So it's good practice to soft delete or lock the record in some way before starting the worker, to prevent that from hapenning and make the delete appear instant.
I implemented this by locking the account (as I'm already using Devise
and its :lockable
module). This ensures that while the deletion is in progress (or maybe in queue and not even started yet), the user cannot sign-in again and use his account:
current_user.set(locked_at: Time.now)
MongoidMethodWorker.enqueue(current_user, :destroy)