Blog Using `destroy_async` with Mongoid 8?

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
If you're interested in copying this code, you can click here to show my spec file too. It's using rspec and specific to my model so will need to be adapted, but it can still save you some time.
# 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
  1. The #enqueue method is a helper to make the call easier to read and perform some validations to avoid programming errors early.
  2. There is no specific error handling in this worker because the goal is to let it raise so I can see any errors (and let it retry natually).
  3. I'm using 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)

Adrien Rey-Jarthon
Created on November 15, 2023 · Last update on November 16, 2023