Implementing Undo with a Delayed Sidekiq Worker

May 1, 2014    Ruby Rails

Allowing your user to undo destructive actions is a great user experience improvement that's unfortunately missing from many web applications. In this article I'll discuss implementing basic undo functionality with Rails & the Sidekiq gem.

The Problem and Setup Code

Imagine that you have an application that involves users inviting other users to work on projects. Our goal is to give the project owner 15 minutes to undo before actually sending out the invitation.

Your model associations may look something like this:

# user.rb
has_many :invitations

# invitation.rb
belongs_to :user
belongs_to :project

# project.rb
has_many :invitations

And the invitations controller would look something like this:

invitations_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
class InvitationsController < ApplicationController
  def create
    @project = Project.find(params[:project_id])

    # We want to wait 15 minutes before "finalizing" the invitation
    # creation & sending out the email.

    @invitation = @project.invitations.create!(user_id: params[:user_id])
    UserMailer.invitation_received(@invitation).deliver

    redirect_to @project
  end
end

This controller action simply creates an invitation record and sends an email to the invitation recipient.

Setting up Sidekiq

In my last post, Writing Your First Background Worker, I covered the basics of setting up and getting started with Sidekiq. If you’re unfamiliar with the process, you should check that out first.

The Basic Approach

  • When a user is invited to a project, the invitation record is created, but is not “delivered”.
  • When the invitation is created, a background worker is queued for 15 minutes from that time. This worker will mark the invitation as “delivered” after 15 minutes.
  • During that 15 minute timespan, the user that created the invitation can undo it. The undo button will simply destroy the invitation record.
  • Recipient users have many invitations, but the invitations that are not delivered are not shown to them yet.
  • Invitations that are delivered can no longer be deleted, (or undone).

Okay, that’s an overview of the approach we’ll be taking. Now let’s go step by step and see to accomplish it.

Differentiating between delivered and undelivered invitations

One way that we can meaningfully distinguish between delivered and undelivered invitations is to introduce a delivered boolean column in the invitations table. We'll make sure it's not null and defaults to false.

class AddDeliveredToInvitations < ActiveRecord::Migration
  def change
    add_column :invitations, :delivered, :boolean, null: false, default: false
  end
end

Now, in our views and other application logic, we can be sure to check @invitation.delivered? (ActiveRecord automatically creates both the delivered and delivered? methods for us!). After 15 minutes, our worker will set this field to true and we can show the users their invitations.

Also, we'd probably want to introduce an delivered scope on the Invitation model:

invitation.rb
1
2
3
4
5
class Invitation < ActiveRecord::Base
  # ...
  scope :delivered, -> { where(delivered: true) }
  # ...
end

This way, we can show a user only invitations that have been “delivered”, like so: User.find(params[:id]).invitations.delivered.

Undo Button

In the view, we can simply add a link to invitations#destroy if the invitation has NOT yet been delievered:

<% unless @invitation.delivered? %>
  <%= button_to "Undo", @invitation, method: :delete %>
<% end %>

Revising the controller

invitations_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class InvitationsController < ApplicationController
  def create
    @project = Project.find(params[:project_id])

    @invitation = @project.invitations.create!(user_id: params[:user_id])
    InvitationDeliveryWorker.perform_in(15.minutes, @invitation.id)

    # This will move to our worker.
    # UserMailer.invitation_received(@invitation).deliver

    redirect_to @project
  end

  def destroy   # undo
    @invitation = Invitation.find(params[:invitation_id])
    @project = @invitation.project

    if @invitation.delivered?
      redirect_to @project, alert: "You can't undo this invitation, it has already been delivered."
    else
      @invitation.destroy
      redirect_to @project, notice: "Invitation undo successful"
    end
  end
end

Important note! Be sure to only pass primitives or simple objects as arguements to the worker, e.g. .perform_in(15.minutes, @invitation.id). These arguements must be serialized and placed into the Redis queue, and attempting to serialize an entire ActiveRecord object is inefficient and not likely to work.

Writing Our Invitation Delivery Worker

Now all that's left is to write a worker class to process the invitation after the 15 minute undo grace period.

app/workers/invitation_delivery_worker.rb
1
2
3
4
5
6
7
8
9
10
class InvitationDeliveryWorker
  include Sidekiq::Worker

  def perform(invitation_id)
    @invitation = Invitation.find(invitation_id)
    @invitation.update!(delivered: true)
    UserMailer.invitation_received(@invitation).deliver
  rescue ActiveRecord::RecordNotFound
  end
end

Basically, we'll fetch the record from the ID, update the delivery status, and send out the email. Note that we'll rescue from an ActiveRecored::RecordNotFound exception incase that the invitation is actually undone (deleted).

Integration Testing

At some point, you might want to cover the undo functionality in your tests. Here’s an example of what that might look like.

In your spec helper (I’m assuming RSpec here, but you could easily adapt this to another testing framework), most of the time you’ll want to have a setup like this:

spec_helper.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
# ..

Sidekiq::Testing.inline!

RSpec.configure do |config|
  # ..

  config.before(:each) do
    Sidekiq::Worker.clear_all
  end
end

# ..

The Sidekiq::Testing.inline! call will tell Sidekiq to perform all jobs inline, immediately after they are requested. Thus, in our tests, even delayed jobs that we call are performed immediately. For most tests, this is what we want, because only a few of the tests need to actually look at the delayed behavior.

Sometimes though, we do want our jobs to be delayed so we can test the behavior. That’s where Sidekiq::Testing.fake! comes in. This will tell Sidekiq to create a fake job queue for testing purposes (it’s just an array).

When using Sidekiq::Testing.fake!, sometimes we may have unwanted jobs that still around in the queue in-between tests. To remedy this, we can just run Sidekiq::Worker.clear_all before each test (as seen above).

spec/features/invitation_spec.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
describe invitations do
  before { Sidekiq::Testing.fake! }

  it allows the user 15 minutes to undo the invitation do
    # Because we’ve specified “fake!” above, our `InvitationDeliveryWorker` 
    # will not run unless we explicitly tell it to.  So, in this test we can
    # verify that the undo button is working as expected, and that the
    # invited user does NOT see the invitation yet (it’s not delivered!).

    # create the invitation
    # expect the undo button to be visible
    # expect the email to NOT be sent
    # expect the invitation recipient to NOT be able to see it
    # expect that the invitation can be undone.
  end

  it hides the undo button after 15 minutes, and shows the invitation to the user do
    # create the invitation

    Sidekiq::Worker.drain_all    # This will perform all pending worker tasks.

    # expect the undo button to be hidden
    # expect that the email was sent
    # expect that the recipient can see the invitation.
  end
end

Obviously, I didn’t include many details in the test example above. I just wanted to highlight the important methods.

I do recall having a little difficulty finding documentation related to testing Sidekiq jobs, but the source code for the testing helpers is quite easy to understand. I encourage you to check it out.

Recommended viewing & reading:

Thanks for reading!

For this post, I've manipulated actual project code into context-independent code. As such, I could have easily made some mistakes! Sorry about that, and if you catch anything wrong please let me know.

If you have any questions or comments tweet me @bolandrm! Please let me know if there are any topics you’d like me to write about!