Sidekiq and Request-Specific Context
2022-07-29
At some point in growing a large-scale software system, you’ll require “out of band” context: data which is not explicitly passed as an argument to a function but rather implicitly attached to the request, job or event being processed.
Usually context is implemented as thread-local variables; your code first sets up the necessary context and then processes the request.
A common example is multi-tenancy: you might want to limit any data queried by a given request to a database schema specific to a given tenant.
When a request first comes into your system, your code would determine the associated tenant_id
and set that context:
class Context
def self.tenant_id
Thread.current[:tenant_id]
end
def self.tenant_id=(tid)
Thread.current[:tenant_id] = tid
end
end
class ApplicationController < ActionController::Base
before_action :set_tenant
def set_tenant
Context.tenant_id = user.organization_id
end
end
Usually you’ll use a gem like apartment
to help implement multi-tenancy. The code above is not production-ready; keep reading.
The Rails Solution
Of course Rails already offers an API to implement request-specific context: here’s the CurrentAttributes
API and a blog post showing how to use it. Let’s define our attributes object:
class Myapp::Current < ActiveSupport::CurrentAttributes
attribute :tenant_id
end
Now we have a thing which holds our request-specific context. We’ll set our tenant before processing any request:
class ApplicationController < ActionController::Base
before_action :set_tenant
def set_tenant
Myapp::Current.tenant_id = 123
end
end
But wait, we have a problem: your app will often spin off background jobs for a request and those jobs will need that context too!
Sidekiq to the Resque
That’s why Sidekiq 6.3 added support for ActiveSupport::CurrentAttributes! You just need to put this in your initializer:
require "sidekiq/middleware/current_attributes"
Sidekiq::CurrentAttributes.persist(Myapp::Current) # Your AS::CurrentAttributes singleton
Now when you set tenant_id
in your request and then create a background job, SomeJob.perform_async("mike")
, you’ll get a job payload in Redis that looks like this:
irb(main):001:0> Myapp::Current.tenant_id = 123
irb(main):002:0> SomeJob.perform_async("mike")
irb(main):003:0> Sidekiq::Queue.new.first
{"retry"=>true,
"queue"=>"default",
"backtrace"=>5,
"args"=>["mike"],
"class"=>"SomeJob",
"jid"=>"cd041555bda96d781cbc8539",
"created_at"=>1659150392.662438,
"cattr"=>{"tenant_id"=>123},
"enqueued_at"=>1659150392.663126}
Note that cattr
hash is the data within Myapp::Current
serialized at the time when the job was created.
Sidekiq will automatically restore that data every time the job executes.
Here’s the code for you to read if you want to see how it’s done.
Request-specific context is a bit magical and functional purists will argue they act like global variables.
That is true but I take a more pragmatic stance: they solve a real world problem.
Go chose the opposite approach with their context
package and requires all Go code to pass a context.Context
argument to every function.
I’ve worked with both approaches and I still prefer Ruby’s thread-local variables which are far easier to integrate into an existing system as it grows.
The ability to add a significant new feature (e.g. multi-tenancy) without refactoring your entire app is a massive win.