New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Helper middleware to preserve implicit context? #4982
Conversation
module Myapp
class Current < ActiveSupport::CurrentAttributes
attribute :user_id, :org_id, :tz, :locale, :user
end
end
Myapp::Current.tz = 'UTC'
Myapp::Current.org_id = "123456"
Myapp::Current.user_id = 123
Myapp::Current.locale = 'pt-BR'
# will not persist because it is not a native JSON type
# Myapp::Current.user = User.new(id: 123) Attributes will only persist correctly if they are native JSON types. |
👋 I saw your tweet around this last night and was going to post how our middleware looks that solves mirroring what Current was in our application when the job was enqueued. In case it is of any interest module Middleware
class SidekiqCurrent
CURRENT_USER_KEY = 'current_user_id'.freeze
REQUEST_ID_KEY = 'request_id'.freeze
class Client
def call(_worker_class, job, _queue, _redis_pool)
job[CURRENT_USER_KEY] = Current.user.id if Current.user&.signed_in?
job[REQUEST_ID_KEY] = Current.request_id if Current.request_id
yield
end
end
class Server
def call(_worker_class, job, _queue)
Current.user = User.find(job[CURRENT_USER_KEY]) if job.key?(CURRENT_USER_KEY)
Current.request_id = job[REQUEST_ID_KEY] if job.key?(REQUEST_ID_KEY)
yield
Current.user = nil
Current.request_id = nil
end
end
end
end |
This matches the way Sidekiq handled arguments for jobs. I'm a bit afraid this might cause unwanted side effects in regard to the In our case we have a That being said, I'm not sure if the middleware should solve the serialization. In that case support for When the middleware raises an error when something is not serializable, I would probably rewrite the class Current < ActiveSupport::CurrentAttributes
attribute :user, :user_id
resets { Time.zone = nil }
def user=(user)
super
self.user_id = user.id if self.user_id != user.id
Time.zone = user.time_zone
end
def user_id=(id)
super
self.user = User.find(id)
end
end This allows us to use
|
After working on a
On our When we explicitly need to define which attributes should be transferred to a job, I can also see an approach were the class Current < ActiveSupport::CurrentAttributes
attribute :user
resets do
I18n.locale = I18n.default_locale
Time.zone = nil
end
def user=(user)
super
I18n.locale = user.language
Time.zone = user.time_zone
end
def serialize
{ user_id: user.id }
end
def deserialize(attributes)
self.user = User.find(attributes[:id])
end
end |
One rule I try to always follow is "do the simplest thing possible" as that's also usually the highest performance and easiest to understand. I would prefer to stay away from I believe that the Ruby JSON API does have hooks for serialization/deserialization if you want custom objects to transparently work but I've never tried it myself. Maybe you can also use a |
I agree that we should find the simplest solution possible. I'm still not convinced you should just ignore attributes that couldn't be serialized. Because all attributes in a context form the whole context, one missing attribute makes it another context and therefore can cause unexpected behavior. Without a warning or error, I can even be a security risk. At least the documentation should clearly state this behavior. I think the JSON additions API is also worth mentioning in the documentation for this feature. Furthermore, I'm thinking about the concept of a class Current < ActiveSupport::CurrentAttributes
attribute :user, :user_agent, :request_id
resets do
JobContext.user_id = nil
end
def user=(user)
super
JobContext.user_id = user.id
end
end
class JobContext < ActiveSupport::CurrentAttributes
attribute :user_id
resets do
Current.user = nil
end
def user_id=(id)
super
Current.user = User.find(id)
end
end
chain.add Sidekiq::Context::Save, with: Myapp::JobContext So all in all, I think with the right documentation and warnings about caveats, this simple implementation is a good one. Thanks Mike! 👍 |
My comment above was incorrect, the code does persist all attributes but they will only persist correctly if they convert to/from JSON types. Glad you tentatively approve, send an issue or PR if you have further ideas/improvements. |
Perfect, that eliminates my fear of incorrect contexts. 🚀 |
In case anyone cares, here's our paper_trail/request_store based version, more or less config/initializers/sidekiq.rb require "sidekiq/middleware/current_attributes"
Rails.application.configure do
config.after_initialize do
# Avoid 'Initialization autoloaded the constant CurrentRequest'
Sidekiq::CurrentAttributes.persist(CurrentRequest)
end
end app/services/current_request.rb module CurrentRequest
extend self
delegate :[], :[]=, :exist?, :fetch, :delete, :clear!, to: RequestStore
def user_id
::PaperTrail.request.whodunnit
end
def user_id=(user_id)
::PaperTrail.request.whodunnit = user_id
end
def controller_info
PaperTrail.request.controller_info
end
def controller_info=(controller_info)
PaperTrail.request.controller_info = controller_info
end
def request_id=(request_id)
self[:request_id] = request_id
end
def request_id
self[:request_id]
end
# ActiveSupport::CurrentAttributes.attributes compatibility
# For use with https://github.com/mperham/sidekiq/pull/4982
def current_attributes_keys
@current_attributes_keys ||= %i[
request_id
controller_info
user_id
]
end
# ActiveSupport::CurrentAttributes.attributes compatibility
# https://github.com/rails/rails/blob/6-0-stable/activesupport/lib/active_support/current_attributes.rb
def attributes
current_attributes_keys.each_with_object({}) { |attribute_name, attributes|
attributes[attribute_name] = public_send(attribute_name)
}
end
# ActiveSupport::CurrentAttributes.set compatibility
# https://github.com/rails/rails/blob/6-0-stable/activesupport/lib/active_support/current_attributes.rb
def set(set_attributes)
old_attributes = compute_attributes(set_attributes.keys)
assign_attributes(set_attributes)
yield
ensure
assign_attributes(old_attributes)
end
private
def assign_attributes(new_attributes)
new_attributes.each do |attribute_name, attribute_value|
public_send("#{attribute_name}=", attribute_value)
end
end
def compute_attributes(keys)
keys.collect { |key| [key, public_send(key)] }.to_h
end
end xref what I had been looking at prior to this PR since 'why not'
|
Implicit context via thread-local variables can be a powerful tool for things like multi-tenancy, logging, i18n, etc. Rails has ActiveSupport::CurrentAttributes, there's also gems like RequestStore. It would be excellent if Sidekiq included helper middleware that knows how to de-hydrate and re-hydrate variables between client and server so jobs can preserve this implicit context for their execution.
For example, one possible API: