Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1d33337
Batch job POC
jpcamara Feb 2, 2024
5fe18ed
Use ActiveSupport::IsolatedExecutionState to honor user isolation lev…
jpcamara Feb 5, 2024
66f0a77
Ability to retrieve batch from a job
jpcamara Feb 5, 2024
16e2122
Allow batch jobs to be instances
jpcamara Feb 8, 2024
40d36d3
Use text so the jobs store properly on mysql
jpcamara Mar 23, 2024
612092c
Handle on_failure and on_success
jpcamara Sep 24, 2024
def5d78
Allow enqueueing into a batch instance
jpcamara Sep 24, 2024
bb6266b
Block enqueueing if the batch is finished
jpcamara Sep 24, 2024
c34a40f
Migration to allow nesting batches
jpcamara Sep 24, 2024
d81f44e
Expanded batch readme
jpcamara Sep 26, 2024
5b06d4e
Force an initial batch check
jpcamara Sep 26, 2024
22207c6
Initial batch lifecycle tests
jpcamara Sep 26, 2024
980a9ce
Add job batches to queue_schema.rb as well
jpcamara Nov 22, 2024
49b11ea
Refactor internals and api namespace of batches
jpcamara Aug 29, 2025
79b92d5
Move away from a batch_processed_at to batch_execution model
jpcamara Sep 5, 2025
3ba3637
Reduce complexity of batches implementation
jpcamara Sep 8, 2025
dd902b0
Test updates
jpcamara Sep 8, 2025
c491824
Create batch executions alongside ready and scheduled executions
jpcamara Sep 9, 2025
ea388a9
Leftover from previous implementation
jpcamara Sep 10, 2025
8879a3c
Move batch completion checks to job
jpcamara Sep 11, 2025
bc7c207
Support rails versions that don't have after_all_transactions_commit
jpcamara Sep 11, 2025
1305175
Remove support for nested batches for now
jpcamara Sep 13, 2025
0e24780
Fix starting batch in rails 7.1
jpcamara Sep 13, 2025
247752c
Helper status method
jpcamara Sep 15, 2025
5f7fa14
Remove parent/child batch relationship, which simplifies the logic
jpcamara Sep 15, 2025
ba40df7
Performance improvements
jpcamara Sep 16, 2025
c5fd365
We no longer need to keep jobs
jpcamara Sep 16, 2025
3c41cb4
Removing pending_jobs column
jpcamara Sep 16, 2025
f7a1a7b
Update doc to reflect current feature state
jpcamara Sep 16, 2025
a7e3ae6
We always save the batch first now, so we don't need to upsert
jpcamara Sep 16, 2025
cc5a0b5
Rubocop
jpcamara Sep 16, 2025
0c36456
Accidental claude.md
jpcamara Sep 16, 2025
f2e0696
Allow omitting a block, which will just enqueue an empty job
jpcamara Sep 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
- [Performance considerations](#performance-considerations)
- [Failed jobs and retries](#failed-jobs-and-retries)
- [Error reporting on jobs](#error-reporting-on-jobs)
- [Batch jobs](#batch-jobs)
- [Puma plugin](#puma-plugin)
- [Jobs and transactional integrity](#jobs-and-transactional-integrity)
- [Recurring tasks](#recurring-tasks)
Expand Down Expand Up @@ -584,6 +585,66 @@ class ApplicationMailer < ActionMailer::Base
Rails.error.report(exception)
raise exception
end
```

## Batch jobs

SolidQueue offers support for batching jobs. This allows you to track progress of a set of jobs,
and optionally trigger callbacks based on their status. It supports the following:

- Relating jobs to a batch, to track their status
- Three available callbacks to fire:
- `on_finish`: Fired when all jobs have finished, including retries. Fires even when some jobs have failed.
- `on_success`: Fired when all jobs have succeeded, including retries. Will not fire if any jobs have failed, but will fire if jobs have been discarded using `discard_on`
- `on_failure`: Fired when all jobs have finished, including retries. Will only fire if one or more jobs have failed.
- If a job is part of a batch, it can enqueue more jobs for that batch using `batch#enqueue`
- Attaching arbitrary metadata to a batch

```rb
class SleepyJob < ApplicationJob
def perform(seconds_to_sleep)
Rails.logger.info "Feeling #{seconds_to_sleep} seconds sleepy..."
sleep seconds_to_sleep
end
end

class BatchFinishJob < ApplicationJob
def perform(batch) # batch is always the default first argument
Rails.logger.info "Good job finishing all jobs"
end
end

class BatchSuccessJob < ApplicationJob
def perform(batch) # batch is always the default first argument
Rails.logger.info "Good job finishing all jobs, and all of them worked!"
end
end

class BatchFailureJob < ApplicationJob
def perform(batch) # batch is always the default first argument
Rails.logger.info "At least one job failed, sorry!"
end
end

SolidQueue::Batch.enqueue(
on_finish: BatchFinishJob,
on_success: BatchSuccessJob,
on_failure: BatchFailureJob,
metadata: { user_id: 123 }
) do
5.times.map { |i| SleepyJob.perform_later(i) }
end
```

### Batch options

In the case of an empty batch, a `SolidQueue::Batch::EmptyJob` is enqueued.

By default, this jobs run on the `default` queue. You can specify an alternative queue for it in an initializer:

```rb
Rails.application.config.after_initialize do # or to_prepare
SolidQueue::Batch.maintenance_queue_name = "my_batch_queue"
end
```

Expand Down
12 changes: 12 additions & 0 deletions app/jobs/solid_queue/batch/empty_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module SolidQueue
class Batch
class EmptyJob < (defined?(ApplicationJob) ? ApplicationJob : ActiveJob::Base)
def perform
# This job does nothing - it just exists to trigger batch completion
# The batch completion will be handled by the normal job_finished! flow
end
end
end
end
154 changes: 154 additions & 0 deletions app/models/solid_queue/batch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# frozen_string_literal: true

module SolidQueue
class Batch < Record
include Trackable

has_many :jobs, foreign_key: :batch_id, primary_key: :batch_id
has_many :batch_executions, foreign_key: :batch_id, primary_key: :batch_id, class_name: "SolidQueue::BatchExecution",
dependent: :destroy

serialize :on_finish, coder: JSON
serialize :on_success, coder: JSON
serialize :on_failure, coder: JSON
serialize :metadata, coder: JSON

after_initialize :set_batch_id
after_commit :start_batch, on: :create, unless: -> { ActiveRecord.respond_to?(:after_all_transactions_commit) }
Copy link
Contributor Author

@jpcamara jpcamara Sep 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a couple places that use after_commits (or just do things after all transactions have committed using ActiveRecord.after_all_transactions_commit), which means they are susceptible to intermitten errors causing them to never fire. Ideally I would update the concurrency maintenance task to also manage checking that batches actually initialize properly. But I didn't want to add anything like that until I get an overall ok about the PRs approach.


mattr_accessor :maintenance_queue_name
self.maintenance_queue_name = "default"

def enqueue(&block)
raise "You cannot enqueue a batch that is already finished" if finished?

transaction do
save! if new_record?

Batch.wrap_in_batch_context(batch_id) do
block&.call(self)
end

if ActiveRecord.respond_to?(:after_all_transactions_commit)
ActiveRecord.after_all_transactions_commit do
start_batch
end
end
end
end

def on_success=(value)
super(serialize_callback(value))
end

def on_failure=(value)
super(serialize_callback(value))
end

def on_finish=(value)
super(serialize_callback(value))
end

def check_completion!
return if finished? || !ready?
return if batch_executions.limit(1).exists?

rows = Batch
.by_batch_id(batch_id)
.unfinished
.empty_executions
.update_all(finished_at: Time.current)

return if rows.zero?

with_lock do
failed = jobs.joins(:failed_execution).count
finished_attributes = {}
if failed > 0
finished_attributes[:failed_at] = Time.current
finished_attributes[:failed_jobs] = failed
end
finished_attributes[:completed_jobs] = total_jobs - failed

update!(finished_attributes)
execute_callbacks
end
end

private

def set_batch_id
self.batch_id ||= SecureRandom.uuid
end

def as_active_job(active_job_klass)
active_job_klass.is_a?(ActiveJob::Base) ? active_job_klass : active_job_klass.new
end

def serialize_callback(value)
return value if value.blank?
active_job = as_active_job(value)
# We can pick up batch ids from context, but callbacks should never be considered a part of the batch
active_job.batch_id = nil
active_job.serialize
end

def perform_completion_job(job_field, attrs)
active_job = ActiveJob::Base.deserialize(send(job_field))
active_job.send(:deserialize_arguments_if_needed)
active_job.arguments = [ self ] + Array.wrap(active_job.arguments)
SolidQueue::Job.enqueue_all([ active_job ])

active_job.provider_job_id = Job.find_by(active_job_id: active_job.job_id).id
attrs[job_field] = active_job.serialize
end

def execute_callbacks
if failed_at?
perform_completion_job(:on_failure, {}) if on_failure.present?
else
perform_completion_job(:on_success, {}) if on_success.present?
end

perform_completion_job(:on_finish, {}) if on_finish.present?
end

def enqueue_empty_job
Batch.wrap_in_batch_context(batch_id) do
EmptyJob.set(queue: self.class.maintenance_queue_name || "default").perform_later
end
end

def start_batch
enqueue_empty_job if reload.total_jobs == 0
update!(enqueued_at: Time.current)
end

class << self
def enqueue(on_success: nil, on_failure: nil, on_finish: nil, metadata: nil, &block)
new.tap do |batch|
batch.assign_attributes(
on_success: on_success,
on_failure: on_failure,
on_finish: on_finish,
metadata: metadata
)

batch.enqueue(&block)
end
end

def current_batch_id
ActiveSupport::IsolatedExecutionState[:current_batch_id]
end

def wrap_in_batch_context(batch_id)
previous_batch_id = current_batch_id.presence || nil
ActiveSupport::IsolatedExecutionState[:current_batch_id] = batch_id
yield
ensure
ActiveSupport::IsolatedExecutionState[:current_batch_id] = previous_batch_id
end
end
end
end
69 changes: 69 additions & 0 deletions app/models/solid_queue/batch/trackable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module SolidQueue
class Batch
module Trackable
extend ActiveSupport::Concern

included do
scope :finished, -> { where.not(finished_at: nil) }
scope :succeeded, -> { finished.where(failed_at: nil) }
scope :unfinished, -> { where(finished_at: nil) }
scope :failed, -> { where.not(failed_at: nil) }
scope :by_batch_id, ->(batch_id) { where(batch_id:) }
scope :empty_executions, -> {
where(<<~SQL)
NOT EXISTS (
SELECT 1 FROM solid_queue_batch_executions
WHERE solid_queue_batch_executions.batch_id = solid_queue_batches.batch_id
LIMIT 1
)
SQL
}
end

def status
if finished?
failed? ? "failed" : "completed"
elsif enqueued_at.present?
"processing"
else
"pending"
end
end

def failed?
failed_at.present?
end

def succeeded?
finished? && !failed?
end

def finished?
finished_at.present?
end

def ready?
enqueued_at.present?
end

def completed_jobs
finished? ? self[:completed_jobs] : total_jobs - batch_executions.count
end

def failed_jobs
finished? ? self[:failed_jobs] : jobs.joins(:failed_execution).count
end

def pending_jobs
finished? ? 0 : batch_executions.count
end

def progress_percentage
return 0 if total_jobs == 0
((completed_jobs + failed_jobs) * 100.0 / total_jobs).round(2)
end
end
end
end
32 changes: 32 additions & 0 deletions app/models/solid_queue/batch_execution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module SolidQueue
class BatchExecution < Record
belongs_to :job, optional: true
belongs_to :batch, foreign_key: :batch_id, primary_key: :batch_id

after_commit :check_completion, on: :destroy

private
def check_completion
batch = Batch.find_by(batch_id: batch_id)
batch.check_completion! if batch.present?
end

class << self
def create_all_from_jobs(jobs)
batch_jobs = jobs.select { |job| job.batch_id.present? }
return if batch_jobs.empty?

batch_jobs.group_by(&:batch_id).each do |batch_id, jobs|
BatchExecution.insert_all!(jobs.map { |job|
{ batch_id:, job_id: job.respond_to?(:provider_job_id) ? job.provider_job_id : job.id }
})

total = jobs.size
SolidQueue::Batch.where(batch_id:).update_all([ "total_jobs = total_jobs + ?", total ])
end
end
end
end
end
23 changes: 23 additions & 0 deletions app/models/solid_queue/execution/batchable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module SolidQueue
class Execution
module Batchable
extend ActiveSupport::Concern

included do
after_create :update_batch_progress, if: -> { job.batch_id? }
end

private
def update_batch_progress
if is_a?(FailedExecution)
# FailedExecutions are only created when the job is done retrying
job.batch_execution&.destroy!
end
rescue => e
Rails.logger.error "[SolidQueue] Failed to notify batch #{job.batch_id} about job #{job.id} failure: #{e.message}"
end
end
end
end
2 changes: 1 addition & 1 deletion app/models/solid_queue/failed_execution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module SolidQueue
class FailedExecution < Execution
include Dispatching
include Dispatching, Batchable

serialize :error, coder: JSON

Expand Down
5 changes: 3 additions & 2 deletions app/models/solid_queue/job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module SolidQueue
class Job < Record
class EnqueueError < StandardError; end

include Executable, Clearable, Recurrable
include Executable, Clearable, Recurrable, Batchable

serialize :arguments, coder: JSON

Expand Down Expand Up @@ -62,7 +62,8 @@ def attributes_from_active_job(active_job)
scheduled_at: active_job.scheduled_at,
class_name: active_job.class.name,
arguments: active_job.serialize,
concurrency_key: active_job.concurrency_key
concurrency_key: active_job.concurrency_key,
batch_id: active_job.batch_id
}
end
end
Expand Down
Loading