solid_queue 1.5.1 → 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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +163 -6
  3. data/UPGRADING.md +12 -0
  4. data/app/models/solid_queue/batch/callbacks.rb +50 -0
  5. data/app/models/solid_queue/batch/clearable.rb +23 -0
  6. data/app/models/solid_queue/batch/status.rb +64 -0
  7. data/app/models/solid_queue/batch/sweepable.rb +64 -0
  8. data/app/models/solid_queue/batch.rb +133 -0
  9. data/app/models/solid_queue/batch_execution.rb +52 -0
  10. data/app/models/solid_queue/claimed_execution.rb +1 -0
  11. data/app/models/solid_queue/failed_execution/batchable.rb +22 -0
  12. data/app/models/solid_queue/failed_execution.rb +1 -1
  13. data/app/models/solid_queue/job/batchable.rb +50 -0
  14. data/app/models/solid_queue/job/executable.rb +5 -1
  15. data/app/models/solid_queue/job.rb +11 -3
  16. data/lib/active_job/batch_id.rb +57 -0
  17. data/lib/generators/solid_queue/install/templates/db/queue_schema.rb +31 -0
  18. data/lib/generators/solid_queue/update/templates/db/add_batches_to_solid_queue.rb +39 -0
  19. data/lib/solid_queue/configuration.rb +76 -11
  20. data/lib/solid_queue/dispatcher/concurrency_maintenance.rb +4 -37
  21. data/lib/solid_queue/dispatcher/maintenance.rb +79 -0
  22. data/lib/solid_queue/dispatcher.rb +13 -9
  23. data/lib/solid_queue/engine.rb +4 -0
  24. data/lib/solid_queue/fiber_pool.rb +130 -0
  25. data/lib/solid_queue/fork_supervisor.rb +13 -4
  26. data/lib/solid_queue/log_subscriber.rb +16 -1
  27. data/lib/solid_queue/pool.rb +46 -25
  28. data/lib/solid_queue/processes/runnable.rb +2 -5
  29. data/lib/solid_queue/processes/supervised.rb +7 -0
  30. data/lib/solid_queue/supervisor/signals.rb +3 -0
  31. data/lib/solid_queue/supervisor.rb +29 -16
  32. data/lib/solid_queue/thread_pool.rb +28 -0
  33. data/lib/solid_queue/version.rb +1 -1
  34. data/lib/solid_queue/worker.rb +9 -3
  35. data/lib/solid_queue.rb +1 -0
  36. metadata +29 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82eb2e9041fa69b3e7f3bd4a3d62c46b7826100a708b2237750f0f9e4fdee9c5
4
- data.tar.gz: 14000eeba6030e0248273d4cc158e17f25f78268d5e2c62c5e74f9a959f8d94f
3
+ metadata.gz: 15044fdadc1f5170f909ebfeeb3f0d31e948bb51edb336aa040ad477b9d12545
4
+ data.tar.gz: 566ab7ad221caa7c96ea1f755c8e5537ac4e37304afbb7ced6697eb1a120033b
5
5
  SHA512:
6
- metadata.gz: 9bb7b225b9a3b1b13e1f2edfa04927cb2f3954d0a0b226ca79e7fc69dff6675c002ac5e6dd13fd0ff9794d2f0a660487a70cb078868034e7a048378b14911a4f
7
- data.tar.gz: b30ef8f4992fc7a3d858294b3ae9b1c4459f813d9ec3bb3ff4a4fde97ad114469d76d913bb19912117de3d081e6c8d159798e5bcdd0790858ea3af6f4faf7cc6
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)
@@ -204,6 +210,10 @@ Or you can also set the environment variable `SOLID_QUEUE_SUPERVISOR_MODE` to `a
204
210
 
205
211
  **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
206
212
 
213
+ This supervisor mode is separate from a worker's concurrency model. Supervisor mode decides whether supervised processes live in forks or threads. Worker configuration decides whether claimed jobs run in a thread pool (`threads: N`) or as fibers on a single fiber reactor thread (`fibers: N`).
214
+
215
+ Because these are separate concerns, you can combine the default `fork` supervisor mode with fiber workers. In that setup, each worker process gets its own fiber reactor and bounded fiber count.
216
+
207
217
  ## Configuration
208
218
 
209
219
  By default, Solid Queue will try to find your configuration under `config/queue.yml`, but you can set a different path using the environment variable `SOLID_QUEUE_CONFIG` or by using the `-c/--config_file` option with `bin/jobs`, like this:
@@ -232,6 +242,9 @@ production:
232
242
  threads: 5
233
243
  polling_interval: 0.1
234
244
  processes: 3
245
+ - queues: "api*"
246
+ fibers: 100
247
+ polling_interval: 0.05
235
248
  scheduler:
236
249
  dynamic_tasks_enabled: true
237
250
  polling_interval: 5
@@ -274,10 +287,13 @@ Here's an overview of the different options:
274
287
 
275
288
  Check the sections below on [how queue order behaves combined with priorities](#queue-order-and-priorities), and [how the way you specify the queues per worker might affect performance](#queues-specification-and-performance).
276
289
 
277
- - `threads`: this is the max size of the thread pool that each worker will have to run jobs. Each worker will fetch this number of jobs from their queue(s), at most and will post them to the thread pool to be run. By default, this is `3`. Only workers have this setting.
278
- It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker thread uses one connection, and two additional connections are reserved for polling and heartbeat.
279
- - `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. **Note**: this option will be ignored if [running in `async` mode](#fork-vs-async-mode).
290
+ - `threads`: configures a worker to execute jobs in a thread pool of this size. By default, workers use `threads: 3`. Only workers have this setting, and it can't be combined with `fibers`.
291
+ It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker uses connections for polling and heartbeat and thread mode may use additional connections for job execution.
292
+ - `fibers`: configures a worker to execute jobs as fibers on a single fiber reactor thread, with this value as the maximum number of in-flight jobs. It can't be combined with `threads`.
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`.
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).
280
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.
281
297
 
282
298
 
283
299
  ### Optional scheduler configuration
@@ -367,7 +383,17 @@ queues: back*
367
383
 
368
384
  ### Threads, processes, and signals
369
385
 
370
- Workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling.
386
+ By default, workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Workers can also be configured with `fibers`, in which case claimed jobs are executed as fibers on a single reactor thread and bounded by the worker's fiber count. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling.
387
+
388
+ Fiber worker execution is best suited for cooperative, mostly I/O-bound jobs. Blocking or CPU-heavy work still blocks the single reactor thread, so it should not be expected to outperform thread mode for every workload.
389
+
390
+ Because fiber workers run multiple fibers on a single thread, Rails must also isolate execution state per fiber rather than per thread. If your app keeps the default thread-scoped isolation level, Solid Queue will raise a boot-time error instead of running fiber workers with shared Active Record state.
391
+
392
+ Keep in mind that `config.active_support.isolation_level = :fiber` applies to your whole application, not just to Solid Queue: if you run Solid Queue inside Puma via [the plugin](#puma-plugin), or combine fiber workers with thread workers in the same process using the supervisor's `async` mode, everything in that process will use fiber-scoped execution state. This is fully supported by Rails, but it's a global setting worth being deliberate about.
393
+
394
+ On Rails 7.2 and later, fiber workers can often use a much smaller queue database pool than an equivalent thread pool. A practical starting point is `3-5` queue database connections per worker process: one for job execution, one for polling, one for heartbeats, plus some headroom. In the default `fork` supervisor mode, that guidance applies per worker process. In supervisor `async` mode, all workers share one process, so add together the requirements for the workers running there.
395
+
396
+ That lower-pool guidance depends on job code not holding connections open across non-blocking waits. APIs such as `ActiveRecord::Base.connection`, `lease_connection`, `connection_pool.checkout`, or long-lived `with_connection` / transaction blocks can pin connections and push fiber workers back toward thread-like pool usage. On Rails 7.1, plan conservatively and assume the configured fiber count can still grow queue database connection usage.
371
397
 
372
398
  The supervisor is in charge of managing these processes, and it responds to the following signals when running in its own process via `bin/jobs` or with [the Puma plugin](#puma-plugin) with the default `fork` mode:
373
399
  - `TERM`, `INT`: starts graceful termination. The supervisor will send a `TERM` signal to its supervised processes, and it'll wait up to `SolidQueue.shutdown_timeout` time until they're done. If any supervised processes are still around by then, it'll send a `QUIT` signal to them to indicate they must exit.
@@ -379,6 +405,10 @@ On Windows, the `QUIT` signal can't be trapped, so the supervisor only responds
379
405
 
380
406
  If processes have no chance of cleaning up before exiting (e.g. if someone pulls a cable somewhere), in-flight jobs might remain claimed by the processes executing them. Processes send heartbeats, and the supervisor checks and prunes processes with expired heartbeats. Jobs that were claimed by processes with an expired heartbeat will be marked as failed with a `SolidQueue::Processes::ProcessPrunedError`. You can configure both the frequency of heartbeats and the threshold to consider a process dead. See the section below for this.
381
407
 
408
+ Worker heartbeats are driven by a separate timer task, not by the worker execution backend itself. This means fiber workers do not rely on the reactor loop to prove liveness. However, liveness is still tracked at the worker-process level, not at the individual thread or fiber level.
409
+
410
+ This means finished and failed jobs still follow the normal Solid Queue lifecycle, but a single stuck job can remain claimed if the worker process itself is still alive. If you need stronger stuck-job detection, that requires an explicit timeout or watchdog mechanism on top of process heartbeats.
411
+
382
412
  In a similar way, if a worker is terminated in any other way not initiated by the above signals (e.g. a worker is sent a `KILL` signal), jobs in progress will be marked as failed so that they can be inspected, with a `SolidQueue::Processes::ProcessExitError`. Sometimes a job in particular is responsible for this, for example, if it has a memory leak and you have a mechanism to kill processes over a certain memory threshold, so this will help identifying this kind of situation.
383
413
 
384
414
 
@@ -394,7 +424,7 @@ _Note_: The settings in this section should be set in your `config/application.r
394
424
 
395
425
  There are several settings that control how Solid Queue works that you can set as well:
396
426
  - `logger`: the logger you want Solid Queue to use. Defaults to the app logger.
397
- - `app_executor`: the [Rails executor](https://guides.rubyonrails.org/threading_and_code_execution.html#executor) used to wrap asynchronous operations, defaults to the app executor
427
+ - `app_executor`: the [Rails executor](https://guides.rubyonrails.org/threading_and_code_execution.html#executor) used to wrap background operations, defaults to the app executor
398
428
  - `on_thread_error`: custom lambda/Proc to call when there's an error within a Solid Queue thread that takes the exception raised as argument. Defaults to
399
429
 
400
430
  ```ruby
@@ -599,7 +629,7 @@ production:
599
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).
600
630
 
601
631
 
602
- In addition, mixing concurrency controls with **bulk enqueuing** (Active Job's `perform_all_later`) is not a good idea because concurrency controlled job needs to be enqueued one by one to ensure concurrency limits are respected, so you lose all the benefits of bulk enqueuing.
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.
603
633
 
604
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.
605
635
 
@@ -638,9 +668,134 @@ class ApplicationMailer < ActionMailer::Base
638
668
  Rails.error.report(exception)
639
669
  raise exception
640
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
641
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) }
741
+ end
742
+ ```
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
642
784
  ```
643
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
+
644
799
  ## Puma plugin
645
800
 
646
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
@@ -807,6 +962,8 @@ SolidQueue.unschedule_recurring_task("my_dynamic_task")
807
962
 
808
963
  Only dynamic tasks can be unscheduled at runtime. Attempting to unschedule a static task (defined in `config/recurring.yml`) will raise an `ActiveRecord::RecordNotFound` error.
809
964
 
965
+ To update an existing dynamic task, unschedule it and then schedule it again with the new options. A running scheduler only detects dynamic tasks being created and deleted, so updating a `SolidQueue::RecurringTask` record in place (for example, changing its `schedule` with `update!`) won't be picked up until the scheduler restarts.
966
+
810
967
  Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them.
811
968
 
812
969
  ## Inspiration
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