-
-
Notifications
You must be signed in to change notification settings - Fork 208
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
Custom advisory locks to prevent certain jobs from being worked on concurrently? #206
Comments
@reczy thanks for proposing this idea! I think it's possible to extend GoodJob / Lockable, but with some tweaks. I think it would be fairly simple to have Lockable have a configurable SQL lock string that gets MD5ed. It would need to be done entirely in SQL though, meaning it would probably end up looking like The trickiest part would be figuring out the I think that's possible. The caveat is that the GoodJob::Job / Lockable locking code has no awareness of ActiveJob or different job classes. The lock string would need to be global rather than on a per-job basis, which probably makes it even more complicated SQL. If you wanted to try extending GoodJob::Lockable, I'd definitely consider the change. But it's low priority otherwise. I'd initially suggest what I think you're already doing: try locking the secondary record and then retry the job if it's already locked elsewhere. |
Thanks for the consideration and additional suggestions! I think starting with the advisory_lock scope you linked to is helpful context and helps narrow the focus a bit here. For my use case, using a global lock string would introduce too much of a potential bottleneck. However, I still think it's possible to set the lock string on a per-job basis if we do it at job creation. What about something like:
Then in GoodJob::Job
Then, of course, we could use your suggestion of I think this could work, but that still leaves the decision of whether store the lock in its own column or in the serialized_params jsonb column. Do you have a preference? I think the former would be simpler at the expense of a little additional setup by the user (though only for the subset of users that wants to control the locks). Going with the latter would probably entail slightly more complex sql and means we're adding metadata to the serialized_params column that's beyond the serialized active_job. I recognize this is not a priority for you but sincerely appreciate your thoughts to help mitigate any dead-ends and stay in line with your future vision of good_job! Thanks! |
This code looks like what I was thinking, though with a twist: I would like GoodJob to be able to be extended to achieve this functionality, but I do not want to build a separate lock column as core functionality because GoodJob (the core of it) is focused on ActiveJob functionality. I'm imagining that, if the GoodJob was extendable to allow this functionality, the implementation in your app would look something like this: # config/initializers/good_job
# It looks like there isn't a good notification that intercepts the not-yet-saved GoodJob::Job record,
# but that's easily added into the Job.enqueue method
ActiveSupport::Notifications.subscribe "before_enqueue_job.good_job" do |event|
good_job, active_job = event.payload.values_at(:good_job, :active_job)
good_job.lock_key = active_job.lock_key if active_job.respond_to?(:lock_key)
# You would have made your own migration to extend the `good_jobs` table with the lock_key column.
end
GoodJob::Job.lockable_function_sql = "COALESCE(lock_key, id)" # <= I think this is the hard part to make work. |
Oh, I see what you're saying. I'll have to play around with this a bit to see what's possible here. Thanks, Ben! |
Hey @reczy I think you might be interested in this PR rails/rails#40337 that might end up in the Rails itself. |
I have reconsidered my objections, described in #255, and now believe that this would be a good capability to integrate into GoodJob regardless of whether ActiveJob defines a core interface for it. I've added this Issue to the prioritized backlog. Let's do this 🚀 |
@bensheldon Amazing news! From #255
Sounds good! |
@bensheldon I'm going to be a bit short on time and unable to meaningfully contribute over the next couple weeks, but I wanted to at least start out here by throwing out some code that seems to be working for me (There very well may be some errors here and it's 100% not production ready, but hopefully it can help prevent some duplicative efforts - sorry it's not a real PR. This is more thinking out loud with examples. I initially started thinking about including 1 bigint or 2 integer columns (lock_classid, lock_objid) to track the advisory locks, but the signed 64 bit / signed 32 bit to unsigned 32 bit oid conversions are not the most straightforward. So, I ended up with a lock_key text column:
I think developers would want to avoid having running jobs during this migration.
The only changes here are (1) the calculation of lock in Other changes to Lockable would mostly revolve around the change in calculation of the lock - for example:
Notably missing here is the public API for users to control the lock_key. Not sure if you wanted it to look like the ActiveJob concurrency PR in rails or something else. For my own use case, the important thing is that locks can be controlled per job, per job class, and across multiple job classes (which I suppose just boils down to controllability on a per-job basis). |
@reczy thank you for thinking through this proposal. One thing that sticks out (though maybe not necessary for the implementation) is that database migrations need to be supportable/representable in It's maybe a bit of a diversion from where this Issue started, but I'd like to refocus on supporting the interface proposed in rails/rails#40337 that allows for a configurable level of concurrency, rather than just |
Not sure exactly how recent this is, but the postgres checks / constraints are supported through
Sounds good to me @bensheldon. Just so I understand, are you suggesting that there would be 2 columns then, say
Is that kinda what you were proposing?
I think we are on close to the same page here. For the advisory lock( With respect to the |
It looks like I am thinking of not trying to achieve concurrency via modifying the fetch-and-lock code and instead just query for how many instances are active/locked separately. e.g. The benefit is that this approach is drastically simpler, covers all the concurrency values, and is likely robust enough (I really don't know about how problematic it is to join against |
I did some noodling on this and I think this is a bare-bones implementation: # Example usage:
class MyJob < ApplicationJob
include GoodJob::ActiveJobExtensions::Concurrency
good_job_enqueue_exclusively limit: 2
good_job_perform_exclusively limit: 1
def good_job_concurrency_key
[self.class.name, @some_arg].join("")
end
end
# Example implementation:
# TODO: GoodJob::Adapter#enqueue will have to call job.good_job_concurrency_key
# and store the value in the database record
module GoodJob::ActiveJobExtensions::Concurrency
extend ActiveSupport::Concern
ConcurrencyExceededError = Class.new(StandardError)
included do
retry_on GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError, attempts: Float::INFINITY, wait: :exponentially_longer
end
class_methods do
def good_job_enqueue_exclusively(limit: 1)
before_enqueue do |job|
enqueue_concurrency = GoodJob::Job.where(concurrency_key: job.good_job_concurrency_key).unfinished.count
if enqueue_concurrency + 1 > limit
# does this work?
throw :abort
end
end
end
def good_job_perform_exclusively(limit: 1)
before_perform do |job|
perform_concurrency = GoodJob::Job.where(concurrency_key: job.good_job_concurrency_key).advisory_locked.count
# the current job has already been locked and will appear in the previous query
if perform_concurrency > limit
raise GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError
end
end
end
end
end |
@reczy I have an implementation of this in #281. I'd love feedback on the interface. It's currently two class-level methods, and an instance method: class MyJob < ApplicationJob
include GoodJob::ActiveJobExtensions::Concurrency
good_job_enqueue_exclusively_with(limit: 1)
good_job_perform_exclusively_with(limit: 1)
def perform(name)
# do work
end
def good_job_concurrency_key
"Unique-#{arguments.first}" # MyJob.perform_later("Alice") => "Unique-Alice"
end
end |
@bensheldon Yeah, interface on this looks great! Having access to the job arguments was key in my own use-case, and this accomplishes that nicely. Offhand, I can't think of a scenario that I do have a question, though - what happens in the following scenario?
Note that the only differences above are the enqueue/perform limits (concurrency key is the same). This might occur when you are trying to lock jobs with a certain concurrency key across job classes (I happen to face this issue and need to keep certain job classes from being worked on at the same time for a given tenant). Maybe this is a non-issue and you need only be concerned about the limits set for the job (or rather, its class) you're trying to add? Thoughts? |
@reczy thanks for all the feedback!
Great example! I think two suggestions are:
That could imply a different interface. Maybe something like: class MyJob < ApplicationJob
self.good_job_enqueue_exclusively_with limit: 2, key: -> { "Unique-#{arguments.first}" } # job.instance_eval it
self.good_job_perform_exclusively_with limit: 2, key: -> { "Unique-#{arguments.first}" }
#... I guess it wouldn't work if the keys were different because a job can only have one key
# maybe set them both at the same time?
self.good_job_exclusively_with enqueue_limit: 2, perform_limit: 2, key: -> { "Unique-#{arguments.first}" }
# that would make it more easily inheritable, and you could also do something like this in multiple classes...
MY_JOBS_EXCLUSIVELY = { perform_limit: 3, enqueue_limit: 3, key: -> { "Unique-#{arguments.first}" } }
self.good_job_exclusively_with **MY_JOBS_EXCLUSIVELY
end But I dunno 🤷♂️ What do you think of that? |
That makes sense. I don't think either approach is better or worse in any meaningful way (for the developer experience, specifically) so I don't know that I personally have a strong opinion or recommendation on this particular aspect of design (though if you do go with an interface in your last response, I think the one with enqueue_limit and perform_limit together is cleaner). If you went with the original design, I could see myself using dedicated lock concerns for multi-class locks to keep one source of truth. Maybe just go with whatever one is cleanest to implement in GoodJob internally? |
Thanks for that feedback! I think I'm going to go with the simplest option, and also the most clearly named option: self.good_job_control_concurrency_with enqueue_limit: 2, perform_limit: 2, key: -> { "Unique-#{arguments.first}" } |
Concurrency controls have been released in GoodJob |
Hi Ben,
First of all, thanks so much for your hard work on this gem. I've been following the project since it showed up on HN a few months ago and recently started porting over my background jobs from Resque.
I'm submitting this issue to see if you'd be open to custom advisory locks? In my case, the purpose would be to prevent certain jobs (across multiple job classes) that work on data for a particular tenant from being worked on concurrently. In other words - perform only one job at a time for a particular account/organization, based on the job arguments.
As a workaround for now, I am grabbing a second advisory lock in around_perform that's based on a particular job argument, but this approach is obviously inefficient and fails to take advantage of your nice code already in Lockable.
Currently, you use part of the md5 hash of (1) the good_job table name and (2) the job primary key to determine the advisory lock for a particular job. Ideally, I'd have the opportunity to specify the text your Lockable module feeds to md5. In my opinion, doing it this way (rather than allowing the user to just specify the final 64-bit integer or 2 32-bit integers) seems like a decent trade-off between control and complexity. I suppose that just being able to specify (2) would be fine as well since the table_name doesn't change.
I've tried to think through the easiest way of accomplishing this. One option is to allow a job method that would return a string to be fed to md5 (if not present, then fallback to the current way of doing things).
PSUEDO CODE:
Then in GoodJob::Lockable
and elsewhere in Lockable
md5(#{hashable_lock_text})
So anyway, I'd love to hear your thoughts on whether this is something you'd consider for this gem. I have no attachment to the specific design suggestion above - just trying to spark ideas. It would be great to be able to rely on the code already in Lockable to limit job concurrency (and avoid duplicating db locking).
Thanks!
The text was updated successfully, but these errors were encountered: