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:
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:
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
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.
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:
# ..
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).
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!