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)