solid_queue 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +133 -1
- data/UPGRADING.md +12 -0
- data/app/models/solid_queue/batch/callbacks.rb +50 -0
- data/app/models/solid_queue/batch/clearable.rb +23 -0
- data/app/models/solid_queue/batch/status.rb +64 -0
- data/app/models/solid_queue/batch/sweepable.rb +64 -0
- data/app/models/solid_queue/batch.rb +133 -0
- data/app/models/solid_queue/batch_execution.rb +52 -0
- data/app/models/solid_queue/claimed_execution.rb +1 -0
- data/app/models/solid_queue/failed_execution/batchable.rb +22 -0
- data/app/models/solid_queue/failed_execution.rb +1 -1
- data/app/models/solid_queue/job/batchable.rb +50 -0
- data/app/models/solid_queue/job/executable.rb +5 -1
- data/app/models/solid_queue/job.rb +11 -3
- data/lib/active_job/batch_id.rb +57 -0
- data/lib/generators/solid_queue/install/templates/db/queue_schema.rb +31 -0
- data/lib/generators/solid_queue/update/templates/db/add_batches_to_solid_queue.rb +39 -0
- data/lib/solid_queue/configuration.rb +2 -1
- data/lib/solid_queue/dispatcher/concurrency_maintenance.rb +4 -37
- data/lib/solid_queue/dispatcher/maintenance.rb +79 -0
- data/lib/solid_queue/dispatcher.rb +13 -9
- data/lib/solid_queue/engine.rb +4 -0
- data/lib/solid_queue/fork_supervisor.rb +13 -4
- data/lib/solid_queue/log_subscriber.rb +16 -1
- data/lib/solid_queue/processes/runnable.rb +2 -5
- data/lib/solid_queue/processes/supervised.rb +7 -0
- data/lib/solid_queue/supervisor/signals.rb +3 -0
- data/lib/solid_queue/supervisor.rb +29 -16
- data/lib/solid_queue/version.rb +1 -1
- data/lib/solid_queue.rb +1 -0
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15044fdadc1f5170f909ebfeeb3f0d31e948bb51edb336aa040ad477b9d12545
|
|
4
|
+
data.tar.gz: 566ab7ad221caa7c96ea1f755c8e5537ac4e37304afbb7ced6697eb1a120033b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0744c1361eddc92f7cc24f26d3ca6194c7a06474cc2e3a287b3935e86b6e4dbb88c595b70ef63327431f687b8578770805a68cb33729aecb1ef7ca895b3d8e07
|
|
7
|
+
data.tar.gz: 2a40a741c8269c3a31baec31970addfc268d65f9d3bdc6130b75a1e908f22ecce2a0786b34c8dfd6c88cb92a024ca3fb84709f6d1c79e4f00022880ebd55056f
|
data/README.md
CHANGED
|
@@ -30,6 +30,12 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
|
|
|
30
30
|
- [Performance considerations](#performance-considerations)
|
|
31
31
|
- [Failed jobs and retries](#failed-jobs-and-retries)
|
|
32
32
|
- [Error reporting on jobs](#error-reporting-on-jobs)
|
|
33
|
+
- [Jobs interrupted by non-graceful process death](#jobs-interrupted-by-non-graceful-process-death)
|
|
34
|
+
- [Batch jobs](#batch-jobs)
|
|
35
|
+
- [Batch progress and counters](#batch-progress-and-counters)
|
|
36
|
+
- [Batch maintenance](#batch-maintenance)
|
|
37
|
+
- [Clearing batches](#clearing-batches)
|
|
38
|
+
- [Upgrading existing installations](#upgrading-existing-installations)
|
|
33
39
|
- [Puma plugin](#puma-plugin)
|
|
34
40
|
- [Jobs and transactional integrity](#jobs-and-transactional-integrity)
|
|
35
41
|
- [Recurring tasks](#recurring-tasks)
|
|
@@ -287,6 +293,7 @@ It is recommended to set this value less than or equal to the queue database's c
|
|
|
287
293
|
Fiber workers require fiber-scoped isolated execution state. In Rails apps, set `config.active_support.isolation_level = :fiber` before using `fibers`. Solid Queue refuses to boot fiber workers when isolation remains thread-scoped. On Rails 7.2 and later, a practical starting point is usually `3-5` queue database connections per worker process rather than matching the `fibers` value, because ordinary Active Record query paths can release connections between non-blocking waits. On Rails 7.1, size the queue database pool more conservatively, as in-flight fiber jobs may still retain connections roughly in proportion to `fibers`.
|
|
288
294
|
- `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. This works with both `threads` and `fibers` workers as long as the supervisor is running in the default `fork` mode. **Note**: this option is ignored only when the supervisor itself is [running in `async` mode](#fork-vs-async-mode).
|
|
289
295
|
- `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else.
|
|
296
|
+
- `batch_maintenance`: whether the dispatcher will sweep stalled [batches](#batch-jobs) as part of its maintenance work, on the same timer as concurrency maintenance (see [batch maintenance](#batch-maintenance)). This is `true` by default; disable it if you don't use batches, or if you run multiple dispatchers and want only some of them doing maintenance work.
|
|
290
297
|
|
|
291
298
|
|
|
292
299
|
### Optional scheduler configuration
|
|
@@ -622,7 +629,7 @@ production:
|
|
|
622
629
|
Or something similar to that depending on your setup. You can also assign a different queue to a job on the moment of enqueuing so you can decide whether to enqueue a job in the throttled queue or another queue depending on the arguments, or pass a block to `queue_as` as explained [here](https://guides.rubyonrails.org/active_job_basics.html#queues).
|
|
623
630
|
|
|
624
631
|
|
|
625
|
-
In addition, mixing concurrency controls with **bulk enqueuing** (Active Job's `perform_all_later`)
|
|
632
|
+
In addition, mixing concurrency controls with **bulk enqueuing** (Active Job's `perform_all_later`) has no benefit because concurrency-controlled jobs need to be enqueued one by one to ensure concurrency limits are respected, so you lose all the benefits of bulk enqueuing.
|
|
626
633
|
|
|
627
634
|
When jobs that have concurrency controls and `on_conflict: :discard` are enqueued in bulk, the ones that fail to be enqueued and are discarded would have `successfully_enqueued` set to `false`. The total count of jobs enqueued returned by `perform_all_later` will exclude these jobs as expected.
|
|
628
635
|
|
|
@@ -661,9 +668,134 @@ class ApplicationMailer < ActionMailer::Base
|
|
|
661
668
|
Rails.error.report(exception)
|
|
662
669
|
raise exception
|
|
663
670
|
end
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Jobs interrupted by non-graceful process death
|
|
674
|
+
|
|
675
|
+
When a process dies without a clean shutdown (for example, `SIGKILL`ed by the OS or the container runtime because of memory limits), the jobs it was running can't be released back to their queues. Once another process notices the missing heartbeats and prunes the dead process's registration, its in-flight jobs are marked as failed with `SolidQueue::Processes::ProcessPrunedError`. Solid Queue deliberately doesn't retry these automatically: the job itself might be what's killing the process (for example, a job that exhausts the container's memory), and retrying it blindly would just kill the next worker too.
|
|
676
|
+
|
|
677
|
+
If you know your jobs are idempotent and want to implement your own recovery policy, you can subscribe to the `fail_many_claimed.solid_queue` event, which includes the error and the affected job IDs in its payload:
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
# config/initializers/solid_queue_recovery.rb
|
|
681
|
+
ActiveSupport::Notifications.subscribe("fail_many_claimed.solid_queue") do |event|
|
|
682
|
+
if event.payload[:error].is_a?(SolidQueue::Processes::ProcessPrunedError)
|
|
683
|
+
SolidQueue::FailedExecution.where(job_id: event.payload[:job_ids]).each do |failed_execution|
|
|
684
|
+
# Apply your own safeguard against retrying the same job in a loop,
|
|
685
|
+
# e.g. a counter in the job's arguments or a cap stored elsewhere.
|
|
686
|
+
failed_execution.retry
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
The event is emitted in the process that performs the pruning (or the supervisor when it reaps a crashed fork, with `SolidQueue::Processes::ProcessExitError`), so make sure the subscription is set up in an initializer, where all Solid Queue processes will load it.
|
|
693
|
+
|
|
694
|
+
## Batch jobs
|
|
695
|
+
|
|
696
|
+
Solid Queue supports grouping jobs into batches, so you can track the progress of the set as a whole and optionally fire callbacks based on its status. Batches support the following:
|
|
697
|
+
|
|
698
|
+
- Relating jobs to a batch, to track their status
|
|
699
|
+
- Three available callbacks to fire:
|
|
700
|
+
- `on_finish`: fired when all jobs have finished, including retries, even when some jobs have failed.
|
|
701
|
+
- `on_success`: fired when all jobs have succeeded, including retries. It won't fire if any jobs have failed, but it will fire if jobs have been discarded using `discard_on`.
|
|
702
|
+
- `on_failure`: fired when all jobs have finished, including retries, and one or more of them have failed.
|
|
703
|
+
- Enqueuing more jobs for a batch from inside one of its jobs, with `batch.enqueue`
|
|
704
|
+
- Attaching a description and arbitrary metadata to a batch
|
|
705
|
+
|
|
706
|
+
Callback jobs are regular jobs: the batch doesn't pass them any arguments (although you can configure your own), and they can access the batch they belong to through the `batch` accessor:
|
|
707
|
+
|
|
708
|
+
```ruby
|
|
709
|
+
class SleepyJob < ApplicationJob
|
|
710
|
+
def perform(seconds_to_sleep)
|
|
711
|
+
Rails.logger.info "Feeling #{seconds_to_sleep} seconds sleepy..."
|
|
712
|
+
sleep seconds_to_sleep
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
class BatchFinishJob < ApplicationJob
|
|
717
|
+
def perform
|
|
718
|
+
Rails.logger.info "Finished all #{batch.total_jobs} jobs"
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
class BatchSuccessJob < ApplicationJob
|
|
723
|
+
def perform
|
|
724
|
+
Rails.logger.info "All #{batch.completed_jobs} jobs worked!"
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
class BatchFailureJob < ApplicationJob
|
|
729
|
+
def perform
|
|
730
|
+
Rails.logger.info "#{batch.failed_jobs} jobs failed, sorry!"
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
SolidQueue::Batch.enqueue(
|
|
735
|
+
on_finish: BatchFinishJob,
|
|
736
|
+
on_success: BatchSuccessJob,
|
|
737
|
+
on_failure: BatchFailureJob,
|
|
738
|
+
user_id: 123
|
|
739
|
+
) do
|
|
740
|
+
5.times { |i| SleepyJob.perform_later(i) }
|
|
664
741
|
end
|
|
665
742
|
```
|
|
666
743
|
|
|
744
|
+
A job joins the batch that's active *when its enqueue is requested*—this also works when Rails defers the actual enqueue until after the surrounding transaction commits. In particular:
|
|
745
|
+
|
|
746
|
+
- A job created outside a batch and enqueued inside one joins that batch.
|
|
747
|
+
- Creating a job inside a batch without enqueueing it doesn't keep the batch open: if the batch finishes before the job is finally enqueued, the enqueue raises `SolidQueue::Batch::AlreadyFinished`.
|
|
748
|
+
- If a job already carries a batch ID but is enqueued inside another active batch, the active batch takes precedence.
|
|
749
|
+
|
|
750
|
+
Besides the callbacks, `SolidQueue::Batch.enqueue` accepts a `description:`, to label the batch, and a `metadata:` hash; any other keyword arguments (like `user_id: 123` above) are merged into the batch's `metadata`.
|
|
751
|
+
|
|
752
|
+
Callbacks can be given as a job class or as a configured job instance—for example, `on_finish: BatchFinishJob.new.set(queue: :batches)` or `on_success: BatchSuccessJob.new("some argument")`. Note that the job is serialized when the batch is created, so options resolved at that point (like `wait_until:` timestamps) are relative to batch creation, not to when the callback is eventually enqueued.
|
|
753
|
+
|
|
754
|
+
Callback jobs always enqueue through Solid Queue, even when the job classes involved (or the application default) use a different Active Job adapter. And a batch that ends up with no jobs finishes as soon as it starts, firing its callbacks right away.
|
|
755
|
+
|
|
756
|
+
### Batch progress and counters
|
|
757
|
+
|
|
758
|
+
Batches track `total_jobs`, `completed_jobs`, `failed_jobs` and `pending_jobs`, plus a `progress_percentage` helper. A couple of accounting details to be aware of:
|
|
759
|
+
|
|
760
|
+
- Counters track *logical* jobs, matching what you enqueued: a retry via `retry_on` keeps the job's Active Job ID, so a job that fails twice and then succeeds still contributes 1 to `total_jobs`. Each attempt does get its own row in the batch's `jobs` relation, though.
|
|
761
|
+
- Jobs discarded via `discard_on`, concurrency's `on_conflict: :discard`, or manual discarding count as completed, not failed.
|
|
762
|
+
- Manually retrying a failed job (via `SolidQueue::FailedExecution#retry`) doesn't re-add it to its batch: if the batch already finished as failed, a successful manual retry won't change the batch's status.
|
|
763
|
+
|
|
764
|
+
### Batch maintenance
|
|
765
|
+
|
|
766
|
+
Batch completion is normally detected as jobs finish, without ever locking the batch row outside a single once-per-batch moment. A few edge cases can't trigger that detection: jobs removed via bulk discards (which delete jobs without callbacks), a process that crashed after enqueueing jobs but before starting its batch, or a completion whose callback enqueueing failed and rolled back.
|
|
767
|
+
|
|
768
|
+
The dispatcher sweeps these up automatically via `SolidQueue::Batch.sweep_stalled`, as part of its regular maintenance (every `concurrency_maintenance_interval` seconds, sharing a single maintenance timer and database connection). If you disable `batch_maintenance` (or don't run a dispatcher), you can run the sweep yourself, for example as a [recurring task](#recurring-tasks):
|
|
769
|
+
|
|
770
|
+
```yml
|
|
771
|
+
batch_maintenance:
|
|
772
|
+
command: "SolidQueue::Batch.sweep_stalled"
|
|
773
|
+
schedule: every 5 minutes
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
### Clearing batches
|
|
777
|
+
|
|
778
|
+
Finished, non-failed batches are cleared with `SolidQueue::Batch.clear_finished_in_batches` after `config.solid_queue.clear_finished_jobs_after`, but only when you invoke it. Failed batches are kept, like failed jobs, so you can inspect them. Installing Solid Queue configures [a recurring task](#recurring-tasks) that clears finished jobs every hour; you can add a matching entry for batches to your `recurring.yml`:
|
|
779
|
+
|
|
780
|
+
```yml
|
|
781
|
+
clear_solid_queue_finished_batches:
|
|
782
|
+
command: "SolidQueue::Batch.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
783
|
+
schedule: every hour at minute 12
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### Upgrading existing installations
|
|
787
|
+
|
|
788
|
+
If you installed Solid Queue before batches existed, copy the migration that adds the new tables to your app and run it:
|
|
789
|
+
|
|
790
|
+
```bash
|
|
791
|
+
bin/rails solid_queue:update
|
|
792
|
+
bin/rails db:migrate
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Until you do, Solid Queue works exactly as before—jobs enqueue and run without any batch bookkeeping, trying to start a batch raises, and the dispatcher logs a deprecation warning to remind you the migration is pending. It becomes part of the base schema in Solid Queue 2.0.
|
|
796
|
+
|
|
797
|
+
The copied migration is yours to adapt: if you're on PostgreSQL with a large jobs table, consider building the jobs index concurrently—`algorithm: :concurrently` on its `add_index`, with `disable_ddl_transaction!` on the migration—so the build doesn't block enqueues while it runs. Everything in the migration skips what already exists, so it's safe to rerun after a failure; just drop the invalid index a failed concurrent build leaves behind first.
|
|
798
|
+
|
|
667
799
|
## Puma plugin
|
|
668
800
|
|
|
669
801
|
We provide a Puma plugin if you want to run the Solid Queue's supervisor together with Puma and have Puma monitor and manage it. You just need to add
|
data/UPGRADING.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
# Upgrading to version 1.7.x
|
|
2
|
+
This version introduces support for grouping jobs into batches, which needs new tables. Fresh installs get them with the base schema; existing installations need to copy the migration that adds them and run it:
|
|
3
|
+
|
|
4
|
+
```bash
|
|
5
|
+
bin/rails solid_queue:update
|
|
6
|
+
bin/rails db:migrate
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The migration is optional for now: until you run it, everything works as before, batches aside. It will become part of the required schema in Solid Queue 2.0.
|
|
10
|
+
|
|
11
|
+
The copied migration is yours to adapt—for example, on PostgreSQL with a large jobs table, you can build the jobs index concurrently (`algorithm: :concurrently` with `disable_ddl_transaction!`) so it doesn't block enqueues while it runs.
|
|
12
|
+
|
|
1
13
|
# Upgrading to version 1.5.x
|
|
2
14
|
Ruby 3.1 is no longer supported, as it reached end-of-life in March 2025. Solid Queue now requires Ruby 3.2 or newer. If you're still on Ruby 3.1, Bundler will continue to resolve solid_queue 1.4.x for you, but you won't receive any new versions until you upgrade Ruby.
|
|
3
15
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class Batch
|
|
5
|
+
module Callbacks
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
%w[ finish success failure ].each do |callback_type|
|
|
10
|
+
serialize "on_#{callback_type}", coder: JSON
|
|
11
|
+
|
|
12
|
+
define_method("on_#{callback_type}=") do |callback|
|
|
13
|
+
super serialize_callback(callback)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
def serialize_callback(value)
|
|
20
|
+
if value.present?
|
|
21
|
+
active_job = value.is_a?(ActiveJob::Base) ? value : value.new
|
|
22
|
+
# We can pick up batch ids from context, but callbacks should never be considered a part of the batch
|
|
23
|
+
active_job.batch_id = nil
|
|
24
|
+
active_job.serialize
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def enqueue_callback_jobs
|
|
29
|
+
if failed? then enqueue_callback_job(:on_failure)
|
|
30
|
+
else
|
|
31
|
+
enqueue_callback_job(:on_success)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
enqueue_callback_job(:on_finish)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enqueue_callback_job(callback_name)
|
|
38
|
+
if callback = send(callback_name)
|
|
39
|
+
active_job = ActiveJob::Base.deserialize(callback)
|
|
40
|
+
active_job.callback_batch_id = id
|
|
41
|
+
# Bypass the job class's adapter so callbacks stay in Solid Queue and
|
|
42
|
+
# their enqueue stays in this transaction, while honoring enqueue callbacks.
|
|
43
|
+
active_job.run_callbacks(:enqueue) do
|
|
44
|
+
Job.enqueue(active_job, scheduled_at: active_job.scheduled_at || Time.current)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class Batch
|
|
5
|
+
module Clearable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
scope :clearable, ->(finished_before: SolidQueue.clear_finished_jobs_after.ago) { succeeded.where(finished_at: ...finished_before) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def clear_finished_in_batches(batch_size: 500, finished_before: SolidQueue.clear_finished_jobs_after.ago, sleep_between_batches: 0)
|
|
14
|
+
loop do
|
|
15
|
+
records_deleted = clearable(finished_before: finished_before).limit(batch_size).delete_all
|
|
16
|
+
sleep(sleep_between_batches) if sleep_between_batches > 0
|
|
17
|
+
break if records_deleted == 0
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class Batch
|
|
5
|
+
module Status
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
scope :finished, -> { where.not(finished_at: nil) }
|
|
10
|
+
scope :succeeded, -> { finished.where(failed_at: nil) }
|
|
11
|
+
scope :unfinished, -> { where(finished_at: nil) }
|
|
12
|
+
scope :failed, -> { where.not(failed_at: nil) }
|
|
13
|
+
scope :enqueued, -> { where.not(enqueued_at: nil) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def status
|
|
17
|
+
if finished?
|
|
18
|
+
failed? ? :failed : :completed
|
|
19
|
+
elsif enqueued?
|
|
20
|
+
:enqueued
|
|
21
|
+
else
|
|
22
|
+
:pending
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def failed?
|
|
27
|
+
failed_at.present?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def succeeded?
|
|
31
|
+
finished? && !failed?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finished?
|
|
35
|
+
finished_at.present?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enqueued?
|
|
39
|
+
enqueued_at.present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Failed jobs no longer have tracking rows, so exclude them from the completed count.
|
|
43
|
+
def completed_jobs
|
|
44
|
+
finished? ? self[:completed_jobs] : [ total_jobs - pending_jobs - failed_jobs, 0 ].max
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def failed_jobs
|
|
48
|
+
finished? ? self[:failed_jobs] : jobs.failed.count
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Pending counts attempts, not logical jobs: while a retry is enqueued
|
|
52
|
+
# and its previous attempt hasn't finished yet, both have tracking rows,
|
|
53
|
+
# so the counts derived from it clamp at the logical totals.
|
|
54
|
+
def pending_jobs
|
|
55
|
+
finished? ? 0 : batch_executions.count
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def progress_percentage
|
|
59
|
+
return 0 if total_jobs == 0
|
|
60
|
+
([ total_jobs - pending_jobs, 0 ].max * 100.0 / total_jobs).round(2)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class Batch
|
|
5
|
+
# Repairs batches that the regular completion detection can't finish on
|
|
6
|
+
# its own: jobs removed via bulk discards, processes that crashed after
|
|
7
|
+
# enqueueing jobs but before starting their batch, or completions whose
|
|
8
|
+
# callback enqueueing failed and rolled back.
|
|
9
|
+
module Sweepable
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def sweep_stalled(stalled_for: 5.minutes, batch_size: 500)
|
|
14
|
+
SolidQueue.instrument(:sweep_stalled_batches, stalled_for: stalled_for, stale_executions: 0, finished_batches: 0, started_batches: 0) do |payload|
|
|
15
|
+
payload[:stale_executions] = sweep_stale_executions(batch_size:)
|
|
16
|
+
payload[:finished_batches] = finish_stalled_batches(batch_size:)
|
|
17
|
+
payload[:started_batches] = start_stalled_batches(stalled_for:, batch_size:)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
# BatchExecution rows represent outstanding work. A row for a resolved
|
|
23
|
+
# job violates that invariant, so remove it immediately; destroy's
|
|
24
|
+
# after_commit callback retries the batch completion check.
|
|
25
|
+
def sweep_stale_executions(batch_size:)
|
|
26
|
+
swept = 0
|
|
27
|
+
|
|
28
|
+
[ BatchExecution.with_finished_jobs, BatchExecution.with_failed_jobs ].each do |stale|
|
|
29
|
+
stale.find_each(batch_size: batch_size) do |batch_execution|
|
|
30
|
+
swept += 1
|
|
31
|
+
batch_execution.destroy
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
swept
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A started batch with no tracking rows left can finish
|
|
39
|
+
def finish_stalled_batches(batch_size:)
|
|
40
|
+
finished = 0
|
|
41
|
+
|
|
42
|
+
unfinished.enqueued.without_executions.find_each(batch_size: batch_size) do |batch|
|
|
43
|
+
finished += 1
|
|
44
|
+
batch.finish
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
finished
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A batch that crashed between creation and start never got enqueued
|
|
51
|
+
def start_stalled_batches(stalled_for:, batch_size:)
|
|
52
|
+
started = 0
|
|
53
|
+
|
|
54
|
+
unfinished.where(enqueued_at: nil).where(created_at: ...stalled_for.ago).find_each(batch_size: batch_size) do |batch|
|
|
55
|
+
started += 1
|
|
56
|
+
batch.start
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
started
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class Batch < Record
|
|
5
|
+
class AlreadyFinished < StandardError; end
|
|
6
|
+
|
|
7
|
+
class PendingMigrations < StandardError
|
|
8
|
+
def initialize(message = "The batches schema hasn't been installed yet. Run `bin/rails solid_queue:update` to copy the pending migrations to your application, and then `bin/rails db:migrate` to run them")
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
include Callbacks, Status
|
|
14
|
+
include Clearable, Sweepable
|
|
15
|
+
|
|
16
|
+
has_many :jobs
|
|
17
|
+
has_many :batch_executions, dependent: :destroy
|
|
18
|
+
|
|
19
|
+
store :metadata, coder: JSON
|
|
20
|
+
|
|
21
|
+
# Join-free so update_all keeps this condition in the completion update's own WHERE
|
|
22
|
+
scope :without_executions, -> { where.not(id: BatchExecution.select(:batch_id)) }
|
|
23
|
+
|
|
24
|
+
# Provider-agnostic batch identifier, analogous to jobs.active_job_id.
|
|
25
|
+
before_create :set_active_job_batch_id
|
|
26
|
+
after_commit :start, on: :create, unless: -> { ActiveRecord.respond_to?(:after_all_transactions_commit) }
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# The batches schema ships as an optional migration in Solid Queue 1.x
|
|
30
|
+
# and becomes part of the base schema in 2.0. Until the app has run the
|
|
31
|
+
# migration, jobs enqueue without any batch bookkeeping and batches
|
|
32
|
+
# themselves can't be used.
|
|
33
|
+
def migrated?
|
|
34
|
+
@migrated ||= table_exists? && BatchExecution.table_exists? && Job.column_names.include?("batch_id")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enqueue(description: nil, on_success: nil, on_failure: nil, on_finish: nil, metadata: nil, **extra_metadata, &block)
|
|
38
|
+
raise PendingMigrations unless migrated?
|
|
39
|
+
|
|
40
|
+
new.tap do |batch|
|
|
41
|
+
batch.assign_attributes(description:, on_success:, on_failure:, on_finish:, metadata: (metadata || {}).merge(extra_metadata))
|
|
42
|
+
batch.enqueue(&block)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def current_batch_id
|
|
47
|
+
ActiveSupport::IsolatedExecutionState[:current_batch_id]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wrap_in_batch_context(batch_id)
|
|
51
|
+
previous_batch_id = current_batch_id.presence
|
|
52
|
+
ActiveSupport::IsolatedExecutionState[:current_batch_id] = batch_id
|
|
53
|
+
yield
|
|
54
|
+
ensure
|
|
55
|
+
ActiveSupport::IsolatedExecutionState[:current_batch_id] = previous_batch_id
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def enqueue(&block)
|
|
60
|
+
# Fast-fail for the common case. create_all_from_jobs atomically guards
|
|
61
|
+
# concurrent additions when it creates their tracking rows.
|
|
62
|
+
if finished?
|
|
63
|
+
raise AlreadyFinished, "Can't enqueue an already finished batch"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
transaction do
|
|
67
|
+
save! if new_record?
|
|
68
|
+
|
|
69
|
+
self.class.wrap_in_batch_context(id) { block&.call(self) }
|
|
70
|
+
|
|
71
|
+
if ActiveRecord.respond_to?(:after_all_transactions_commit)
|
|
72
|
+
ActiveRecord.after_all_transactions_commit { start }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def metadata
|
|
78
|
+
(super || {}).with_indifferent_access
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def start
|
|
82
|
+
mark_as_enqueued
|
|
83
|
+
|
|
84
|
+
# Refresh enqueued_at after marking as enqueued, and let a batch that started
|
|
85
|
+
# with no jobs finish right away
|
|
86
|
+
reload
|
|
87
|
+
finish
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def finish
|
|
91
|
+
return if finished? || !enqueued?
|
|
92
|
+
return if batch_executions.exists?
|
|
93
|
+
|
|
94
|
+
transaction do
|
|
95
|
+
updated = Batch.where(id: id).unfinished.enqueued.without_executions.update_all(finished_at: Time.current)
|
|
96
|
+
finalize if updated > 0
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
def set_active_job_batch_id
|
|
102
|
+
self.active_job_batch_id ||= SecureRandom.uuid
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def mark_as_enqueued
|
|
106
|
+
Batch.where(id: id, enqueued_at: nil).update_all(enqueued_at: Time.current)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def finalize
|
|
110
|
+
reload
|
|
111
|
+
|
|
112
|
+
# PostgreSQL can let a blocked CAS win from a stale NOT EXISTS snapshot:
|
|
113
|
+
# after a lock wait, READ COMMITTED re-checks the target row's conditions
|
|
114
|
+
# against the latest data but keeps the original snapshot for subqueries.
|
|
115
|
+
# Re-check in a new statement, which gets a fresh snapshot while this
|
|
116
|
+
# transaction's row lock keeps adders out, since they increment before
|
|
117
|
+
# inserting their executions. MySQL doesn't need this: it reads DML
|
|
118
|
+
# subqueries from the latest committed data, so its CAS can't win wrongly.
|
|
119
|
+
raise ActiveRecord::Rollback if batch_executions.exists?
|
|
120
|
+
|
|
121
|
+
SolidQueue.instrument(:finish_batch, batch_id: id) do |payload|
|
|
122
|
+
failed_jobs = jobs.failed.count
|
|
123
|
+
failed_at = Time.current if failed_jobs > 0
|
|
124
|
+
completed_jobs = total_jobs - failed_jobs
|
|
125
|
+
|
|
126
|
+
update_columns(failed_jobs:, failed_at:, completed_jobs:)
|
|
127
|
+
enqueue_callback_jobs
|
|
128
|
+
|
|
129
|
+
payload.merge!(total_jobs:, failed_jobs:, completed_jobs:)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class BatchExecution < Execution
|
|
5
|
+
self.assumable_attributes_from_job = [ :batch_id ]
|
|
6
|
+
|
|
7
|
+
belongs_to :batch
|
|
8
|
+
|
|
9
|
+
scope :with_finished_jobs, -> { joins(:job).merge(SolidQueue::Job.finished) }
|
|
10
|
+
scope :with_failed_jobs, -> { joins(job: :failed_execution) }
|
|
11
|
+
|
|
12
|
+
after_commit :finish_batch, on: :destroy
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def create_all_from_jobs(jobs)
|
|
16
|
+
jobs.select(&:batched?).group_by(&:batch_id).each do |batch_id, jobs_in_batch|
|
|
17
|
+
# Update the counter first: inserting tracking rows takes a shared FK lock on
|
|
18
|
+
# the batch row, then incrementing can deadlock concurrent MySQL adders.
|
|
19
|
+
if attempt_to_update_total_jobs(batch_id, jobs_in_batch)
|
|
20
|
+
super jobs_in_batch
|
|
21
|
+
else
|
|
22
|
+
raise Batch::AlreadyFinished, "Can't add jobs into an already finished batch"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
def attempt_to_update_total_jobs(batch_id, jobs)
|
|
29
|
+
new_jobs_count = count_new_jobs_among(jobs)
|
|
30
|
+
updated = SolidQueue::Batch.where(id: batch_id).unfinished.update_all([ "total_jobs = total_jobs + ?", new_jobs_count ])
|
|
31
|
+
updated > 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A job that has executed before was already counted when it first joined
|
|
35
|
+
# the batch: retries keep their active_job_id and batch across re-enqueues.
|
|
36
|
+
# This might undercount jobs whose retries switch to another batch, but that
|
|
37
|
+
# should be a rare enough case. The counter is used only for report/info, so
|
|
38
|
+
# we favour simplicity here
|
|
39
|
+
def count_new_jobs_among(jobs)
|
|
40
|
+
jobs.reject { |job| job.arguments["executions"].to_i > 0 }.map(&:active_job_id).uniq.size
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
def finish_batch
|
|
46
|
+
# Skip the serialized callback and metadata columns on this hot path
|
|
47
|
+
if batch = Batch.select(:id, :finished_at, :enqueued_at).find_by(id: batch_id)
|
|
48
|
+
batch.finish
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class FailedExecution
|
|
5
|
+
# A FailedExecution is created only after retries are exhausted, when the
|
|
6
|
+
# job stops counting as pending in its batch.
|
|
7
|
+
module Batchable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
after_create :destroy_job_batch_execution, if: -> { Batch.migrated? && job.batch_id? }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
def destroy_job_batch_execution
|
|
16
|
+
job.batch_execution&.destroy!
|
|
17
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
18
|
+
SolidQueue.instrument(:batch_progress_error, batch_id: job.batch_id, job_id: job.id, error: e)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|