-
Notifications
You must be signed in to change notification settings - Fork 22.1k
Automatically delay Active Job enqueues to after commit #51426
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
Conversation
rafaelfranca
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if this should not require the user to always explicitly create the transaction.
I find the complexity to find the right transaction too much and all usage that I know of can easily build the transaction.
Something like:
Topic.transaction do |transaction|
transaction.after_commit { called += 1 }
end|
@rafaelfranca I think job queueing is the base example of a use case where that doesn't work: we want to defer until any currently-in-flight transactions have committed, not set up a local transaction to wrap our work. If the transaction was expected to be as local as in your example, the caller could just move the |
|
Yeah, Jean explained to me |
2fc2e5e to
9e61d74
Compare
9e61d74 to
e3676b1
Compare
e3676b1 to
ba8c36b
Compare
|
Alright, I implemented the Active Job integration, in the end I think it's not too invasive, but I can rework it if it's seen as too tightly coupled. I think with a small bit of polish this could move to a proper PR instead of being a PoC. @rafaelfranca @matthewd I'd appreciate some second-opinion on the naming and implementation, etc. |
db0589d to
3f4ae43
Compare
activerecord/lib/active_record/connection_adapters/abstract/transaction.rb
Show resolved
Hide resolved
| elsif open_transactions.size == 1 | ||
| open_transactions.first.after_commit(&block) | ||
| else | ||
| callback = count_down_callback(open_transactions.size, &block) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly tying in with my comment above 😅 -- I'm not sure what I think should happen, but I think this will behave surprisingly if any of the transactions get rolled back (and thus count never reaches zero).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's how I see it though. If any transaction is rolled back, we don't enqueue the job at all. I think that's what we want most of the time, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO no: while we can make a best effort at having the job run at a convenient time by introducing delay (waiting for concurrent transactions to get out the way), if perform_later didn't raise, we've claimed that we have successfully enqueued the job, so we have to follow through.
(Also having a potentially completely-unrelated DB transaction slow down the enqueuing is a defensible curiosity, having it's rollback result in your job being silently dropped seems more complicated.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand that we'll probably need an escape hatch, but e.g.:
transaction do
user = User.create!
WelcomeEmailJob.perform_later(user)
# something that fails and rollback the transaction
endIf the transaction is rolled back, you don't want this job enqueued.
Now maybe perform_later should be left unchanged, and it's another API that should have this behavior.
But the goal here is to reduce the behavioral difference between queues that use the same DB as Active Record, hence are transaction aware, and those that aren't.
In the above example, if you are using SolidQueue / GoodJob etc, the perform_later is acked but will be dropped. So I think we're consistent here.
bensheldon
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As GoodJob maintainer, I think this is a good change. It's easy enough for me to add configuration to the adapter, and easy enough for an application developer to override that on ActiveJob::Base 👍🏻
d65638f to
7983982
Compare
|
@casperisfine to help with context:
|
|
Yeah, I'm also thinking returning the job makes sense. I'm just a bit unclear on a few things, so I need to investigate all the consequences. And it's definitely a bit of a breaking change I didn't foresee. |
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
|
I love this change, it's something we've discussed a few times at 37signals, to introduce in Active Job 🤩 Thanks a lot!
FWIW, I think the good argument for DB-backed active job backends is slightly more subtle than simply not caring about when you're enqueuing the job. The really useful case for enqueuing a job within a transaction and having it run after the transaction commits, taking advantage of jobs living in the same DB as your app records, is that you get transactional integrity, in the sense that the transaction is rolled back if enqueuing the job fails.
Totally, we have a note/warning about this in the Solid Queue README, but there are some valid cases where you might want to have this behaviour instead of some complicated, ad-hoc manual "rollback" of a committed transaction if enqueuing your job |
…commit) (#40) This anticipates the release of a new feature in Rails 7.2 introduced here: rails/rails#51426 In Rails 7.2, ActiveJob will default `enqueue_after_transaction_commit` to `true`, and we would like to explicitly set this to `false` (retaining transactional enqueues, since `delayed` is DB-backed). This PR goes a step further and also raises a `UnsafeEnqueueError` if, during enqueue, it encounters any job type that has overridden `enqueue_after_transaction_commit` to `true`. Worth noting that this exception may still be raised _after transaction commit_ (based on the way the lifecycle hooks are handled), so it is intended first and foremost as a guard during development & testing. As part of this change I also: - Added a new Appraisal for the latest Rails main build on GitHub. - Re-generated the rubocop todo file. There are changes we can pull out & autocorrect, but we can do them in separate PRs.
Ref: rails#26103 Ref: rails#51426 A fairly common mistake with Rails is to enqueue a job from inside a transaction, and a record as argumemnt, which then lead to a RecordNotFound error when picked up by the queue. This is even one of the arguments advanced for job runners backed by the database such as `solid_queue`, `delayed_job` or `good_job`. But relying on this is undesirable iin my opinion as it makes the Active Job abstraction leaky, and if in the future you need to migrate to another backend or even just move the queue to a separate database, you may experience a lot of race conditions of the sort. But more generally, being able to defer work to after the current transaction has been a missing feature of Active Record. Right now the only way to do it is from a model callback, and this forces moving things in Active Record models that sometimes are better done elsewhere. Even as a self-proclaimed "service object skeptic", I often wanted this capability over the last decade, and I'm sure it got asked or desired by many more people. Also there's some 3rd party gems adding this capability using monkey patches. It's not a reason to upstream the capability, but it's a proof that there is demand for it. Implementation wise, this proof of concept shows that it's not really hard to implement, even with nested multi-db transactions support. Co-Authored-By: Cristian Bica <cristian.bica@gmail.com>
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
|
This is completely wrong, for example if I'm publishing a news story and part of that process is to enqueue the job that will deliver that story, the publishing transaction should fail if the job is not enqueued. also, RecordNotFound will only happen for perform_now, job queue will only notice the job after transaction is committed (if talking about solid_queue, delayed_job or good_job), for redis backed ones might happen if the commit is slow and queue empty but for business reasons is better to retry a job than have one not enqueued when it needed to be. |
You can still do that. This is a config that you disable if it doesn't work for you. You can even disable per-job
You are right. This change is mostly to match the behavior of non-relational queues with relational queues. But it is more common than you think. Just Google about jobs and Example: sidekiq/sidekiq#322 Now, let's talk about your feedback. You could have said "This doesn't work for me" or "That is not my experience" would be fine. But Saying "completely wrong" when a simple google search shows that it is you that got it wrong is just disrespectful. I got what you mean, and I'm sure you didn't tried to be disrespectful, but please try to avoid that. |
|
Ah, you also have a point about the chance of the job not being enqueued, let's say if the redis server is down when enqueuing the job. This does happen, and it is worse than retying a job when Now you could argue which is the better default, and we do believe enqueuing the job in |
|
Thanks for the feedback. Sorry if my last message came across the wrong way. I'll try to be more professional in the future. |
Ref: rails#26103 Ref: rails#51426 A fairly common mistake with Rails is to enqueue a job from inside a transaction, and a record as argumemnt, which then lead to a RecordNotFound error when picked up by the queue. This is even one of the arguments advanced for job runners backed by the database such as `solid_queue`, `delayed_job` or `good_job`. But relying on this is undesirable iin my opinion as it makes the Active Job abstraction leaky, and if in the future you need to migrate to another backend or even just move the queue to a separate database, you may experience a lot of race conditions of the sort. But more generally, being able to defer work to after the current transaction has been a missing feature of Active Record. Right now the only way to do it is from a model callback, and this forces moving things in Active Record models that sometimes are better done elsewhere. Even as a self-proclaimed "service object skeptic", I often wanted this capability over the last decade, and I'm sure it got asked or desired by many more people. Also there's some 3rd party gems adding this capability using monkey patches. It's not a reason to upstream the capability, but it's a proof that there is demand for it. Implementation wise, this proof of concept shows that it's not really hard to implement, even with nested multi-db transactions support. Co-Authored-By: Cristian Bica <cristian.bica@gmail.com>
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
|
So, it's still unclear to me if this feature replaces https://github.com/Envek/after_commit_everywhere ? |
Ref: #26103
Ref: #51474
A fairly common mistake with Rails is to enqueue a job from inside a transaction, with a record as argumemnt, which then lead to a
RecordNotFounderror when picked up by the queue.This is even one of the arguments advanced for job runners backed by the database such as
solid_queue,delayed_joborgood_job.But relying on this is undesirable in my opinion as it makes the Active Job abstraction leaky, and if in the future you need to migrate to another backend or even just move the queue to a separate database, you may experience a lot of race conditions of the sort.
To resolve this problem globally, we can make Active Job optionally transaction aware, and automatically defer job queueing to
after_commit.