Skip to content

Batch Support - #142

Open
jpcamara wants to merge 63 commits into
rails:mainfrom
jpcamara:batch-poc
Open

Batch Support#142
jpcamara wants to merge 63 commits into
rails:mainfrom
jpcamara:batch-poc

Conversation

@jpcamara

@jpcamara jpcamara commented Feb 2, 2024

Copy link
Copy Markdown
Contributor

Support for batches in SolidQueue!

Batches are a powerful feature in Sidekiq Pro and GoodJob which help with job coordination. A "Batch" is a collection of jobs, and when those jobs meet certain completion criteria it can optionally trigger another job with the batch record as an argument.

The goal of this feature is to:

  1. Enhance job coordination
  2. Maintain the overall simplicity of SolidQueue. To quote @rosa in the batch support issue:

We have batch support in our list of possible features to add, but it's not in the immediate plans because it's a bit at odds with the simplicity we're aiming for... I'm not quite sure yet how it could look like, in a way that maintains the overall simplicity of the gem

This PR provides a functional batch implementation. The following scenarios will work:

# Create a job to run as part of the batch
class SleepyJob < ApplicationJob
  queue_as :background

  def perform(seconds_to_sleep)
    Rails.logger.info "Feeling #{seconds_to_sleep} seconds sleepy..."
    sleep seconds_to_sleep
  end
end

# Create a batch completion job - the first argument is always the batch record itself
class BatchCompletionJob < ApplicationJob
  queue_as :background

  def perform(batch)
    Rails.logger.info "#{batch.jobs.size} jobs completed!"
  end
end

# Create the batch itself. There are three callback options: `on_finish`, `on_success` and `on_failure`
SolidQueue::Batch.enqueue(on_success: BatchCompletionJob) do
  5.times.map { |i| SleepyJob.perform_later(i) }
end

You should see the following in your logs:

[SolidQueue] Claimed 5 jobs
Performing SleepyJob (Job ID: 9a97e394-f1b4-47b5-8db3-6df86faf0913) from SolidQueue(background) enqueued at 2024-02-02T02:02:05.563284000Z with arguments: 1
Feeling 1 seconds sleepy...
Performing SleepyJob (Job ID: 4ae76794-25e1-4378-bbc7-c505d7775395) from SolidQueue(background) enqueued at 2024-02-02T02:02:05.567448000Z with arguments: 2
Feeling 2 seconds sleepy...
Performing SleepyJob (Job ID: fd41b240-08b6-4f7d-b4de-38ee2e6a58b4) from SolidQueue(background) enqueued at 2024-02-02T02:02:05.557246000Z with arguments: 0
Feeling 0 seconds sleepy...
Performed SleepyJob (Job ID: fd41b240-08b6-4f7d-b4de-38ee2e6a58b4) from SolidQueue(background) in 0.1ms
Performing SleepyJob (Job ID: 40498973-9b5a-4f57-acce-03e2b8e2a97c) from SolidQueue(background) enqueued at 2024-02-02T02:02:05.594398000Z with arguments: 4
Feeling 4 seconds sleepy...
Performing SleepyJob (Job ID: c2a123ea-61ba-4d33-99e7-9478a8ca4f5d) from SolidQueue(background) enqueued at 2024-02-02T02:02:05.589439000Z with arguments: 3
Feeling 3 seconds sleepy...
Performed SleepyJob (Job ID: 9a97e394-f1b4-47b5-8db3-6df86faf0913) from SolidQueue(background) in 1005.47ms
Performed SleepyJob (Job ID: 4ae76794-25e1-4378-bbc7-c505d7775395) from SolidQueue(background) in 2006.58ms
Performed SleepyJob (Job ID: c2a123ea-61ba-4d33-99e7-9478a8ca4f5d) from SolidQueue(background) in 3006.1ms
Performed SleepyJob (Job ID: 40498973-9b5a-4f57-acce-03e2b8e2a97c) from SolidQueue(background) in 4008.12ms
[SolidQueue] Claimed 1 jobs
Performing BatchCompletionJob (Job ID: 625ca70c-4e80-4789-988b-3950ed1b23f3) from SolidQueue(background) enqueued at 2024-02-02T02:02:10.183075000Z with arguments: #<GlobalID:0x000000010b5b0390 @uri=#<URI::GID gid://dummy/SolidQueue::JobBatch/13>>
5 jobs completed!
Performed BatchCompletionJob (Job ID: 625ca70c-4e80-4789-988b-3950ed1b23f3) from SolidQueue(background) in 7.97ms

Here is the full interface, demonstrating a few different options:

# All three callback types
# on_finish: runs after all jobs have finished running, including retries. It runs regardless of failed jobs
# on_success: runs after all jobs have finished running, including retries. Only runs if all jobs succeed
# on_failure: runs after all jobs have finished running, including retries. Only runs if one of the jobs fails
SolidQueue::Batch.enqueue(
  # You can hand in a populated instance, to set options like wait times or a custom queue for the callback
  on_finish: BatchCompletionJob.new.set(wait_until: 1.hour.from_now, queue: :batch),
  # Otherwise just supply the job class
  on_success: BatchSuccessJob,
  on_failure: BatchFailureJob
) do
  5.times.map { |i| SleepyJob.perform_later(i) }
end

# Jobs that are part of a batch can enqueue more jobs into the batch
class EnqueueMoreJobsJob < ApplicationJob
  def perform
    batch.enqueue { YetAnotherJob.perform_later }
  end
end

Some have mentioned the GoodJob example for complex batches and asked if this could be implemented in the SolidQueue::Batch approach: https://github.com/bensheldon/good_job?tab=readme-ov-file#complex-batches. GoodJob offers mutable batches, and the SolidQueue::Batch implementation mostly does not. So this is how you would implement the same, more complex example:

class BatchWorkJob < ApplicationJob
  def perform(step)
    puts "BatchWorkJob: #{step}"
    if step == 'e'
      batch.enqueue { BatchWorkJob.perform_later('f') }
      puts "BatchWorkJob: enqueue f"
    end
  end
end

class BatchJob < ApplicationJob
  def perform(batch)
    metadata = batch.metadata
    if metadata[:stage].nil?
      puts "BatchJob: initial stage"
      SolidQueue::Batch.enqueue(on_finish: BatchJob, stage: 1) do
        BatchWorkJob.perform_later('a')
        BatchWorkJob.perform_later('b')
        BatchWorkJob.perform_later('c')
      end
    elsif metadata[:stage] == 1
      puts "BatchJob: stage 1"
      SolidQueue::Batch.enqueue(on_finish: BatchJob, stage: 2) do
        BatchWorkJob.perform_later('d')
        BatchWorkJob.perform_later('e')
      end
    elsif metadata[:stage] == 2
      puts "BatchJob: stage 2"
      # ...
    end
  end
end

SolidQueue::Batch.enqueue(on_finish: BatchJob)

# BatchJob: initial stage
# BatchWorkJob: c
# BatchWorkJob: a
# BatchWorkJob: b
# BatchJob: stage 1
# BatchWorkJob: d
# BatchWorkJob: e
# BatchWorkJob: enqueue f
# BatchWorkJob: f
# BatchJob: stage 2

Here are the things that are open questions and missing implementation details:

  • Naming: is JobBatch the right name? General feedback on naming in the feature
  • Is it simple enough?
  • Do the callbacks make sense? on_success, on_finish, on_failure
  • We cannot handle discards right now. SolidQueue handles discard_on by marking the job as finished. That means the batch cannot identify that the job actually failed.
  • Hand a generalized interface into the job instead of an actual batch record?
  • Keeping things efficient when you have tons of jobs in a batch
  • How would/could batches fit into Mission Control - Jobs? Very basic batch support jpcamara/mission_control-jobs#1

@jpcamara jpcamara mentioned this pull request Feb 2, 2024
@mbajur

mbajur commented Feb 2, 2024

Copy link
Copy Markdown

Could that also support adding jobs to already existing batch? Like SleepyJob enqueuing another job that would also be added to the batch.

@jpcamara

jpcamara commented Feb 2, 2024

Copy link
Copy Markdown
Contributor Author

Could that also support adding jobs to already existing batch? Like SleepyJob enqueuing another job that would also be added to the batch.

@mbajur Definitely. As long as you're in a job that's part of the batch, adding another job to the batch would work fine. It'd be pretty simple to extend the existing code to handle that - something like this would solve your use-case I think?

class SleepyJob < ApplicationJob
  queue_as :background

  def perform(seconds_to_sleep)
    Rails.logger.info "Feeling #{seconds_to_sleep} seconds sleepy..."
    sleep seconds_to_sleep

    batch.enqueue { AnotherJob.perform_later }
  end
end

I can update the ActiveJob::JobBatchId to add an extra batch method to the job which returns the current batch based on the job batch_id, and add an instance level SolidQueue::JobBatch#enqueue method which let's you add more jobs to the batch.

This would only work safely inside of the job - if you were outside of the job, it's possible the batch would finish before the job gets created.

@mbajur

mbajur commented Feb 2, 2024

Copy link
Copy Markdown

Yes that would absolutely do the trick for me :) Thank you!

@jpcamara

jpcamara commented Mar 30, 2024

Copy link
Copy Markdown
Contributor Author

Hi @rosa 👋🏼 Congrats on getting SolidQueue past incubation and under the Rails umbrella officially!

I'm sure you've got alot on your plate! Are there any questions I can answer in regards to this PR? I can take the interface/functionality further, but I wanted to discuss it a bit before doing that. If there's anything additional you'd like me to tighten up/try out before discussing it, i'm happy to do so.

Also ok to just be on hold and not ready to discuss this further atm. Since it's been a couple months, I figured i'd check in.

@rosa

rosa commented Apr 12, 2024

Copy link
Copy Markdown
Member

Hey @jpcamara, so sorry for the delay and the silence here. I just haven't had the proper time to dedicate to this, and I think this requires and deserves more than a quick look. Thank you so much for putting this together!

My instinct with this kind of feature is that they require someone to use them before they're ready. From just looking at the code, I'm not quite sure what kind of edge cases and race conditions could arise here. This is how most of Solid Queue has been built: we've used it in HEY before making it "official" for everyone, seeing how it behaves under certain loads and what kind of problems we encounter. We caught and improved quite a few things that way.

We don't have a use case for batches right now, so I'm afraid I won't be able to take this feature to this "production-test" point on my side. Do you see yourself using this in a production setting?

@jpcamara

Copy link
Copy Markdown
Contributor Author

Hey @jpcamara, so sorry for the delay and the silence here. I just haven't had the proper time to dedicate to this, and I think this requires and deserves more than a quick look. Thank you so much for putting this together!

My instinct with this kind of feature is that they require someone to use them before they're ready. From just looking at the code, I'm not quite sure what kind of edge cases and race conditions could arise here. This is how most of Solid Queue has been built: we've used it in HEY before making it "official" for everyone, seeing how it behaves under certain loads and what kind of problems we encounter. We caught and improved quite a few things that way.

We don't have a use case for batches right now, so I'm afraid I won't be able to take this feature to this "production-test" point on my side. Do you see yourself using this in a production setting?

That makes sense! It was a bit of a chicken and an egg issue for me - I wanted to have batches in SolidQueue before starting to transition some things over, because I have code using Sidekiq Pro batches. But I can start experimenting with it now and report back. I'll continue to work on this PR as well in that case, too.

@dimroc

dimroc commented Aug 3, 2024

Copy link
Copy Markdown

Thanks for all the hard work here. I'm a big fan of batch jobs so I've been keeping my eye on this PR for a while. Sidekiq Pro and others support the notion of child batches. Like batch jobs, child batches let you work with a higher level abstraction that conceptually simplifies your background work. While implementing that in this PR would be feature creep, I wanted to raise awareness of it in hopes that we design with its extensibility in mind.

Thanks again @jpcamara

@jpcamara

jpcamara commented Aug 15, 2024

Copy link
Copy Markdown
Contributor Author

Thanks for all the hard work here. I'm a big fan of batch jobs so I've been keeping my eye on this PR for a while. Sidekiq Pro and others support the notion of child batches. Like batch jobs, child batches let you work with a higher level abstraction that conceptually simplifies your background work. While implementing that in this PR would be feature creep, I wanted to raise awareness of it in hopes that we design with its extensibility in mind.

Thanks again @jpcamara

hey @dimroc! I couldn't agree more. I used child batches in Sidekiq recently in a project and it highlighted the need to add them to this PR - it's an important feature. I've been putting alot of work into releasing a blog series on ruby concurrency and it's been eating up my free coding-related time, but i'm prioritizing getting back to this soon. Thanks for the feedback!

@jpcamara
jpcamara force-pushed the batch-poc branch 2 times, most recently from c58f11d to 5ba1c27 Compare September 24, 2024 02:51
@jpcamara jpcamara changed the title Batch POC Batch Support Sep 25, 2024
@jpcamara

jpcamara commented Sep 26, 2024

Copy link
Copy Markdown
Contributor Author

Thanks for all the hard work here. I'm a big fan of batch jobs so I've been keeping my eye on this PR for a while. Sidekiq Pro and others support the notion of child batches. Like batch jobs, child batches let you work with a higher level abstraction that conceptually simplifies your background work. While implementing that in this PR would be feature creep, I wanted to raise awareness of it in hopes that we design with its extensibility in mind.
Thanks again @jpcamara

hey @dimroc! I couldn't agree more. I used child batches in Sidekiq recently in a project and it highlighted the need to add them to this PR - it's an important feature. I've been putting alot of work into releasing a blog series on ruby concurrency and it's been eating up my free coding-related time, but i'm prioritizing getting back to this soon. Thanks for the feedback!

I've added child batches to this PR @dimroc

@jpcamara

Copy link
Copy Markdown
Contributor Author
batch.enqueue { AnotherJob.perform_later }

Added support to this PR for enqueueing within a job using the syntax I suggested: batch.enqueue { AnotherJob.perform_later } @mbajur

@mariochavez

Copy link
Copy Markdown

@jpcamara just an idea here. Could this functionality become its own gem? Like an add-on for solid_queue?
I'm currently using Gush, which is similar to what this functionality does.

@nickpoorman

nickpoorman commented Sep 27, 2024

Copy link
Copy Markdown

One place I think this could be useful could be rolling back changes. Since the move to SQLite doesn't create jobs to a secondary database until after committing records to the main database, there is no atomic guarantee that both records will commit to both databases, leaving us to need manual rollback logic. Additionally, this reminds me that sidekiq had some atomic writing of batch jobs guarantee, either in pro or enterprise.

Same thing might happen if you make a change in your database and call an external API. With SQLite you no longer want to make those changes in your database and make the [long-running] external API call in a transaction. Let's say you have to call stripe twice and want to keep a record of what stage you're at. But something goes wrong with the second stripe call (say the charge is declined) and you need to rollback your database so it's not in an inconsistent state.

In this case you can queue the rollback as part of the batch.

If memory serves, I think the pattern for this sort of thing is called Sagas, or a DAG such as Elixir has with GenStage.

I also found this pattern extremely useful in the past because you can parallelize work more effectively. Say I need to make 1000 remote API calls. Each call should really be in its own job so that it can be retried if it hits the API limits and I need to perform some final job once all 1000 API calls have been made and the batch is complete.

@jpcamara

jpcamara commented Oct 1, 2024

Copy link
Copy Markdown
Contributor Author

@jpcamara just an idea here. Could this functionality become its own gem? Like an add-on for solid_queue? I'm currently using Gush, which is similar to what this functionality does.

@mariochavez it's true, it probably could be a gem! Sidekiq has batches as a pro feature, but there is also an open-source gem that mostly supports the same api https://github.com/breamware/sidekiq-batch.

There are some gotchas with approaching it that way (one, for instance, around jobs being automatically cleaned up and not being able to do anything but warn users against it). But my main motivation is that I personally want it as a first-class feature of SolidQueue. It's a first-class (albeit paid) feature of Sidekiq, and it's a first-class feature of GoodJob. Being built-in means it's more likely to get use/support and alleviates concerns it may be abandoned at some point. I also think it's a great core feature of a job library.

Gush is awesome! It definitely works similarly, though this being backed by a DB in SolidQueue means it has more ACID-type guarantees.

Something I would like to see is an even more sophisticated "workflow" type layer that worked with any activejob system, and that's something I've toyed around wtih over the past year. That kind of system I think goes a step beyond, is more complicated, and is better served as a separate gem. I think batch support being included in the job server is a good fit.

@dimroc

dimroc commented Oct 1, 2024

Copy link
Copy Markdown

Thanks for all this work @jpcamara!

I'm curious what the expected behavior is with limit_concurrency. Does it limit concurrency until the batch is complete (ie: only 3 batch jobs can run concurrently even when waiting on their individual jobs), which would surpass even Sidekiq's behavior, or does limit_concurrency only work with individual jobs?

An argument for keeping this in the main gem is to ease integration testing with the other features to increase cohesion and stability. I can see it getting hairy when you stack a few different configurations on top of nested batches, and having to assert proper behavior.

https://github.com/rails/solid_queue?tab=readme-ov-file#concurrency-controls

@abrunner94

Copy link
Copy Markdown

I would love to see batches in Solid Queue! My use case is primarily creating workflows.

@kaka-ruto

Copy link
Copy Markdown

Just wanna thank @jpcamara for the work he's done, is doing here. Would love to use this 🙏

@jpcamara

jpcamara commented Nov 2, 2024

Copy link
Copy Markdown
Contributor Author

Just wanna thank @jpcamara for the work he's done, is doing here. Would love to use this 🙏

Hey @kaka-ruto, I think I saw a message about you being willing to try this out? That would be great! Most of my free time is working on a RubyConf talk I have in a couple weeks, but i'll be shifting back to this right after that and will give you an update.

@jpcamara
jpcamara force-pushed the batch-poc branch 2 times, most recently from 1ef7f38 to 8099030 Compare November 22, 2024 22:52
* Helps to differentiate the batch in a UX. Most batch systems support a description

* Add finished_at index to improve lookup performance of active batches

* Add tests for description, and demonstrate constructing a batch then running #enqueue
* Instead of receiving the `batch` as the first argument, there is now metadata stored and you can reuse the same `batch` method you can use on normal jobs. This reduces confusion around the difference between creating a new instance of the callback job, and what the perform method takes for arguments

* Add a new `Clearable` concern, nearly identicaly to the job `Clearable` concern, that users of SolidQueue can run to clear out finished batches. Ideally we would consolidate the two together at some point, but to minimize impact on existing code this new interface is now available
@rosa rosa mentioned this pull request Jan 26, 2026
@rosa

rosa commented Feb 23, 2026

Copy link
Copy Markdown
Member

Ok, I'm finally back at this as well! Thanks a lot for the last push, @jpcamara! 🙏🏻 I'm planning Solid Queue 2.0 with the idea that batches are there.

Regardless, I think due to the use of after_commit in this PR there is always the chance a server crash could happen at a code seam, and maybe require an addition to the maintenance task to also check if any batches happen to have not finished running. Thoughts on this @rosa?

Yes, this seems reasonable to me! Similar to what we do for other cases, like a job failing to unblock the next one for concurrency controls.

I'm planning to add a description field, and an index to the finished_at. The description and finished_at index will improve usage in mission control jobs. I also need to add a Clearable type concern for batches. I added that in my production usage local to that project, to clean up batches. Ideally we'd only have one cleanup task, but that doesn't really gel well with the existing job cleanup that comes in with the template. Thoughts on that @rosa?

Good point. I think it's ok to change that, especially considering we'll change a major version number.

I'm going to do another pass. Thanks a lot!

jpcamara and others added 18 commits July 29, 2026 07:45
Batches could get permanently stuck or lose callbacks through several
races, all fixed without locking the batch row outside a single
once-per-batch moment:

- Completion keeps the single-statement CAS (exactly one finisher
  matches; losers resolve with a 0-row update) but shares its
  transaction with counters and callback enqueueing, so a crash can't
  leave a finished batch without callbacks
- After winning the CAS, executions are re-checked with a current
  snapshot: on PostgreSQL READ COMMITTED a blocked finisher re-evaluates
  NOT EXISTS against its original snapshot and could otherwise finish a
  batch that just gained jobs from a concurrent adder
- Adding jobs to a finished batch is prevented lock-free: the existing
  total_jobs increment gains an `unfinished` condition, so it and the
  completion CAS contend on the same row and exactly one wins; losing
  rolls back the enqueue transaction with AlreadyFinished
- The increment runs before inserting tracking rows: execution inserts
  take a shared lock on the batch row (FK validation) and upgrading to
  exclusive deadlocked concurrent adders on MySQL (163/200 in stress
  tests); exclusive-first serializes them cleanly
- start_batch marks the batch started before enqueueing the empty job
  and sweeps for completion afterwards, closing the window where jobs
  finish before enqueued_at is set and nothing ever finishes the batch;
  the start transition is single-winner so concurrent sweepers can't
  enqueue duplicate empty jobs
- Batch.sweep_stalled is the safety net for work that can't finish
  through the normal flow: bulk-discarded jobs, processes that died
  before starting their batch, and completions whose callback
  enqueueing failed
- Completion and sweeps emit finish_batch and sweep_stalled_batches
  events

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
In-flight counters double counted failures: failed jobs have also lost
their batch execution, so completed_jobs (total - pending) included them
while failed_jobs counted them again, letting progress_percentage exceed
100% and report completion with jobs still running. Progress is now
(total - pending) / total, which needs one COUNT instead of three and is
consistent before and after finishing. Also: failed counts reuse the
Job.failed scope, status returns symbols like Job#status, and the
empty_executions scope stays join-free so update_all keeps it in the
completion CAS's own WHERE clause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Jobs join the batch that's active when they're enqueued, not when
they're instantiated: a job built outside the block and enqueued inside
it joins the batch, and vice versa. The active context takes precedence
so reused instances rebind to the current batch, falling back to prior
membership so retries stay in theirs. Bulk enqueues bypass per-job
enqueue, so Job.enqueue_all captures the context itself.

Capture must run before Rails 7.2's enqueue_after_transaction_commit
deferral or deferred jobs would silently miss their batch — the include
is nested like Rails' own initializer so BatchId deterministically wraps
outside EnqueueAfterTransactionCommit. This hazard is why capture
originally lived in initialize; with the ordering guaranteed, enqueue
time keeps the better semantics without the initialize override.

Also: serialize only writes batch keys when set (sparing dead JSON on
every non-batch job), and the batch accessor memoizes by id so a nil
read before enqueue doesn't hide a batch assigned later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
- Tracking rows are created before dispatching in the bulk path, so jobs
  discarded by concurrency conflicts clean up via dependent: :destroy
  and count the same as in the single-job path (previously perform_later
  counted them and perform_all_later didn't)
- Batch executions get on_delete: :cascade foreign keys like every other
  execution table; bulk discards delete jobs without callbacks, and the
  cascade removes their tracking rows declaratively so the sweep can
  finish the batch (Execution itself stays untouched)
- The failure-side concern moves to FailedExecution::Batchable:
  single-owner concerns live under their owner like Job::Batchable, and
  failed executions are the only terminal execution type, so they're the
  only place batch tracking ends
- Tracking failures emit batch_progress_error events instead of
  swallowing errors with a bare log line, and rescue only
  ActiveRecord::ActiveRecordError so unexpected errors propagate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
ConcurrencyMaintenance generalizes into Dispatcher::Maintenance running
both concurrency and batch routines on one timer at
concurrency_maintenance_interval, individually toggleable via the
concurrency_maintenance and batch_maintenance options — batch support
adds no maintenance thread or worst-case database connection to the
dispatcher. ConcurrencyMaintenance remains as a compatibility shim with
its original signature and behavior, so no shipped API is removed.

The LogSubscriber renders the new batch events: finish_batch,
sweep_stalled_batches and batch_progress_error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Every load-bearing line in batch completion has a test that fails if
it's removed, verified by sabotage (breaking each guard makes its test
go red):

- Concurrent completion checks finish a batch exactly once, with exactly
  one set of callbacks (kills the CAS conditions if weakened; also
  caught a where.missing refactor that silently moved `unfinished` out
  of the UPDATE's own WHERE on PostgreSQL)
- Concurrent adders keep exact accounting and hit no deadlocks (the
  reverted statement order fails with 33/40 adds deadlocked on MySQL)
- A completion check racing a concurrent adder does not finish the batch
  (deterministically reproduces the PostgreSQL stale-snapshot
  interleaving; removing the post-CAS re-check wrongly finishes it)
- start_batch sweeps up jobs that finished before the batch was started,
  and is single-winner against stale instances
- sweep_stalled finishes bulk-discarded batches and starts batches whose
  creating process died
- In-flight counters stay bounded and consistent with failures present
- Batch membership follows enqueue-time context in both directions,
  reused instances rebind, the accessor doesn't cache a stale nil, and
  BatchId stays ahead of EnqueueAfterTransactionCommit in the ancestor
  chain
- Conflict-discarded jobs count identically for single and bulk enqueues
- Dispatcher batch maintenance is optional and rides the shared timer;
  ConcurrencyMaintenance keeps its original signature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Fixes the callback examples — callbacks are enqueued with their original
arguments and use the batch accessor; the documented perform(batch)
signature raised ArgumentError. Documents enqueue-time batch membership,
counter semantics (retry attempts count toward total_jobs), discard and
manual-retry behavior, callback set() timing, batch maintenance and the
recurring-task alternative, clearing batches, and a migration snippet
for existing installations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Found by exercising real batches in a Resque-default dummy app:
Batch::EmptyJob inherits the host's ApplicationJob and enqueued to
Redis, and callback jobs enqueue via their class's current adapter in
the worker process, so they leaked to the app default too. EmptyJob now
pins its adapter, and callbacks enqueue directly through
SolidQueue::Job so they stay in Solid Queue and atomic with the
finishing transaction regardless of adapter configuration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
test_empty_batches_fire_callbacks waited 2 seconds where every other
test in the file waits 5, and it's the one batch test that flakes on CI:
four empty batches each need an empty job claimed, performed and
finished, then a callback job claimed and performed, which doesn't
reliably fit 2 seconds on shared runners with SQLite's single writer
(the outermost batch's callback misses the window). The only
batch-owned failure across the full starburstlabs CI matrix; the
remaining red legs are upstream (continuation test on rails_main,
concurrency-controls and forked-lifecycle timing flakes) and fail on
the base branch too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
The persistent rails-7.1 + SQLite CI failures (present on the base
branch for months) fit one mechanism: removing a batch's tracking row
raises inside the finishing transaction (SQLite can't retry busy
errors mid-transaction), the batchable rescue swallows it, and the
resolved job leaves a live tracking row behind — the batch can never
finish, its callback never fires, and it's never clearable. Widening
test waits didn't help because the batch wasn't slow, it was stuck.

sweep_stalled now repairs the leak: a tracking row whose job is
finished or failed is always a leak (they only resolve together in one
transaction), so the sweep destroys such rows with no staleness
threshold and the destroy re-triggers the completion check. The
lifecycle tests run dispatcher maintenance every second so repairs
land within their wait windows on busy CI runners; a deterministic
regression test constructs both leak flavors directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Two fixes from evaluating every CI failure across the matrix:

- Edge Rails defers bulk enqueues past all open transactions
  (enqueue_after_transaction_commit now covers enqueue_all), so
  perform_all_later inside a batch block pushed its jobs after the
  block's transaction committed and the batch context was gone: the
  jobs silently lost their batch, which looked empty and completed with
  just its empty job. Membership is now seeded at instantiation again —
  the original design, whose enqueue-timing hazard instinct keeps being
  right — and rebound at enqueue time when a context is active.
  Deterministically reproduced and fixed under the rails_main gemfile.

- A failed finishing transaction (SQLite can't retry busy errors
  mid-transaction) left batches empty-but-unfinished, and the sweep's
  5-minute staleness threshold kept it from repairing them in any
  useful window. Completion checks are idempotent and single-winner, so
  that threshold is now a 3-second grace covering only the
  started-but-empty-job-still-deferred gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Enqueueing callback jobs directly through SolidQueue::Job skipped their
before/around/after_enqueue hooks and ignored aborts — the one
regression an independent review found in the cumulative diff. The
callback chain now wraps the direct enqueue, so hooks run and :abort
prevents the enqueue, while callbacks keep their solid_queue pinning and
atomicity with the finishing transaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Batch support: fix completion races, accounting, and API polish
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.